Skip to content

fix(release): the release-integrity backfill builds from the version commit's tree, not github.sha - #21008

Merged
os-justin merged 3 commits into
mainfrom
claude/issue-20982-backfill-version-commit
Oct 1, 2026
Merged

os-justin merged 3 commits into
mainfrom
claude/issue-20982-backfill-version-commit

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Closes #20982
Clause-②: no (release wiring; no published package's accept set or public surface moves, per the question in scripts/pm/clause2-line.mjs)

What this changes

release.yml's release-integrity job repairs an already-published release by backfilling its GitHub Releases and its ADR-0087 D4 asset (spec-changes.json). Before this PR, those backfill steps ran in the job's checkout. That checkout is github.sha, the head of whichever push is being audited. Since PR #20625, the publish job builds the version commit instead. As a result:

  • A landing after the version commit that touched packages/spec/src made a repaired D4 asset describe a tree npm did not ship.
  • The Releases backfill pointed target_commitish and the CHANGELOG permalink at that landing.

After this PR, every backfill step builds from the version commit's tree, as the publish job does. Per triage 5922583890 and the claim 5922727017:

  • Audit step. It already makes a worktree of the version commit (git worktree add --detach, from the object database, for the whole-group probe). It now passes that worktree to the backfill steps as a new step output, version-tree. Nothing else in the audit moves. The checkout is not swapped, the audit still reads github.sha, and its tripwire still reads the workspace.
  • Install dependencies (the version commit's tree). pnpm install --frozen-lockfile runs in that worktree, against the version commit's lockfile. The github.sha checkout is no longer installed, because nothing reads it after the audit.
  • Releases backfill. The version commit's own scripts/release-github-releases.mjs runs in its own tree, so every package and every CHANGELOG.md is read from there. GITHUB_SHA is set to the version commit for that one process. The script uses that value as target_commitish and as the ref of the CHANGELOG permalink. GitHub ignores target_commitish when the tag already exists. But a publish that died before its tag push leaves no tag, and the Release then creates the tag at target_commitish.
  • D4 backfill. release-spec-changes.sh --prepare and --attach run in the worktree, with that commit's generator, exactly as the publish job's own D4 step does. So the asset a repair attaches is the manifest the publish built.
  • Each of the three steps refuses a tree that is not at the version commit. It compares git -C "$VERSION_TREE" rev-parse HEAD with version-commit. Without this check, an empty version-tree would silently leave the step in the checkout, which is the defect.
  • The lane header used to say the job "Reads github.sha ONLY". It now says the job reads github.sha and the version commit it selects, and that every backfill builds from that commit's tree.

scripts/release-spec-changes.sh is unchanged. It needs no tree argument, because the version commit's own copy runs in the version commit's tree.

Pin

The pin is battery 13 of node scripts/release-verify-npm.mjs --self-test, which lint.yml already runs as "Post-publish npm verification self-test". Battery 12, next to it, pins when the audit backfills; battery 13 pins which tree the backfill builds from. The self-test now has 93 cases across 13 batteries (82 across 12 before), and the roster floor is 13.

  • Fixture. A throwaway repository: base (1.0.0), then the version commit (1.1.0), then a landing that moved packages/spec/src. The release scripts are committed into the fixture together with their imports. A symlink would not work: Node would resolve release-github-releases.mjs to this repository and read this repository's workspace.
  • What runs. Five steps, each from its own text in release.yml. Each step's env: is read from the YAML too, and every expression in it is either resolved or refused. The five steps are:
    • the audit, with github.sha set to the landing;
    • the three backfill steps;
    • the publish job's own D4 step, in a worktree at the version commit (what its ref: checks out).
  • Stubs.
    • npm: view, versions, and a real pack.
    • gh: records each upload.
    • pnpm: records each install. It also stands in for the generator: it finds the workspace above its cwd, as pnpm does, and writes a manifest that is a pure function of that workspace's packages/spec/src.
    • A Releases API on 127.0.0.1 that records every POST.
  • The 11 cases.
    • Fixture control: the D4 step run in the landing's tree builds a different manifest from the publish's.
    • The audit requests the Releases backfill and names a version-tree that is at the version commit.
    • Install runs in that tree only.
    • Both Releases are POSTed with target_commitish equal to the version commit.
    • The truncated spec body links CHANGELOG.md at the version commit.
    • The generator runs in that tree, and the upload is made from it.
    • The attached spec-changes.json is byte-identical to the publish job's manifest.
    • Every backfill step refuses an empty version-tree, with nothing installed, created or attached.
    • Every backfill step refuses a version-tree at the landing, with nothing installed, created or attached.
    • Every step's env resolved.
    • The five steps are read out of release.yml itself.

Ablations

Each leg starts from the committed fix and goes through node scripts/ablation-replace.mjs. The anchor had to hit, and the blob changed on disk. The tool wrapped the self-test, under a trap. Every restore was proven by the file's blob matching HEAD's and by an empty git diff HEAD.

Mutation in release.yml Result
D4 step: drop cd "$VERSION_TREE" before --prepare, so it runs in the checkout again 2 of 93 red. The generator ran in the checkout, and the attached manifest (145 bytes) is not the publish job's (138 bytes); it equals the landing-tree build
Releases step: drop the GITHUB_SHA="$VERSION_COMMIT" override 2 of 93 red. Both Releases were POSTed at the landing, and the permalink names the landing
Delete the tree guard from all three steps (--expect 3) 2 of 93 red. The two refusal cases fail: install ran in the checkout, then in the landing's tree
Audit: drop the version-tree output line 6 of 93 red. Every backfill step refuses, so the lane fails closed

The first attempt at the first leg was a no-op: the replacement text was already in the file. ablation-replace refused it before anything ran, and the leg was re-run with a distinct marker.

Gates

  • node scripts/release-verify-npm.mjs --self-test exits 0 on 68e56d7e65 (93 cases, 13 batteries).
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack over this diff (2 paths) derives 50 commands. All 50 exit 0 on 68e56d7e65; dispatch-gates --ran: 50 derived, 50 run, 0 NOT-MEASURED, 0 UNRUN; check:pm-dispatch-gates 1976 cases pass (dev report 5923103867).
  • No changeset: a workflow and a root scripts/ file publish nothing, so skip-changeset applies.

Acceptance notes

  • Not changed here: the publish job's own Create GitHub Releases step. It runs release-github-releases.mjs with Actions' GITHUB_SHA, so its truncated bodies link CHANGELOG.md at the head of the queuing push. On a repair dispatch, that head can be well past the version commit. Its CHANGELOGs are still read from the version commit's checkout. Its tags already exist by then, because release-publish.sh pushes them before published=true, so target_commitish is unused. The linked file at the later head still carries the entry. This is outside this card's surface (the backfill). Carrier: none.
  • The new install step's pnpm is materialised from github.sha's packageManager pin, by setup-pnpm. Corepack then runs whatever the version commit's package.json pins. If the two differ, the version commit's pnpm is downloaded once. That is the same pnpm the publish job uses, and the Corepack cache is not involved, because it is saved inside setup-pnpm before this step runs.

Generated by Claude Code

claude added 3 commits October 1, 2026 01:20
…commit's tree, not github.sha

The audit already selects the version commit and makes a worktree of it;
it now hands that tree to the backfill steps (version-tree output), which
install, create the Releases (target_commitish and CHANGELOG permalink at
the version commit) and regenerate and attach the ADR-0087 D4 asset there,
each refusing a tree that is not at the version commit. The checkout and
everything the audit reads are unchanged.

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
…he backfill and the publish D4 step from release.yml

A landing that moved packages/spec/src after the version commit is the
fixture; the attached spec-changes.json must equal the publish job's
manifest byte for byte, every Release is created at the version commit,
and each backfill step refuses a tree that is not at it.

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 68e56d7e65c624b3587dd7096cb9e0d0e6b9fb90
Local-runs: none

Inputs: card #20982 (body; comments 5922583890 triage, 5922727017 claim, 5923103867 dev report, 5923147764 ruling), PR #20625 and PR #20978 bodies, PR #21008 (body, file list, net diff against main at the merge-base f8178ffece: 2 files, +600/-15; the head is a merge of that base into the two fix commits and brings no third file), release.yml and every script the three backfill steps run as they read at the head, and the check-runs on the head.

① Derived judgments

Accept-set and public-surface changes implied by the diff: none. The two files are .github/workflows/release.yml and the root scripts/release-verify-npm.mjs (its --self-test and two step-reading helpers only; the production --probe and verify paths are untouched). The root package is private: true; nothing a consumer installs moves. Right.

The audit step (release.yml:834-1053): nothing it reads moved. The only change inside the step is one output line after the pre-existing git worktree add --quiet --detach (:1002-1006): version-tree=${RUNNER_TEMP}/release-version-commit. The version is still read with git show "${SHA}:packages/cli/package.json", the tripwire still reads the workspace (:854), the selection still runs at github.sha (:870), the group probe still runs github.sha's own release-verify-npm.mjs --probe --root (:1007). No checkout swap. Right. version-tree is written only after the worktree exists; under Actions' bash -e a failed worktree add fails the step and releases-missing is never set, so no backfill step runs on a tree that was not made. Fail-closed. Right.

The three backfill steps (:1083-1146): the guard fails closed. Each carries VERSION_COMMIT and VERSION_TREE from the audit's outputs, refuses unless git -C "$VERSION_TREE" rev-parse HEAD equals VERSION_COMMIT, then does cd "$VERSION_TREE". versionCommit is a full %H sha (release-pending-publish.mjs:172), so the string equality is well-formed. Empty tree: refused by -z. Missing or non-git path: rev-parse prints nothing, which is not the sha, refused. A tree at another commit: refused. The error text names both values and carries no tracker number. Right. One unreachable edge, noted and not flagged: a non-git path together with an EMPTY VERSION_COMMIT would compare empty to empty and pass; both outputs come from the same audit step, which exits 1 before writing version-commit unless the selection returned a sha (:870-885), and git worktree add of an empty or null commit fails before version-tree is written.

Install (:1083-1094). pnpm install --frozen-lockfile in the worktree, so against the version commit's lockfile; the github.sha checkout is no longer installed. "Nothing reads it after the audit" is right: the next three steps cd first, and ./.github/actions/setup-pnpm reads only $GITHUB_WORKSPACE/package.json's packageManager pin and installs nothing. The prepare hook (scripts/setup-git-hooks.mjs) now fires inside a LINKED worktree, where .git is a file; that script is written for exactly that case (its header at :25-34 and :63-77, and :102-108: it registers once in the shared config through git rev-parse and never binds to one worktree), and the stub pnpm of the pin cannot see this, so it was checked here by reading. Right. Corepack, PR body acceptance note 2: the action materialises github.sha's pin and SAVES the store as its last step, with the "only the pin" assertion before the save, so a version commit that pins a different pnpm downloads it once into COREPACK_HOME after the save and never into the cache. Right.

Releases backfill (:1096-1121). GITHUB_SHA="$VERSION_COMMIT" node scripts/release-github-releases.mjs in the worktree. The script locates its workspace from its own file (REPO_ROOT = resolve(__dirname, '..'), :122-123), so every package and every CHANGELOG.md is the version commit's; it takes GITHUB_SHA as target_commitish (:591, :644) and as the ref of the permalink (:442). Same generator (that commit's copy), same target_commitish, same permalink as the publish job, which checks out the version commit (:1306-1309) and runs that copy (:1734) with Actions' GITHUB_SHA, which on the version push is the version commit or one merge-queue batch away. Right. The step comment's tag sentences are right: GitHub ignores target_commitish when the tag exists; release-publish.sh creates the tags inside changeset publish and pushes them in one git push origin --tags (:63-69), and published=true (:1722) is written only after that and the npm verification, so a publish that died or lost tags between npm and the tag push (the #2191 shape) is exactly the case where the Release CREATES the tag, and it now creates it at the version commit, not the landing.

D4 backfill (:1123-1146). release-spec-changes.sh --prepare then --attach, the version commit's copy, in the worktree; repo_root is derived from BASH_SOURCE, so the generator, the previous-tarball unpack and the gh release upload all run in that tree. Same tree, same generator as the publish job's Generate the per-release spec-changes section (ADR-0087 D4) step (:1639-1642). The one difference between the two jobs' trees is that the publish job has run pnpm run build (:1507) first; spec's build script (gen:schema, gen:openapi, tsup, the dts checks, the dev-prereqs stamp) writes none of the generator's inputs: build-spec-changes.ts reads src/ through tsx imports (:73-85), the TRACKED api-surface/ and spec-changes.json (:88-90, both tracked at the head) and package.json, plus the unpacked previous tarball; gen:api-surface is not part of build. So the manifest is a function of the version commit's tree and the previous published tarball in both jobs, and the D4 step comment's "exactly as the publish job does: in the version commit's tree, with that commit's generator" holds for every input the generator has. Right. prev_version ("newest on npm other than the one being repaired") is unchanged and correct for every reachable repair, because the audit only repairs the version main currently carries, which is the newest published.

Running the version commit's own copies of the scripts under the current workflow: intended and safe. Intended: ADR-0125's amendment (:168) states that the publish job's steps are the workflow at github.sha while the scripts they run are the version commit's; this PR makes the repair match the publish. Safe: the env contract the three steps hand the scripts (RELEASE_VERSION, GITHUB_TOKEN or GH_TOKEN, GITHUB_SHA, Actions' GITHUB_REPOSITORY, GITHUB_API_URL, GITHUB_SERVER_URL) is the contract the publish job hands the same copies; --prepare and --attach exist in every version commit since 8b4890343e (2026-09-18) and the GITHUB_SHA reading since 5993c5b6ba (2026-08-05); the one real repair candidate on the card, 17.5.0's 0f6dcac5, carries both. An older copy would answer unknown mode at exit 2 and the step would go red: closed, not silent, and unreachable anyway since only main's current version is ever audited. gh release upload resolves the repository through the worktree's shared remote config. Right.

Lane header (:743-745). "Reads github.sha and the version commit it selects, both out of the object database" is right. "every backfill builds from that version commit's tree, as the publish job does" is over-broad by one word: this job also requests the runtime-image backfill (image-missing drives the docker job, and the job's own concurrency comment at :767-768 counts the image among "its backfills"); docker-publish.yml:46 checks out with no ref:, so github.sha, and bakes the npm-published cli at the version. That backfill never builds from the version commit's tree (its payload is the npm artifact; its Dockerfile context is the landing's). True for the two backfills this job runs in its own steps, on main the moment this PR lands; never for the image unless docker-publish.yml changes. Wording, non-blocking; "both backfills this job runs" would be exact.

Block comment (:1058-1078) and the two step comments. "The publish job checks out the version commit and runs that commit's own scripts" right (:1309; ADR-0125 amendment D1 double-prime). "The checkout itself is NOT swapped" right (above). "an empty version-tree would leave the step in this checkout, which is the defect, with nothing said" right: cd "" is a no-op in bash, and the dev's A3 leg measured exactly that. "Pinned by battery 13 of node scripts/release-verify-npm.mjs --self-test (lint.yml runs it)" right: lint.yml:3682-3683, the Post-publish npm verification self-test step. The "ADR-0125 D1 as amended 2026-09-29" citations match the ADR's own status line. The self-test docblock names the card number; a script docblock is not a runtime string. Right.

PR body. Sentences tested against the tree, all right unless named: "Since PR #20625, the publish job builds the version commit" (:1309); "It already makes a worktree of the version commit" (on main at the base, :1002-1003 are context lines, from PR #20978, merge 587c84e6d5); "The github.sha checkout is no longer installed, because nothing reads it after the audit" (above); the two target_commitish sentences (above); "93 cases across 13 batteries (82 across 12 before), and the roster floor is 13" consistent with the diff (floor 12 to 13; battery 13 pinned by name at 11; eleven t() cases counted in the battery; 82 plus 11); "A symlink would not work: Node would resolve release-github-releases.mjs to this repository" right (an ESM entry reached through a symlink resolves to its real path, and REPO_ROOT is derived from import.meta.url); "pnpm finds the workspace above its cwd, as pnpm does" right. Acceptance note 1 is right: :1724-1734 sets no GITHUB_SHA, the tags exist before that step, and packages/*/CHANGELOG.md is release-owned and never edited in a code PR, so the permalink resolves at every later main head. The Gates bullet now reads the measured result, the seat's correction under ruling 5923147764.

The pin, battery 13: it executes the workflow's step text, not a paraphrase. workflowStepScript finds the ONE step whose - name: matches byte for byte, takes its literal run: | block un-indented (or a plain one-line run:, the branch added for the publish D4 step, which refuses a folded or quoted scalar), and throws on any Actions expression inside it; workflowStepEnv reads the step's env: map, comment lines skipped; resolveStepEnv substitutes each expression from the values the simulation sets and REFUSES one it does not know, so a step wired to an output nobody sets reds; runAsActions spawns bash --noprofile --norc -eo pipefail FILE, Actions' own invocation, with GITHUB_OUTPUT, GITHUB_STEP_SUMMARY, RUNNER_TEMP and GITHUB_WORKSPACE set and the stub bin first on PATH. The five step names (AUDIT_STEP_NAME, the three BACKFILL_STEP_NAMES, PUBLISH_D4_STEP_NAME) match the YAML at the head. The fixture repository commits the four scripts and their ./ import closure (no symlink), builds base 1.0.0, the version commit 1.1.0 with a spec entry over the 125,000 limit so the permalink is exercised, then a landing that edits packages/spec/src/registry.ts; the audit runs with github.sha = the landing and github.event.before = the version commit; the publish job's D4 step runs in a worktree of the version commit and, as the control, in the landing's. The 11 cases are the ones the body lists, including the control (the landing's tree yields a different manifest) and THE pin (the attached bytes equal the publish's manifest), and the two refusal sweeps assert nothing was installed, POSTed or uploaded. The floor roster pins the battery by name at 11 and the roster at 13. Right. The stub generator is a pure function of packages/spec/src; the real generator's further inputs are tracked files of the same tree, so the tree-as-input model is faithful. The ablations A1 to A4 are the dev's measurement (report 5923103867), not re-run here under the read-only shape; their red counts match what the case list predicts: A1 reds the two D4 cases, A2 the two Releases cases, A3 the two refusal cases, A4 the six cases downstream of the version-tree output. The three cases without a release.yml leg (steps read from release.yml, the fixture control, every env resolved) are harness self-checks that red by construction on a renamed step, an unmoved fixture or an unknown expression.

② Semver level

No changeset, skip-changeset on the PR: right. The diff publishes nothing from any released package (a workflow and a root scripts/ file under a private: true root). Clause-②: no (release wiring; ...) read per scripts/pm/clause2-line.mjs: the key is line-initial in the fixed spelling, named once on the line, not quoted-and-continued; the value token is no; the parenthetical opens with release, not an arm word, so the arm is absent and there is no contradiction: declared, value no, arm null. Right in substance: no published accept set widens or narrows. Check Changeset reads skipped on the label.

③ Boundary flags

Dev report 5923103867: open_questions none. deviations: (1) the label write was refused by the local permission classifier with zero requests sent; answered by ruling 5923147764 and verified on the PR: labels skip-changeset and needs:contract-review (with ci/cd, size/l), assignee os-justin. (2) the body's Gates bullet was written while the 50 derived gates still ran, and "applies" preceded the label; answered: the bullet now carries the measured result (50 of 50 exit 0; reconciliation 50 derived, 50 run, 0 NOT-MEASURED, 0 UNRUN; 1976 self-test cases) and the label is on. The dev's one NOT MEASURED family, check-required-contexts --verify-required-set, is CI-only and is covered by Lint & Repo Gates success on the head. out_of_scope_findings: the publish job's own Create GitHub Releases step (:1724-1734) runs the script with Actions' GITHUB_SHA, so on a repair dispatch its permalink names main's head rather than the version commit. Answered: accepted as an acceptance note and not a card, as the ruling recorded it. No wrong answer reaches a public door (the link resolves at every later main head because a CHANGELOG entry is never removed; the tag exists before that step, so target_commitish is unused there). Parity, if the maintainer wants it, is the same one-token change this PR makes at :1121 applied to :1734, a separate card. Reviewer's own note, non-blocking: the lane-header "every backfill" over-breadth in ①.

Check-runs on 68e56d7e65c624b3587dd7096cb9e0d0e6b9fb90, read last at 2026-10-01T02:02Z, deduped by name on the newest started_at, 31 names, none still running. The required set: Lint & Repo Gates success (completed 02:00:09Z; the job that runs the self-test), TypeScript Type Check success, Test Core success with all six shards success, Dogfood Regression Gate success, Governed Surface Queue Guard success, Build Core skipped and Temporal Conformance (live PG + MySQL) skipped by the filter job for a workflow-plus-root-script diff. Also success: the four Type Check jobs, Check Documentation Links, The card this PR closes must claim this branch, No other open PR may claim the same issue, No other open PR may claim the same single-writer path, Part-of PR must not also close its card, filter. Skipped: Check Changeset, Check PR Size, Auto Label, Build Docs, Console Pin Gate, Dogfood Verify CLI, Packed-tarball smoke (opt-in), the shard matrix placeholder. No failure.

Implemented-by: claude/issue-20982-backfill-version-commit
Reviewed-by: session_01Sfe5YjBLwB9J3y8fvm2xq1

VERDICT: PASS

Adopted and posted by domain:spec seat 5 (session_01Sfe5YjBLwB9J3y8fvm2xq1) · 2026-10-01T02:06Z · rendered by the seat's at-tier review subagent on this head. The seat read its served tier family from the subagent transcript before posting.


Generated by Claude Code

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

Labels

ci/cd size/l skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants