Harden release workflow validation and retries

This commit is contained in:
vorotamoroz
2026-07-16 19:23:29 +00:00
parent e31e761c5a
commit e114f66fb2
7 changed files with 164 additions and 44 deletions
+2 -2
View File
@@ -1,4 +1,4 @@
import { readFileSync, writeFileSync } from "fs";
import { readFileSync, writeFileSync, writeSync } from "fs";
const updatesPath = "updates.md";
@@ -8,7 +8,7 @@ const updatesPath = "updates.md";
// allowed.
function fail(message) {
console.error(message);
writeSync(process.stderr.fd, `${message}\n`);
process.exit(1);
}
+64
View File
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { ensureTags } from "./release-tags.mjs";
const releaseNotesScript = fileURLToPath(new URL("./release-notes.mjs", import.meta.url));
const versionBumpScript =
@@ -11,6 +12,7 @@ const versionBumpScript =
const workspaceUpdateScript = fileURLToPath(new URL("../update-workspaces.mjs", import.meta.url));
const prepareReleaseWorkflow = fileURLToPath(new URL("../.github/workflows/prepare-release.yml", import.meta.url));
const finaliseReleaseWorkflow = fileURLToPath(new URL("../.github/workflows/finalise-release.yml", import.meta.url));
const releaseWorkflow = fileURLToPath(new URL("../.github/workflows/release.yml", import.meta.url));
const temporaryDirectories: string[] = [];
afterEach(() => {
@@ -39,6 +41,28 @@ function runNode(script: string, args: string[], cwd: string, env: Record<string
});
}
function createTagGit(expectedRevision: string, initialTags: Record<string, string> = {}) {
const tags = new Map(Object.entries(initialTags));
const git = (args: string[], allowMissing = false): string | undefined => {
if (args[0] === "rev-parse") {
const revision = args.at(-1);
if (revision === `${expectedRevision}^{commit}`) return expectedRevision;
const tagMatch = revision?.match(/^refs\/tags\/(.+)\^\{commit\}$/);
if (tagMatch) {
const commit = tags.get(tagMatch[1]);
if (commit !== undefined) return commit;
if (allowMissing) return undefined;
}
}
if (args[0] === "tag" && args.length === 3) {
tags.set(args[1], args[2]);
return "";
}
throw new Error(`Unexpected git command: ${args.join(" ")}`);
};
return { git, tags };
}
function createReleaseFixture(version = "0.25.81"): string {
const directory = makeTemporaryDirectory();
writeJson(directory, "package.json", { version });
@@ -137,10 +161,50 @@ describe("release workflow", () => {
const workflow = readFileSync(finaliseReleaseWorkflow, "utf8");
expect(workflow).toContain("actions: write");
expect(workflow).toContain('node utils/release-tags.mjs ensure "${VERSION}" "${EXPECTED_HEAD_SHA}"');
expect(workflow).toContain('git push --atomic origin "refs/tags/${VERSION}" "refs/tags/${VERSION}-cli"');
expect(workflow).not.toContain("Tag already exists");
expect(workflow).toContain("gh workflow run release.yml");
expect(workflow).toContain("gh workflow run cli-docker.yml");
expect(workflow).toContain("dry_run=false");
});
it("publishes only by explicit dispatch and validates the selected release", () => {
const workflow = readFileSync(releaseWorkflow, "utf8");
expect(workflow).not.toMatch(/^\s+push:/m);
expect(workflow).toContain("ref: ${{ inputs.tag }}");
expect(workflow).toContain('node utils/release-notes.mjs validate "${TAG}"');
expect(workflow).toContain('TAG_SHA="$(git rev-parse "refs/tags/${TAG}^{commit}")"');
expect(workflow).not.toContain("Get Version");
});
});
describe("release tags", () => {
it("creates missing tags and accepts matching tags on retry", () => {
const head = "a".repeat(40);
const { git, tags } = createTagGit(head);
const messages: string[] = [];
ensureTags("0.25.84", head, git, (message) => messages.push(message));
expect(tags.get("0.25.84")).toBe(head);
expect(tags.get("0.25.84-cli")).toBe(head);
ensureTags("0.25.84", head, git, (message) => messages.push(message));
expect(messages).toContain(`Tag 0.25.84 already points to the expected commit ${head}.`);
expect(messages).toContain(`Tag 0.25.84-cli already points to the expected commit ${head}.`);
});
it("rejects an existing release tag that points to another commit without creating missing tags", () => {
const previousHead = "b".repeat(40);
const expectedHead = "a".repeat(40);
const { git, tags } = createTagGit(expectedHead, { "0.25.84-cli": previousHead });
expect(() => ensureTags("0.25.84", expectedHead, git)).toThrow(
`Tag 0.25.84-cli points to ${previousHead}; expected ${expectedHead}.`
);
expect(tags.has("0.25.84")).toBe(false);
});
});
describe("version bump", () => {
+73
View File
@@ -0,0 +1,73 @@
import { spawnSync } from "node:child_process";
import { writeSync } from "node:fs";
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";
function fail(message) {
writeSync(process.stderr.fd, `${message}\n`);
process.exit(1);
}
function assertVersion(version) {
if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version)) {
throw new Error(`Invalid release version: ${version}`);
}
}
function git(args, allowMissing = false) {
const result = spawnSync("git", args, { encoding: "utf8" });
if (allowMissing && result.status === 1 && result.stdout === "" && result.stderr === "") {
return undefined;
}
if (result.error) {
throw new Error(`Could not run git ${args.join(" ")}: ${result.error.message}`);
}
if (result.status !== 0) {
throw new Error(result.stderr.trim() || `git ${args.join(" ")} exited with status ${result.status}.`);
}
return result.stdout.trim();
}
function resolveCommit(revision, runGit) {
return runGit(["rev-parse", "--verify", `${revision}^{commit}`]);
}
function resolveTag(tag, runGit) {
return runGit(["rev-parse", "--verify", "--quiet", `refs/tags/${tag}^{commit}`], true);
}
export function ensureTags(version, expectedRevision, runGit = git, log = console.log) {
assertVersion(version);
const expectedCommit = resolveCommit(expectedRevision, runGit);
const tags = [version, `${version}-cli`];
const existing = tags.map((tag) => ({ tag, commit: resolveTag(tag, runGit) }));
for (const { tag, commit } of existing) {
if (commit !== undefined && commit !== expectedCommit) {
throw new Error(`Tag ${tag} points to ${commit}; expected ${expectedCommit}.`);
}
}
for (const { tag, commit } of existing) {
if (commit === undefined) {
runGit(["tag", tag, expectedCommit]);
log(`Created tag ${tag} at ${expectedCommit}.`);
} else {
log(`Tag ${tag} already points to the expected commit ${expectedCommit}.`);
}
}
}
const isMain = process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href;
if (isMain) {
const [command, version, expectedRevision] = process.argv.slice(2);
if (command !== "ensure" || !version || !expectedRevision) {
fail("Usage: node utils/release-tags.mjs ensure <version> <expected-commit>");
}
try {
ensureTags(version, expectedRevision);
} catch (error) {
fail(error instanceof Error ? error.message : String(error));
}
}