From 32d210bde336a329ceaa3a226b0a3a04fed94e6d Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 03:38:15 +0000 Subject: [PATCH] fix(ci): derive cut-rc's template release-file allowlist from stampedPaths() 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 Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt --- .github/workflows/cut-rc.yml | 76 ++++++++++++++++++++++++++++-------- 1 file changed, 60 insertions(+), 16 deletions(-) diff --git a/.github/workflows/cut-rc.yml b/.github/workflows/cut-rc.yml index cd7a900d26..3e30eb7fe0 100644 --- a/.github/workflows/cut-rc.yml +++ b/.github/workflows/cut-rc.yml @@ -560,14 +560,10 @@ jobs: # Three more paths can appear at a major boundary and are allowed for that # reason, all written by `sync-protocol-version.mjs` / # `sync-template-versions.mjs`: packages/spec/src/kernel/protocol-version.ts - # and the blank template's objectstack.config.ts (engines.protocol) and - # objectstack.manifest.json (specVersion). The manifest is named literally, - # like its sibling config.ts, rather than read the way the doc surfaces - # below are: `sync-template-versions.mjs` does declare its targets (a - # `TEXT_STAMPS` table plus a walk of `src/templates/`), but exports none of - # them and has no entry-point guard, so importing it would RUN the sync - # instead of reading its list. Give it those two things and this pathspec - # should read from it too. + # and, per bundled template, objectstack.config.ts (engines.protocol) and + # objectstack.manifest.json (specVersion). protocol-version.ts is named + # literally because that one file IS the whole of its surface. The template + # paths are not, and the reason is the one the doc half states below. # # THE DOC SURFACES ARE READ, NOT RESTATED. `pnpm run version` ends with # `sync-docs-image-tags.mjs`, which rewrites the doc surfaces that pin a @@ -587,6 +583,29 @@ jobs: # point of the assertion. Onboarding a surface stays a one-line edit to # `SURFACES` that a reviewer sees in the diff. # + # THE TEMPLATE SURFACES ARE READ ON THE SAME TERMS. They used to be two + # literals, both hard-coding the template name `blank`, and this block used + # to record why: `sync-template-versions.mjs` declared its targets (a + # `TEXT_STAMPS` table plus a walk of `src/templates/`) but exported none of + # them and ran the sync at module scope, so importing it to ask "which files + # does the version pass stamp?" would have rewritten the templates instead of + # answering. #9648 gave it the export and the entry-point guard this block + # asked for, so the restatement is retired here rather than re-seeded: + # `stampedPaths()` derives its answer from the same walk and the same table + # the stamper's own `main()` uses. + # + # This is a COLLAPSE OF TWO COPIES OF ONE LIST, not a widening. The literals + # were never a deliberately narrower allowlist — they were the same set, + # spelled by hand, and they equal it only while `blank` is the only bundled + # template. `findTemplateDirs()` exists BECAUSE the template set is not + # curated ("a template added tomorrow is covered on the day it lands"), so + # the day a second template ships, the walk stamps it, the literal pair does + # not cover it, and the unstaged-files assertion below refuses the cut — with + # nothing red until someone attempts a release. `stampedPaths()` also reports + # each template's own package.json, which `'*package.json'` already permits, + # so the resolved list is a superset of the two literals and never a + # narrowing. + # # The allowlist is ENFORCED rather than described. Only allowlisted paths are # staged, and then every staged path is re-checked against the same rule and # the worktree is asserted clean. If `pnpm run version` ever grows a new @@ -633,14 +652,37 @@ jobs: echo "doc surfaces declared by SURFACES (${#DOCS_SURFACES[@]}):" sed 's/^/ /' "$SURFACE_LIST" + # The template surfaces `sync-template-versions.mjs` stamps, read from the + # same walk and the same table the stamper itself uses. Import-safe by + # construction: that module carries the entry-point guard and the exports + # added for exactly this consumer. + TEMPLATE_LIST="${RUNNER_TEMP:-/tmp}/cut-rc-template-version-surfaces.txt" + if ! node --input-type=module \ + -e 'import { stampedPaths } from "./scripts/sync-template-versions.mjs"; for (const p of stampedPaths()) console.log(p);' \ + > "$TEMPLATE_LIST"; then + echo "::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 1 + fi + # Same rule as the doc half: unknown is a failure, never an empty + # allowlist. `stampedPaths()` THROWS rather than returning [] on a moved + # or empty template directory, so an empty file here means a resolution + # that reported nothing while still exiting 0 — which would stage no + # template path at all and then blame the files it left unstaged. + if [ ! -s "$TEMPLATE_LIST" ]; then + echo "::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 1 + fi + mapfile -t TEMPLATE_SURFACES < "$TEMPLATE_LIST" + echo "template surfaces declared by stampedPaths() (${#TEMPLATE_SURFACES[@]}):" + sed 's/^/ /' "$TEMPLATE_LIST" + git add -A -- \ '*package.json' \ '*CHANGELOG.md' \ .changeset \ .objectui-sha \ packages/spec/src/kernel/protocol-version.ts \ - packages/create-objectstack/src/templates/blank/objectstack.config.ts \ - packages/create-objectstack/src/templates/blank/objectstack.manifest.json \ + "${TEMPLATE_SURFACES[@]}" \ "${DOCS_SURFACES[@]}" STAGED="$(git diff --cached --name-only)" @@ -650,13 +692,15 @@ jobs: fi # Re-check every staged path against the allowlist. The pathspec above is - # convenience; THIS is the guarantee. Two filters, same allowlist the - # pathspec used: the fixed release paths by pattern, then the declared doc - # surfaces by WHOLE-LINE EXACT match (`-xF`) against the very list that was - # staged — so the second filter cannot accept a path `SURFACES` does not - # name, and needs no regex-escaping of the paths to stay exact. + # convenience; THIS is the guarantee. Three filters, same allowlist the + # pathspec used: the fixed release paths by pattern, then the declared + # template and doc surfaces by WHOLE-LINE EXACT match (`-xF`) against the + # very lists that were staged — so neither derived filter can accept a path + # its declaration does not name, and neither needs regex-escaping of the + # paths to stay exact. BAD="$(printf '%s\n' "$STAGED" \ - | grep -vE '(^|/)package\.json$|(^|/)CHANGELOG\.md$|^\.changeset/|^\.objectui-sha$|^packages/spec/src/kernel/protocol-version\.ts$|^packages/create-objectstack/src/templates/blank/objectstack\.config\.ts$|^packages/create-objectstack/src/templates/blank/objectstack\.manifest\.json$' \ + | grep -vE '(^|/)package\.json$|(^|/)CHANGELOG\.md$|^\.changeset/|^\.objectui-sha$|^packages/spec/src/kernel/protocol-version\.ts$' \ + | grep -vxF -f "$TEMPLATE_LIST" \ | grep -vxF -f "$SURFACE_LIST" || true)" if [ -n "$BAD" ]; then echo "::error::the version commit would carry paths outside the release file surface. Refusing to push. Offending paths follow; if the version pass legitimately grew a new output, widen the allowlist in this workflow deliberately."