From a489ba15a63e9c7dbb6ae215b11d4f3d4fa6a57a Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Tue, 4 Aug 2026 19:51:50 -0700 Subject: [PATCH 1/3] Declare the generator pin once and record what the theme is a copy of Three conformance gaps against the fleet hugo type (ptr727/ProjectTemplate#560), plus two smaller items found alongside them. Closes #28. Closes #29. The Hugo version and checksum were declared in both validate-task.yml and deploy-site-task.yml, each with an instruction to update both and nothing enforcing it. A one-sided bump was silent and produced the failure the pin exists to prevent: validation building the site with one generator while the deploy shipped a tree built by another, each verifying its own checksum against its own version and both passing. No Dependabot ecosystem tracks Hugo, so there was no bot to catch the skew either. Both installs now call a composite action that owns the pin, so the two cannot diverge, and it asserts the extended build from the binary rather than inferring it from the file name. The vendored theme recorded no upstream ref, so nothing could be diffed, updated, or audited against it. themes/README.md now records the commit, recovered by matching all 125 tracked blobs against upstream history rather than guessed: 154d006e0182dfc7da38008323976b02e6bfab4a, describing as v8.0-138-g154d006. Every file matches it exactly except two, both additions in extension points the theme documents for the purpose, and both are listed with the note that Hugo would resolve them from the project root instead, which would make the next update a clean directory replace. The record sits outside PaperMod/ so replacing that directory does not take it with it. That exposed a scoping bug: the markdown glob excluded all of themes/, so a file we author about a vendored tree would not have been linted. It now excludes themes/*/** instead, reaching inside a theme rather than over the directory that holds them. Also: assert-ref and assert-environment ran without a permissions block, so two jobs that only echo and case-match inherited the repository default, and both are now permissions: {}. And deploy-site-task.yml exposed no outputs, so no caller could record what shipped; it now returns release-id and site-url, and the live check already proves that id is the one answering, which makes it the value a rollback names. Verified: actionlint clean, markdownlint 0 issues across 16 files with the provenance file now in scope and the vendored tree still out, editorconfig-checker clean on everything tracked, and the site builds under --panicOnWarning. Co-Authored-By: Claude Opus 5 (1M context) --- .github/actions/install-hugo/action.yml | 44 +++++++++++++++++++++++++ .github/workflows/deploy-site-task.yml | 30 +++++++++-------- .github/workflows/deploy-site.yml | 2 ++ .github/workflows/validate-task.yml | 23 +++---------- themes/README.md | 36 ++++++++++++++++++++ 5 files changed, 103 insertions(+), 32 deletions(-) create mode 100644 .github/actions/install-hugo/action.yml create mode 100644 themes/README.md diff --git a/.github/actions/install-hugo/action.yml b/.github/actions/install-hugo/action.yml new file mode 100644 index 0000000..2f7f055 --- /dev/null +++ b/.github/actions/install-hugo/action.yml @@ -0,0 +1,44 @@ +name: Install Hugo +description: Install the pinned Hugo extended build, verified by checksum before install. + +# The single declaration of which generator this repo builds with. +# It used to live in both validate-task.yml and deploy-site-task.yml, with an instruction to update both and nothing enforcing it. +# A one-sided bump was silent and produced exactly the failure the pin exists to prevent: validation building the site with one generator while the deploy shipped a tree built by another, each verifying its own checksum against its own version and both passing. +# No Dependabot ecosystem tracks Hugo, so both values move by hand and there was no bot to catch the skew either. +# +# Pinned by version and by checksum rather than installed from a floating action, because the site is reproducible only if the generator is, and a minor bump can change rendered output. +# Update both values together, from the checksums file on the Hugo release. + +inputs: + version: + description: Hugo version to install. + required: false + default: 0.164.0 + sha256: + description: SHA256 of the Hugo extended linux-amd64 .deb for that version. + required: false + default: 8325f3653032d0fc536503691f4833dc4eb6c6be02ee62466758f3f37a7f2fcd + +runs: + using: composite + steps: + + # A tampered or moved artifact fails at the checksum rather than producing a wrong site. + # The extended build is asserted from the binary rather than inferred from the file name, since the name is the only thing that carried that requirement before. + - name: Install Hugo step + shell: bash + env: + HUGO_VERSION: ${{ inputs.version }} + HUGO_SHA256: ${{ inputs.sha256 }} + 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 + hugo version | grep -q extended || { + echo "::error::Hugo reports a non-extended build, which cannot process this site's SCSS." + exit 1 + } diff --git a/.github/workflows/deploy-site-task.yml b/.github/workflows/deploy-site-task.yml index 3693b28..cd54490 100644 --- a/.github/workflows/deploy-site-task.yml +++ b/.github/workflows/deploy-site-task.yml @@ -7,12 +7,15 @@ on: 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 + 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 }} jobs: @@ -22,6 +25,8 @@ jobs: 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: @@ -44,6 +49,9 @@ jobs: environment: ${{ inputs.environment }} permissions: contents: read + outputs: + release-id: ${{ steps.release.outputs.id }} + site-url: ${{ vars.HUGO_BASEURL }} steps: @@ -53,15 +61,9 @@ jobs: with: fetch-depth: 0 + # The pin lives in the action, so the deploy and validation cannot install different generators. - 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 + uses: ./.github/actions/install-hugo # REQUIRE_BROTLI below makes a missing binary fatal, so this keeps the build from failing. - name: Install brotli step diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index 773055d..becbb25 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -23,6 +23,8 @@ jobs: assert-ref: name: Assert deploy ref job runs-on: ubuntu-latest + # Nothing here reads the repository, so it needs no token scope at all. + permissions: {} steps: - name: Assert ref matches environment step run: | diff --git a/.github/workflows/validate-task.yml b/.github/workflows/validate-task.yml index 2656e82..4ddcdce 100644 --- a/.github/workflows/validate-task.yml +++ b/.github/workflows/validate-task.yml @@ -5,13 +5,6 @@ name: Validate task on: workflow_call: -env: - # Hugo is pinned by version and checksum rather than installed from a floating action. - # The site build is only reproducible if the generator is, and a minor Hugo bump can change output. - # Update both values together, from the checksums file on the Hugo release. - HUGO_VERSION: 0.164.0 - HUGO_SHA256: 8325f3653032d0fc536503691f4833dc4eb6c6be02ee62466758f3f37a7f2fcd - jobs: # Source-only repo: lint plus a site build gated on the URL contract, using the same configs the editor uses. @@ -29,15 +22,16 @@ jobs: # 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. + # The markdown glob excludes the imported archive and the vendored theme trees. # Neither is authored here, and .markdownlint-cli2.jsonc is carried verbatim so it cannot scope them. + # The theme exclusion reaches inside a theme directory rather than all of themes/, so a file we author about a vendored tree is still linted. - name: Lint Markdown step uses: DavidAnson/markdownlint-cli2-action@6bf21b07787794f89a243495939cd651942aeabe # v24.1.0 with: globs: | **/*.md !content/** - !themes/** + !themes/*/** !public/** # The cspell gate covers README + HISTORY only. @@ -77,16 +71,9 @@ jobs: done python3 -c 'import yaml,sys; yaml.safe_load(open("hugo.yaml"))' - # Pinned by checksum, so a tampered or moved artifact fails here rather than producing a wrong site. + # The pin lives in the action, so validation and the deploy cannot install different generators. - 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 + uses: ./.github/actions/install-hugo # --panicOnWarning is the real gate. # Two PaperMod templates are overridden in layouts/ precisely so it can stay on. diff --git a/themes/README.md b/themes/README.md new file mode 100644 index 0000000..c1c7189 --- /dev/null +++ b/themes/README.md @@ -0,0 +1,36 @@ +# Vendored themes + +This directory holds third-party theme source, copied in rather than fetched by a manager. It sits outside `PaperMod/` deliberately, so replacing that directory wholesale on an update does not take this record with it. + +Vendoring is the decision; not recording what was vendored was the gap. Without an upstream ref there is no way to ask what changed upstream, whether a fix landed, or whether a local edit is still needed, and a 125-file copy is a large surface to carry blind. + +## PaperMod + +| | | +| --- | --- | +| Upstream | | +| Commit | `154d006e0182dfc7da38008323976b02e6bfab4a` | +| Committed upstream | 2026-05-10 | +| Describes as | `v8.0-138-g154d006` | +| License | MIT, retained at `PaperMod/LICENSE` | + +The commit was recovered by matching all 125 tracked blobs against upstream history rather than by reading a version marker, since the copy carries none. Every file matches that commit exactly except the two below, so the identification is not approximate. + +### Local edits + +Both sit in extension points the theme documents for this purpose, so neither is a fork of theme logic. + +| File | Edit | +| --- | --- | +| `PaperMod/assets/css/extended/blank.css` | The theme's custom-CSS slot, which ships empty. Carries the Lexend body font and the `gallery` and `gallery-cols-*` rules the gallery shortcode needs. | +| `PaperMod/layouts/_partials/extend_head.html` | The theme's head-extension partial, which ships empty. Carries the Google Fonts preconnect and stylesheet links for Lexend. | + +Both could live outside the vendored tree instead: Hugo resolves a project's own `assets/css/extended/` and `layouts/_partials/` ahead of the theme's, so moving them would make an update a clean directory replace with nothing to reapply. Worth doing at the next update rather than as a change of its own. + +Separately, `layouts/` at the repository root already overrides two theme templates, for the reason recorded in [`TODO.md`](../TODO.md): PaperMod uses APIs Hugo deprecated in 0.158, and `--panicOnWarning` would otherwise fail on the theme rather than on content. Whether those overrides are still needed is answerable by diffing against the commit above, which is what this record exists for. + +## Updating + +Compare against the recorded commit first, so the local edits above are known before anything moves. Replace `PaperMod/` with the new upstream tree, reapply the two edits (or move them out, per the note above), update the table here, and confirm the site still builds under `--panicOnWarning`, which is the gate the theme has failed before. + +No bot watches this. `.github/dependabot.yml` covers GitHub Actions only, since a vendored copy has no manifest to track, so an update is a deliberate act. From d531aafdf9843b645fd102189225c05b781fcdda Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Tue, 4 Aug 2026 20:04:30 -0700 Subject: [PATCH 2/3] Hardcode the Hugo pin so a caller cannot override it Copilot's point, and it is right: exposing version and sha256 as inputs with defaults left the divergence this action exists to remove, one level up. Two callers could pass different values and reintroduce the silent skew, and the only thing preventing it was that neither caller passes the arguments today. That is correctness by convention, which is what the original two-file pin also was. The pin is now hardcoded in the action, so callers cannot override it and every caller moves together or none does. Co-Authored-By: Claude Opus 5 (1M context) --- .github/actions/install-hugo/action.yml | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/.github/actions/install-hugo/action.yml b/.github/actions/install-hugo/action.yml index 2f7f055..e9af64a 100644 --- a/.github/actions/install-hugo/action.yml +++ b/.github/actions/install-hugo/action.yml @@ -9,15 +9,10 @@ description: Install the pinned Hugo extended build, verified by checksum before # Pinned by version and by checksum rather than installed from a floating action, because the site is reproducible only if the generator is, and a minor bump can change rendered output. # Update both values together, from the checksums file on the Hugo release. -inputs: - version: - description: Hugo version to install. - required: false - default: 0.164.0 - sha256: - description: SHA256 of the Hugo extended linux-amd64 .deb for that version. - required: false - default: 8325f3653032d0fc536503691f4833dc4eb6c6be02ee62466758f3f37a7f2fcd +# The pin is hardcoded below rather than exposed as inputs with defaults. +# An overridable input would let two callers pass different values and reintroduce the divergence this action exists to remove, which is the same defect one level up. +# A pin that callers cannot override is correct by construction rather than by everyone agreeing to omit the argument. +# Change it here, in one place, and every caller moves together or none does. runs: using: composite @@ -28,8 +23,8 @@ runs: - name: Install Hugo step shell: bash env: - HUGO_VERSION: ${{ inputs.version }} - HUGO_SHA256: ${{ inputs.sha256 }} + HUGO_VERSION: 0.164.0 + HUGO_SHA256: 8325f3653032d0fc536503691f4833dc4eb6c6be02ee62466758f3f37a7f2fcd run: | set -Eeuo pipefail deb="hugo_extended_${HUGO_VERSION}_linux-amd64.deb" From fa0fc199d0bbc003b6863a3a02f99f84bd160101 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Tue, 4 Aug 2026 20:14:45 -0700 Subject: [PATCH 3/3] Gate production on the full ref, and pin the transport's SSH options Two review findings from the hub's copy of this pipeline (ptr727/ProjectTemplate#560), both of which apply here identically. The production gate compared github.ref_name against "main". Tags and branches are separate namespaces that share a short name, so a tag named main would satisfy that comparison while pointing at an arbitrary commit, bypassing the one gate protecting production. It now compares github.ref against refs/heads/main, which is unambiguous. The rsync transport left host key checking and the known-hosts location to the runner's OpenSSH defaults. StrictHostKeyChecking defaults to ask, which a non-interactive runner resolves ambiguously, and the known-hosts path was the default rather than the file the deploy key step writes. Both are now pinned, along with BatchMode=yes so a credential problem fails the step instead of hanging the job to its timeout. The option string is declared once at workflow level, so the upload and the pointer flip cannot drift apart, which is the same single-declaration reasoning as the generator pin. Verified: actionlint clean at exit 0. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/deploy-site-task.yml | 17 +++++++++++++++-- .github/workflows/deploy-site.yml | 6 ++++-- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/.github/workflows/deploy-site-task.yml b/.github/workflows/deploy-site-task.yml index cd54490..e1b8f5d 100644 --- a/.github/workflows/deploy-site-task.yml +++ b/.github/workflows/deploy-site-task.yml @@ -17,6 +17,19 @@ on: 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 @@ -118,7 +131,7 @@ jobs: set -Eeuo pipefail rsync -az --mkpath --no-g --chmod=D2755,F644 \ --link-dest="/${ENVIRONMENT}/current/" \ - -e "ssh -i ~/.ssh/deploy -o IdentitiesOnly=yes" \ + -e "$SSH_TRANSPORT" \ "${RUNNER_TEMP}/bundle/releases/${RELEASE_ID}/" \ "${DEPLOY_SSH_USER}@${DEPLOY_SSH_HOST}:/${ENVIRONMENT}/releases/${RELEASE_ID}/" @@ -132,7 +145,7 @@ jobs: run: | set -Eeuo pipefail rsync -a --no-recursive \ - -e "ssh -i ~/.ssh/deploy -o IdentitiesOnly=yes" \ + -e "$SSH_TRANSPORT" \ "${RUNNER_TEMP}/bundle/current" \ "${DEPLOY_SSH_USER}@${DEPLOY_SSH_HOST}:/${ENVIRONMENT}/" diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index becbb25..aa727d0 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -20,6 +20,8 @@ jobs: # Staging deploys from any ref, since proving a branch before it merges is what staging is for. # First, so a mis-dispatched production deploy fails before anything is installed or written. + # Compared against the full ref rather than ref_name, because tags and branches are separate namespaces that share a short name. + # A tag named main would satisfy a ref_name comparison while pointing at an arbitrary commit, which is a bypass of the one gate protecting production. assert-ref: name: Assert deploy ref job runs-on: ubuntu-latest @@ -29,8 +31,8 @@ jobs: - 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 }}." + if [ "${{ inputs.environment }}" = "production" ] && [ "${{ github.ref }}" != "refs/heads/main" ]; then + echo "::error::Deploy production from main; got ${{ github.ref }}." exit 1 fi