From 0bfd6134ba741b5799dfb29c4ba804ec7736475a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 09:21:03 +0000 Subject: [PATCH] fix(ci): stage what the version pass actually writes in cut-rc.yml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pnpm run version` has ended with `node scripts/sync-docs-image-tags.mjs` since #9064, and `sync-template-versions.mjs` stamps `specVersion` into the blank template's `objectstack.manifest.json`. Neither output was covered by the staging pathspec in "Build the single version commit", so the step's "nothing tracked may be left modified-but-unstaged" assertion tripped and every cut refused to push. Measured, not assumed: a full `pnpm run version` in a throwaway clone over 199 pending changesets (17.0.0 -> 18.0.0-rc.0) leaves 160 dirty paths, four of which fall outside the old allowlist: content/docs/deployment/self-hosting.mdx content/docs/upgrading.mdx docker/README.md packages/create-objectstack/src/templates/blank/objectstack.manifest.json The three doc surfaces are RESOLVED, not restated: `sync-docs-image-tags.mjs` imports `SURFACES` from `scripts/check-docs-image-tag.mjs` rather than owning a list, and this step now joins as a third consumer of that same declaration. Deliberately not a wildcard over `content/docs/**` — a docs page `SURFACES` does not name is still rejected, which is the signal the assertion exists for. Two new guards refuse the cut when the declaration cannot be resolved or resolves empty, so an unknown surface can never degrade into an empty allowlist. The template manifest is named literally beside its sibling `objectstack.config.ts`: `sync-template-versions.mjs` declares its targets but exports none of them and has no entry-point guard, so it cannot be read the way the docs gate can. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HDA9nN6nXQngoQUAAzRdMb --- .github/workflows/cut-rc.yml | 85 ++++++++++++++++++++++++++++++------ 1 file changed, 72 insertions(+), 13 deletions(-) diff --git a/.github/workflows/cut-rc.yml b/.github/workflows/cut-rc.yml index 8676b1b5ec..ecb40fdef1 100644 --- a/.github/workflows/cut-rc.yml +++ b/.github/workflows/cut-rc.yml @@ -486,16 +486,46 @@ jobs: # cut: the pin bump and the version output are one atomic unit, and there is # never a main commit carrying a bumped pin but an unversioned tree. # - # WHAT MAY BE IN IT — measured on this train (17.0.0-rc.5 -> 17.0.0-rc.6, a - # full local `pnpm run version` over 1704 pending changesets), not assumed: - # 76 modified package.json, 76 modified CHANGELOG.md, and - # .changeset/pre.json. Zero deletions, zero untracked files. In PRE mode - # `changeset version` does NOT delete the consumed changesets — it records - # them in pre.json and they are removed at `changeset pre exit` — so a cut - # never races a lane PR over a `.changeset/*.md` file it wants to keep. - # Two more paths can appear at a major boundary and are allowed for that - # reason: packages/spec/src/kernel/protocol-version.ts and the blank - # template's objectstack.config.ts (both written by the sync scripts). + # WHAT MAY BE IN IT — re-measured on the 17.0.0 -> 18.0.0-rc.0 train (a full + # local `pnpm run version` in a throwaway clone over 199 pending changesets), + # not assumed. 160 paths: + # 77 modified package.json, 76 modified CHANGELOG.md, + # .changeset/pre.json, the 3 major-boundary paths below, and the 3 doc + # surfaces below. Zero deletions. `.changeset/pre.json` is UNTRACKED on the + # first cut of a train (`changeset pre enter` just created it) and modified + # on later ones; `git add -A` covers both. In PRE mode `changeset version` + # does NOT delete the consumed changesets — it records them in pre.json and + # they are removed at `changeset pre exit` — so a cut never races a lane PR + # over a `.changeset/*.md` file it wants to keep. + # 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. + # + # 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 + # concrete image tag / CLI version. That rewriter does not own the list — it + # imports `SURFACES` from `scripts/check-docs-image-tag.mjs`, the gate whose + # findings it exists to clear, on the stated principle "one list, two + # consumers". This step is the THIRD consumer and joins on the same terms: + # it resolves `SURFACES` at run time rather than copying the paths, because a + # fourth literal is a fourth contract and the two drifting apart is exactly + # the defect that reached this assertion in the first place — the rewriter + # joined the `version` script while this allowlist kept describing a train + # measured before it existed, and every cut since would have refused to push. + # Note what this deliberately is NOT: a wildcard over `content/docs/**`. Only + # the paths a reviewed declaration names are allowed, so a version pass that + # writes anywhere else — a new sync script, or this rewriter growing an + # output outside `SURFACES` — still fails loudly here, which is the whole + # point of the assertion. Onboarding a surface stays a one-line edit to + # `SURFACES` that a reviewer sees in the diff. # # 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 @@ -514,13 +544,36 @@ jobs: # the index, so bump + version become one commit on top of the snapshot. git reset --soft "$SNAPSHOT_SHA" + # The doc surfaces `sync-docs-image-tags.mjs` rewrites, read from the same + # declaration the rewriter reads. Import-safe by construction: the module + # carries an entry-point guard added for exactly this kind of consumer. + SURFACE_LIST="${RUNNER_TEMP:-/tmp}/cut-rc-docs-image-tag-surfaces.txt" + if ! node --input-type=module \ + -e 'import { SURFACES } from "./scripts/check-docs-image-tag.mjs"; for (const s of SURFACES) console.log(s.file);' \ + > "$SURFACE_LIST"; then + echo "::error::could not resolve SURFACES from scripts/check-docs-image-tag.mjs, so the doc half of the release file surface is unknown. Refusing to push." + exit 1 + fi + # An empty list would silently re-open the hole this replaced: the docs the + # version pass rewrites would go unstaged and the assertion below would + # blame them. Unknown is a failure, never an empty allowlist. + if [ ! -s "$SURFACE_LIST" ]; then + echo "::error::SURFACES in scripts/check-docs-image-tag.mjs resolved EMPTY, so no doc surface would be staged even though the version pass rewrites them. Refusing to push." + exit 1 + fi + mapfile -t DOCS_SURFACES < "$SURFACE_LIST" + echo "doc surfaces declared by SURFACES (${#DOCS_SURFACES[@]}):" + sed 's/^/ /' "$SURFACE_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.config.ts \ + packages/create-objectstack/src/templates/blank/objectstack.manifest.json \ + "${DOCS_SURFACES[@]}" STAGED="$(git diff --cached --name-only)" if [ -z "$STAGED" ]; then @@ -529,8 +582,14 @@ jobs: fi # Re-check every staged path against the allowlist. The pathspec above is - # convenience; THIS is the guarantee. - 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$' || true)" + # 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. + 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 -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." printf '%s\n' "$BAD" | sed 's/^/::error:: unexpected: /'