From d52199f53396fe42ab795689cbc214f6114fecdf Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 1 Aug 2026 10:35:24 -0700 Subject: [PATCH 1/3] Document the local verification loop for content and server changes CI proves the render half of the URL contract and cannot prove the other 917. A redirect is the web server's job, and the validation workflow has no server to point at, so a change to the Caddy config or a generated map is invisible to it. The workflow goes green while the redirect it broke stays broken until someone follows an old link. OPERATIONS.md now states which paths require a local release and live check before a pull request is opened, and why each one does: the Caddyfile because rule order is load-bearing and an over-matching regex is silent, the maps because a regenerated one can lose entries and still parse, content and static because a moved page turns a redirect destination into a 404 the build gate does not follow, and hugo.yaml and layouts because permalink changes move URLs underneath the redirects pointing at them. The loop was run rather than written from memory. Against the current build it reports "PASS - 1245 URLs honored", which is the output quoted in the new section. That also settles a residual delta in the audit, which recorded the redirect half as asserted rather than proven. It is now proven against a running server, though only the local mirror and only by hand. CI still cannot enforce it, and that stays true until staging exists, so the report says so rather than claiming the gap is closed. Co-Authored-By: Claude Opus 5 (1M context) --- OPERATIONS.md | 24 ++++++++++++++++++++++++ reports/Blog/audit.md | 12 ++++++++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/OPERATIONS.md b/OPERATIONS.md index e5ecf8f..fa5f5b9 100644 --- a/OPERATIONS.md +++ b/OPERATIONS.md @@ -29,6 +29,30 @@ 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 11 redirect rules. Rule order is load-bearing, and a regex that matches too much is silent. | +| [`deploy/maps/`](./deploy/maps/) | The 5 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 +deploy/make-release.sh +checks/check-live-urls.sh "$HUGO_BASEURL" +``` + +Both values come from `secrets/.env`, so neither needs an argument locally. The first command refuses to install a release that fails the build gate, and the second 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 diff --git a/reports/Blog/audit.md b/reports/Blog/audit.md index 32ef14b..deeca35 100644 --- a/reports/Blog/audit.md +++ b/reports/Blog/audit.md @@ -80,7 +80,15 @@ Floor assertions are present and below the real counts, so a truncated list fail checks/check-live-urls.sh:18 FLOOR=(["golden-urls.txt"]=320 ["redirect-urls.txt"]=900) ``` -**The live redirect gate has still not run.** `checks/check-live-urls.sh` is what proves the 917 redirects, and a redirect is the web server's job that no build can prove. It has passed against the local mirror for all 1,245 URLs, but not against this build and not from CI. The redirect half of the contract remains asserted rather than currently proven, and it stays that way until the VPS exists. +**The redirect half is proven, against a running server rather than a build.** `checks/check-live-urls.sh` releases to the local mirror and follows all 1,245 URLs, checking each redirect's destination rather than trusting its status code: + +```text +==> checking 328 URLs that must render +==> checking 917 URLs that must redirect +PASS - 1245 URLs honored +``` + +That is a local mirror, not CI and not production. CI cannot run it, because the validation workflow has no server to point at, so this remains a pre-pull-request step documented in [OPERATIONS.md](../../OPERATIONS.md) rather than an automated gate. It becomes automatable once staging exists. ## Baseline File Presence @@ -113,7 +121,7 @@ Both are recorded in [AUDIT.md](../../AUDIT.md) and reported upstream, so neithe Carried forward rather than closed: -- The live redirect gate has not run against this build, so 917 of the 1,245 contracted URLs are asserted rather than proven. +- The redirect half of the contract is proven only against the local mirror, by hand, before a pull request. CI has no server to point at, so nothing enforces it automatically until staging exists. - `publish-release.yml` has never been dispatched, so the release path is untested. - No deploy exists, so the publish surface stays deferred and the registry entry stays `publish: []`. - `checks/README.md` carries a small prose backlog of `dash` and `semicolon` findings, left for the next edit of that file per the correct-as-you-next-edit rule. From 64116347a1e916f5433dd7cb39f6d3cbe370ea9b Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 1 Aug 2026 10:39:39 -0700 Subject: [PATCH 2/3] Attribute the release and the check to the right scripts Two corrections from review, both cases of the prose contradicting the commands directly beneath it. The audit said `check-live-urls.sh` releases to the local mirror. It does not. `make-release.sh` installs the release and the check script only verifies against a server that is already running, which is the whole reason the two are separate steps. OPERATIONS.md said neither command needs an argument, while the block below it passed `"$HUGO_BASEURL"` to one of them. `make-release.sh` takes no arguments because it reads `secrets/.env` itself. `check-live-urls.sh` does take a base URL. The block now sources the file first, so the value comes from the environment rather than being typed, which is what the sentence was reaching for. The corrected three-line block was run verbatim in one shell and reports "PASS - 1245 URLs honored". Co-Authored-By: Claude Opus 5 (1M context) --- OPERATIONS.md | 3 ++- reports/Blog/audit.md | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/OPERATIONS.md b/OPERATIONS.md index fa5f5b9..414d09b 100644 --- a/OPERATIONS.md +++ b/OPERATIONS.md @@ -43,11 +43,12 @@ So release to the local mirror and run the live check **before** opening a pull | `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 checks/check-live-urls.sh "$HUGO_BASEURL" ``` -Both values come from `secrets/.env`, so neither needs an argument locally. The first command refuses to install a release that fails the build gate, and the second follows all 1,245 URLs against the running mirror, checking each redirect's destination rather than trusting its status code. +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 takes no arguments, and 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. diff --git a/reports/Blog/audit.md b/reports/Blog/audit.md index deeca35..eaaabf0 100644 --- a/reports/Blog/audit.md +++ b/reports/Blog/audit.md @@ -80,7 +80,7 @@ Floor assertions are present and below the real counts, so a truncated list fail checks/check-live-urls.sh:18 FLOOR=(["golden-urls.txt"]=320 ["redirect-urls.txt"]=900) ``` -**The redirect half is proven, against a running server rather than a build.** `checks/check-live-urls.sh` releases to the local mirror and follows all 1,245 URLs, checking each redirect's destination rather than trusting its status code: +**The redirect half is proven, against a running server rather than a build.** `deploy/make-release.sh` installs the build on the local mirror, then `checks/check-live-urls.sh` follows all 1,245 URLs against it, checking each redirect's destination rather than trusting its status code: ```text ==> checking 328 URLs that must render From b89540b3e84ede0181de5ad02682cf7036dc7325 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 1 Aug 2026 10:44:37 -0700 Subject: [PATCH 3/3] Drop the hard-coded rule counts from the verification table The table said the Caddyfile holds "11 redirect rules" and the maps are "5 lookup tables". The file carries 13 `redir` directives, so the count was wrong, and it was incidental anyway: that cell exists to say why the Caddyfile needs a running server, not to enumerate it. Both counts are removed rather than corrected, since a number in prose that has to track a config file drifts the moment either changes. The same imprecision exists in `deploy/README.md`, which claims "11 regex rules plus 5 map files". That file is not in this change, so it is recorded in `TODO.md` for a proper re-derivation instead of being fixed by guess. Raised as a suppressed comment inside the collapsed review body, which carries no thread. Worth noting because a loop that polls only unresolved threads reports a clean pass while a finding like this stands, exactly as the merge gate warns. Co-Authored-By: Claude Opus 5 (1M context) --- OPERATIONS.md | 4 ++-- TODO.md | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/OPERATIONS.md b/OPERATIONS.md index 414d09b..a2e5c5a 100644 --- a/OPERATIONS.md +++ b/OPERATIONS.md @@ -37,8 +37,8 @@ So release to the local mirror and run the live check **before** opening a pull | Path | Why it needs a running server | | --- | --- | -| [`deploy/Caddyfile`](./deploy/Caddyfile) | The 11 redirect rules. Rule order is load-bearing, and a regex that matches too much is silent. | -| [`deploy/maps/`](./deploy/maps/) | The 5 lookup tables. A regenerated map can lose entries and still parse. | +| [`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. | diff --git a/TODO.md b/TODO.md index 1836168..a645812 100644 --- a/TODO.md +++ b/TODO.md @@ -23,6 +23,7 @@ The site is built and gated in CI. It is on GitHub, and it is not yet serving it ## Next, in dependency order - Dispatch `publish-release.yml` once to prove the release path, which exists but has never run. +- Re-derive the rule counts in `deploy/README.md`. It says "11 regex rules plus 5 map files", while the Caddyfile carries 13 `redir` directives, so the R1 to R11 numbering does not map one-to-one onto what the file actually does. Found by review on #5, where the same count was quoted and has since been dropped rather than guessed at. - 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.