Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ end_of_line = unset
insert_final_newline = false
trim_trailing_whitespace = false

# Caddy map files are tab-separated redirect tables generated by `checks/build-redirects.py`.
# Caddy map files are space-separated redirect tables generated by `capture/build-redirects.py`.
# Trailing whitespace is significant to the parse, and the generator owns the formatting.
[deploy/maps/*.map]
trim_trailing_whitespace = false
Expand Down
10 changes: 7 additions & 3 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,14 @@
# A CRLF shebang breaks execution, so scripts stay LF regardless of the default.
*.sh text eol=lf

# The URL-parity and redirect-map generators are shebang-executable and run by path in CI.
# They are pinned individually rather than by a blanket `*.py` rule.
checks/build-redirects.py text eol=lf
# Shebang-executable Python, where a CRLF on line one is a broken interpreter line rather
# than a cosmetic difference. `capture/` is pinned whole, because everything there is a
# script; under `checks/` the two executables are named, because that directory also holds
# lists and fixtures. Neither is a blanket `*.py` rule, so a future non-executable module
# elsewhere is not swept in by accident, and a new executable needs a line here.
capture/*.py text eol=lf
checks/check-url-parity.py text eol=lf
checks/check-env-docs.py text eol=lf

# These formats are parsed line by line by a daemon rather than by a shell.
# Caddy and OpenSSH both reject or silently mis-parse a CRLF file.
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/validate-task.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ jobs:
- name: Lint shell scripts step
run: |
set -Eeuo pipefail
scripts=(checks/check-live-urls.sh deploy/make-release.sh ops/vps-backup-pull ops/install.sh)
scripts=(checks/check-live-urls.sh deploy/make-release.sh ops/vps-backup-pull ops/install.sh capture/run-wp2hugo.sh)
docker run --rm --pull=always -v "$PWD":/mnt --workdir /mnt \
koalaman/shellcheck:stable "${scripts[@]}"
docker run --rm --pull=always -v "$PWD":/mnt --workdir /mnt \
Expand Down
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ hugo_stats.json
*.user
.claude

# Python byproducts from the check and redirect-map generators under `checks/`.
# Python byproducts from the gates under `checks/` and the provenance tools under `capture/`.
__pycache__/
*.py[cod]
.venv/
Expand Down
5 changes: 4 additions & 1 deletion ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@ Held in `secrets/<server>.<environment>.env`, one file per environment. Template
| `EXPECT_SITE_ENV` | the environment that must answer, compared against the `X-Blog-Env` header the bundle stamps | A proxy rule aimed at the wrong container returns a healthy 200 under the right hostname, so the check refuses to start rather than proving nothing. |
| `PANGOLIN_ACCESS_TOKEN_ID` | the resource access token's id, for an environment behind the auth gate | Set both or neither. Leave both unset for a site that is public. |
| `PANGOLIN_ACCESS_TOKEN` | the token itself | Read by `check-live-urls.sh`. Staging keeps its gate on because it serves a byte-identical copy of the public site. |
| `CAPTURE_ROOT` | the provenance capture, holding the WordPress exports, the crawl of the old platform, and the inventories derived from it | `checks/build-redirects.py` takes it as its one argument. Environment-independent, so it belongs in the default file only. Nothing sources it. |
| `CAPTURE_ROOT` | the provenance capture, holding the WordPress exports, the crawl of the old platform, and the inventories derived from it | Every script under [`capture/`](./capture/) reads beneath it, and all but one write there too. The exception is [`capture/build-redirects.py`](./capture/build-redirects.py), which writes the committed maps under `deploy/maps/` in this repository, and which also accepts the capture as a first argument that wins over this value. Environment-independent, so it belongs in the default file only. |
| `CAPTURE_SOURCE_URL` | the old platform's base URL, the site the crawl and the URL verification ran against | **Not `HUGO_BASEURL`.** The two hold the same string after the cutover and mean different things, so merging them points a verification run at the new site while every check still passes. Environment-independent. |
| `CAPTURE_SOURCE_API` | the old platform's REST API for that site, carrying its numeric site id | Read for the post and page bodies in **rendered** form, which is what expands shortcodes so a media reference is seen the way a reader's browser sees it. Environment-independent. |
| `CAPTURE_AUTHOR_SLUG` | the old platform's author slug, used to backfill the author archive and its pagination | Optional, and an account name rather than a site value, which is why it is a variable at all. Unset, [`capture/classify.py`](./capture/classify.py) skips the backfill and says so, rather than emitting a list that is silently short by the author URLs. Environment-independent. |
| `VPS_SSH_HOST` | the VPS administrative login | Not the deploy account. See "Two credentials" below. Environment-independent. |
| `VPS_TRAEFIK_LOG` | today's live access log on the VPS, still being appended to | Never pulled, because rotation is what makes a file eligible. An analysis covering today reads it over SSH. Nothing sources it. |
| `VPS_TRAEFIK_LOG_ARCHIVE` | the rotated access logs on the VPS, and the source of the off-host copy | Also read by the pull, below. |
Expand Down
21 changes: 7 additions & 14 deletions OPERATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,26 +48,19 @@ This site has served the same domain across earlier platforms, so its whole oper

## The Migration Record

**The migration is documented once, as a post on the site, and that post is the artifact to reference.** [`content/posts/2026/08/01/moving-this-blog-from-wordpress-to-hugo.md`](./content/posts/2026/08/01/moving-this-blog-from-wordpress-to-hugo.md) holds how the URL surface was captured, why the contract splits into a render half and a redirect half, why the Blogger permalink map needs more entries than the posts it covers, why the media had to come from the export tar and be hash-verified, and which Hugo taxonomy default moves every archive to a new address without reporting anything.
**The procedure and the facts live in the directory READMEs.** [`capture/README.md`](./capture/README.md) is the authority on how the inputs were captured and what is derived from them, [`checks/README.md`](./checks/README.md) on the URL contract, and [`deploy/README.md`](./deploy/README.md) on how the redirects are expressed. Each sits beside the thing it describes, which is what keeps it true.

**Read it before changing anything under [`checks/`](./checks/) or [`deploy/maps/`](./deploy/maps/).** Both hold values that no code derives and no test explains, and the reasoning behind them is in the post rather than beside them. Cite the post rather than restating it. This file is the procedure and the post is the account of how the procedure came to be, so where the two disagree this file governs what to do while the post explains why the check exists.
**The migration also has an account of itself, as a post on the site.** It is the casual version, what was done and how it went, and it is worth reading before changing anything under [`checks/`](./checks/) or [`deploy/maps/`](./deploy/maps/), because those hold values that no code derives.

**The direction between them is one-way.** A post may cite a README. A README never cites the post. A doc that sends a reader to published prose for an operational fact has put the fact where it cannot be kept current, and where correcting it means editing something people have already read.

**The post is content, so it sits under the URL contract.** Editing it moves nothing. Renaming it or taking it down breaks an address the site serves. A fact in it that proves wrong is corrected in the post rather than footnoted here.

### Rebuilding from the Exports

Everything derived is in this repository. Everything it was derived *from* is in a capture directory outside it, which is where a rebuild starts. **The capture path is `CAPTURE_ROOT` in `secrets/local.production.env`**, recorded alongside the other values that name a machine rather than the project, so it is read from there rather than searched for. The capture is not a git repository, so it has no history to revert to, and it is read-only in normal use.

| Under the capture | Holds | Recoverable |
| --- | --- | --- |
| `export/raw/` | the WordPress content export, WXR XML | yes, from the WordPress account while it exists |
| `export/media-tar/` | the media export, the only trustworthy copy of the images | yes, from the same place |
| `mirror/` | a crawl of the old platform as it served, including the media it linked from other hosts | no, once the old hosting ends |
| `inventory/` | the URL and media inventories derived from that crawl | no, for the same reason |

The two exports are the only inputs a person has to fetch, and `EXPORT-INSTRUCTIONS.md` at the root of the capture records which two menu items produce them and the counts each has to reconcile against. The counts are the point, because a partial export is the common way a migration loses posts without reporting anything.
Everything derived is in this repository. Everything it was derived *from* is a capture directory outside it, at `CAPTURE_ROOT`, which is where a rebuild starts. The capture is not a git repository, so it has no history to revert to, and it is read-only in normal use.

[`checks/build-redirects.py`](./checks/build-redirects.py) takes the capture directory as its one argument and rebuilds everything under `deploy/maps/` from it. It selects the export **by content** rather than by filename and fails unless exactly one candidate holds published posts, because the capture also holds a media-only export whose zero posts produce empty maps that are indistinguishable from working ones until the redirects are live.
**[`capture/README.md`](./capture/README.md) holds the procedure**: what is under the capture and which parts of it can be fetched again, the two exports and the counts they must reconcile against, the ordered rebuild, and the results that look like success and are not.

## Local Verification Before a Pull Request

Expand Down Expand Up @@ -420,7 +413,7 @@ Ordering is load-bearing, so every redirect lives in a single `route` block. Out
| Blogger label archives | `labels.map`, defaulting to the archive index |
| Term archives the generator does not build | `terms.map` |

The maps are generated by `checks/build-redirects.py` from the source export, which lives outside this repository. It is a provenance script rather than a CI step, and its outputs are committed. It selects the export by content and refuses to run unless exactly one contains published posts, because the capture holds a full export and a media-only one, and reading the wrong one yields empty maps that are indistinguishable from working ones until the redirects are live.
The maps are generated by [`capture/build-redirects.py`](./capture/build-redirects.py) and the generated files are committed, so a deploy never regenerates them. How it selects its input, and why that selection is the part to get right, is in [`capture/README.md`](./capture/README.md).

## Server Hardening

Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ Deployment is a release directory plus a symlink. A build is installed alongside

The site has served the same domain since 2008, across three platforms: Blogger until 2012, WordPress until 2026, and Hugo from then on. Converting the posts took an afternoon. Preserving sixteen years of inbound links was the work, and it is why this repository carries a URL contract and gates it rather than trusting the build.

The account of that migration is a post on the site, [Moving This Blog From WordPress to Hugo][migration-post]. It covers what a WordPress export holds and what it leaves out, why the sitemap named barely a tenth of the addresses the site was actually serving, how the Blogger-era permalinks resolve through a lookup table rather than a pattern, why media fetched over HTTP is not the same bytes as the media in the export and only a content hash tells them apart, and which Hugo default moves every taxonomy archive to a new address without reporting anything.
How it was done is in [`capture/README.md`][capture-readme], and the account of it is a post on the site, [Moving This Blog From WordPress to Hugo][migration-post]. It covers what a WordPress export holds and what it leaves out, why the sitemap named barely a tenth of the addresses the site was actually serving, how the Blogger-era permalinks resolve through a lookup table rather than a pattern, why media fetched over HTTP is not the same bytes as the media in the export and only a content hash tells them apart, and which Hugo default moves every taxonomy archive to a new address without reporting anything.

## How a Change Reaches the Site

Expand Down Expand Up @@ -152,6 +152,7 @@ flowchart LR
| [`hugo.yaml`][hugo-config] | site configuration, taxonomy URLs, and the feed name |
| [`checks/`][checks] | the URL contract and the gates that enforce it |
| [`deploy/`][deploy] | the release script, the web-server config, and the redirect maps |
| [`capture/`][capture] | the migration's provenance tooling, and how the site was derived from the old platform's exports |
| [`ops/`][ops] | the pull that copies the server's backups and access logs off it, and its schedule |
| [`ENVIRONMENT.md`][environment] | every configuration value, described once |

Expand Down Expand Up @@ -214,6 +215,8 @@ Licensed under the [MIT License][license]\
[checks]: ./checks/
[commits-link]: https://github.com/ptr727/Blog/commits
[deploy]: ./deploy/
[capture]: ./capture/
[capture-readme]: ./capture/README.md
[ops]: ./ops/
[environment]: ./ENVIRONMENT.md
[deploy-readme]: ./deploy/README.md
Expand Down
Loading