Skip to content

cut-rc.yml refuses every cut since #9064: sync-docs-image-tags.mjs writes three paths outside the enforced release file surface #9518

Closed

Description

@os-project-manager

Found while rehearsing #9497 (the @changesets/cli v3 epic, #9465). It is not caused by that migration — it reproduces identically on today's @changesets/cli@2.31.1, so it is filed on its own.

What is broken

cut-rc.yml's "Build the single version commit" step stages an allowlisted set of paths and then asserts that nothing tracked is left modified-but-unstaged:

LEFT="$(git diff --name-only)"
if [ -n "$LEFT" ]; then
  echo "::error::tracked files were modified but fall outside the release file surface, so the version commit would be incomplete. Refusing to push."

pnpm run version now ends with node scripts/sync-docs-image-tags.mjs, which rewrites three paths that the staging pathspec does not cover:

content/docs/deployment/self-hosting.mdx
content/docs/upgrading.mdx
docker/README.md

So LEFT is non-empty and the step exits 1. The cut refuses to push — correctly, by its own rule; the rule's input surface just went stale.

Measured

Throwaway clone of origin/main @ 3397d7ec9 (165 pending changesets), .changeset/pre.json absent, then changeset pre enter rc + the full pnpm run version pipeline, then cut-rc.yml's staging + assertions replayed verbatim:

@changesets/cli@2.31.1  (today's pin)
  STAGED count: 156
  allowlist regex: accepts every staged path
  unstaged assertion: TRIPS on:
    content/docs/deployment/self-hosting.mdx
    content/docs/upgrading.mdx
    docker/README.md

sync-docs-image-tags.mjs's own output for the same run:

  docker/README.md
    32:6  image-tag: 17.0.0 -> 18.0.0-rc.0
    43:3  image-tag: 17.0.0 -> 18.0.0-rc.0
    65:45  build-arg: 17.0.0 -> 18.0.0-rc.0
  content/docs/deployment/self-hosting.mdx
    76:3  image-tag: 17.0.0 -> 18.0.0-rc.0
    94:3  image-tag: 17.0.0 -> 18.0.0-rc.0
    145:6  image-tag: 17.0.0 -> 18.0.0-rc.0
    163:20  npm-pin: 17.0.0 -> 18.0.0-rc.0
  content/docs/upgrading.mdx
    41:8  image-tag: 17.0.0 -> 18.0.0-rc.0
  8 pin(s) across 3 surface(s)

The same replay under @changesets/cli@3.0.0 trips on exactly the same three paths, so the migration neither causes nor cures it.

Why nobody has seen it

sync-docs-image-tags.mjs was added to the version script by #9064 (2026-08-16). v17.0.0 shipped 2026-08-14. No cut has run since the rewriter joined the pipeline, so the assertion has never been reached with these three files dirty. The workflow's allowlist comment still describes a pnpm run version measured on the 17.0.0-rc.5 -> rc.6 train, before the rewriter existed.

release.yml's version-PR lane is unaffected: changesets/action@v1 runs a plain git add ., with no allowlist.

Fix shape

Per #9465 ruling 3 the allowlist follows the measurement. Widen the staging pathspec and the re-check regex in cut-rc.yml to cover the three docs/docker surfaces the version pass legitimately writes — ideally by naming them as the rewriter's declared output rather than by pattern, so the next surface sync-docs-image-tags.mjs learns about fails loudly here again.

Note for whoever takes this: #9498 rewrites this same allowlist for the v3 pre-mode file set. Whichever lands first should carry the other's change or hand it over explicitly — two PRs editing this block is the shape #9465 exists to prevent.

Activity

  1. os-project-manager commented on Aug 18, 2026

    @os-project-manager
    CollaboratorAuthor

    Claim: epic #9465, round 2 — adopted as sub-issue 5/5 of that epic
    Session: session_01HDA9nN6nXQngoQUAAzRdMb
    Branch: claude/issue-9518-cut-rc-docs-image-tags
    Worktree: objectstack-issue-9518
    Domain: domain:devx
    File surface: .github/workflows/cut-rc.yml (the staging pathspec, its re-check regex, and the allowlist comment that is now stale) (stop on breach; explain in the report)
    Container & model: S, mode:subagent, model: opus — small diff, but it is the gate that decides whether a release commit may be pushed, so it is not a mechanical card.
    Serial constraints cleared: #9498 rewrites this same allowlist block for the v3 pre-mode file set, and is currently held on a maintainer decision (release numbering), so there is no race — this lands first and #9498 rebases onto it, which is the handover the filer asked for. #9450 also names cut-rc.yml (line 285's "range is walkable" preflight) but is an ungraded finding with no assignee and no branch, i.e. not in flight; it is a disjoint block and is not carried here.

    Filed by the #9497 rehearsal and adopted rather than left in the backlog because it is not a migration artifact: it reproduces on today's pinned @changesets/cli@2.31.1, so the next RC cut is blocked right now, whatever happens to the v3 epic. Graded priority:p0 and typed Bug on that basis — the release lane refuses to push, and nobody would find out until a cut was attempted.


    Generated by Claude Code

  2. os-project-manager commented on Aug 18, 2026

    @os-project-manager
    CollaboratorAuthor
    {
      "issue": 9518,
      "status": "done",
      "branch": "claude/issue-9518-cut-rc-docs-image-tags",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/9553",
      "premise_still_valid": true,
      "summary": "The card's premise held exactly and was INCOMPLETE in one way that matters. cut-rc.yml's staging pathspec and re-check regex now cover what pnpm run version actually writes. The three doc surfaces are RESOLVED at run time from SURFACES in scripts/check-docs-image-tag.mjs rather than copied: sync-docs-image-tags.mjs already imports that same declaration ('one list, two consumers'), the module is import-safe thanks to the entry-point guard #9064 added, and this step joins as the third consumer — so ruling 2's preferred route was available and taken. Measurement also found a FOURTH offending path the card does not name, packages/create-objectstack/src/templates/blank/objectstack.manifest.json, written by sync-template-versions.mjs (specVersion), and it is fixed here: allowlisting only three of four would leave the cut still refusing to push, i.e. would not satisfy the card. Per epic ruling 3 the allowlist follows the measurement. That fourth path is named literally beside its sibling objectstack.config.ts because 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 — filed as #9554. Deliberately NOT a wildcard: a content/docs page SURFACES does not name is still rejected. Two new guards refuse the cut if the declaration cannot be resolved or resolves empty. cut-rc.yml:285 untouched; no release performed.",
      "tests": "All at final commit 0bfd6134b (git rev-parse --short HEAD after the last commit). GATES, re-derived for the actual diff with `node scripts/pm/dispatch-gates.mjs .github/workflows/cut-rc.yml` — same five families as dispatch named, no new ones: check:node-version rc=0, check:required-contexts rc=0, check:shard-attestation rc=0, check:workflow-status-functions rc=0, plus check:nul-bytes rc=0 ('scanned 6141 text file(s) ... no raw ASCII control bytes'). UNION_FAIL=0 at 0bfd6134b. REPLAY, per the card: throwaway clone of origin/main @ b9f807734, no remotes, changeset pre enter rc, full `pnpm run version` on the pinned @changesets/cli@2.31.1 -> exit 0, cli 17.0.0 -> 18.0.0-rc.0, rewriter output reproduced the card's byte-for-byte ('8 pin(s) across 3 surface(s)', same line:col). git status --porcelain BEFORE staging = 160 entries (159 M + 1 ?? pre.json): 77 package.json, 76 CHANGELOG.md, protocol-version.ts, blank/objectstack.config.ts, blank/objectstack.manifest.json, docker/README.md, content/docs/upgrading.mdx, content/docs/deployment/self-hosting.mdx, .changeset/pre.json. The step's shell was EXTRACTED FROM THE YAML (not hand-copied) and run under `bash -e`, the runner's default shell for run:, with the dirty tree restored byte-identically before each leg. LEG 1 origin/main: exit 1, LEFT non-empty on FOUR paths (the card's three plus blank/objectstack.manifest.json). LEG 2 this PR: exit 0, STAGED=160, accepted-by-fixed-regex=157, accepted-by-SURFACES-list=3, 157+3=160, BAD=[] and LEFT=[] both EMPTY, commit built. LEG 3 (can it still fail): two synthetic dirty tracked files -> exit 1 naming README.md AND content/docs/ai/actions-as-tools.mdx — a content/docs page outside SURFACES is still rejected, which is the proof this is not a wildcard. LEG 4 SURFACES emptied -> exit 1 'resolved EMPTY ... Refusing to push'. LEG 5 module made unparseable -> exit 1 'could not resolve SURFACES ... Refusing to push'. Legs 3-5 are reverse verification of the new guards; expected direction was 'turns red' and that is what was observed in all three. No ablation-on-dist applies (no package build is involved). NO RELEASE: throwaway clone with no remotes, no publish, no tags, no pre-mode entry/exit on origin, no workflow dispatch.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #9554: sync-template-versions.mjs declares its version-stamp targets (TEXT_STAMPS + a walk of src/templates/) but exports none of them and has no entry-point guard, so cut-rc.yml must restate two template paths literally — both hardcoding the template name 'blank', which re-opens this same defect the day a second template ships",
        "filed as #9555: any local `pnpm run version` rehearsal hangs FOREVER in this container — the shared checkout is shallow, so every changeset resolves to a parentless boundary commit and @changesets/git's getCommitsThatAddFiles loops on deepenCloneBy with no reachable remote. Cost ~2.5h here before diagnosis; includes the working scaffold (drop + re-add .changeset in two commits, net tree identical) that takes a resolution pass from never-terminating to 0.57s. Not a production defect: cut-rc.yml checks out with fetch-depth: 0",
        "not filed, reported for PM judgement: the `skip-changeset` label was applied by me and confirmed by READ-BACK — PR #9553 labels are ci/cd, size/s, skip-changeset. Written as the union of the bot's existing set, since issue_write's label write is a whole-set PUT and pr-automation.yml documents that race explicitly"
      ]
    }

    Generated by Claude Code


    Generated by Claude Code

  3. os-project-manager commented on Aug 18, 2026

    @os-project-manager
    CollaboratorAuthor

    ACCEPT. Verified against the branch and origin/main, not against the report.

    • File surface held exactly: PR fix(ci): stage what the version pass actually writes in cut-rc.yml #9553 changes one file, .github/workflows/cut-rc.yml, +72/−13. The fix for a fourth offending path lives inside that same file, so finding more than the card named did not widen the diff beyond its declared surface.
    • Fixes #9518 is the first body line; labels ci/cd, size/s, skip-changeset.
    • The "read, don't restate" ruling was satisfied by the better route, and I checked the premise rather than the claim: SURFACES really is export const in scripts/check-docs-image-tag.mjs, and sync-docs-image-tags.mjs really does import it. This step joining as the third consumer of one declaration is the shape the ruling asked for.
    • The non-wildcard property is structural, not just asserted. The re-check filters staged paths with grep -vxF -f against the very list that was staged — whole-line, fixed-string. The PR body's own reason for that choice is the kind of detail that decides correctness later: an unescaped ERE alternation spelling README.md would have accepted READMEXmd.
    • The requirement I would have sent this back for is met. Leg 3 shows the assertion still fails on a content/docs page SURFACES does not name; legs 4 and 5 show the two new guards refuse the cut when the declaration is unresolvable or empty. A gate that can no longer fail would have been worse than the bug — that is why it was demanded before the work started, and it was answered with output rather than argument.
    • CI: the Check Changeset failure at 09:23 is superseded by its 09:27 re-run, skipped, after skip-changeset landed. Everything else is success or skipped by path filter, as a workflow-only diff should be. Lint & Repo Gates, TypeScript Type Check, Console Pin Freshness, Part-of and the same-issue guard all green.
    • skip-changeset is the right call and was justified from the diff rather than applied to clear a red: one workflow file, no package published, nothing shipped to consumers.

    One correction, and it is against me. I started to flag the fourth path's attribution as wrong — nothing in scripts/sync-template-versions.mjs named objectstack.manifest.json or TEXT_STAMPS. I was reading the shared checkout, whose HEAD sits at this morning's 5904b0500. On origin/main (b348ac2c2) that script has grown from 3,388 to 10,818 bytes and does exactly what the report says. The attribution is correct; the stale read was mine. Recording it because this repo's own rule — verify against origin/main, never the shared working tree — exists precisely for the near-miss I just had, and a reviewer who skips it publishes a confident, wrong correction.

    Landing: flipping to ready and enabling auto-merge. #9498 rebases onto this once it lands, per the sequencing on #9465.


    Generated by Claude Code

  4. os-project-manager commented on Aug 18, 2026

    @os-project-manager
    CollaboratorAuthor

    Landed and verified on origin/main as 955ccf20d — fix(ci): stage what the version pass actually writes in cut-rc.yml (#9553).

    Checked by content rather than by sha ancestry, since the queue lands changes as a squash and the reviewed sha is therefore not an ancestor of main. On origin/main:.github/workflows/cut-rc.yml:

    • SURFACE_LIST resolved at run time (line 550) with both guards — unresolvable (553) and empty (560) — present;
    • mapfile -t DOCS_SURFACES feeding the staging pathspec (564);
    • the fourth path, packages/create-objectstack/src/templates/blank/objectstack.manifest.json, in the allowlist (575);
    • the re-check's whole-line exact filter grep -vxF -f "$SURFACE_LIST" (592).

    What this unblocks: the next RC cut can push again. It had been refusing on every attempt since #9064 joined sync-docs-image-tags.mjs to the version script, and nobody would have found out until someone tried to cut — which is why this was graded p0 the moment the rehearsal surfaced it, ahead of the migration that found it.

    Follow-ups left open deliberately, both filed by the same run: #9554 (sync-template-versions.mjs declares its stamp targets but exports none and has no entry-point guard, so this workflow must still name two template paths literally — and both hardcode blank, which re-opens this defect the day a second template ships) and #9555 (local pnpm run version rehearsals hang forever in this container on a shallow checkout; carries the working scaffold). Neither blocks anything today.

    Closing as completed.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions