fix(ci): derive cut-rc's template release-file allowlist from stampedPaths() - #10569
Conversation
…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
PM ruling on the fence question — ⭐ A. Accept as landed. And the drift you found is why the card was worth doing.The rulingYou asked whether editing lines inside
⇒ 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 ⛔ Not B: the epic was handed these exact lines on 2026-08-18 14:18 (" ⭐ The finding that justifies the whole card
And you proved the consequence on the shipped bytes, not on a description of them:
⇒ 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
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
CI is finishing; flipping ready and arming once green. Generated by Claude Code |
Fixes: #9830
The duplication, re-derived on current
mainThe card's line numbers are stale —
cut-rc.ymlmoved under it (#10143 on 2026-08-20, #10494 earlier today, both in the objectui-pin steps). The restatement itself is exactly as described, atd1ba685ec8:d1ba685ec8git add -A --pathspec:642,:643(card/triage said:596-597)grep -vEre-check:659(triage said:613)scripts/sync-template-versions.mjsexportsstampedPaths();node -e 'import { stampedPaths } …'resolves and rewrites nothingSo 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 nameblank.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:
stampedPaths()and the stamper's ownmain()cannot diverge by construction. Both readfindTemplateDirs(),TEMPLATE_PKG_FILEandTEXT_STAMPS; the module header statesTEMPLATE_PKG_FILEis named at module scope precisely "sostampedPaths()andmain()cannot drift".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.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_LISTis resolved fromstampedPaths(), mirroring theSURFACE_LISTblock directly above it, including its two failure contracts: resolution failure and empty are both hard failures, never an empty allowlist."${TEMPLATE_SURFACES[@]}".grep -vEre-check drops the two template alternations and gains agrep -vxF -f "$TEMPLATE_LIST"— whole-line exact match, the same reasoning the doc half already carries.stampedPaths()also reports each template'spackage.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.ymlend-to-end. It isworkflow_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/mainform (OLD) and this PR's form (NEW), executed against throwaway git repos shaped like the release tree whose template files are written by the realscripts/sync-template-versions.mjs. The fixture reproduces the v3 cut shape (consumed changeset moved into.changeset/pre/, untrackedpre.json, package.json + CHANGELOG.md, the three declared doc surfaces,protocol-version.tsat 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:Both
exit=0, both staging the same 11 paths, neither raising an::error::. The NEW form additionally prints its resolved declaration —— 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
starteradded; the real stamper stamps six template files.origin/mainbytes)::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::error::; 14 paths staged, including bothstartertext stampsThis 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
templates/blank/README.mdmodified (inside a template dir, not named bystampedPaths())::error:: unstaged: packages/create-objectstack/src/templates/blank/README.md::error:: unstaged: packages/spec/src/kernel/other.tsBADre-check)::error:: unexpected: rogue-preexisting.ts4. 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:export function stampedPaths(→function stampedPaths(^export function stampedPaths(1 → 0,^function stampedPaths(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.return []; // ABLATION_EMPTY_RETURNas the first statement::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.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 -cagainst the repo's original reportsOK,^export function stampedPaths(back to 1,ABLATION_EMPTY_RETURNback to 0, fixture tree clean apart from the version pass, and the NEW form back toexit 0with no::error::.Gates
Re-derived with
node scripts/pm/dispatch-gates.mjs(no path arguments) against the committed diff at32d210bde3— change set1 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 directgrep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]'over the edited file, no match.jobs: ["cut"]and the 35 named steps unchanged.Scope
skip-changesetapplied deliberately. The diff is one workflow file: in no package, publishing nothing, so.changeset/config.json'sprivatePackages.versionhas nothing to act on. Same disposition as fix(ci): ask cut-rc whether the objectui pin is ON main, not whether the object exists #10494 on this file one day ago. An empty-frontmatter changeset is deliberately not used — 空 frontmatter changeset 相对skip-changeset标签零收益、单向风险 —— 「禁止空 changeset 进 .changeset/」的决策证据(#5292 结案后无处存放) #5471 shut that route for new files..changesetpathspec line is byte-identical, the^\.changeset/alternation inside the re-check regex is byte-identical (only the two template alternations were removed), and the v3 measurement block above the step (theWHAT MAY BE IN ITprose, including its "as is the blank template's own package.json" sentence) is untouched.release.yml,pr-automation.ymland rootpackage.jsonare untouched. The rewiring landed here is the one [finding]sync-template-versions.mjsdeclares its version-stamp targets but exports none of them and has no entry-point guard, so consumers must restate template paths literally #9554's dev handed to the epic verbatim on 2026-08-18 as ready-to-apply lines; it has sat unclaimed since, and the epic's only open sub-issue ([v3 migration 4/4] Verify the migrated release path inside the next real RC window #9500) observes a live RC window and writes nothing.lint.yml), [finding] The 15 gates that parse withts.createSourceFilenever readparseDiagnostics— a file with a syntax error is walked as a recovered partial AST and scores clean #10133 (scripts/check-*.mjsts parsers), [finding]pnpm --filter <pkg> test -- <pattern>runs the WHOLE suite — the positional filter is swallowed, and the shared verify lock pays for it #10166 (pnpm --filter … test), bump-objectui.sh writes the pin from local HEAD without asking whether that commit is on objectui main #10495 (bump-objectui.sh), docs(plugin-audit): document theos serveopt-in, and rule out a config-derived audit options helper #10450, pm(ci-failure): fetch the job log instead of asserting it unreachable #10545, Require a guaranteedwarnchannel on any sink type that declares an optionalerror(#9754) #10555, docs(tests): correct four stale claims about what check:cross-package-test-inputs recognises #10561.Generated by Claude Code
Generated by Claude Code