diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8e0457c..7da4dd9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -91,3 +91,10 @@ so provenance identifies the actual build commit. It never moves the tag, and it skips npm publication if that version already exists. An existing GitHub release draft remains a draft until a maintainer publishes it after verifying the npm package. + +After dispatching a retry, run `pnpm release:await` from a checkout with the +version being released. The waiter selects the latest matching tag run or +manual retry on `main`, checking the retry commit's package version. It resolves +the tag through GitHub, so workflow-fix commits and checkouts without local tags +can wait for the same release. A failed retry still exits with failure; a +successful run must also be visible on npm before the command reports success. diff --git a/package.json b/package.json index 40076a8..e8a46df 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "build:wasm": "node scripts/build-wasm.mjs", - "test": "node --test packages/core/src/*.test.js benchmarks/*.test.mjs examples/*.test.mjs", + "test": "node --test packages/core/src/*.test.js benchmarks/*.test.mjs examples/*.test.mjs scripts/*.test.mjs", "examples": "node examples/run.mjs", "test:browser": "node --test tests/browser-smoke.test.mjs", "typecheck": "tsc --noEmit && tsc --project examples/tsconfig.json", diff --git a/scripts/await-release.mjs b/scripts/await-release.mjs index 5ba886a..36ab8e0 100644 --- a/scripts/await-release.mjs +++ b/scripts/await-release.mjs @@ -9,13 +9,25 @@ const packageJson = JSON.parse( ); const version = packageJson.version; const tag = `v${version}`; -const commit = execFileSync("git", ["rev-parse", "HEAD"], { +const commit = execFileSync("gh", ["api", `repos/{owner}/{repo}/commits/${tag}`, "--jq", ".sha"], { encoding: "utf8", }).trim(); const wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); +const versionsByCommit = new Map(); +const releaseVersion = (sha) => { + if (!versionsByCommit.has(sha)) { + const content = execFileSync("gh", [ + "api", `repos/{owner}/{repo}/contents/packages/core/package.json?ref=${sha}`, + "--jq", ".content", + ], { encoding: "utf8" }); + versionsByCommit.set(sha, JSON.parse(Buffer.from(content.trim(), "base64").toString("utf8")).version); + } + return versionsByCommit.get(sha); +}; + const findReleaseRun = () => { const output = execFileSync( "gh", @@ -24,18 +36,18 @@ const findReleaseRun = () => { "list", "--workflow", "Release", - "--event", - "push", - "--branch", - tag, "--limit", "20", "--json", - "databaseId,headSha,status,conclusion,url", + "databaseId,event,headBranch,headSha,status,conclusion,url", ], { encoding: "utf8" }, ); - return JSON.parse(output).find((run) => run.headSha === commit); + return JSON.parse(output).find((run) => { + if (run.event === "push") return run.headBranch === tag && run.headSha === commit; + return run.event === "workflow_dispatch" && run.headBranch === "main" && + releaseVersion(run.headSha) === version; + }); }; let releaseRun; diff --git a/scripts/await-release.test.mjs b/scripts/await-release.test.mjs new file mode 100644 index 0000000..0e06633 --- /dev/null +++ b/scripts/await-release.test.mjs @@ -0,0 +1,99 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +const tagRun = (overrides = {}) => ({ + databaseId: 1, event: "push", headBranch: "v0.1.14", headSha: "tag-commit", + status: "completed", conclusion: "success", url: "https://example.test/runs/1", + ...overrides, +}); +const retryRun = (overrides = {}) => ({ + databaseId: 2, event: "workflow_dispatch", headBranch: "main", headSha: "retry-commit", + status: "completed", conclusion: "success", url: "https://example.test/runs/2", + ...overrides, +}); + +function runWaiter(t, { runs, versions = {}, head = "tag-commit" }) { + const root = mkdtempSync(join(tmpdir(), "streamfold-await-release-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + for (const dir of ["scripts", "packages/core", "bin"]) mkdirSync(join(root, dir), { recursive: true }); + writeFileSync(join(root, "scripts/await-release.mjs"), readFileSync(new URL("./await-release.mjs", import.meta.url))); + writeFileSync(join(root, "packages/core/package.json"), JSON.stringify({ version: "0.1.14" })); + const fixture = join(root, "fixture.json"); + const commands = join(root, "commands.jsonl"); + writeFileSync(fixture, JSON.stringify({ runs, versions: { "retry-commit": "0.1.14", ...versions }, head })); + const shim = `#!${process.execPath} + const { appendFileSync, readFileSync } = require("node:fs"); + const name = require("node:path").basename(process.argv[1]); + const args = process.argv.slice(2); + const fixture = JSON.parse(readFileSync(process.env.RELEASE_TEST_FIXTURE, "utf8")); + appendFileSync(process.env.RELEASE_TEST_COMMANDS, JSON.stringify({ name, args }) + "\\n"); + if (name === "git" && args[0] === "rev-parse") console.log(fixture.head); + else if (name === "npm" && args[0] === "view") console.log(JSON.stringify("0.1.14")); + else if (name === "gh" && args[0] === "run" && args[1] === "list") { + const flag = key => args.includes(key) ? args[args.indexOf(key) + 1] : undefined; + console.log(JSON.stringify(fixture.runs.filter(run => + (!flag("--event") || run.event === flag("--event")) && + (!flag("--branch") || run.headBranch === flag("--branch"))))); + } else if (name === "gh" && args[0] === "run" && args[1] === "watch") { + const run = fixture.runs.find(run => String(run.databaseId) === args[2]); + process.exit(run.watchExit ?? (run.conclusion === "success" ? 0 : 1)); + } else if (name === "gh" && args[0] === "api" && args[1].endsWith("/commits/v0.1.14")) { + console.log("tag-commit"); + } else if (name === "gh" && args[0] === "api" && args[1].includes("/contents/packages/core/package.json?ref=")) { + const sha = args[1].split("?ref=")[1]; + if (!(sha in fixture.versions)) throw new Error("Unexpected commit: " + sha); + console.log(Buffer.from(JSON.stringify({ version: fixture.versions[sha] })).toString("base64")); + } else throw new Error("Unexpected command: " + name + " " + args.join(" ")); + `; + for (const name of ["git", "gh", "npm"]) writeFileSync(join(root, "bin", name), shim, { mode: 0o755 }); + const result = spawnSync(process.execPath, [join(root, "scripts/await-release.mjs")], { + encoding: "utf8", timeout: 5000, + env: { ...process.env, PATH: `${join(root, "bin")}:${process.env.PATH}`, + RELEASE_TEST_FIXTURE: fixture, RELEASE_TEST_COMMANDS: commands }, + }); + assert.equal(result.error, undefined); + const calls = readFileSync(commands, "utf8").trim().split("\n").map(line => JSON.parse(line)); + return { ...result, calls, watched: calls.find(({ name, args }) => name === "gh" && args[1] === "watch")?.args[2] }; +} + +test("recognizes a successful manual retry after a failed tag release", (t) => { + const result = runWaiter(t, { runs: [retryRun(), tagRun({ conclusion: "failure" })] }); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.watched, "2"); + assert.match(result.stdout, /Published streamfold@0\.1\.14/); +}); + +test("finds the tagged release from a newer checkout without a local tag", (t) => { + const result = runWaiter(t, { head: "workflow-fix", runs: [tagRun()] }); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.watched, "1"); +}); + +test("ignores manual releases for other versions or branches and stale tag commits", (t) => { + const result = runWaiter(t, { runs: [ + retryRun({ databaseId: 5, headSha: "next-version" }), + retryRun({ databaseId: 4, headBranch: "feature" }), + tagRun({ databaseId: 3, headSha: "old-tag-commit" }), + tagRun(), + ], versions: { "next-version": "0.1.15" } }); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.watched, "1"); +}); + +test("a failing matching retry is not hidden by an older successful release", (t) => { + const result = runWaiter(t, { runs: [retryRun({ conclusion: "failure" }), tagRun()] }); + assert.equal(result.status, 1); + assert.equal(result.watched, "2"); + assert.equal(result.calls.some(({ name }) => name === "npm"), false); +}); + +test("waits for an in-progress retry before verifying npm publication", (t) => { + const result = runWaiter(t, { runs: [retryRun({ status: "in_progress", conclusion: "", watchExit: 0 }), tagRun()] }); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.watched, "2"); + assert.deepEqual(result.calls.at(-1), { name: "npm", args: ["view", "streamfold@0.1.14", "version", "--json"] }); +});