Declare the generator pin once and record what the theme is a copy of - #30
Conversation
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) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR closes #28 and #29 by centralizing the Hugo generator pin into a single composite action and documenting the provenance of the vendored PaperMod theme, while tightening least-privilege permissions for assertion-only jobs.
Changes:
- Add a reusable composite action (
.github/actions/install-hugo) that installs the pinned Hugo extended build and verifies it by checksum. - Update validation and deploy workflows to use the shared Hugo installer and adjust markdownlint globs so
themes/README.mdis linted while vendored theme trees remain excluded. - Add provenance documentation for the vendored PaperMod theme and reduce token permissions for assertion jobs.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| themes/README.md | New provenance/update record for vendored themes (PaperMod). |
| .github/workflows/validate-task.yml | Switch Hugo install to the composite action; adjust markdownlint globs to lint themes/README.md but exclude theme trees. |
| .github/workflows/deploy-site.yml | Set permissions: {} on assert-ref job (no repo access needed). |
| .github/workflows/deploy-site-task.yml | Add workflow outputs and job outputs; set permissions: {} on assert-environment; switch Hugo install to the composite action. |
| .github/actions/install-hugo/action.yml | New composite action that installs and asserts Hugo extended, pinned by version+SHA256. |
Suppressed comments (1)
.github/workflows/deploy-site-task.yml:18
- GitHub Actions expressions can’t access hyphenated output names via dot notation (this parses like subtraction). Use bracket notation when referencing the job output key
site-url.
value: ${{ jobs.deploy.outputs.site-url }}
Answering the suppressed review commentIt carries no thread, so quoting it here. From the collapsed low-confidence block on the current review, against
Declining, for the same reason as the threaded copy of this finding against line 15, answered in full there. In short: hyphens are legal in Actions property dereference (the name must start with a letter or Both instances stay as they are. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Suppressed comments (1)
.github/actions/install-hugo/action.yml:36
- This action is intended to be the single source of truth for the Hugo pin, but exposing
version/sha256as overridable inputs allows callers to pass different values in different workflows and reintroduce the same silent divergence risk this refactor is meant to eliminate. Hardcode the pin inside the action so workflows cannot override it.
inputs:
version:
description: Hugo version to install.
required: false
default: 0.164.0
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) <noreply@anthropic.com>
Answering the suppressed comment from round 2No thread, so quoting it. Against
Right, and taken in This is the defect the pull request is about, moved up one level rather than removed. Two callers could have passed different values and produced exactly the skew described in #29: validation building with one generator while the deploy ships a tree built by another. The only thing preventing it was that neither caller passes the arguments today, which is correctness by convention, which is what the original two-file pin also was. Replacing one convention with another is not the fix I claimed to have made. The pin is now hardcoded in the action's step Worth noting this is the second finding on these two pull requests of the form "the safety here rests on an argument rather than on construction" — the other being a release id whose uniqueness depended on step ordering, in ptr727/ProjectTemplate#560. Both were worth taking. The other suppressed comment on this round is the hyphenated-output-name claim against line 18, which is a repeat of round 1 and is already answered. Still declining it: hyphens are legal in Actions property dereference, the hub's canonical |
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) <noreply@anthropic.com>
Closes #456. Closes #558. Blog has been running a Hugo build with an rsync-over-SSH deploy to a host we own, and nothing governed it. `WORKFLOW.md`'s `Output Seam by Destination` had no row for a filesystem on our own host, `registry/repos.schema.json`'s `target` enum had no member for it, and Blog stood cataloged as `source-only` behind two interim driftNotes recording the deferral. ## What lands **The `hugo` type** (`spec/project-types.json`), nine checks. Only `hugo.build.strict` names the generator, where a generator-specific flag *is* the letter. The rest are phrased generator-agnostically, so promoting them to a shared type when a second generator arrives is a registry edit rather than a rewrite. `spec/type-model.md` grows a **Generators** section stating that rule and why there is no `static-site` to `hugo` hierarchy at one member. **The `self-hosted` target and the `deploy-ssh` mechanism.** What a repo builds and where the result lands stay separate axes, so an SMB or S3 publisher later is a new *mechanism*, not a new type. **D4.6 and D5.6.** D4.6 requires the deploy to assert *which release* and *which environment* answered, polling to a bounded timeout, rather than trusting the transport's exit status. D5.6 requires the prune of a durable destination to be asserted on the host that was written to. **A reference leaf pair** in `catalog/snippets/workflows/`, plus the 5A addendum, scenarios S12/S13, and a section 6 walkthrough. **Blog reclassified** to `["hugo", "source-only"]` with both publish targets. ## Three questions a reviewer will ask **Does D4.3 need extending?** No. It enumerates how a repo reaches the tag-only release shape, and this destination adds no fourth route: the deploy is a separate dispatch that touches no release, and Blog reaches tag-only through the source-only route already listed. **Why are `requires` and `stores` empty on `deploy-ssh`?** Not for want of credentials. They are per-environment GitHub Environment secrets, which neither `validate.py` nor `audit.py` can enumerate. `validate.py:200` would force any `requires` name into the repo's `requiredSecrets`, and `audit.py:615` unions that into the **actions** store expectation, so listing them guarantees a false DEFECT on a correctly configured repo. The new optional `environments` block records the names as operator documentation and says plainly that it is not a gate, so a clean audit is not evidence an environment is configured. Extending the `stores` enum instead was measured and rejected: `audit.py:606` seeds `required_by_store` with two keys and `:610` indexes it unguarded, so an unknown store raises `KeyError` for every repo whose `publish[]` maps to that mechanism. Follow-up folded into `TODO.md`'s locally-required-secrets entry, since it is the same missing axis. **Why is the leaf concrete rather than parameterized?** An eleven-input generalized transport leaf was proposed and rejected. The other twelve leaves take `ref`/`branch`/`smoke` and nothing else; reuse is by vendored copy, so a copier edits a `run:` line for free; the hub never executes these files, so eleven parameters would be untested surface presented as canonical; and naming it for the transport is the mistake the type name avoids. The two outputs were kept, since Blog currently has none and no caller can record what shipped. ## Two checks Blog fails today Recorded as driftNotes naming their check id, so the next audit retires them mechanically. | Check | What is wrong | Filed | | --- | --- | --- | | `hugo.vendored.provenance` | The vendored theme is 125 tracked files with no `.gitmodules`, no recorded upstream ref, and no Dependabot ecosystem covering it. | ptr727/Blog#28 | | `hugo.generator.pinned` | The generator version and checksum are pinned in two workflows with nothing asserting they agree. | ptr727/Blog#29 | Both are fixed in ptr727/Blog#30. ## A third check was wrong, and the second commit fixes it `hugo.deploy.retention` and D5.6 originally required the deploy to prune the destination and assert the count on the host. **Blog cannot, and should not be made to.** Its deploy credential is a forced `rsync` command confined write-only, so the server never acts as sender and the key can neither delete a release nor read the destination back to count one. Blog's own ownership table already assigns release-prune timers to the host, which is the correct resolution rather than a gap. As first written the check was unsatisfiable for exactly the repos that confine their credentials properly, and the only way to pass it was to widen a deliberately narrow key. That trades a real confinement boundary for a green check, so the check was wrong rather than the design. Retention is now bounded by a **declared count with one side recorded as owning the prune**: the deploy asserts it where its credential can observe the destination, the host owns it where the credential cannot. What the guarantee still rejects is a prune against a local scratch tree, a best-effort prune, and neither side owning it, since each then assumes the other prunes. The reference leaf keeps the assert-in-pipeline shape and says when to delete the step. Blog passes the corrected version, so its retention driftNote is dropped and ptr727/Blog#27 is closed as invalid. Worth noting as evidence for the type: a check written from the hub's side alone, against a real repo, was wrong on first contact in a way only the repo could reveal. ## The record was wrong, and is corrected rather than deleted `TODO.md`'s intake entry predicted three things that are wrong against what Blog actually runs, and writing the type from the prediction would have encoded requirements the repo does not meet: | Predicted | Measured | | --- | --- | | theme as a Dependabot-tracked submodule | vendored, no upstream ref recorded | | generator at `latest`, not pinned | pinned by version **and** SHA256 | | tag cut last, after the live check | deploy is a separate dispatch; the release is untouched | ## Drive-by fixes - `spec/scope-model.md`'s project-type token table was missing `cpp`, pre-existing. - `STANDUP.md`'s new-type procedure never mentioned the registry `target` enum, which is exactly what the first repo declaring a new destination fails `validate.py` on. It also now warns that a leaf must not be named `build-*-task.yml`, since `source-only.detect` is literally that string. ## Verification | Gate | Result | | --- | --- | | `spec/validate.py` | 22 cataloged, 0 backlog, clean | | `spec/audit.py --selftest` | PASS | | `scripts/repo_gate.py` | eol 0, sha-pin 0 | | `scripts/prose_lint.py --diff develop` | clean (the 522-violation tree backlog is #519's, untouched) | | markdownlint-cli2 | 0 issues in 44 files | | actionlint | clean | | editorconfig-checker | only untracked `.artifacts/` and `Tests/obj/` build output | Selector resolution verified directly: Blog resolves to `{hugo, source-only, release, dispatch-only, pull}`, the deploy leaf is selected, `build-release-task.yml` is correctly **not** selected, the `release` develop payload is selected over the operational one, and `self-hosted` routes to `deploy-ssh` with an empty `requires` so no secret finding is manufactured. ## Not in this PR - The three Blog-side fixes, filed and fixed downstream. - A fresh `reports/blog/audit.md`; the current one predates the deploy and its staleness is noted in the conformance matrix. - Real environment-scoped store support in `audit.py`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ate (#568) Resolves the audit-tooling half of #563, filed from `ptr727/Blog` after its re-integration. All four of that issue's asks, plus a defect in one of the fixes it proposes. ## The gate that suppressed the check `spec/audit.py` wrapped the whole freshness check in `if not findings:`. The rationale was sound in isolation, in that a clean repo has no outstanding work for a pending-marker note to describe, but the consequence is that **any** repo carrying one finding it cannot clear has its entire `driftNotes` list exempted. Blog carries exactly that, the `carried: AGENTS.md references the template repo` finding tracked in #552, so its notes were never checked however clean the rest of the audit ran. The repo with open findings is where a stale note is most likely, which is the inverse of what the gate produced. Both shapes are now evaluated on every run, worded by context rather than suppressed: | Note | Audit clean | Findings open | | --- | --- | --- | | prose marker (`pending`, `still`, ...) | contradicted outright, as before | raised as which of the open findings it means | | names a check id | surfaced for a hand decision | surfaced for a hand decision | Measured live, this is one added advisory across the fleet. Of 53 notes on 22 repos, one carries a marker (MediaTools, `pending fleet-wide ratification`), and it now reads `while 25 finding(s) are open - confirm it describes one of them rather than closed work`. Under the old gate it was silent. ## The check-id matcher, and why it is not anchored #563 proposes `\(([a-z]+\.[a-z.]+)\)$`. That pattern matches **neither of the two notes it was written for**, because both end the sentence after the paren: > ... so it cannot be moved or diffed against upstream (hugo.vendored.provenance)**.** Run over the whole registry it matches zero notes on zero repos, which is indistinguishable from a fleet carrying no such note. That is the silent-narrowing shape `GOVERNANCE.md` "Verification Discipline" names: a pattern that matches less still exits zero. `CHECK_ID_RE` is therefore unanchored, and the self-test covers the trailing-period case, the mid-sentence case, an id absent from the catalog, an id whose type the repo does not declare, and a parenthesized version string as the false positive the shape has to exclude. **Watched failing**: re-anchoring the pattern to `$` turns five cases red and the suite reports `SELFTEST FAIL`. ## What the audit will not claim Resolving the id is as far as a tool can honestly go here, because `spec/audit.py` never reads `spec/project-types.json` at all. So the audit checks that the id exists and that the repo declares its type, then hands the check itself to the auditor. The finding clears when the note is deleted, which is the retirement path #560 promised, made visible rather than left to a matcher that could never fire. That same fact is now stated where an agent reads a run rather than only in the issue: `AUDIT.md` section 4, the console line printed for a clean repo, the `--issue` body preamble, and `OPERATIONS.md`. Adding a check to `project-types.json` changes what an auditor must judge and changes no tool's output, and silence from a tool that was never looking reads exactly like a pass. ## Blog's two notes are dropped Both deviations closed in ptr727/Blog#30 and are on Blog's ground-truth `main` (`2b132e4`), verified by reading that branch rather than trusting the issue: - `hugo.vendored.provenance` - `themes/README.md` records the upstream repository, commit `154d006e`, its upstream date, `git describe`, the license location, and both local edits. - `hugo.generator.pinned` - the version and SHA256 are declared once at `.github/actions/install-hugo/action.yml:26-27`, and `validate-task.yml:76` and `deploy-site-task.yml:79` both consume that composite action. ## Verification | Gate | Result | | --- | --- | | `spec/audit.py --selftest` | PASS, and FAIL on the anchored matcher | | `spec/validate.py` | 22 cataloged, 0 backlog, clean | | `spec/audit.py Blog MediaTools` (live) | behaves as described above | | markdownlint-cli2 | 44 files, 0 issues | | cspell (README, HISTORY) | 0 issues | | editorconfig-checker | clean | | `scripts/prose_lint.py` | no new violations on any touched file, net -2 | `Refs` rather than `Closes`, since a closing keyword cannot fire from a `develop`-targeted pull request. #563 is closed by hand with evidence once this merges. Filed by an agent in `ptr727/Blog`, resolved here. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes #28. Closes #29.
Conformance gaps against the fleet
hugotype landing in ptr727/ProjectTemplate#560, plus two smaller items found alongside them. #27 was filed in the same pass and is closed as invalid — the premise was wrong and this repo's design was right, explained in that thread.The generator pin was declared twice
HUGO_VERSIONandHUGO_SHA256lived in bothvalidate-task.ymlanddeploy-site-task.yml, each with an "update both values together" instruction 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 install 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.
Both installs now call
.github/actions/install-hugo, which owns the pin. The two cannot diverge because there is only one declaration. It also asserts the extended build fromhugo versionrather than inferring it from the.debfile name, which is the only thing that carried that requirement before (deploy/README.md:24states it as a requirement).Verified:
grep -rn 'HUGO_VERSION|HUGO_SHA256' .github/returns exactly one file.The theme recorded no upstream ref
themes/PaperModis 125 tracked files with no.gitmodules, no version marker, and no recorded origin, so nothing could be diffed against upstream, checked for a fix, or updated with any confidence.themes/README.mdnow records it. The commit was recovered rather than guessed, by hashing all 125 tracked blobs and matching them against upstream history:154d006e0182dfc7da38008323976b02e6bfab4av8.0-138-g154d006Every file matches that commit exactly except two, and both are additions in extension points the theme documents for the purpose rather than forks of theme logic:
assets/css/extended/blank.cssgallery-cols-*ruleslayouts/_partials/extend_head.htmlThe record notes both could live at the project root instead, since Hugo resolves a project's own
assets/css/extended/andlayouts/_partials/ahead of the theme's. Moving them would make the next update a clean directory replace with nothing to reapply. Left for that update rather than done here.It also notes what this now makes answerable:
TODO.mdrecords that PaperMod uses APIs Hugo deprecated in 0.158, which is why twolayouts/overrides exist to keep--panicOnWarningusable. Whether they are still needed is a diff against the recorded commit, which was not previously possible.The record sits at
themes/README.md, outsidePaperMod/, so replacing that directory on an update does not take it with it.A scoping bug that fix exposed
The markdown glob excluded
!themes/**, so the provenance file — authored here, about a vendored tree — would not have been linted. It now excludes!themes/*/**, reaching inside a theme directory rather than over the directory that holds them.Verified: 16 files linted, 0 issues, with
themes/README.mdin scope andthemes/PaperMod/README.mdstill out.Two smaller items
permissions: {}onassert-refandassert-environment. Both onlyechoandcase-match, and both were inheriting the repository default token scope. Not exploitable, but it is the same least-privilege finding the hub has open against merge-bot in merge-bot: GITHUB_TOKEN permissions are unused, since every write goes through the App token ProjectTemplate#521.outputs:ondeploy-site-task.yml—release-idandsite-url. There were none, so no caller could record what shipped. The live check already proves that id is the one answering, which makes it the value a rollback names.Verification
public/)hugo --gc --minify --panicOnWarningCorrection: an earlier revision of this description said the deploy path itself is unchanged. That was true when written and stopped being true two commits later.
fa0fc19addsStrictHostKeyChecking=yes,UserKnownHostsFileandBatchMode=yesto the rsync transport, which is the live deploy path, and moves the production ref gate to the full ref. Those were verified locally withssh -Gbut have never run against the real host throughrrsync -wo. The transport now fails closed, so a staleDEPLOY_SSH_KNOWN_HOSTSthat was previously tolerated will stop a deploy. Staging must pass before production, and the retest is tracked in #33 for the Blog and VPS agents to run together.🤖 Generated with Claude Code