Skip to content

feat(release): cut-rc — snapshot one-click rc release (#7447) - #7453

Merged
hotlong merged 2 commits into
mainfrom
claude/issue-7447-cut-rc-snapshot-release
Aug 10, 2026
Merged

feat(release): cut-rc — snapshot one-click rc release (#7447)#7453
hotlong merged 2 commits into
mainfrom
claude/issue-7447-cut-rc-snapshot-release

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #7447

A workflow_dispatch-only lane that bumps the objectui pin, versions, gates and publishes an rc from one snapshot. Cutting rc.6 today needed four pin-bump laps (8aad9fd9b9fa49cfeb3781b6188dbcd3e02, every lap overtaken) and would have needed ~40 min of coordinated freezes across two repos. The snapshot is the whole idea: objectstack main and objectui main HEAD are each resolved exactly once, and every downstream step reads those frozen values — so both repositories may keep moving for the entire run.

File surface: .github/workflows/cut-rc.yml (new) and docs/releases-maintenance.md. release.yml's three lanes, scripts/check-objectui-pin-fresh.mjs and content/docs/releases/** are untouched.

The sequence

# Step Note
1 Guard event / ref / version shape dispatch-only, from main, X.Y.Z-rc.N only
2 Checkout main at full depth, record SNAPSHOT_SHA fetch-depth: 0
3 Guard the train: pre.json is mode: pre / tag: rc, and the requested version is not the one main already carries rc-only asserted against committed state, not the string
4 Resolve objectui main HEAD once via git ls-remote, full-clone it unreachable remote is red, never green
5 bump-objectui.sh $OBJECTUI_SHA (always explicit), assert .objectui-sha == $OBJECTUI_SHA self-consistency, not liveness
6 pnpm objectui:build, check:console-sha, playwright install, pnpm sdui:manifest ADR-0082 D4 ratchet — this lane moves the pin, so it owes the ratchet
7 Pre-version gates check-changeset-fixed, check:nul-bytes, and the three .changeset gates
8 pnpm run version + assert computed version == dispatch input the repo script, never bare changeset version
9 Build ONE squashed version commit, allowlist-enforced dry run stops here
10 Push to main, confirm ancestry, publish from the landed commit #6170 to the letter

Invariants preserved

Two things I measured rather than assumed

1. The version pass's real file surface. I ran a full pnpm run version locally on this train (1704 pending changesets) and captured the result: 76 modified package.json, 76 modified CHANGELOG.md, and .changeset/pre.json. Zero deletions, zero untracked files. Computed version: 17.0.0-rc.6, matching the issue's prediction.

Worth recording because it corrects the issue's stated surface: 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, which is a stronger disjointness guarantee than the issue claimed.

That measurement is what the commit-building step's allowlist is derived from, and the allowlist is enforced, not described: only allowlisted paths are staged, every staged path is then re-checked against the same rule, and the worktree is asserted clean. If pnpm run version ever grows a new output the run fails loudly there instead of pushing a surprise to main. Verified in both directions — all 162 real surface paths accepted; packages/objectql/src/engine.ts, .github/workflows/ci.yml, scripts/*.mjs, content/docs/releases/v17.mdx, pnpm-lock.yaml and packages/console/dist/** all blocked.

2. The three .changeset gates are forward-only DIFF gates, so they needed a base. Their --list modes are audits that always exit 0 (--audit-stock says in its own header that a non-zero exit there would make it a gate, which it is not), so wiring one of those in would have been a gate that cannot fail. I hand them --base "$SNAPSHOT_SHA" instead, which judges this run's own contribution — the @objectstack/console changeset the pin bump just emitted. That is a real gate: the digest resolves its bump level from objectui's declarations, so an objectui major would otherwise promote all ~70 packages, and a digest bug emitting empty frontmatter would stall the release silently and greenly (#4898).

$SNAPSHOT_SHA is a pinned sha, which #6129 warns against — but that warning is about pr-automation.yml's frozen base.sha, a value that goes stale as main moves under a PR. Here the snapshot is by construction the parent of the commit being judged, so it is the merge base and cannot go stale. I also confirmed the three gates' self-tests scope their "exactly one call site" wiring assertions to pr-automation.yml / lint.yml, so the new call sites do not trip them — all 377 assertions pass with this branch applied.

Admin prerequisite (one-time, maintainer-only)

The lane pushes the version commit straight to main, so the pushing identity must be on main's ruleset bypass listcontents: write alone does not put it there. Either:

  • (a) add the GitHub Actions app to the ruleset's bypass list, ideally scoped to this workflow, and leave the secret unset; or
  • (b) create a fine-grained PAT with contents: write on this repository for an account already on that bypass list, and store it as the repository secret RELEASE_PUSH_TOKEN.

The workflow uses the secret when present and the Actions identity otherwise, so configuring either is enough and neither needs a workflow edit. Until one is configured the push step fails with exactly that message and nothing is published — the failure is before any irreversible step, and it is distinguished from a network fault in the error text. I did not implement the catch-up-PR fallback: that is the rc.3/rc.4 accident shape (#6170) and the issue rules it out.

Direct-to-main push is not structurally impossible here — it is an ordinary ruleset bypass configuration — so this is a prerequisite to satisfy, not a needs_decision.

What I could and could not verify locally

Executed, real output:

  • node scripts/check-workflow-status-functions.mjs --self-test + real scan — 34 assertions; scan covered 24 workflow files including this one, 43 jobs, 9 job-level if: reading needs.*.outputs.*, all naming a status function.
  • node scripts/check-required-contexts.mjs — pass (this workflow registers no required context; it is dispatch-only).
  • pnpm check:nul-bytes — 6830 files, no raw control bytes; plus a targeted grep -naP self-scan over both changed files.
  • pnpm check:changeset-gate-self-tests — 377 assertions across all three gates, green with the new call sites present.
  • The three gates run with the exact argument shapes the workflow uses, in both the real-diff case (--base HEAD~5) and the empty-diff case (--base HEAD, i.e. the "objectui had not moved, no bump commit" path). All exit 0.
  • bash scripts/bump-objectui.sh --help — confirms the bump-objectui.sh SHA positional shape the workflow relies on.
  • pnpm run version end to end — the measurement above; the worktree was reset afterwards.
  • YAML parsed with the repo's own yaml package: 1 trigger, 2 inputs, 1 job, 37 steps, no step name containing ": " (the nested-mapping trap).
  • Every multi-line run: block extracted and bash -n syntax-checked — 20 blocks, all clean.
  • pnpm check: for node-version, adr-anchors, adr-links, docs-audit-scope, release-notes, role-word, org-identifier, merge-driver, quick-reference-counts, objectui-changeset, doc-authoring, empty-changeset — all pass.

Not executable locally, and stated plainly: actionlint and shellcheck are not installed in this container, so the YAML review is the parse above plus manual review against release.yml's patterns, and the shell review is bash -n plus manual review. More importantly, I cannot execute the workflow end to end — no runner, no release environment, no npm or push credentials. Nothing here proves the live behaviour of the push-retry loop, the Playwright install against objectui's build tree, or the publish steps.

That is exactly what dry_run is for, and it is why I made it stop later than the issue's acceptance criterion asks. It stops after the local squashed commit rather than after the version step, so the squash, the allowlist enforcement and the version assertion have all really executed, and the uploaded artifact is byte-for-byte the commit a real run would push. Please dispatch with dry_run checked before the first real cut.

Deliberate non-inclusions

Releases nothing (workflow + docs only) → skip-changeset.


Generated by Claude Code

A `workflow_dispatch`-only lane that bumps the objectui pin, versions, gates
and publishes an rc from ONE snapshot, ending the two-repo pin-chasing freeze
dance (rc.6 needed four overtaken pin-bump laps and ~40 min of coordinated
freezes).

The snapshot is the whole idea: objectstack `main` and objectui `main` HEAD are
each resolved exactly once, and every downstream step reads those frozen values,
so both repositories may keep moving for the entire run.

Preserves every invariant of the existing lanes:
- 2026-08-07 manual-publish ruling: dispatch-only trigger, `environment: release`.
- #6170: the version commit lands on `main` FIRST and is published SECOND, so
  nothing is ever published from a ref that only existed inside the run.
- #3340: `check-objectui-pin-fresh.mjs` is unchanged and still enforces on the
  GA path. This lane asserts pin SELF-CONSISTENCY instead of liveness, because
  liveness is exactly what a snapshot gives up; the changeset record still
  covers OLD_PIN..SNAPSHOT by construction.
- #5960: the ADR-0082 D4 declaration-parity ratchet runs here, since this lane
  is now a route that moves the pin.

`release.yml`'s three lanes, `scripts/check-objectui-pin-fresh.mjs` and
`content/docs/releases/**` are untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BPWqbmEFU8gJepBJTHESXd
@vercel

vercel Bot commented Aug 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 10, 2026 12:58pm

Request Review

@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation ci/cd labels Aug 10, 2026
@os-zhuang os-zhuang added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 10, 2026 — with Claude

Copy link
Copy Markdown
Contributor Author

PM review (dispatching seat, session session_01BPWqbmEFU8gJepBJTHESXd): accepted in substance. I read the workflow end to end, not just the PR body. Three things I checked specifically because they are where this design could have gone wrong, and all three are right:

Two corrections to the dispatch I want on the record, because the dev measured rather than assumed and was right both times: (1) in pre mode changeset version does not delete consumed changesets — it records them in pre.json — so the disjointness from lane PRs is stronger than the issue claimed; (2) the three .changeset gates are forward-only diff gates and needed --base "$SNAPSHOT_SHA", which is the merge base by construction here and so is not the #6129 stale-base trap.

One gap to close before this leaves draft

What happens to the standing chore: version packages (rc) PR (#6208) after a cut-rc run is undefined, and the answer is counter-intuitive. A push made with the Actions identity (GITHUB_TOKEN) does not trigger new workflow runs — GitHub's recursion guard. So on route (a), landing the version commit will not fire release.yml's version-pr job, and #6208 will sit there showing a stale, already-consumed version bump until some later unrelated push refreshes it. On route (b) (PAT) it will fire and the PR will self-correct. Same lane, two different resting states depending on which credential the maintainer configured — that is exactly the kind of thing that gets rediscovered at 2am.

Please add: a short "What happens to the standing Version Packages PR" paragraph in docs/releases-maintenance.md stating both behaviours and telling the cutter that a stale #6208 after an rc cut is expected-and-harmless (its changesets are already consumed; it regenerates correctly at the next GA cut or the next main push), plus a line in the workflow's run summary naming it. No code change needed — this is a documented-expectation gap, not a defect.

Everything else is ready. CI is green or running with no failures on aeeab1e; Console Pin Freshness passes (this branch does not move the pin). After the paragraph lands, mark ready for review — I will not flip it out of draft on the dev's behalf.

Reminder for the maintainer reading this thread: the admin prerequisite is one of the two routes, not both, and route (a) needs no new secret at all.


Generated by Claude Code

… a cut-rc run (#7447)

A cut leaves #6208 (`chore: version packages (rc)`) in one of two resting
states depending on which push credential the maintainer configured, and the
answer is counter-intuitive enough to be worth writing down rather than
rediscovering mid-incident.

Verified rather than assumed, three ways:
- GitHub documents that events triggered by the automatic GITHUB_TOKEN do not
  create workflow runs, with `workflow_dispatch` / `repository_dispatch` the
  only exceptions. So on route (a) the version-commit push does not fire
  release.yml's push lane and `version-pr` never runs.
- A PAT is not the GITHUB_TOKEN, so route (b) triggers normally and the PR
  self-corrects.
- This repo already depends on the same guard and says so: docker-publish.yml's
  header explains a `push: tags:` trigger "would never fire" because the release
  workflow pushes its tags with GITHUB_TOKEN.

States plainly that a stale #6208 after an rc cut is expected and harmless — its
changesets are consumed and recorded in pre.json, the PR carries no publish
capability by construction — and that the cutter must not fix it by hand.

Also sharpens the runtime-image note while adjacent: the image comes from a
LATER push under both routes, not the cut's own, because on route (b)
`release-integrity` audits long before the cut's publish step reaches npm.

Adds the same two expectations to the workflow's run summary, so the operator
reads them at the moment they would otherwise be surprised.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BPWqbmEFU8gJepBJTHESXd

Copy link
Copy Markdown
Contributor Author

Gap closed in 1a77494 — docs-only, still draft. Thanks for catching it; the two-resting-states behaviour was genuinely undefined in what I shipped.

I checked the recursion-guard claim rather than encoding it, as you asked. It survives, and it got stronger. Three independent confirmations:

  1. GitHub's own documentation. "Events triggered by the GITHUB_TOKEN will not create a new workflow run", and the only stated exceptions are workflow_dispatch and repository_dispatch, which always create runs. The docs give the push case explicitly: a workflow run that pushes code using the repository's GITHUB_TOKEN will not start a new workflow even where a push-triggered workflow exists.
  2. A PAT is not the GITHUB_TOKEN, and the docs name exactly that as the workaround ("use a GitHub App installation access token or a personal access token instead of GITHUB_TOKEN to trigger events that require a token"). So route (b) fires and self-corrects, as you said.
  3. This repo has already measured it and written it down. docker-publish.yml's header, lines 9-11: a push: tags: trigger "would never fire — the release workflow pushes its tags with GITHUB_TOKEN, and GitHub suppresses workflow triggers from GITHUB_TOKEN-pushed refs." That is the same guard, already load-bearing in this repo's release path. I cite it in the docs paragraph, because an in-repo receipt beats a docs link for the next reader.

So your version is correct and I encoded it. Two refinements I added because the checking turned them up:

  • One nuance on route (a) worth keeping straight: the docs list a GitHub App installation access token as something that DOES trigger. That is not route (a). Route (a) is the workflow's own GITHUB_TOKEN (the Actions app merely needs to be on the ruleset bypass list to be allowed to push) — a different credential from a separately-minted App installation token, and the recursion guard applies to it. I kept the docs wording on the credential rather than on the app identity so those two cannot be conflated.
  • A correction to something I wrote in the original PR body, surfaced by this same check. I said the runtime image arrives via release-integrity on "the next push to main". That is true, but I had not noticed it is a later push under both routes, never the cut's own: on route (a) the cut's push triggers nothing, and on route (b) it does trigger, but release-integrity runs its npm audit long before the cut's own publish step has put the version on npm — so it reports "release pending a human" and exits green either way. The docs now say that explicitly instead of leaving route (b) readers to expect an image that is not coming.

What landed: a "What happens to the standing Version Packages PR" subsection in docs/releases-maintenance.md covering both routes, stating that a stale #6208 is expected and harmless (changesets consumed and recorded in pre.json; the PR carries no publish capability by construction since release.yml passes the changesets action no publish: script) and that the cutter must not fix it by hand; plus a "Two things that are expected, not defects" block in the workflow's run summary, so the operator reads it at the moment they would otherwise be surprised rather than having to go looking.

Re-ran after the edit: check:nul-bytes (6830 files clean), check:doc-authoring (374 files clean), plus adr-links, docs-audit-scope, release-notes, role-word, quick-reference-counts, workflow-status-functions, required-contexts — all pass. YAML re-parsed (37 steps) and the edited summary step re-checked with bash -n. The previous commit aeeab1e had already converged fully green: all 23 check runs completed, ESLint and TypeScript Type Check both success, zero failures.

Leaving it in draft for you / the maintainer to flip, per your note.


Generated by Claude Code


Generated by Claude Code

@hotlong
hotlong marked this pull request as ready for review August 10, 2026 13:32
@hotlong
hotlong added this pull request to the merge queue Aug 10, 2026
Merged via the queue into main with commit eea20c7 Aug 10, 2026
21 checks passed
@hotlong
hotlong deleted the claude/issue-7447-cut-rc-snapshot-release branch August 10, 2026 13:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd documentation Improvements or additions to documentation size/l skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

release: snapshot one-click rc cut — a cut-rc workflow that bumps, versions, gates and publishes from ONE SHA, ending the two-treadmill freeze dance

3 participants