Skip to content

Commit a797bf2

Browse files
committed
fix(release): pin the audited SHA through dispatch and workflow
Closes a mutable-ref race in the release path: the origin-moved guard read the local remote-tracking ref (minutes stale) and release.yml resolved a mutable branch at dispatch. The helper now re-reads the live remote head via git ls-remote immediately before dispatch and passes expected-sha; the workflow refuses to publish when GITHUB_SHA differs. Manual dispatches without expected-sha warn instead. Found in pre-release audit (Schrodinger).
1 parent fbec6ed commit a797bf2

3 files changed

Lines changed: 63 additions & 4 deletions

File tree

.github/workflows/release.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ on:
2424
required: false
2525
type: boolean
2626
default: true
27+
expected-sha:
28+
description: "Immutable release commit this dispatch must publish (fail if the branch moved)"
29+
required: false
30+
type: string
2731

2832
permissions:
2933
contents: write # create the matching GitHub Release + version tag after npm publish
@@ -44,6 +48,17 @@ jobs:
4448
with:
4549
fetch-depth: 0
4650

51+
- name: Verify dispatched SHA
52+
env:
53+
EXPECTED_SHA: ${{ inputs.expected-sha }}
54+
run: |
55+
if [ -z "$EXPECTED_SHA" ]; then
56+
echo "::warning::no expected-sha supplied; publishing whatever the branch currently points at"
57+
elif [ "$GITHUB_SHA" != "$EXPECTED_SHA" ]; then
58+
echo "::error::branch moved after the release audit (expected ${EXPECTED_SHA}, got ${GITHUB_SHA}) — refusing to publish an unaudited commit"
59+
exit 1
60+
fi
61+
4762
# opencodex is bun-native (the prepublishOnly GUI build + typecheck run under bun).
4863
- name: Setup Bun
4964
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2

scripts/release.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,17 @@ async function _remoteMainSha(): Promise<string> {
201201
return sha;
202202
}
203203

204+
/** Live (network) head of a remote branch — never the local remote-tracking ref. */
205+
async function remoteBranchHead(branch: string): Promise<string> {
206+
const out = (await $`git ls-remote origin refs/heads/${branch}`.text()).trim();
207+
const [sha] = out.split(/\s+/);
208+
if (!sha) {
209+
console.error(`✗ could not resolve origin/${branch}`);
210+
process.exit(1);
211+
}
212+
return sha;
213+
}
214+
204215
if (args[0] === "watch") {
205216
await watchLatest();
206217
process.exit(0);
@@ -263,15 +274,19 @@ await waitForSuccessfulCi(releaseSha);
263274
console.log(`→ wait for Service lifecycle (${releaseSha})`);
264275
await waitForSuccessfulCi(releaseSha, SERVICE_WORKFLOW, "Service lifecycle");
265276

266-
const originSha = (await $`git rev-parse origin/${branch}`.text()).trim();
267-
if (originSha !== releaseSha) {
268-
console.error(`✗ origin/${branch} moved while waiting for CI (${originSha} != ${releaseSha}); aborting release dispatch.`);
277+
// Live-remote guard: re-read the actual remote head over the network immediately
278+
// before dispatch. The local remote-tracking ref can be minutes stale, and the
279+
// workflow_dispatch below resolves a mutable branch — so this is the last chance
280+
// to refuse publishing an unaudited newer commit.
281+
const liveOriginSha = await remoteBranchHead(branch);
282+
if (liveOriginSha !== releaseSha) {
283+
console.error(`✗ origin/${branch} moved while waiting for CI (${liveOriginSha} != ${releaseSha}); aborting release dispatch.`);
269284
process.exit(1);
270285
}
271286

272287
console.log(`→ dispatch Release (tag=${tag}, dry-run=${dryRun})`);
273288
const dispatchStartedAt = new Date(Date.now() - 5_000).toISOString();
274-
await $`gh workflow run release.yml --ref ${branch} -f version=${version} -f tag=${tag} -f dry-run=${String(dryRun)}`;
289+
await $`gh workflow run release.yml --ref ${branch} -f version=${version} -f tag=${tag} -f expected-sha=${releaseSha} -f dry-run=${String(dryRun)}`;
275290

276291
// 5. Watch it.
277292
const releaseRun = await waitForReleaseWorkflowRun(releaseSha, branch, dispatchStartedAt);

tests/release-helper.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ interface LoggedCall {
1818
interface ReleaseScenario {
1919
branch?: string;
2020
headSha?: string;
21+
remoteHeadSha?: string;
2122
privacyExitCode?: number;
2223
testExitCode?: number;
2324
typecheckExitCode?: number;
@@ -71,6 +72,10 @@ if (args[0] === "status" && args[1] === "--porcelain") {
7172
}
7273
7374
if (args[0] === "ls-remote") {
75+
if (args.some(a => typeof a === "string" && a.startsWith("refs/heads/"))) {
76+
const branchRef = args.find(a => typeof a === "string" && a.startsWith("refs/heads/"));
77+
stdout(\`\${process.env.FAKE_GIT_REMOTE_HEAD_SHA ?? headSha}\t\${branchRef}\n\`);
78+
}
7479
process.exit(0);
7580
}
7681
@@ -194,6 +199,7 @@ function runRelease(version: string, scenario: ReleaseScenario = {}) {
194199
FAKE_RELEASE_LOG: logPath,
195200
FAKE_GIT_BRANCH: scenario.branch ?? "main",
196201
FAKE_GIT_HEAD_SHA: scenario.headSha ?? "abc123def456",
202+
...(scenario.remoteHeadSha ? { FAKE_GIT_REMOTE_HEAD_SHA: scenario.remoteHeadSha } : {}),
197203
FAKE_BUN_TSC_EXIT_CODE: String(scenario.typecheckExitCode ?? 0),
198204
FAKE_BUN_TEST_EXIT_CODE: String(scenario.testExitCode ?? 0),
199205
FAKE_BUN_PRIVACY_EXIT_CODE: String(scenario.privacyExitCode ?? 0),
@@ -253,4 +259,27 @@ describe("release helper", () => {
253259
&& call.args.includes("dry-run=true"),
254260
)).toBeGreaterThanOrEqual(0);
255261
});
262+
263+
test("dispatch pins the audited release SHA via expected-sha", () => {
264+
const { calls, result } = runRelease("9.9.9", { headSha: "deadbeefcafe1234" });
265+
266+
expect(result.status).toBe(0);
267+
expect(findCallIndex(calls, "gh", call =>
268+
call.args[0] === "workflow"
269+
&& call.args[1] === "run"
270+
&& call.args.includes("release.yml")
271+
&& call.args.includes("expected-sha=deadbeefcafe1234"),
272+
)).toBeGreaterThanOrEqual(0);
273+
});
274+
275+
test("aborts before dispatch when the remote branch moved during the CI wait", () => {
276+
const { calls, result } = runRelease("9.9.9", {
277+
headSha: "abc123def456",
278+
remoteHeadSha: "9999999999999999999999999999999999999999",
279+
});
280+
281+
expect(result.status).not.toBe(0);
282+
expect(result.stderr + result.stdout).toContain("moved while waiting for CI");
283+
expect(findCallIndex(calls, "gh", call => call.args[0] === "workflow" && call.args[1] === "run")).toBe(-1);
284+
});
256285
});

0 commit comments

Comments
 (0)