Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions scripts/auto-version.d.mts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export class BranchUpdateRequiredError extends Error {}
export function stableVersion(value: unknown): boolean;
export function calculateVersion(input: any): Promise<any>;
export function packageWithVersion(text: string, version: string): string;
Expand Down
13 changes: 12 additions & 1 deletion scripts/auto-version.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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();
Expand Down
18 changes: 14 additions & 4 deletions scripts/prepare-version.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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; });
63 changes: 62 additions & 1 deletion tests/auto-version.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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<any> => {
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);
});
Loading