From 9a666dc5c8a9fea640cd50d73433bf0fc4dbb399 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 12:37:34 +0000 Subject: [PATCH 01/11] Bump the actions-deps group with 3 updates (#10) Bumps the actions-deps group with 3 updates: [actions/checkout](https://github.com/actions/checkout), [softprops/action-gh-release](https://github.com/softprops/action-gh-release) and [DavidAnson/markdownlint-cli2-action](https://github.com/davidanson/markdownlint-cli2-action). Updates `actions/checkout` from 7.0.0 to 7.0.1 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0...3d3c42e5aac5ba805825da76410c181273ba90b1) Updates `softprops/action-gh-release` from 3.0.1 to 3.0.2 - [Release notes](https://github.com/softprops/action-gh-release/releases) - [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md) - [Commits](https://github.com/softprops/action-gh-release/compare/718ea10b132b3b2eba29c1007bb80653f286566b...3d0d9888cb7fd7b750713d6e236d1fcb99157228) Updates `DavidAnson/markdownlint-cli2-action` from 24.0.0 to 24.1.0 - [Release notes](https://github.com/davidanson/markdownlint-cli2-action/releases) - [Commits](https://github.com/davidanson/markdownlint-cli2-action/compare/8de2aa07cae85fd17c0b35642db70cf5495f1d25...6bf21b07787794f89a243495939cd651942aeabe) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: 7.0.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions-deps - dependency-name: softprops/action-gh-release dependency-version: 3.0.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions-deps - dependency-name: DavidAnson/markdownlint-cli2-action dependency-version: 24.1.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: actions-deps ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish-release.yml | 4 ++-- .github/workflows/validate-task.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 49d83ae..b6d474d 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -41,7 +41,7 @@ jobs: # Full history for NBGV; pin the dispatch-time commit - a push landing after dispatch must not release unvalidated. - name: Checkout code step - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.sha }} fetch-depth: 0 @@ -56,7 +56,7 @@ jobs: # The target_commitish input pins the tag to the exact built commit, not the default branch. # The release is the tag plus GitHub's auto source archive, README, and LICENSE, with no build assets. - name: Create GitHub release step - uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1 + uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: generate_release_notes: true tag_name: ${{ steps.nbgv.outputs.SemVer2 }} diff --git a/.github/workflows/validate-task.yml b/.github/workflows/validate-task.yml index 4ce5889..2656e82 100644 --- a/.github/workflows/validate-task.yml +++ b/.github/workflows/validate-task.yml @@ -25,14 +25,14 @@ jobs: steps: - name: Checkout code step - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # Doc linters run as pinned action wrappers. # The editorconfig-checker action is install-only, so it runs via Docker instead. # The markdown glob excludes the imported archive and the vendored theme. # Neither is authored here, and .markdownlint-cli2.jsonc is carried verbatim so it cannot scope them. - name: Lint Markdown step - uses: DavidAnson/markdownlint-cli2-action@8de2aa07cae85fd17c0b35642db70cf5495f1d25 # v24.0.0 + uses: DavidAnson/markdownlint-cli2-action@6bf21b07787794f89a243495939cd651942aeabe # v24.1.0 with: globs: | **/*.md From 61a45d30cee14d45ccb8e6982852ab04115d6616 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 3 Aug 2026 16:00:07 -0700 Subject: [PATCH 02/11] Re-vendor the eight verbatim carries the hub advanced past (#11) * Re-vendor the eight verbatim carries the hub advanced past The hub's first audit of this repo (ProjectTemplate#547) found eight verbatim units behind the canonical at hub main `3a7cc64`. Six are stale copies and two never arrived at all, and the distinction matters, because a stale copy still states the rule in an older form while an absent one states nothing. ## Absent, so this is the first time the rule lands here - `AGENTS.md > Fleet Bootstrap` says where the canonical rules live and routes by the state this repository is actually in. Without it, an agent starting here has nothing telling it the hub exists. - `GOVERNANCE.md > Representative Data in Agent-Authored Text` is the rule that agent-authored text illustrates with constructed data rather than with data observed in the maintainer's environment. ## Stale, so the rule was here in an older form - `AGENTS.md > Context and Delegation Discipline` gains the rule that a wait separates three outcomes and says which one it reached. - `AGENTS.md > Where the Rules Live` gains the row routing to the representative-data rule above. - `GOVERNANCE.md > Git and Commit Rules` gains "Commit means commit and push". - `GOVERNANCE.md > Verification Discipline` gains "A launched process is not a result, and a cause nobody observed is not a diagnosis". - `GOVERNANCE.md > PR Review Etiquette` gains the whole "Every Finding Ends in an Action" subsection, so the review loop arrives with the five outcomes that close a finding rather than without them. - `repo-config/configure.sh` takes the payload-driven check mode. The copy here compared the merge methods and the required-status-check contexts by name; the canonical compares every parameterized rule's whole parameters object in both directions, so a rule added live that no payload declares is now caught. TODO.md says not to hand-edit `configure.sh` or the ruleset payloads. Re-vendoring from the hub canonical is the sanctioned exception and the reason that rule exists. The two ruleset payloads are deliberately not touched: ProjectTemplate#545 changed them an hour ago on the hub's `develop`, and ground truth is the hub's `main`, so that one converges when it promotes. The intro line is adjusted from two things to four, because the file now holds the bootstrap as well. ## Verification - Every verbatim unit byte-matches the canonical after LF normalization: the three `AGENTS.md` sections, the eighteen `GOVERNANCE.md` sections, `configure.sh`, and `.markdownlint-cli2.jsonc`. The only two that differ are `Devcontainer` and `Repository Layout`, both intent fidelity. - `repo-config/configure.sh check ptr727/Blog release` exits 0 under the new stricter check mode, so the live rulesets and settings still conform, which is the check that would have caught a bad re-vendor. - markdownlint, editorconfig-checker, and the JSON and prose gates are clean, and all 20 rows of the rules map resolve to a carried section except the one the table itself marks hub-only. Co-Authored-By: Claude Opus 5 (1M context) * State the hub ref the carry now matches The report named `6501479`, the ref the baseline was carried from. The re-vendor in this branch moves every verbatim unit to `3a7cc64`, so the line named a ref the tree no longer matched the moment the rest of this change landed. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- AGENTS.md | 19 ++++++++++-- GOVERNANCE.md | 35 +++++++++++++++++++-- repo-config/configure.sh | 67 ++++++++++++++++++++++++++++------------ reports/Blog/audit.md | 2 +- 4 files changed, 97 insertions(+), 26 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 96974b3..67935ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ **Blog** is the source and deployment pipeline for a personal technical blog, a Hugo static site served by Caddy behind a reverse proxy. It holds the content, the media, the URL contract the site must honor, and the release tooling that builds and publishes it. -This file is the entry point every coding agent reads first, and it holds only two things: the rules for managing context and delegation, which apply to every task, and a map of where every other rule lives. The rule text itself is in [`GOVERNANCE.md`](./GOVERNANCE.md), one section per topic. Code style lives in [`CODESTYLE.md`](./CODESTYLE.md), the CI/CD workflow contract in [`WORKFLOW.md`](./WORKFLOW.md), and the deploy, rollback, and server procedures in [`OPERATIONS.md`](./OPERATIONS.md). +This file is the entry point every coding agent reads first, and it holds only four things: this project's own conventions, the bootstrap that says where the canonical rules live and which procedure to follow for the state this repository is actually in, the rules for managing context and delegation, which apply to every task, and a map of where every other rule lives. The rule text itself is in [`GOVERNANCE.md`](./GOVERNANCE.md), one section per topic. Code style lives in [`CODESTYLE.md`](./CODESTYLE.md), the CI/CD workflow contract in [`WORKFLOW.md`](./WORKFLOW.md), and the deploy, rollback, and server procedures in [`OPERATIONS.md`](./OPERATIONS.md). Treat this file and `GOVERNANCE.md` as authoritative for cross-cutting rules, and do not restate their rules elsewhere. This project's own conventions and behavioral contracts live here, **not** in [`.github/copilot-instructions.md`](./.github/copilot-instructions.md), because that file targets GitHub Copilot / VS Code specifically, while this file and `GOVERNANCE.md` are the agent-agnostic ones every coding agent is directed to read, so any rule a reviewer must honor has to live in one of those two files to be provider-independent. @@ -15,6 +15,19 @@ This site has served the same domain across earlier platforms, so its whole risk - **`content/` is an archive.** It carries sixteen years of text. Prose, spelling, and style sweeps do not reach it, and rewriting it corrupts provenance rather than improving style. - **A gate proves itself by failing.** Every check here is demonstrated against a deliberate break before it is trusted, because a gate that has only ever passed is indistinguishable from one that checks nothing. +## Fleet Bootstrap + +This repository is governed by a shared template, and the canonical rules, machine-readable spec, and procedures live in `github.com/ptr727/ProjectTemplate`. Fetch that repository before acting on anything about conformance, carried content, repository settings, or standing a repository up, because a carried copy here can be stale or absent and the hub is the only authority on what this repository is supposed to hold. This section is byte-locked across every repository in the fleet, so it reads identically wherever it is found, and it is the entry point whenever nothing else present says where the rules are. + +Route by what this repository currently holds rather than by what it is expected to hold, since the two differ exactly when this section matters most. + +- **No repository yet, or a local tree with no remote.** Follow the hub's `STANDUP.md` from section 0. That file is hub-only and deliberately not carried, because a repository needing it cannot be relied on to hold a current copy. Note that nothing in it creates the GitHub repository, which is an outward-facing write requiring explicit permission, so section 0A is the list handed to the maintainer before anything else starts. +- **A repository with no carried instruction set, or a partial one.** Carry the baseline per the hub's `STANDUP.md` sections 1A and 2, which resolve what this repository is owed from its declared types and workflow model. Absent files are not drift to re-vendor, they are a baseline that never arrived, and the two are fixed differently. +- **A repository with the instruction set, current or stale.** Follow the hub's `AUDIT.md` end to end, then apply what it finds per its section 10. An audit that reports drift and stops is half the procedure. +- **A repository that believes it is conformant.** Run the audit anyway and commit the report, because conformance asserted without a report is conformance nobody can check. This is the same procedure as the case above and is listed separately only because it is the one most often skipped. + +Two rules bound every path above. **Read the hub's `main` branch as ground truth**, since that is the promoted and gated state, and read `develop` only to detect divergence. And **the audit is read-only**: it produces a report and never edits the repository it measures, so a fix is a separate, reviewable change. + ## Context and Delegation Discipline An agent session is billed on the context it carries, not the work it does. Every request re-reads the whole accumulated context, so a token added early is paid for again on every request that follows, and a long session bills its last task for every earlier one. These are cost rules. None of them licenses doing less work, skipping verification, or shipping something unreviewed. @@ -22,7 +35,7 @@ An agent session is billed on the context it carries, not the work it does. Ever ### Session Scope - **One deliverable, one session.** A session covers one branch and one deliverable, and ends when that work merges. A multi-step task is one deliverable and stays in one session. Two unrelated tasks are two sessions even when they run back to back. -- **End a session at any of these, without being asked:** the branch changes, the pull request merges, the next task is unrelated to the last, or a third review round opens on the same pull request. +- **End a session at any of these, without being asked:** the branch changes, the pull request merges, or the next task is unrelated to the last. A review round is none of them. A loop still producing findings is the deliverable in progress, and a round count is not a reason to leave one open. - **Hand off in a file, never in context.** Close a session by writing at most 2 KB to a scratch file: branch, pull request link, what is done, the next command. A summary held in context is re-billed until the session ends, and a summary on disk is read once by whoever needs it. - **Re-derive state, do not carry it.** "This session already has the context" is the signal to split, not to continue. Context that has gone stale is worse than absent, because a file read hundreds of requests ago no longer describes the file. - **Compaction is a fallback, not the strategy.** It restarts context from a floor and climbs again, where a fresh session starts from zero. @@ -54,6 +67,7 @@ If a rule you were given does not cover what you find, stop and report it. Do no ``` - **Wait in a background process, not in a poll loop.** A review or CI wait is a sequence of near-identical requests, each billed for whatever context it happens to carry. Run the wait as one backgrounded command that returns when the condition is met. +- **A wait separates three outcomes, and says which one it reached.** The condition was met, it has not been met yet, and the wait cannot reach it at all are three different results, and a backgrounded wait that emits nothing renders all three identically. Run the command once in the foreground and read its output before backgrounding it, because a wait is only as good as the command inside it, and an unsupported flag on the installed tool version exits non-zero with an empty stdout that every naive test reads as "nothing yet". Never let a fallback stand in for a failed command, since `|| echo '[]'`, `|| true`, and `2>/dev/null` convert an error into that same reading, which is the suppression the write-safety rules already forbid on a mutation. Make the wait emit on failure as loudly as on success, so silence means "still running" and nothing else, and bound it, so a condition that is never coming ends in a report rather than in another wait. ## Where the Rules Live @@ -64,6 +78,7 @@ Every rule below is a level-two section of [`GOVERNANCE.md`](./GOVERNANCE.md). R | Why the rules are shaped this way | `Foundational Principles` | | Recording a durable lesson or updating governance | `Durable Knowledge and Self-Improvement` | | Any push, API mutation, comment, label, or merge | `Repository Boundaries and Write Safety` | +| Quoting data into a comment, commit, test, or doc | `Representative Data in Agent-Authored Text` | | Committing, signing, rebasing, force-pushing | `Git and Commit Rules` | | Branch choice, promotion, keeping branches in sync | `Branching Model` | | Releasing, version bumps, publishing | `Release Model` | diff --git a/GOVERNANCE.md b/GOVERNANCE.md index c071a23..e498f13 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -26,9 +26,18 @@ A state-changing GitHub call is the highest-blast-radius thing an agent does her - **Never fabricate, guess, or reuse an identifier passed to a write.** Every id a state-changing call consumes (a node id, a numeric id, a thread or comment id) is captured from a live query in the **same** session into a variable and passed from there. Do not hand-type an id, guess it, recall it from memory or an earlier session, or copy it from documentation or an example. Ids commonly resolve **globally**, so a wrong-but-valid id does not fail. It writes to the wrong target, in someone else's repository. If a query returns no id, stop rather than invent one to proceed. - **A write is never a probe, and a write's output is never suppressed.** Never fire a state-changing call to see whether it works: decide it should happen, make it happen, and read the result. Never append output-discarding redirection or a force-success tail to a mutation (for example `>/dev/null`, `2>/dev/null`, `&>/dev/null`, `|| true`, `|| :`, `|| echo`), because the write's output is exactly what must be read. A write that appears to fail is **verified, not assumed harmless**, because the operation may have succeeded on the server while the client reported an error, so confirm the actual state before retrying or moving on. The ban targets hiding a *failure*. An ad-hoc call's response is the only signal you get, so `>/dev/null 2>&1`, `|| true`, and `|| echo`, which swallow the error stream or force success, are never acceptable on one. A committed script under `set -e` is a narrow exception: it may send a write's *stdout* to `/dev/null` to drop the success-response noise, because stderr stays visible and a failed write still aborts loudly (`repo-config/configure.sh` does exactly this). The exception is stdout-only suppression inside a reviewed, fail-loud script, never `2>&1` or a force-success tail, and never an ad-hoc command. +## Representative Data in Agent-Authored Text + +Agent-authored text illustrates with data the agent constructed, never with data it observed in the maintainer's environment. This binds every surface an agent writes: pull request and issue comments, review replies, commit messages, code, tests, fixtures, and docs. Reading real data is unrestricted, and what is bounded is what an agent copies out of the environment into text that is committed or posted. The rule holds for a private repository as much as a public one, since a repository's audience changes with one settings toggle while the text stays exactly where it was written, and it holds where the data is the maintainer's own, since the exposure happens on their behalf before they can weigh it. + +- **Synthetic evidence is the better evidence, not a weaker substitute.** A case constructed to carry the defect demonstrates it exactly and any reader can re-run it, where observed data proves the same thing and can never be reproduced by anyone else. A filename built to contain a newline is a complete proof of a newline-handling defect, and the real directory it was found in adds nothing the proof needed. Reaching for observed data to make a finding more convincing inverts which of the two is the stronger evidence. Where observed data is what revealed the defect, name its shape, meaning the property that triggers the fault, and construct a case that carries that property. +- **The exposure is one-way.** A public comment is fetched, cached, and indexed the moment it posts, so editing it afterwards is mitigation rather than a fix, and the edit leaves the original readable in the comment's edit history to anyone who can read the repository. Text that has already landed is reported to the maintainer rather than quietly rewritten, since the decision on what to do about it, deletion included, is theirs. Do not quote the exposed data again while reporting or investigating it, because a transcript, an issue, or a commit message written about the exposure reproduces it somewhere new. +- **No checker closes this.** A pattern finds an absolute home path or a drive letter, and gating that subset is worth doing as a floor. The exposure this rule exists for was name-shaped, and a name is not pattern-detectable, so a search of the offending text for path-shaped strings returns nothing while the names sit in plain sight. A gate here catches the easy half, and mistaking it for the answer is what stops anyone looking at the other half, which is why this is a judgment an agent applies rather than a check it waits for. + ## Git and Commit Rules - **Default to staging, not committing.** Stage changes with `git add` and leave `git commit` to the developer unless the developer has explicitly authorized the agent to commit for the current ask ("commit this", "open a PR", etc.). Authorization is scope-bound: it covers the commits needed for that specific task, not a blanket commit license for the rest of the session. +- **"Commit" means commit and push.** An authorization to commit carries the push to the feature branch the work belongs on, because nothing reviews a local commit. The Copilot review loop, the required status checks, and the maintainer all read the remote, so work that stops at `git commit` leaves the review unstarted and the branch's state private to one machine, which reads as progress while none of the gates have run. Push to the feature branch, never to a protected branch (see the Branching Model), and never with `--force`. Holding a commit locally is the narrower case, so it happens when the developer asks for it rather than by default. - **Check the working tree for the maintainer's own uncommitted edits before committing.** The maintainer hand-edits files live (often `README.md`/`HISTORY.md`, sometimes with the editor's LF->CRLF flip on top). Review `git status` first. If there are changes you did not make, ask whether to include them rather than bundling half-finished work or stranding it in an unrelated commit. - **All commits must be cryptographically signed (SSH or GPG).** Branch protection enforces this on both branches, and unsigned commits are rejected on push. Signing depends on environment configuration: `git config commit.gpgsign true`, a configured `user.signingkey`, and a working signing agent (loaded `ssh-agent` for SSH, or `gpg-agent` for GPG). If signing is not configured in the environment, **do not commit**. Surface the missing config to the developer and stop at `git add`. Verify before any agent-authored commit (`git config --get commit.gpgsign && ssh-add -L` or the GPG equivalent). **Signing must be live before the *first* commit, not retrofitted.** Turning on `Require signed commits` against a branch that already has unsigned commits forces a rewrite of that entire history to re-sign it, changing every commit SHA and making whoever does the rewrite the committer and signer of every commit (a rebase preserves the `author` field but not the original signatures, and you cannot sign another contributor's commits for them). During new-repo setup, never create commits until signing is verified. - **Commit under the committing account's own GitHub `noreply` identity, never a private, personal, or invented address.** The `author` and `committer` on every agent-authored commit are the GitHub `noreply` address of the account whose key signs the commit (above). GitHub issues these in a `username@users.noreply.github.com` or `ID+username@users.noreply.github.com` form, and for this single-maintainer fleet it is the owner's `ptr727@users.noreply.github.com`. Do not set `user.name`/`user.email` to a fabricated persona, bot name, or product name, and do not commit under whatever identity the environment happens to carry: verify `git config --get user.email` is that GitHub `noreply` address before committing. **Verify it, do not set it.** The identity is host configuration, set globally once, so a repo-local `user.email` is redundant where the global is right and a wrong identity where it is not, and it silently shadows the global it overrides. A mismatch is a host fault to surface to the maintainer rather than to patch per repo, because a local override hides a broken host that then commits under the wrong identity in every other repo on that machine. A wrong identity is not cosmetic: a private email trips GitHub's email-privacy push protection (GH007), and an unrecognized or invented author pollutes history. Identity is separate from signing: a wrong author does not by itself fail the signature rule, but the ad-hoc identities that produce it are typically also unsigned, which the signing rule above then rejects on push. @@ -212,6 +221,7 @@ The checks that separate work actually done from work that merely reports succes - **Never edit source through a shell heredoc when the text carries backslash escapes.** The shell consumes the escape and writes an invisible control character in its place, so a `\b` inside a regex becomes a backspace and the pattern silently matches nothing while every test still passes. Use a file-editing tool for such text. When a check inspects text for control characters, use `str.isprintable()` rather than a codepoint floor, since DEL and the Unicode format characters sit above 32 and are equally invisible in a diff. - **Never edit an active `.code-workspace` file.** A workspace file rewritten on disk can make VS Code reload the window, and a reload destroys the running agent session's context, so the work in flight is lost with nothing to catch it, and the trigger is not fully characterized (an agent's edit has caused the reload where a human's identical edit did not). Surface the needed change for the maintainer to apply by hand. - **A green check is not evidence the work happened.** A skipped job and a passing job are indistinguishable in the aggregated required check. When a job exists to exercise something, confirm from its log that it ran and produced the output it promises. +- **A launched process is not a result, and a cause nobody observed is not a diagnosis.** "The watcher is armed" names a process rather than a finding, so what gets reported is the output that process produced, and where it produced none, that absence is the report. The failure it prevents is an agent standing still on a condition that was met half an hour earlier, having announced the wait and never read it. Naming an external cause for such a stall afterwards, a throttle or a quota that appears nowhere in the record, turns a local defect into a story about someone else and closes the investigation on the wrong party, so read the record for the cause before naming one, and where the record does not carry it, report the cause as unknown. - **A workflow change is only fully exercised by CI.** Extracting a `run:` block and executing it locally validates the script and nothing else, because `secrets: inherit`, `permissions:`, `needs:` wiring, and reusable-workflow inputs resolve only in a real run. - **A review flags an instance, so fix the class.** When a reviewer cites one stale claim, one silent-narrowing pattern, or one mis-worded contract, sweep for its siblings before replying. Reviewers sample rather than enumerate. @@ -248,28 +258,47 @@ Drive the loop to green, meaning a review confirmed on the latest head SHA and e For provider-specific mechanics (how to request review, query review state, post replies, resolve threads), see the **GitHub Copilot Review Runbook** in [.github/copilot-instructions.md](./.github/copilot-instructions.md). This file owns the contract, and that file owns the mechanics. +### Every Finding Ends in an Action + +**A finding is closed by one of five outcomes, and a round count is never one of them.** The loop runs until no finding stands, however many rounds that takes, because the number of rounds measures how much was found rather than whether the work is done. A finding parked, waited out, or superseded by a push is still open. + +1. **It is real, so fix it.** Reply with the fixing commit SHA. +2. **It is not real, so disprove it in the thread**, with the command and its output, the code path that makes it impossible, or the rule that governs it. The proof is addressed to the reviewer as much as to the maintainer, since a decline it can read is what stops it raising the same thing next round. An assertion is not a proof and does not close a finding. +3. **It is real and deliberately not being fixed, which is the maintainer's call and not the agent's.** Say what the finding is, why the fix is unwanted, and get an explicit answer. Never suppress one by silence, by resolving the thread, or by an answer that reads as a decline while conceding the point. +4. **It is real and worth doing later, so file the issue first and reply with its link.** A deferral recorded only in a thread is lost the moment the pull request merges, so the issue is what carries it and the link is what proves it exists rather than being intended. This is for work the change did not create: an adjacent defect the reviewer noticed in passing, or a fix too large to ride along. It does not cover a defect in the change under review, because filing an issue about a bug you are about to merge is outcome 3 in other clothes, and that one is the maintainer's to decide. +5. **It keeps coming back, so fix the class rather than the instance.** A finding raised repeatedly against correct code is a defect in what the code communicates, not in the reviewer. Give it what it lacks: the non-obvious *why* as a comment where the code cannot state it, a clearer name, a narrower interface, or the rule change where the rule is what is wrong. A comment written for this earns its place under the comment rules like any other, so it states the why, stays short, and never cites a rule or addresses the reviewer. Making the noise stop is worth doing well, because a reviewer that repeats itself trains the reader to skim it, and skimming is how a real finding gets missed. + ### Triaging Review Comments +**A low-confidence finding is not a low-value one.** Copilot collapses the findings it is least sure of into the review body instead of raising a thread, and in this fleet's experience those are right the large majority of the time. Judge each one against the code, never against its confidence label. They are also the easiest to lose, because they appear in no thread, so a loop that polls threads alone reports a clean pass while they stand (see the Merge Gate, condition 3). + For each comment, classify before responding: - **Bug** - wrong behavior, missing test coverage, or a real divergence between code and docs. Fix it. Reply with the fixing commit SHA when done. - **Style/convention** - the comment cites a rule from this file or a language-specific style guide. Two cases: - The cited rule matches what the existing codebase already does -> fix the offending code. - - The cited rule contradicts what's in the tree, or industry norm -> **update the rule instead of the code**. The rule is wrong, not the code. Bouncing the same code across rounds is the symptom of a wrong rule. Heuristic: three rounds on the same style category means the rule needs adjusting and the user should authorize the rule change. + - The cited rule contradicts what's in the tree, or industry norm -> **update the rule instead of the code**. The rule is wrong, not the code. Bouncing the same code across rounds is the symptom of a wrong rule, so treat the recurrence itself as the finding and take it to the user for the rule change (outcome 5 above), rather than counting rounds until some threshold licenses it. - **Architectural opinion** - the comment proposes a different design ("constrain this to disabled-by-default", "move it elsewhere", "add a runtime guardrail"). This is judgment, not a bug. Surface it to the user with a recommendation, and don't apply it unilaterally. ### Responding and Resolution Expectations -Reply inline with either the fixing commit SHA (for accepted issues) or a concise rationale (for declines). Resolve review threads when addressed or intentionally declined with rationale. Issue-level comments (those at `repos/.../issues//comments` rather than tied to a specific line) have no resolution action, so acknowledge with a reply if needed and move on. +Reply inline with either the fixing commit SHA (for accepted issues) or the evidence that disproves it (for declines). **A decline carries proof rather than an assertion**, meaning the command and its output, the code path that makes the concern impossible, or the rule that governs it. "This is fine" is not a reply, and disagreeing without evidence is not addressing a finding, so a thread is not resolved on one. Resolve review threads when addressed, or when declined with that evidence recorded in the thread. Issue-level comments (those at `repos/.../issues//comments` rather than tied to a specific line) have no resolution action, so acknowledge with a reply if needed and move on. After the final push on a PR, sweep older threads from earlier rounds whose code paths no longer exist, or stale unresolved markers remain in the review UI. +**Answering a suppressed finding is a different act from replying in a thread, and it carries its own pairing.** A threaded reply sits under the comment it answers and the UI shows whether it is resolved. A suppressed finding has neither, so an answer that does not carry its own context is unverifiable: the maintainer cannot tell that it was seen, which finding it addresses, or whether any were skipped, and has to ask. An answer therefore **quotes the finding** in a blockquote, with its `file:line` anchor and enough of Copilot's own words to identify it, **carries one bold verdict per finding** (`Fixed in `, `Disproven`, or `No change needed`) so the outcomes are scannable without reading prose, **states the `(N)` count** the block heading gives so N answers can be checked against N findings, and **links the review** that raised them, since a PR accumulates rounds and an unlinked answer is ambiguous about which one it closes. One comment per review round keeps the answers together. + +**Read every round, not only the head.** A suppressed finding has no resolved state, so a push does not retire it: the finding simply stops appearing in a head-scoped query while remaining unanswered. Treating "superseded by a push" as "answered" is how rounds of findings go unanswered. `scripts/pr_review.py status ` reports every round and marks which are from earlier ones. + +**The review's own overview cannot be trusted to say whether findings exist.** A body that reads "Copilot reviewed N out of N changed files and generated no new comments" routinely carries a collapsed block of suppressed findings directly beneath that sentence. Read the body for the block rather than the summary line, because the summary line and `reviewDecision` and an empty unresolved-thread list all agree that a review with four outstanding findings is clean. + ### Escalating to the User Bring the user in when: - **Genuine design trade-off** surfaces (fail-open vs fail-closed, narrow vs broad refactor scope, "should we add a guardrail or trust the docstring"). Triage, recommend, ask. -- **Repeated friction** across rounds without convergence, which is the rule-needs-updating signal. Stop, summarize the pattern, and let the user authorize the rule change. +- **A recurring finding** the code keeps attracting, which is the fix-the-class signal. Summarize the pattern and bring the remedy, whether that is the rule change or what the code has to say differently to stop earning it. +- **A finding you judge real but do not want fixed**, which is outcome 3 above and is never the agent's call to make quietly. - **Architectural redesign** is requested rather than a bug fix. Surface with a recommendation, and never apply it unilaterally. Anti-pattern: don't keep flipping the code on the same style point. Flip the rule once and stick to the rule. diff --git a/repo-config/configure.sh b/repo-config/configure.sh index 86a88d4..41a3a53 100755 --- a/repo-config/configure.sh +++ b/repo-config/configure.sh @@ -17,9 +17,15 @@ # The develop ruleset is develop.json where the model is PR-gated, or operational/develop.json for direct signed pushes. # Applying the same configuration twice changes nothing, so the mode is idempotent. # -# The check mode is the read-only inverse, where every applied ruleset, setting, and security feature must match. -# The ruleset and settings assertions are driven by the committed payloads, so they stay repo-agnostic. -# That also survives the GitHub API normalizing a stored ruleset, comparing rule presence, merge methods and required checks rather than a byte diff. +# The check mode is the read-only inverse, and it verifies the same three groups apply writes. +# The ruleset and static-settings assertions are driven by the committed payloads, so they stay repo-agnostic. +# A ruleset is checked on enforcement, on the rule-type set compared in both directions, and on the whole parameters object of every parameterized rule. +# Comparing the parameters object rather than named fields means a parameter added to a payload is audited with no change here. +# Both directions matter, since a rule added live that the payload never declared is drift this catches. +# That still survives the GitHub API normalizing a stored ruleset, since the comparison is over parsed JSON with sorted keys rather than a byte diff. +# The derived settings apply computes are asserted by name rather than from a payload, meaning has_discussions and default_branch. +# The two Dependabot security features are asserted the same way, since apply enables them and no payload declares them. +# What is unaudited is a static setting absent from settings.json, since only that group is payload-driven. # Secrets are per-repo (see spec/secrets.json) and not checkable from a standalone carry, so they are a manual-verify note. set -Eeuo pipefail @@ -185,7 +191,7 @@ jq_has() { jq -e "$@" >/dev/null 2>&1; } gh_ok() { gh api "$@" >/dev/null 2>&1; } check_ruleset() { # payload-file - the live ruleset must match the committed policy, driven by the payload - local file="$1" rname id live t want got wantc gotc want_enf + local file="$1" rname id live t want got want_enf if [ ! -e "$file" ]; then fail "ruleset payload $file missing"; return; fi rname="$(jq -r '.name // empty' "$file")" if [ -z "$rname" ]; then fail "ruleset payload $file has no name"; return; fi @@ -194,23 +200,35 @@ check_ruleset() { # payload-file - the live ruleset must match the committed pol if ! live="$(gh api "repos/$repo/rulesets/$id")"; then fail "ruleset '$rname' - could not read live state"; return; fi want_enf="$(jq -r '.enforcement' "$file")" assert "ruleset '$rname' enforcement = $want_enf" test "$(jq -r '.enforcement' <<<"$live")" = "$want_enf" - # Every rule type the committed payload declares must be present live (payload-driven, so repo-agnostic). + # The live rule-type set must equal the payload's, compared in both directions. + # Checking only that each payload type is present live misses a rule someone added by hand. + # That is drift this script exists to catch, and it passed as clean before. + local want_types got_types + if ! want_types="$(jq -r '[.rules[].type] | sort | join(",")' "$file")"; then + fail "ruleset payload $file did not parse"; return + fi + if [ -z "$want_types" ]; then fail "ruleset payload $file declares no rules"; return; fi + got_types="$(jq -r '[.rules[].type] | sort | join(",")' <<<"$live")" + assert "'$rname' rule set = $want_types" test "$got_types" = "$want_types" + # Every parameterized rule is compared on its whole parameters object rather than on selected fields. + # Naming fields one at a time meant a payload could declare a parameter the check never read. + # Review-thread resolution, stale-review dismissal, and the status-check policy flags all went unverified that way. + # Comparing the object keeps the check payload-driven: a parameter added to a payload is audited with no code change. + # Keys are sorted on both sides, so key order from the API cannot read as drift. + # Set-like arrays are sorted too, since the API guarantees no order and the previous per-field comparison sorted them explicitly. + # Dropping that would turn array order into false drift. + # A scalar array sorts directly, and required_status_checks sorts by context, its identifying field. + local ptypes norm + norm='def n: walk(if type=="array" then (if length==0 then . elif (all(.[]; type=="string" or type=="number")) then sort elif (all(.[]; type=="object" and has("context"))) then sort_by(.context) else . end) else . end); n' + ptypes="$(jq -r '[.rules[] | select(has("parameters")) | .type] | .[]' "$file")" while IFS= read -r t; do + [ -z "$t" ] && continue # shellcheck disable=SC2016 # $t is a jq --arg variable, not a shell expansion - assert "'$rname' enforces rule '$t'" jq_has --arg t "$t" '.rules[] | select(.type==$t)' <<<"$live" - done < <(jq -r '.rules[].type' "$file") - # For pull_request, the live merge methods must match the payload, which is the develop=squash and main=merge policy. - if jq_has '.rules[] | select(.type=="pull_request")' "$file"; then - want="$(jq -c '[.rules[]|select(.type=="pull_request").parameters.allowed_merge_methods[]]|sort' "$file")" - got="$(jq -c '[.rules[]|select(.type=="pull_request").parameters.allowed_merge_methods[]]|sort' <<<"$live")" - assert "'$rname' merge methods = $want" test "$got" = "$want" - fi - # For required_status_checks, the live required contexts must match the payload. - if jq_has '.rules[] | select(.type=="required_status_checks")' "$file"; then - wantc="$(jq -c '[.rules[]|select(.type=="required_status_checks").parameters.required_status_checks[].context]|sort' "$file")" - gotc="$(jq -c '[.rules[]|select(.type=="required_status_checks").parameters.required_status_checks[].context]|sort' <<<"$live")" - assert "'$rname' required checks = $wantc" test "$gotc" = "$wantc" - fi + want="$(jq -S -c --arg t "$t" "[.rules[] | select(.type==\$t) | .parameters] | first | $norm" "$file")" + # shellcheck disable=SC2016 + got="$(jq -S -c --arg t "$t" "[.rules[] | select(.type==\$t) | .parameters] | first | $norm" <<<"$live")" + assert "'$rname' rule '$t' parameters match the payload" test "$got" = "$want" + done <<<"$ptypes" } check_settings() { @@ -219,11 +237,20 @@ check_settings() { if ! live="$(gh api "repos/$repo")"; then fail "could not read repository settings"; return; fi # Static settings are driven from settings.json, so the check never drifts from the file. # Add a key there and it is audited here automatically. + # The payload is parsed into a variable before the loop rather than streamed from a process substitution. + # A jq failure inside `done < <(...)` leaves the loop body unexecuted without tripping set -e. + # Every static setting would then report as checked and passing while nothing was compared, a false clean. + local pairs + if ! pairs="$(jq -r 'to_entries[] | "\(.key)\t\(.value)"' "$settings_file")"; then + fail "settings payload $settings_file did not parse"; return + fi + # A payload that parses to nothing is a floor failure rather than a clean run, so it is asserted. + if [ -z "$pairs" ]; then fail "settings payload $settings_file declares no keys"; return; fi while IFS=$'\t' read -r key want; do # shellcheck disable=SC2016 # $k is a jq --arg variable, not a shell expansion got="$(jq -r --arg k "$key" '.[$k]' <<<"$live")" assert "setting $key = $want" test "$got" = "$want" - done < <(jq -r 'to_entries[] | "\(.key)\t\(.value)"' "$settings_file") + done <<<"$pairs" # Dynamic settings apply sets: has_discussions (public repos only), default_branch (main, if it exists). private="$(jq -r '.private' <<<"$live")" wantdisc=true; [ "$private" = "true" ] && wantdisc=false diff --git a/reports/Blog/audit.md b/reports/Blog/audit.md index fb655b2..38846ab 100644 --- a/reports/Blog/audit.md +++ b/reports/Blog/audit.md @@ -3,7 +3,7 @@ Self-audit of this repository against its own committed ground truth, per [AUDIT.md](../../AUDIT.md). Read-only, and confined to this repository. **Date:** 2026-08-01 -**Hub ref carried:** `ptr727/ProjectTemplate` `main` `6501479` +**Hub ref carried:** `ptr727/ProjectTemplate` `main` `3a7cc64` **Declared:** `types: ["source-only"]`, `workflowModel: release`, `lineEndings: "lf"` ## Verdict From 83a11573008047789c0e72d916fe96b5dc788bb0 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 3 Aug 2026 16:00:29 -0700 Subject: [PATCH 03/11] Correct the docs that stopped describing the repository (#12) * Correct the docs that stopped describing the repository The hub's first audit of this repo (ProjectTemplate#547) found three prose violations and, reading the docs alongside it, three claims that were true when written and are not now. A doc that describes a past state is worse than an absent one, because a reader cannot tell which half still holds. ## Claims that outlived their state - `reports/Blog/audit.md` said the release path was untested because the publisher had never been dispatched. It was dispatched on 2026-08-01 and cut release `1.0.11`, so the path is proven rather than untested. - The same file said the registry entry stays `publish: []`. The hub now carries an entry declaring the GitHub release, which is the only channel that ships today. The VPS target is still deferred, and that half is unchanged. - `TODO.md` still listed dispatching the publisher as the next task. Removed, and the State table gains the release-pipeline row, so the fact that it is proven is recorded rather than merely no longer denied. ## The current-time rule `HISTORY.md` described the 1.0 content as "having previously lived inside a hosted blogging platform". Release notes are written in current time, describing what the thing is: a reader of 1.0 never saw the platform it came from, so the delta is noise that only makes sense to whoever did the work. ## Prose Two of the three findings are fixed: a comment opening on a lowercase identifier in the merge-bot workflow, and a semicolon in `OPERATIONS.md` prose. The third is the COPYRIGHT SIGN in `layouts/rss.xml`, left alone deliberately. The character-set tiers classify it nowhere, so the linter reports `charset-unknown` rather than pass or fail, and transliterating it in a machine-read feed to satisfy a rule that does not exist yet is the wrong way round. It is raised as an escalation in the hub's audit report and is the hub's call. ## Verification markdownlint, editorconfig-checker, and the JSON gates are clean, and `prose_lint.py` over the repo excluding the imported archive now reports only the escalated `charset-unknown`. Co-Authored-By: Claude Opus 5 (1M context) * Say which setting stops the cancellation, not which adjective The reworded comment opened "The false cancel-in-progress", which reads as an adjective modifying the key name rather than as the key's value. Naming the setting and its value says the same thing without the ambiguity, and it still opens on a capital, which is what the rewording was for. Co-Authored-By: Claude Opus 5 (1M context) * Separate the release, which passes, from the deploy, which is deferred The section read "Publish and Release: Deferred" over a paragraph saying the release is proven end to end, which is a heading contradicting its own body. A reader skimming headings got the opposite of the finding. They are two surfaces and only one is outstanding. The release is dispatch-only and proven by release `1.0.11`, and the hub registry declares it as such. The deploy to the VPS is the deferred half, and it is deferred because the VPS does not exist rather than because anything failed. Fixing the heading exposed two more places stating the merged claim: the verdict paragraph and the dimension table, which carried one "Publish and release: Deferred" row where there are two answers. Both now say which surface they mean, and the stale "`publish` is empty and `releaseTrigger` is `none`" line goes with them, since the registry entry landing in ProjectTemplate#547 declares the GitHub release and `dispatch-only`. Found by a suppressed low-confidence Copilot finding on the previous push, which was correct. Co-Authored-By: Claude Opus 5 (1M context) * Name the deferred surface the VPS deploy, not the publish The residual-delta bullet still called it the publish surface, from before the section split release from deploy. That reading now includes the GitHub release, which ships and is proven, so the bullet said the proven half was deferred. Found by a suppressed low-confidence Copilot finding, the second in a row on this branch that was correct. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- .github/workflows/merge-bot-pull-request.yml | 2 +- HISTORY.md | 2 +- OPERATIONS.md | 2 +- TODO.md | 2 +- reports/Blog/audit.md | 16 ++++++++-------- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/merge-bot-pull-request.yml b/.github/workflows/merge-bot-pull-request.yml index 5b08735..381b974 100644 --- a/.github/workflows/merge-bot-pull-request.yml +++ b/.github/workflows/merge-bot-pull-request.yml @@ -17,7 +17,7 @@ on: # 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. +# Setting cancel-in-progress to false means a follow-up synchronize cannot cancel an in-flight opened run. concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number }} cancel-in-progress: false diff --git a/HISTORY.md b/HISTORY.md index f2f6537..5d16e86 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -5,7 +5,7 @@ Pieter Viljoen's blog, and the tooling that builds, verifies, and deploys it. ## Release History - 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. + - The blog's content, media, URL contract, and deploy tooling live in one repository, under version control and gated by CI. - 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. diff --git a/OPERATIONS.md b/OPERATIONS.md index 1fc7094..d1a446b 100644 --- a/OPERATIONS.md +++ b/OPERATIONS.md @@ -139,7 +139,7 @@ A restart is needed whenever **any** Caddy config changes, not only this file. T ## Redirects -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. +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 8da733f..754b74d 100644 --- a/TODO.md +++ b/TODO.md @@ -13,6 +13,7 @@ The site is built and gated in CI. It is on GitHub, and it is not yet serving it | Deploy shape | done and proven against a running Caddy, on a local mirror | | 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 | +| Release pipeline | proven end to end. Release `1.0.11` carries the tag, source archive, README, and LICENSE | | VPS | untouched | ## Blocked on the maintainer @@ -22,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. - 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 38846ab..2f33baf 100644 --- a/reports/Blog/audit.md +++ b/reports/Blog/audit.md @@ -10,7 +10,7 @@ Self-audit of this repository against its own committed ground truth, per [AUDIT **Operational.** Every applicable check passes against the live repository. -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. +The release surface passes. The **deploy** to the VPS is **deferred**, not failed, and that deferral is declared rather than hidden, tracked in [ProjectTemplate#456][hub-issue], which [`STANDUP.md` section 5][standup] permits. | Dimension | Result | | --- | --- | @@ -19,7 +19,8 @@ The publish and release surface is **deferred**, not failed. That deferral is de | 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 | +| Release | **Pass.** Dispatch-only, proven by release `1.0.11` | +| Deploy to the VPS | **Deferred**, deliberately | ## 1. Settings and Rulesets @@ -102,13 +103,13 @@ That is a local mirror, not CI and not production. CI cannot run it, because the 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 +## Release Proven, Deploy Deferred -`publish` is empty and `releaseTrigger` is `none`, deliberately. +The two are separate and only one of them is outstanding. -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 release is proven.** `publish-release.yml` is dispatch-only, and release `1.0.11` on 2026-08-01 carries the tag, the source archive, the README, and the LICENSE. The hub registry declares it accordingly: `publish` names the GitHub release and `releaseTrigger` is `dispatch-only`. -`publish-release.yml` exists and is dispatch-only, but has never been dispatched. The release path is therefore untested. +**The deploy is deferred.** 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. ## Deliberate Deviations @@ -122,8 +123,7 @@ Both are recorded in [AUDIT.md](../../AUDIT.md) and reported upstream, so neithe Carried forward rather than closed: - 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: []`. +- No deploy exists, so the VPS deploy surface stays deferred. The GitHub release is the only channel that currently ships, which is what the hub registry declares, and the VPS target is revisited when a deploy has actually run. - `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 6cfc462f36246ef84aebe9589f2b8bc1c3011b3f Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 3 Aug 2026 16:30:35 -0700 Subject: [PATCH 04/11] Re-vendor the ruleset payloads and configure.sh after the promotion (#13) The hub promoted `develop` to `main` as `7194f91` while the previous two convergence pull requests were open, so the ground truth this repo is measured against moved under them. ProjectTemplate#545 takes `bypass_actors` out of all three payloads, and rewrites `configure.sh` around that decision. The rewrite is the substantive half. Apply now reads the live bypass list and writes it back unchanged, because a PUT replaces the whole document and omitting the field would delete the list rather than leave it alone, and it aborts rather than proceeding if that read fails. Check reports the live list and asserts nothing about it. Who may bypass a ruleset decides who the rules do not apply to, which is a decision for the maintainer rather than a value the fleet config hands out. ## What this deliberately does not do Remove the live bypass. Both rulesets here carry the `RepositoryRole` admin entry the old payload granted every repo, and nothing in this change touches it. Removing it is a protected-branch settings write and a decision the hub just declared human, so it is the maintainer's. Worth knowing that the two tools now disagree about that state. `configure.sh check ptr727/Blog release` exits 0 and reports the bypass as unmanaged, while `spec/audit.py` still compares `bypass_actors` and calls the same state a DEFECT on both branches. Raised as ProjectTemplate#554, since it makes every fleet repo carrying a bypass permanently not operational on a field the fleet decided not to manage. ## Verification - All four verbatim units byte-match hub `main` `7194f91` after LF normalization: the three re-vendored here plus `.markdownlint-cli2.jsonc`. - `repo-config/configure.sh check ptr727/Blog release` exits 0 under the rewritten check mode, and reports "bypass list: RepositoryRole 5 always (not managed by this script)" on both rulesets rather than asserting it. - The JSON and editorconfig gates are clean. Co-authored-by: Claude Opus 5 (1M context) --- repo-config/configure.sh | 24 ++++++++++++++++++++++-- repo-config/develop.json | 7 ------- repo-config/main.json | 7 ------- 3 files changed, 22 insertions(+), 16 deletions(-) diff --git a/repo-config/configure.sh b/repo-config/configure.sh index 41a3a53..4842702 100755 --- a/repo-config/configure.sh +++ b/repo-config/configure.sh @@ -111,7 +111,7 @@ ruleset_id() { # =============================== apply =============================== apply_ruleset() { # payload-file - create-or-update the ruleset by name - local file="$1" rname id + local file="$1" rname id live_bypass body if [ ! -e "$file" ]; then echo "Ruleset payload $file not found. Aborting to avoid a partially-applied configuration." >&2 exit 1 @@ -124,9 +124,23 @@ apply_ruleset() { # payload-file - create-or-update the ruleset by name id="$(ruleset_id "$rname")" if [ -n "$id" ]; then echo "Updating ruleset '$rname' (id $id) on $repo" - gh api --method PUT "repos/$repo/rulesets/$id" --input "$file" >/dev/null + # The bypass list is a human decision, so this script neither grants nor revokes it. + # A PUT replaces the whole document, so omitting the field would delete the live list rather than leave it alone. + # The live value is therefore read and written back unchanged, which is what "hands off" has to mean against a replacing API. + # A read failure aborts rather than proceeding, since applying without it would silently clear the list. + if ! live_bypass="$(gh api "repos/$repo/rulesets/$id" --jq '.bypass_actors // []')"; then + echo "Could not read the live bypass list for ruleset '$rname' on $repo. Aborting rather than applying a payload that would clear it." >&2 + exit 1 + fi + if ! body="$(jq --argjson b "$live_bypass" '.bypass_actors = $b' "$file")"; then + echo "Could not compose the ruleset payload for '$rname'. Aborting." >&2 + exit 1 + fi + gh api --method PUT "repos/$repo/rulesets/$id" --input - <<<"$body" >/dev/null else echo "Creating ruleset '$rname' on $repo" + # No bypass list is sent on create, so a new ruleset starts with GitHub's own empty default. + # Nothing is deleted here, because nothing existed to delete. gh api --method POST "repos/$repo/rulesets" --input "$file" >/dev/null fi } @@ -210,6 +224,12 @@ check_ruleset() { # payload-file - the live ruleset must match the committed pol if [ -z "$want_types" ]; then fail "ruleset payload $file declares no rules"; return; fi got_types="$(jq -r '[.rules[].type] | sort | join(",")' <<<"$live")" assert "'$rname' rule set = $want_types" test "$got_types" = "$want_types" + # The bypass list is reported and never asserted, because no payload declares one. + # Who may bypass a ruleset is a human decision taken in the UI, so code states what is there and judges nothing. + # It is surfaced on every run rather than left invisible, since it is the field that decides who the rules do not apply to. + local bypass + bypass="$(jq -r '[.bypass_actors[]? | "\(.actor_type) \(.actor_id) \(.bypass_mode)"] | join("; ")' <<<"$live")" + note "ruleset '$rname' bypass list: ${bypass:-none} (not managed by this script)" # Every parameterized rule is compared on its whole parameters object rather than on selected fields. # Naming fields one at a time meant a payload could declare a parameter the check never read. # Review-thread resolution, stale-review dismissal, and the status-check policy flags all went unverified that way. diff --git a/repo-config/develop.json b/repo-config/develop.json index dcb0faf..16c89f4 100644 --- a/repo-config/develop.json +++ b/repo-config/develop.json @@ -1,11 +1,4 @@ { - "bypass_actors": [ - { - "actor_id": 5, - "actor_type": "RepositoryRole", - "bypass_mode": "always" - } - ], "conditions": { "ref_name": { "exclude": [], diff --git a/repo-config/main.json b/repo-config/main.json index 9168c50..a99ed24 100644 --- a/repo-config/main.json +++ b/repo-config/main.json @@ -1,11 +1,4 @@ { - "bypass_actors": [ - { - "actor_id": 5, - "actor_type": "RepositoryRole", - "bypass_mode": "always" - } - ], "conditions": { "ref_name": { "exclude": [], From 27f3d1ff4c8c21e3d545a4bb7c49757271f096e4 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 3 Aug 2026 16:30:49 -0700 Subject: [PATCH 05/11] Put the repo's own conventions where the section model sends them (#14) * Put the repo's own conventions where the section model sends them `AGENTS.md > Project Conventions` was a section the hub does not declare, which the audit flags as drift to reconcile. Reconciling it found that most of it was already reconciled elsewhere. Three of the four rules were near-verbatim duplicates of entries already in `TODO.md` Traps: never populating media over HTTP, `content/` being an imported archive, and a gate proving itself by failing. Duplicated rules are worse than absent ones, because the two copies drift and nothing says which is current, so those three are deleted here rather than moved. The fourth was unique and is the load-bearing one. The URL contract being ground truth and its lists being append-only appeared nowhere else, and in particular not in `OPERATIONS.md`, which operates that contract through the whole local-verification and deploy path without ever stating the invariant behind it. It moves there, ahead of the verification section that depends on it, and picks up the length-floor clause that `TODO.md` carried separately, since a floor is what makes append-only checkable rather than aspirational. `AGENTS.md` is left carrying only the three sections the hub declares, and its intro drops from four things to three, which is what the hub's own intro says. Note this does not resolve ProjectTemplate#552. That contradiction is about the byte-locked `Fleet Bootstrap` section naming the template repo, which is untouched here and still flagged. ## Verification - Every verbatim unit still byte-matches the canonical, so removing the local section disturbed none of the carried ones. - markdownlint, editorconfig-checker, and the prose gate are clean. Co-Authored-By: Claude Opus 5 (1M context) * Point at the doc that owns a convention, now that one moved The preamble said this project's own conventions live "here", which was true while `Project Conventions` was in this file and stopped being true in the same change that moved it out. An agent reading the sentence would look for repo invariants in `AGENTS.md` and find none. The sentence's actual point is provider independence, that a rule a reviewer must honor cannot live in the Copilot-specific file, and that point survives intact. It now names the topical docs that own the content instead of claiming this file does. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- AGENTS.md | 13 ++----------- OPERATIONS.md | 6 ++++++ 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 67935ee..dc41645 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,18 +2,9 @@ **Blog** is the source and deployment pipeline for a personal technical blog, a Hugo static site served by Caddy behind a reverse proxy. It holds the content, the media, the URL contract the site must honor, and the release tooling that builds and publishes it. -This file is the entry point every coding agent reads first, and it holds only four things: this project's own conventions, the bootstrap that says where the canonical rules live and which procedure to follow for the state this repository is actually in, the rules for managing context and delegation, which apply to every task, and a map of where every other rule lives. The rule text itself is in [`GOVERNANCE.md`](./GOVERNANCE.md), one section per topic. Code style lives in [`CODESTYLE.md`](./CODESTYLE.md), the CI/CD workflow contract in [`WORKFLOW.md`](./WORKFLOW.md), and the deploy, rollback, and server procedures in [`OPERATIONS.md`](./OPERATIONS.md). +This file is the entry point every coding agent reads first, and it holds only three things: the bootstrap that says where the canonical rules live and which procedure to follow for the state this repository is actually in, the rules for managing context and delegation, which apply to every task, and a map of where every other rule lives. The rule text itself is in [`GOVERNANCE.md`](./GOVERNANCE.md), one section per topic. Code style lives in [`CODESTYLE.md`](./CODESTYLE.md), the CI/CD workflow contract in [`WORKFLOW.md`](./WORKFLOW.md), and the deploy, rollback, and server procedures in [`OPERATIONS.md`](./OPERATIONS.md). -Treat this file and `GOVERNANCE.md` as authoritative for cross-cutting rules, and do not restate their rules elsewhere. This project's own conventions and behavioral contracts live here, **not** in [`.github/copilot-instructions.md`](./.github/copilot-instructions.md), because that file targets GitHub Copilot / VS Code specifically, while this file and `GOVERNANCE.md` are the agent-agnostic ones every coding agent is directed to read, so any rule a reviewer must honor has to live in one of those two files to be provider-independent. - -## Project Conventions - -This site has served the same domain across earlier platforms, so its whole risk is silent URL loss. These bind every change. - -- **The URL contract is ground truth.** [`checks/golden-urls.txt`](./checks/golden-urls.txt) and [`checks/redirect-urls.txt`](./checks/redirect-urls.txt) record URLs verified with a live request. The lists are append-only. Nothing legitimately removes a URL the site has served. -- **Never populate media over HTTP.** The source export is the only trustworthy media source, because a hosted platform serves optimized derivatives at the same filename and path. Verify by content hash, never by file count. -- **`content/` is an archive.** It carries sixteen years of text. Prose, spelling, and style sweeps do not reach it, and rewriting it corrupts provenance rather than improving style. -- **A gate proves itself by failing.** Every check here is demonstrated against a deliberate break before it is trusted, because a gate that has only ever passed is indistinguishable from one that checks nothing. +Treat this file and `GOVERNANCE.md` as authoritative for cross-cutting rules, and do not restate their rules elsewhere. This project's own conventions and behavioral contracts live in the topical doc that owns them, [`OPERATIONS.md`](./OPERATIONS.md) for how the site is run and verified and [`CODESTYLE.md`](./CODESTYLE.md) for how it is written, and **never** in [`.github/copilot-instructions.md`](./.github/copilot-instructions.md), because that file targets GitHub Copilot / VS Code specifically while the rest are agent-agnostic, so any rule a reviewer must honor has to live outside it to be provider-independent. ## Fleet Bootstrap diff --git a/OPERATIONS.md b/OPERATIONS.md index d1a446b..ce954c0 100644 --- a/OPERATIONS.md +++ b/OPERATIONS.md @@ -29,6 +29,12 @@ 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. +## The URL Contract + +This site has served the same domain across earlier platforms, so its whole operational risk is silent URL loss. Everything below exists to make that risk visible. + +**The contract is ground truth.** [`checks/golden-urls.txt`](./checks/golden-urls.txt) and [`checks/redirect-urls.txt`](./checks/redirect-urls.txt) record URLs verified with a live request, not predicted from the content tree. The lists are **append-only**: nothing legitimately removes a URL the site has served, so a change that would drop one is a change to reject rather than a list to shorten. A list-driven check also carries a length floor, or a truncated list passes while checking almost nothing. + ## 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. From 9c43aaef2fc95e27418433bfbfc0713b8397f0c3 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 3 Aug 2026 16:38:24 -0700 Subject: [PATCH 06/11] Record the hub conformance state and what is open against the hub (#15) * Record the hub conformance state and what is open against the hub This repo reconverged with the fleet hub on 2026-08-03, and four findings are open at the hub afterwards. None is work this repo can do, and each changes what a fleet audit of this repo means, so a reader who runs one and sees two DEFECTs needs the reason in the repo rather than only in an issue tracker. The State table gains a fleet-conformance row. A new section records where conformance stands, links the hub registry entry and the audit report the hub authored, and lists the four open issues with what each means here rather than restating the issue. It also records that the live ruleset bypass is deliberate and stays. The owner is automatically an admin and holds that capability anyway, so the entry grants nothing new, and the payloads stopped declaring it because code should not be in the business of granting a bypass at all. That reasoning existed only in conversation, which is exactly the kind of thing a later audit misreads as drift. ## Two traps The carried-file trap gains its second half: a reviewer finding a real defect in a verbatim file is answered by declining locally and filing it at the hub, never by editing the file. That happened this session on a correct jq portability finding, and editing would have broken the fleet audit that governs the file. The new one is that the hub's `main` can promote while a convergence pull request is open, so ground truth moves underneath work that was correct when it started. It happened twice in one session, and the second time added drift the branch could not have known about. The defense is to re-run the audit against the hub ref actually carried and name that ref in the change. Co-Authored-By: Claude Opus 5 (1M context) * Name the hub ref this record was measured against, and pin to it The section claimed a byte-match against the canonical, linked the hub by `main`, and then two entries below warned that `main` moves under work that was correct when it started. A record that cannot say which ref it describes is the failure its own trap names. It now states hub `main` `3b802b9`, and both hub links are pinned to that ref rather than to `main`, so the claim stays checkable after the hub moves again. Re-measuring for the ref turned up one exception worth recording rather than rounding away. Every verbatim section, both ruleset payloads, and `.markdownlint-cli2.jsonc` match. `repo-config/configure.sh` is one commit behind, on the change that fixes the jq portability defect this repo reported, so the re-vendor closing that loop is still owed. Found by a Copilot review that read the claim against the trap two entries below it. Co-Authored-By: Claude Opus 5 (1M context) * Pin the hub ref by full SHA, since a short one can go ambiguous GitHub resolves an abbreviated SHA only while it stays unique, and a `blob//` link built on one breaks when the hub's history grows enough to collide. That is a record designed to expire, in the section whose whole point is staying checkable after the hub moves. All three references take the full 40 characters, the two link definitions and the ref named in the prose. Found by a suppressed low-confidence Copilot finding, the fourth this session that was correct. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- TODO.md | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/TODO.md b/TODO.md index 754b74d..acd923d 100644 --- a/TODO.md +++ b/TODO.md @@ -14,6 +14,7 @@ The site is built and gated in CI. It is on GitHub, and it is not yet serving it | 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 | | Release pipeline | proven end to end. Release `1.0.11` carries the tag, source archive, README, and LICENSE | +| Fleet conformance | cataloged in the hub registry, audited, and carrying the current canonical | | VPS | untouched | ## Blocked on the maintainer @@ -44,6 +45,23 @@ Both are recorded in [AUDIT.md](./AUDIT.md) and reported to [ProjectTemplate#456 - `lineEndings: "lf"` on a `release` repo, where the rule grants the native-platform default to operational repos only. Every consumer here is Linux. - `types: ["source-only"]` rather than the `docs` the hub proposed, because both `docs` predicates are false for a repo that builds a site and gates a URL contract. +## Hub conformance, and what is open against the hub + +Reconverged 2026-08-03. This repo is cataloged in [`registry/repos.json`][hub-registry] and the hub authored [`reports/blog/audit.md`][hub-report]. Before that it was in no registry, so no hub tool had ever measured it and the fleet ledger under-counted by exactly this repo. + +**Measured against hub `main` `3b802b9eb9a841c0149d018f4db6ffa1b9419051`**, and the ref is named because `main` moves, which is the trap below. Every verbatim section of `AGENTS.md` and `GOVERNANCE.md` byte-matches, as do both ruleset payloads and `.markdownlint-cli2.jsonc`. The one exception is `repo-config/configure.sh`, one commit behind on [ProjectTemplate#553][pr-553], which fixes the jq portability defect this repo reported as [#549][issue-549] and is owed a re-vendor. Both links above are pinned to that same ref rather than to `main`, so this record stays checkable after the hub moves again. + +Four findings are open at the hub. None is work this repo can do, and each changes what a fleet audit of this repo means, which is why they are recorded here rather than only in the issues. + +| Issue | What it means here | +| --- | --- | +| [#550][issue-550] | Nothing detects a repo missing from the registry, which is how this repo stayed invisible. Three other repos are still absent. | +| [#552][issue-552] | The audit flags any carried `AGENTS.md` naming the template repo, and the byte-locked `Fleet Bootstrap` section names it. Carrying the canonical correctly cannot pass. | +| [#554][issue-554] | `spec/audit.py` still compares `bypass_actors` after the payloads stopped declaring it, so this repo reports two DEFECTs that no agent action can clear. | +| [#456][hub-issue] | The static-site type, still waiting on a measured deploy shape from the VPS work below. | + +**The live ruleset bypass is deliberate and stays.** Both rulesets carry the `RepositoryRole` admin entry. The owner is automatically an admin and holds that capability regardless, so the entry grants nothing new, and the payloads stopped declaring it because code should not be in the business of granting a bypass at all. `configure.sh check` reports it as unmanaged and exits 0. Only `spec/audit.py` disagrees, which is [#554][issue-554]. + ## Traps Each of these was hit or nearly hit, and each is cheap to re-trip. @@ -56,7 +74,8 @@ Each of these was hit or nearly hit, and each is cheap to re-trip. - **`content/` is an imported archive.** Prose, spelling, and style sweeps do not reach it, and `cspell.json` ignores it deliberately. - **A gate is trusted only after it has been demonstrated failing.** Every gate here has been. A list-driven check also needs a length floor, or a truncated list passes while checking almost nothing. - **Do not name any workflow `build-*-task.yml`** while the repo declares `source-only`, since `detect` is literally `["no build-*-task.yml"]`. -- **Do not edit `.markdownlint-cli2.jsonc`, `repo-config/configure.sh`, or the two ruleset payloads.** They are carried verbatim and byte-matched against the hub. Scope a glob in the workflow instead. +- **Do not edit `.markdownlint-cli2.jsonc`, `repo-config/configure.sh`, or the two ruleset payloads.** They are carried verbatim and byte-matched against the hub. Scope a glob in the workflow instead. A reviewer finding a real defect in one of them is answered by declining locally and filing it at the hub, never by editing the file to satisfy the review. +- **The hub's `main` can promote while a convergence pull request is open**, so ground truth moves underneath work that was correct when it started. It happened twice in one session on 2026-08-03, and the second time added drift the branch could not have known about. Re-run the audit against the hub ref actually carried before claiming convergence, and name that ref in the change, or the claim ages into a false one. - **`gh pr merge --delete-branch` on a `develop -> main` promotion deletes `develop`.** Use a plain `gh pr merge --merge`. ## Reference @@ -89,3 +108,10 @@ Secrets and variables, per environment. The App-token pair is repository-scoped [hub-issue]: https://github.com/ptr727/ProjectTemplate/issues/456 +[hub-registry]: https://github.com/ptr727/ProjectTemplate/blob/3b802b9eb9a841c0149d018f4db6ffa1b9419051/registry/repos.json +[hub-report]: https://github.com/ptr727/ProjectTemplate/blob/3b802b9eb9a841c0149d018f4db6ffa1b9419051/reports/blog/audit.md +[issue-549]: https://github.com/ptr727/ProjectTemplate/issues/549 +[issue-550]: https://github.com/ptr727/ProjectTemplate/issues/550 +[issue-552]: https://github.com/ptr727/ProjectTemplate/issues/552 +[issue-554]: https://github.com/ptr727/ProjectTemplate/issues/554 +[pr-553]: https://github.com/ptr727/ProjectTemplate/pull/553 From 4b2def3ee9ebd039c0e1345c6bd8b221320f507f Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Tue, 4 Aug 2026 14:31:33 -0700 Subject: [PATCH 07/11] Deploy to an environment and prove the release is the one answering (#16) * Address two environments instead of one The release tooling could name a single deploy root, which was enough while there was one mirror and will not be once the VPS grows a staging site and a production site. Nothing here could address two at a time. The blocker was not the obvious one. make-release.sh sourced secrets/.env with `set -a`, which exports every assignment in the file and overwrites a DEPLOY_ROOT the caller exported first, so sourcing a different environment file silently deployed to the original root. On a host serving two sites that failure is not an error, it publishes to the other site. ENV_FILE now selects the file, the first argument still overrides the root because it is read afterwards, and a named file that does not exist is a hard failure rather than a fall-through to whatever the ambient environment holds. check-live-urls.sh could not authenticate, so it could not check a site that keeps its auth gate on. Staging keeps its gate on deliberately: it serves a byte-identical copy of the public site, and an open one is a duplicate handed to every crawler, which is the one thing a migration about preserving URLs should not do. A Pangolin resource access token opens it instead. The credential travels in a mode-600 curl config file rather than in -H arguments. A command line is readable in ps for the life of the process and this runs 1,245 of them, and the config file is also the only form that survives the `export -f` the parallel checks run under, since bash cannot export an array. It is sent to the base URL's own origin and nowhere else, so a rule that one day redirects off-site cannot carry it away. A preflight request runs first, because behind an auth gate a wrong token fails every URL and the output then reads as a site that has vanished rather than as a bad credential. Two decisions the docs asserted the opposite of, both now recorded with their reasoning rather than silently reversed. One deploy key covers both environments instead of one per environment: the split pays off only where the two keys never share a machine, and both sit on one workstation and in one secret store. The cost is that the rrsync forced command can no longer separate the environments, so the deploy roots move under a common parent and one pinned command covers both. And staging takes the VPS wildcard at blog.vps.insanegenius.net, which needs no new certificate and closes the open FQDN decision. Verified against both local mirrors, which are two containers in a sibling repository's new blog stack: 1,245 URLs honored on each, each baking its own base URL, neither disturbing the other's release history. Each new gate was demonstrated failing before being trusted, per the rule in TODO.md. Co-Authored-By: Claude Opus 5 (1M context) * Deploy to an environment and prove the release is the one answering A release reaches a host by rsync and no restart, so nothing outside the verifier observes whether the rules that answer are the rules shipped. Content follows the current symlink per request and is live immediately, while rules wait on an in-process config reload. A container that never reloads therefore serves new content under a previous release's redirects, and the URL contract passes against a config that was never deployed. The bundle stamps its own version, substituted at install time, and the check compares it before requesting any of the 1,245 URLs. It waits rather than sampling once, because the reload is asynchronous and a check run straight after a deploy races it. The timeout still catches a container that never converges. Caddy also stops watching permanently after one failed config load, without logging that it has given up, so anything that breaks the symlink even briefly ends that container's ability to pick up releases until it is restarted. The failure reports as healthy, which is why the verifier names both causes. Two more headers identify the environment, since every environment serves one bundle on one port and only the container distinguishes them. Robots defaults to the value that is harmless on production, because the failures are asymmetric: an unset staging container is still behind its auth gate, where production inheriting noindex would deindex the site silently. trusted_proxies takes its ranges from the container, and excludes the bridge gateway. Trusting the subnet trusts the host, which was verified by forging a client address from it. Co-Authored-By: Claude Opus 5 (1M context) * Anchor ENV_FILE under the repo and correct the restart note A relative ENV_FILE resolved against the caller's working directory while the default resolved against the repo, so the same name meant different files depending on where the script ran. The container comment described a restart as the way a config change goes live, which the in-process reload replaced. Co-Authored-By: Claude Opus 5 (1M context) * Scope the staging-only secrets and correct the environment prose The manifest listed the access token beside a credential every environment carries, which reads as required in both. A name audit would then report it missing from production, where it is deliberately absent. The README described configuration as coming from one file, which the per-environment selection replaced. Co-Authored-By: Claude Opus 5 (1M context) * Assert the release placeholder and constrain the environment name sed reports success when it matches nothing, so a Caddyfile that lost the placeholder shipped unstamped and the live check then blamed a dead config watcher for a bundle that never carried a release id. The environment name selects a GitHub Environment and lands in a remote path, and a workflow_call caller is not bound by the dispatch choice list. It is asserted in its own job, because the environment binding resolves before any step in the deploying job runs. Co-Authored-By: Claude Opus 5 (1M context) * Record the reviewer login trap that reads as a clean review Co-Authored-By: Claude Opus 5 (1M context) * Describe the accepted statuses, the env file, and the default name The redirect failure named one status while the check accepted two, and the usage text described a deploy root the environment file selection replaced. The setup instruction named only the per-environment form, so a single environment produced a file nothing reads. Co-Authored-By: Claude Opus 5 (1M context) * Require an origin boundary before sending the access token The base URL has its trailing slash stripped, so a prefix test also matched a host that merely starts with it. A redirect to a lookalike registered as an attacker's subdomain would have received the credential. Co-Authored-By: Claude Opus 5 (1M context) * Constrain the release version to a path-safe and substitution-safe form The value becomes a directory name, a symlink target, and a sed replacement, so a separator, a traversal, or a substitution metacharacter would place the release outside releases/ or corrupt the release stamp. Co-Authored-By: Claude Opus 5 (1M context) * Record the deploy scripts' Linux dependency ceiling The scripts use GNU and bash 4.4 constructs that a stock macOS lacks, and the platform rule requires a narrowed platform to be recorded so it reads as a dependency ceiling rather than an oversight. Co-Authored-By: Claude Opus 5 (1M context) * Report an unreachable host as transport rather than as a status A name that does not resolve produced a status code, which the preflight then explained as a bad credential or a broken symlink. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- .github/workflows/deploy-site-task.yml | 148 ++++++++++++++++++++ .github/workflows/deploy-site.yml | 50 +++++++ OPERATIONS.md | 118 +++++++++++++--- README.md | 46 +++---- TODO.md | 21 ++- checks/check-live-urls.sh | 130 ++++++++++++++++-- deploy/Caddyfile | 24 ++++ deploy/README.md | 183 +++++++++++++++++++++++-- deploy/env.example | 59 ++++++-- deploy/make-release.sh | 63 ++++++++- spec/secrets.json | 12 +- 11 files changed, 766 insertions(+), 88 deletions(-) create mode 100644 .github/workflows/deploy-site-task.yml create mode 100644 .github/workflows/deploy-site.yml diff --git a/.github/workflows/deploy-site-task.yml b/.github/workflows/deploy-site-task.yml new file mode 100644 index 0000000..8fd7f9e --- /dev/null +++ b/.github/workflows/deploy-site-task.yml @@ -0,0 +1,148 @@ +name: Deploy site task + +on: + workflow_call: + inputs: + environment: + description: The GitHub Environment to deploy to, production or staging. + required: true + type: string + +env: + # Pinned by version and checksum, because the site is reproducible only if the generator is. + # Update both values together. + HUGO_VERSION: 0.164.0 + HUGO_SHA256: 8325f3653032d0fc536503691f4833dc4eb6c6be02ee62466758f3f37a7f2fcd + +jobs: + + # The name selects a GitHub Environment and lands in a remote path, and a workflow_call caller + # is not bound by the dispatch choice list. + # A separate job, because the environment binding below resolves before any step runs. + assert-environment: + name: Assert environment name job + runs-on: ubuntu-latest + steps: + - name: Assert environment is known step + env: + ENVIRONMENT: ${{ inputs.environment }} + run: | + set -Eeuo pipefail + case "$ENVIRONMENT" in + production | staging) ;; + *) + echo "::error::environment must be production or staging; got '$ENVIRONMENT'." + exit 1 + ;; + esac + + # Host-specific values come from the environment, so this file names no host, path, or address. + deploy: + name: Deploy site job + runs-on: ubuntu-latest + needs: [ assert-environment ] + environment: ${{ inputs.environment }} + permissions: + contents: read + + steps: + + # Full history, because a shallow clone silently changes page metadata if git info is on. + - name: Checkout code step + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + + - name: Install Hugo step + run: | + set -Eeuo pipefail + deb="hugo_extended_${HUGO_VERSION}_linux-amd64.deb" + curl -sSLf -o "$deb" \ + "https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/${deb}" + echo "${HUGO_SHA256} ${deb}" | sha256sum --check --strict + sudo dpkg --install "$deb" + hugo version + + # REQUIRE_BROTLI below makes a missing binary fatal, so this keeps the build from failing. + - name: Install brotli step + run: | + set -Eeuo pipefail + sudo apt-get update + sudo apt-get install --yes --no-install-recommends brotli + + # Derived once and used three times, as the directory name, the stamp, and EXPECT_RELEASE. + # Deriving it twice yields ids seconds apart, and the gate then asserts a phantom version. + - name: Resolve release id step + id: release + run: | + set -Eeuo pipefail + echo "id=$(date -u +%Y%m%d-%H%M%S)" >> "$GITHUB_OUTPUT" + + # Assembled to a scratch path, since the environment's deploy root is on the far host. + # Naming the root explicitly also marks this a bundle for shipping rather than an install. + - name: Assemble release bundle step + env: + HUGO_BASEURL: ${{ vars.HUGO_BASEURL }} + REQUIRE_BROTLI: '1' + run: | + set -Eeuo pipefail + deploy/make-release.sh "${RUNNER_TEMP}/bundle" "${{ steps.release.outputs.id }}" + + - name: Install deploy key step + env: + DEPLOY_SSH_PRIVATE_KEY: ${{ secrets.DEPLOY_SSH_PRIVATE_KEY }} + DEPLOY_SSH_KNOWN_HOSTS: ${{ vars.DEPLOY_SSH_KNOWN_HOSTS }} + run: | + set -Eeuo pipefail + mkdir -p ~/.ssh + chmod 700 ~/.ssh + printf '%s\n' "$DEPLOY_SSH_PRIVATE_KEY" > ~/.ssh/deploy + chmod 600 ~/.ssh/deploy + printf '%s\n' "$DEPLOY_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts + chmod 600 ~/.ssh/known_hosts + + # The destination is anchored at the key's confinement root. + # A full host path is remapped beneath that root and fails as an IO error. + # - link-dest points at current, which still resolves to the previous release until the flip. + # - mkpath creates releases/, which does not exist on a fresh environment. + # - delete is omitted, since at an environment root it silently removes rollback targets. + - name: Upload release step + env: + DEPLOY_SSH_USER: ${{ vars.DEPLOY_SSH_USER }} + DEPLOY_SSH_HOST: ${{ vars.DEPLOY_SSH_HOST }} + RELEASE_ID: ${{ steps.release.outputs.id }} + ENVIRONMENT: ${{ inputs.environment }} + run: | + set -Eeuo pipefail + rsync -az --mkpath --chmod=D755,F644 \ + --link-dest="/${ENVIRONMENT}/current/" \ + -e "ssh -i ~/.ssh/deploy -o IdentitiesOnly=yes" \ + "${RUNNER_TEMP}/bundle/releases/${RELEASE_ID}/" \ + "${DEPLOY_SSH_USER}@${DEPLOY_SSH_HOST}:/${ENVIRONMENT}/releases/${RELEASE_ID}/" + + # Separate from the upload, so a failed transfer cannot half-publish a site. + # rsync replaces the symlink through a temporary and a rename, so it is never absent. + - name: Flip current step + env: + DEPLOY_SSH_USER: ${{ vars.DEPLOY_SSH_USER }} + DEPLOY_SSH_HOST: ${{ vars.DEPLOY_SSH_HOST }} + ENVIRONMENT: ${{ inputs.environment }} + run: | + set -Eeuo pipefail + rsync -a --no-recursive \ + -e "ssh -i ~/.ssh/deploy -o IdentitiesOnly=yes" \ + "${RUNNER_TEMP}/bundle/current" \ + "${DEPLOY_SSH_USER}@${DEPLOY_SSH_HOST}:/${ENVIRONMENT}/" + + # The only step that observes the running site. + # An upload succeeds against a container serving nothing, and a flip without a config reload. + # The token pair is set on staging alone, since production answers unauthenticated. + - name: Verify URL contract step + env: + EXPECT_SITE_ENV: ${{ inputs.environment }} + EXPECT_RELEASE: ${{ steps.release.outputs.id }} + PANGOLIN_ACCESS_TOKEN_ID: ${{ secrets.PANGOLIN_ACCESS_TOKEN_ID }} + PANGOLIN_ACCESS_TOKEN: ${{ secrets.PANGOLIN_ACCESS_TOKEN }} + run: | + set -Eeuo pipefail + checks/check-live-urls.sh "${{ vars.HUGO_BASEURL }}" diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml new file mode 100644 index 0000000..f6e689c --- /dev/null +++ b/.github/workflows/deploy-site.yml @@ -0,0 +1,50 @@ +name: Deploy site action + +on: + workflow_dispatch: + inputs: + environment: + description: Which environment to deploy. + required: true + type: choice + options: + - staging + - production + +# Runs queue rather than cancel, because a cancelled deploy leaves a release uploaded and unflipped. +concurrency: + group: ${{ github.workflow }}-${{ inputs.environment }} + cancel-in-progress: false + +jobs: + + # The same gate the pull request and a release run. + validate: + name: Validate sources job + uses: ./.github/workflows/validate-task.yml + permissions: + contents: read + + # Staging deploys from any ref, since proving a branch before it merges is what staging is for. + # Asserted before Hugo is installed and before the key reaches the runner. + assert-ref: + name: Assert deploy ref job + runs-on: ubuntu-latest + steps: + - name: Assert ref matches environment step + run: | + set -Eeuo pipefail + if [ "${{ inputs.environment }}" = "production" ] && [ "${{ github.ref_name }}" != "main" ]; then + echo "::error::Deploy production from main; got ${{ github.ref_name }}." + exit 1 + fi + + deploy: + name: Deploy site job + needs: [ validate, assert-ref ] + uses: ./.github/workflows/deploy-site-task.yml + with: + environment: ${{ inputs.environment }} + permissions: + contents: read + secrets: inherit diff --git a/OPERATIONS.md b/OPERATIONS.md index ce954c0..0003b31 100644 --- a/OPERATIONS.md +++ b/OPERATIONS.md @@ -4,13 +4,24 @@ How this site is built, released, served, and rolled back. [`GOVERNANCE.md`](./G ## Environments +Four environments, in two pairs. Each pair is one publish site and one staging site, and the local pair exists to rehearse the remote one. + | Environment | Address | Fronted by | Purpose | | --- | --- | --- | --- | -| Local mirror | a private hostname, set in `secrets/.env` | a reverse proxy on the maintainer's own network | Proves the artifact. The redirect rules, the maps, and the release mechanics. | -| Staging | on the VPS, behind the auth gate | Pangolin | Proves the infrastructure. Routing, TLS, and the deploy path. | +| Local publish mirror | a private hostname, set in `secrets/.env` | Traefik, on the maintainer's own network | Proves the artifact. The redirect rules, the maps, and the release mechanics. | +| Local staging mirror | a second private hostname, set in `secrets/staging.env` | Traefik | Proves that two environments on one host stay independent, before that matters on a server. | +| Staging | `blog.vps.insanegenius.net`, behind the auth gate | Pangolin | Proves the infrastructure. Routing, TLS, and the deploy path. | | Production | `blog.insanegenius.com` | Pangolin | The public site. | -The local mirror is not staging. It runs the same bundle against the same web server, so it catches a broken redirect or a bad permission for free, but it exercises none of the routing, authentication, or certificate machinery that only exists on the VPS. Passing locally says the artifact is right. It says nothing about whether the server in front of it is. +The local mirrors are not staging. They run the same bundle against the same web server, so they catch a broken redirect or a bad permission for free, but they exercise none of the routing, authentication, or certificate machinery that only exists on the VPS. Passing locally says the artifact is right. It says nothing about whether the server in front of it is. + +**The two words are `production` and `staging`, spelled out, in every position.** No `prod`, no `stage`. The same two name the container, the deploy root, the environment file, the `X-Blog-Env` value, and the GitHub Environment. This is not tidiness: the environment name is a value that gets **compared**, by `EXPECT_SITE_ENV` and by the deploy, so a spelling that differs in one position fails a deploy for a reason that reads like an outage. The local mirrors prefix the same words, `mirror-production` and `mirror-staging`, so a header names exactly one of the four environments in the fleet. + +Each environment is one file under `secrets/`, selected with `ENV_FILE`, holding the deploy root, the base URL, and the container name. Selecting the file is how an environment is chosen: the file is sourced with `set -a`, so it overwrites a `DEPLOY_ROOT` the caller exported and setting that variable by hand does not switch anything. A named file that does not exist is a hard failure rather than a fall-through, because on a host serving two sites the ambient value is the other site's root. + +**The staging FQDN sits under the VPS wildcard deliberately.** `blog.vps.insanegenius.net` needs no new certificate and no new DNS record, and it keeps the staging name off the production domain. + +**Staging keeps its auth gate on.** It serves a byte-identical copy of the public site, so exposing it publicly would hand every crawler a duplicate of a site whose entire migration risk is URL preservation. `checks/check-live-urls.sh` gets through with a Pangolin resource access token instead. See [Checking a Site Behind the Auth Gate](#checking-a-site-behind-the-auth-gate). ## The Release Bundle @@ -51,17 +62,36 @@ 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. +Against the staging mirror, name its file in both places, since the sourced values and the ones `make-release.sh` reads must describe the same environment: + +```sh +set -a; . secrets/staging.env; set +a +ENV_FILE=secrets/staging.env deploy/make-release.sh +checks/check-live-urls.sh "$HUGO_BASEURL" +``` + +**There is no restart step, and that depends on one flag.** The container runs `caddy run --watch`, which re-adapts the config on a timer and reloads it in process. Re-adapting re-executes every `import`, so a new release's `Caddyfile` and `maps/*.map` are picked up through the unchanged `/config/Caddyfile` that the watcher actually names. Measured on this host: content is live the instant the symlink moves, and the rules follow within about a quarter of a second. + +**The watcher dies silently after one failed config load.** Verified: a flip to a valid release reloads, a flip to a missing one logs the failure and retains the last good config, and a flip back to a valid release **never reloads again**. Nothing in the log says it has given up. Every later deploy then lands content without its rules, which is the failure this section's release stamp exists to catch, and a restart is the only fix. Anything that breaks `current` even briefly, including a test, ends that container's ability to pick up releases. -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. +**`--watch` needs no admin API**, which is the part worth knowing, because `admin off` makes `caddy reload` impossible and that looks like it should rule out reloading altogether. It does not. `caddy reload` POSTs to the admin endpoint; the watcher reloads in process and never uses it. The log prints `admin endpoint disabled` and `watching config file for changes` together. -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. +**Without that flag the failure is silent and specific.** Caddy expands `import` at config-parse time and does not watch the imported files, so swapping `current` changes what a *static file* request resolves to, per request, while the redirect rules and map tables stay as they were when Caddy last loaded. Verified both ways against a two-release fixture whose `Caddyfile` was byte-identical and whose map differed: with `--watch`, a flip moved a redirect from 301 to 404 and its replacement from 404 to 301. Without it, neither moved. -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. +That is why the check verifies the config rather than trusting it. **When the release changed `deploy/Caddyfile` or anything under `deploy/maps/`**, a check run against stale rules exercises the **previous** config, and a broken redirect reports `PASS` while the shipped artifact is broken. The wrong answer is a green check rather than an error, which is the worst shape a failure can take here. + +So the bundle stamps its own version as `X-Blog-Release`, and `check-live-urls.sh` compares it against `EXPECT_RELEASE` before checking a single URL. It **waits** for a match rather than sampling once, because the reload is asynchronous and a check that starts immediately after a deploy will otherwise race it. The timeout is what still catches a container that is not watching at all, since that one never converges: + +```sh +EXPECT_RELEASE= checks/check-live-urls.sh "$HUGO_BASEURL" +``` + +Sourcing the environment file 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. + +`ENV_FILE` is set as well as sourced, and the redundancy is deliberate. The script sources its own file regardless, so leaving `ENV_FILE` off would build and install against `secrets/.env` while the shell's `$HUGO_BASEURL` still named staging, and the run would check the staging site after publishing to the production root. The script prints the file it read, on every build, for that reason. 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. @@ -69,6 +99,28 @@ Expect `PASS - 1245 URLs honored`. Anything less is a finding, and the output na 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. +## Checking a Site Behind the Auth Gate + +Staging keeps Pangolin's authentication on, so an unauthenticated request never reaches the site. `check-live-urls.sh` presents a Pangolin resource access token when both halves of the pair are set, and sends nothing when neither is: + +```sh +set -a; . secrets/staging.env; set +a +checks/check-live-urls.sh "$HUGO_BASEURL" +``` + +| Variable | Header | +| --- | --- | +| `PANGOLIN_ACCESS_TOKEN_ID` | `P-Access-Token-Id` | +| `PANGOLIN_ACCESS_TOKEN` | `P-Access-Token` | + +Set both or neither. Half a pair is a typo rather than a choice, and it is rejected as one rather than presented as a failing site. + +Three properties of how the credential is handled, each there for a reason worth keeping: + +- **It travels in a mode-`600` curl config file, not in `-H` arguments.** A command line is readable in `ps` for the life of the process, and this runs 1,245 of them. The config file is also the only form that survives the `export -f` the parallel checks run under, because bash cannot export an array. +- **It is sent to the base URL's own origin and nowhere else.** The check follows every redirect's destination, and every destination in the contract is same-origin today. A rule that one day points off-site must not mail the credential to whoever is on the other end. +- **A preflight request runs before the 1,245.** Behind an auth gate a wrong token fails *every* URL, and the output then reads as a site that has vanished rather than as a bad credential. The two are indistinguishable from the far end of a CI log, so the run stops on the first request with a message naming which of the two it was. + ## Deploying ```sh @@ -76,12 +128,13 @@ HUGO_BASEURL= deploy/make-release.sh "$(git rev-parse -- checks/check-live-urls.sh ``` -The deploy root and the base URL are the only host-specific values. A local run reads them from an untracked `secrets/.env`, copied from [`deploy/env.example`](./deploy/env.example), and CI passes both explicitly. The whole `secrets/` directory is gitignored, so no address, path, or container name belonging to one machine reaches the published history. +The deploy root and the base URL are the only host-specific values. A local run reads them from an untracked file under `secrets/`, one per environment, copied from [`deploy/env.example`](./deploy/env.example), and CI passes both explicitly. The whole `secrets/` directory is gitignored, so no address, path, or container name belonging to one machine reaches the published history. **Always set `HUGO_BASEURL` for anything that is not production.** The base URL is baked into the canonical tag, the feed links, and every absolute permalink, so a mirror built without it serves pages that all point back at the production address. Nothing downstream catches this, because the pages render at the right paths and the build gate passes. The effective value is printed on every build for that reason. | Variable | Effect | | --- | --- | +| `ENV_FILE` | Which environment file to source. Defaults to `secrets/.env`. | | `DEPLOY_ROOT` | Fallback deploy root. The first argument wins. | | `HUGO_BASEURL` | Overrides the site base URL. | | `REQUIRE_BROTLI=1` | Fails rather than shipping gzip-only. CI sets this. | @@ -96,12 +149,13 @@ 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" ``` -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 content reverts on the rename alone, because the container mounts the parent directory and the kernel resolves `current` per request. The rules follow on the watcher's next poll, within about a quarter of a second, since a rollback is a config change like any other and re-adapting re-reads the reverted release's `Caddyfile` and maps. + +**For that fraction of a second the reverted content is served under the newer release's rules.** That is the same window every deploy has, in the other direction, and it is harmless while every rule is a redirect: a stale redirect sends a visitor to a page that exists in both releases. It would stop being harmless if a rule ever *gated* content rather than redirecting it, and at that point the flip has to become a restart again. -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 `EXPECT_RELEASE` set to the release being rolled back **to**, which is what proves the rules actually reverted rather than assuming they did. Verify with `checks/check-live-urls.sh` against the environment before considering the rollback finished. @@ -111,6 +165,20 @@ Ten releases are kept. Unchanged files hard-link to the previous release, so the The script asserts both halves of that rather than assuming them. It fails when the prune leaves more releases than the limit, and when hard-linking produces no shared files at all. Both have failed silently before, and on a compressing filesystem the disk usage looks plausible either way. +## Who Owns What + +The site and the server it runs on are maintained separately, so the boundary is written down rather than inferred. This repo owns the artifact and what proves it correct; the host owns where a release may be written and what happens to it afterwards. + +| This repo | The host | +| --- | --- | +| The GitHub Actions workflow | The SSH endpoint and its forced command | +| `deploy/make-release.sh`, the bundle layout, and the Caddyfile inside it | The bootstrap `import`, the containers, and their environment variables | +| `checks/check-live-urls.sh` and the URL contract | Config-watchdog and release-prune timers | +| The release id and the `@@RELEASE@@` stamp | Proxy resources, routing, tokens, and TLS | +| What a release contains | Where a release may be written, and what happens after | + +The two meet at the container contract in [`deploy/README.md`](./deploy/README.md#container-contract). A defect on the host side is fixed on the host; a pipeline that needs the contract to say something different asks for a contract change rather than growing a second copy of the other side's work. + ## Serving Caddy serves the bundle and binds an internal port only. TLS and the public listener belong to the proxy in front of it, so `auto_https off` and `admin off` are deliberate. @@ -119,6 +187,12 @@ 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. +**Each environment is its own container with its own deploy root**, rather than one server addressing several roots. That is what keeps the bundle's config internal: the Caddyfile inside a release names `/srv/blog/current`, one root, and knows nothing about a sibling. A single server covering both would have to name both roots in a config held outside either bundle, and that config could not then roll back with the content it serves. Both containers bind the same internal port and are told apart by hostname, which the proxy in front resolves. + +That last sentence is also the risk. **The container is the only thing that distinguishes one environment from another**, so a proxy rule aimed at the wrong one serves the wrong site under the right hostname and returns a healthy `200` with nothing logged anywhere. The bundle stamps `X-Blog-Env` and `X-Robots-Tag` from container variables so a response says which environment produced it, and `checks/check-live-urls.sh` fails on a mismatch when `EXPECT_SITE_ENV` is set. [`deploy/README.md`](./deploy/README.md#identifying-the-environment) carries the mechanism and, more importantly, why `SITE_ROBOTS` defaults to `index, follow` rather than to the safer-looking `noindex`. + +Caddy also sets `trusted_proxies`, because a proxy fronts it in every environment and the peer address is therefore always the proxy. Without it the access log records that one internal address as the client for every request the site serves, and `X-Forwarded-For` is ignored rather than trusted. The ranges come from the container, since the same bundle runs on hosts whose docker subnets differ. **Exclude the bridge gateway from whatever range is trusted**: it is inside the subnet and it is how the host itself reaches the container, so trusting the subnet trusts every process on the host. Verified on the home mirrors by forging a header from the host, which succeeded until the gateway was excluded. **Trusting a range means believing `X-Forwarded-For` from anything in it**, so it is a security boundary rather than a formality, and the bundle's RFC1918 default is only correct where a proxy is genuinely the only thing that can reach the port. [`deploy/README.md`](./deploy/README.md#trusting-the-proxy) carries the three behaviours and why binding to `127.0.0.1` does not make direct access impossible. + ### 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: @@ -131,17 +205,21 @@ The container reads three host paths, and only one of them a release ever writes [`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: +Because it sits outside the bundle, no release updates it. Install or refresh it explicitly, once per environment, which is the same command against a different sourced file: ```sh -set -a; . secrets/.env; set +a +set -a; . secrets/.env; set +a # or secrets/staging.env install -m 644 deploy/bootstrap.Caddyfile "$CADDY_APPDATA/config/Caddyfile" -docker restart "$CADDY_CONTAINER" +docker restart "$CADDY_CONTAINER" # only this file needs one, see below ``` -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. +**A container started before its environment has a release restart-loops**, because the bootstrap imports a path that does not exist yet. Create the directories, install the bootstrap, cut the first release, and start the container in that order. The container definition can also be held disabled until the release exists, which is the same fix from the other side. + +**This is the one file whose change still needs a restart**, and the reason is a nice inversion of why everything else does not. The watcher polls `/config/Caddyfile` and reloads when the *adapted result* changes, which is how a release reaches it at all: this file never changes, but re-adapting re-executes its `import` and picks up the new release behind it. Editing this file itself is the case the watcher handles worst, because a bootstrap that no longer parses leaves nothing to reload into. Restart, and read the log. -`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. +Everything inside the bundle, `deploy/Caddyfile` and anything under `deploy/maps/`, reloads without one. See "Local Verification Before a Pull Request" above. + +`CADDY_APPDATA` is recorded in each environment's file for exactly this reason. No script reads it, so a rebuild would otherwise depend on someone remembering where the bootstrap goes. ## Redirects @@ -168,9 +246,13 @@ The deploy account exists to receive a release and nothing else. - The account is unprivileged and owns only the deploy root. - Its key is restricted in `authorized_keys` with `restrict` and a forced command, so it cannot open a shell, allocate a terminal, or forward a port. -- Each environment has its own key, so a staging deploy cannot reach production. +- **One key covers both environments**, rather than one per environment. Recorded here as a decision rather than an omission, because the opposite is the obvious default and this file asserted it until the two environments actually existed. A per-environment split pays off only where the two keys never share a machine, and here they would: both private keys sit on the maintainer's one workstation, and both secrets in one GitHub store, so whatever reaches one reaches the other. The split would buy a boundary that is already crossed everywhere it is held. +- **The forced command is therefore the only boundary left, and it is confined to the parent of both roots.** That is what a single key costs: `rrsync` pins a key to one directory, so the two deploy roots sit under one parent and one pinned command covers both. The roots are `/srv/blog/sites/production` and `/srv/blog/sites/staging`, and the confinement root is `/srv/blog/sites`. +- **That parent holds content and nothing else, which is why it is not `/srv/blog`.** `/srv/blog` is the deploy account's home directory and contains `/srv/blog/.ssh/authorized_keys`. Confining the key there would let it rewrite the very file that defines what the key may do, and a `--delete` at the root would take `.ssh` with it. Confinement that encloses its own definition is not confinement. The extra `sites/` level is a security boundary rather than tidiness. - Unattended upgrades run with automatic reboot, which is safe because the site is static and the swap survives a restart. +A deploy key that can write a release can already rewrite the site's Caddy config, because [`deploy/Caddyfile`](./deploy/Caddyfile) ships inside the bundle and the bootstrap imports it. Withholding the container's `/config` directory from the same key therefore protects nothing, which is why the bootstrap stays outside the deploy path for the reason given below and not for a security one. + ## Backup and Restore **The deploy root needs no backup.** The site is reproducible from this repository by running the deploy again, so the only thing worth protecting on the server is its configuration: the container definition, the proxy configuration, the deploy account and its restricted key, and the upgrade schedule. diff --git a/README.md b/README.md index 6a63ab8..b25ad3b 100644 --- a/README.md +++ b/README.md @@ -2,23 +2,22 @@ Pieter Viljoen's blog, and the tooling that builds, verifies, and deploys it. +The live blog is hosted at [blog.insanegenius.com][blog-link]. + ## Build and Distribution -- **Source Code**: [GitHub][blog-link], holding the source, issues, discussions, and CI/CD pipelines. +- **Source Code**: [GitHub][github-link], holding the source, issues, discussions, and CI/CD pipelines. - **Versioned Releases**: [GitHub Releases][releases-link], version-tagged source archives. -The site itself is not distributed as a package. It is built from this source and deployed to a host over SSH, so a release here is a tagged snapshot of the source rather than an artifact to install. - ### Build Status -[![Releases Build][releases-build-shield]][actions-link]\ -[![Last Commit][last-commit-shield]][commits-link]\ -[![License][license-shield]][license] +[![Release Status][release-status-shield]][actions-link]\ +[![Last Commit][last-commit-shield]][commits-link] ### Releases -[![GitHub Release][github-release-shield]][releases-link]\ -[![GitHub Pre-Release][github-pre-release-shield]][releases-link] +[![GitHub Release][release-version-shield]][releases-link]\ +[![GitHub Pre-Release][pre-release-version-shield]][releases-link] ### Release Notes @@ -30,7 +29,7 @@ The site itself is not distributed as a package. It is built from this source an - 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. +See [Release History][history] for complete release notes and older versions. ## Table of Contents @@ -38,8 +37,6 @@ See [Release History][history] for the full history. - [Configuration](#configuration) - [Questions or Issues](#questions-or-issues) - [Development Environment Setup](#development-environment-setup) -- [3rd Party Tools](#3rd-party-tools) -- [License](#license) ## Use Cases @@ -69,7 +66,7 @@ Deploy paths, environment variables, and the server layout are documented in [OP To discuss a post, use [Discussions][discussions-link]. The site itself carries no comment system, deliberately: comments on the old platform were closed years ago, and a static site has nowhere to put them without adding a third-party service that outlives its usefulness. Discussions gives a reader somewhere to respond without the site taking on a moving part. -For a defect in the site or the tooling, such as a broken link, a missing redirect, or a page that renders wrongly, open an issue on [the repository][blog-link]. +For a defect in the site or the tooling, such as a broken link, a missing redirect, or a page that renders wrongly, open an [Issue][issues-link]. ## Development Environment Setup @@ -89,11 +86,9 @@ deploy/make-release.sh checks/check-live-urls.sh "$HUGO_BASEURL" ``` -The deploy root and the base URL come from an untracked `secrets/.env`, copied from [deploy/env.example][env-example]. The whole `secrets/` directory is gitignored, so host-specific values stay out of the published history. - -Commits are signed. A greenfield repository signs from its first commit, because the branch ruleset rejects unsigned history and re-signing it afterwards needs a force push the ruleset also blocks. +The deploy root and the base URL come from an untracked file per environment under `secrets/`, copied from [deploy/env.example][env-example] and selected with `ENV_FILE`. The whole `secrets/` directory is gitignored, so host-specific values stay out of the published history. -## 3rd Party Tools +## 3rd Party Tools | Tool | Role | License | | --- | --- | --- | @@ -101,17 +96,18 @@ Commits are signed. A greenfield repository signs from its first commit, because | [PaperMod][papermod-link] | theme, vendored under `themes/` | MIT | | [Caddy][caddy-link] | web server, serving the built site and the redirects | Apache-2.0 | -## License +## License -See [LICENSE][license]. +Licensed under the [MIT License][license]\ +![GitHub License][license-shield] -[github-pre-release-shield]: https://img.shields.io/github/v/release/ptr727/Blog?include_prereleases&label=GitHub%20Pre-Release&logo=github -[github-release-shield]: https://img.shields.io/github/v/release/ptr727/Blog?logo=github&label=GitHub%20Release +[pre-release-version-shield]: https://img.shields.io/github/v/release/ptr727/Blog?include_prereleases&label=GitHub%20Pre-Release&logo=github +[release-version-shield]: https://img.shields.io/github/v/release/ptr727/Blog?logo=github&label=GitHub%20Release [last-commit-shield]: https://img.shields.io/github/last-commit/ptr727/Blog?logo=github&label=Last%20Commit [license-shield]: https://img.shields.io/github/license/ptr727/Blog?label=License -[releases-build-shield]: https://img.shields.io/github/actions/workflow/status/ptr727/Blog/publish-release.yml?event=workflow_dispatch&logo=github&label=Releases%20Build +[release-status-shield]: https://img.shields.io/github/actions/workflow/status/ptr727/Blog/publish-release.yml?event=workflow_dispatch&logo=github&label=Release%20Status @@ -119,12 +115,13 @@ See [LICENSE][license]. -[blog-link]: https://github.com/ptr727/Blog +[github-link]: https://github.com/ptr727/Blog [checks]: ./checks/ [commits-link]: https://github.com/ptr727/Blog/commits [deploy]: ./deploy/ [deploy-readme]: ./deploy/README.md [discussions-link]: https://github.com/ptr727/Blog/discussions +[issues-link]: https://github.com/ptr727/Blog/issues [env-example]: ./deploy/env.example [history]: ./HISTORY.md [hugo-config]: ./hugo.yaml @@ -134,6 +131,7 @@ See [LICENSE][license]. -[caddy-link]: https://caddyserver.com/ -[hugo-link]: https://gohugo.io/ +[blog-link]: https://blog.insanegenius.com +[caddy-link]: https://caddyserver.com +[hugo-link]: https://gohugo.io [papermod-link]: https://github.com/adityatelange/hugo-PaperMod diff --git a/TODO.md b/TODO.md index acd923d..995989b 100644 --- a/TODO.md +++ b/TODO.md @@ -10,7 +10,7 @@ The site is built and gated in CI. It is on GitHub, and it is not yet serving it | --- | --- | | 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 | +| Deploy shape | done and proven against a running Caddy, on a local publish mirror and a local staging mirror | | 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 | | Release pipeline | proven end to end. Release `1.0.11` carries the tag, source archive, README, and LICENSE | @@ -25,8 +25,8 @@ The site is built and gated in CI. It is on GitHub, and it is not yet serving it ## Next, in dependency order - 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. +- The deploy roots are `/srv/blog/sites/{production,staging}` and the confinement root is `/srv/blog/sites`, which is already what the VPS carries. The extra `sites/` level exists because `/srv/blog` is the deploy account's home and holds its own `authorized_keys`, so confining the key there would let it rewrite its own permissions. Restrict the **single** deploy key with `restrict,command=...`, no pty and no forwarding, pinned to that parent. One key rather than one per environment is a deliberate decision, recorded with its reasoning in [OPERATIONS.md](./OPERATIONS.md#server-hardening): the split only pays where the two keys never share a machine, and both sit on one workstation and in one secret store. The cost is that the forced command can no longer separate the environments, which is why the roots share a parent. +- Add the staging DNS record for `blog.vps.insanegenius.net` and expose it through Pangolin. It sits under the existing VPS wildcard, so no new certificate is needed, and **authentication stays on**: staging serves a byte-identical copy of the public site, and an open one is a duplicate handed to every crawler. `check-live-urls.sh` gets through with a resource access token instead. - 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. - 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. @@ -35,7 +35,6 @@ The site is built and gated in CI. It is on GitHub, and it is not yet serving it ## Open decisions -- 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`. ## Deliberate deviations from the fleet baseline @@ -70,12 +69,23 @@ Each of these was hit or nearly hit, and each is cheap to re-trip. - **Never populate media over HTTP, and never trust a file count.** WordPress.com serves optimized derivatives at the same URL and filename. Verify by content hash against the export tar. - **A Picasa URL ending in `-h` serves an HTML wrapper, not an image, with a 200 status.** Check magic bytes rather than status codes when fetching any binary. - **PaperMod uses APIs Hugo deprecated in 0.158**, so `--panicOnWarning` fails on the theme rather than on content. The two overrides in `layouts/` exist to keep that flag on, and they are the reason the flag is a real gate. +- **`caddy run --watch` reloads rules and maps with `admin off`, and without it the deploy is silently stale.** `admin off` blocks `caddy reload`, which looks like it rules out reloading, and does not: the watcher reloads in process and never touches the admin endpoint. The watcher names only `/config/Caddyfile`, which never changes, and works anyway because Caddy re-adapts the whole config each poll and re-adapting re-executes the `import`. Drop the flag and the content symlink moves while the rules do not, so the URL check passes against a config that was never deployed. `X-Blog-Release` and `EXPECT_RELEASE` exist to turn that into an error. +- **The reload is asynchronous, so a check run straight after a deploy races it.** Content follows the symlink per request and is live instantly, the rules land about a quarter of a second later. Caught in practice: two environments deployed in one loop, the first passed and the second failed on a header that had not appeared yet. The check waits for `EXPECT_RELEASE` rather than sampling once, and the timeout is what still catches a container that is not watching at all. +- **A bind mount over `/config` shadows the world-writable `caddy/` the image pre-creates there**, so the `--watch` autosave has to `mkdir` and fails on a read-only mount, logging an ERROR once per deploy. `XDG_CONFIG_HOME=/data` moves it. Mount `/config` read-only *and* set that variable, since doing one alone trades a stray `autosave.json` in appdata for a per-deploy error. +- **`caddy run --watch` stops watching permanently after ONE failed config load, and says nothing.** Reproduced: flip `current` to a valid release and it reloads; point it at a missing release and it logs the failure and keeps the last good config; **restore it to a valid release and it never reloads again**. The log's last line is the original failure, then silence, and every other signal says the container is healthy. A restart is the only remedy, and it is the one nobody would try. Consequence for this repo: a deploy after any config-load failure lands content without rules, which `EXPECT_RELEASE` catches but cannot fix. The invariant worth monitoring is that the served `X-Blog-Release` equals the one in `/current/Caddyfile`. +- **A dangling `current` reads as a release mismatch unless you look for it.** Caddy keeps its **last good config** when the imported release vanishes, rather than dying, so `X-Blog-Env` and `X-Blog-Release` stay correct and plausible while every URL 404s underneath them. A monitor checking only the environment header reports the site healthy. The preflight distinguishes the two: a non-200 that still carries the server's own headers is a broken symlink, not a deploy that never landed. +- **`--link-dest` on a remote deploy needs no bookkeeping: point it at `current`.** The flip happens after the upload, so at upload time `current` is still the previous release. A missing `--link-dest` is a warning and exit 0, not an error, so the first deploy degrades to a full copy on its own. Verified: same inode across releases through the symlink, link count 2. +- **The docker bridge gateway is inside the bridge subnet, so trusting the subnet trusts the host.** Verified here, not inherited: with `TRUSTED_PROXIES=172.18.0.0/16`, a request from this host straight to the container's bridge address forged `client_ip` successfully. Publishing no host port does not close it, because the bridge address is reachable from the host regardless. The value is now the subnet with the gateway's `/32` excluded, which is 16 CIDR blocks and cannot be written shorter. Re-tested both ways after: forgery ignored, real clients through Traefik still resolved. +- **`trusted_proxies` is a security boundary, and its RFC1918 default is only safe behind a proxy.** Trusting a range means believing `X-Forwarded-For` from anything in it, so on a host where the port is reachable directly, the default makes every device on the network able to forge the logged client address. `TRUSTED_PROXIES=` set-but-empty skips the default and trusts nothing, which is correct there; unset applies the default; an explicit list is exactly itself. All three verified. Binding to `127.0.0.1` does not close it, `docker-proxy` SNATs host traffic to the bridge gateway, which is inside RFC1918. - **A hard link keeps its inode's mode and ownership**, so `--chmod` and `--no-g` govern only newly transferred files. A badly moded file rides the link chain into every later release. `NO_LINK_DEST=1` mints fresh inodes. +- **`DEPLOY_ROOT=... deploy/make-release.sh` does not select an environment.** The script sources its environment file with `set -a`, which exports every assignment in it and overwrites whatever the caller exported first, so the variable is set and then silently replaced. `ENV_FILE` selects the file, and the first argument overrides the root, because it is read afterwards. With two sites on one host the failure is not an error: it publishes to the other site. A named `ENV_FILE` that does not exist is a hard failure for the same reason. - **`content/` is an imported archive.** Prose, spelling, and style sweeps do not reach it, and `cspell.json` ignores it deliberately. - **A gate is trusted only after it has been demonstrated failing.** Every gate here has been. A list-driven check also needs a length floor, or a truncated list passes while checking almost nothing. - **Do not name any workflow `build-*-task.yml`** while the repo declares `source-only`, since `detect` is literally `["no build-*-task.yml"]`. - **Do not edit `.markdownlint-cli2.jsonc`, `repo-config/configure.sh`, or the two ruleset payloads.** They are carried verbatim and byte-matched against the hub. Scope a glob in the workflow instead. A reviewer finding a real defect in one of them is answered by declining locally and filing it at the hub, never by editing the file to satisfy the review. - **The hub's `main` can promote while a convergence pull request is open**, so ground truth moves underneath work that was correct when it started. It happened twice in one session on 2026-08-03, and the second time added drift the branch could not have known about. Re-run the audit against the hub ref actually carried before claiming convergence, and name that ref in the change, or the claim ages into a false one. +- **The Copilot reviewer's login differs by API, and a wrong-form filter reads as a clean review.** REST reports `copilot-pull-request-reviewer[bot]`, GraphQL omits the suffix. A filter written in the other form matches nothing, and an empty result is indistinguishable from no findings. Assert the filter matched before trusting what it returned. +- **A Copilot review hides findings in the review body, where the thread API cannot see them.** The `reviewThreads` query returns line threads only, so a review carrying `Suppressed comments (N)` in a `
` block reports zero unresolved while real findings sit unread. Read the review body itself, not just the threads, before calling a review loop finished. - **`gh pr merge --delete-branch` on a `develop -> main` promotion deletes `develop`.** Use a plain `gh pr merge --merge`. ## Reference @@ -99,8 +109,11 @@ Secrets and variables, per environment. The App-token pair is repository-scoped | `DEPLOY_SSH_PRIVATE_KEY` | secret | | `DEPLOY_SSH_HOST`, `DEPLOY_SSH_USER`, `DEPLOY_SSH_KNOWN_HOSTS` | variable | | `DEPLOY_ROOT`, `HUGO_BASEURL` | variable | +| `PANGOLIN_ACCESS_TOKEN_ID`, `PANGOLIN_ACCESS_TOKEN` | secret, staging only | | `CODEGEN_APP_CLIENT_ID`, `CODEGEN_APP_PRIVATE_KEY` | secret, both stores | +`DEPLOY_SSH_PRIVATE_KEY` now holds the same key in both environments, per the decision above. The environment split still carries the deploy root, the base URL, and the staging-only token pair, so it is not decorative. + [migration-post]: ./content/posts/2026/08/01/moving-this-blog-from-wordpress-to-hugo.md diff --git a/checks/check-live-urls.sh b/checks/check-live-urls.sh index 6ae5458..eba83c5 100755 --- a/checks/check-live-urls.sh +++ b/checks/check-live-urls.sh @@ -25,31 +25,63 @@ for list in golden-urls.txt redirect-urls.txt; do done FAILED="$(mktemp)" -trap 'rm -f "$FAILED"' EXIT +CURLERR="$(mktemp)" +CURLRC="" +trap 'rm -f "$FAILED" "$CURLERR" ${CURLRC:+"$CURLRC"}' EXIT + +# A resource access token opens the proxy's auth gate. +# It goes into a curl config file because bash cannot export an array to the parallel checks. +# A command line is also world-readable in ps output, and this runs 1,245 of them. +if [ -n "${PANGOLIN_ACCESS_TOKEN_ID:-}" ] && [ -n "${PANGOLIN_ACCESS_TOKEN:-}" ]; then + CURLRC="$(mktemp)" + chmod 600 "$CURLRC" + printf 'header = "P-Access-Token-Id: %s"\nheader = "P-Access-Token: %s"\n' \ + "$PANGOLIN_ACCESS_TOKEN_ID" "$PANGOLIN_ACCESS_TOKEN" >"$CURLRC" + echo "==> sending a Pangolin access token" +elif [ -n "${PANGOLIN_ACCESS_TOKEN_ID:-}" ] || [ -n "${PANGOLIN_ACCESS_TOKEN:-}" ]; then + # Half a credential is a typo rather than a choice, and it would otherwise fail as an outage. + echo "FAIL set both PANGOLIN_ACCESS_TOKEN_ID and PANGOLIN_ACCESS_TOKEN, or neither" >&2 + exit 2 +fi + +# Assembled once here rather than per request, since it is the same for every call. +AUTH=() +[ -n "$CURLRC" ] && AUTH=(-K "$CURLRC") # Invoked indirectly, through `export -f` and the `xargs bash -c` calls below. # shellcheck disable=SC2329 check_render() { - local url="$1" code - code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 30 "$BASE$url") + local url="$1" code auth=() + [ -n "$CURLRC" ] && auth=(-K "$CURLRC") + code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 30 "${auth[@]}" "$BASE$url") [ "$code" = "200" ] || echo "render $url expected 200, got $code" >>"$FAILED" } # Invoked indirectly, the same way as check_render above. # shellcheck disable=SC2329 check_redirect() { - local url="$1" code dest dcode - code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 30 "$BASE$url") + local url="$1" code dest dcode auth=() dest_auth=() + [ -n "$CURLRC" ] && auth=(-K "$CURLRC") + code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 30 "${auth[@]}" "$BASE$url") case "$code" in 301 | 308) ;; *) - echo "redirect $url expected 301, got $code" >>"$FAILED" + echo "redirect $url expected 301 or 308, got $code" >>"$FAILED" return ;; esac # A redirect to a 404 is a broken redirect, so the destination is followed rather than trusted. - dest=$(curl -s -o /dev/null -w '%{redirect_url}' --max-time 30 "$BASE$url") - dcode=$(curl -s -o /dev/null -w '%{http_code}' --max-time 30 "$dest") + dest=$(curl -s -o /dev/null -w '%{redirect_url}' --max-time 30 "${auth[@]}" "$BASE$url") + # The credential is only ever sent to the origin it belongs to. + # A rule that one day redirects off-site must not mail the token there. + # The match needs an origin boundary, since a bare prefix also accepts a host that merely + # starts with this one, such as a lookalike registered as an attacker's subdomain. + if [ -n "$CURLRC" ]; then + case "$dest" in + "$BASE" | "$BASE"/*) dest_auth=(-K "$CURLRC") ;; + esac + fi + dcode=$(curl -s -o /dev/null -w '%{http_code}' --max-time 30 "${dest_auth[@]}" "$dest") # The media rule lands on an image, and a directory gains a trailing slash, so both answers are accepted. case "$dcode" in 200 | 301 | 308) ;; @@ -58,10 +90,90 @@ check_redirect() { } export -f check_render check_redirect -export BASE FAILED +export BASE FAILED CURLRC echo "==> $BASE" +# One request before the 1,245, because an auth gate turns a bad credential into a total failure. +# Otherwise the output reads as a vanished site rather than a wrong token. +# Transport failures are separated from HTTP ones, since a name that does not resolve otherwise +# reports as a status code and gets diagnosed as a credential or a symlink. +if ! preflight_headers=$(curl -sS -o /dev/null -D- -w '%{http_code}' --max-time 30 "${AUTH[@]}" "$BASE/" 2>"$CURLERR"); then + echo "FAIL preflight: $BASE/ could not be reached, so nothing below was checked" >&2 + sed 's/^/ /' "$CURLERR" >&2 + exit 1 +fi +preflight="${preflight_headers##*$'\n'}" +header_of() { printf '%s' "$preflight_headers" | grep -i "^$1:" | tr -d '\r' | sed 's/^[^:]*: *//'; } + +if [ "$preflight" != "200" ]; then + echo "FAIL preflight: $BASE/ answered $preflight, expected 200" >&2 + # Own headers with no content behind them is the signature of a dangling current symlink. + # Caddy retains the last good config when the import disappears, so the headers stay correct. + # Reporting that as a release mismatch would send someone hunting a deploy that did land. + if [ -n "$(header_of x-blog-release)$(header_of x-blog-env)" ] && [ "$preflight" = "404" ]; then + echo " the server is up and holding a config, but serving no content, so 'current'" >&2 + echo " probably points at a release that is not on disk. Caddy keeps its last good" >&2 + echo " config when the import vanishes, which is why the headers below still look" >&2 + echo " right: env=$(header_of x-blog-env) release=$(header_of x-blog-release)" >&2 + elif [ -n "$CURLRC" ]; then + echo " a token was sent, so check the pair is valid for this resource" >&2 + else + echo " no token was sent. If this site is behind the auth gate, set" >&2 + echo " PANGOLIN_ACCESS_TOKEN_ID and PANGOLIN_ACCESS_TOKEN" >&2 + fi + exit 1 +fi + +# Nothing in a response body says which environment answered. +# A proxy rule aimed at the wrong container returns a healthy 200 under the right hostname. +if [ -n "${EXPECT_SITE_ENV:-}" ]; then + got_env=$(printf '%s' "$preflight_headers" | grep -i '^x-blog-env:' | tr -d '\r' | sed 's/^[^:]*: *//') + if [ "$got_env" != "$EXPECT_SITE_ENV" ]; then + echo "FAIL preflight: $BASE/ is served by '${got_env:-}', expected '$EXPECT_SITE_ENV'" >&2 + echo " the hostname resolved to the wrong environment's container, or SITE_ENV is" >&2 + echo " unset on it. Checking the URL contract now would test the wrong site." >&2 + exit 1 + fi + echo "==> served by $got_env" +fi + +# Nothing else proves the rules answering are the ones just shipped, as no deploy restarts Caddy. +# A stale config serves the previous release's rules while the new content is already live. +read_release() { + curl -s -o /dev/null -D- --max-time 30 "${AUTH[@]}" "$BASE/" | + grep -i '^x-blog-release:' | tr -d '\r' | sed 's/^[^:]*: *//' +} + +got_release=$(printf '%s' "$preflight_headers" | grep -i '^x-blog-release:' | tr -d '\r' | sed 's/^[^:]*: *//') +if [ -n "${EXPECT_RELEASE:-}" ]; then + # The reload is asynchronous, so a check run straight after a deploy races it. + # Content is live instantly, while rules change on the next poll. + # The timeout still catches a container that is not watching, which never converges. + waited=0 + while [ "$got_release" != "$EXPECT_RELEASE" ] && [ "$waited" -lt "${RELOAD_TIMEOUT:-30}" ]; do + sleep 1 + waited=$((waited + 1)) + got_release=$(read_release) + done + if [ "$got_release" != "$EXPECT_RELEASE" ]; then + echo "FAIL preflight: after ${waited}s the rules are from release '${got_release:-}', expected '$EXPECT_RELEASE'" >&2 + echo " The content symlink moved but the config never followed, so the redirects" >&2 + echo " below would be checked against a config that was never deployed, and would" >&2 + echo " pass. Two causes, and the second is the likelier one on a server that has" >&2 + echo " been working:" >&2 + echo " - the container is not running 'caddy run --watch' at all; or" >&2 + echo " - it is, and the watcher is dead. It stops watching permanently after one" >&2 + echo " failed config load, logs nothing further, and reports healthy throughout." >&2 + echo " Anything that broke 'current' even briefly, including a test, does this." >&2 + echo " Only a container restart re-arms it." >&2 + exit 1 + fi + echo "==> rules from release $got_release${waited:+ (after ${waited}s)}" +elif [ -n "$got_release" ]; then + echo "==> rules from release $got_release" +fi + n_render=$(grep -c . "$CHECKS/golden-urls.txt") echo "==> checking $n_render URLs that must render" grep . "$CHECKS/golden-urls.txt" | xargs -P "$PARALLEL" -I{} bash -c 'check_render "$@"' _ {} diff --git a/deploy/Caddyfile b/deploy/Caddyfile index 17f252d..440d423 100644 --- a/deploy/Caddyfile +++ b/deploy/Caddyfile @@ -4,11 +4,35 @@ { admin off auto_https off + + # Trusting a range means believing X-Forwarded-For from anything inside it. + # The value is per-host, because docker subnets differ. + # An empty value trusts nothing, which is correct where Caddy is reachable without a proxy. + # The default keeps a host that omits the variable working. + servers { + trusted_proxies static {$TRUSTED_PROXIES:172.16.0.0/12 10.0.0.0/8 192.168.0.0/16} + } } :8080 { root * /srv/blog/current/site + # Only the container distinguishes environments, which all serve this bundle on one port. + # A proxy rule aimed at the wrong one answers healthily under the right hostname. + # The default matters, since an unset variable yields an empty header and still validates. + header X-Blog-Env "{$SITE_ENV:unset}" + + # Keeps a non-public copy of the site out of search indexes. + # The default is the value that is harmless on production. + # An unset staging container is still behind its auth gate. + # Production inheriting noindex would deindex the site silently. + header X-Robots-Tag "{$SITE_ROBOTS:index, follow}" + + # Names the release whose rules are answering, substituted at install time. + # Content follows the current symlink per request, while rules wait on a config reload. + # Without this a stale config passes the URL contract against rules never shipped. + header X-Blog-Release "@@RELEASE@@" + # One map serves three classes because their key spaces are disjoint. # Merging them keeps a single lookup on the hot path. map {path} {mapped} { diff --git a/deploy/README.md b/deploy/README.md index aa593e6..4d7759f 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -15,6 +15,12 @@ How the site is built, released, and served. The release is a **self-contained b | `bash` 4.4+ | all scripts | arrays, `mapfile` | already present | | `docker` | serving | running Caddy | orchestrated elsewhere | +**These scripts run on Linux, which is a dependency ceiling rather than an omission.** They use +`mv -Tf`, `find -printf`, `mapfile`, and `sed -i` without an argument, all of which are GNU or +bash 4.4 constructs absent from a stock macOS. Every consumer is Linux already: CI builds there, +the containers serve there, and the deploy account receives there. Editing is unaffected, and a +macOS or Windows contributor runs them through a container or a remote Linux host. + **Hugo must be the `extended` build**, and `hugo version` reports `+extended` when it is. Debian's archive does not carry a useful version, so install from the upstream release or via snap. **`brotli` is easy to miss and degrades silently.** The Caddyfile serves `precompressed br gzip`, so without the binary every text response falls back to gzip while the site keeps working and nothing errors. `make-release.sh` warns loudly when it is absent, and `REQUIRE_BROTLI=1` turns that into a hard failure. **CI must set it.** Measured on the home page: 18,845 bytes raw, 6,395 gzip, 5,105 brotli. @@ -25,18 +31,23 @@ How the site is built, released, and served. The release is a **self-contained b This repo produces a release tree and the config that serves it. It never names a host path or an orchestrator variable, because the consumer may name both sides and the producer must name -neither. Five facts are the whole contract: +neither. Seven facts are the whole contract: 1. The deploy root is bind-mounted **read-only** at `/srv/blog`. Mount the **parent**, never `current`, because a symlink is resolved once at container creation and mounting it pins the container to whichever release was live then. 2. The site config is at `/srv/blog/current/Caddyfile`. -3. A stable per-container config directory is mounted at `/config`, holding a bootstrap that - imports (2). Mount `/data` as well to persist Caddy state across a recreate. The image ships - `/data/caddy`, so this is persistence rather than a startup requirement. +3. A stable per-container config directory is mounted **read-only** at `/config`, holding a + bootstrap that imports (2). Mount `/data` writable as well, and set `XDG_CONFIG_HOME=/data`. 4. Caddy binds `:8080`, plain HTTP, with `admin off` and `auto_https off`. TLS and the public listener belong to the fronting proxy. -5. The release tree is world-readable and world-traversable, so any uid can serve it. +5. Caddy runs with **`--watch`**. This is not optional. See + [Reloading without a restart](#reloading-without-a-restart). +6. The release tree is world-readable and world-traversable, so any uid can serve it. +7. The container sets `SITE_ENV` and `SITE_ROBOTS`, which the bundle stamps on every response + as `X-Blog-Env` and `X-Robots-Tag`, and `TRUSTED_PROXIES`. See + [Identifying the environment](#identifying-the-environment) and + [Trusting the proxy](#trusting-the-proxy). ## Building a release @@ -46,15 +57,32 @@ checks/check-live-urls.sh "$HUGO_BASEURL" ``` The deploy root and the base URL are the only host-specific values, and they pair per -environment. Copy [`env.example`](./env.example) to `secrets/.env` and set both. `secrets/` is -gitignored as a whole directory, so a value naming one machine cannot reach a public repo by -being added to a file nobody remembered to ignore. CI passes them explicitly instead, which -keeps a pipeline run self-describing: +environment. Copy [`env.example`](./env.example) to `secrets/.env`, which is the file read when +`ENV_FILE` is unset, and add `secrets/.env` for each further environment. A single +environment therefore needs `secrets/.env` and nothing else, since a differently named file is +read only when `ENV_FILE` names it. `secrets/` is gitignored as a whole directory, so a value +naming one machine cannot reach a public repo by being added to a file nobody remembered to +ignore. CI passes them explicitly instead, which keeps a pipeline run self-describing: ```sh HUGO_BASEURL= deploy/make-release.sh "$(git rev-parse --short HEAD)" ``` +**One file per environment, selected by `ENV_FILE`.** `secrets/.env` is the default and is read +when `ENV_FILE` is unset, so a single-environment host needs nothing else: + +```sh +deploy/make-release.sh # secrets/.env +ENV_FILE=secrets/staging.env deploy/make-release.sh # the staging site on the same host +``` + +Selecting the file is the only way to switch environments. The file is sourced with `set -a`, +which exports every assignment in it and **overwrites** a variable the caller exported first, so +`DEPLOY_ROOT=... deploy/make-release.sh` does not do what it looks like. The first argument +still wins, because it is read after the file. A named file that does not exist is a hard +failure rather than a fall-through to the ambient environment, since on a host running two sites +the ambient value is the other site's root. + **Always set `HUGO_BASEURL` for anything that is not production.** The base URL is baked into the canonical tag, the feed links, and every absolute permalink, so a mirror built without it serves pages that all point back at production. Nothing downstream catches this, because the @@ -65,11 +93,148 @@ every build for that reason. | Variable | Effect | | --- | --- | +| `ENV_FILE` | Which environment file to source. Defaults to `secrets/.env`. | | `DEPLOY_ROOT` | Fallback deploy root. The first argument wins. | | `HUGO_BASEURL` | Overrides the site base URL. Hugo maps `HUGO_` onto config natively. | | `REQUIRE_BROTLI=1` | Fails rather than shipping gzip-only. CI sets this. | | `NO_LINK_DEST=1` | Full copy instead of hard-linking from the previous release. | +`checks/check-live-urls.sh` reads five more, and none of them reaches `make-release.sh`. Two +open the auth gate: + +| Variable | Effect | +| --- | --- | +| `PANGOLIN_ACCESS_TOKEN_ID` | Resource access token id, sent as the `P-Access-Token-Id` header. | +| `PANGOLIN_ACCESS_TOKEN` | The token itself, sent as `P-Access-Token`. | + +Set both or neither; half a pair is rejected as the typo it is. They go to curl through a +mode-`600` config file rather than as `-H` arguments, which keeps the credential out of the +`ps` output of 1,245 requests, and is also the only form that survives the `export -f` the +parallel checks run under. The token is sent to the base URL's own origin and to nothing else, +so a redirect that one day points off-site cannot carry it away. + +Three more assert that the thing answering is the thing that was just deployed, all checked in +the preflight before a single URL is requested: + +| Variable | Effect | +| --- | --- | +| `EXPECT_SITE_ENV` | Asserts the environment that answered, read from `X-Blog-Env`. | +| `EXPECT_RELEASE` | Asserts the release whose rules answered, read from `X-Blog-Release`. | +| `RELOAD_TIMEOUT` | Seconds to wait for that release to become live. Default 30. | + +## Reloading without a restart + +The container runs `caddy run --watch`, so a release goes live with **no restart**. The watcher +re-adapts the config on a timer and reloads it in process, and re-adapting re-executes every +`import`, which is how a new release's `Caddyfile` and `maps/*.map` are picked up through a +`/config/Caddyfile` that never itself changes. + +**`--watch` does not need the admin API.** `admin off` makes `caddy reload` impossible, which +looks like it should rule out reloading entirely, and does not: `caddy reload` POSTs to the admin +endpoint while the watcher reloads in process. The log prints `admin endpoint disabled` and +`watching config file for changes` together. + +**`XDG_CONFIG_HOME=/data` is required alongside it.** The watcher autosaves the adapted config on +every reload, to `$XDG_CONFIG_HOME/caddy`, which the image defaults to `/config`. A bind mount +over `/config` shadows the world-writable directory the image pre-creates there, so the autosave +has to `mkdir` and the outcome depends on the mount: + +| `/config` mount | Result | +| --- | --- | +| read-only, no `XDG_CONFIG_HOME` | an ERROR line **per reload**, one per deploy, reload still succeeds | +| read-only, `XDG_CONFIG_HOME=/data` | clean | +| writable, no `XDG_CONFIG_HOME` | silent, and Caddy writes `caddy/autosave.json` into the config directory | + +Mount `/config` read-only *and* set `XDG_CONFIG_HOME=/data`. Doing one without the other trades a +silent stray file for a per-deploy error, in the place a real error most needs to stand out. + +**Without `--watch` the failure is silent.** Caddy expands `import` at config-parse time and does +not watch the imported files, so the content symlink moves while the rules stay as they were when +Caddy last loaded. The URL check then passes against a config that was never deployed. That is why +the release stamps its own version into the config it ships with, as `X-Blog-Release`, and why +`check-live-urls.sh` compares it to `EXPECT_RELEASE` before checking a single URL. + +It **waits** for the match rather than sampling once. The reload is asynchronous, so a check +starting straight after a deploy races it and reads the previous release's config. `RELOAD_TIMEOUT` +bounds the wait, and a container that is not watching never converges, which is what turns a silent +staleness into a named failure. + +**Content and rules do not switch together.** `file_server` resolves `current` per request, so new +content is live instantly while the rules follow on the next poll, about a quarter of a second +later. For that window the new content is served under the previous release's rules. Harmless while +every rule is a redirect, since a stale redirect lands on a page that exists in both releases. It +stops being harmless if a rule ever *gates* content rather than redirecting it, and at that point +the flip has to become a restart again. + +## Identifying the environment + +Every environment runs the **same bundle on the same port** in its own container, so nothing in a +response says which one answered. A proxy rule aimed at the wrong container connects happily and +serves the wrong environment under the right hostname, returning a healthy `200`. That is a +failure a reader reports before a monitor notices. + +The bundle stamps two headers for that, taking both values from the container so the artifact +stays the same everywhere and still rolls back as one unit: + +| Container variable | Header | Values | +| --- | --- | --- | +| `SITE_ENV` | `X-Blog-Env` | `production`, `staging`, and the local mirrors | +| `SITE_ROBOTS` | `X-Robots-Tag` | `index, follow` or `noindex, nofollow` | + +Both are emitted at site level, outside the `route` block, which is what puts them on the error +path as well. Verified on all three response classes: `200` from `file_server`, `301` from a +`redir`, and `404` through `handle_errors`. + +**Both carry a default, because an unset `{$VAR}` is silent.** It expands to an empty header +rather than an error, and `caddy validate` still reports a valid configuration, so a missing +value would otherwise reach production unnoticed. `SITE_ENV` defaults to `unset`, which +`EXPECT_SITE_ENV` then fails on. + +**`SITE_ROBOTS` defaults to `index, follow`, which is deliberate and is not the safer-looking +choice.** The two failure directions are not symmetric: + +- A **staging** container missing the value is still behind its auth gate, so nothing reaches it + to index. The header is the second line of defence there, not the first. +- A **production** container that picked up `noindex` would deindex the site silently, and this + site's entire migration exists to preserve sixteen years of search ranking. Recovery is + measured in weeks of recrawling. + +So the default is the value that is harmless on production, and `noindex` is reachable only by +asking for it explicitly. + +`checks/check-live-urls.sh` asserts this when `EXPECT_SITE_ENV` is set, before it checks the +1,245 URLs, since checking the contract against the wrong environment proves nothing. + +## Trusting the proxy + +A proxy fronts Caddy in every environment, so the peer address is always the proxy and the real +client arrives in `X-Forwarded-For`. `trusted_proxies` is what makes Caddy believe it. Without it, +`client_ip` and `remote_ip` are both the proxy and the forwarded header has no effect at all. + +**The CIDRs come from the container, not the bundle.** The same artifact runs on hosts whose docker +subnets differ, so any literal in the bundle is wrong on one of them. + +**Trusting a range means believing `X-Forwarded-For` from anything inside it**, so the range is a +security boundary, and it should be no wider than what can actually reach the port. Three +behaviours, all verified: + +| `TRUSTED_PROXIES` | Result | +| --- | --- | +| unset | the bundle's default applies, all of RFC1918 | +| set but empty | the default is skipped and nothing is trusted | +| an explicit list | exactly those ranges | + +The default exists so a host that forgets the variable keeps working. It is not a safe value +everywhere: **it is only correct where a proxy is the only thing that can reach Caddy.** Where the +port is reachable directly, RFC1918 makes every device on the network a trusted proxy, and anything +there can forge the client address in the access log. `TRUSTED_PROXIES=` (present, blank) is the +right answer there, because a client that is not a proxy has no forwarded header worth honouring. + +Binding the port to `127.0.0.1` does **not** make direct access impossible. `docker-proxy` SNATs +host-originated traffic to the bridge gateway, which is itself inside RFC1918 and therefore inside +the default. Narrowing to the container subnet does not fix it either, since the gateway sits inside +that too and has to be excluded deliberately. + ## Layout ```text diff --git a/deploy/env.example b/deploy/env.example index 8a8d822..5f81f59 100644 --- a/deploy/env.example +++ b/deploy/env.example @@ -1,32 +1,61 @@ -# Copy to secrets/.env and set for this host. Every value here names a particular machine rather -# than the project, which is why secrets/ is gitignored as a whole directory and this template -# lives outside it. CI sets the deploy values from environment secrets and never reads a file. +# Copy to secrets/.env and set for this host. +# Every value here names a machine rather than the project, so secrets/ is gitignored whole. +# CI sets the deploy values from environment secrets and reads no file. +# +# One file per environment, selected by ENV_FILE: +# secrets/.env the default, read when ENV_FILE is unset +# secrets/staging.env ENV_FILE=secrets/staging.env deploy/make-release.sh +# +# The file is sourced with `set -a`, which overwrites a variable the caller exported first. +# Selecting the file is therefore how an environment is chosen. +# The first argument to make-release.sh is how its root is overridden. +# A named file that does not exist is a hard failure rather than a fall-through. # # Naming convention: the prefix names whatever owns the value, not whatever reads it. # HUGO_ is fixed by Hugo, which maps HUGO_ onto its own config natively. # DEPLOY_ is the release tooling, which writes the deploy root. # CADDY_ is the container, which owns state the release never touches. +# PANGOLIN_ is the proxy, which owns the credential that opens its auth gate. # Written by every release, and mounted read-only by the container at /srv/blog. -# make-release.sh also takes it as a first argument, which wins over this value. -# CI sets the same name per environment, where the deploy is an rsync rather than a local copy. +# The first argument to make-release.sh wins over this value. DEPLOY_ROOT=/path/to/deploy/root # Must be set for anything that is not production. -# The base URL is baked into the canonical tag, the feed links, and every absolute permalink, -# so a mirror built without it serves pages that all point back at production while every gate -# still passes. +# The base URL is baked into the canonical tag, the feed links, and every absolute permalink. +# A mirror built without it serves pages pointing back at production, and every gate still passes. HUGO_BASEURL=https://blog.example.com/ # The container's persistent state root, deliberately outside DEPLOY_ROOT. # Two directories hang off it, and a release writes neither: # /config mounted at /config, holding the bootstrap Caddyfile -# /data mounted at /data, persisting Caddy state across a recreate -# No script reads this. It is recorded so a rebuild does not depend on someone remembering -# where the bootstrap goes, since the release bundle deliberately does not carry it. +# /data mounted at /data, holding Caddy state and the reload autosave +# No script reads this, so it is recorded to keep a rebuild from depending on memory. CADDY_APPDATA=/path/to/container/appdata -# The container to restart when config changes. -# Caddy expands the site config and the map files at parse time and does not watch them, so a -# release or a rollback that changes either is not live until the container restarts. -CADDY_CONTAINER=blog +# The container serving this environment. +# A release needs no restart, because Caddy reloads its config in process. +# Restarting is the remedy when the watcher dies, which it does silently after one failed load. +# +# Environments are named production and staging, spelled out, with no prod or stage anywhere. +# The name is compared, by EXPECT_SITE_ENV below and by the deploy. +# A spelling that differs by environment fails a deploy for a reason that reads like an outage. +CADDY_CONTAINER=blog-production + +# A resource access token, read by check-live-urls.sh, for an environment behind the auth gate. +# Staging keeps its gate on, because it serves a byte-identical copy of the public site. +# Set both or neither, and leave both unset for a site that is public. +PANGOLIN_ACCESS_TOKEN_ID= +PANGOLIN_ACCESS_TOKEN= + +# The environment that must answer, compared against the X-Blog-Env header the bundle stamps. +# A proxy rule aimed at the wrong container returns a healthy 200 under the right hostname. +# Checking the URL contract against that proves nothing, so the check refuses to start. +EXPECT_SITE_ENV=production + +# Read by the deploy workflow, which resolves them from the GitHub Environment rather than a file. +# They are named here so the local file and the environment describe the same shape. +# A local run deploys to a path and needs none of them. +#DEPLOY_SSH_HOST= +#DEPLOY_SSH_USER= +#DEPLOY_SSH_KNOWN_HOSTS= diff --git a/deploy/make-release.sh b/deploy/make-release.sh index b01a16d..63f9a43 100755 --- a/deploy/make-release.sh +++ b/deploy/make-release.sh @@ -11,28 +11,62 @@ KEEP_RELEASES=10 usage() { echo "usage: $0 [deploy-root] [version]" >&2 - echo " deploy-root defaults to DEPLOY_ROOT, from the environment or secrets/.env" >&2 + echo " deploy-root defaults to DEPLOY_ROOT, from the environment or \$ENV_FILE" >&2 + echo " ENV_FILE defaults to secrets/.env, and a relative path resolves against the repo" >&2 exit 2 } REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # The deploy root and the base URL are the only host-specific values, and they pair per environment. -# CI passes both explicitly, and a local run reads them from an untracked secrets/.env. -# That directory is gitignored whole, so nothing naming this machine reaches a public repo. -if [ -f "$REPO/secrets/.env" ]; then +# ENV_FILE selects the environment, because `set -a` overwrites a value the caller exported. +# The first argument overrides the root, being read after this. +DEFAULT_ENV_FILE="$REPO/secrets/.env" +ENV_FILE="${ENV_FILE:-$DEFAULT_ENV_FILE}" +# Anchored under the repo, so a relative name resolves the same from any working directory. +case "$ENV_FILE" in +/*) ;; +*) ENV_FILE="$REPO/$ENV_FILE" ;; +esac +if [ -f "$ENV_FILE" ]; then + echo "==> environment: $ENV_FILE" set -a - # shellcheck disable=SC1091 - . "$REPO/secrets/.env" + # shellcheck disable=SC1090,SC1091 + . "$ENV_FILE" set +a +elif [ "$ENV_FILE" != "$DEFAULT_ENV_FILE" ]; then + # The default file is optional, because CI passes every value explicitly and reads no file. + # A named file is not, since a typo would fall through to the other environment's root. + echo "environment file not found: $ENV_FILE" >&2 + exit 1 +fi + +# This script installs to a local path, so a remote environment's DEPLOY_ROOT would be built here. +# The guard is on the fallback rather than the variable. +# An explicit first argument names a local path and is always honoured, which is what CI passes. +ROOT_ARG="${1:-}" +if [ -z "$ROOT_ARG" ] && [ -n "${DEPLOY_SSH_HOST:-}" ]; then + echo "$ENV_FILE names DEPLOY_SSH_HOST=$DEPLOY_SSH_HOST, so its DEPLOY_ROOT is a path on that" >&2 + echo "host and this script would create it here instead. Pass a local path as the first" >&2 + echo "argument to assemble a bundle for shipping, or use a local environment file." >&2 + exit 1 fi -ROOT="${1:-${DEPLOY_ROOT:-}}" +ROOT="${ROOT_ARG:-${DEPLOY_ROOT:-}}" [ -n "$ROOT" ] || usage # CI passes the version so a release directory traces back to a commit rather than to a clock. VERSION="${2:-$(date -u +%Y%m%d-%H%M%S)}" +# Constrained because the value becomes a directory name, a symlink target, and a sed replacement. +# A separator or a traversal would place the release outside releases/ or corrupt the stamp. +case "$VERSION" in +"" | *[!A-Za-z0-9._-]* | *..* | -*) + echo "version must be one or more of A-Z a-z 0-9 . _ -, without '..' or a leading '-'" >&2 + exit 1 + ;; +esac + command -v hugo >/dev/null || { echo "hugo not found on PATH" >&2 exit 1 @@ -121,6 +155,21 @@ rsync -a --no-g --chmod=D2755,F644 --delete "${LINK_SITE[@]}" public/ "$STAGE/si rsync -a --no-g --chmod=D2755,F644 --delete "${LINK_MAPS[@]}" "$REPO/deploy/maps/" "$STAGE/maps/" install -m 644 "$REPO/deploy/Caddyfile" "$STAGE/Caddyfile" +# Stamp the release into the config it ships with, so a response names the rules answering. +# A stale config otherwise passes the URL contract against rules that were never shipped. +# Asserted before substituting, because sed reports success when it matches nothing. +# A Caddyfile that lost the placeholder would otherwise ship unstamped, and the live check would +# then blame a dead config watcher for a bundle that never carried a release id. +if ! grep -q "@@RELEASE@@" "$REPO/deploy/Caddyfile"; then + echo "deploy/Caddyfile carries no @@RELEASE@@ placeholder to stamp" >&2 + exit 1 +fi +sed -i "s/@@RELEASE@@/$VERSION/" "$STAGE/Caddyfile" +if grep -q "@@RELEASE@@" "$STAGE/Caddyfile"; then + echo "release stamp was not substituted into the shipped Caddyfile" >&2 + exit 1 +fi + # --chmod and --no-g govern only the files rsync newly transfers. # A file supplied by --link-dest keeps its original inode's mode, so the result is inspected rather than assumed. bad_files=$(find "$STAGE/site" "$STAGE/maps" -type f ! -perm -o=r | head -20) diff --git a/spec/secrets.json b/spec/secrets.json index 8a6d3b1..af7078c 100644 --- a/spec/secrets.json +++ b/spec/secrets.json @@ -18,7 +18,7 @@ "note": "The App-token secrets power the App-signed merge-bot (auto-merge that re-triggers downstream workflows), which every fleet repo runs. Used via actions/create-github-app-token with the client-id input (not the deprecated app-id). The CODEGEN_* name is historical, not codegen-specific." }, "environments": { - "note": "Not part of the baseline audit. Each deployment environment carries its own key so a staging deploy cannot reach production.", + "note": "Not part of the baseline audit. One key covers both environments, a deliberate decision recorded in OPERATIONS.md: the per-environment split only pays where the two keys never share a machine, and both sit on one workstation and in one secret store. The environment split still carries the deploy root, the base URL, and the staging-only access token, so it is not decorative.", "names": [ "staging", "production" @@ -32,6 +32,14 @@ "DEPLOY_SSH_KNOWN_HOSTS", "DEPLOY_ROOT", "HUGO_BASEURL" - ] + ], + "secretsNote": "The 'secrets' and 'variables' lists are required in every environment named above. 'environmentSecrets' names what one environment carries and another does not, so a name audit does not read a staging-only credential as missing from production. Staging keeps its auth gate on and production answers unauthenticated, so the access token exists on staging alone and checks/check-live-urls.sh sends no credential where the pair is absent.", + "environmentSecrets": { + "staging": [ + "PANGOLIN_ACCESS_TOKEN_ID", + "PANGOLIN_ACCESS_TOKEN" + ], + "production": [] + } } } From 013c30c92bbdbf4923545c60d0367c8791062ca7 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Tue, 4 Aug 2026 14:48:01 -0700 Subject: [PATCH 08/11] Re-vendor the drifted workflow contract and record the hub debt (#17) The carried CI/CD contract had fallen one revision behind the hub on the release-classification guarantee, where the hub describes the smoke-build case in terms the local copy had lost. The deploy this repo now performs has no destination in that contract, and the shape it needs is a variant of the existing registry-push leaf rather than a new release surface: the same dispatch-gated leaf publishing to its own destination, differing only in the build tool and the transport. Co-authored-by: Claude Opus 5 (1M context) --- TODO.md | 15 +++++++++++++++ WORKFLOW.md | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/TODO.md b/TODO.md index 995989b..4db4e30 100644 --- a/TODO.md +++ b/TODO.md @@ -33,6 +33,21 @@ The site is built and gated in CI. It is on GitHub, and it is not yet serving it - 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. +## Owed to the hub + +The hub is owed a spec update for this repo's publishing type, tracked in [ProjectTemplate#456][hub-issue]. The measured deploy shape is reported there. + +**Frame it as a variant of the existing registry-push leaf, not a new release surface.** A NuGet or PyPI leaf builds an artifact and pushes it to its own destination, contributing no `release-asset-*`. This repo does exactly that. Only two things differ, and neither changes the seam: + +| Same as NuGet and PyPI | Unique here | +| --- | --- | +| A leaf builds, then pushes to its own destination | The build is Hugo rather than a language toolchain | +| No `release-asset-*` contributed | The transport is rsync over SSH to a host the project owns | +| Publish is dispatch-gated, never a merge | The destination is a filesystem, so the artifact carries its own version | +| Credentials come from a GitHub Environment | Two environments serve the same artifact, so a deploy must prove which one answered | + +What the type genuinely needs is therefore small: a destination row in `Output Seam by Destination`, and one guarantee that a deploy is verified against the running host by release rather than by transport success. The release model, the branching model, and the never-publish-on-merge rule all hold unchanged. + ## Open decisions - `/robots.txt/` and `/osd.xml/` currently sit in `slugs.map` pointing at `/`. The first would be better pointing at the real `/robots.txt`. diff --git a/WORKFLOW.md b/WORKFLOW.md index 135daa9..6cd1e62 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -150,7 +150,7 @@ The required behaviors, organized by domain. Each is a **MUST**, stated as input ### D2 - Input/State Validation at Entry - **D2.1 Validate before expensive work.** Output: a dedicated entry job/step asserts each cross-input/derived-state invariant and fails fast before builds. Downstream jobs `needs:` it. -- **D2.2 Release branch matches version classification.** Input: a real (non-smoke) release build. Output: the gate fails loudly if the default branch carries a prerelease suffix **or** a non-default branch carries none. It strips `+buildmetadata` before testing for the prerelease `-` (only a core/prerelease `-` counts), and it is **skipped on smoke** (a detached PR head always versions as prerelease). *Prevents: a non-default leg published as stable; a build-metadata false-positive; the gate blocking every default-base promotion PR.* +- **D2.2 Release branch matches version classification.** Input: a real (non-smoke) release build. Output: the gate fails loudly if the default branch carries a prerelease suffix **or** a non-default branch carries none. It strips `+buildmetadata` before testing for the prerelease `-` (only a core/prerelease `-` counts), and on a smoke build the **check exits early while the job still reports success** (a detached PR head always versions as prerelease). Read that as the validation being skipped rather than the job, because a job-level `if:` would skip the job itself, and a dependent skips with it unless that dependent opts out with `if: always()` and reads the result explicitly, the way the PR aggregator does. `github-release` carries `validate-release` in `needs:` and does **not** opt out, so a job-level skip there would couple the release to smoke through a second path on top of the `if:` it already carries. *Prevents: a non-default leg published as stable; a build-metadata false-positive; the gate blocking every default-base promotion PR.* - **D2.3 Publish only from main or develop.** Input: a dispatch publish. Output: a dispatch from any ref other than `main` or `develop` fails fast. *Prevents: cutting a release from an unintended branch.* - **D2.4 Mutually-exclusive / paired inputs are validated.** Input: a workflow with either/or or must-pair inputs (e.g. the docker-readme task's `repositories` XOR `manifest`+`manifest-jq`). Output: a half-filled or conflicting combination fails fast. *Prevents: a silent fall-through.* From 36f5883900597f490011473702f855e9d22b787a Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Tue, 4 Aug 2026 15:19:59 -0700 Subject: [PATCH 09/11] Point the hub debt at the issue carrying the proposed wording (#18) * Point the hub debt at the issue carrying the proposed wording The intake thread holds the questions and their answers, and the change itself needs an issue that can be worked without reconstructing which framing stands. Co-Authored-By: Claude Opus 5 (1M context) * Name the repository in the hub issue reference The reference sat beside a spelled-out one in the same sentence, so the bare form read as a local issue number. Co-Authored-By: Claude Opus 5 (1M context) * Record the review tooling the hub already authors The review loop was hand-rolled, which re-discovered two failures the hub script exists to prevent: findings suppressed into a review body reach no thread, and a request nothing picks up is indistinguishable from a slow reviewer. Co-Authored-By: Claude Opus 5 (1M context) * Drop the counted cross-reference from the tooling note A count of the bullets that follow it is wrong as soon as one is added. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- TODO.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/TODO.md b/TODO.md index 4db4e30..6c87102 100644 --- a/TODO.md +++ b/TODO.md @@ -35,7 +35,7 @@ The site is built and gated in CI. It is on GitHub, and it is not yet serving it ## Owed to the hub -The hub is owed a spec update for this repo's publishing type, tracked in [ProjectTemplate#456][hub-issue]. The measured deploy shape is reported there. +The hub is owed a spec update for this repo's publishing type. The change is [ProjectTemplate#558][hub-spec-issue], carrying proposed wording for both additions, and [ProjectTemplate#456][hub-issue] holds the intake questions and the measured answers. **Frame it as a variant of the existing registry-push leaf, not a new release surface.** A NuGet or PyPI leaf builds an artifact and pushes it to its own destination, contributing no `release-asset-*`. This repo does exactly that. Only two things differ, and neither changes the seam: @@ -99,6 +99,8 @@ Each of these was hit or nearly hit, and each is cheap to re-trip. - **Do not name any workflow `build-*-task.yml`** while the repo declares `source-only`, since `detect` is literally `["no build-*-task.yml"]`. - **Do not edit `.markdownlint-cli2.jsonc`, `repo-config/configure.sh`, or the two ruleset payloads.** They are carried verbatim and byte-matched against the hub. Scope a glob in the workflow instead. A reviewer finding a real defect in one of them is answered by declining locally and filing it at the hub, never by editing the file to satisfy the review. - **The hub's `main` can promote while a convergence pull request is open**, so ground truth moves underneath work that was correct when it started. It happened twice in one session on 2026-08-03, and the second time added drift the branch could not have known about. Re-run the audit against the hub ref actually carried before claiming convergence, and name that ref in the change, or the claim ages into a false one. +- **The hub authors `scripts/pr_review.py`, and hand-rolling the review loop re-discovers its bugs.** One `status` call reports rounds, head coverage, unresolved threads, suppressed findings across every round, and whether a request was ever picked up. `wait` runs the backoff in-process, so a review wait costs one turn rather than one per poll. It is read-only by design and the mutations stay explicit, so fetch and run it rather than reimplementing it. Its README documents the traps below as the reason it exists. +- **A review request can sit forever without being picked up, which looks exactly like patience.** Copilot raises a `copilot_work_started` timeline event within about half a minute of accepting; a request that never draws one is not slow, it is inert, and elapsed time cannot tell them apart. The event is REST-only. Recover by clearing the request with `union: false` and an empty `botIds`, then requesting again, after reading the pending set so a human reviewer is not dropped. - **The Copilot reviewer's login differs by API, and a wrong-form filter reads as a clean review.** REST reports `copilot-pull-request-reviewer[bot]`, GraphQL omits the suffix. A filter written in the other form matches nothing, and an empty result is indistinguishable from no findings. Assert the filter matched before trusting what it returned. - **A Copilot review hides findings in the review body, where the thread API cannot see them.** The `reviewThreads` query returns line threads only, so a review carrying `Suppressed comments (N)` in a `
` block reports zero unresolved while real findings sit unread. Read the review body itself, not just the threads, before calling a review loop finished. - **`gh pr merge --delete-branch` on a `develop -> main` promotion deletes `develop`.** Use a plain `gh pr merge --merge`. @@ -136,6 +138,7 @@ Secrets and variables, per environment. The App-token pair is repository-scoped [hub-issue]: https://github.com/ptr727/ProjectTemplate/issues/456 +[hub-spec-issue]: https://github.com/ptr727/ProjectTemplate/issues/558 [hub-registry]: https://github.com/ptr727/ProjectTemplate/blob/3b802b9eb9a841c0149d018f4db6ffa1b9419051/registry/repos.json [hub-report]: https://github.com/ptr727/ProjectTemplate/blob/3b802b9eb9a841c0149d018f4db6ffa1b9419051/reports/blog/audit.md [issue-549]: https://github.com/ptr727/ProjectTemplate/issues/549 From 882bd66b71b1a7be25725148516ff89173c6e6db Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Tue, 4 Aug 2026 15:42:21 -0700 Subject: [PATCH 10/11] Separate a transport failure from a missing header while waiting (#20) * Separate a transport failure from a missing header while waiting The reload wait read an unreachable host as a reply carrying no release, so a connection lost mid-wait reported as a config that never reloaded. Co-Authored-By: Claude Opus 5 (1M context) * Keep a missing header from reporting as an unreachable host Pipefail carries grep's no-match status out of the read, so a reachable host serving no release header reported as a lost connection. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- checks/check-live-urls.sh | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/checks/check-live-urls.sh b/checks/check-live-urls.sh index eba83c5..a5de29a 100755 --- a/checks/check-live-urls.sh +++ b/checks/check-live-urls.sh @@ -140,9 +140,15 @@ fi # Nothing else proves the rules answering are the ones just shipped, as no deploy restarts Caddy. # A stale config serves the previous release's rules while the new content is already live. +# Returns non-zero on a transport failure, and empty on a reply carrying no release header. +# Collapsing the two would report an unreachable host as a config that never reloaded. read_release() { - curl -s -o /dev/null -D- --max-time 30 "${AUTH[@]}" "$BASE/" | - grep -i '^x-blog-release:' | tr -d '\r' | sed 's/^[^:]*: *//' + local headers + headers=$(curl -sS -o /dev/null -D- --max-time 30 "${AUTH[@]}" "$BASE/" 2>"$CURLERR") || return 1 + printf '%s' "$headers" | grep -i '^x-blog-release:' | tr -d '\r' | sed 's/^[^:]*: *//' + # Explicit, because pipefail carries grep's no-match status out of the function, which would + # report a reachable host serving no release header as unreachable. + return 0 } got_release=$(printf '%s' "$preflight_headers" | grep -i '^x-blog-release:' | tr -d '\r' | sed 's/^[^:]*: *//') @@ -154,7 +160,11 @@ if [ -n "${EXPECT_RELEASE:-}" ]; then while [ "$got_release" != "$EXPECT_RELEASE" ] && [ "$waited" -lt "${RELOAD_TIMEOUT:-30}" ]; do sleep 1 waited=$((waited + 1)) - got_release=$(read_release) + if ! got_release=$(read_release); then + echo "FAIL: $BASE/ became unreachable after ${waited}s of waiting for the reload" >&2 + sed 's/^/ /' "$CURLERR" >&2 + exit 1 + fi done if [ "$got_release" != "$EXPECT_RELEASE" ]; then echo "FAIL preflight: after ${waited}s the rules are from release '${got_release:-}', expected '$EXPECT_RELEASE'" >&2 From 6b2c739476f241169eb6d76a5040b635ed3c3b9a Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Tue, 4 Aug 2026 15:53:57 -0700 Subject: [PATCH 11/11] Gate the deploy before validation, and match the bundle's directory mode (#21) The ref assertion ran beside validation rather than before it, so a mis-dispatched production deploy installed a toolchain and built a site before refusing. The upload cleared the setgid bit the bundle is assembled with, so the group would stop propagating below the first directory the transfer creates. A relative environment file is refused rather than resolved when it traverses, since a relative name is meant to reach the secrets directory. Co-authored-by: Claude Opus 5 (1M context) --- .github/workflows/deploy-site-task.yml | 2 +- .github/workflows/deploy-site.yml | 19 ++++++++++--------- deploy/make-release.sh | 7 ++++++- 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/.github/workflows/deploy-site-task.yml b/.github/workflows/deploy-site-task.yml index 8fd7f9e..3693b28 100644 --- a/.github/workflows/deploy-site-task.yml +++ b/.github/workflows/deploy-site-task.yml @@ -114,7 +114,7 @@ jobs: ENVIRONMENT: ${{ inputs.environment }} run: | set -Eeuo pipefail - rsync -az --mkpath --chmod=D755,F644 \ + rsync -az --mkpath --no-g --chmod=D2755,F644 \ --link-dest="/${ENVIRONMENT}/current/" \ -e "ssh -i ~/.ssh/deploy -o IdentitiesOnly=yes" \ "${RUNNER_TEMP}/bundle/releases/${RELEASE_ID}/" \ diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index f6e689c..773055d 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -18,15 +18,8 @@ concurrency: jobs: - # The same gate the pull request and a release run. - validate: - name: Validate sources job - uses: ./.github/workflows/validate-task.yml - permissions: - contents: read - # Staging deploys from any ref, since proving a branch before it merges is what staging is for. - # Asserted before Hugo is installed and before the key reaches the runner. + # First, so a mis-dispatched production deploy fails before anything is installed or written. assert-ref: name: Assert deploy ref job runs-on: ubuntu-latest @@ -39,9 +32,17 @@ jobs: exit 1 fi + # The same gate the pull request and a release run. + validate: + name: Validate sources job + needs: [ assert-ref ] + uses: ./.github/workflows/validate-task.yml + permissions: + contents: read + deploy: name: Deploy site job - needs: [ validate, assert-ref ] + needs: [ validate ] uses: ./.github/workflows/deploy-site-task.yml with: environment: ${{ inputs.environment }} diff --git a/deploy/make-release.sh b/deploy/make-release.sh index 63f9a43..d1e86d9 100755 --- a/deploy/make-release.sh +++ b/deploy/make-release.sh @@ -23,9 +23,14 @@ REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # The first argument overrides the root, being read after this. DEFAULT_ENV_FILE="$REPO/secrets/.env" ENV_FILE="${ENV_FILE:-$DEFAULT_ENV_FILE}" -# Anchored under the repo, so a relative name resolves the same from any working directory. +# A relative name resolves against the repo, so it means the same from any working directory. +# Traversal is refused rather than resolved, since a relative name is meant to reach secrets/. case "$ENV_FILE" in /*) ;; +*..*) + echo "ENV_FILE must not traverse: $ENV_FILE" >&2 + exit 1 + ;; *) ENV_FILE="$REPO/$ENV_FILE" ;; esac if [ -f "$ENV_FILE" ]; then