Skip to content

Commit 2a4254a

Browse files
fix(release): the release-integrity backfill builds from the version commit's tree, not github.sha (#21008)
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](https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f257223 commit 2a4254a

2 files changed

Lines changed: 600 additions & 15 deletions

File tree

‎.github/workflows/release.yml‎

Lines changed: 68 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -740,7 +740,9 @@ jobs:
740740
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
741741

742742
# ══════════════════════════════════════════════════════════════════════════
743-
# PUSH LANE 2 — release integrity audit. Reads github.sha ONLY. Never mints.
743+
# PUSH LANE 2 — release integrity audit. Never mints. Reads github.sha and the
744+
# version commit it selects, both out of the object database; every backfill
745+
# builds from that version commit's tree, as the publish job does.
744746
# ══════════════════════════════════════════════════════════════════════════
745747
release-integrity:
746748
name: Release integrity (audit + no-mint backfill)
@@ -999,6 +1001,9 @@ jobs:
9991001
# has its repair — the `force` dispatch (D4) — not a push.
10001002
vc_tree="${RUNNER_TEMP}/release-version-commit"
10011003
git worktree add --quiet --detach "$vc_tree" "$version_commit"
1004+
# The backfill steps below build from this tree, not from this
1005+
# checkout (github.sha), so it is handed to them by name.
1006+
echo "version-tree=${vc_tree}" >> "$GITHUB_OUTPUT"
10021007
if ! group=$(RELEASE_VERSION="$version" node scripts/release-verify-npm.mjs --probe --root "$vc_tree"); then
10031008
echo "::error::could not read whether the whole fixed group of ${version} is on npm (reason above). Refusing to request a backfill nobody measured."
10041009
exit 1
@@ -1049,13 +1054,44 @@ jobs:
10491054
10501055
# Everything below is skipped on the overwhelmingly common path (nothing to
10511056
# repair), which is why the install is here rather than at the top of the job.
1057+
#
1058+
# ── Every backfill builds from the VERSION COMMIT's tree ─────────────
1059+
# The publish job checks out the version commit and runs that commit's
1060+
# own scripts over that commit's own tree (ADR-0125 D1 as amended
1061+
# 2026-09-29). A repair describes the same release, so the steps below
1062+
# run in the worktree the audit made of the version commit (its
1063+
# `version-tree` output), with that commit's lockfile and tooling, and
1064+
# never in this checkout. This checkout is `github.sha`, the head of
1065+
# whichever push is being audited. A landing after the version commit
1066+
# that touched packages/spec/src would otherwise make the D4 asset
1067+
# describe a tree npm did not ship, and a Release created here would
1068+
# point its CHANGELOG link, and any tag it has to create, at that landing.
1069+
#
1070+
# The checkout itself is NOT swapped: the audit above reads `github.sha`
1071+
# and its tripwire reads this workspace, and both stay as they are. Each
1072+
# step re-asserts that its tree is the version commit and refuses
1073+
# otherwise, because an empty `version-tree` would leave the step in
1074+
# this checkout, which is the defect, with nothing said.
1075+
#
1076+
# Pinned by battery 13 of `node scripts/release-verify-npm.mjs
1077+
# --self-test` (lint.yml runs it): these steps' own text runs after the
1078+
# audit's, and a landing that moved packages/spec/src is the fixture.
10521079
- name: Setup pnpm
10531080
if: steps.audit.outputs.releases-missing == 'true'
10541081
uses: ./.github/actions/setup-pnpm
10551082

1056-
- name: Install dependencies
1083+
- name: Install dependencies (the version commit's tree)
10571084
if: steps.audit.outputs.releases-missing == 'true'
1058-
run: pnpm install --frozen-lockfile
1085+
env:
1086+
VERSION_COMMIT: ${{ steps.audit.outputs.version-commit }}
1087+
VERSION_TREE: ${{ steps.audit.outputs.version-tree }}
1088+
run: |
1089+
if [ -z "$VERSION_TREE" ] || [ "$(git -C "$VERSION_TREE" rev-parse HEAD 2>/dev/null)" != "$VERSION_COMMIT" ]; then
1090+
echo "::error::the version commit's tree ('${VERSION_TREE}') is not at the version commit '${VERSION_COMMIT}'. Refusing to backfill from any other tree."
1091+
exit 1
1092+
fi
1093+
cd "$VERSION_TREE"
1094+
pnpm install --frozen-lockfile
10591095
10601096
- name: Backfill GitHub Releases (bodies truncated to the API limit)
10611097
if: steps.audit.outputs.releases-missing == 'true'
@@ -1066,22 +1102,46 @@ jobs:
10661102
# Changesets `fixed` group releases every public package at one
10671103
# version, which check-changeset-fixed.mjs gates.
10681104
RELEASE_VERSION: ${{ steps.audit.outputs.version }}
1069-
run: node scripts/release-github-releases.mjs
1105+
VERSION_COMMIT: ${{ steps.audit.outputs.version-commit }}
1106+
VERSION_TREE: ${{ steps.audit.outputs.version-tree }}
1107+
# The script reads every package and CHANGELOG.md from the tree it
1108+
# lives in, and takes `GITHUB_SHA` as each Release's `target_commitish`
1109+
# and as the ref of its CHANGELOG permalink. Both must be the version
1110+
# commit. GitHub ignores `target_commitish` when the tag already exists,
1111+
# but a publish that died before its tag push leaves no tag, and the
1112+
# Release then CREATES it at `target_commitish`. So the version
1113+
# commit's own script runs in its own tree, with GITHUB_SHA set for that
1114+
# one process only.
1115+
run: |
1116+
if [ -z "$VERSION_TREE" ] || [ "$(git -C "$VERSION_TREE" rev-parse HEAD 2>/dev/null)" != "$VERSION_COMMIT" ]; then
1117+
echo "::error::the version commit's tree ('${VERSION_TREE}') is not at the version commit '${VERSION_COMMIT}'. Refusing to backfill from any other tree."
1118+
exit 1
1119+
fi
1120+
cd "$VERSION_TREE"
1121+
GITHUB_SHA="$VERSION_COMMIT" node scripts/release-github-releases.mjs
10701122
10711123
- name: Backfill spec-changes.json on the GitHub Release (ADR-0087 D4)
10721124
# Ordering is load-bearing: `gh release upload` needs the Release the
10731125
# step above creates.
10741126
#
10751127
# `--prepare` regenerates the manifest against the previously published
1076-
# tarball exactly as the publish lane does, so the asset this repair
1077-
# uploads carries the same per-release section the npm artifact does.
1078-
# It cannot repair the npm artifact itself — that tarball is immutable —
1079-
# and this lane never publishes one.
1128+
# tarball exactly as the publish job does: in the version commit's
1129+
# tree, with that commit's generator. So the asset this repair uploads
1130+
# carries the same per-release section the npm artifact does. It cannot
1131+
# repair the npm artifact itself — that tarball is immutable — and this
1132+
# lane never publishes one.
10801133
if: steps.audit.outputs.releases-missing == 'true'
10811134
env:
10821135
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
10831136
RELEASE_VERSION: ${{ steps.audit.outputs.version }}
1137+
VERSION_COMMIT: ${{ steps.audit.outputs.version-commit }}
1138+
VERSION_TREE: ${{ steps.audit.outputs.version-tree }}
10841139
run: |
1140+
if [ -z "$VERSION_TREE" ] || [ "$(git -C "$VERSION_TREE" rev-parse HEAD 2>/dev/null)" != "$VERSION_COMMIT" ]; then
1141+
echo "::error::the version commit's tree ('${VERSION_TREE}') is not at the version commit '${VERSION_COMMIT}'. Refusing to backfill from any other tree."
1142+
exit 1
1143+
fi
1144+
cd "$VERSION_TREE"
10851145
bash scripts/release-spec-changes.sh --prepare
10861146
bash scripts/release-spec-changes.sh --attach
10871147

0 commit comments

Comments
 (0)