diff --git a/.github/workflows/merge-bot-pull-request.yml b/.github/workflows/merge-bot-pull-request.yml new file mode 100644 index 0000000..5b08735 --- /dev/null +++ b/.github/workflows/merge-bot-pull-request.yml @@ -0,0 +1,96 @@ +name: Merge bot pull request action + +# Auto-merges in-repo Dependabot pull requests: enable on opened or reopened, disable on a maintainer push. +# The merge method follows the base, since the two rulesets allow different forms. +# - develop takes squash. +# - main takes a merge commit. +# An App token is used rather than GITHUB_TOKEN, for two reasons. +# - It fires downstream workflows on merge, which GITHUB_TOKEN deliberately does not. +# - A Dependabot pull request's GITHUB_TOKEN is read-only regardless of who triggered the event. +# The trigger is pull_request_target rather than pull_request, because these jobs hold the App key. +# That resolves the workflow and action SHAs from the trusted base rather than from the pull request head. +# It is safe here because no job checks out pull request code, each one merging by URL alone. +# This repo carries no codegen workflow and no upstream-version tracker, so those jobs are not vendored. +on: + pull_request_target: + types: [ opened, reopened, synchronize ] + +# Concurrency keys on the pull request number rather than github.ref. +# Under pull_request_target github.ref is the base branch, which would serialize every bot pull request. +# cancel-in-progress is false so a follow-up synchronize cannot cancel an in-flight opened run. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: false + +jobs: + + merge-dependabot: + name: Merge dependabot pull request job + runs-on: ubuntu-latest + # Dependabot pull requests raised in this repo, never from a fork. + # Restricted to opened and reopened so the disable job below stays sticky. + if: >- + (github.event.action == 'opened' || github.event.action == 'reopened') && + github.event.pull_request.user.login == 'dependabot[bot]' && + github.event.pull_request.head.repo.full_name == github.repository + permissions: + contents: write + pull-requests: write + + steps: + + - name: Generate GitHub App token step + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }} + private-key: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }} + + # Every tier is auto-merged, semver-major included. + # The required checks are the gate, not the size of the bump. + # A major that breaks the build fails its checks and never merges. + - name: Merge pull request step + run: | + set -Eeuo pipefail + case "${{ github.event.pull_request.base.ref }}" in + develop) method=--squash ;; + main) method=--merge ;; + *) + echo "::error::Unsupported base branch: ${{ github.event.pull_request.base.ref }}" + exit 1 + ;; + esac + gh pr merge --auto "$method" "$PR_URL" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} + + disable-auto-merge-on-maintainer-push: + name: Disable auto-merge on maintainer push job + runs-on: ubuntu-latest + # Fires when a maintainer pushes to the bot's branch, which is a synchronize by a non-bot actor. + # Auto-merge is disabled so the maintainer's commits do not merge along with the bot's. + # Re-enabling it is then a deliberate manual act, and the disable call is idempotent. + if: >- + github.event.action == 'synchronize' && + github.event.pull_request.head.repo.full_name == github.repository && + github.event.pull_request.user.login == 'dependabot[bot]' && + github.actor != github.event.pull_request.user.login + permissions: + pull-requests: write + + steps: + + - name: Generate GitHub App token step + # An App token is required because a Dependabot pull request's GITHUB_TOKEN is read-only. + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }} + private-key: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }} + + - name: Disable auto-merge step + run: gh pr merge --disable-auto "$PR_URL" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} diff --git a/HISTORY.md b/HISTORY.md index 75efab6..f2f6537 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -4,4 +4,10 @@ Pieter Viljoen's blog, and the tooling that builds, verifies, and deploys it. ## Release History -The site has not been released to its public address yet. The migration is in progress, and this file records it once the first production deploy lands. +- Version 1.0: + - First public release. The blog's content, media, URL contract, and deploy tooling are published as a repository for the first time, having previously lived inside a hosted blogging platform. + - 108 posts and 2 pages as Hugo content, in a tree that mirrors the URLs it serves, with 778 media files carried at their original bytes. + - The URL contract as committed ground truth: 328 addresses that must render, 917 that must redirect, and 778 legacy image URLs that must resolve, each verified with a live request rather than predicted. + - CI gates that contract on every pull request, alongside the doc, shell, and workflow linters, with the Hugo version pinned by checksum so a build is reproducible. + - A self-contained release bundle carrying the site, the web-server config, and the redirect maps together, so a rollback reverts the rules and the content they refer to as one unit. + - The site is not yet serving its public address. This release is the source and its pipeline, not the cutover. diff --git a/OPERATIONS.md b/OPERATIONS.md index e5ecf8f..1fc7094 100644 --- a/OPERATIONS.md +++ b/OPERATIONS.md @@ -29,6 +29,40 @@ Shipping the config inside the release is what makes a rollback honest. The rule `current` is a **relative** symlink. That frees the host path, so one bundle works at whatever root each environment mounts, with no rewriting. +## Local Verification Before a Pull Request + +**CI cannot prove a redirect.** The validation workflow builds the site and checks the render half of the contract, which is every URL that must return a page. The other 917 URLs are the web server's job, and nothing in a build exercises them. A change to the Caddy config or to a generated map is therefore invisible to CI: the workflow goes green while the redirect it broke stays broken until someone follows a sixteen-year-old link. + +So release to the local mirror and run the live check **before** opening a pull request that touches any of these: + +| Path | Why it needs a running server | +| --- | --- | +| [`deploy/Caddyfile`](./deploy/Caddyfile) | The redirect rules. Rule order is load-bearing, and a regex that matches too much is silent. | +| [`deploy/maps/`](./deploy/maps/) | The lookup tables. A regenerated map can lose entries and still parse. | +| `content/`, `static/` | A moved or renamed page turns a redirect destination into a 404, which the build gate does not follow. | +| `hugo.yaml`, `layouts/` | Permalink and taxonomy changes move URLs underneath the redirects that point at them. | + +```sh +set -a; . secrets/.env; set +a +deploy/make-release.sh +docker restart "$CADDY_CONTAINER" # required: see below +checks/check-live-urls.sh "$HUGO_BASEURL" +``` + +**Restart every time, even though only some changes strictly need it.** Caddy expands `import` at config-parse time, both for the site config and for the `map` blocks that read `maps/*.map`, and it does not watch those files. Swapping the `current` symlink therefore changes what a *static file* request resolves to, per request, while the redirect rules and map tables stay exactly as they were when Caddy last loaded. Verified against the running mirror: a new map entry present in the live release on disk returned 404 until the container was restarted, then 301. + +So the failure is specific. **When the release changed `deploy/Caddyfile` or anything under `deploy/maps/`**, checking without a restart exercises the **previous** rules, and a broken redirect reports `PASS` while the shipped artifact is broken. A content-only release does not have this problem, because the rules Caddy already holds are still the right ones. + +The step is unconditional anyway, for two reasons. Deciding correctly means knowing whether anything reached the config, which is easy to get wrong when a change spans several paths or a map was regenerated as a side effect. And getting it wrong is silent, since the wrong answer is a green check rather than an error. A restart costs a few seconds on a static site, which is cheaper than reasoning about it each time. + +Sourcing `secrets/.env` first puts the deploy root and the base URL in the environment, so no literal value is typed. `make-release.sh` then needs no arguments, because its deploy root falls back to `$DEPLOY_ROOT` and its version falls back to a timestamp. It still accepts both, and [Deploying](#deploying) below passes them explicitly, which is what CI does so a pipeline run names the commit it built rather than the clock. Either form works locally, and the argument wins over the environment. + +It refuses to install a release that fails the build gate. `check-live-urls.sh` does take a base URL, which is where the sourced `$HUGO_BASEURL` goes. It follows all 1,245 URLs against the running mirror, checking each redirect's destination rather than trusting its status code. + +Expect `PASS - 1245 URLs honored`. Anything less is a finding, and the output names each URL that failed and what it answered. + +A documentation-only or workflow-only change does not need this. A change to the four paths above does, because for those CI's green is not evidence. + ## Deploying ```sh @@ -56,9 +90,12 @@ Point `current` at the previous release. The swap is a single rename, so a reque ```sh ln -sfn "releases/" "/.current.tmp" mv -Tf "/.current.tmp" "/current" +docker restart "$CADDY_CONTAINER" ``` -No restart and no reload. The container mounts the parent directory, so the kernel resolves `current` per request and the change is visible immediately. +The content reverts on the rename alone, because the container mounts the parent directory and the kernel resolves `current` per request. **The rules do not.** Caddy holds the Caddyfile and the maps as parsed config, so without the restart a rollback serves the previous release's content under the current release's redirects, which is precisely the mismatch that shipping the config inside the bundle exists to prevent. + +The restart is therefore part of the rollback, not an optional follow-up. It costs a few seconds of downtime on a static site, which is the cheaper half of the trade. Verify with `checks/check-live-urls.sh` against the environment before considering the rollback finished. @@ -76,9 +113,33 @@ The container mounts the deploy root **read-only**, and mounts the **parent** ra Routing differs by environment and the bundle does not. Traefik on the home host has the Docker provider enabled, so container labels route. Pangolin's Traefik on the VPS does not, so routing there is created in the Pangolin UI and labels are silently ignored. +### The bootstrap, and why it is not in the release + +The container reads three host paths, and only one of them a release ever writes: + +| Host path | Mounted at | Written by | +| --- | --- | --- | +| `$DEPLOY_ROOT` | `/srv/blog`, read-only | every release | +| `$CADDY_APPDATA/config` | `/config` | placed once, by hand | +| `$CADDY_APPDATA/data` | `/data` | Caddy itself, persisting state across a recreate | + +[`deploy/bootstrap.Caddyfile`](./deploy/bootstrap.Caddyfile) goes in the `config` directory and is the **only** Caddy file outside the release bundle. It carries a single `import` and no rules of its own, deliberately: everything describing the site ships inside the release, so a rollback reverts the rules and the content together. Rules held here instead would leave a rolled-back site being served by the current release's redirects. + +Because it sits outside the bundle, no release updates it. Install or refresh it explicitly: + +```sh +set -a; . secrets/.env; set +a +install -m 644 deploy/bootstrap.Caddyfile "$CADDY_APPDATA/config/Caddyfile" +docker restart "$CADDY_CONTAINER" +``` + +A restart is needed whenever **any** Caddy config changes, not only this file. That includes `deploy/Caddyfile` and anything under `deploy/maps/`, because both are expanded at config-parse time and Caddy does not watch them. Only static file requests follow the `current` symlink per request. See "Local Verification Before a Pull Request" above, where skipping the restart is the difference between a real check and a false pass. + +`CADDY_APPDATA` is recorded in `secrets/.env` for exactly this reason. No script reads it, so a rebuild would otherwise depend on someone remembering where the bootstrap goes. + ## Redirects -The site answers roughly a thousand addresses it does not render. They are satisfied by eleven regular-expression rules and five map files, all inside the bundle. +The site answers 917 addresses it does not render, satisfied by 13 `redir` directives reading 5 map files, all inside the bundle. [`deploy/README.md`](./deploy/README.md) carries the per-class breakdown and the counts; this section covers the operational shape only, so the two do not restate each other. Ordering is load-bearing, so every redirect lives in a single `route` block. Outside one, Caddy sorts directives by its own precedence rather than by file order, and the broad attachment rule claims the per-post comment feeds that the narrower rule must match first. diff --git a/README.md b/README.md index e2ddf89..6a63ab8 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,14 @@ The site itself is not distributed as a package. It is built from this source an ### Release Notes +**Version**: 1.0 + +**Summary**: + +- First public release. The content, media, URL contract, and deploy tooling are published as a repository for the first time. +- The URL contract is committed ground truth and gated in CI: 328 addresses that must render, 917 that must redirect, and 778 legacy image URLs that must resolve. +- The site is not yet serving its public address. This release is the source and its pipeline, not the cutover. + See [Release History][history] for the full history. ## Table of Contents diff --git a/TODO.md b/TODO.md index cee2399..8da733f 100644 --- a/TODO.md +++ b/TODO.md @@ -4,34 +4,30 @@ Running backlog for this repo, kept in a committed file so the work survives acr ## State -The site is built, gated, and proven against a real server. It is not yet on GitHub and not yet serving its public address. +The site is built and gated in CI. It is on GitHub, and it is not yet serving its public address. | Piece | State | | --- | --- | | Content and media | done. 514 pages, 778 media files hash-verified against the export tar | | URL contract | done. 328 render, 917 redirect, 778 legacy image URLs, all gated | | Deploy shape | done and proven against a running Caddy, on a local mirror | -| CI workflows | written and locally verified, never yet run on GitHub | -| GitHub repo | does not exist | +| CI workflows | green. Validation runs on every pull request and feeds the required check | +| GitHub repo | public, both rulesets active, `configure.sh check` exits 0 | | VPS | untouched | ## Blocked on the maintainer -- Create `ptr727/Blog` as a **public** repo, since an outward-facing write needs explicit per-repo permission. Everything below the first push depends on it, and nothing local can reveal the problems that only appear once CI runs for real. -- Install the GitHub App and set `CODEGEN_APP_CLIENT_ID` and `CODEGEN_APP_PRIVATE_KEY` in **both** the Actions and Dependabot stores. The App must be installed rather than only created, and `CODEGEN_APP_ID` must stay absent because it is a `forbids` and the deprecated input silently does nothing. - Install `shellcheck`, `shfmt`, `nodejs`, and `npm`, then `markdownlint-cli2` and `cspell`, so the lint gate can run locally instead of only in CI. Every one of them currently runs here through Docker, which works but is slower than it should be for an edit loop. +- Read the migration post before it ships, since it is written in the maintainer's voice and has not been reviewed. ## Next, in dependency order -- Push `main` and `develop`, then let the pull request workflow run **once** before any ruleset is applied. The `main` ruleset requires a check named `Check pull request workflow status job`, which binds by name and only reports after a run, so applying rulesets first deadlocks the first pull request. -- Run `repo-config/configure.sh apply ptr727/Blog release`, then `check` until it exits 0. Going public also enables Discussions, which `configure.sh` derives from visibility. -- Run `AUDIT.md` end to end and commit the result to `reports/Blog/audit.md`, recording the publish and release dimensions as deferred rather than passing. +- Dispatch `publish-release.yml` once to prove the release path, which exists but has never run. - Provision the VPS: an unprivileged `blogdeploy` user, the deploy root, and `unattended-upgrades` with automatic reboot. - Restrict the deploy key with `restrict,command=...`, no pty and no forwarding, so it can do nothing but rsync into `releases/` and swap the symlink. Generate per-environment keys so staging cannot reach production. - Choose the staging FQDN, add its DNS record, and expose it through Pangolin as a public resource with **no auth**, since CI's live-URL check has to reach it. Authentication defaults to on for a public resource and has to be turned off deliberately. - Write `deploy-site.yml` and prove it: a dry run that mutates nothing, then a real run, then a forced mid-deploy failure to confirm rollback keeps the site up. Report the measured deploy shape back to [ProjectTemplate#456][hub-issue], which is waiting on it before the publish type can be defined. - Deploy to a temporary production FQDN and validate there before touching the live record. Lower the `blog` A-record TTL to 60s a day ahead, then flip it to the VPS, unproxied. -- Read the migration post once more before it ships, since it is written in the maintainer's voice and has not been reviewed. - Watch server logs for 404s daily for the first week, because real traffic finds what the golden list missed. Append anything new to `checks/golden-urls.txt` and add a redirect. - Add the weekly non-blocking external-link-check workflow, which is the one gate that cannot be blocking because it fails on other people's outages. - Decommission WordPress.com only after **30 clean days**, and downgrade to free rather than deleting, which keeps the media reachable as a safety net and preserves the ability to re-export. Do not start sooner: the conversion fetched media over HTTP from the live site. @@ -40,8 +36,6 @@ The site is built, gated, and proven against a real server. It is not yet on Git - The staging FQDN name. - `/robots.txt/` and `/osd.xml/` currently sit in `slugs.map` pointing at `/`. The first would be better pointing at the real `/robots.txt`. -- Whether the 78 legacy date-archive redirects should point at `/all/` rather than `/`, so a visitor following a `/2015/` link lands somewhere they can find 2015 posts. It is a one-line change to rule R3. -- Whether to carry `merge-bot-pull-request.yml`. Dependabot is configured and its PRs will sit open without it, but it needs the App secrets, so it cannot be proven until those exist. ## Deliberate deviations from the fleet baseline diff --git a/checks/check-url-parity.py b/checks/check-url-parity.py index 94582b1..b3232f6 100755 --- a/checks/check-url-parity.py +++ b/checks/check-url-parity.py @@ -62,10 +62,10 @@ def check_media(public): for url in legacy: rewritten = re.sub(r"^/wp-content/uploads/", "/media/", url) if rewritten == url: - missing.append(f"{url} (does not match the R8 rewrite prefix)") + missing.append(f"{url} (does not match the @uploads rewrite prefix)") elif not (public / rewritten.lstrip("/")).is_file(): missing.append(url) - print(f"media : {len(legacy) - len(missing)}/{len(legacy)} legacy image URLs resolve after the R8 rewrite") + print(f"media : {len(legacy) - len(missing)}/{len(legacy)} legacy image URLs resolve after the @uploads rewrite") return missing diff --git a/content/posts/2026/08/01/moving-this-blog-from-wordpress-to-hugo.md b/content/posts/2026/08/01/moving-this-blog-from-wordpress-to-hugo.md index ae17fed..cec4a88 100644 --- a/content/posts/2026/08/01/moving-this-blog-from-wordpress-to-hugo.md +++ b/content/posts/2026/08/01/moving-this-blog-from-wordpress-to-hugo.md @@ -196,9 +196,15 @@ Everything that is not a rendered page is the web server's job, so the choice of I evaluated static-web-server and ruled it out. Its redirect matching looks at the path only, and the query string is never an input. This blog has 110 legacy `/?p=` shortlinks, so `/?p=123` would have matched `/`, redirected the homepage, and carried the query through. It also does a linear regex scan per request with no lookup primitive. -Caddy handles it in **11 regex rules and 5 map files**. Maps are the right structure for the cases where no pattern can derive the answer: the Blogger permalinks, the `?p=` ids, and the attachment slugs. +Caddy handles it in **13 redirect directives and 5 map files**. Maps are the right structure for the cases where no pattern can derive the answer: the Blogger permalinks, the `?p=` ids, and the attachment slugs. -The deploy is deliberately boring. A release is a directory containing the built site, the Caddy config, and the redirect maps *together*, and going live is swapping one symlink. Shipping the config inside the release is what makes a rollback honest, because the redirect rules and the content they point at move as one unit. Rolling back cannot leave yesterday's site being served by today's rules. +The deploy is deliberately boring. A release is a directory containing the built site, the Caddy config, and the redirect maps *together*, and going live is swapping one symlink. Shipping the config inside the release is what makes a rollback honest, because the redirect rules and the content they point at move as one unit. + +**With one catch I got wrong at first, and it is worth knowing if you build this.** Swapping the symlink reverts the *content* immediately, because the kernel resolves the link per request. It does not revert the *rules*. Caddy expands its config, including the imported map files, when it loads, and it does not watch those files afterwards. So a rollback without a reload gives you yesterday's pages served by today's redirects, which is the exact mismatch the bundle was supposed to prevent. + +Worse, it makes verification lie. Change a redirect, deploy, run your checker without reloading, and the checker exercises the *old* rules and reports a pass while the thing you shipped is broken. I found this by adding a deliberate probe entry to a map, deploying it, and watching the URL keep returning 404 until I restarted the container, at which point it returned the 301 it should have all along. + +The fix is one line, a restart after any deploy or rollback that touches the config. The lesson is the general one: an atomic swap is only atomic for the thing that actually reads through it per request. Unchanged files are hard-linked from the previous release, so ten retained releases cost about 600 MB rather than 5.6 GB. diff --git a/cspell.json b/cspell.json index 91f6762..3c40073 100644 --- a/cspell.json +++ b/cspell.json @@ -21,6 +21,7 @@ "brotli", "caddyfile", "codegen", + "cutover", "dependabot", "goldmark", "insanegenius", diff --git a/deploy/README.md b/deploy/README.md index 89430ce..aa593e6 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -88,26 +88,35 @@ Everything the site does not render is the web server's job, and the workload co - **The query string must be matchable.** 110 `?p=` shortlinks redirect on the query alone. A server that matches on the path only would resolve `/?p=123` as `/`, redirect the homepage, and carry the query through to it. - **There must be a lookup primitive.** 279 of the 917 resolve through map files rather than patterns, since no rule can derive their destination, and the five maps carry 661 entries between them. A linear scan of that many rules per request is the wrong shape. -The 917 redirects are 11 regex rules plus 5 map files. A map is used wherever no pattern can derive the answer from the input. +The Caddyfile carries **13 `redir` directives**, reading **5 map files** through **3 `map` blocks**. Ten directives match on a pattern and three resolve through a map lookup, which is used wherever no pattern can derive the destination from the input. -| Rule | Covers | Shape | +Directives and URL classes are not one to one, in both directions. `@mapped` is a single directive serving three classes, because their key spaces are disjoint and merging them keeps one lookup on the hot path. `@uploads` is one directive covering a URL set that is gated separately. + +Each row below is a **URL class**, named by the matcher that serves it, so the table can be checked against [`Caddyfile`](./Caddyfile) by grep rather than by trust. + +| Matcher | Class size | Shape | | --- | --- | --- | -| R1 | 216 | `/YYYY/MM/DD/post//` -> `/YYYY/MM/DD/post/`, attachment pages and per-post feeds | -| R2 | 107 | `/YYYY/MM/DD/post//feed/` -> `/YYYY/MM/DD/post/`, ordered **before** R1 | -| R3 | 78 | `/YYYY/` and `/YYYY/MM/` -> `/` | -| R4 | 5 | `/YYYY/page/N/` -> `/` | -| R5 | 11 | `/author//` and its pagination -> `/` | -| R6 | 1 | `/feed/` -> `/feed.xml` | -| R7 | 192 | `/tag//feed/` and `/category//feed/` -> the term archive | -| R8 | 778 | `/wp-content/uploads/(.*)` -> `/media/$1`, preserving every legacy image URL | -| R9 | 2 | `/p/.html` -> `//`, Blogger's static-page shape | -| R10 | 2 | `/feeds/posts/default` -> `/feed.xml`, Blogger's Atom feed | -| R11 | wildcard | `/YYYY_MM_01_archive.html` -> one post, whatever the date | -| `slugs.map` | 107 | bare `//` -> best destination | -| `p-ids.map` | 110 | `/?p=` -> permalink | -| `blogger.map` | 59 | `/YYYY/MM/slug.html` -> current post URL, both full and truncated slug | - -Two orderings are load-bearing. R2 precedes R1 because both match the same shape. No golden URL is 5 segments under a date, so R1 cannot swallow a page that must render, and R8 rewrites under a prefix no rendered page occupies. +| `@post_child` | 216 | `/YYYY/MM/DD/post//` -> the post, attachment pages | +| `@term_feed` | 192 | `/tag//feed/` and `/category//feed/` -> the term archive | +| `@post_id` | 110 | `/?p=` -> the permalink, via `p-ids.map` | +| `@post_child_feed` | 107 | `/YYYY/MM/DD/post//feed/` -> the post, ordered **before** `@post_child` | +| `@mapped` via `slugs.map` | 107 | bare `//` -> best destination | +| `@date_archive` | 83 | `/YYYY/`, `/YYYY/MM/`, and their pagination -> `/all/` | +| `@mapped` via `blogger.map` | 59 | `/YYYY/MM/slug.html` -> the current post | +| `@blogger_archive` | 21 | `/YYYY_MM_01_archive.html` -> `/all/`, any date, including ones never covered | +| `@author` | 12 | `/author//`, its pagination and feed -> `/` | +| `@site_feed` | 3 | `/feed/`, `/comments/feed/`, `/about/feed/` -> `/feed.xml` | +| `@mapped` via `terms.map` | 3 | the three empty term archives | +| `@blogger_feed` | 2 | `/feeds/posts/default` -> `/feed.xml`, Blogger's Atom feed | +| `@blogger_page` | 2 | `/p/.html` -> `//`, Blogger's static-page shape | + +**Those thirteen classes sum to 917**, which is the line count of [`checks/redirect-urls.txt`](../checks/redirect-urls.txt) and the whole redirect contract. + +`@uploads` is deliberately absent from that table and from the 917. It rewrites `/wp-content/uploads/(.*)` to `/media/$1`, preserving all 778 legacy image URLs, which are gated by `golden-media-legacy.txt` on their own. Counting them here would double-count a set that has its own list. + +`@label` is the fourteenth class and is deliberately **not** in the contract. `/search/label/