Skip to content

Standardize the static-site-deploy repo type and its destination - #560

Merged
ptr727 merged 10 commits into
developfrom
feature/hugo-static-site-type
Aug 5, 2026
Merged

Standardize the static-site-deploy repo type and its destination#560
ptr727 merged 10 commits into
developfrom
feature/hugo-static-site-type

Conversation

@ptr727

@ptr727ptr727 commented Aug 5, 2026

Copy link
Copy Markdown
Owner

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.

CheckWhat is wrongFiled
hugo.vendored.provenanceThe vendored theme is 125 tracked files with no .gitmodules, no recorded upstream ref, and no Dependabot ecosystem covering it.ptr727/Blog#28
hugo.generator.pinnedThe 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:

PredictedMeasured
theme as a Dependabot-tracked submodulevendored, no upstream ref recorded
generator at latest, not pinnedpinned by version and SHA256
tag cut last, after the live checkdeploy 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

GateResult
spec/validate.py22 cataloged, 0 backlog, clean
spec/audit.py --selftestPASS
scripts/repo_gate.pyeol 0, sha-pin 0
scripts/prose_lint.py --diff developclean (the 522-violation tree backlog is #519's, untouched)
markdownlint-cli20 issues in 44 files
actionlintclean
editorconfig-checkeronly 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

Blog has been running a Hugo build with an rsync-over-SSH deploy to a host
we own, governed by nothing: the CI contract had no destination for a
filesystem on our own host, the registry target enum had no member for it,
and Blog stood cataloged as source-only behind two interim driftNotes.
This closes#456's intake and #558's spec change.
Add the hugo type with nine checks. Only hugo.build.strict names the
generator, where the flag is the letter. The rest are phrased so that
promoting them to a shared type when a second generator arrives is a
registry edit, per the new type-model "Generators" section. What a repo
builds and where the result lands stay separate axes, so the transport is
the publish target rather than part of the type.
Add the self-hosted target and the deploy-ssh mechanism. Both requires and
stores are empty deliberately: the credentials are per-environment GitHub
Environment secrets, which validate.py and audit.py cannot enumerate, and
listing them would make the audit demand them in the repository actions
store and report a correctly configured repo as missing them. The optional
environments block records the names as operator documentation, and says
plainly that it is not a gate. Extending the stores enum instead would
raise KeyError in audit.py for every repo using the mechanism.
Add D4.6, which asserts which release and which environment answered rather
than that the transport exited zero, and D5.6, which requires the prune of a
durable destination to be asserted on the host. D4.3 is unchanged: the
deploy is a separate dispatch that touches no release, so it adds no fourth
route to the tag-only shape.
Reclassify Blog to hugo plus source-only with both publish targets. Three
checks fail against it today and are recorded as driftNotes naming their
check id: nothing prunes the remote release tree, the vendored theme records
no upstream ref, and the generator pin is duplicated across two workflows.
The reference leaf carries the prune the repo lacks, so it leads its only
member by one step. Correct TODO.md rather than deleting it, since the
intake predicted a submodule theme, an unpinned generator, and a tag cut
after the deploy, and all three are wrong against what runs.
Drive-by: scope-model.md was missing cpp from the token table, and STANDUP's
new-type procedure never mentioned the registry target enum, which is what
the first repo declaring a new destination fails validate.py on.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first draft of hugo.deploy.retention and D5.6 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 nor read the destination back. Blog's own ownership table
already assigns release-prune timers to the host, which is the correct
resolution rather than a gap.
As 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
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, and 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 check, so its retention driftNote is dropped.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Standardizes governance and spec support for deploying static-site artifacts to a filesystem on a project-owned host, including a first-class hugo generator type, a self-hosted publish target mapped to an SSH-based deploy mechanism, and reference workflow snippets that implement deploy verification and on-host retention guarantees.

Changes:

  • Add self-hosted as a registry publish target and map it to a new deploy-ssh mechanism (with environment-scoped secrets documented but not audited).
  • Introduce the hugo project type plus accompanying model/spec updates (scope/type model, conformance matrix, audit/type detection notes).
  • Add reference deploy workflows (deploy-site.yml, deploy-site-task.yml) and extend WORKFLOW/GOVERNANCE contracts with deploy verification (D4.6) and destination pruning (D5.6).

Reviewed changes

Copilot reviewed 17 out of 17 changed files in this pull request and generated 2 comments.

Show a summary per file
FileDescription
WORKFLOW.mdAdds the “filesystem on own host” destination row plus D4.6/D5.6 guarantees and hugo/static-site walkthrough/scenarios.
TODO.mdUpdates backlog entries/record-keeping around secrets modeling and the new hugo/self-hosted work.
STANDUP.mdExtends the new-type procedure to include the registry target enum and naming constraints for build-*-task.yml.
spec/type-model.mdAdds a “Generators” section describing generator-type naming and promotion strategy.
spec/secrets.schema.jsonAdds an environments documentation block shape for environment-scoped secrets/vars.
spec/secrets.jsonAdds deploy-ssh and maps self-hosted to it; documents why env secrets can’t be audited yet.
spec/scope-model.mdUpdates the project-type token table (adds cpp and hugo).
spec/project-types.jsonIntroduces the hugo type and its checks.
spec/files.jsonAdds an interface baseline entry for .github/workflows/deploy-site-task.yml for hugo repos.
reports/conformance-matrix.mdUpdates matrix rows for Blog and adds a self-hosted target row.
registry/repos.schema.jsonExtends target enum with self-hosted.
registry/repos.jsonReclassifies Blog to ["hugo","source-only"] and adds self-hosted publish target + updated drift notes.
GOVERNANCE.mdAdds the deploy-to-own-host output seam description to the release model section.
catalog/snippets/workflows/README.mdDocuments the new deploy entrypoint/task snippets.
catalog/snippets/workflows/deploy-site.ymlAdds dispatch entrypoint workflow with env choice, ref gate, shared validation, and env-keyed concurrency.
catalog/snippets/workflows/deploy-site-task.ymlAdds reusable deploy task implementing SSH+rsync deploy, on-host pruning, and live verification.
AUDIT.mdUpdates type detection and applicability description to include hugo.

Comment threadcatalog/snippets/workflows/deploy-site-task.yml
Comment threadcatalog/snippets/workflows/deploy-site-task.yml
CopilotAI review requested due to automatic review settings August 5, 2026 02:47

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 17 out of 17 changed files in this pull request and generated no new comments.

Suppressed comments (1)

catalog/snippets/workflows/deploy-site-task.yml:155

  • The "Flip current" step claims rsync replaces the symlink via temp+rename so it is never absent, but rsync does not guarantee atomic symlink replacement. This can leave a brief window where current is removed/recreated in-place, which risks transient 404s or serving a mixed release during a deploy.

Consider flipping current with an explicit remote atomic rename (create a new symlink, then mv -T it into place) instead of syncing the symlink with rsync.

 # A separate step from the upload, so a failed transfer cannot half-publish a site.
# The pointer is relative, so one bundle works at any remote root.
# The transport replaces a symlink through a temporary and a rename, so it is never absent to a request in flight.
- name: Flip current step
env:

Copilot raised a collision between two deploys starting in the same second.
The scenario as stated is not reachable: a multi-minute generator install
precedes this step, and the environment-keyed concurrency group serializes
deploys to one environment, so two ids a second apart cannot both be derived.
The finding is still worth taking, for a reason it did not give. Both of
those properties are incidental to where the step sits, and neither is stated
anywhere. Moving the id derivation to the top of the job is an obvious
refactor that would silently reintroduce the collision, and a colliding id is
the one value the live check cannot tell apart, since it is simultaneously
the directory name, the stamp in the shipped config, and the expectation.
The run id and attempt make it unique by construction. The timestamp stays
the prefix, so the id still reads chronologically and a name-sorted prune
keeps working.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CopilotAI review requested due to automatic review settings August 5, 2026 02:55
@ptr727

Copy link
Copy Markdown
OwnerAuthor

Answering the suppressed review comment

It has no thread to reply in, so quoting it here. From the collapsed block on the current review, against catalog/snippets/workflows/deploy-site-task.yml:155:

The "Flip current" step claims rsync replaces the symlink via temp+rename so it is never absent, but rsync does not guarantee atomic symlink replacement. This can leave a brief window where current is removed/recreated in-place, which risks transient 404s or serving a mixed release during a deploy.

Consider flipping current with an explicit remote atomic rename (create a new symlink, then mv -T it into place) instead of syncing the symlink with rsync.

Declining: I traced it, and rsync does exactly the temp-plus-rename the comment says it does not guarantee.

$ ln -sfn releases/old dst/current # before
$ ln -sfn releases/new src/current # what we are shipping
$ strace -f -e trace=symlink,rename,unlink rsync -a --no-recursive src/current dst/symlink("releases/new", ".current.0") = 0rename(".current.0", "current") = 0
$ readlink dst/currentreleases/new

There is no unlink of current at any point. rsync creates .current.0 alongside it and rename(2)s over it, and rename(2) is atomic on POSIX: a concurrent request resolves either the old target or the new one, never nothing. So the failure this describes, a window where current is absent, does not occur, and the comment in the file is accurate.

The suggested fix is also the same operation. mv -Tisrename(2), so "create a new symlink, then mv -T it into place" is what rsync is already doing, one process earlier. Adopting it would additionally require a shell on the far side, which the confined forced-command deploy key deliberately does not provide.

Worth saying which part of the concern is real, though, because a neighbouring one is. Content and configuration do not go live together: a static file server resolves current per request, so content is live the instant the rename lands, while server rules wait on an asynchronous config reload. That genuinely can serve new content under the previous release's rules, and it is why D4.6 in this PR requires the deploy to poll the running host for the release id rather than trust the flip. So the "mixed release" risk is addressed, just by the guarantee rather than by the transport.

Low-confidence findings on this repo have historically been actionable, so this one got the same treatment as the two threaded ones rather than being taken on faith in either direction.

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 17 out of 17 changed files in this pull request and generated 1 comment.

Suppressed comments (3)

catalog/snippets/workflows/deploy-site-task.yml:159

  • Same issue as the upload step: rsync -a includes --owner/--group and can fail under a non-root, forced-command deploy account. Disabling owner/group preservation makes the pointer-flip step robust on tightly confined keys.
 rsync -a --no-recursive \
-e "ssh -i ~/.ssh/deploy -o IdentitiesOnly=yes" \
"${RUNNER_TEMP}/bundle/current" \
"${DEPLOY_SSH_USER}@${DEPLOY_SSH_HOST}:/${ENVIRONMENT}/"

WORKFLOW.md:135

  • The Output Seam row implies the deploy always "prunes what it wrote to", but D5.6 explicitly allows retention to be host-owned when the deploy credential is deliberately write-only (no in-pipeline prune). This sentence should be phrased in terms of bounded/owned retention rather than an always-prune step.
- **Filesystem on a host the project owns** (a static site, a config tree): the leaf builds the tree, ships it to the host, and contributes no `release-asset-*`. The transport is the repo's own. What the contract fixes is that the deploy is a **separate `workflow_dispatch`** from the release, so a redeploy of an unchanged commit mints no tag and a host rebuild, a rollback, or proving a branch on a non-production environment costs nothing; that its credentials come from a **per-environment GitHub Environment** rather than the repository secret store; and that the deploy ends by asserting **what the host serves** rather than the transport's exit status (D4.6), then prunes what it wrote to (D5.6).

spec/secrets.json:3

  • This note says feature workflow mechanisms are "detected by the mechanism's workflowNeeds action", but neither spec/audit.py nor spec/validate.py implements any workflow-content detection for featureMechanisms/workflowNeeds. As written, it overstates what the audit cross-checks today and could mislead operators relying on that detection.
 "note": "Secrets the audit cross-checks. `baseline` applies to every fleet repo (the App-signed merge-bot runs everywhere). `mechanisms` are per-target/per-feature additions: a repo requires the baseline plus the mechanisms whose publish target (`targetMechanisms`) or feature workflow (`featureMechanisms`, detected by the mechanism's `workflowNeeds` action) it uses. Baseline secrets are implicit and are NOT repeated in a repo's registry `requiredSecrets`, which lists only the domain-specific additions. `typeMechanisms` are per-language requirements: a `csharp` or `python` repo must carry the mapped mechanism (codecov) regardless of opt-in. A configured secret that no applicable mechanism claims is a stale-secret finding; a present `forbids` secret is a defect. `environments`, where a repo carries it, lists the per-environment GitHub Environment secrets and variables its deploy needs. It is operator documentation rather than part of the mechanism audit: no tool reads it, because neither `spec/validate.py` nor `spec/audit.py` can enumerate an environment-scoped store, so a clean audit is not evidence that an environment is configured. `environmentSecrets` names what one environment carries and another does not, so a name audit does not read a single-environment credential as missing everywhere else.",

Comment threadcatalog/snippets/workflows/deploy-site-task.yml
…tector
Two review findings, both accurate.
The Output Seam row still ended "then prunes what it wrote to", which is
what D5.6 said before the previous commit rewrote it. It now allows the
prune to be host-owned where the deploy credential is confined write-only,
so the row asserted a step the guarantee no longer requires. This is my own
inconsistency from correcting one and not the other, and the row now states
the bounded-and-owned shape.
The secrets.json note claimed featureMechanisms is "detected by the
mechanism's workflowNeeds action". Nothing implements that. workflowNeeds
appears in neither audit.py nor validate.py, featureMechanisms is only
shape-validated at validate.py:89-95, and the one feature it names is
claimed through typeMechanisms at build profile instead
(audit.py:596, audit.py:678). The note now says all three mappings resolve
from the registry entry rather than from workflow content, and that
workflowNeeds is documentation for a reader rather than a detector.
That text predates this change, but the note is the region this change
already edits, so it is fixed here rather than stepped around.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CopilotAI review requested due to automatic review settings August 5, 2026 03:02
@ptr727

Copy link
Copy Markdown
OwnerAuthor

Answering the three suppressed comments from the latest round

None carry a thread, so quoting each. Two of the three are right and are fixed in a1cbbac69a9989fe8829cf3f65c1016dd8a2eab0.


1. The seam row contradicts D5.6 — correct, fixed

The Output Seam row implies the deploy always "prunes what it wrote to", but D5.6 explicitly allows retention to be host-owned when the deploy credential is deliberately write-only (no in-pipeline prune). This sentence should be phrased in terms of bounded/owned retention rather than an always-prune step.

Right, and this is my own inconsistency rather than a subtlety. D5.6 originally did require an in-pipeline prune. A later commit rewrote it, because Blog's deploy credential is a forced rsync command confined write-only and can neither delete nor read back, so the original wording was unsatisfiable for exactly the repos that confine their credentials properly. I corrected the guarantee, the walkthrough, and the check, and left the seam row asserting the step the guarantee had stopped requiring.

The row now reads:

Retention at the destination is bounded by a declared count with one side recorded as owning the prune, which is the deploy where its credential can observe the destination and the host where that credential is deliberately write-only (D5.6).

Good catch on a cross-file inconsistency, which is the kind that survives review most easily.


2. The secrets note claims a detector that does not exist — correct, fixed

This note says feature workflow mechanisms are "detected by the mechanism's workflowNeeds action", but neither spec/audit.py nor spec/validate.py implements any workflow-content detection for featureMechanisms/workflowNeeds. As written, it overstates what the audit cross-checks today and could mislead operators relying on that detection.

Also right, and I checked rather than took it:

  • grep -n 'workflowNeeds' spec/audit.py spec/validate.py returns nothing. It is read by no tool.
  • featureMechanisms appears only at spec/validate.py:89-95, which shape-validates it and nothing more.
  • The one feature it names is claimed through typeMechanisms instead: spec/audit.py:596 resolves codecov from the repo's declared types at build profile, and :678 uses that to make codecov.yml N/A.

So the note described a mechanism that has never existed, and your phrase for the risk is the right one: an operator could rely on detection that is not there. The note now says all three mappings resolve from the registry entry rather than from workflow content, that nothing reads a repo's Actions files to infer a mechanism, and that workflowNeeds documents what a mechanism needs to appear in a workflow rather than detecting it.

That text predates this pull request. Fixing it here rather than filing it, since the note is a region this change already edits.


3. rsync -a and owner preservation on the flip step — declining

Same issue as the upload step: rsync -a includes --owner/--group and can fail under a non-root, forced-command deploy account.

Same construct and same answer as the threaded copy against line 144, answered in full there. Measured: rsync -az --no-g exits 0 as uid 1000, and --itemize-changes shows no owner attribute in the change set, because rsync gates owner preservation on the receiver being super-user and drops it silently rather than attempting a failing chown. --no-g is present precisely because group preservation is the one that does fail for an unprivileged receiver; the asymmetry is deliberate. And the command is proven against exactly this configuration, since it is what ptr727/Blog runs to a live host under a confined forced-command key.


Two of three actionable, and both were cross-file or cross-commit inconsistencies rather than local ones. Worth noting for the record that the collapsed block has now produced the two most useful findings on this pull request.

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 20 out of 20 changed files in this pull request and generated no new comments.

Suppressed comments (4)

spec/files.json:33

  • deploy-site.yml is part of the new standardized deploy surface (catalog snippet + WORKFLOW.md walkthrough), but it is not included in the baseline manifest. As a result, spec/audit.py will not check that Hugo repos actually carry the dispatch entrypoint (ref gate + shared validation + call into deploy-site-task.yml). Add .github/workflows/deploy-site.yml to spec/files.json (likely as fidelity: interface) alongside deploy-site-task.yml.
 { "path": ".github/workflows/deploy-site-task.yml", "fidelity": "interface", "contract": { "requiredJobKeys": ["assert-environment", "deploy"], "requireTokensInJob": { "deploy": ["environment:"] } }, "reference": "catalog/snippets/workflows/deploy-site-task.yml", "intentRef": "WORKFLOW.md#d4---release--publish", "appliesTo": ["hugo"] },

owntest/src/a.txt:1

  • This looks like an ad-hoc test artifact ("hi") and is not referenced anywhere in the repo. Please delete it to avoid shipping stray files in the template and accidentally seeding them into downstream repos.
hi

owntest/dest/a.txt:1

  • This looks like an ad-hoc test artifact ("hi") and is not referenced anywhere in the repo. Please delete it to avoid shipping stray files in the template and accidentally seeding them into downstream repos.
hi

owntest/dest2/a.txt:1

  • This looks like an ad-hoc test artifact ("hi") and is not referenced anywhere in the repo. Please delete it to avoid shipping stray files in the template and accidentally seeding them into downstream repos.
hi

… mistake
Two review findings, both right.
The dispatch entry point was left out of the manifest on the reasoning that
its caller shape has one member. That was wrong, and inconsistent with
declaring the task leaf next to it. The entry point is where the ref gate
lives, which is what stops production being deployed from an arbitrary
branch, and it was the one security-relevant property of this surface with
nothing mechanically checking it. It is now an interface entry requiring the
assert-ref, validate and deploy job keys, and requiring the deploy job to
call the task leaf with secrets: inherit. Blog satisfies it as it stands, so
this records the shape rather than demanding a change.
The three owntest/ files were rsync test fixtures. I ran the test without
changing directory into the scratchpad first, so it built the tree in the
repository root, and a later git add -A swept it into the commit. They are
removed. This is exactly the failure the queued agent-isolation rule
describes, where a command's working directory is implied rather than
written into the command, and it is worth noting the mechanical fix is the
same: the absolute path belongs inside the command, not in a preceding cd.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CopilotAI review requested due to automatic review settings August 5, 2026 03:08
@ptr727

Copy link
Copy Markdown
OwnerAuthor

Answering the four suppressed comments from round 4

All four taken, in 57bc5225c527f3fa3875bdc6b33be640f047318b. Three are one finding repeated across the files it produced.


1. The dispatch entry point is not in the manifest, so nothing checks the ref gate

deploy-site.yml is part of the new standardized deploy surface (catalog snippet + WORKFLOW.md walkthrough), but it is not included in the baseline manifest. As a result, spec/audit.py will not check that Hugo repos actually carry the dispatch entrypoint (ref gate + shared validation + call into deploy-site-task.yml).

Right, and my reasoning for leaving it out does not survive being written down. I excluded it because "the caller shape has one member", which is equally true of the task leaf sitting immediately below it in the same manifest, and I declared that one. The inconsistency is the tell.

The substantive point is the one you make: the entry point is where the ref gate lives, and that gate is what stops production being deployed from an arbitrary branch. It was the only security-relevant property of this surface with nothing mechanically checking it, while the merely structural properties next to it were checked. Added at interface fidelity:

{ "path": ".github/workflows/deploy-site.yml", "fidelity": "interface", "contract": { "requiredJobKeys": ["assert-ref", "validate", "deploy"], "requireTokensInJob": { "deploy": ["deploy-site-task.yml", "secrets: inherit"] } }, "reference": "catalog/snippets/workflows/deploy-site.yml", "intentRef": "WORKFLOW.md#d4---release--publish", "appliesTo": ["hugo"] }

requiredJobKeys covers the gate, the shared validation, and the deploy; requireTokensInJob covers the call into the task leaf, so a repo cannot satisfy the shape while calling something else. Verified that the sole member satisfies it unchanged, so this records the shape rather than demanding a change, and that the selector still resolves (Blog's baseline goes from 26 files to 27).


2, 3 and 4. owntest/ scratch files

This looks like an ad-hoc test artifact ("hi") and is not referenced anywhere in the repo. Please delete it to avoid shipping stray files in the template and accidentally seeding them into downstream repos.

Entirely correct, and my mistake. Those are rsync fixtures from verifying the --link-dest and --owner claims on this pull request. I set a scratchpad variable and then ran mkdir without changing into it, so the tree was built in the repository root, and a later git add -A swept it in. Removed, and verified: git ls-files | grep -E '^owntest/' returns nothing.

Worth flagging the failure rather than just the fix, because it is a known shape here: a command whose working directory is implied by a preceding cd rather than written into the command itself. That is precisely the hazard the queued agent-isolation rule in TODO.md describes, and the mechanical fix is the same one it names, which is that the absolute path belongs inside the command. This is a template repository, so a stray file does not stay stray, and the "seeding them into downstream repos" framing is the right severity for it.

That the shipping-a-stray-file finding arrived in the collapsed low-confidence block rather than as a thread is worth recording on its own.


The two open threads on this pull request are declined with measured evidence rather than outstanding: --link-dest against a missing directory is a warning at exit 0 rather than exit 23, and -a owner preservation is skipped for a non-super-user receiver rather than attempted and failed. Both were tested locally on rsync 3.4.1, one with strace, and both are additionally proven by the sole member running the exact commands to a live host under a confined forced-command key. Resolving them so the ruleset sees an answered surface, per the etiquette rule that a resolve records the answer rather than agreement.

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 17 out of 17 changed files in this pull request and generated 1 comment.

Suppressed comments (2)

catalog/snippets/workflows/deploy-site-task.yml:159

  • Same as the upload step: make the SSH options explicit for the pointer-flip rsync as well (StrictHostKeyChecking/UserKnownHostsFile/BatchMode) so the snippet enforces the intended security behavior regardless of runner SSH defaults.
 rsync -a --no-recursive \
-e "ssh -i ~/.ssh/deploy -o IdentitiesOnly=yes" \
"${RUNNER_TEMP}/bundle/current" \
"${DEPLOY_SSH_USER}@${DEPLOY_SSH_HOST}:/${ENVIRONMENT}/"

catalog/snippets/workflows/deploy-site-task.yml:144

  • The rsync SSH transport relies on OpenSSH defaults for host key checking and known-hosts location. Since this is a reference deploy snippet and the comments call out host key integrity as critical, explicitly pin StrictHostKeyChecking=yes, UserKnownHostsFile, and BatchMode=yes in the -e ssh ... options to avoid any runner-default drift and to ensure failures are non-interactive.

This issue also appears on line 156 of the same file.

 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}/" \

Comment threadcatalog/snippets/workflows/deploy-site.yml Outdated
ptr727 added a commit to ptr727/Blog that referenced this pull request Aug 5, 2026
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>
Two review findings on the reference leaves, both right.
The production gate compared github.ref_name against the default branch
name. Tags and branches are separate namespaces that share a short name, so
a tag named for the default branch would satisfy that comparison while
pointing at an arbitrary commit. That is a bypass of the one gate protecting
production, in the file this change just added to the manifest for carrying
exactly that gate. It now compares github.ref against the full
refs/heads/<default> form.
The rsync transport left host key checking and the known-hosts location to
the runner's OpenSSH defaults, in a snippet whose own comments call host key
integrity load-bearing. StrictHostKeyChecking, UserKnownHostsFile and
BatchMode are now pinned explicitly, and the option string is declared once
at workflow level so the upload and the pointer flip cannot drift apart.
BatchMode in particular turns a credential problem into a failed step rather
than a job that hangs to its timeout.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CopilotAI review requested due to automatic review settings August 5, 2026 03:15

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 18 out of 18 changed files in this pull request and generated 1 comment.

Comment threadcatalog/snippets/workflows/deploy-site-task.yml
CopilotAI review requested due to automatic review settings August 5, 2026 03:24

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 18 out of 18 changed files in this pull request and generated no new comments.

Suppressed comments (2)

catalog/snippets/workflows/deploy-site-task.yml:47

  • The SSH_TRANSPORT command uses -o UserKnownHostsFile=~/.ssh/known_hosts. Because ~ is not expanded by the shell when it appears after =, this relies on SSH performing tilde expansion itself and can lead to StrictHostKeyChecking reading the wrong known_hosts path. Use $HOME (or an absolute path) for both the identity file and known_hosts to avoid ambiguity.
 SSH_TRANSPORT: >-
ssh -i ~/.ssh/deploy
-o IdentitiesOnly=yes
-o StrictHostKeyChecking=yes
-o UserKnownHostsFile=~/.ssh/known_hosts

TODO.md:48

  • This TODO entry says the type landed with an environments block in spec/secrets.json, but this PR adds the environments schema to spec/secrets.schema.json and updates spec/secrets.json prose; there is no environments block in spec/secrets.json today. Adjust the wording so it matches the actual implementation.
- Declare locally-required secrets the way GitHub-stored ones are already declared, and make a gitignored `secrets/` directory the fleet standard that holds them. [`spec/secrets.json`][secrets] covers only the Actions and Dependabot stores, so a repo that deploys somewhere has no declared way to say what it needs at runtime, and the required set is discoverable only by reading the deploy. The pattern already runs in the fleet in two shapes: HomeAutomation-Config keeps a gitignored secrets directory of env files and Docker secret files, and ESPHome-Config keeps a gitignored `secrets.yaml` beside a committed `_secrets.yaml`. The committed file carries the required names with dummy values, so the shape of the requirement is in git while the values never are, which is the same split the GitHub side already gets from `requiredSecrets[]`. Blog needs it immediately, since it deploys on the proxmox host through HomeAutomation-Config's Docker Compose stack and carries the copy destinations and the internal URI. The hub carries neither the directory nor a `.gitignore` entry for one today, so adopting it here comes first. Note that the **GitHub** side has the same missing axis, surfaced by the `hugo` type: a deploy's credentials are per-environment GitHub Environment secrets and variables, `stores` is a closed enum of `actions` and `dependabot`, and `spec/audit.py` seeds `required_by_store` with those two keys and indexes it unguarded, so adding an `environments` value to the enum raises `KeyError` for every repo whose `publish[]` maps to that mechanism. The type landed with an optional, unread `environments` block in `spec/secrets.json` documenting the names, which is honest but is not a gate, so a clean audit says nothing about whether an environment is configured. Solving it properly means a store vocabulary that does not crash on an unknown key and a per-environment secrets and variables fetch, and it is the same axis as the local half above rather than a separate problem.

Copilot's point, and it is right: the TODO entry said the type landed with an
environments block in spec/secrets.json. It did not. What landed is the
optional block in spec/secrets.schema.json plus a description in the note, so
a repo that has environments may declare its per-environment names and the
shape is legal rather than invented. The hub's own secrets.json carries no
such block, having no environments of its own.
The distinction matters because the entry is a record of what is still owed.
Reading it as "the block exists in the hub's file" would make the remaining
work look smaller than it is.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CopilotAI review requested due to automatic review settings August 5, 2026 03:31
@ptr727

Copy link
Copy Markdown
OwnerAuthor

Answering the two suppressed comments from round 8

One taken, one declined with a measurement, and the declined one is worth reading because the suggested fix would have broken the deploy.


1. TODO.md describes an environments block that is not there — correct, taken in 9541fcc68c92444e504042235ad1e3111d3ce50a

This TODO entry says the type landed with an environments block in spec/secrets.json, but this PR adds the environments schema to spec/secrets.schema.json and updates spec/secrets.json prose; there is no environments block in spec/secrets.json today.

Right, and precisely stated. What landed is the optional block in spec/secrets.schema.json plus a description in the note, so a repo that has environments may declare its names and the shape is legal rather than invented. The hub's own spec/secrets.json carries no such block, having no environments of its own.

This matters more than a wording slip because the entry is a record of what is still owed. Read as "the block exists in the hub's file", the remaining work looks smaller than it is. Reworded to say exactly which file gained what, and that no tool reads the block where it does exist.


2. Tilde in UserKnownHostsFile — declining, and the proposed fix would break it

Because ~ is not expanded by the shell when it appears after =, this relies on SSH performing tilde expansion itself and can lead to StrictHostKeyChecking reading the wrong known_hosts path. Use $HOME (or an absolute path) for both the identity file and known_hosts to avoid ambiguity.

The premise about the shell is right and the conclusion does not follow, because ssh does perform that expansion. Measured with ssh -G, single-quoting the option so the shell definitely does not touch it:

$ ssh -G -o 'UserKnownHostsFile=~/.ssh/known_hosts' -o 'IdentityFile=~/.ssh/deploy' example.invalididentityfile ~/.ssh/deployuserknownhostsfile /home/pieter/.ssh/known_hosts

userknownhostsfile comes back fully expanded to the real home directory, which is the exact case the finding says could read the wrong path. identityfile prints raw in -G output and is expanded at use, which is the long-standing behavior and is also proven in production: the deploy has been authenticating through -e "ssh -i ~/.ssh/deploy ..." since before this change.

$HOME would be worse, and would fail. The transport string is a workflow-level env: value, which is not shell-expanded, and bash does not recursively expand the contents of "$SSH_TRANSPORT". So $HOME would reach ssh as the four literal characters, and ssh expands ~ and its own %d token but not shell variables. The result would be a known-hosts path that does not exist, and with StrictHostKeyChecking=yes now set, every deploy would fail closed on an unverifiable host key. That is a good failure mode to have, and still a broken deploy.

Worth being explicit that the underlying instinct was sound: the value is doing real work now that StrictHostKeyChecking=yes is on, so it is worth being certain rather than assuming, which is why it got measured rather than waved off.


That is every finding across eight rounds answered. Final tally: 16 findings, 10 in the collapsed low-confidence block. Six were correct and taken, and the collapsed block carried five of those six, including the two most consequential (a scratch directory I had committed into a template repository, and a guarantee contradicted by three files that restated it). The threaded findings were mostly refuted by measurement. On this change the low-confidence block was the review.

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 18 out of 18 changed files in this pull request and generated no new comments.

Suppressed comments (3)

catalog/snippets/workflows/deploy-site-task.yml:192

  • This step runs deploy/prune-releases.sh directly, which depends on the script having the executable bit set in git. Calling it via bash avoids a brittle Permission denied failure mode while keeping behavior the same.
 run: |
set -Eeuo pipefail
deploy/prune-releases.sh "${DEPLOY_SSH_USER}@${DEPLOY_SSH_HOST}" "${ENVIRONMENT}" "${KEEP_RELEASES}"

catalog/snippets/workflows/deploy-site-task.yml:203

  • This step executes checks/check-live-urls.sh directly, which requires the executable bit to be set. Using bash here makes the reference workflow less fragile when copied into new repos.
 run: |
set -Eeuo pipefail
checks/check-live-urls.sh "${{ vars.SITE_BASE_URL }}"

catalog/snippets/workflows/deploy-site-task.yml:123

  • This step executes deploy/make-release.sh directly. That requires the script to be committed with the executable bit set; if it is not (common when scripts are created/edited on Windows), the deploy will fail with a Permission denied error. Invoking it via bash makes the snippet more robust without changing its inputs/outputs.

This issue also appears in the following locations of the same file:

  • line 189
  • line 201
 run: |
set -Eeuo pipefail
deploy/make-release.sh "${RUNNER_TEMP}/bundle" "${{ steps.release.outputs.id }}"

Copilot's point across three call sites, and right for a snippet whose whole
purpose is being copied: calling a script by path requires the executable bit
to have survived into the copying repo's git index. A repo authoring those
scripts fresh, particularly on Windows where the fleet's default line endings
already point, gets Permission denied at deploy time rather than at review
time.
It also matches the convention the hub already uses on itself, where every
script runs through its interpreter (python3 spec/validate.py, python3
scripts/test_prose_lint.py). Shell scripts were the inconsistent case.
Left unchanged in the one downstream member, whose scripts are committed
100755 and are proven by a real deploy. The leaf is doctrine and the repo is
working code, so this is not worth a round of review there.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CopilotAI review requested due to automatic review settings August 5, 2026 03:38
@ptr727

Copy link
Copy Markdown
OwnerAuthor

Answering the three suppressed comments from round 9

One finding across three call sites, and taken in bdd17bdc304ab426dea1fe7b109a8a1660375046.

This step executes deploy/make-release.sh directly. That requires the script to be committed with the executable bit set; if it is not (common when scripts are created/edited on Windows), the deploy will fail with a Permission denied error. Invoking it via bash makes the snippet more robust without changing its inputs/outputs.

Right, and the reasoning is specifically strong for this file rather than in general. A workflow in a working repo can rely on its own scripts being 100755, because they are and CI proves it every run. A reference snippet cannot: its entire purpose is to be copied into a repo whose scripts do not exist yet, and the mode bit is the one thing about them that a fresh author will not think about. The Windows angle is the sharp end of that, and this fleet's default line endings say where these repos are authored.

Checking the house convention rather than taking it on the argument alone, it turns out this was the inconsistent case already. The hub runs every one of its own scripts through an interpreter:

python3 spec/validate.pypython3 scripts/test_prose_lint.pypython3 scripts/test_repo_gate.pypython3 spec/audit.py --selftest

Python scripts got interpreter-prefixed invocation and shell scripts did not, for no reason anyone wrote down. So this makes the snippet consistent with the repo it ships from, which is a better justification than robustness alone.

All three call sites changed: the bundle assembly, the prune, and the live check.

Not changed in the one downstream member. Its scripts are committed 100755 and are proven by a real deploy to a live host, so the failure mode this prevents cannot occur there. Spending a review round on it would buy nothing, and the leaf leading the repo by a step is already the case for the prune. Recording that as a deliberate difference rather than an oversight.


That is 19 findings across nine rounds, all answered. Seven taken, twelve declined with a measurement or a citation.

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 18 out of 18 changed files in this pull request and generated no new comments.

Suppressed comments (1)

catalog/snippets/workflows/deploy-site-task.yml:47

  • SSH_TRANSPORT uses ~/.ssh/... paths inside a string passed to rsync's -e. Tilde expansion is not guaranteed there (it may be exec'd without a shell, or via /bin/sh which doesn't expand ~), which can make the SSH key/known_hosts paths fail to resolve at runtime.
 SSH_TRANSPORT: >-
ssh -i ~/.ssh/deploy
-o IdentitiesOnly=yes
-o StrictHostKeyChecking=yes
-o UserKnownHostsFile=~/.ssh/known_hosts

@ptr727

Copy link
Copy Markdown
OwnerAuthor

Answering the suppressed comment from round 10

Same finding as round 8, restated with a different mechanism. Quoting it since it carries no thread:

SSH_TRANSPORT uses ~/.ssh/... paths inside a string passed to rsync's -e. Tilde expansion is not guaranteed there (it may be exec'd without a shell, or via /bin/sh which doesn't expand ~), which can make the SSH key/known_hosts paths fail to resolve at runtime.

Still declining, and the new mechanism is the exact one the earlier measurement was designed to rule out. I single-quoted the options specifically so no shell touched them, which is the no-shell exec case:

$ ssh -G -o 'UserKnownHostsFile=~/.ssh/known_hosts' -o 'IdentityFile=~/.ssh/deploy' example.invalididentityfile ~/.ssh/deployuserknownhostsfile /home/pieter/.ssh/known_hosts

userknownhostsfile comes back expanded to the real home directory with no shell involved anywhere, because OpenSSH performs that expansion itself. Whether rsync execs ssh directly or through /bin/sh therefore does not change the outcome: ssh receives a literal ~ in both cases and resolves it either way. identityfile prints raw in -G output and resolves at use, which is long-standing behavior and is separately proven by a production deploy that has been authenticating through -e "ssh -i ~/.ssh/deploy ..." since before this change.

Repeating the other half too, because it is the part that would cause harm: the suggested $HOME substitution would break this. A workflow-level env: value is not shell-expanded, and bash does not recursively expand the contents of "$SSH_TRANSPORT", so $HOME would reach ssh as four literal characters. ssh expands ~ and its own %d token, not shell variables. With StrictHostKeyChecking=yes now set, the result would be a nonexistent known-hosts path and every deploy failing closed.

No change. If there is a concrete rsync or OpenSSH version where ssh -G disagrees with the above, that would settle it and I would take it.

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 18 out of 18 changed files in this pull request and generated no new comments.

@ptr727
ptr727 merged commit 2a1afc0 into developAug 5, 2026
8 checks passed
ptr727 added a commit to ptr727/Blog that referenced this pull request Aug 5, 2026
…#30)
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ptr727
ptr727 deleted the feature/hugo-static-site-type branch August 5, 2026 13:12
ptr727 added a commit that referenced this pull request Aug 5, 2026
…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>
ptr727 added a commit that referenced this pull request Aug 6, 2026
…574)
Promotes the nine commits `develop` has carried since the previous
promotion (#555) to `main`. Merge commit only, no squash, and `develop`
is not deleted.
## What lands
- **#560** Standardize the static-site-deploy repo type and its
destination, including the deploy-verification guarantee and a retention
rule that records which side owns the prune.
- **#561** Ask for a blocked decision instead of reporting it, and scope
the clickable-link rule to a surface that renders markdown.
- **#564** Read a suppressed section nested inside the review-details
wrapper, so `scripts/pr_review.py` stops reporting a clean round over
findings that reach no thread.
- **#566** Settle Markdown capitalization in `CODESTYLE.md`, and fix the
two `TODO.md` prose defects the digest defect had hidden.
- **#568** Check a `driftNote` on every run rather than only on an
otherwise clean audit, and state what the audit does not evaluate.
- **#569** Name who trips the production ref gate, and state that a
harness refusal is a different thing from the maintainer's permission
and is not lifted by it.
- **#571** Re-audit `ptr727/Blog` against the hugo type and bump the
conformance matrix.
- **#573** Scope the semicolon exemption to the sentence its list lives
in, with the 44 newly reported occurrences recorded on #519 rather than
swept here.
- **#572** Exempt a verbatim section from the coordination-reference
scan.
## Issues closed
Fixes#562. Closes#565.
The closing keywords sit on this promotion rather than on the feature
pull requests, because GitHub fires them only on a merge into the
default branch. #456, #558, #563, #567, and #570 are already closed
against their merged fixes.
## Verification
`git merge-tree` reports no conflict between `origin/main` and
`origin/develop`, so this promotion needs no throwaway resolution
branch. Every constituent pull request merged green with its review loop
closed.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@ptr727