Skip to content

ci(release): validate the post-version tree before the version PR is opened - #11964

Merged
os-steve merged 3 commits into
mainfrom
claude/issue-11945-release-pr-no-ci
Aug 25, 2026
Merged

ci(release): validate the post-version tree before the version PR is opened#11964
os-steve merged 3 commits into
mainfrom
claude/issue-11945-release-pr-no-ci

Conversation

@claude

@claudeclaudeBot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Fixes#11945

The chore: version packages PR runs no CI and cannot be made to: changesets/action@v1 opens and force-pushes it with the default GITHUB_TOKEN, and GitHub does not start workflow runs from events raised by that token. So the largest machine-generated diff of the release cycle is the only commit that reaches main without passing the gates.

This renders the post-version tree inside the version-pr job, validates it, and puts it back before changesets/action reads it — maintainer ruling objectui#5397 (2026-08-22, 「接受所有」), option A. No PAT / GitHub App token, no per-artifact guarantee.

Premise re-verified on current origin/main

Confirmed, not inherited. release.yml line 313 is uses: changesets/action@v1, and line 342 hands it GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}. Two files in this repository already state the mechanism in their own words:

  • .github/workflows/lint.yml:1244-1252 — "changeset version bumps packages/cli on a release PR that (per sync-template-versions.mjs's own header) gets NO CI because changesets/action opens it with the default GITHUB_TOKEN — so the bump merged green and the gate reddened on the NEXT ordinary PR, naming files that author never touched."
  • scripts/sync-protocol-version.mjs, header — "release PRs opened by changesets/action with the default GITHUB_TOKEN do not trigger CI (GitHub's anti-recursion rule), so the guard only fired AFTER the merge. Fixing the value at version time is the only spot that cannot be skipped."

One citation in the card has rotted. The card cites lint.yml:988-991; that range is now the doc-frontmatter gate's prose and says nothing about release PRs. Triage's lint.yml:1249 is the live line. Quoted above from the current file.

The surface, measured here (2026-08-25, at 3689991d2)

The sibling's scope conclusion does not transfer, and the reason is concrete: pnpm run version here is four rewriters, and one of them writes TypeScript. Rendered against the real tree (158 pending changesets, @objectstack/cli 17.2.0 → 17.3.0), it moved 313 paths, in four classes and no others:

countclassdetail
158.changeset/*.mddeleted (consumed)
76*/package.json152 changed lines, every one "version": — not one dependency range moved
76*/CHANGELOG.mdgenerated from the changeset bodies
3doc surfaces8 concrete image-tag / npm-pin lines in content/docs/deployment/self-hosting.mdx, content/docs/upgrading.mdx, docker/README.md

Two further classes are major-boundary only and did not move: packages/spec/src/kernel/protocol-version.ts (a runtime source file — which is why "the version step cannot move source" is false here) and, per bundled template, objectstack.config.ts / objectstack.manifest.json. A major cannot reach this lane while check-changeset-no-major.mjs holds, so they are dormant rather than covered, and the workflow says so out loud rather than validating a surface with no gate behind it.

cut-rc.yml's allowlist block carries the same measurement for the RC lane and agrees path-for-path.

All four sync scripts still run — checked, not assumed. This PR also corrects the version-pr job's own comment, which still called pnpm run version three rewriters after #9064 made it four.

What ships: two halves

Shape — every moved path must fall inside the reviewed surface, with the doc and template halves resolved at run time from the declarations the rewriters themselves read (SURFACES in check-docs-image-tag.mjs, stampedPaths() in sync-template-versions.mjs), using the same predicate cut-rc.yml applies to the same surface, on its stated principle that "a fourth literal is a fourth contract". This is the half that ends the treadmill: a fifth rewriter, or an existing one growing an output, reddens this lane on the day it lands.

Content — the eight gates whose corpus is what the version step actually writes: check:docs-image-tag, check:docs-image-tag-sync, check:template-version-sync, check:nul-bytes, check:release-notes, check:release-page-status, check-release-section-coverage.mjs, check:published-readme-links. Measured at 7 s on the rendered tree, in this job's own state (install, no build).

Plus a restore step (if: always()) that asserts the restoration, because changesets/action picks its branch from the working tree: with .changeset/ consumed and no publish: input on this lane, v1 takes case !hasChangesets && !hasPublishScript, logs "No changesets present or were removed by merging release PR" and returns — a permanent silent no-op with nothing red anywhere.

Which per-artifact rewriters this makes redundant

None are removed here, and none should be — they still produce the correct value, which is worth more than detecting a wrong one. What this retires is the obligation each one carries to be its own gate: sync-protocol-version.mjs (#2769), sync-template-versions.mjs, and sync-docs-image-tags.mjs (#9064) were each built because the gate that judges their output structurally could not fire on the release PR. check:docs-image-tag now runs on the post-version tree, which is exactly the run #9064 says was impossible. The next version-time output needs a rewriter; it no longer needs to be trusted without one.

Deliberately not run, and why it is not an omission

Fed this 313-path change set, scripts/pm/dispatch-gates.mjs names 49 families; 44 run without a build in 113 s, and every one is green on the post-version tree. The control that makes that number mean something: the identical sweep on the pre-version tree returns the identical verdicts — 44 green, the same 6 red — so the version step changes no gate's mind on this train, and those 6 are environment (5 need a build, 1 needs full git history), not findings.

It is mostly restatement: a family is derived because a package's package.json moved, and what moved inside it was the "version" key, while the gate reads that package's source, which ci.yml judged on this very commit minutes earlier.

Running that selector in CI was drafted and withdrawn — see the open question below.

The five build-requiring families (check:i18n, check:i18n-coverage, check:dev-prereqs, check:doc-formula-expressions, check:doc-security-posture) would in any case re-add to this lane the ~9 minutes of build that this job's own comment records as deliberately moved to the publish job, to gate a manifest's "version" key. What they read is not what the version step wrote: the two doc gates judge formula fences and security-posture prose, and the only content/** lines this step can move are the 8 concrete pins above.

Verification

Because release.yml runs only on schedule / workflow_dispatch, CI cannot exercise these steps on this PR. So they were run locally as shipped — the three step scripts were extracted from release.yml itself with a YAML parser and executed with the Actions context variables they read, against the real tree.

Green path, on the final commit 7a29541a9:

RENDER_EXIT=0 158 pending changesets consumed, 313 paths moved
VALIDATE_EXIT=0 "Post-version tree validated." 313 path(s), all inside the reviewed shape; content gates failed: 0
RESTORE_EXIT=0 "Pre-version tree restored: 158 pending changeset(s), working tree clean."

That the gates really judge the rendered tree rather than main's is not asserted but observed: check-release-section-coverage.mjs reported @objectstack/spec 17.3.0 is published (it has a ## 17.3.0 section in packages/spec/CHANGELOG.md) — a section that exists only after the render.

Reverse verification — each limb mutates, proves the mutation on disk, runs the shipped validate step, and restores under a trap:

limbon-disk proofresult
a fifth rewriter writes outside the surfaceinjected path present in the moved list (count 1)red — unexpected: packages/core/src/invented-version-output.ts
a major train wakes the dormant surfacesprotocol-version.ts present in the moved list (count 1)red — unvalidated: … crossed a MAJOR boundary … which this lane has no gate for
a doc pin the rewriter did not fixobjectstack:17.3.0 occurrences in docker/README.md 2 → 1red — fails 1 gate(s) …: pnpm check:docs-image-tag

The second limb is why there is a third commit: on the first pass it fell into the "wrote outside the reviewed surface" branch — red for the right reason and wrong about why, on a lane whose refusals are supposed to name the remedy.

Gates for this diff, derived live with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack at 7a29541a9 and all green (16/16), check:workflow-status-functions, check:required-contexts, check:step-collectors, check:aggregator-roster, check:shard-attestation, check:nul-bytes and check-self-test-wired.mjs among them.

Open question for the maintainer

Should the release lane select its gates by derivation instead of by list? Feeding the change set to scripts/pm/dispatch-gates.mjs generalises perfectly — a new version-time output pulls in its surface's gates with nothing here to update — and it is affordable (113 s + 18 s for the tool's own self-test, four times a day). It was withdrawn because shipping it needs two things this card should not decide alone: scripts/check-self-test-wired.mjs must grow a new ledger shape (the tool's --self-test cannot be wired directly — its own self-test refuses the direct spelling as a fresh escapable literal, measured), and check-dispatch-gates.mjs's header already records the reviewed position that "there is no verdict in [the live derivation] for CI to hold". Both forms of the derivation name the same 49 families on this change set, so the measurement is in hand whenever you want it.

Siblings

cloud and objectos could not be audited: both are outside this session's repository allowlist (objectstack-ai/objectstack, objectstack-ai/objectui only), and attaching objectstack-ai/cloud was refused — "you don't have access". No card filed for either, per the dispatch order. The one grep each still needs doing by a session that can read them.

skip-changeset: workflow-only change, nothing published moves.


Generated by Claude Code

…opened
The "chore: version packages" PR runs no CI: changesets/action opens and
force-pushes it with the default GITHUB_TOKEN, and GitHub does not start
workflow runs from events raised by that token. So the largest
machine-generated diff of the release cycle is the only commit that reaches
main without passing the gates. Two files in this repository already state
that mechanism -- lint.yml's docs-image-tag block and
scripts/sync-protocol-version.mjs's header -- and each states it while
explaining a rewriter built to work around it.
Render the post-version tree in the version-pr job, validate it, and put it
back before changesets/action reads it. The gate list is derived from the
change set by scripts/pm/dispatch-gates.mjs rather than written down, so a
fifth version-time rewriter pulls in its surface's gates on the day it lands.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015ahemw8RcTgqtxrj15PEZx
…gates that read it
Replaces the first draft's derive-and-run selector. Feeding the change set to
scripts/pm/dispatch-gates.mjs named 49 families and ran 44 of them green in
113 s, but running the tool in CI needs check-self-test-wired.mjs to grow a new
ledger shape, and check-dispatch-gates.mjs already records the reviewed position
that the live derivation has no verdict for CI to hold. That is a maintainer's
call, not a rider on this card.
What ships instead is two halves: a shape assertion that resolves the doc and
template surfaces at run time from the declarations the rewriters themselves
read (cut-rc.yml's idiom, same predicate), so a new version-time output reddens
this lane on the day it lands; and the eight gates whose corpus is what the
version step actually writes, measured at 7 s.
Also corrects this job's comment, which called `pnpm run version` three
rewriters after #9064 made it four.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015ahemw8RcTgqtxrj15PEZx
Reverse verification caught the routing: with protocol-version.ts missing from
the shape filter's pattern, a major train's output fell into the "wrote outside
the reviewed post-version surface" branch -- red for the right reason and wrong
about why, on a lane whose refusals are supposed to name the remedy.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015ahemw8RcTgqtxrj15PEZx
@claudeclaudeBot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 25, 2026
@os-steveClaude

Copy link
Copy Markdown
Collaborator

ACCEPT, and option A on the open question. Flipping out of draft; arming once green.

Verified against the branch, not read back

claimcheckresult
scopegit diff --stat origin/main...HEAD1 file, release.yml, +365/−2
premise still liveorigin/main:release.ymlchangesets/action@v1 at :313, GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} at :342
the three stepsthe diff✅ Render → Validate → Restore with if: always()
⭐ the rotted citationlint.yml on main:989 is a bare # line; :1249 carries "changesets/action opens it with the default GITHUB_TOKEN — so the bump…"

That last one is what I asked for in Zone 2 — the card said 988-991, triage said :1249, and I noted at least one had to have rotted. You found which, and quoted the live line rather than the dead one.

⭐ The restore is load-bearing, and you found out why rather than assuming

with .changeset consumed and no publish: input the action takes case !hasChangesets && !hasPublishScript and the refresh becomes a permanent silent no-op

That reframes if: always() from hygiene into the thing that keeps the lane alive. A restore step that merely "tidied up" could have been dropped by a later editor; one that prevents a permanent silent no-op cannot. Reading the action's own branch table to establish it is the right depth for a step nothing can test in CI.

The price measurement, with the control that makes it mean something

Post-version farm 117 s, 44 green / 6 red; the identical sweep on the pre-version tree: 117 s, 44 green / the SAME 6 red. So the version step changes no gate's verdict on this train, and the 6 are environmental (5 asking for pnpm build, check-engine-split-ratio refusing on a shallow clone). Without the pre-version control the 6 reds would have looked like the version step's doing — that pairing is what turns a number into evidence.

And the scope conclusion is genuinely this repo's: 313 paths, four classes, 152 changed lines in package.json files all of them "version": with no dependency range moved. That is what I fenced against inheriting from objectui, and you measured it here.

⭐ Limb (b) failing its first run is the best evidence in the report

it hit the "wrote outside the reviewed surface" branch — red for the right reason, wrong about why

A reverse-verification limb that goes red can be banked without looking. You looked, found the red was arriving through the wrong branch, and fixed the classification in 7a29541a9. That is the difference between "the guard fires" and "the guard fires for the reason it claims", and it is the third time tonight a dev has caught a green-or-red that would have passed unexamined.

Extracting the three step scripts out of the YAML with a parser rather than retyping them, then running them with the Actions context variables, is the correct substitute for a CI run — and the proof that the gates read the rendered tree is exactly right: check-release-section-coverage.mjs reporting a ## 17.3.0 section that exists only after the render.

⚠️ The one thing every later reader must know: CI cannot exercise this

release.yml runs only on schedule / workflow_dispatch, so no CI run on this PR touches the three new steps. The usual backstop is absent. That is why (3) and (4) exist, and it is why this PR's verification had to be heavier than its diff suggests. Anyone editing these steps later inherits the same obligation.

Option A, and B stays available at no cost

A. It is the shape the maintainer already ruled on the sibling, and the half that closes the class this repo has paid for three times (per-artifact rewriters) is the shape assertion — a fifth rewriter reddens the lane on the day it lands, by construction rather than by memory. Resolving the doc and template surfaces at run time from the declarations the rewriters themselves read is what makes that hold without a list to maintain.

B is not rejected, it is deferred with its price already paid. You measured that both forms name the same 49 families on this change set, so nothing is lost by waiting. Two things make it a maintainer's call rather than a rider: it asks check-self-test-wired.mjs — which landed only tonight, via #11918 — to grow a new ledger shape for a spelling its own self-test currently refuses; and it re-opens #8162's recorded position deliberately. ⚠️ It would also promote dispatch-gates.mjs, a PM dispatch aid, into the release lane's critical path, which is a dependency worth choosing on purpose.

Siblings and out-of-scope, both handled correctly

cloud and objectosnot audited and nothing filed — outside this session's repository allowlist, and add_repo for objectstack-ai/cloud was refused. Reporting the access boundary instead of guessing is right; the halves stay open on the card.

Not filing the two release-coverage advisories because #11879 is the standing anchor the patrol rewrites in place — correct, and searched before concluding.


Generated by Claude Code

@os-steve
os-steve marked this pull request as ready for review August 25, 2026 03:31
@os-steve
os-steve added this pull request to the merge queueAug 25, 2026
Merged via the queue into main with commit 227c178Aug 25, 2026
27 checks passed
@os-steve
os-steve deleted the claude/issue-11945-release-pr-no-ci branch August 25, 2026 04:30
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

2 participants

@os-steve@claude