From b84230f7f96980108018f992e37a55049e7c7996 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 15:50:31 +0200 Subject: [PATCH] fix(release): skip unrebased PRs in trusted version preparation Master push enumerated every open PR and failed Prepare version when the PR had not integrated current master. Classify that as a branch update, write no record, and continue so unrelated open PRs cannot turn the trusted writer red. --- scripts/auto-version.d.mts | 1 + scripts/auto-version.mjs | 13 +++++++- scripts/prepare-version.mjs | 18 ++++++++--- tests/auto-version.test.ts | 63 ++++++++++++++++++++++++++++++++++++- 4 files changed, 89 insertions(+), 6 deletions(-) diff --git a/scripts/auto-version.d.mts b/scripts/auto-version.d.mts index 4ef3c26..eeabc6f 100644 --- a/scripts/auto-version.d.mts +++ b/scripts/auto-version.d.mts @@ -1,3 +1,4 @@ +export class BranchUpdateRequiredError extends Error {} export function stableVersion(value: unknown): boolean; export function calculateVersion(input: any): Promise; export function packageWithVersion(text: string, version: string): string; diff --git a/scripts/auto-version.mjs b/scripts/auto-version.mjs index 0d2357d..d1470d9 100644 --- a/scripts/auto-version.mjs +++ b/scripts/auto-version.mjs @@ -7,6 +7,12 @@ import { Bumper } from "conventional-recommended-bump"; const sha = (s) => typeof s === "string" && /^[a-f0-9]{40}$/.test(s); export const stableVersion = (s) => typeof s === "string" && /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(s); const git = (cwd, ...args) => execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 8 * 1024 * 1024 }).trim(); +export class BranchUpdateRequiredError extends Error { + constructor() { + super("Branch update required: integrate current master before version preparation; no record was prepared."); + this.name = "BranchUpdateRequiredError"; + } +} export function validateBaseline(baseline, { cwd = process.cwd(), master }) { if (!baseline || !stableVersion(baseline.version) || baseline.tag !== `v${baseline.version}` || !sha(baseline.sha) || baseline.gitHead !== baseline.sha || !sha(master)) throw new Error("Verified published baseline required"); if (git(cwd, "rev-parse", `${baseline.tag}^{commit}`) !== baseline.sha) throw new Error("Published tag changed"); @@ -23,8 +29,13 @@ export function packageFilesChanged(cwd, from, to) { export async function calculateVersion({ cwd = process.cwd(), head, master, baseline }) { if (!sha(head)) throw new Error("Exact candidate head required"); validateBaseline(baseline, { cwd, master }); + try { + git(cwd, "merge-base", "--is-ancestor", master, head); + } catch (error) { + if (error.status === 1) throw new BranchUpdateRequiredError(); + throw error; + } git(cwd, "merge-base", "--is-ancestor", baseline.sha, head); - git(cwd, "merge-base", "--is-ancestor", master, head); if (!packageFilesChanged(cwd, baseline.sha, head)) return null; const reader = new Bumper(cwd).loadPreset("angular").tag(baseline.tag).commits({ from: baseline.sha, to: head }, {}); const initial = await reader.bump(); diff --git a/scripts/prepare-version.mjs b/scripts/prepare-version.mjs index 7e83267..549c01d 100644 --- a/scripts/prepare-version.mjs +++ b/scripts/prepare-version.mjs @@ -3,13 +3,14 @@ import { realpathSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { execFileSync } from "node:child_process"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { calculateVersion, packageWithVersion, stableVersion } from "./auto-version.mjs"; +import { BranchUpdateRequiredError, calculateVersion, packageWithVersion, stableVersion } from "./auto-version.mjs"; import { CLI_REPOSITORY, fullSha, github, canonicalRun, successfulJobs } from "./release-github.mjs"; import { readReceiptArchive } from "./verify-coordinated-receipt.mjs"; const git = (...args) => execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 8 * 1024 * 1024 }).trim(); export function validatePullRequest(pr, expectedHead, expectedBase) { - if (!pr || pr.state !== "open" || pr.head?.repo?.full_name !== CLI_REPOSITORY || pr.base?.repo?.full_name !== CLI_REPOSITORY || pr.base?.ref !== "master" || pr.head.sha !== expectedHead || pr.base.sha !== expectedBase || !fullSha(expectedHead) || !fullSha(expectedBase) || !Number.isSafeInteger(pr.number) || !/^[a-zA-Z0-9_./-]+$/.test(pr.head.ref) || pr.head.ref === "master") throw new Error("Live exact same-repository PR and base required"); + if (!pr || pr.state !== "open" || pr.head?.repo?.full_name !== CLI_REPOSITORY || pr.base?.repo?.full_name !== CLI_REPOSITORY || pr.base?.ref !== "master" || pr.head.sha !== expectedHead || !fullSha(pr.base.sha) || !fullSha(expectedHead) || !fullSha(expectedBase) || !Number.isSafeInteger(pr.number) || !/^[a-zA-Z0-9_./-]+$/.test(pr.head.ref) || pr.head.ref === "master") throw new Error("Live exact same-repository PR and base required"); + if (pr.base.sha !== expectedBase) throw new BranchUpdateRequiredError(); return pr; } export async function publishedBaseline(get, registry = async () => { @@ -168,7 +169,16 @@ export async function reconcileVersionEvent({ env, event, bootstrap = false }, { for (const hint of prs) { if (hint.head?.repo?.full_name !== CLI_REPOSITORY) continue; if (!fullSha(hint.head.sha)) throw new Error("Exact head required"); - records.push(await prepare(hint)); + try { + records.push(await prepare(hint)); + } catch (error) { + if (error instanceof BranchUpdateRequiredError) { + console.error(error.message); + records.push(null); + continue; + } + throw error; + } } return records; } @@ -209,4 +219,4 @@ function isEntrypoint() { return false; } } -if (isEntrypoint()) main().catch(() => { console.error("Trusted version preparation rejected; inspect exact head/base/baseline metadata."); process.exitCode = 1; }); +if (isEntrypoint()) main().catch((error) => { console.error(error instanceof BranchUpdateRequiredError ? error.message : (error instanceof Error && error.message) || "Trusted version preparation rejected; inspect exact head/base/baseline metadata."); process.exitCode = 1; }); diff --git a/tests/auto-version.test.ts b/tests/auto-version.test.ts index 54ac823..d56e0b7 100644 --- a/tests/auto-version.test.ts +++ b/tests/auto-version.test.ts @@ -3,7 +3,7 @@ import { execFileSync } from "node:child_process"; import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { calculateVersion, packageWithVersion } from "../scripts/auto-version.mjs"; +import { BranchUpdateRequiredError, calculateVersion, packageWithVersion } from "../scripts/auto-version.mjs"; import { preparePullRequest, validatePullRequest, verifyMergedPreparation, reconcileVersionEvent } from "../scripts/prepare-version.mjs"; const sha = (c: string) => c.repeat(40); function fixture() { @@ -131,3 +131,64 @@ describe("generated version filtering uses content, never commit scope", () => { } finally { f.cleanup(); } }, 30000); }); + +describe("stale PR version preparation", () => { + it("classifies unrebased open PRs, writes nothing, and lets reconcile skip them while preparing current PRs", async () => { + const f = fixture(); + try { + f.git("checkout", "-qb", "candidate"); + mkdirSync(join(f.cwd, "skills")); writeFileSync(join(f.cwd, "skills/SKILL.md"), "helper\n"); + f.git("add", "."); f.git("commit", "-qm", "feat: helper candidate"); + let staleHead = f.git("rev-parse", "HEAD"); + f.git("checkout", "-qb", "current-master", f.base); + writeFileSync(join(f.cwd, "src/index.ts"), "export const value = 2;\n"); + f.git("add", "."); f.git("commit", "-qm", "feat: merged sibling"); f.git("tag", "v1.1.0"); + const master = f.git("rev-parse", "HEAD"), repo = { full_name: "przeprogramowani/10x-cli" }; + f.git("checkout", "-qb", "current-candidate", master); + writeFileSync(join(f.cwd, "src/index.ts"), "export const value = 3;\n"); + f.git("add", "."); f.git("commit", "-qm", "fix: current open candidate"); + const currentHead = f.git("rev-parse", "HEAD"); + let baseline = f.baseline; + const stale = { number: 47, state: "open", head: { sha: staleHead, ref: "candidate", repo }, base: { sha: f.base, ref: "master", repo } }; + const current = { number: 49, state: "open", head: { sha: currentHead, ref: "current-candidate", repo }, base: { sha: master, ref: "master", repo } }; + const writes: Array<{ path: string; method: string }> = []; + const get = async (path: string, method = "GET"): Promise => { + if (method !== "GET") { writes.push({ path, method }); return { sha: sha("d") }; } + if (path === "pulls/47") return stale; + if (path === "pulls/49") return current; + if (path === "pulls?state=open&base=master&per_page=100") return [stale, current]; + if (path === "git/ref/heads/master") return { object: { sha: master } }; + return { tree: { sha: sha("c") } }; + }; + const prepareHint = (hint: { number: number }) => preparePullRequest({ number: hint.number, runId: "201", runAttempt: 1, workflowSha: master }, { + get, calculate: (input: any) => calculateVersion({ cwd: f.cwd, ...input }), + readPackage: async (value: string) => f.git("show", `${value}:package.json`), baseline: async () => baseline, + }); + await expect(prepareHint(stale)).rejects.toMatchObject({ name: "BranchUpdateRequiredError" }); + expect(writes).toEqual([]); + stale.base.sha = master; + await expect(prepareHint(stale)).rejects.toMatchObject({ name: "BranchUpdateRequiredError" }); + expect(writes).toEqual([]); + for (const input of [{ head: staleHead, baseline: { ...baseline, gitHead: sha("f") } }, { head: sha("f"), baseline }]) { + let error: any; + try { await calculateVersion({ cwd: f.cwd, master, ...input }); } catch (caught) { error = caught; } + expect(error).toBeDefined(); expect(error).not.toBeInstanceOf(BranchUpdateRequiredError); + } + stale.base.sha = f.base; + const env = { GITHUB_REPOSITORY: repo.full_name, GITHUB_REF: "refs/heads/master", GITHUB_EVENT_NAME: "push" }; + const records = await reconcileVersionEvent({ env, event: {} }, { get, prepare: prepareHint, baseline: async () => baseline }); + expect(records[0]).toBeNull(); + expect(records[1]?.prNumber).toBe(49); + expect(records[1]?.version).toBe("1.1.0"); + expect(records[1]?.inputHead).toBe(currentHead); + expect(writes.some((write) => write.path === "git/refs/heads/current-candidate" && write.method === "PATCH")).toBe(true); + expect(writes.some((write) => write.path.startsWith("git/refs/heads/candidate"))).toBe(false); + f.git("checkout", "-q", "candidate"); + f.git("merge", "--no-edit", "current-master"); + staleHead = f.git("rev-parse", "HEAD"); stale.head.sha = staleHead; stale.base.sha = master; + writes.length = 0; + const updated = await prepareHint(stale); + expect(updated?.baseSha).toBe(master); expect(updated?.inputHead).toBe(staleHead); expect(updated?.version).toBe("1.1.0"); + } finally { f.cleanup(); } + }, 30000); +});