From ef4e08f525b2f9a892150f78763f9f0be15c56d9 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 1 Aug 2026 09:50:01 -0700 Subject: [PATCH 1/7] Record the post-standup audit and prune the completed backlog (#2) The repository now exists on GitHub, so the two audit dimensions that could not be checked before are checkable, and both pass. Verdict moves from not operational to operational. Settings and rulesets pass with configure.sh check exiting 0 across 31 assertions. Secrets pass with both required names present in the Actions and Dependabot stores and the forbidden CODEGEN_APP_ID absent from both. The URL contract now passes in CI rather than only locally, on the first run, with the pinned Hugo build reporting 328/328 render, 778/778 media, and 1012/1012 assets. The ordering constraint held: the workflow was dispatched once and reported before either ruleset was applied, so the required check existed by the time it was bound by name. What the report does not claim: the live redirect gate still has not run, so 917 of the 1,245 contracted URLs remain asserted rather than proven, and publish-release.yml has never been dispatched, so the release path is untested. Both are recorded as residual deltas rather than glossed. TODO.md drops the four steps that are done and gains the two that standing the repo up newly exposed, proving the release path and deciding on the merge bot, whose App secrets are now in place. Co-authored-by: Claude Opus 5 (1M context) --- TODO.md | 15 +++--- reports/Blog/audit.md | 116 ++++++++++++++++-------------------------- 2 files changed, 51 insertions(+), 80 deletions(-) diff --git a/TODO.md b/TODO.md index cee2399..d4df466 100644 --- a/TODO.md +++ b/TODO.md @@ -4,34 +4,31 @@ 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. +- Decide on `merge-bot-pull-request.yml`. Dependabot is configured and its pull requests will otherwise sit open, and the App secrets it needs are now in place. - 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. diff --git a/reports/Blog/audit.md b/reports/Blog/audit.md index ba5a091..86903f3 100644 --- a/reports/Blog/audit.md +++ b/reports/Blog/audit.md @@ -8,64 +8,71 @@ Self-audit of this repository against its own committed ground truth, per [AUDIT ## Verdict -**Not operational.** Two of the three dimensions cannot be checked at all, because the repository does not exist on GitHub yet. Nothing has been pushed, so there is no remote, no ruleset, no secret, and no environment. +**Operational.** Every applicable check passes against the live repository. -This is the expected state at this point rather than a failure, and it is recorded here rather than left in a session, per [`STANDUP.md` section 5][standup], which allows a repo to stand up with residual deltas tracked in a report plus an issue. The issue is [ProjectTemplate#456][hub-issue]. - -Every check that can run locally passes. The value of that is limited, and stating it plainly is the point of this report: local checks pass by construction because the same person wrote the checks and the thing being checked. Only a real CI run and a real deploy test the parts that matter, and neither has happened. +The publish and release surface is **deferred**, not failed. That deferral is declared rather than hidden, and is tracked in [ProjectTemplate#456][hub-issue], which [`STANDUP.md` section 5][standup] permits. | Dimension | Result | | --- | --- | -| 1. Settings and rulesets | **Blocked.** No repository on GitHub | -| 2. Secrets | **Blocked.** No repository on GitHub | -| 3. The URL contract | **Pass** | +| 1. Settings and rulesets | **Pass.** `configure.sh check` exits 0 | +| 2. Secrets | **Pass.** Both required present in both stores, forbidden one absent | +| 3. The URL contract | **Pass.** Enforced by CI, not only locally | | Baseline file presence | **Pass.** 23 of 23 | | Verbatim fidelity | **Pass.** 4 of 4 | -| Publish and release | **Deferred**, deliberately. See below | +| Publish and release | **Deferred**, deliberately | ## 1. Settings and Rulesets -Blocked. `repo-config/configure.sh check ptr727/Blog release` cannot run against a repository that does not exist. +**Pass.** + +```text +$ repo-config/configure.sh check ptr727/Blog release +... 31 assertions, all ok ... +Configuration matches on ptr727/Blog. +exit 0 +``` -What is verifiable locally is that the payloads are internally consistent and that the required check is bound by a name the workflow actually produces, which is the ordering trap that deadlocks a first pull request: +Both rulesets are active and carry every expected rule. `develop` allows squash only, `main` allows merge only, and both bind the required check by the same name the workflow produces: ```text -repo-config/main.json required_status_checks: ["Check pull request workflow status job"] -.github/workflows/test-pull-request.yml:26 name: Check pull request workflow status job +'develop' required checks = ["Check pull request workflow status job"] +'main' required checks = ["Check pull request workflow status job"] ``` -The two strings match. They are one string renamed together, never independently. +The ordering constraint was honored at standup: the workflow was dispatched once and reported before any ruleset was applied. Applying first would have deadlocked the first pull request, because the required check binds by name and only appears after a run. -The carried `develop` payload is the `release` variant (`repo-config/develop.json`), carrying `pull_request`, `required_status_checks`, `required_linear_history`, `copilot_code_review`, `deletion`, `non_fast_forward`, and `required_signatures`. The `operational/develop.json` variant is absent, which is correct for this workflow model. +`has_discussions = true`, derived by `configure.sh` from public visibility rather than from a committed setting. `default_branch = main`. Dependabot vulnerability alerts and automated security updates are enabled. ## 2. Secrets -Blocked. `gh secret list` cannot run against a repository that does not exist. +**Pass.** Names only. No secret value was read, printed, or logged. -The manifest in `spec/secrets.json` declares: +| Name | Actions | Dependabot | +| --- | --- | --- | +| `CODEGEN_APP_CLIENT_ID` | present | present | +| `CODEGEN_APP_PRIVATE_KEY` | present | present | +| `CODEGEN_APP_ID` (forbidden) | absent | absent | -- `requires`: `CODEGEN_APP_CLIENT_ID`, `CODEGEN_APP_PRIVATE_KEY`, in **both** the Actions and Dependabot stores -- `forbids`: `CODEGEN_APP_ID` +`CODEGEN_APP_ID` is forbidden because the App-token action takes `client-id`, and the deprecated `app-id` name silently does nothing. -No secret value has been read, printed, or logged. +The `staging` and `production` environments do not exist yet, which is correct: they hold deploy credentials for a VPS that has not been provisioned, and `AUDIT.md` places them outside the baseline audit. ## 3. The URL Contract -**Pass.** This is the only dimension with real evidence behind it, because it is the only one that does not need GitHub. +**Pass, and now enforced by CI rather than only locally**, which is the material change from the pre-standup state. -```text -$ hugo --gc --minify --panicOnWarning -Pages 514 | Total in 462 ms | zero warnings +From the first run on `main`: -$ checks/check-url-parity.py public +```text +hugo v0.164.0+extended (pinned by version and sha256) +Pages 514 | Total in 858 ms (zero warnings under --panicOnWarning) render : 328/328 golden URLs built - 207 additional URLs built (not a failure) media : 778/778 legacy image URLs resolve after the R8 rewrite assets : 1012/1012 local asset references resolve PASS - the built site honors the URL contract ``` -Contract sizes, matching the committed lists: 328 render, 917 redirect, 778 legacy media. +Every gate in the validation job passed on its first attempt: markdownlint, cspell, actionlint, `editorconfig-checker`, shellcheck, `shfmt -d`, config validation, the Hugo build, and the contract check. Floor assertions are present and below the real counts, so a truncated list fails rather than passing while covering nothing: @@ -73,61 +80,27 @@ 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 207 extra built URLs are the new post added this session plus its four new term archives and their pagination. An extra URL is reported and is not a failure. A missing one would be. - -**The live gate has not run this session.** `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 previously passed against the local mirror for all 1,245 URLs, but that predates this session's changes and is not re-evidenced here. Treat the redirect half of the contract as asserted rather than currently proven. +**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. ## Baseline File Presence **Pass, 23 of 23** applicable to `types: ["source-only"]` plus `workflowModel: release`. -`OPERATIONS.md` is retained although it left the required set when the workflow model changed from `operational` to `release`. It is accurate and useful, and carrying an extra file is not drift. +`OPERATIONS.md` is retained although it left the required set when the workflow model changed from `operational` to `release`. Carrying an extra file is not drift. ## Verbatim Fidelity -**Pass, 4 of 4**, compared after line-ending normalization as [`spec/fidelity-model.md`][fidelity] specifies: - -```text -.markdownlint-cli2.jsonc match -repo-config/configure.sh match -repo-config/main.json match -repo-config/develop.json match -``` - -Eight carried files arrived CRLF and were normalized to LF to satisfy this repository's declared `lineEndings`. That is governed drift rather than a fidelity deviation, and it is reported to the hub as an onboarding trap, since nothing in the standup text says to normalize after carrying. - -## Lint and Prose - -Every gate the validation workflow runs was executed locally, through Docker, since the host lacks the tools: - -```text -actionlint clean -editorconfig-checker clean -markdownlint-cli2 (workflow globs) 0 issues in 14 files -cspell (README.md, HISTORY.md) 0 issues -shellcheck (default severity) clean -shfmt -d clean -``` - -Prose rules, using the hub's `scripts/prose_lint.py` against every file this repo authors: - -```text -charset, dupword, comment-wrap, comment-case 0 findings -dash, semicolon, charset-unknown 15 findings -``` - -The 15 remaining are backlog rules that report without gating, and the correct-as-you-next-edit rule leaves them alone until their file is touched: +**Pass, 4 of 4**, compared after line-ending normalization as [`spec/fidelity-model.md`][fidelity] specifies: `.markdownlint-cli2.jsonc`, `repo-config/configure.sh`, `repo-config/main.json`, `repo-config/develop.json`. -- `checks/README.md`, 14. Not edited this session. -- `layouts/rss.xml`, 1. **Kept deliberately.** The copyright sign is the argument to `strings.TrimPrefix "© "`, so it is a string literal that must match the character it strips, not typography. It is a functional deviation, not an unswept one. +Eight carried files arrived CRLF and were normalized to LF to satisfy this repository's declared `lineEndings`. That is governed drift rather than a fidelity deviation, and it is reported upstream as an onboarding trap, since nothing in the standup text says to normalize after carrying. ## Publish and Release: Deferred -`publish` is empty and `releaseTrigger` is `none` in the proposed registry entry, deliberately. +`publish` is empty and `releaseTrigger` is `none`, deliberately. -This repository deploys a built site to a VPS over SSH, which is a release surface the fleet spec has no type for. The hub's position, which this repo follows, is to declare the deferral rather than hide it, and to report the measured shape after CI has run and a deploy has actually happened rather than predicting it. Tracked in [ProjectTemplate#456][hub-issue]. +This repository will deploy a built site to a VPS over SSH, which is a release surface the fleet spec has no type for. The measured shape will be reported to [ProjectTemplate#456][hub-issue] once CI has run a deploy, rather than predicted now. The VPS does not exist, so there is nothing to measure. -The VPS does not exist yet, so there is nothing to measure. +`publish-release.yml` exists and is dispatch-only, but has never been dispatched. The release path is therefore untested. ## Deliberate Deviations @@ -140,10 +113,11 @@ Both are recorded in [AUDIT.md](../../AUDIT.md) and reported upstream, so neithe Carried forward rather than closed: -- The repository does not exist on GitHub. Dimensions 1 and 2 stay unverifiable until it does, and the first CI run is the first real test of the workflows. -- The live redirect gate has not run against this session's build. -- `merge-bot-pull-request.yml` is not carried. Dependabot is configured, so its pull requests will sit open until either the merge bot is added or they are merged by hand. The bot needs the App secrets, so it cannot be proven before those exist. -- `checks/README.md` carries 14 prose backlog findings. +- The live redirect gate has not run against this build, so 917 of the 1,245 contracted URLs are asserted rather than proven. +- `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: []`. +- `merge-bot-pull-request.yml` is not carried. Dependabot is configured and its pull requests will sit open until either the merge bot is added or they are merged by hand. The App secrets it needs are now in place, so this is no longer blocked, only undone. +- `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 efbafe8766546fac5fe9460198c6be3504503d2e Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 1 Aug 2026 10:20:09 -0700 Subject: [PATCH 2/7] Add the 1.0 release notes to README and HISTORY (#3) * Add the 1.0 release notes to README and HISTORY The repo is public and CI is green, so 1.0 is the first public release and both files now say what it contains. `version.json` already declared 1.0, and NBGV computes the tag from it on a dispatch from `main`. HISTORY.md replaces its placeholder with a real entry, keeping the fleet shape of a version heading and a nested list. README carries the short summary the README structure asks for, three bullets, with the full list left to HISTORY. Both state the thing a reader would otherwise assume wrongly: the site is not yet serving its public address, so 1.0 is the source and its pipeline, not the cutover. Releasing without saying that would read as the migration being finished. Adds "cutover" to cspell.json, since it now appears in the two files the spell gate actually covers. Co-Authored-By: Claude Opus 5 (1M context) * Move the version label's colon outside the bold text The line read `**Version: 1.0**:`, with a colon inside the bold and another after it, which renders as a double colon. The sibling line `**Summary**:` already puts the colon outside, so the two were inconsistent with each other as well. Now `**Version**: 1.0`, matching the sibling and the plain `Version 1.0:` form used in HISTORY.md. Raised by Copilot on this pull request. The construction was inherited from the hub's own README, which carries the same double colon, and spec/readme-structure.md does not mandate that literal form, asking only for the current version and a short summary. README.md is repo-owned, so this is fixed here and reported upstream rather than worked around. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- HISTORY.md | 8 +++++++- README.md | 8 ++++++++ cspell.json | 1 + 3 files changed, 16 insertions(+), 1 deletion(-) 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/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/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", From 91a039b12774af53acf8a25705391450db09dc02 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 1 Aug 2026 10:31:53 -0700 Subject: [PATCH 3/7] Carry the merge bot so Dependabot pull requests can merge (#4) `.github/dependabot.yml` is a baseline file for every fleet repo, and `merge-bot-pull-request.yml` is what services what it opens. This repo carried the first without the second, so bot pull requests had no way to merge and one was already sitting open. Two jobs are vendored: auto-merge on opened or reopened, and disable auto-merge when a maintainer pushes to the bot's branch. The merge method follows the base, squash for `develop` and a merge commit for `main`, matching what each ruleset allows. The codegen and upstream-version jobs are dropped. This repo has neither, and a job keyed on a bot login that never opens a pull request here would be dead code that reads as coverage. Every tier auto-merges, semver-major included, because the required checks are the gate rather than the size of the bump. Comments are rewritten to one sentence per line. The upstream copy is block-prose, which is the shape the comment rule forbids, and carrying it verbatim would import a defect into a file this repo owns. Clears the corresponding residual delta in `reports/Blog/audit.md` and the open decision in `TODO.md`. Co-authored-by: Claude Opus 5 (1M context) --- .github/workflows/merge-bot-pull-request.yml | 96 ++++++++++++++++++++ TODO.md | 2 - reports/Blog/audit.md | 1 - 3 files changed, 96 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/merge-bot-pull-request.yml 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/TODO.md b/TODO.md index d4df466..1836168 100644 --- a/TODO.md +++ b/TODO.md @@ -23,7 +23,6 @@ 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. -- Decide on `merge-bot-pull-request.yml`. Dependabot is configured and its pull requests will otherwise sit open, and the App secrets it needs are now in place. - 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. @@ -38,7 +37,6 @@ The site is built and gated in CI. It is on GitHub, and it is not yet serving it - 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/reports/Blog/audit.md b/reports/Blog/audit.md index 86903f3..32ef14b 100644 --- a/reports/Blog/audit.md +++ b/reports/Blog/audit.md @@ -116,7 +116,6 @@ 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. - `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: []`. -- `merge-bot-pull-request.yml` is not carried. Dependabot is configured and its pull requests will sit open until either the merge bot is added or they are merged by hand. The App secrets it needs are now in place, so this is no longer blocked, only undone. - `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 3e6e2b1b18550873def70683e27e8c3022a7e517 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 1 Aug 2026 11:09:07 -0700 Subject: [PATCH 4/7] Document the local verification loop for content and server changes (#5) * 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) * 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) * 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) --------- Co-authored-by: Claude Opus 5 (1M context) --- OPERATIONS.md | 25 +++++++++++++++++++++++++ TODO.md | 1 + reports/Blog/audit.md | 12 ++++++++++-- 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/OPERATIONS.md b/OPERATIONS.md index e5ecf8f..a2e5c5a 100644 --- a/OPERATIONS.md +++ b/OPERATIONS.md @@ -29,6 +29,31 @@ 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 +checks/check-live-urls.sh "$HUGO_BASEURL" +``` + +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. + +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/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. diff --git a/reports/Blog/audit.md b/reports/Blog/audit.md index 32ef14b..eaaabf0 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.** `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 +==> 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 4fd96d8b0748601442e1de45f4e6962f6265d2e8 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 1 Aug 2026 11:54:13 -0700 Subject: [PATCH 5/7] Record the container appdata path and re-derive the redirect table (#6) * Record the container appdata path and re-derive the redirect table Two gaps, both surfaced by asking why `secrets/.env` carried only two values. **The appdata path was recorded nowhere.** The container reads three host paths and a release writes only one. `$DEPLOY_ROOT` holds the bundle, while the bootstrap Caddyfile and Caddy's persistent state live under a separate appdata root that no script touches and nothing documented. The bootstrap is outside the bundle deliberately, since rules held there would leave a rolled-back site served by the current release's redirects, but that also means no release refreshes it and a rebuild depended on someone remembering where it goes. `CADDY_APPDATA` now records it in `secrets/.env`, and OPERATIONS.md carries the install command and the mount table. The value stays out of committed files, because this repo names no host path. The deployed bootstrap had drifted from the repo's copy, carrying the pre-rewrite comments and a stale rule count. Functionally identical, the `import` line matched exactly, so nothing was broken. Now byte-identical, and the site was re-verified after the restart at 1245 URLs honored. **The redirect table was wrong in more than its count.** It claimed 11 regex rules where the Caddyfile carries 13 `redir` directives reading 5 map files through 3 `map` blocks. Three destinations were also stale: the date archives, their pagination, and the Blogger monthly archives were all documented as redirecting to `/` when the config sends them to `/all/`. `labels.map` and `terms.map` appeared nowhere. The table is rebuilt against the config and keyed on the Caddy matcher names rather than invented R numbers, so a reader can grep the Caddyfile instead of trusting the doc. Every row was checked: the classes sum to 917 exactly, matching `redirect-urls.txt`, with the 778 legacy image URLs called out as gated separately. `@label` is documented as deliberately outside the contract, since that class is a soft 404 rather than a redirect. Clears the backlog item added when #5 dropped the count rather than guessing at it. Co-Authored-By: Claude Opus 5 (1M context) * Require a restart after any config change, and name the container Review caught a claim that was not just wrong but inverted, and testing it against the running mirror found a second instance that was worse. Caddy expands `import` at config-parse time, for the site config and for the `map` blocks reading `maps/*.map`, and does not watch those files. Only static file requests follow the `current` symlink per request. Proven on the mirror: a map entry present in the live release on disk answered 404 until the container restarted, then 301. That makes the verification loop unsound as it shipped. Change a redirect, release, check without reloading, and the check exercises the previous rules, so a broken redirect reports PASS while the artifact is broken. That is exactly the failure the loop exists to catch, so the restart is now a step in it rather than a footnote. The rollback procedure was the worse instance. It said "no restart and no reload", which would revert the content while leaving the previous release served by the current release's redirects, the precise mismatch that shipping config inside the bundle exists to prevent. Also proven: rolling back to a release containing the probe still answered 404 until the restart, then 301. The container name moves to `CADDY_CONTAINER` rather than being hardcoded in the docs, since this repo names no host value in a committed file. `deploy/env.example` now states the naming convention, which is that the prefix names whatever owns the value rather than whatever reads it. HUGO_ is fixed by Hugo's own env mapping, DEPLOY_ is the release tooling that writes the root and is also the CI variable for the SSH deploy, and CADDY_ is container state a release never touches. The set was already consistent under that rule and nothing said so. Also clarifies the redirect table per review: the thirteen classes sum to 917 and `@uploads` is excluded deliberately, with each repeated `@mapped` row naming its map file. Co-Authored-By: Claude Opus 5 (1M context) * Correct the stale rule count in the two places it survived Re-deriving the count in deploy/README.md fixed one instance of it and left two, because the number was never grepped for across the repo. Both found by review. OPERATIONS.md still opened its Redirects section with "eleven regular-expression rules and five map files". It now states the verified figures and points at deploy/README.md for the per-class breakdown, so the two files stop restating each other and there is one place for a count to go stale. The blog post carried the same wrong figure, and a worse claim beside it: that rolling back "cannot leave yesterday's site being served by today's rules". That is exactly what a rollback does without a reload, as proven on the mirror earlier in this branch. A published post giving that advice would be actively misleading, so it now carries the catch, the way it was found, and the general lesson that an atomic swap is only atomic for whatever reads through it per request. Verified after the change with the full documented loop, release then restart then check: 1245 URLs honored. Co-Authored-By: Claude Opus 5 (1M context) * Qualify when a missing restart actually invalidates the check The warning read as unconditional. It is not: a content-only release leaves the Caddyfile and the maps untouched, so the rules Caddy already holds are still the right ones and a check without a restart is honest. The false pass is specific to a release that changed `deploy/Caddyfile` or anything under `deploy/maps/`. The step stays unconditional regardless, and now says why rather than overstating the risk. 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 because the wrong answer is a green check rather than an error. A few seconds of restart is cheaper than that reasoning. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- OPERATIONS.md | 38 +++++++++++++++- TODO.md | 1 - ...moving-this-blog-from-wordpress-to-hugo.md | 10 ++++- deploy/README.md | 45 +++++++++++-------- deploy/env.example | 32 ++++++++++--- 5 files changed, 98 insertions(+), 28 deletions(-) diff --git a/OPERATIONS.md b/OPERATIONS.md index a2e5c5a..4317a6d 100644 --- a/OPERATIONS.md +++ b/OPERATIONS.md @@ -45,9 +45,16 @@ So release to the local mirror and run the live check **before** opening a pull ```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 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. @@ -81,9 +88,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. @@ -101,9 +111,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/TODO.md b/TODO.md index a645812..1836168 100644 --- a/TODO.md +++ b/TODO.md @@ -23,7 +23,6 @@ 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. 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/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/