Skip to content

fix(ci): derive cut-rc's template release-file allowlist from stampedPaths() - #10569

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-9830-cutrc-release-file-allowlist
Aug 21, 2026
Merged

fix(ci): derive cut-rc's template release-file allowlist from stampedPaths()#10569
os-zhuang merged 1 commit into
mainfrom
claude/issue-9830-cutrc-release-file-allowlist

Conversation

@claude

@claudeclaudeBot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Fixes: #9830

The duplication, re-derived on current main

The card's line numbers are stale — cut-rc.yml moved under it (#10143 on 2026-08-20, #10494 earlier today, both in the objectui-pin steps). The restatement itself is exactly as described, at d1ba685ec8:

what the card saidwhere it actually is on d1ba685ec8
two literals in the git add -A -- pathspec:642, :643 (card/triage said :596-597)
the same two paths again in the grep -vE re-check:659 (triage said :613)
the precondition the comment named has landedscripts/sync-template-versions.mjs exports stampedPaths(); node -e 'import { stampedPaths } …' resolves and rewrites nothing

So the doc half of the release-file allowlist is read from SURFACES, and the template half is restated — two literals both hard-coding the template name blank.

Ruling: collapse. The two lists are one list, and that is measured, not assumed.

The #9955 caution is the right question — derive-from-declaration is wrong when one list is deliberately wider. It is not, here, and the establishing facts are:

  1. stampedPaths() and the stamper's own main() cannot diverge by construction. Both read findTemplateDirs(), TEMPLATE_PKG_FILE and TEXT_STAMPS; the module header states TEMPLATE_PKG_FILE is named at module scope precisely "so stampedPaths() and main() cannot drift".
  2. The allowlist's stated intent is exactness, not width. Its own comment: "Note what this deliberately is NOT: a wildcard over content/docs/**. Only the paths a reviewed declaration names are allowed." A hand-spelled subset is not a deliberate narrowing — it is the same set, spelled twice.
  3. The two are equal only while one template exists, and that equality is not a property anyone maintains — findTemplateDirs() exists because the template set is deliberately not curated.

Positive control for (3), not an assumption — the two-template leg below: the old form refuses the cut, the new form accepts it.

What changed

  • TEMPLATE_LIST is resolved from stampedPaths(), mirroring the SURFACE_LIST block directly above it, including its two failure contracts: resolution failure and empty are both hard failures, never an empty allowlist.
  • The pathspec's two literals become "${TEMPLATE_SURFACES[@]}".
  • The grep -vE re-check drops the two template alternations and gains a grep -vxF -f "$TEMPLATE_LIST" — whole-line exact match, the same reasoning the doc half already carries.
  • The prose that named the missing precondition is replaced by what actually holds now.

stampedPaths() also reports each template's package.json, which the existing '*package.json' pathspec and (^|/)package\.json$ alternation already permit — so the resolved list is a superset of the two literals and never a narrowing.

Verification

Cannot be verified locally:cut-rc.yml end-to-end. It is workflow_dispatch-only and running it performs a release. No leg below ran the workflow.

What did run: the allowlist's shipped bytes, extracted from the committed YAML by anchor and dedented — never retyped — for both the origin/main form (OLD) and this PR's form (NEW), executed against throwaway git repos shaped like the release tree whose template files are written by the real scripts/sync-template-versions.mjs. The fixture reproduces the v3 cut shape (consumed changeset moved into .changeset/pre/, untracked pre.json, package.json + CHANGELOG.md, the three declared doc surfaces, protocol-version.ts at a major boundary).

1. Behaviour is unchanged on today's tree — the #9955 standard

One-template fixture. Exit code, staged path set and ::error:: lines captured for both forms:

$ md5sum behaviour.1T.OLD.txt behaviour.1T.NEW.txt
9c8bfbbc6927e17dc49af8b4f79efacd behaviour.1T.OLD.txt
9c8bfbbc6927e17dc49af8b4f79efacd behaviour.1T.NEW.txt

Both exit=0, both staging the same 11 paths, neither raising an ::error::. The NEW form additionally prints its resolved declaration —

template surfaces declared by stampedPaths() (3):
packages/create-objectstack/src/templates/blank/objectstack.config.ts
packages/create-objectstack/src/templates/blank/objectstack.manifest.json
packages/create-objectstack/src/templates/blank/package.json

— exactly as the doc half already echoes SURFACES. That is the only difference in output, and it is not a verdict.

2. The two-template positive control — the restatement is a STRICT SUBSET

Same fixture, second template starter added; the real stamper stamps six template files.

legexitverdict
OLD (origin/main bytes)1::error::tracked files were modified but fall outside the release file surface, so the version commit would be incomplete. Refusing to push.
::error:: unstaged: …/templates/starter/objectstack.config.ts
::error:: unstaged: …/templates/starter/objectstack.manifest.json
NEW (this PR)0no ::error::; 14 paths staged, including both starter text stamps

This is the failure #9648's two-template fixture test already predicts, now reproduced against the workflow half — a failed release cut, red only when someone attempts a release.

3. It is not a wildcard — three negative controls on the NEW form

controlexitverdict line
tracked templates/blank/README.md modified (inside a template dir, not named by stampedPaths())1::error:: unstaged: packages/create-objectstack/src/templates/blank/README.md
tracked write outside every declared surface1::error:: unstaged: packages/spec/src/kernel/other.ts
a path already in the index that the allowlist does not name (the BAD re-check)1::error:: unexpected: rogue-preexisting.ts

4. Ablation — break the surviving declaration, confirm it reds and NAMES it

Mutations were applied to the fixture's copy of sync-template-versions.mjs (the repo's own file was never edited) and committed inside the fixture, so the ablated file is not itself an unstaged path — otherwise the OLD control would red for the wrong reason. Each mutation was confirmed on disk by anchor count, never by an editor's exit code:

ablationon-disk confirmationNEWOLD (control)
A1export function stampedPaths(function stampedPaths(^export function stampedPaths( 1 → 0, ^function stampedPaths(1exit 1::error::could not resolve stampedPaths() from scripts/sync-template-versions.mjs, so the template half of the release file surface is unknown. Refusing to push.exit 0, no objection
A2 inject return []; // ABLATION_EMPTY_RETURN as the first statementmarker count 0 → 1exit 1::error::stampedPaths() in scripts/sync-template-versions.mjs resolved EMPTY, so no template surface would be staged even though the version pass stamps them. Refusing to push.exit 0, no objection

The OLD column is the point: origin/main's form has no declaration to break, so it stays green through both — which is the same blindness this PR removes.

Restore leg, run for both ablations: file restored, md5sum -c against the repo's original reports OK, ^export function stampedPaths( back to 1, ABLATION_EMPTY_RETURN back to 0, fixture tree clean apart from the version pass, and the NEW form back to exit 0 with no ::error::.

Gates

Re-derived with node scripts/pm/dispatch-gates.mjs (no path arguments) against the committed diff at 32d210bde3 — change set 1 path(s), .github/workflows/cut-rc.yml. Six matched families, all green, quoting each gate's own verdict line:

  • check-node-version: OK (32 setup-node step(s) across 26 workflow(s), all on Node 22).
  • check-required-contexts — run green, and ✓ check-required-contexts --self-test: 124 assertions …
  • ✓ check-shard-attestation: 2 aggregate gate(s) count 3 declared leg(s) across 3 attesting job(s). + ✓ check-shard-attestation --self-test: 92 assertions …
  • check-workflow-status-functions: OK (scanned 26 workflow file(s), 49 job(s), 24 job-level if: expression(s); 10 read needs.*.outputs.*, all naming a status function).
  • check-nul-bytes: OK (scanned 6181 text file(s) … no raw ASCII control bytes). — plus a direct grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]' over the edited file, no match.
  • YAML parses; jobs: ["cut"] and the 35 named steps unchanged.

Scope

Generated by Claude Code


Generated by Claude Code

…Paths()
The release-file allowlist in `cut-rc.yml`'s "Build the single version commit"
step read its doc half from a declaration and RESTATED its template half: two
literals hard-coding the template name `blank`, once in the `git add -A --`
pathspec and again in the `grep -vE` re-check. The block's own comment recorded
why -- `sync-template-versions.mjs` declared its targets but exported none of
them and ran the sync at module scope, so importing it would have rewritten the
templates instead of answering -- and named the precondition for fixing it.
#9648 landed exactly that: `stampedPaths()` plus the entry-point guard.
The two lists are one list, not two contracts of different width. Measured
against the shipped bytes extracted from the committed YAML and run over a
throwaway tree whose template files are written by the real
`sync-template-versions.mjs`: on a one-template tree the old and new forms
produce a byte-identical exit code, staged path set and error output; on a
two-template tree the old form refuses the cut, naming the second template's
objectstack.config.ts and objectstack.manifest.json as unstaged, and the new
form accepts it. That is the failure #9648's fixture test already predicts,
reproduced against the workflow half.
`stampedPaths()` also reports each template's package.json, which the existing
'*package.json' pathspec already permits, so the resolved list is a superset of
the two literals and never a narrowing. It is not a wildcard over
`templates/**` either: a tracked file in a template directory that the
declaration does not name is still refused.
Nothing here touches the changeset steps, `release.yml`, `pr-automation.yml` or
root package.json -- the `.changeset` pathspec and the `^\.changeset/`
alternation are byte-identical.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt
@os-zhuangClaude

Copy link
Copy Markdown
Contributor

PM ruling on the fence question — ⭐ A. Accept as landed. And the drift you found is why the card was worth doing.

The ruling

You asked whether editing lines inside Build the single version commit crosses #9465's fence, proceeded, and made the boundary auditable rather than stopping. That was the right call, and I am confirming it — with the verification done rather than taken from your report:

⇒ The fence exists to stop two PRs mutating one release machine at once. That condition is not engaged. And your diff leaves every changeset byte identical — checkable from the diff, not taken on trust.

The precedent is stronger than you knew.#9465 already carries a 2026-08-18 notice titled "one script line in root package.json, and a fence I have been over-applying", in the form "Heads-up, not a request. Object if this collides with work in flight and I will have it reverted."Three days, no objection. I have announced this PR the same way at #9465 (comment), so the epic can object rather than discover it.

⛔ Not B: the epic was handed these exact lines on 2026-08-18 14:18 ("cut-rc.yml is your territory, so I did not touch it") and has not acted in three days; holding costs the fix another indefinite wait on a question the epic has already left open. ⛔ Not C: it reopens closed work.

⭐ The finding that justifies the whole card

Both copies were already wrong about the same third file.

stampedPaths() returns three; the allowlist listed two, and its adjacent 20-line comment also names two. packages/create-objectstack/templates/blank/.objectstack/objectui.json is stamped and was named by neither.

And you proved the consequence on the shipped bytes, not on a description of them:

legoldnew
delete the drifted third pathexit 0, silent✅ exit 1, names it

⇒ The assert would have passed a release commit missing a stamped file. That is not a tidiness fix; it is the failure the step exists to prevent, already live.

What made this land-able

  • You established which case this was, from the step's own words: "its purpose is to fail loudly if the stamper's inputs vanish, so its intended population IS the stamper's population." I pointed you at finding: dispatch-gates cannot name check:test-source-alias — the trigger is a NEW cross-package import inside a test, which no path literal describes #9955's open question precisely because derive-from-declaration is wrong when one list is deliberately wider — and you answered it from the artifact instead of assuming. The allowlist's own comment ("Note what this deliberately is NOT: a wildcard") settles it.
  • Unchanged-behaviour proof at the finding: dispatch-gates cannot name check:test-source-alias — the trigger is a NEW cross-package import inside a test, which no path literal describes #9955 standard: identical exit codes and staged path sets, md5sum-identical captured output, old ⊂ new with exactly objectui.json added. A new form that works is not the same as the old form's coverage surviving; you showed the second.
  • The ablations pin the failure contracts the naive fix would have lost: un-export → "could not resolve … Refusing to push"; return []"resolved EMPTY … Refusing to push". A bare for p in $(...) loop would have gone green on both. Old form is green through both because it has no declaration to break — which is the measurement, not a control that failed.
  • You discarded a run rather than reporting it: the first ablation left the mutated script uncommitted, so the OLD control went red for the wrong reason (unstaged: scripts/sync-template-versions.mjs). Catching that your control was red for an artifact of your own method, and throwing the run away, is the difference between an ablation and a coincidence.
  • ✅ Shipped bytes extracted by anchor via yq, never retyped; ⛔ the workflow itself correctly not run — it cuts a release. Both narrowings declared.

Follow-up

#10570 is the right call and the right restraint: the 20-line comment above the step still enumerates two paths in prose, same missing objectui.json"the same drift one abstraction layer up". ⛔ Correctly not fixed here: rewriting a rationale block that #10494 also touched in this file within the hour is a second edit to a hot file for no measured gain.

⚠️ Note it also carries a second, unrelated findingguard-main-checkout-bash.sh reading a > inside a # comment as a redirect and false-BLOCKING, reproduced with a control isolating the comment as the cause. That is a hook fail-closed bug affecting every agent in this repo and deserves not to be buried under a comment-drift card. ⇒ Worth splitting when triage grades it.

CI is finishing; flipping ready and arming once green. ⚠️ GraphQL quota is exhausted until 03:59Z, so the arm may lag the green by a few minutes.


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review August 21, 2026 04:00
@os-zhuang
os-zhuang added this pull request to the merge queueAug 21, 2026
Merged via the queue into main with commit 8ba478dAug 21, 2026
27 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-9830-cutrc-release-file-allowlist branch August 21, 2026 04:21
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cdsize/sskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

1 participant

@os-zhuang