From 09cfb31ba7b53eff6ce40e766f2cd602731d63b4 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 23 Aug 2026 18:36:17 -0700 Subject: [PATCH 1/3] Adopt the Hub's Reusable deploy-site-task.yml Workflow (#103) * Adopt the Hub's Reusable deploy-site-task.yml Replaces this repo's carried deploy-site-task.yml with the hub-hosted reusable workflow, pinned at the released 2.0.448 tag. The new .github/actions/deploy/action.yml hook carries this repo's own build, prune, and verify logic for the hub task's three modes. Prune is a deliberate no-op: OPERATIONS.md already states that the host's blog-prune-releases.timer owns retention, not this repo's deploy key. The GitHub Environment variable HUGO_BASEURL is renamed to SITE_BASE_URL on both staging and production (done live by the maintainer before this commit), matching the hub task's own generic, non-Hugo-specific interface. The deploy hook bridges it back to HUGO_BASEURL for Hugo and this repo's own scripts, which keep reading that name exactly as OPERATIONS.md documents. PANGOLIN_ACCESS_TOKEN_ID/PANGOLIN_ACCESS_TOKEN forward as the hub task's SITE_AUTH_TOKEN_ID/SITE_AUTH_TOKEN via the caller's explicit secrets: map, since secrets: inherit does not cross repositories. checks/check-env-docs.py gained a second, clearly-commented allowlist for GitHub Environment values now read by the hub-hosted task rather than by a workflow file this repo's own scan can see. * Retire the Resolved deploy-site-task.yml AUDIT.md Deviation This scope item recorded 'this repo's own progress adopting' the hub-hosted deploy-site-task.yml as deferred. That adoption landed in this same PR, so the bullet, and the intro sentence counting it as a third gap, are both stale now. --- .github/actions/deploy/action.yml | 101 +++++++++++++ .github/workflows/deploy-site-task.yml | 191 ------------------------- .github/workflows/deploy-site.yml | 10 +- AUDIT.md | 3 +- ENVIRONMENT.md | 4 +- TODO.md | 6 +- checks/check-env-docs.py | 19 ++- 7 files changed, 131 insertions(+), 203 deletions(-) create mode 100644 .github/actions/deploy/action.yml delete mode 100644 .github/workflows/deploy-site-task.yml diff --git a/.github/actions/deploy/action.yml b/.github/actions/deploy/action.yml new file mode 100644 index 0000000..8f09acd --- /dev/null +++ b/.github/actions/deploy/action.yml @@ -0,0 +1,101 @@ +# Blog's own deploy hook, run by the hub's deploy-site-task.yml in build, prune, and verify modes. +# The hub task forwards SITE_BASE_URL, SITE_AUTH_TOKEN_ID, and SITE_AUTH_TOKEN as plain process env vars, not through this composite action's own expression context. +# This hook therefore reads them as $SITE_BASE_URL and so on in its run: steps, never as ${{ env.* }}. +# Hugo's own HUGO_BASEURL convention is bridged from SITE_BASE_URL inside build mode only. +# Every other script in this repo keeps reading HUGO_BASEURL exactly as OPERATIONS.md documents. +name: Deploy hook (Blog) +description: The build, prune, and verify modes deploy-site-task.yml calls. + +inputs: + mode: + description: build, prune, or verify. + required: true + bundle-path: + description: Scratch path for the assembled release bundle, used by build mode. + required: false + default: '' + release-id: + description: The release id this run is building or verifying. + required: false + default: '' + environment: + description: The GitHub Environment name, used by verify mode. + required: false + default: '' + +runs: + using: composite + steps: + + # One update for the job, since each is a network round trip that can fail on its own. + # REQUIRE_BROTLI later makes a missing brotli fatal. + - name: Install build tools step + if: inputs.mode == 'build' + shell: bash + run: | + set -Eeuo pipefail + sudo apt-get update + sudo apt-get install --yes --no-install-recommends brotli + + # A file links under --link-dest only when size and mtime both match, and git stores no mtimes. + # This restores static/ only, since generated pages are written fresh by every build and can never match. + - name: Restore file mtimes step + if: inputs.mode == 'build' + uses: chetan/git-restore-mtime-action@d186aca54f8760da4dec55313195e51ed3ebb0b3 # v2.3 + with: + args: static + + # The outcome is asserted because a failing restore reports success and does nothing. + # A restored file cannot be newer than the commit it was dated from. + - name: Assert mtimes were restored step + if: inputs.mode == 'build' + shell: bash + run: | + set -Eeuo pipefail + bound=$(git log -1 --format=%ct) + newest=$(find static -type f -printf '%T@\n' | sort -n | tail -1 | cut -d. -f1) + echo "static/ newest mtime $newest, HEAD committed $bound, margin $((bound - newest))s" + if [ "$newest" -gt "$bound" ]; then + echo "::error::mtime restore did nothing: static/ carries files newer than HEAD's commit, so they still hold their checkout time and --link-dest will link nothing." + exit 1 + fi + + # The pin lives in the action, so this hook and the validate hook install the same generator. + - name: Install Hugo step + if: inputs.mode == 'build' + uses: ./.github/actions/install-hugo + + # HUGO_BASEURL is set here, once, from the hub's SITE_BASE_URL. + # Everything downstream, Hugo itself and the verify step below, reads whichever name it already expects. + # No other script in this repo touches SITE_BASE_URL directly. + - name: Assemble release bundle step + if: inputs.mode == 'build' + shell: bash + env: + REQUIRE_BROTLI: '1' + MTIME_RESTORED: '1' + run: | + set -Eeuo pipefail + HUGO_BASEURL="$SITE_BASE_URL" deploy/make-release.sh "${{ inputs.bundle-path }}" "${{ inputs.release-id }}" + + # Deliberately a no-op. + # OPERATIONS.md "Who Owns What" states it directly: the host's blog-prune-releases.timer owns retention, and nothing in this repo does. + # The deploy key needs no delete capability at all. + - name: No-op prune step + if: inputs.mode == 'prune' + shell: bash + run: | + echo "no-op: the host's blog-prune-releases.timer owns retention, not this deploy key" + + # The only step that observes the running site. + # The token pair is set on staging alone, since production answers unauthenticated. + - name: Verify URL contract step + if: inputs.mode == 'verify' + shell: bash + env: + EXPECT_SITE_ENV: ${{ inputs.environment }} + EXPECT_RELEASE: ${{ inputs.release-id }} + run: | + set -Eeuo pipefail + PANGOLIN_ACCESS_TOKEN_ID="$SITE_AUTH_TOKEN_ID" PANGOLIN_ACCESS_TOKEN="$SITE_AUTH_TOKEN" \ + checks/check-live-urls.sh "$SITE_BASE_URL" diff --git a/.github/workflows/deploy-site-task.yml b/.github/workflows/deploy-site-task.yml deleted file mode 100644 index f20e229..0000000 --- a/.github/workflows/deploy-site-task.yml +++ /dev/null @@ -1,191 +0,0 @@ -name: Deploy site task - -on: - workflow_call: - inputs: - environment: - description: The GitHub Environment to deploy to, production or staging. - required: true - type: string - outputs: - # A caller records what shipped, rather than having to read the host to find out. - # The verification below proves this id is the one answering, so it is the value a rollback names. - release-id: - description: The id of the release installed by this run. - value: ${{ jobs.deploy.outputs.release-id }} - site-url: - description: The base URL the deploy was verified against. - value: ${{ jobs.deploy.outputs.site-url }} - -# The transport's options are pinned rather than left to the runner's OpenSSH defaults, and declared once so the two transfers cannot drift apart. -# StrictHostKeyChecking=yes refuses an unknown or changed host key outright, where the default asks and a non-interactive runner then resolves that ambiguously. -# UserKnownHostsFile names the file the deploy key step writes, so the check reads the pinned value rather than whatever the runner image carries. -# BatchMode=yes makes every prompt an immediate failure, so a credential problem surfaces as a failed step rather than a job that hangs to its timeout. -# IdentitiesOnly=yes stops the agent offering other keys, so the deploy authenticates as the confined account or not at all. -env: - SSH_TRANSPORT: >- - ssh -i ~/.ssh/deploy - -o IdentitiesOnly=yes - -o StrictHostKeyChecking=yes - -o UserKnownHostsFile=~/.ssh/known_hosts - -o BatchMode=yes - -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 - # Nothing here reads the repository, so it needs no token scope at all. - permissions: {} - 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 - outputs: - release-id: ${{ steps.release.outputs.id }} - site-url: ${{ vars.HUGO_BASEURL }} - - steps: - - # Full history, because a shallow clone silently changes page metadata if git info is on. - # The mtime restore below needs it too: a shallow clone has no commit to date a file from. - - name: Checkout code step - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - - # One update for the job, since each is a network round trip that can fail on its own. - # REQUIRE_BROTLI later makes a missing brotli fatal. - - name: Install build tools step - run: | - set -Eeuo pipefail - sudo apt-get update - sudo apt-get install --yes --no-install-recommends brotli - - # A file links under --link-dest only when size and mtime both match, and git stores no mtimes. - # static/ only, since generated pages are written fresh by every build and can never match. - # The action rather than the Ubuntu package, whose git-tools v2022.12 calls `git whatchanged`. - # Current git refuses that and the tool exits 0 regardless, restoring nothing. - - name: Restore file mtimes step - uses: chetan/git-restore-mtime-action@d186aca54f8760da4dec55313195e51ed3ebb0b3 # v2.3 - with: - args: static - - # The outcome is asserted because a failing restore reports success and does nothing. - # A restored file cannot be newer than the commit it was dated from. - # A checkout happens after the commit it checks out, so the bound calibrates itself. - - name: Assert mtimes were restored step - run: | - set -Eeuo pipefail - bound=$(git log -1 --format=%ct) - newest=$(find static -type f -printf '%T@\n' | sort -n | tail -1 | cut -d. -f1) - echo "static/ newest mtime $newest, HEAD committed $bound, margin $((bound - newest))s" - if [ "$newest" -gt "$bound" ]; then - echo "::error::mtime restore did nothing: static/ carries files newer than HEAD's commit, so they still hold their checkout time and --link-dest will link nothing." - exit 1 - fi - - # The pin lives in the action, so the deploy and validation cannot install different generators. - - name: Install Hugo step - uses: ./.github/actions/install-hugo - - # 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. - # The action above restored the mtimes and runs the tool from its own directory, not PATH. - # MTIME_RESTORED therefore skips the script's own restore, which would look for a tool absent here. - - name: Assemble release bundle step - env: - HUGO_BASEURL: ${{ vars.HUGO_BASEURL }} - REQUIRE_BROTLI: '1' - MTIME_RESTORED: '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 --no-g --chmod=D2755,F644 \ - --link-dest="/${ENVIRONMENT}/current/" \ - -e "$SSH_TRANSPORT" \ - "${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_TRANSPORT" \ - "${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 index 2fb1f9f..a4a17d1 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -57,9 +57,15 @@ jobs: deploy: name: Deploy site job needs: [ validate ] - uses: ./.github/workflows/deploy-site-task.yml + uses: ptr727/ProjectTemplate/.github/workflows/deploy-site-task.yml@cd7bae9ea830d2e42d1f7bb6e45b3e7f631c0668 # 2.0.448 with: environment: ${{ inputs.environment }} permissions: contents: read - secrets: inherit + # Cross-repo, so secrets: inherit does not apply, and the names differ from what this repo stores. + # PANGOLIN_ACCESS_TOKEN_ID/PANGOLIN_ACCESS_TOKEN forward as the hub task's generic SITE_AUTH_TOKEN_ID/SITE_AUTH_TOKEN. + # Production maps both to empty, since neither secret is set there, which the hub task's own assert step treats as a public site. + secrets: + DEPLOY_SSH_PRIVATE_KEY: ${{ secrets.DEPLOY_SSH_PRIVATE_KEY }} + SITE_AUTH_TOKEN_ID: ${{ secrets.PANGOLIN_ACCESS_TOKEN_ID }} + SITE_AUTH_TOKEN: ${{ secrets.PANGOLIN_ACCESS_TOKEN }} diff --git a/AUDIT.md b/AUDIT.md index bc5f462..674bd28 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -8,9 +8,8 @@ The ground truth is the hub's committed `repo-config/` payloads, which this repo This repo declares `types: ["source-only"]` and `workflowModel: release` with `lineEndings: "lf"`. -Two of those three are deliberate deviations from what the fleet spec would predict, recorded here rather than left to be rediscovered as drift. A third gap is not a deviation from the spec but from this repo's own progress adopting it, recorded the same way: +Both are deliberate deviations from what the fleet spec would predict, recorded here rather than left to be rediscovered as drift: -- **`deploy-site.yml` still calls this repo's own local `deploy-site-task.yml`, not the hub-hosted one [WORKFLOW.md](./WORKFLOW.md) describes.** Splitting `make-release.sh`'s hard-link and assertion logic into the documented build/prune/verify hook shape is deferred: this repo's script layout (pruning lives inside `make-release.sh` rather than as its own script) doesn't match what the adoption guide assumes, and untangling that on the live SSH deploy path needs more care than a quick fix gives it. `WORKFLOW.md` describes the fleet's target shape, not yet this repo's actual one, for this one guarantee. - **`lineEndings: "lf"` on a `release` repo.** [`GOVERNANCE.md` "Line Endings"](./GOVERNANCE.md#line-endings) grants the native-platform default to operational repos only and holds `release` repos to the CRLF fleet default. Every consumer here is Linux: Hugo builds in CI, Caddy and OpenSSH read their config on Ubuntu, and the deploy scripts run there. Taking CRLF would mean an LF override for the shell scripts, the workflow YAML, the Caddyfile, the generated Caddy maps, and the content tree, which is the over-normalization that rule exists to prevent. The rule ties the ending to the workflow model when the thing that actually determines it is the consuming platform. - **`types: ["source-only"]` rather than `docs`.** `docs` detects a "governance-only repo" and asserts that CI runs linting only with no build. Both are false here, since this repo builds a site with Hugo and gates it on a URL contract. `source-only` detects "no `build-*-task.yml`", which is true, and its checks describe the release shape this repo actually has. Both selectors resolve to the same 24 baseline files, so the choice costs nothing and only one of them is honest. diff --git a/ENVIRONMENT.md b/ENVIRONMENT.md index e39b85f..473775c 100644 --- a/ENVIRONMENT.md +++ b/ENVIRONMENT.md @@ -64,7 +64,7 @@ Held on the `production` and `staging` environments. The deploy workflow reads n | Value | Kind | Names | | --- | --- | --- | -| `HUGO_BASEURL` | variable | the base URL, used twice: the site is built with it and `check-live-urls.sh` is pointed at it | +| `SITE_BASE_URL` | variable | the base URL, used twice: `.github/actions/deploy/action.yml` builds the site with it (as `HUGO_BASEURL`) and points `check-live-urls.sh` at it | | `DEPLOY_SSH_HOST` | variable | the deploy endpoint | | `DEPLOY_SSH_USER` | variable | the confined deploy account | | `DEPLOY_SSH_KNOWN_HOSTS` | variable | the pinned host key. A variable rather than a secret, deliberately, since it is public by nature | @@ -72,7 +72,7 @@ Held on the `production` and `staging` environments. The deploy workflow reads n | `PANGOLIN_ACCESS_TOKEN_ID` | secret | as above, for an environment behind the gate | | `PANGOLIN_ACCESS_TOKEN` | secret | as above | -**`HUGO_BASEURL` being read twice is the trap worth knowing.** A wrong value bakes the wrong address into every canonical tag and then runs the full URL contract against that same wrong address, so the deploy verifies itself and passes. +**`SITE_BASE_URL` being read twice is the trap worth knowing.** A wrong value bakes the wrong address into every canonical tag and then runs the full URL contract against that same wrong address, so the deploy verifies itself and passes. Its generic name is the hub's own `deploy-site-task.yml` interface, since that task is not Hugo-specific. Blog's own scripts and `OPERATIONS.md` keep reading `HUGO_BASEURL`, which the deploy hook bridges from `SITE_BASE_URL` in one place. **A host rebuild regenerates the SSH host keys and the pinned value stops matching**, which fails every deploy closed and blocks the rollback path at the same moment a rebuild makes both matter. Replace `DEPLOY_SSH_KNOWN_HOSTS` on **both** environments before the first deploy after a rebuild. diff --git a/TODO.md b/TODO.md index e9809de..ebb4007 100644 --- a/TODO.md +++ b/TODO.md @@ -31,9 +31,9 @@ The site is built, gated in CI, and deployed to staging by pipeline. It is not y - **Prove a rollback through the pipeline.** A forced mid-deploy failure, then a flip back to the previous release, verified by `EXPECT_RELEASE` rather than by the transport exiting zero. The server side has been measured at well under a second by hand; what is unproven is that a **pipeline** run leaves the site serving when its deploy fails part way. - **Production is deployed, which the VPS agent calls M7a, done 2026-08-08.** `blog.insanegenius.net` answers `200` unauthenticated on a Let's Encrypt certificate issued 2026-08-07, with the serving release read from the `X-Blog-Release` header rather than from a pipeline's exit code. **M7a itself was `20260808-154717`. What it serves today is in the State table above and is deliberately not repeated here**, since a release id moves with every deploy and a second copy of it goes stale by the afternoon. The host side verified that first production release independently, 9/9 unauthenticated with the built `baseURL` read from the deployed bytes rather than from this repo's config, across a 3,095-request gate run with no unexplained 404s. What remains is **M7b, the `.com` cutover**, and the sub-items below are where this repo stands against it, two of them owed and one already answered. The VPS agent's §19, §20, §23 and §24 carry the detail and that file is not in the repository, so pull it first per [`OPERATIONS.md`](./OPERATIONS.md) "The Channel Between the Two Sides": - - **`HUGO_BASEURL` on the `production` environment is set to `https://blog.insanegenius.net/`**, done 2026-08-07. It held `https://blog.insanegenius.com/`, the live WordPress address, which is what the workflow both builds with and points the live check at, so a deploy would have baked the old platform's address into every canonical tag, feed link and `sitemap.xml` and then run the whole live check at the live site to verify it. **Setting it back to `.com` at M7b is the other half and is not done.** + - **`SITE_BASE_URL` on the `production` environment is set to `https://blog.insanegenius.net/`**, done 2026-08-07 (renamed from `HUGO_BASEURL` when this repo adopted the hub's `deploy-site-task.yml`, same value throughout). It held `https://blog.insanegenius.com/`, the live WordPress address, which is what the workflow both builds with and points the live check at, so a deploy would have baked the old platform's address into every canonical tag, feed link and `sitemap.xml` and then run the whole live check at the live site to verify it. **Setting it back to `.com` at M7b is the other half and is not done.** - **Production emits `X-Robots-Tag: noindex, nofollow` for the length of the rehearsal**, deliberately, because `.net` serves a public duplicate of a live site and Certificate Transparency publishes the hostname. Where a check asserts `index, follow`, make the expected value a parameter rather than flipping a literal, since it reverts at M7b and a hardcoded literal is one more thing to remember at the wrong moment. - - **The two questions in §19.3 are answered.** `HUGO_BASEURL` holds the interim `.net` name, per the item above. Exactly one place hardcodes `blog.insanegenius.com`: `baseURL` on line 1 of `hugo.yaml`, which is the production default every environment overrides through `HUGO_BASEURL`. Nothing under `checks/`, `deploy/`, `layouts/`, or `.github/` carries it. + - **The two questions in §19.3 are answered.** `SITE_BASE_URL` holds the interim `.net` name, per the item above. Exactly one place hardcodes `blog.insanegenius.com`: `baseURL` on line 1 of `hugo.yaml`, which is the production default every environment overrides through `HUGO_BASEURL`. Nothing under `checks/`, `deploy/`, `layouts/`, or `.github/` carries it. - **`robots.txt` is decided, built, and deployed, 2026-08-08.** The site emits one, `enableRobotsTXT` is set, and the theme's template derives the `Sitemap:` line from the built `baseURL`, so it names `.net` during the rehearsal and `.com` after the cutover with nothing to remember at M7b. `/robots.txt/` redirects to the real file rather than to the home page, and `check-url-parity.py` gates all of it. Verified from the served bytes on release `20260808-154717`: `/robots.txt` answers 200 advertising `https://blog.insanegenius.net/sitemap.xml`, `/robots.txt/` 301s to it, and `sitemap.xml` carries 312 `.net` URLs and zero `.com`. The record below is kept because the reasoning is what the next decision about crawl directives will need. - **The first deploy did not fix the 404, and that is what turned this from a gap into a decision.** The VPS agent raised it in §22.10 and both halves were measured rather than assumed: the site emitted no `robots.txt` at all, because `hugo.yaml` set no `enableRobotsTXT`, so the 404 survived the deploy and `X-Robots-Tag` was the only control, while `sitemap.xml` **was** emitted and became fetchable on the interim name at that same deploy. A crawler got a full sitemap and no robots file. `enableRobotsTXT` is now set and a deploy has carried it, so this describes the state up to release `20260808-041050` rather than what is served today. - **At the cutover this stops being a gap and becomes a loss, which is the half neither side had checked.** The live `.com` blog **serves a `robots.txt` today, carrying a `Sitemap:` line**. Because this site emitted none, M7b would not have been a return to a previous state, it would have been a move from having crawl directives to having none on a site that has had them for years, and the sitemap pointer would have gone with them. The VPS agent measured this from the outside in §23.3, will not put a file in this repository's bundle, and has made it a decision that blocks step 1 of the M7b checklist rather than one discovered after it. The minimum that preserves today's behavior is `User-agent: *`, no `Disallow`, and the sitemap line, since every `Disallow` the old platform serves names a WordPress path this site does not have. **That is what was chosen**, out of three options: preserve today's behavior, write what this site actually wants, or keep emitting nothing and accept the loss. The sitemap URL is derived from the built `baseURL` rather than typed, which is what makes the choice survive the cutover without a second edit. @@ -194,7 +194,7 @@ 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 | -| `HUGO_BASEURL` | variable | +| `SITE_BASE_URL` | variable | | `PANGOLIN_ACCESS_TOKEN_ID`, `PANGOLIN_ACCESS_TOKEN` | secret, staging only | | `CODEGEN_APP_CLIENT_ID`, `CODEGEN_APP_PRIVATE_KEY` | secret, both stores | diff --git a/checks/check-env-docs.py b/checks/check-env-docs.py index acc40e5..4fae408 100755 --- a/checks/check-env-docs.py +++ b/checks/check-env-docs.py @@ -50,18 +50,28 @@ "MTIME_RESTORED", } +# Real, stored GitHub Environment values, invisible to WORKFLOWS for a different reason. +# The hub-hosted deploy-site-task.yml reads these by its own vars.X/secrets.X reference. +# Since that reference lives outside this repo's own workflow files, this scan never sees them declared. +HUB_HOSTED_ENVIRONMENT_VALUES = { + "SITE_BASE_URL", + "DEPLOY_SSH_HOST", + "DEPLOY_SSH_USER", + "DEPLOY_SSH_KNOWN_HOSTS", +} + # Names that look like configuration to the patterns above but are not. # ENVIRONMENT and RELEASE_ID are computed inside the workflow and passed down, and # GITHUB_* is the runner's own namespace. IGNORE = {"ENVIRONMENT", "RELEASE_ID", "SSH_TRANSPORT"} -DECL = re.compile(r"^([A-Z][A-Z0-9_]*)=", re.M) -COMMENTED_DECL = re.compile(r"^#\s*([A-Z][A-Z0-9_]*)=", re.M) +DECL = re.compile(r"^([A-Z][A-Z0-9_]*)=", re.MULTILINE) +COMMENTED_DECL = re.compile(r"^#\s*([A-Z][A-Z0-9_]*)=", re.MULTILINE) GH_REF = re.compile(r"\b(?:vars|secrets)\.([A-Z][A-Z0-9_]*)\b") # A row is `| `NAME` | ...`, and the backticks are what separate a described value from a # mention of one in a sentence. The trailing `=value` is optional because a knob is # documented as REQUIRE_BROTLI=1, which names the value that switches it on. -DOC_ROW = re.compile(r"^\|\s*`([A-Z][A-Z0-9_]*)(?:=[^`]*)?`", re.M) +DOC_ROW = re.compile(r"^\|\s*`([A-Z][A-Z0-9_]*)(?:=[^`]*)?`", re.MULTILINE) # Values the doc names in prose rather than in a table row, which is how the three # commented-out template keys and the two bot secrets are covered. DOC_INLINE = re.compile(r"`([A-Z][A-Z0-9_]{2,})(?:=[^`]*)?`") @@ -103,6 +113,9 @@ def note(name: str, where: str) -> None: for name in KNOBS: note(name, "per-invocation knob") + for name in HUB_HOSTED_ENVIRONMENT_VALUES: + note(name, "read by the hub-hosted deploy-site-task.yml") + undocumented = sorted(n for n in declared if n not in documented_any) # Only table rows count as "described", so a value the doc merely mentions in passing # is not treated as having a description it can be removed against. From 0b2f50061dd9440fe8f19a3541f16649ce893089 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 23 Aug 2026 19:10:15 -0700 Subject: [PATCH 2/3] Rename PANGOLIN_ACCESS_TOKEN_* to SITE_AUTH_TOKEN_* (#104) * Rename PANGOLIN_ACCESS_TOKEN_ID/PANGOLIN_ACCESS_TOKEN to SITE_AUTH_TOKEN_ID/SITE_AUTH_TOKEN The stored GitHub secret on staging is renamed (done live by the maintainer, same values), matching the hub task's own generic, auth-mechanism-agnostic interface, the same shape as the earlier SITE_BASE_URL rename. The deploy hook already bridges the hub's SITE_AUTH_TOKEN_ID/ SITE_AUTH_TOKEN into PANGOLIN_ACCESS_TOKEN_ID/PANGOLIN_ACCESS_TOKEN for check-live-urls.sh, so no hook change is needed. That script keeps its own Pangolin-specific naming, and the local secrets/*.env convention and example.env are unaffected. * Update spec/secrets.json for Both Rename Rounds environmentSecrets.staging still named PANGOLIN_ACCESS_TOKEN_ID/ PANGOLIN_ACCESS_TOKEN, and variables still named HUGO_BASEURL, neither updated when the live GitHub Environment values were renamed. Both are now SITE_AUTH_TOKEN_ID/SITE_AUTH_TOKEN and SITE_BASE_URL, matching what AUDIT.md's manual secrets check actually reads against. --- .github/workflows/deploy-site.yml | 9 ++++----- ENVIRONMENT.md | 4 ++-- TODO.md | 2 +- spec/secrets.json | 6 +++--- 4 files changed, 10 insertions(+), 11 deletions(-) diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index a4a17d1..8e3fdfc 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -62,10 +62,9 @@ jobs: environment: ${{ inputs.environment }} permissions: contents: read - # Cross-repo, so secrets: inherit does not apply, and the names differ from what this repo stores. - # PANGOLIN_ACCESS_TOKEN_ID/PANGOLIN_ACCESS_TOKEN forward as the hub task's generic SITE_AUTH_TOKEN_ID/SITE_AUTH_TOKEN. - # Production maps both to empty, since neither secret is set there, which the hub task's own assert step treats as a public site. + # Cross-repo, so secrets: inherit does not apply. + # Production maps both auth-token secrets to empty, since neither is set there, which the hub task's own assert step treats as a public site. secrets: DEPLOY_SSH_PRIVATE_KEY: ${{ secrets.DEPLOY_SSH_PRIVATE_KEY }} - SITE_AUTH_TOKEN_ID: ${{ secrets.PANGOLIN_ACCESS_TOKEN_ID }} - SITE_AUTH_TOKEN: ${{ secrets.PANGOLIN_ACCESS_TOKEN }} + SITE_AUTH_TOKEN_ID: ${{ secrets.SITE_AUTH_TOKEN_ID }} + SITE_AUTH_TOKEN: ${{ secrets.SITE_AUTH_TOKEN }} diff --git a/ENVIRONMENT.md b/ENVIRONMENT.md index 473775c..c64cfcd 100644 --- a/ENVIRONMENT.md +++ b/ENVIRONMENT.md @@ -69,8 +69,8 @@ Held on the `production` and `staging` environments. The deploy workflow reads n | `DEPLOY_SSH_USER` | variable | the confined deploy account | | `DEPLOY_SSH_KNOWN_HOSTS` | variable | the pinned host key. A variable rather than a secret, deliberately, since it is public by nature | | `DEPLOY_SSH_PRIVATE_KEY` | secret | the deploy key, held behind an `rrsync` forced command | -| `PANGOLIN_ACCESS_TOKEN_ID` | secret | as above, for an environment behind the gate | -| `PANGOLIN_ACCESS_TOKEN` | secret | as above | +| `SITE_AUTH_TOKEN_ID` | secret | as above, for an environment behind the gate. `.github/actions/deploy/action.yml` reads it as `PANGOLIN_ACCESS_TOKEN_ID` for `check-live-urls.sh` | +| `SITE_AUTH_TOKEN` | secret | as above, bridged to `PANGOLIN_ACCESS_TOKEN` the same way | **`SITE_BASE_URL` being read twice is the trap worth knowing.** A wrong value bakes the wrong address into every canonical tag and then runs the full URL contract against that same wrong address, so the deploy verifies itself and passes. Its generic name is the hub's own `deploy-site-task.yml` interface, since that task is not Hugo-specific. Blog's own scripts and `OPERATIONS.md` keep reading `HUGO_BASEURL`, which the deploy hook bridges from `SITE_BASE_URL` in one place. diff --git a/TODO.md b/TODO.md index ebb4007..1a5d53b 100644 --- a/TODO.md +++ b/TODO.md @@ -195,7 +195,7 @@ 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 | | `SITE_BASE_URL` | variable | -| `PANGOLIN_ACCESS_TOKEN_ID`, `PANGOLIN_ACCESS_TOKEN` | secret, staging only | +| `SITE_AUTH_TOKEN_ID`, `SITE_AUTH_TOKEN` | secret, staging only | | `CODEGEN_APP_CLIENT_ID`, `CODEGEN_APP_PRIVATE_KEY` | secret, both stores | `DEPLOY_SSH_PRIVATE_KEY` holds the same key in both environments, per the decision above. The environment split still carries the base URL, the SSH endpoint, and the staging-only token pair, so it is not decorative. diff --git a/spec/secrets.json b/spec/secrets.json index 69d028c..0626156 100644 --- a/spec/secrets.json +++ b/spec/secrets.json @@ -30,13 +30,13 @@ "DEPLOY_SSH_HOST", "DEPLOY_SSH_USER", "DEPLOY_SSH_KNOWN_HOSTS", - "HUGO_BASEURL" + "SITE_BASE_URL" ], "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" + "SITE_AUTH_TOKEN_ID", + "SITE_AUTH_TOKEN" ], "production": [] }, From 1048c947cf0b9fb6e374bca31a977ec8e073a4f9 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 23 Aug 2026 20:44:34 -0700 Subject: [PATCH 3/3] Harden the Verify Step Against Unset Auth Tokens (#106) The verify step runs under set -u and expanded $SITE_AUTH_TOKEN_ID/ $SITE_AUTH_TOKEN directly. GitHub Actions' env: block always defines a mapped key, even empty, so the hub task's own unconditional env mapping should already make this safe on production. Safe expansion removes the doubt regardless, at no cost, right before this path's first production dispatch. --- .github/actions/deploy/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/deploy/action.yml b/.github/actions/deploy/action.yml index 8f09acd..04ba9d4 100644 --- a/.github/actions/deploy/action.yml +++ b/.github/actions/deploy/action.yml @@ -97,5 +97,5 @@ runs: EXPECT_RELEASE: ${{ inputs.release-id }} run: | set -Eeuo pipefail - PANGOLIN_ACCESS_TOKEN_ID="$SITE_AUTH_TOKEN_ID" PANGOLIN_ACCESS_TOKEN="$SITE_AUTH_TOKEN" \ + PANGOLIN_ACCESS_TOKEN_ID="${SITE_AUTH_TOKEN_ID:-}" PANGOLIN_ACCESS_TOKEN="${SITE_AUTH_TOKEN:-}" \ checks/check-live-urls.sh "$SITE_BASE_URL"