From b98a3d7bc5252cf442ca169b4bbe1d2b5206ed24 Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Tue, 6 Oct 2026 07:25:31 -0400 Subject: [PATCH 1/2] fix(release): read release-please-config once and refuse a malformed one release-please-config.json was parsed in three places (the anchor, the boundary check, the anchor check), each as an untyped object, so a file that was not JSON failed with a bare parse error naming no file, and a boundary that was not a commit sha (an array config, a number) was read as a stale boundary and answered with a repair value. One reader now parses the file and refuses a missing or malformed one, or a last-release-sha that is no 40-hex sha, naming the file and the fix; an absent boundary stays the pre-first-release state. The config is release-please's and the user's, so nothing is repaired. --- .github/scripts/release-pipeline.ts | 88 +++++++++++++++++++-------- test/scripts/release-pipeline.test.ts | 43 +++++++++++++ 2 files changed, 106 insertions(+), 25 deletions(-) diff --git a/.github/scripts/release-pipeline.ts b/.github/scripts/release-pipeline.ts index ba397c61..cd708fdb 100644 --- a/.github/scripts/release-pipeline.ts +++ b/.github/scripts/release-pipeline.ts @@ -630,6 +630,50 @@ export function retagMajor(options: RetagMajorOptions): RetaggedMajor { export const RELEASE_PR_BRANCH_PREFIX = "release-please--"; const RELEASE_PR_BRANCH = `${RELEASE_PR_BRANCH_PREFIX}branches--main`; +/** release-please's config with the one key this pipeline reads and writes; the rest is release-please's. */ +type ReleaseConfig = Record & { "last-release-sha"?: string }; + +/** The config as the checkout holds it. The file is the user's and release-please's, so nothing here is repaired: a + * missing or malformed one, or a boundary that is no commit sha, is refused naming the file (an absent boundary is + * the pre-first-release state and passes). */ +function readReleaseConfig(cwd: string): ReleaseConfig { + let text: string; + try { + text = readFileSync(join(cwd, CONFIG_FILE), "utf8"); + } catch (error) { + if ((error as { code?: unknown }).code !== "ENOENT") { + throw error; + } + throw new Error( + `${CONFIG_FILE} is missing from ${cwd}; the release pipeline reads release-please's boundary (last-release-sha) from it. Restore it by PR.`, + ); + } + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch (error) { + throw new Error( + `${CONFIG_FILE} is not valid JSON (${error instanceof Error ? error.message : String(error)}); fix it by PR.`, + ); + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + const kind = Array.isArray(parsed) + ? "an array" + : parsed === null + ? "null" + : `a ${typeof parsed}`; + throw new Error(`${CONFIG_FILE} holds ${kind}, not a JSON object; fix it by PR.`); + } + const config = parsed as Record; + const boundary = config["last-release-sha"]; + if (boundary !== undefined && (typeof boundary !== "string" || !FULL_SHA.test(boundary))) { + throw new Error( + `last-release-sha in ${CONFIG_FILE} is ${JSON.stringify(boundary)}, not a 40-hex commit sha; fix it by PR.`, + ); + } + return config as ReleaseConfig; +} + export interface AnchorOptions { cwd: string; /** Main's head this run tested; the merge parent the boundary records. */ @@ -670,9 +714,7 @@ export function anchorReleasePr(options: AnchorOptions): AnchorResult { reason: "the release PR branch is not built on this head; its own refresh anchors", }; } - const config = JSON.parse(readFileSync(join(cwd, CONFIG_FILE), "utf8")) as { - "last-release-sha"?: unknown; - }; + const config = readReleaseConfig(cwd); if (config["last-release-sha"] === sourceSha) { return { changed: false, reason: "already anchored" }; } @@ -720,9 +762,7 @@ export function boundaryCheck(cwd: string): { boundary: string } { "boundary-check", "a release merge or the recorded boundary can sit beyond its depth, and no verdict on a truncated history holds.", ); - const config = JSON.parse(readFileSync(join(cwd, CONFIG_FILE), "utf8")) as { - "last-release-sha"?: unknown; - }; + const recorded = readReleaseConfig(cwd)["last-release-sha"]; // The grep only narrows (it matches any message line with the prefix); RELEASE_SUBJECT decides, so // "chore(main): release pipeline documentation" can neither become the boundary nor hide the real newest merge. const listed = git(cwd, "log", "--grep", "^chore(main): release ", "--format=%H%x09%s"); @@ -734,12 +774,11 @@ export function boundaryCheck(cwd: string): { boundary: string } { break; } } - const recorded = config["last-release-sha"]; if (latest === "") { if (recorded === undefined) { return { boundary: "none (no release merge on this history yet)" }; } - const cause = isAncestor(cwd, String(recorded), "HEAD") + const cause = isAncestor(cwd, recorded, "HEAD") ? `this history holds it, so release-please's merge subject no longer matches ${RELEASE_SUBJECT} (investigate RELEASE_SUBJECT)` : "it is not on this history at all (not main, or a boundary that never landed)"; throw new Error( @@ -747,16 +786,18 @@ export function boundaryCheck(cwd: string): { boundary: string } { ); } const parent = tryGit(cwd, "rev-parse", `${latest}^`); - if (recorded === latest || (parent !== null && recorded === parent)) { - return { boundary: String(recorded) }; - } - if (isAncestor(cwd, latest, String(recorded)) && isAncestor(cwd, String(recorded), "HEAD")) { - throw new Error( - `last-release-sha in ${CONFIG_FILE} is ${JSON.stringify(recorded)}, NEWER than ` + - `${latest}, the newest release merge whose subject matches ${RELEASE_SUBJECT}: either a ` + - `newer release merge's subject stopped matching (investigate RELEASE_SUBJECT) or the ` + - `boundary was edited by hand. It must not be rolled back to ${parent ?? latest}.`, - ); + if (recorded !== undefined) { + if (recorded === latest || (parent !== null && recorded === parent)) { + return { boundary: recorded }; + } + if (isAncestor(cwd, latest, recorded) && isAncestor(cwd, recorded, "HEAD")) { + throw new Error( + `last-release-sha in ${CONFIG_FILE} is ${JSON.stringify(recorded)}, NEWER than ` + + `${latest}, the newest release merge whose subject matches ${RELEASE_SUBJECT}: either a ` + + `newer release merge's subject stopped matching (investigate RELEASE_SUBJECT) or the ` + + `boundary was edited by hand. It must not be rolled back to ${parent ?? latest}.`, + ); + } } throw new Error( `last-release-sha in ${CONFIG_FILE} is ${JSON.stringify(recorded)}, but the newest release ` + @@ -769,22 +810,19 @@ export function boundaryCheck(cwd: string): { boundary: string } { * that the anchor commit survived a release-please force-push, so requiring last-release-sha to equal origin's * CURRENT main tip makes an unanchored release PR unmergeable instead of parking the pipeline after its merge. */ export function anchorCheck(cwd: string): { boundary: string } { - const config = JSON.parse(readFileSync(join(cwd, CONFIG_FILE), "utf8")) as { - "last-release-sha"?: unknown; - }; - const recorded = config["last-release-sha"]; - const tip = git(cwd, "ls-remote", "origin", "refs/heads/main").split("\t")[0]; + const recorded = readReleaseConfig(cwd)["last-release-sha"]; + const tip = git(cwd, "ls-remote", "origin", "refs/heads/main").split("\t")[0] ?? ""; if (recorded !== tip) { throw new Error( `last-release-sha in ${CONFIG_FILE} is ${JSON.stringify(recorded)}, but main's tip is ` + - `${tip ?? "?"}; the anchor is missing or stale, so merging would land a wrong boundary. ` + + `${tip}; the anchor is missing or stale, so merging would land a wrong boundary. ` + `The release pipeline's update-release-pr hook re-applies it on every release-PR refresh ` + `- wait for (or dispatch) the next green main run, then close/reopen the PR so its ` + `checks run on the anchored head (the anchor is pushed with the default token, which ` + `triggers no new checks).`, ); } - return { boundary: String(recorded) }; + return { boundary: recorded }; } /** The manifest's version at a commit: what release-please last released, or is about to. */ diff --git a/test/scripts/release-pipeline.test.ts b/test/scripts/release-pipeline.test.ts index cd6375f7..ebf10df6 100644 --- a/test/scripts/release-pipeline.test.ts +++ b/test/scripts/release-pipeline.test.ts @@ -1049,6 +1049,49 @@ describe("anchorCheck", () => { }); }); +describe("the release-please config", () => { + const malformed: [string, string, RegExp][] = [ + ["not JSON", "{ not json\n", /^release-please-config\.json is not valid JSON \(/], + ["a JSON array", "[]\n", /^release-please-config\.json holds an array, not a JSON object;/], + [ + "a boundary that is not a commit sha", + `${JSON.stringify({ "last-release-sha": 42, packages: {} })}\n`, + /^last-release-sha in release-please-config\.json is 42, not a 40-hex commit sha;/, + ], + ]; + test.each(malformed)( + "a config that is %s is refused naming the file by every subcommand that reads it", + (_name, text, error) => { + const fx = seedFixture(); + createReleasePrBranch(fx, fx.mergeSha, "2.2.0"); + const checker = clone(fx.root, fx.origin, "config-boundary"); + write(checker, "release-please-config.json", text); + expect(() => boundaryCheck(checker)).toThrow(error); + const pr = clone(fx.root, fx.origin, "config-anchor-check"); + git(pr, "checkout", "--quiet", "release-please--branches--main"); + write(pr, "release-please-config.json", text); + expect(() => anchorCheck(pr)).toThrow(error); + const branch = clone(fx.root, fx.origin, "config-branch"); + git(branch, "checkout", "--quiet", "release-please--branches--main"); + write(branch, "release-please-config.json", text); + commitAll(branch, "chore: a config release-please would not write"); + git(branch, "push", "--quiet", "origin", "HEAD:refs/heads/release-please--branches--main"); + const worker = clone(fx.root, fx.origin, "config-anchor"); + expect(() => anchorReleasePr({ cwd: worker, sourceSha: fx.mergeSha })).toThrow(error); + }, + ); + + test("a missing config is refused naming the file and where it was looked for", () => { + const fx = seedFixture(); + const checker = clone(fx.root, fx.origin, "config-missing"); + rmSync(join(checker, "release-please-config.json")); + expect(() => boundaryCheck(checker)).toThrow( + `release-please-config.json is missing from ${checker}; the release pipeline reads ` + + "release-please's boundary (last-release-sha) from it. Restore it by PR.", + ); + }); +}); + describe("release configuration contract", () => { test("release-please cuts a tagless draft and leaves the release to the hook: no tag ever lands on main, and the release job runs from the hook", () => { // An external tool's knobs the platform does not enforce: a tag release-please created would sit on main, and From 22bb39ec527b357ae36b56eefc97f3f38dffca86 Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Tue, 6 Oct 2026 08:12:12 -0400 Subject: [PATCH 2/2] refactor(scripts): split release-pipeline along its sections release-pipeline.ts held every section of the release pipeline in one 1562-line file. Its sections move to sibling modules under .github/scripts/release-pipeline/: git.ts (the git helpers every section shares, with ancestry, a commit's position on main, and the manifest read), tags.ts (packaged commits and the refs that name them), anchor.ts (release-please's boundary), npm.ts (the pre-release version, the verdicts, the publish, the floor), and draft.ts (the draft release's source and assets). The entry file keeps its path, the one the workflows and the test fixture invoke, and holds main() alone. The test file splits the same way: its npm half becomes test/scripts/release-pipeline-npm.test.ts. Every function keeps its text; only imports, exports, and file headers are new, and every importer and comment that named the old file follows the move. --- .github/scripts/release-pipeline.ts | 1383 +----------------- .github/scripts/release-pipeline/anchor.ts | 223 +++ .github/scripts/release-pipeline/draft.ts | 45 + .github/scripts/release-pipeline/git.ts | 136 ++ .github/scripts/release-pipeline/npm.ts | 514 +++++++ .github/scripts/release-pipeline/tags.ts | 522 +++++++ .github/workflows/update-release-pr.yml | 4 +- .github/workflows/update-release.yml | 2 +- test/scripts/release-pipeline-build.test.ts | 2 +- test/scripts/release-pipeline-npm.test.ts | 997 +++++++++++++ test/scripts/release-pipeline.test.ts | 973 +----------- test/workflows/checks-workflow.test.ts | 2 +- test/workflows/npm-publish-workflows.test.ts | 10 +- test/workflows/post-green-workflow.test.ts | 2 +- 14 files changed, 2465 insertions(+), 2350 deletions(-) create mode 100644 .github/scripts/release-pipeline/anchor.ts create mode 100644 .github/scripts/release-pipeline/draft.ts create mode 100644 .github/scripts/release-pipeline/git.ts create mode 100644 .github/scripts/release-pipeline/npm.ts create mode 100644 .github/scripts/release-pipeline/tags.ts create mode 100644 test/scripts/release-pipeline-npm.test.ts diff --git a/.github/scripts/release-pipeline.ts b/.github/scripts/release-pipeline.ts index 700f94d5..169c78c5 100644 --- a/.github/scripts/release-pipeline.ts +++ b/.github/scripts/release-pipeline.ts @@ -17,1379 +17,22 @@ * refreshes the release PR only when a releasable commit lands (release-please-config.json leaves always-update * off); that refresh is what publishes the `next` pre-release. * - * Node builtins only: bun runs this before `bun install`. - */ - -import { execFileSync, type SpawnSyncReturns, spawnSync } from "node:child_process"; -import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { setTimeout as sleep } from "node:timers/promises"; -import { capture, setOutput } from "./lib/workflow-step.js"; - -const MANIFEST_FILE = ".release-please-manifest.json"; -const CONFIG_FILE = "release-please-config.json"; -const MANIFEST = "package.json"; -/** What a packaged commit carries beyond its source (a directory entry stages every file under it). */ -const PACKAGED_PATHS = ["lib/index.js", "lib/settings.schema.json", "lib/pkg/"] as const; -/** What every packaged commit must carry as non-empty regular files. */ -const REQUIRED_BUILT_FILES = [ - "lib/index.js", - "lib/settings.schema.json", - "lib/pkg/index.js", -] as const; -const PACKAGED = "lib/index.js, lib/settings.schema.json, and lib/pkg/"; -const LATEST_REF = "refs/tags/latest"; -const BUILD_TAG_PREFIX = "refs/tags/build/"; -const BUILD_TAG = /^refs\/tags\/build\/([1-9]\d*)\.[0-9a-f]{7}$/; -/** How many build tags stay; releases and latest keep their own commits. */ -export const KEPT_BUILD_TAGS = 10; -/** Never --depth: a depth-limited fetch marks the commit it lands on shallow and cuts the parent link every package - * check reads. Blobs arrive on demand, or with the commit on a server without filter support. */ -const TAG_FETCH = ["--filter=blob:none"]; -/** A squash-merged release-please PR's subject on main. Anchored at both ends wherever release merges are recognized: - * a prefix match would let "chore(main): release pipeline documentation" impersonate one and park the boundary check. */ -const RELEASE_SUBJECT = /^chore\(main\): release (\d+\.\d+\.\d+)(?: \(#\d+\))?$/; -const FULL_SHA = /^[0-9a-f]{40}$/; -const PUSH_ATTEMPTS = 3; - -function git(cwd: string, ...args: string[]): string { - return gitWithEnv(cwd, {}, ...args); -} - -function gitWithEnv(cwd: string, env: Record, ...args: string[]): string { - try { - return execFileSync("git", args, { - cwd, - encoding: "utf8", - env: { ...process.env, ...env }, - }).trim(); - } catch (error) { - throw gitFailure(args, error); - } -} - -function gitFailure(args: string[], error: unknown): Error { - const stderr = (error as { stderr?: unknown }).stderr; - const detail = typeof stderr === "string" && stderr.trim() !== "" ? `: ${stderr.trim()}` : ""; - return new Error(`git ${args.join(" ")} failed${detail}`); -} - -/** git's stdout, or null when it exited 1 (a "no" from --verify, --is-ancestor, and the like); any other failure - * is thrown, so a repository git cannot read never passes for one without the object. */ -function gitOrNo(cwd: string, ...args: string[]): string | null { - try { - return execFileSync("git", args, { - cwd, - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - }).trim(); - } catch (error) { - if ((error as { status?: unknown }).status === 1) { - return null; - } - throw gitFailure(args, error); - } -} - -/** git's stdout, or null on any failure: for reads whose absence git reports with exit 128 (a missing path). */ -function tryGit(cwd: string, ...args: string[]): string | null { - try { - return execFileSync("git", args, { - cwd, - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"], - }).trim(); - } catch { - return null; - } -} - -/** A push as git ran it. A refusal is never read from git's words: the caller re-reads origin, and a ref that - * moved since it was observed is a lost compare-and-set to retry, an unmoved one a refusal to throw. */ -function push(cwd: string, ...pushArgs: string[]): Error | null { - try { - execFileSync("git", ["push", ...pushArgs], { cwd, encoding: "utf8" }); - return null; - } catch (error) { - return gitFailure(["push", ...pushArgs], error); - } -} - -/** The identity the pipeline's own commits carry, passed per invocation: written into a checkout's config it would - * outlive the run and stamp every later commit made from that repository, or any worktree sharing it. */ -const BOT_IDENTITY = { - GIT_AUTHOR_NAME: "settings-as-code-release", - GIT_AUTHOR_EMAIL: "settings-as-code-release@users.noreply.github.com", - GIT_COMMITTER_NAME: "settings-as-code-release", - GIT_COMMITTER_EMAIL: "settings-as-code-release@users.noreply.github.com", -}; - -/** Every ref derivation passes through this parse, so a malformed tag stops the pipeline instead of minting "v2" - * from "v2.1-rc.0". */ -function releaseMajor(tag: string): string { - const match = tag.match(/^v(\d+)\.\d+\.\d+$/); - if (!match) { - throw new Error( - `tag ${JSON.stringify(tag)} is not a vX.Y.Z release tag; refusing to derive version refs from it.`, - ); - } - return `v${match[1]}`; -} - -/** Verdicts on a truncated history do not hold; the jobs check out with fetch-depth 0. */ -function assertFullHistory(cwd: string, what: string, consequence: string): void { - if (git(cwd, "rev-parse", "--is-shallow-repository") === "true") { - throw new Error( - `${what} needs the full history (fetch-depth: 0) and this checkout is shallow: ${consequence}`, - ); - } -} - -/** merge-base fails outright on a sha this checkout lacks, so unknown ones are screened into a plain "no" first. */ -function isAncestor(cwd: string, ancestor: string, descendant: string): boolean { - return ( - [ancestor, descendant].every((sha) => resolveCommit(cwd, sha) !== null) && - gitOrNo(cwd, "merge-base", "--is-ancestor", ancestor, descendant) !== null - ); -} - -/** The commit a name resolves to, or null for none (a sha the checkout lacks, or a short one naming several objects). */ -function resolveCommit(cwd: string, name: string): string | null { - return gitOrNo(cwd, "rev-parse", "--verify", "--quiet", `${name}^{commit}`); -} - -/** The required build files as non-empty REGULAR files: a symlink or a gitlink at that path has a size too, and no build. */ -function assertCarries(cwd: string, treeish: string, what: string, remedy: string): void { - for (const file of REQUIRED_BUILT_FILES) { - // ls-tree answers a missing path with empty output and exit 0; a failing call must propagate. - const entry = git(cwd, "ls-tree", "-l", treeish, "--", file); - const [mode = "", , , size] = entry.split(/\s+/); - if ((mode !== "100644" && mode !== "100755") || Number(size) === 0) { - throw new Error( - `${what} does not carry a non-empty regular-file ${file} (${entry === "" ? "no entry" : `entry ${entry.split("\t")[0]}`}); refusing to point a consumable ref at an unpackaged commit; ${remedy}`, - ); - } - } -} - -/** The scripts npm's git fetcher (pacote) takes as a signal to run `npm install --include=dev` and the prepare - * lifecycle in a `github:` dependency's checkout; the packaged commit ships the build already. */ -const PREPARATION_SCRIPTS = ["prepare", "prepack", "build", "preinstall", "install", "postinstall"]; - -/** sourceSha's tree plus `addBuild`'s entries, with package.json's preparation scripts removed, assembled in a - * private index so nothing else can enter and the checkout's own index stays untouched. */ -function packagedTreeOf( - cwd: string, - sourceSha: string, - addBuild: (env: Record) => void, -): string { - const indexFile = join(git(cwd, "rev-parse", "--absolute-git-dir"), "release-pipeline.index"); - const env = { GIT_INDEX_FILE: indexFile }; - try { - gitWithEnv(cwd, env, "read-tree", sourceSha); - const text = tryGit(cwd, "show", `${sourceSha}:${MANIFEST}`); - const pkg = text === null ? null : (JSON.parse(text) as { scripts?: Record }); - if (pkg?.scripts && PREPARATION_SCRIPTS.some((name) => name in (pkg.scripts ?? {}))) { - for (const name of PREPARATION_SCRIPTS) { - delete pkg.scripts[name]; - } - const blob = execFileSync("git", ["hash-object", "-w", "--stdin"], { - cwd, - input: `${JSON.stringify(pkg, null, 2)}\n`, - encoding: "utf8", - }).trim(); - gitWithEnv(cwd, env, "update-index", "--add", "--cacheinfo", `100644,${blob},${MANIFEST}`); - } - addBuild(env); - return gitWithEnv(cwd, env, "write-tree"); - } finally { - rmSync(indexFile, { force: true }); - } -} - -/** - * `packaged` is a package of `sourceSha` as this pipeline mints them: its child (the parent edge is the one record - * of the source), carrying the source's tree plus the build outputs and the stripped manifest, nothing else, every - * required build file a regular file. With `built`, the tree this checkout's fresh build produced, the packaged tree - * must be that tree byte for byte (a tree id names every byte and mode under it). - */ -function assertPackageOf( - cwd: string, - packaged: string, - sourceSha: string, - what: string, - remedy: string, - built?: string, -): void { - const parents = git(cwd, "log", "-1", "--format=%P", packaged).split(" ").filter(Boolean); - if (parents.length !== 1 || parents[0] !== sourceSha) { - throw new Error( - `${what} has ${parents.length === 0 ? "no parent" : `parent ${parents.join(", ")}`}, so it is no package of ${sourceSha} (a packaged commit is that commit's child); ${remedy}`, - ); - } - const actual = git(cwd, "rev-parse", `${packaged}^{tree}`); - if (built !== undefined && actual === built) { - return; - } - // Rebuilt from the source with the packaged commit's own build entries: tree identity, so an empty subtree a - // path diff cannot list still differs. - const entries = git(cwd, "ls-tree", "-r", packaged, "--", ...PACKAGED_PATHS) - .split("\n") - .filter(Boolean); - const expected = packagedTreeOf(cwd, sourceSha, (env) => { - for (const line of entries) { - const [meta = "", path = ""] = line.split("\t"); - const [mode = "", , blob = ""] = meta.split(/\s+/); - gitWithEnv(cwd, env, "update-index", "--add", "--cacheinfo", `${mode},${blob},${path}`); - } - }); - if (actual !== expected) { - throw new Error( - `${what} is not ${sourceSha} plus ${PACKAGED}, minus ${MANIFEST}'s preparation scripts, alone: its tree is ${actual}, the rebuilt one is ${expected} (git diff ${expected} ${packaged} lists what deviates); ${remedy}`, - ); - } - assertCarries(cwd, packaged, what, remedy); - if (built !== undefined) { - throw new Error( - `${what} packages ${sourceSha}, but its tree ${actual} is not the tree ${built} this checkout's build packages, ` + - `so the two differ under ${PACKAGED}: either the commit was not built from this source or the build is not ` + - `reproducible. Diff the two trees by hand; ${remedy}`, - ); - } -} - -/** The checkout must BE sourceSha with a clean worktree: that is what makes the build outputs a build of that source - * rather than of a by-hand edit. They are gitignored, so they never show as pending. */ -function builtTree(cwd: string, sourceSha: string): string { - const head = git(cwd, "rev-parse", "HEAD"); - if (head !== sourceSha) { - throw new Error(`the checkout is at ${head}, not the source commit ${sourceSha} to package.`); - } - const dirty = git(cwd, "status", "--porcelain").split("\n").filter(Boolean); - if (dirty.length > 0) { - throw new Error( - `the worktree has pending changes beyond ${PACKAGED} (${dirty.join("; ")}); the build must be a build of ${sourceSha} alone - commit, stash, or clean them first.`, - ); - } - // -f: main gitignores the build outputs; a path the build left out is reported by the carry check, not by git add. - const built = PACKAGED_PATHS.filter((path) => existsSync(join(cwd, path))); - const tree = packagedTreeOf(cwd, sourceSha, (env) => { - if (built.length > 0) { - gitWithEnv(cwd, env, "add", "-f", "--", ...built); - } - }); - assertCarries(cwd, tree, `the build of ${sourceSha}`, "run the build before packaging."); - return tree; -} - -/** A plain fetch does not deepen a shallow clone, so ancestry against this head holds only on a full checkout. */ -function fetchMainHead(cwd: string): string { - git(cwd, "fetch", "--quiet", "origin", "refs/heads/main"); - return git(cwd, "rev-parse", "FETCH_HEAD"); -} - -function assertOnMain(cwd: string, sourceSha: string, refusal: string): void { - const mainHead = fetchMainHead(cwd); - if (!isAncestor(cwd, sourceSha, mainHead)) { - throw new Error( - `${sourceSha} is not on origin's main (its head is ${mainHead}); refusing to ${refusal}.`, - ); - } -} - -/** A ref as origin advertises it: the id a lease holds against, and the commit it peels to (an annotated tag's id - * is not its commit's). Both empty when the ref does not exist. */ -function observeRemote(cwd: string, ref: string): { id: string; peeled: string } { - let id = ""; - let peeled = ""; - for (const line of git(cwd, "ls-remote", "origin", ref, `${ref}^{}`).split("\n")) { - const [sha = "", name] = line.split("\t"); - if (name === ref) { - id = sha; - } else if (name === `${ref}^{}`) { - peeled = sha; - } - } - return { id, peeled: peeled === "" ? id : peeled }; -} - -/** Bring an observed ref's commit into this clone by the ref's name (origin serves no fetch by arbitrary sha). The - * ref can move or vanish between the observation and the fetch; the caller re-observes and decides again then. A - * fetch that fails with the ref standing where it was read is thrown. */ -function fetchObserved(cwd: string, ref: string, observedId: string): boolean { - try { - git(cwd, "fetch", "--quiet", ...TAG_FETCH, "origin", `+${ref}:${ref}`); - } catch (error) { - if (observeRemote(cwd, ref).id === observedId) { - throw error; - } - return false; - } - return git(cwd, "rev-parse", ref) === observedId; -} - -/** A packaged commit and the main commit it packages (its parent). */ -interface Packaged { - commit: string; - source: string; -} - -interface EnsuredTag extends Packaged { - created: boolean; - ref: string; -} - -/** - * A tag that exists exactly once: an existing one is fetched and `verify`d, an absent one is created with a plain - * push (no force, no lease) on the commit `mint` returns. git refuses the push once the tag exists, so the loser of - * two runs verifies the winner's commit as a rerun does. A ref that vanishes or moves mid-read is read again, and so - * is a refused create whose ref is absent on the re-read (a rival created and pruned it in between, or the push was - * refused for good: the two look alike, so the refusal is thrown only once every attempt is spent). - */ -function ensureTag( - cwd: string, - ref: string, - source: string, - mint: () => string, - verify: (peeled: string) => void, -): EnsuredTag { - let refused: Error | null = null; - for (let attempt = 1; attempt <= PUSH_ATTEMPTS; attempt++) { - const observed = observeRemote(cwd, ref); - if (observed.id !== "") { - if (!fetchObserved(cwd, ref, observed.id)) { - continue; - } - verify(observed.peeled); - return { created: false, ref, commit: observed.peeled, source }; - } - const commit = mint(); - refused = push(cwd, "origin", `${commit}:${ref}`); - if (refused === null) { - return { created: true, ref, commit, source }; - } - } - throw ( - refused ?? - new Error( - `${ref} kept changing under this run through ${PUSH_ATTEMPTS} reads; something keeps creating and deleting it - rerun this job once it settles.`, - ) - ); -} - -const BUILD_REMEDY = - "no run replaces a packaged commit it did not mint; if the build is wrong, delete the tag by hand and rerun."; - -/** sourceSha's packaged commit under its build tag: this checkout's build `tree` as the source's child, minted once. - * Both parts of the name are the commit's own, so every run for one commit names one tag. */ -function ensurePackaged(cwd: string, sourceSha: string, tree: string, runUrl?: string): EnsuredTag { - const ref = `${BUILD_TAG_PREFIX}${mainPosition(cwd, sourceSha).count}.${sourceSha.slice(0, 7)}`; - return ensureTag( - cwd, - ref, - sourceSha, - () => - gitWithEnv( - cwd, - BOT_IDENTITY, - "commit-tree", - "-p", - sourceSha, - "-m", - `build: main at ${git(cwd, "rev-parse", "--short", sourceSha)}`, - ...(runUrl === undefined ? [] : ["-m", `Workflow-run: ${runUrl}`]), - tree, - ), - (peeled) => assertPackageOf(cwd, peeled, sourceSha, `${ref} (${peeled})`, BUILD_REMEDY, tree), - ); -} - -/** The build tags beyond the `keep` newest by position, deleted in one push. Two runs pruning at once commute: the - * server answers the deletion of a ref a rival deleted first with a warning, not a refusal. A ref under build/ this - * pipeline would not name stops the prune: sorted in, it could push a genuine tag out of the window. */ -export function pruneBuildTags(cwd: string, keep = KEPT_BUILD_TAGS): string[] { - const refs = git(cwd, "ls-remote", "origin", `${BUILD_TAG_PREFIX}*`) - .split("\n") - .map((line) => line.split("\t")[1] ?? "") - .filter((ref) => ref !== "" && !ref.endsWith("^{}")) - .map((ref) => { - const position = Number(ref.match(BUILD_TAG)?.[1]); - if (Number.isNaN(position)) { - throw new Error( - `origin holds ${ref}, which is not a build/. tag this pipeline names; delete it by hand.`, - ); - } - return { ref, position }; - }) - .sort((a, b) => b.position - a.position || a.ref.localeCompare(b.ref)) - .slice(keep) - .map((tag) => tag.ref); - if (refs.length > 0) { - git(cwd, "push", "origin", ...refs.map((ref) => `:${ref}`)); - } - return refs; -} - -export interface PointerMove { - ref: string; - /** Where the pointer is when the move ends. */ - sha: string; - changed: boolean; - reason: string; -} - -/** - * The one way a pointer (latest, vX) moves: forward along main, never back. A pointer's source is its commit's - * parent when that parent is on main; the candidate's source is on main by its caller's check. + * This file dispatches; the sections live under release-pipeline/ rather than lib/, which holds what several + * scripts share while these belong to this one. * - * A value left in place must be a package of the commit it is read as packaging, or a hand-pushed bare child of a - * newer commit would stand as "already past". Main's head is read AFTER the pointer on every pass: a pointer a rival - * moved to a newer commit's package has that commit on main by then. - */ -export function movePointer(cwd: string, ref: string, candidate: Packaged): PointerMove { - for (let attempt = 1; attempt <= PUSH_ATTEMPTS; attempt++) { - const observed = observeRemote(cwd, ref); - const at = observed.peeled; - if (observed.id !== "") { - if (!fetchObserved(cwd, ref, observed.id)) { - continue; - } - const mainHead = fetchMainHead(cwd); - const parents = git(cwd, "log", "-1", "--format=%P", at).split(" ").filter(Boolean); - const current = parents.length === 1 ? parents[0] : undefined; - // A pointer whose commit is no child of a main commit (the retired chain's tip, a hand-pushed bare root) is replaced, not judged. - if (current !== undefined && isAncestor(cwd, current, mainHead)) { - if (isAncestor(cwd, candidate.source, current)) { - assertPackageOf(cwd, at, current, `${ref} (${at})`, "inspect it by hand."); - if ( - current === candidate.source && - git(cwd, "rev-parse", `${at}^{tree}`) !== - git(cwd, "rev-parse", `${candidate.commit}^{tree}`) - ) { - throw new Error( - `${ref} is at ${at}, another package of ${candidate.source} than ${candidate.commit} with another tree; two builds of one main commit exist - inspect both by hand.`, - ); - } - return { - ref, - sha: at, - changed: false, - reason: `${ref} already at ${at}, packaging ${current}, which is ${candidate.source} or past it`, - }; - } - } - } - const refused = push( - cwd, - `--force-with-lease=${ref}:${observed.id}`, - "origin", - `${candidate.commit}:${ref}`, - ); - if (refused === null) { - return { - ref, - sha: candidate.commit, - changed: true, - reason: `${ref}: moved to ${candidate.commit}${at === "" ? "" : ` from ${at}`}`, - }; - } - if (observeRemote(cwd, ref).id === observed.id) { - throw refused; - } - } - throw new Error( - `could not move ${ref} after ${PUSH_ATTEMPTS} compare-and-swap attempts; something keeps moving it concurrently - rerun this job once it settles.`, - ); -} - -interface PackageCommitOptions { - cwd: string; - /** The green main commit this run judged; the checkout must be at it with the bundle built. */ - sourceSha: string; - /** Provenance trailer for a packaged commit this run mints (the workflow run URL). */ - runUrl?: string; -} - -interface PackageCommitResult extends EnsuredTag { - /** The build tags this run deleted, beyond the kept window. */ - pruned: string[]; - latest: PointerMove; -} - -/** The green push's step: the commit's package under its build tag, the window pruned, latest moved forward. */ -export function packageCommit(options: PackageCommitOptions): PackageCommitResult { - const { cwd, sourceSha, runUrl } = options; - assertFullHistory( - cwd, - "package-commit", - "the commit's position on main and whether latest's source lies on its history cannot be judged on a truncated one.", - ); - const tree = builtTree(cwd, sourceSha); - assertOnMain(cwd, sourceSha, "package a commit main does not hold"); - const packaged = ensurePackaged(cwd, sourceSha, tree, runUrl); - const pruned = pruneBuildTags(cwd); - return { ...packaged, pruned, latest: movePointer(cwd, LATEST_REF, packaged) }; -} - -interface PackageOptions { - cwd: string; - tag: string; - /** The release's merge commit, resolved from the draft (not necessarily this run's own push); the tag's recorded source. */ - sourceSha: string; - /** Provenance trailer for a packaged commit this run has to mint (the workflow run URL). */ - runUrl?: string; -} - -interface PackagedRelease { - created: boolean; - packagedSha: string; - /** The build tags this run deleted, beyond the kept window. */ - pruned: string[]; - latest: PointerMove; -} - -export const FROZEN = - "the release-tags ruleset freezes version tags, so no rerun can replace it - inspect it by hand."; - -/** - * Mint the release's version tag on its packaged commit exactly once; the checkout must be the merge commit with the - * bundle freshly built. A rerun finds the tag on origin and holds it to this build instead, so no rerun can move or - * replace a version tag, then prunes and reconciles latest as post-green does, healing a run that died between the - * pushes (and giving @latest a value even where post-green cannot push). - * - * source off origin's main -> stop before any push: the frozen tag would be poisoned - * the build tag packages it -> tagged there (normally minted by post-green earlier in this run) - * no build tag (pruned, or skipped) -> minted here through post-green's path; a package the window has moved past goes again at once - */ -export function packageRelease(options: PackageOptions): PackagedRelease { - const { cwd, tag, sourceSha, runUrl } = options; - releaseMajor(tag); - assertFullHistory( - cwd, - "package", - "the merge commit's position on main and whether latest's source lies on its history cannot be judged on a truncated one.", - ); - const tree = builtTree(cwd, sourceSha); - // A hand recovery with the wrong TAG, or a draft pointing at the wrong commit, must stop before an immutable tag is minted. - const manifest = manifestVersionAt(cwd, "HEAD"); - if (tag !== `v${manifest}`) { - throw new Error( - `tag ${tag} does not match the manifest version ${JSON.stringify(manifest)} at ${sourceSha}; refusing to package a version this source did not release.`, - ); - } - assertOnMain(cwd, sourceSha, "package, tag, or publish a commit main does not hold"); - const ref = `refs/tags/${tag}`; - const tagged = ensureTag( - cwd, - ref, - sourceSha, - () => ensurePackaged(cwd, sourceSha, tree, runUrl).commit, - (peeled) => assertPackageOf(cwd, peeled, sourceSha, `${ref} (${peeled})`, FROZEN, tree), - ); - const pruned = pruneBuildTags(cwd); - return { - created: tagged.created, - packagedSha: tagged.commit, - pruned, - latest: movePointer(cwd, LATEST_REF, tagged), - }; -} - -interface RetagMajorOptions { - cwd: string; - tag: string; - sourceSha: string; -} - -interface RetaggedMajor { - major: string; - packagedSha: string; - move: PointerMove; -} - -/** The version tag is read from origin afresh and judged from its objects alone (the byte check against a fresh - * build was package's, a step earlier); the major then moves through movePointer, so a rerun of an old release's - * job leaves a newer release's major where it is. */ -export function retagMajor(options: RetagMajorOptions): RetaggedMajor { - const { cwd, tag, sourceSha } = options; - assertFullHistory( - cwd, - "retag-major", - "whether the release and the major's current source lie on main cannot be judged on a truncated one.", - ); - const ref = `refs/tags/${tag}`; - git(cwd, "fetch", "--quiet", ...TAG_FETCH, "origin", `+${ref}:${ref}`); - const packagedSha = git(cwd, "rev-parse", `${ref}^{}`); - assertOnMain(cwd, sourceSha, "bless a release main does not hold"); - assertPackageOf(cwd, packagedSha, sourceSha, `${ref} (${packagedSha})`, FROZEN); - const major = releaseMajor(tag); - const move = movePointer(cwd, `refs/tags/${major}`, { commit: packagedSha, source: sourceSha }); - return { major, packagedSha, move }; -} - -/** checks.yml's head_ref conditions spell this by hand; test/workflows/checks-workflow.test.ts pins them to it. */ -export const RELEASE_PR_BRANCH_PREFIX = "release-please--"; -const RELEASE_PR_BRANCH = `${RELEASE_PR_BRANCH_PREFIX}branches--main`; - -/** release-please's config with the one key this pipeline reads and writes; the rest is release-please's. */ -type ReleaseConfig = Record & { "last-release-sha"?: string }; - -/** The config as the checkout holds it. The file is the user's and release-please's, so nothing here is repaired: a - * missing or malformed one, or a boundary that is no commit sha, is refused naming the file (an absent boundary is - * the pre-first-release state and passes). */ -function readReleaseConfig(cwd: string): ReleaseConfig { - let text: string; - try { - text = readFileSync(join(cwd, CONFIG_FILE), "utf8"); - } catch (error) { - if ((error as { code?: unknown }).code !== "ENOENT") { - throw error; - } - throw new Error( - `${CONFIG_FILE} is missing from ${cwd}; the release pipeline reads release-please's boundary (last-release-sha) from it. Restore it by PR.`, - ); - } - let parsed: unknown; - try { - parsed = JSON.parse(text); - } catch (error) { - throw new Error( - `${CONFIG_FILE} is not valid JSON (${error instanceof Error ? error.message : String(error)}); fix it by PR.`, - ); - } - if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { - const kind = Array.isArray(parsed) - ? "an array" - : parsed === null - ? "null" - : `a ${typeof parsed}`; - throw new Error(`${CONFIG_FILE} holds ${kind}, not a JSON object; fix it by PR.`); - } - const config = parsed as Record; - const boundary = config["last-release-sha"]; - if (boundary !== undefined && (typeof boundary !== "string" || !FULL_SHA.test(boundary))) { - throw new Error( - `last-release-sha in ${CONFIG_FILE} is ${JSON.stringify(boundary)}, not a 40-hex commit sha; fix it by PR.`, - ); - } - return config as ReleaseConfig; -} - -export interface AnchorOptions { - cwd: string; - /** Main's head this run tested; the merge parent the boundary records. */ - sourceSha: string; - attempts?: number; -} - -export interface AnchorResult { - changed: boolean; - reason: string; -} - -/** The boundary rides INSIDE the release PR as last-release-sha = main's current head, the future merge commit's - * PARENT (known now, unlike the merge sha), so the squash merge lands it on main with no push to main and no admin credential. - * the parent, not the merge -> the changelog walk then also includes the merge itself, a chore commit it hides anyway - * a stale parent -> ruled out by the managed release-freshness gate: a PR must contain main's tip to merge */ -export function anchorReleasePr(options: AnchorOptions): AnchorResult { - const { cwd, sourceSha, attempts = 3 } = options; - // If main moved, the newer push's run refreshes the branch and anchors the newer head. - const head = git(cwd, "ls-remote", "origin", "refs/heads/main").split("\t")[0]; - if (head !== sourceSha) { - return { changed: false, reason: `main moved to ${head ?? "?"}; the newer run anchors` }; - } - const branchRef = `refs/heads/${RELEASE_PR_BRANCH}`; - for (let attempt = 1; attempt <= attempts; attempt++) { - const observed = git(cwd, "ls-remote", "origin", branchRef).split("\t")[0] ?? ""; - if (observed === "") { - return { changed: false, reason: "no release PR branch to anchor" }; - } - // Detached, never a local branch: a retry after an overtaken push re-fetches, and git refuses to fetch into a checked-out ref. - git(cwd, "fetch", "--quiet", "--force", "origin", branchRef); - git(cwd, "checkout", "--quiet", "--force", "--detach", "FETCH_HEAD"); - // A branch still built from an older main must not be stamped with this head. A shallow history that cannot - // prove ancestry no-ops the same way; the refresh that follows anchors. - if (!isAncestor(cwd, sourceSha, "FETCH_HEAD")) { - return { - changed: false, - reason: "the release PR branch is not built on this head; its own refresh anchors", - }; - } - const config = readReleaseConfig(cwd); - if (config["last-release-sha"] === sourceSha) { - return { changed: false, reason: "already anchored" }; - } - config["last-release-sha"] = sourceSha; - writeFileSync(join(cwd, CONFIG_FILE), `${JSON.stringify(config, null, 2)}\n`); - git(cwd, "add", CONFIG_FILE); - gitWithEnv( - cwd, - BOT_IDENTITY, - "commit", - "-m", - "chore: anchor release-please to this release cycle's base", - "-m", - "last-release-sha records the merge parent so the squash merge itself lands the next cycle's boundary on main: version tags live on packaged commits that are not on main, so release-please cannot find the boundary by tag.", - ); - // A newer push's run may already have refreshed and anchored the branch before the fetch above: this run's commit - // then descends from that anchor, pushes cleanly, and regresses the boundary to the older head. - const headNow = git(cwd, "ls-remote", "origin", "refs/heads/main").split("\t")[0]; - if (headNow !== sourceSha) { - return { changed: false, reason: `main moved to ${headNow ?? "?"}; the newer run anchors` }; - } - const refused = push(cwd, "origin", `HEAD:${branchRef}`); - if (refused === null) { - return { changed: true, reason: `${RELEASE_PR_BRANCH}: anchored at ${sourceSha}` }; - } - if ((git(cwd, "ls-remote", "origin", branchRef).split("\t")[0] ?? "") === observed) { - throw refused; - } - // release-please force-pushed a refresh mid-anchor; reapply on it. - console.error( - `anchor push attempt ${attempt}/${attempts} overtaken by a refresh; re-reading the branch`, - ); - } - throw new Error( - `could not anchor ${RELEASE_PR_BRANCH} after ${attempts} attempts; something keeps rewriting the branch - rerun this job once the branch settles.`, - ); -} - -/** On main, last-release-sha must be the newest release merge or its parent; anything else means an anchor was lost - * or a release merge slipped past the pipeline, and every release PR refresh would compute from a stale boundary. - * A boundary NEWER than every recognized merge is a missed merge or a hand edit; either way it is never rolled back. */ -export function boundaryCheck(cwd: string): { boundary: string } { - assertFullHistory( - cwd, - "boundary-check", - "a release merge or the recorded boundary can sit beyond its depth, and no verdict on a truncated history holds.", - ); - const recorded = readReleaseConfig(cwd)["last-release-sha"]; - // The grep only narrows (it matches any message line with the prefix); RELEASE_SUBJECT decides, so - // "chore(main): release pipeline documentation" can neither become the boundary nor hide the real newest merge. - const listed = git(cwd, "log", "--grep", "^chore(main): release ", "--format=%H%x09%s"); - let latest = ""; - for (const line of listed.split("\n")) { - const [sha, subject] = line.split("\t"); - if (sha !== undefined && subject !== undefined && RELEASE_SUBJECT.test(subject)) { - latest = sha; - break; - } - } - if (latest === "") { - if (recorded === undefined) { - return { boundary: "none (no release merge on this history yet)" }; - } - const cause = isAncestor(cwd, recorded, "HEAD") - ? `this history holds it, so release-please's merge subject no longer matches ${RELEASE_SUBJECT} (investigate RELEASE_SUBJECT)` - : "it is not on this history at all (not main, or a boundary that never landed)"; - throw new Error( - `last-release-sha in ${CONFIG_FILE} is ${JSON.stringify(recorded)}, but no release merge is reachable from HEAD: ${cause}. Refusing to read a recorded boundary as a pre-first-release history.`, - ); - } - const parent = tryGit(cwd, "rev-parse", `${latest}^`); - if (recorded !== undefined) { - if (recorded === latest || (parent !== null && recorded === parent)) { - return { boundary: recorded }; - } - if (isAncestor(cwd, latest, recorded) && isAncestor(cwd, recorded, "HEAD")) { - throw new Error( - `last-release-sha in ${CONFIG_FILE} is ${JSON.stringify(recorded)}, NEWER than ` + - `${latest}, the newest release merge whose subject matches ${RELEASE_SUBJECT}: either a ` + - `newer release merge's subject stopped matching (investigate RELEASE_SUBJECT) or the ` + - `boundary was edited by hand. It must not be rolled back to ${parent ?? latest}.`, - ); - } - } - throw new Error( - `last-release-sha in ${CONFIG_FILE} is ${JSON.stringify(recorded)}, but the newest release ` + - `merge on main is ${latest} (parent ${parent}); release PR refreshes would be computed ` + - `from a stale boundary. Fix by PR: set last-release-sha to ${parent ?? latest}.`, - ); -} - -/** Run on the release PR's own checkout. The managed release-freshness gate proves the PR contains main's tip, not - * that the anchor commit survived a release-please force-push, so requiring last-release-sha to equal origin's - * CURRENT main tip makes an unanchored release PR unmergeable instead of parking the pipeline after its merge. */ -export function anchorCheck(cwd: string): { boundary: string } { - const recorded = readReleaseConfig(cwd)["last-release-sha"]; - const tip = git(cwd, "ls-remote", "origin", "refs/heads/main").split("\t")[0] ?? ""; - if (recorded !== tip) { - throw new Error( - `last-release-sha in ${CONFIG_FILE} is ${JSON.stringify(recorded)}, but main's tip is ` + - `${tip}; the anchor is missing or stale, so merging would land a wrong boundary. ` + - `The release pipeline's update-release-pr hook re-applies it on every release-PR refresh ` + - `- wait for (or dispatch) the next green main run, then close/reopen the PR so its ` + - `checks run on the anchored head (the anchor is pushed with the default token, which ` + - `triggers no new checks).`, - ); - } - return { boundary: recorded }; -} - -/** The manifest's version at a commit: what release-please last released, or is about to. */ -function manifestVersionAt(cwd: string, treeish: string): string { - const manifest = JSON.parse(git(cwd, "show", `${treeish}:${MANIFEST_FILE}`)) as Record< - string, - unknown - >; - return String(manifest["."]); -} - -export interface MainPosition { - /** Commits reachable from it along first parents: one more per merge to main, whatever a merged PR's branch held. */ - count: number; - /** Its committer date in UTC, YYYYMMDD. */ - date: string; -} - -/** Refused on a shallow checkout: it would count to its boundary and mint a truncated count, so the version would - * sort below ones minted from the full history for older commits. */ -export function mainPosition(cwd: string, sourceSha: string): MainPosition { - if (git(cwd, "rev-parse", "--is-shallow-repository") === "true") { - throw new Error( - "the pre-release version needs the full history (fetch-depth: 0) and this checkout is shallow: the count of commits under the source would stop at the shallow boundary.", - ); - } - const count = Number(git(cwd, "rev-list", "--count", "--first-parent", sourceSha)); - const committed = Number(git(cwd, "show", "-s", "--format=%ct", sourceSha)); - const date = new Date(committed * 1000).toISOString().slice(0, 10).replaceAll("-", ""); - return { count, date }; -} - -/** - * The npm version a main commit's library build publishes under the `next` dist-tag: the manifest version's - * next patch, then `main`, the source's position on main, and its short sha. That sorts above the last release, - * below the next one whatever its bump, and along main: npm compares the count first, and it grows by one with - * each merge (the date is for the reader; two merges on one day share it). The sha carries a `g` prefix, as git - * describe writes it: npm reads an all-digit identifier as a number and drops its leading zero, so a bare sha7 - * such as 0123456 would be rewritten to 123456 and name no commit. - */ -export function prereleaseVersion( - manifestVersion: string, - position: MainPosition, - sourceSha: string, -): string { - const version = manifestVersion.match(/^(\d+)\.(\d+)\.(\d+)$/); - if (!version) { - throw new Error( - `the manifest version ${JSON.stringify(manifestVersion)} is not X.Y.Z; refusing to derive a pre-release version from it.`, - ); - } - // The first commit counts 1, so a count of 0 names no commit; a version carrying one was never minted here. - if (!Number.isInteger(position.count) || position.count < 1) { - throw new Error( - `the commit count ${JSON.stringify(position.count)} is not a positive integer; refusing to mint a pre-release version from it.`, - ); - } - if (!FULL_SHA.test(sourceSha)) { - throw new Error( - `the source ${JSON.stringify(sourceSha)} is not a full commit sha; refusing to mint a pre-release version from it.`, - ); - } - const [, major, minor, patch] = version; - return `${major}.${minor}.${Number(patch) + 1}-main.${position.count}.${position.date}.g${sourceSha.slice(0, 7)}`; -} - -export interface PrereleaseVersionOptions { - cwd: string; - /** The commit whose build is published; the checkout must be at it. */ - sourceSha: string; -} - -/** The checkout must be at the source whose build is published: the manifest and package.json are read there. */ -function assertCheckoutAt(cwd: string, sourceSha: string): void { - const head = git(cwd, "rev-parse", "HEAD"); - if (head !== sourceSha) { - throw new Error( - `the checkout is at ${head}, not the source commit ${sourceSha} whose build is published.`, - ); - } -} - -export function prereleaseVersionOf(options: PrereleaseVersionOptions): string { - const { cwd, sourceSha } = options; - assertCheckoutAt(cwd, sourceSha); - return prereleaseVersion( - manifestVersionAt(cwd, sourceSha), - mainPosition(cwd, sourceSha), - sourceSha, - ); -} - -/** A version this pipeline mints, parsed: a release, or a pre-release carrying its source's short sha. The - * identifiers between `main` and the sha are not read back: a published pre-release is placed by its source's - * ancestry, never by comparing them. */ -interface MintedVersion { - release: [number, number, number]; - sha7: string | null; -} - -function mintedVersion(version: string): MintedVersion | null { - const match = version.match( - /^(\d+)\.(\d+)\.(\d+)(?:-main\.(?:(?:0|[1-9]\d*)\.)+g([0-9a-f]{7}))?$/, - ); - if (!match) { - return null; - } - const [, major = "", minor = "", patch = "", sha7 = null] = match; - return { release: [Number(major), Number(minor), Number(patch)], sha7 }; -} - -/** The source sha7 a pre-release this pipeline minted carries; null for a release or a version minted elsewhere. */ -const sha7Of = (version: string): string | null => mintedVersion(version)?.sha7 ?? null; - -/** A version a dist-tag names must be one this pipeline mints; anything else stops the run rather than being guessed at. */ -function parseMinted(version: string): MintedVersion { - const minted = mintedVersion(version); - if (minted === null) { - throw new Error( - `${JSON.stringify(version)} is not a version this pipeline mints (X.Y.Z or X.Y.Z-main..g); refusing to order it.`, - ); - } - return minted; -} - -/** Whether release `a` sorts above `b`: by major, minor, patch. */ -function newerRelease(a: [number, number, number], b: [number, number, number]): boolean { - return a[0] !== b[0] ? a[0] > b[0] : a[1] !== b[1] ? a[1] > b[1] : a[2] > b[2]; -} - -/** What the registry holds for the package: every published version, and where each dist-tag points. */ -export interface Packument { - versions: Record; - "dist-tags": Record; -} - -export type PublishVerdict = - | { publish: true; version: string } - | { publish: false; version: string; reason: string }; -/** The next channel's guard also carries, one line each, the published pre-releases it set aside: a source the checkout cannot place. */ -export type NextVerdict = PublishVerdict & { notices: string[] }; - -/** A published pre-release whose source is a strict descendant of this run's: newer on main, whatever its numbers say. */ -interface Descendant { - version: string; - /** The full sha the version's sha7 resolved to. */ - sha: string; -} - -/** - * Where a pre-release's source stands to this run's, once its sha resolves: the run's own commit, an ancestor, a strict - * descendant, or one on neither side of the source (off its line of main). - */ -type Placement = "own" | "ancestor" | "descendant" | "unrelated"; -/** A source placed, or one the checkout lacks (a sha it never fetched, or a short one naming several objects). */ -type Placed = { sha: string; placement: Placement } | { sha: null; placement: "unresolved" }; - -function placeSource(cwd: string, sourceSha: string, sha7: string): Placed { - const sha = resolveCommit(cwd, sha7); - if (sha === null) { - return { sha, placement: "unresolved" }; - } - const descends = (ancestor: string, descendant: string): boolean => - gitOrNo(cwd, "merge-base", "--is-ancestor", ancestor, descendant) !== null; - if (sha === sourceSha) { - return { sha, placement: "own" }; - } - if (descends(sourceSha, sha)) { - return { sha, placement: "descendant" }; - } - return { sha, placement: descends(sha, sourceSha) ? "ancestor" : "unrelated" }; -} - -/** - * The published pre-releases placed against this run's source by ancestry: the descendants, the one furthest along - * main, and a notice for each the checkout cannot place. The version `next` names is placed with them, whether or not - * the record lists it. - */ -function placePublished( - cwd: string, - sourceSha: string, - packument: Packument, -): { descendants: Descendant[]; newest: Descendant | null; notices: string[] } { - const descendants: Descendant[] = []; - const notices: string[] = []; - let newest: Descendant | null = null; - const nextVersion = packument["dist-tags"].next; - const versions = new Set(Object.keys(packument.versions)); - if (nextVersion !== undefined) { - versions.add(nextVersion); - } - for (const version of versions) { - const sha7 = sha7Of(version); - if (sha7 === null) { - continue; - } - const placed = placeSource(cwd, sourceSha, sha7); - if (placed.sha === null) { - notices.push(`${version} names ${sha7}, which is no commit in this checkout; ignored`); - } else if (placed.placement === "descendant") { - descendants.push({ version, sha: placed.sha }); - if ( - newest === null || - gitOrNo(cwd, "merge-base", "--is-ancestor", newest.sha, placed.sha) !== null - ) { - newest = { version, sha: placed.sha }; - } - } else if (placed.placement === "unrelated") { - notices.push( - `${version} names ${sha7}, which is neither an ancestor nor a descendant of ${sourceSha.slice(0, 7)} on main; ignored`, - ); - } - } - return { descendants, newest, notices }; -} - -/** - * The pre-publish guard on npm state, not a decision about whether to publish (the release-PR refresh that runs the - * publish job made that): every published pre-release is placed by its source's ancestry, so a rerun of a run that - * already published, or a stale retry once a newer commit's pre-release is on the registry, publishes nothing, - * whatever order the runs finished in (`npm publish --tag next` moves next to whatever it publishes). A source the - * checkout cannot place is set aside with a notice. Null is a package the registry has never seen: the first publish goes. - */ -export function nextPublishVerdict( - cwd: string, - sourceSha: string, - version: string, - packument: Packument | null, -): NextVerdict { - if (packument === null) { - return { publish: true, version, notices: [] }; - } - // A dist-tag names a version the registry holds, so next naming this one is the same rerun as the record listing it. - if (version in packument.versions || packument["dist-tags"].next === version) { - return { - publish: false, - version, - reason: `${version} is already on the registry`, - notices: [], - }; - } - const { newest, notices } = placePublished(cwd, sourceSha, packument); - if (newest !== null) { - return { - publish: false, - version, - reason: `the registry already holds ${newest.version}, whose source ${newest.sha.slice(0, 7)} is a descendant of ${sourceSha.slice(0, 7)} on main, so this stale run publishes nothing (npm publish --tag next would move next back)`, - notices, - }; - } - return { publish: true, version, notices }; -} - -/** - * Only `latest` is consulted: a plain `npm publish` moves latest and leaves - * next alone. A release sorts above the pre-releases published before its - * merge; the next release PR's first refresh publishes the one above it. - */ -export function stablePublishVerdict(version: string, packument: Packument | null): PublishVerdict { - if (packument === null) { - return { publish: true, version }; - } - if (version in packument.versions) { - return { - publish: false, - version, - reason: `${version} is already on the registry`, - }; - } - const latest = packument["dist-tags"].latest; - // Until the first stable release, latest names whatever the first publish was (the 0.0.0 placeholder, or a - // pre-release): a packument always carries that key (npm/registry REGISTRY-API.md, "dist-tags: an object with at - // least one key, latest"), whatever --tag asked for. A release must take latest over, so only a newer RELEASE holds one back. - const held = latest === undefined ? null : parseMinted(latest); - if (held?.sha7 === null && newerRelease(held.release, parseMinted(version).release)) { - return { - publish: false, - version, - reason: `the registry's latest is ${latest}, newer than ${version}, so this rerun of an older release publishes nothing (npm publish would move latest back)`, - }; - } - return { publish: true, version }; -} - -/** The registry's record of `name`, or null while it has never been published; any other answer than 200 or 404 throws. - * The URL carries a fresh query string on every read: the registry's CDN serves a packument from cache for up to - * 300 s (cache-control: public, max-age=300, and a request's no-cache is ignored), and a cache key that no earlier - * request had misses it, so the record comes from the origin, a publish just landed included. */ -async function fetchPackument(registry: string, name: string): Promise { - const url = `${registry.replace(/\/$/, "")}/${name.replaceAll("/", "%2F")}`; - const response = await fetch( - `${url}?fresh=${Date.now()}-${Math.random().toString(36).slice(2)}`, - { - headers: { accept: "application/json" }, - }, - ); - if (response.status === 404) { - return null; - } - if (!response.ok) { - throw new Error( - `the registry answered ${response.status} for ${name} (${url}); refusing to publish without knowing what it holds.`, - ); - } - const body: unknown = await response.json(); - const record = (value: unknown): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value); - if (!record(body) || !record(body.versions) || !record(body["dist-tags"])) { - throw new Error( - `the registry's record of ${name} (${url}) is not a packument (an object with versions and dist-tags records); refusing to publish without knowing what it holds.`, - ); - } - const tags = body["dist-tags"]; - for (const [tag, value] of Object.entries(tags)) { - if (typeof value !== "string") { - throw new Error( - `the registry's record of ${name} names dist-tag ${tag} as ${JSON.stringify(value)}, not a version; refusing to publish without knowing what it holds.`, - ); - } - } - return { versions: body.versions, "dist-tags": tags as Record }; -} - -function packageFieldAt(cwd: string, treeish: string, field: "name" | "version"): string { - const pkg = JSON.parse(git(cwd, "show", `${treeish}:package.json`)) as Record; - return String(pkg[field]); -} - -/** The version npm publishes for a release: package.json's, which must be the - * tag's (the hook held the manifest to the tag; package.json is a separate file - * release-please rewrites, and it is the one npm reads). */ -function releaseVersionAt(cwd: string, treeish: string, tag: string): string { - const version = packageFieldAt(cwd, treeish, "version"); - if (`v${version}` !== tag) { - throw new Error( - `package.json at the release source is version ${version}, but the release tag is ${tag}; refusing to publish a version this source did not release.`, - ); - } - return version; -} - -export type NpmVerdictOptions = { - cwd: string; - /** The commit whose build is published; the checkout must be at it. */ - sourceSha: string; - /** The registry's base URL, where the package's record is read. */ - registry: string; -} & ({ channel: "next" } | { channel: "stable"; tag: string }); - -export async function npmVerdict( - options: NpmVerdictOptions, -): Promise { - const { cwd, sourceSha, registry } = options; - let version: string; - if (options.channel === "next") { - version = prereleaseVersionOf({ cwd, sourceSha }); - } else { - assertCheckoutAt(cwd, sourceSha); - version = releaseVersionAt(cwd, "HEAD", options.tag); - } - const packument = await fetchPackument(registry, packageFieldAt(cwd, "HEAD", "name")); - return options.channel === "next" - ? nextPublishVerdict(cwd, sourceSha, version, packument) - : stablePublishVerdict(version, packument); -} - -export type ConfirmVerdict = - /** The registry's record shows the version this run published, and next names no older pre-release than it should. */ - | { outcome: "settled"; version: string; reads: number } - /** The record still lacks the version after every read: a following run may read one without it. */ - | { outcome: "unsettled"; version: string; reason: string } - /** The record shows the version and a pre-release of a descendant, and next names neither that nor a later one. */ - | { outcome: "behind"; version: string; reason: string }; - -export interface NpmConfirmOptions { - cwd: string; - /** The commit whose build this run published; the checkout must be at it. */ - sourceSha: string; - /** The registry's base URL, where the package's record is read. */ - registry: string; - /** How many times the record is read while it lacks the version, and the pause between reads. */ - attempts: number; - delayMs: number; -} - -/** - * After `npm publish --tag next`: the record is read until it shows the version, so the job holds the npm-publish - * lane until the next holder's verdict can see this publish (npm makes a publish readable asynchronously; a verdict - * read in that gap would move next back). Once it shows, and a descendant's pre-release is on the record, next - * must name a descendant's, or this stale run moved it back. A drift is reported, not repaired: trusted publishing (OIDC) - * authenticates `npm publish` alone, not `npm dist-tag add` (npm/cli#8547); the next release-PR refresh publishes and - * moves next forward, and a rerun of the reporting run publishes nothing and passes. - */ -export async function npmConfirm(options: NpmConfirmOptions): Promise { - const { cwd, sourceSha, registry, attempts, delayMs } = options; - if (!Number.isInteger(attempts) || attempts < 1) { - throw new Error(`the read attempts must be a positive integer, not ${attempts}`); - } - const version = prereleaseVersionOf({ cwd, sourceSha }); - const name = packageFieldAt(cwd, "HEAD", "name"); - for (let read = 1; ; read++) { - // A read that fails counts as a read that did not show the version: the lane is held through the budget - // either way, and the last failure is the one reported. - let packument: Packument | null; - try { - packument = await fetchPackument(registry, name); - } catch (error) { - if (read === attempts) { - throw error; - } - await sleep(delayMs); - continue; - } - if (packument !== null && version in packument.versions) { - const { descendants, newest } = placePublished(cwd, sourceSha, packument); - const next = packument["dist-tags"].next; - if (newest !== null && !descendants.some((descendant) => descendant.version === next)) { - return { - outcome: "behind", - version, - reason: - `the registry's next is ${next ?? "unset"} while it holds ${newest.version}, whose source ${newest.sha.slice(0, 7)} ` + - `is a descendant of ${sourceSha.slice(0, 7)} on main; this stale run moved next back, and the next release-PR ` + - `refresh moves it forward (npm dist-tag add ${name}@${newest.version} next repairs it by hand)`, - }; - } - return { outcome: "settled", version, reads: read }; - } - if (read === attempts) { - return { - outcome: "unsettled", - version, - reason: `the registry's record still lacks ${version} after ${attempts} reads over ${Math.round(((attempts - 1) * delayMs) / 1000)} s; a run judged before it shows may move next back, and the release-PR refresh after it moves next forward`, - }; - } - await sleep(delayMs); - } -} - -function endedHow(result: SpawnSyncReturns): string { - const how = result.status === null ? `died of ${result.signal}` : `exited ${result.status}`; - return result.error === undefined ? how : `${how} (${result.error.message})`; -} - -function npm(cwd: string, env: Record, ...args: string[]): void { - const result = spawnSync("npm", args, { cwd, env: { ...process.env, ...env }, stdio: "inherit" }); - if (result.status !== 0) { - throw new Error(`npm ${args.join(" ")} ${endedHow(result)}; see npm's output above.`); - } -} - -export type NpmPublishOptions = { - cwd: string; - /** The commit whose build is published; the checkout must be at it. */ - sourceSha: string; - /** The registry's base URL, where the package's record is read. */ - registry: string; -} & ( - | { channel: "next"; confirm: Pick } - | { channel: "stable"; tag: string } -); - -export type NpmPublishResult = - | { published: false; version: string; reason: string } - | { published: true; channel: "stable"; version: string } - | { published: true; channel: "next"; version: string; confirmed: ConfirmVerdict }; - -/** - * The publish step as one call: the channel's verdict, the publish it allows, and for next the confirmation that - * holds the lane until the record shows the version. The verdict's notices reach `notice` before npm runs, so a - * publish that fails still leaves them on the log. scripts.prepare is dropped from the published manifest: it - * installs lefthook, a devDependency the tarball does not carry, and npm blocks install scripts from a - * provenance-attested package anyway. GITHUB_SHA on the publish is the commit npm's provenance names. + * Node builtins only: bun runs this before `bun install`. */ -export async function npmPublish( - options: NpmPublishOptions, - notice: (line: string) => void, -): Promise { - const { cwd, sourceSha, registry } = options; - const verdict = await npmVerdict(options); - for (const line of "notices" in verdict ? verdict.notices : []) { - notice(line); - } - if (!verdict.publish) { - return { published: false, version: verdict.version, reason: verdict.reason }; - } - const { version } = verdict; - if (options.channel === "stable") { - npm(cwd, {}, "pkg", "delete", "scripts.prepare"); - npm(cwd, { GITHUB_SHA: sourceSha }, "publish"); - return { published: true, channel: "stable", version }; - } - npm(cwd, {}, "version", version, "--no-git-tag-version"); - npm(cwd, {}, "pkg", "delete", "scripts.prepare"); - npm(cwd, { GITHUB_SHA: sourceSha }, "publish", "--tag", "next"); - const confirmed = await npmConfirm({ cwd, sourceSha, registry, ...options.confirm }); - return { published: true, channel: "next", version, confirmed }; -} - -/** Trusted publishing (and its provenance) exists from npm 11.5.1 on. */ -export const NPM_FLOOR = "11.5.1"; - -/** The step output the release hook's later steps and jobs read the resolved merge commit from. */ -export const SOURCE_OUTPUT = "sha"; -/** What the package job uploads to the draft; the managed publish stage attaches attestation.json after the check. */ -const RELEASE_ASSETS = ["index.js", "settings.schema.json"]; - -/** The draft's target commitish, where release-please records the merge commit. The release's identity is read from - * the draft and never from the run's own sha, so a newer push's run can recover an older merge's pending release; - * anything but a full sha (a branch name on a hand-made draft) is refused before a checkout or a tag could name it. */ -export function resolveSource(tag: string): { sha: string } | { refusal: string } { - const target = capture([ - "gh", - "release", - "view", - tag, - "--json", - "targetCommitish", - "--jq", - ".targetCommitish", - ]); - return FULL_SHA.test(target) - ? { sha: target } - : { - refusal: `draft ${tag}'s target commitish is '${target}', not a commit SHA; refusing to package an unidentified source.`, - }; -} -/** The draft's asset names beside attestation.json, sorted. Publishing freezes the asset list, so an incomplete one - * must stop while the release is still a draft (v2.0.0 shipped assetless exactly this way). */ -export function releaseAssets(tag: string): string[] { - const view = JSON.parse(capture(["gh", "release", "view", tag, "--json", "assets"])) as { - assets: { name: string }[]; - }; - return view.assets - .map((asset) => asset.name) - .filter((name) => name !== "attestation.json") - .sort(); -} - -/** Bun's own semver, so a two-digit minor orders numerically and a prerelease of the floor sits below it; a - * string that is no version at all (nothing npm --version prints) counts as below it and reaches the refusal. */ -function belowFloor(version: string, floor: string): boolean { - return !Bun.semver.satisfies(version, `>=${floor}`); -} - -/** An npm at or above `floor` on PATH: an older bundled npm is upgraded once (`npm install -g npm@latest`, on the - * step's log), then held to the floor; `atFloor` false is the version still below it after that. */ -export function npmFloor(floor = NPM_FLOOR): { version: string; atFloor: boolean } { - const read = (): string => - execFileSync("npm", ["--version"], { - encoding: "utf8", - stdio: ["ignore", "pipe", "inherit"], - }).trim(); - let version = read(); - if (belowFloor(version, floor)) { - const install = spawnSync("npm", ["install", "-g", "npm@latest"], { stdio: "inherit" }); - if (install.status !== 0) { - throw new Error( - `npm ${version} is below ${floor} and npm install -g npm@latest ${endedHow(install)}; the publish needs npm ${floor} or newer.`, - ); - } - version = read(); - } - return { version, atFloor: !belowFloor(version, floor) }; -} +import { setOutput } from "./lib/workflow-step.js"; +import { anchorCheck, anchorReleasePr, boundaryCheck } from "./release-pipeline/anchor.js"; +import { + RELEASE_ASSETS, + releaseAssets, + resolveSource, + SOURCE_OUTPUT, +} from "./release-pipeline/draft.js"; +import { NPM_FLOOR, npmFloor, npmPublish, prereleaseVersionOf } from "./release-pipeline/npm.js"; +import { packageCommit, packageRelease, retagMajor } from "./release-pipeline/tags.js"; const DEFAULT_REGISTRY = "https://registry.npmjs.org"; /** 15 reads 20 s apart (up to 280 s): three of the first five publishes were still unreadable after 80 s. */ diff --git a/.github/scripts/release-pipeline/anchor.ts b/.github/scripts/release-pipeline/anchor.ts new file mode 100644 index 00000000..1a35eb71 --- /dev/null +++ b/.github/scripts/release-pipeline/anchor.ts @@ -0,0 +1,223 @@ +/** + * release-please's boundary: last-release-sha in release-please-config.json, anchored inside the release PR branch + * so the squash merge lands it on main, checked fresh on main and present on the release PR. The config is read + * once here and refused when malformed, never repaired. + */ + +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { + assertFullHistory, + BOT_IDENTITY, + FULL_SHA, + git, + gitWithEnv, + isAncestor, + push, + tryGit, +} from "./git.js"; + +const CONFIG_FILE = "release-please-config.json"; + +/** A squash-merged release-please PR's subject on main. Anchored at both ends wherever release merges are recognized: + * a prefix match would let "chore(main): release pipeline documentation" impersonate one and park the boundary check. */ +const RELEASE_SUBJECT = /^chore\(main\): release (\d+\.\d+\.\d+)(?: \(#\d+\))?$/; + +/** checks.yml's head_ref conditions spell this by hand; test/workflows/checks-workflow.test.ts pins them to it. */ +export const RELEASE_PR_BRANCH_PREFIX = "release-please--"; +const RELEASE_PR_BRANCH = `${RELEASE_PR_BRANCH_PREFIX}branches--main`; + +/** release-please's config with the one key this pipeline reads and writes; the rest is release-please's. */ +type ReleaseConfig = Record & { "last-release-sha"?: string }; + +/** The config as the checkout holds it. The file is the user's and release-please's, so nothing here is repaired: a + * missing or malformed one, or a boundary that is no commit sha, is refused naming the file (an absent boundary is + * the pre-first-release state and passes). */ +function readReleaseConfig(cwd: string): ReleaseConfig { + let text: string; + try { + text = readFileSync(join(cwd, CONFIG_FILE), "utf8"); + } catch (error) { + if ((error as { code?: unknown }).code !== "ENOENT") { + throw error; + } + throw new Error( + `${CONFIG_FILE} is missing from ${cwd}; the release pipeline reads release-please's boundary (last-release-sha) from it. Restore it by PR.`, + ); + } + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch (error) { + throw new Error( + `${CONFIG_FILE} is not valid JSON (${error instanceof Error ? error.message : String(error)}); fix it by PR.`, + ); + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + const kind = Array.isArray(parsed) + ? "an array" + : parsed === null + ? "null" + : `a ${typeof parsed}`; + throw new Error(`${CONFIG_FILE} holds ${kind}, not a JSON object; fix it by PR.`); + } + const config = parsed as Record; + const boundary = config["last-release-sha"]; + if (boundary !== undefined && (typeof boundary !== "string" || !FULL_SHA.test(boundary))) { + throw new Error( + `last-release-sha in ${CONFIG_FILE} is ${JSON.stringify(boundary)}, not a 40-hex commit sha; fix it by PR.`, + ); + } + return config as ReleaseConfig; +} + +export interface AnchorOptions { + cwd: string; + /** Main's head this run tested; the merge parent the boundary records. */ + sourceSha: string; + attempts?: number; +} + +export interface AnchorResult { + changed: boolean; + reason: string; +} + +/** The boundary rides INSIDE the release PR as last-release-sha = main's current head, the future merge commit's + * PARENT (known now, unlike the merge sha), so the squash merge lands it on main with no push to main and no admin credential. + * the parent, not the merge -> the changelog walk then also includes the merge itself, a chore commit it hides anyway + * a stale parent -> ruled out by the managed release-freshness gate: a PR must contain main's tip to merge */ +export function anchorReleasePr(options: AnchorOptions): AnchorResult { + const { cwd, sourceSha, attempts = 3 } = options; + // If main moved, the newer push's run refreshes the branch and anchors the newer head. + const head = git(cwd, "ls-remote", "origin", "refs/heads/main").split("\t")[0]; + if (head !== sourceSha) { + return { changed: false, reason: `main moved to ${head ?? "?"}; the newer run anchors` }; + } + const branchRef = `refs/heads/${RELEASE_PR_BRANCH}`; + for (let attempt = 1; attempt <= attempts; attempt++) { + const observed = git(cwd, "ls-remote", "origin", branchRef).split("\t")[0] ?? ""; + if (observed === "") { + return { changed: false, reason: "no release PR branch to anchor" }; + } + // Detached, never a local branch: a retry after an overtaken push re-fetches, and git refuses to fetch into a checked-out ref. + git(cwd, "fetch", "--quiet", "--force", "origin", branchRef); + git(cwd, "checkout", "--quiet", "--force", "--detach", "FETCH_HEAD"); + // A branch still built from an older main must not be stamped with this head. A shallow history that cannot + // prove ancestry no-ops the same way; the refresh that follows anchors. + if (!isAncestor(cwd, sourceSha, "FETCH_HEAD")) { + return { + changed: false, + reason: "the release PR branch is not built on this head; its own refresh anchors", + }; + } + const config = readReleaseConfig(cwd); + if (config["last-release-sha"] === sourceSha) { + return { changed: false, reason: "already anchored" }; + } + config["last-release-sha"] = sourceSha; + writeFileSync(join(cwd, CONFIG_FILE), `${JSON.stringify(config, null, 2)}\n`); + git(cwd, "add", CONFIG_FILE); + gitWithEnv( + cwd, + BOT_IDENTITY, + "commit", + "-m", + "chore: anchor release-please to this release cycle's base", + "-m", + "last-release-sha records the merge parent so the squash merge itself lands the next cycle's boundary on main: version tags live on packaged commits that are not on main, so release-please cannot find the boundary by tag.", + ); + // A newer push's run may already have refreshed and anchored the branch before the fetch above: this run's commit + // then descends from that anchor, pushes cleanly, and regresses the boundary to the older head. + const headNow = git(cwd, "ls-remote", "origin", "refs/heads/main").split("\t")[0]; + if (headNow !== sourceSha) { + return { changed: false, reason: `main moved to ${headNow ?? "?"}; the newer run anchors` }; + } + const refused = push(cwd, "origin", `HEAD:${branchRef}`); + if (refused === null) { + return { changed: true, reason: `${RELEASE_PR_BRANCH}: anchored at ${sourceSha}` }; + } + if ((git(cwd, "ls-remote", "origin", branchRef).split("\t")[0] ?? "") === observed) { + throw refused; + } + // release-please force-pushed a refresh mid-anchor; reapply on it. + console.error( + `anchor push attempt ${attempt}/${attempts} overtaken by a refresh; re-reading the branch`, + ); + } + throw new Error( + `could not anchor ${RELEASE_PR_BRANCH} after ${attempts} attempts; something keeps rewriting the branch - rerun this job once the branch settles.`, + ); +} + +/** On main, last-release-sha must be the newest release merge or its parent; anything else means an anchor was lost + * or a release merge slipped past the pipeline, and every release PR refresh would compute from a stale boundary. + * A boundary NEWER than every recognized merge is a missed merge or a hand edit; either way it is never rolled back. */ +export function boundaryCheck(cwd: string): { boundary: string } { + assertFullHistory( + cwd, + "boundary-check", + "a release merge or the recorded boundary can sit beyond its depth, and no verdict on a truncated history holds.", + ); + const recorded = readReleaseConfig(cwd)["last-release-sha"]; + // The grep only narrows (it matches any message line with the prefix); RELEASE_SUBJECT decides, so + // "chore(main): release pipeline documentation" can neither become the boundary nor hide the real newest merge. + const listed = git(cwd, "log", "--grep", "^chore(main): release ", "--format=%H%x09%s"); + let latest = ""; + for (const line of listed.split("\n")) { + const [sha, subject] = line.split("\t"); + if (sha !== undefined && subject !== undefined && RELEASE_SUBJECT.test(subject)) { + latest = sha; + break; + } + } + if (latest === "") { + if (recorded === undefined) { + return { boundary: "none (no release merge on this history yet)" }; + } + const cause = isAncestor(cwd, recorded, "HEAD") + ? `this history holds it, so release-please's merge subject no longer matches ${RELEASE_SUBJECT} (investigate RELEASE_SUBJECT)` + : "it is not on this history at all (not main, or a boundary that never landed)"; + throw new Error( + `last-release-sha in ${CONFIG_FILE} is ${JSON.stringify(recorded)}, but no release merge is reachable from HEAD: ${cause}. Refusing to read a recorded boundary as a pre-first-release history.`, + ); + } + const parent = tryGit(cwd, "rev-parse", `${latest}^`); + if (recorded !== undefined) { + if (recorded === latest || (parent !== null && recorded === parent)) { + return { boundary: recorded }; + } + if (isAncestor(cwd, latest, recorded) && isAncestor(cwd, recorded, "HEAD")) { + throw new Error( + `last-release-sha in ${CONFIG_FILE} is ${JSON.stringify(recorded)}, NEWER than ` + + `${latest}, the newest release merge whose subject matches ${RELEASE_SUBJECT}: either a ` + + `newer release merge's subject stopped matching (investigate RELEASE_SUBJECT) or the ` + + `boundary was edited by hand. It must not be rolled back to ${parent ?? latest}.`, + ); + } + } + throw new Error( + `last-release-sha in ${CONFIG_FILE} is ${JSON.stringify(recorded)}, but the newest release ` + + `merge on main is ${latest} (parent ${parent}); release PR refreshes would be computed ` + + `from a stale boundary. Fix by PR: set last-release-sha to ${parent ?? latest}.`, + ); +} + +/** Run on the release PR's own checkout. The managed release-freshness gate proves the PR contains main's tip, not + * that the anchor commit survived a release-please force-push, so requiring last-release-sha to equal origin's + * CURRENT main tip makes an unanchored release PR unmergeable instead of parking the pipeline after its merge. */ +export function anchorCheck(cwd: string): { boundary: string } { + const recorded = readReleaseConfig(cwd)["last-release-sha"]; + const tip = git(cwd, "ls-remote", "origin", "refs/heads/main").split("\t")[0] ?? ""; + if (recorded !== tip) { + throw new Error( + `last-release-sha in ${CONFIG_FILE} is ${JSON.stringify(recorded)}, but main's tip is ` + + `${tip}; the anchor is missing or stale, so merging would land a wrong boundary. ` + + `The release pipeline's update-release-pr hook re-applies it on every release-PR refresh ` + + `- wait for (or dispatch) the next green main run, then close/reopen the PR so its ` + + `checks run on the anchored head (the anchor is pushed with the default token, which ` + + `triggers no new checks).`, + ); + } + return { boundary: recorded }; +} diff --git a/.github/scripts/release-pipeline/draft.ts b/.github/scripts/release-pipeline/draft.ts new file mode 100644 index 00000000..f5f7779c --- /dev/null +++ b/.github/scripts/release-pipeline/draft.ts @@ -0,0 +1,45 @@ +/** + * The release hook's reads of the draft release through gh: the merge commit it targets, and the assets it carries + * before anything publishes. + */ + +import { capture } from "../lib/workflow-step.js"; +import { FULL_SHA } from "./git.js"; + +/** The step output the release hook's later steps and jobs read the resolved merge commit from. */ +export const SOURCE_OUTPUT = "sha"; +/** What the package job uploads to the draft; the managed publish stage attaches attestation.json after the check. */ +export const RELEASE_ASSETS = ["index.js", "settings.schema.json"]; + +/** The draft's target commitish, where release-please records the merge commit. The release's identity is read from + * the draft and never from the run's own sha, so a newer push's run can recover an older merge's pending release; + * anything but a full sha (a branch name on a hand-made draft) is refused before a checkout or a tag could name it. */ +export function resolveSource(tag: string): { sha: string } | { refusal: string } { + const target = capture([ + "gh", + "release", + "view", + tag, + "--json", + "targetCommitish", + "--jq", + ".targetCommitish", + ]); + return FULL_SHA.test(target) + ? { sha: target } + : { + refusal: `draft ${tag}'s target commitish is '${target}', not a commit SHA; refusing to package an unidentified source.`, + }; +} + +/** The draft's asset names beside attestation.json, sorted. Publishing freezes the asset list, so an incomplete one + * must stop while the release is still a draft (v2.0.0 shipped assetless exactly this way). */ +export function releaseAssets(tag: string): string[] { + const view = JSON.parse(capture(["gh", "release", "view", tag, "--json", "assets"])) as { + assets: { name: string }[]; + }; + return view.assets + .map((asset) => asset.name) + .filter((name) => name !== "attestation.json") + .sort(); +} diff --git a/.github/scripts/release-pipeline/git.ts b/.github/scripts/release-pipeline/git.ts new file mode 100644 index 00000000..0c8f67d4 --- /dev/null +++ b/.github/scripts/release-pipeline/git.ts @@ -0,0 +1,136 @@ +/** + * The git the release pipeline runs: one spawn per helper, each with the failure shape its callers judge (a thrown + * refusal, a plain "no", or null), the identity the pipeline's own commits carry, and the reads every section + * shares: ancestry, a commit's position on main, the manifest's version. Node builtins only: bun runs this before + * `bun install`. + */ + +import { execFileSync } from "node:child_process"; + +const MANIFEST_FILE = ".release-please-manifest.json"; + +export const FULL_SHA = /^[0-9a-f]{40}$/; + +export function git(cwd: string, ...args: string[]): string { + return gitWithEnv(cwd, {}, ...args); +} + +export function gitWithEnv(cwd: string, env: Record, ...args: string[]): string { + try { + return execFileSync("git", args, { + cwd, + encoding: "utf8", + env: { ...process.env, ...env }, + }).trim(); + } catch (error) { + throw gitFailure(args, error); + } +} + +function gitFailure(args: string[], error: unknown): Error { + const stderr = (error as { stderr?: unknown }).stderr; + const detail = typeof stderr === "string" && stderr.trim() !== "" ? `: ${stderr.trim()}` : ""; + return new Error(`git ${args.join(" ")} failed${detail}`); +} + +/** git's stdout, or null when it exited 1 (a "no" from --verify, --is-ancestor, and the like); any other failure + * is thrown, so a repository git cannot read never passes for one without the object. */ +export function gitOrNo(cwd: string, ...args: string[]): string | null { + try { + return execFileSync("git", args, { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); + } catch (error) { + if ((error as { status?: unknown }).status === 1) { + return null; + } + throw gitFailure(args, error); + } +} + +/** git's stdout, or null on any failure: for reads whose absence git reports with exit 128 (a missing path). */ +export function tryGit(cwd: string, ...args: string[]): string | null { + try { + return execFileSync("git", args, { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } catch { + return null; + } +} + +/** A push as git ran it. A refusal is never read from git's words: the caller re-reads origin, and a ref that + * moved since it was observed is a lost compare-and-set to retry, an unmoved one a refusal to throw. */ +export function push(cwd: string, ...pushArgs: string[]): Error | null { + try { + execFileSync("git", ["push", ...pushArgs], { cwd, encoding: "utf8" }); + return null; + } catch (error) { + return gitFailure(["push", ...pushArgs], error); + } +} + +/** The identity the pipeline's own commits carry, passed per invocation: written into a checkout's config it would + * outlive the run and stamp every later commit made from that repository, or any worktree sharing it. */ +export const BOT_IDENTITY = { + GIT_AUTHOR_NAME: "settings-as-code-release", + GIT_AUTHOR_EMAIL: "settings-as-code-release@users.noreply.github.com", + GIT_COMMITTER_NAME: "settings-as-code-release", + GIT_COMMITTER_EMAIL: "settings-as-code-release@users.noreply.github.com", +}; + +/** Verdicts on a truncated history do not hold; the jobs check out with fetch-depth 0. */ +export function assertFullHistory(cwd: string, what: string, consequence: string): void { + if (git(cwd, "rev-parse", "--is-shallow-repository") === "true") { + throw new Error( + `${what} needs the full history (fetch-depth: 0) and this checkout is shallow: ${consequence}`, + ); + } +} + +/** merge-base fails outright on a sha this checkout lacks, so unknown ones are screened into a plain "no" first. */ +export function isAncestor(cwd: string, ancestor: string, descendant: string): boolean { + return ( + [ancestor, descendant].every((sha) => resolveCommit(cwd, sha) !== null) && + gitOrNo(cwd, "merge-base", "--is-ancestor", ancestor, descendant) !== null + ); +} + +/** The commit a name resolves to, or null for none (a sha the checkout lacks, or a short one naming several objects). */ +export function resolveCommit(cwd: string, name: string): string | null { + return gitOrNo(cwd, "rev-parse", "--verify", "--quiet", `${name}^{commit}`); +} + +/** The manifest's version at a commit: what release-please last released, or is about to. */ +export function manifestVersionAt(cwd: string, treeish: string): string { + const manifest = JSON.parse(git(cwd, "show", `${treeish}:${MANIFEST_FILE}`)) as Record< + string, + unknown + >; + return String(manifest["."]); +} + +export interface MainPosition { + /** Commits reachable from it along first parents: one more per merge to main, whatever a merged PR's branch held. */ + count: number; + /** Its committer date in UTC, YYYYMMDD. */ + date: string; +} + +/** Refused on a shallow checkout: it would count to its boundary and mint a truncated count, so the version would + * sort below ones minted from the full history for older commits. */ +export function mainPosition(cwd: string, sourceSha: string): MainPosition { + if (git(cwd, "rev-parse", "--is-shallow-repository") === "true") { + throw new Error( + "the pre-release version needs the full history (fetch-depth: 0) and this checkout is shallow: the count of commits under the source would stop at the shallow boundary.", + ); + } + const count = Number(git(cwd, "rev-list", "--count", "--first-parent", sourceSha)); + const committed = Number(git(cwd, "show", "-s", "--format=%ct", sourceSha)); + const date = new Date(committed * 1000).toISOString().slice(0, 10).replaceAll("-", ""); + return { count, date }; +} diff --git a/.github/scripts/release-pipeline/npm.ts b/.github/scripts/release-pipeline/npm.ts new file mode 100644 index 00000000..06600238 --- /dev/null +++ b/.github/scripts/release-pipeline/npm.ts @@ -0,0 +1,514 @@ +/** + * The npm side of the pipeline: the pre-release version a main commit mints, the publish verdicts read from the + * registry's record, the publish itself with the confirmation that holds the lane, and the npm floor trusted + * publishing needs. + */ + +import { execFileSync, type SpawnSyncReturns, spawnSync } from "node:child_process"; +import { setTimeout as sleep } from "node:timers/promises"; +import { + FULL_SHA, + git, + gitOrNo, + type MainPosition, + mainPosition, + manifestVersionAt, + resolveCommit, +} from "./git.js"; + +/** + * The npm version a main commit's library build publishes under the `next` dist-tag: the manifest version's + * next patch, then `main`, the source's position on main, and its short sha. That sorts above the last release, + * below the next one whatever its bump, and along main: npm compares the count first, and it grows by one with + * each merge (the date is for the reader; two merges on one day share it). The sha carries a `g` prefix, as git + * describe writes it: npm reads an all-digit identifier as a number and drops its leading zero, so a bare sha7 + * such as 0123456 would be rewritten to 123456 and name no commit. + */ +export function prereleaseVersion( + manifestVersion: string, + position: MainPosition, + sourceSha: string, +): string { + const version = manifestVersion.match(/^(\d+)\.(\d+)\.(\d+)$/); + if (!version) { + throw new Error( + `the manifest version ${JSON.stringify(manifestVersion)} is not X.Y.Z; refusing to derive a pre-release version from it.`, + ); + } + // The first commit counts 1, so a count of 0 names no commit; a version carrying one was never minted here. + if (!Number.isInteger(position.count) || position.count < 1) { + throw new Error( + `the commit count ${JSON.stringify(position.count)} is not a positive integer; refusing to mint a pre-release version from it.`, + ); + } + if (!FULL_SHA.test(sourceSha)) { + throw new Error( + `the source ${JSON.stringify(sourceSha)} is not a full commit sha; refusing to mint a pre-release version from it.`, + ); + } + const [, major, minor, patch] = version; + return `${major}.${minor}.${Number(patch) + 1}-main.${position.count}.${position.date}.g${sourceSha.slice(0, 7)}`; +} + +export interface PrereleaseVersionOptions { + cwd: string; + /** The commit whose build is published; the checkout must be at it. */ + sourceSha: string; +} + +/** The checkout must be at the source whose build is published: the manifest and package.json are read there. */ +function assertCheckoutAt(cwd: string, sourceSha: string): void { + const head = git(cwd, "rev-parse", "HEAD"); + if (head !== sourceSha) { + throw new Error( + `the checkout is at ${head}, not the source commit ${sourceSha} whose build is published.`, + ); + } +} + +export function prereleaseVersionOf(options: PrereleaseVersionOptions): string { + const { cwd, sourceSha } = options; + assertCheckoutAt(cwd, sourceSha); + return prereleaseVersion( + manifestVersionAt(cwd, sourceSha), + mainPosition(cwd, sourceSha), + sourceSha, + ); +} + +/** A version this pipeline mints, parsed: a release, or a pre-release carrying its source's short sha. The + * identifiers between `main` and the sha are not read back: a published pre-release is placed by its source's + * ancestry, never by comparing them. */ +interface MintedVersion { + release: [number, number, number]; + sha7: string | null; +} + +function mintedVersion(version: string): MintedVersion | null { + const match = version.match( + /^(\d+)\.(\d+)\.(\d+)(?:-main\.(?:(?:0|[1-9]\d*)\.)+g([0-9a-f]{7}))?$/, + ); + if (!match) { + return null; + } + const [, major = "", minor = "", patch = "", sha7 = null] = match; + return { release: [Number(major), Number(minor), Number(patch)], sha7 }; +} + +/** The source sha7 a pre-release this pipeline minted carries; null for a release or a version minted elsewhere. */ +const sha7Of = (version: string): string | null => mintedVersion(version)?.sha7 ?? null; + +/** A version a dist-tag names must be one this pipeline mints; anything else stops the run rather than being guessed at. */ +function parseMinted(version: string): MintedVersion { + const minted = mintedVersion(version); + if (minted === null) { + throw new Error( + `${JSON.stringify(version)} is not a version this pipeline mints (X.Y.Z or X.Y.Z-main..g); refusing to order it.`, + ); + } + return minted; +} + +/** Whether release `a` sorts above `b`: by major, minor, patch. */ +function newerRelease(a: [number, number, number], b: [number, number, number]): boolean { + return a[0] !== b[0] ? a[0] > b[0] : a[1] !== b[1] ? a[1] > b[1] : a[2] > b[2]; +} + +/** What the registry holds for the package: every published version, and where each dist-tag points. */ +export interface Packument { + versions: Record; + "dist-tags": Record; +} + +export type PublishVerdict = + | { publish: true; version: string } + | { publish: false; version: string; reason: string }; +/** The next channel's guard also carries, one line each, the published pre-releases it set aside: a source the checkout cannot place. */ +export type NextVerdict = PublishVerdict & { notices: string[] }; + +/** A published pre-release whose source is a strict descendant of this run's: newer on main, whatever its numbers say. */ +interface Descendant { + version: string; + /** The full sha the version's sha7 resolved to. */ + sha: string; +} + +/** + * Where a pre-release's source stands to this run's, once its sha resolves: the run's own commit, an ancestor, a strict + * descendant, or one on neither side of the source (off its line of main). + */ +type Placement = "own" | "ancestor" | "descendant" | "unrelated"; +/** A source placed, or one the checkout lacks (a sha it never fetched, or a short one naming several objects). */ +type Placed = { sha: string; placement: Placement } | { sha: null; placement: "unresolved" }; + +function placeSource(cwd: string, sourceSha: string, sha7: string): Placed { + const sha = resolveCommit(cwd, sha7); + if (sha === null) { + return { sha, placement: "unresolved" }; + } + const descends = (ancestor: string, descendant: string): boolean => + gitOrNo(cwd, "merge-base", "--is-ancestor", ancestor, descendant) !== null; + if (sha === sourceSha) { + return { sha, placement: "own" }; + } + if (descends(sourceSha, sha)) { + return { sha, placement: "descendant" }; + } + return { sha, placement: descends(sha, sourceSha) ? "ancestor" : "unrelated" }; +} + +/** + * The published pre-releases placed against this run's source by ancestry: the descendants, the one furthest along + * main, and a notice for each the checkout cannot place. The version `next` names is placed with them, whether or not + * the record lists it. + */ +function placePublished( + cwd: string, + sourceSha: string, + packument: Packument, +): { descendants: Descendant[]; newest: Descendant | null; notices: string[] } { + const descendants: Descendant[] = []; + const notices: string[] = []; + let newest: Descendant | null = null; + const nextVersion = packument["dist-tags"].next; + const versions = new Set(Object.keys(packument.versions)); + if (nextVersion !== undefined) { + versions.add(nextVersion); + } + for (const version of versions) { + const sha7 = sha7Of(version); + if (sha7 === null) { + continue; + } + const placed = placeSource(cwd, sourceSha, sha7); + if (placed.sha === null) { + notices.push(`${version} names ${sha7}, which is no commit in this checkout; ignored`); + } else if (placed.placement === "descendant") { + descendants.push({ version, sha: placed.sha }); + if ( + newest === null || + gitOrNo(cwd, "merge-base", "--is-ancestor", newest.sha, placed.sha) !== null + ) { + newest = { version, sha: placed.sha }; + } + } else if (placed.placement === "unrelated") { + notices.push( + `${version} names ${sha7}, which is neither an ancestor nor a descendant of ${sourceSha.slice(0, 7)} on main; ignored`, + ); + } + } + return { descendants, newest, notices }; +} + +/** + * The pre-publish guard on npm state, not a decision about whether to publish (the release-PR refresh that runs the + * publish job made that): every published pre-release is placed by its source's ancestry, so a rerun of a run that + * already published, or a stale retry once a newer commit's pre-release is on the registry, publishes nothing, + * whatever order the runs finished in (`npm publish --tag next` moves next to whatever it publishes). A source the + * checkout cannot place is set aside with a notice. Null is a package the registry has never seen: the first publish goes. + */ +export function nextPublishVerdict( + cwd: string, + sourceSha: string, + version: string, + packument: Packument | null, +): NextVerdict { + if (packument === null) { + return { publish: true, version, notices: [] }; + } + // A dist-tag names a version the registry holds, so next naming this one is the same rerun as the record listing it. + if (version in packument.versions || packument["dist-tags"].next === version) { + return { + publish: false, + version, + reason: `${version} is already on the registry`, + notices: [], + }; + } + const { newest, notices } = placePublished(cwd, sourceSha, packument); + if (newest !== null) { + return { + publish: false, + version, + reason: `the registry already holds ${newest.version}, whose source ${newest.sha.slice(0, 7)} is a descendant of ${sourceSha.slice(0, 7)} on main, so this stale run publishes nothing (npm publish --tag next would move next back)`, + notices, + }; + } + return { publish: true, version, notices }; +} + +/** + * Only `latest` is consulted: a plain `npm publish` moves latest and leaves + * next alone. A release sorts above the pre-releases published before its + * merge; the next release PR's first refresh publishes the one above it. + */ +export function stablePublishVerdict(version: string, packument: Packument | null): PublishVerdict { + if (packument === null) { + return { publish: true, version }; + } + if (version in packument.versions) { + return { + publish: false, + version, + reason: `${version} is already on the registry`, + }; + } + const latest = packument["dist-tags"].latest; + // Until the first stable release, latest names whatever the first publish was (the 0.0.0 placeholder, or a + // pre-release): a packument always carries that key (npm/registry REGISTRY-API.md, "dist-tags: an object with at + // least one key, latest"), whatever --tag asked for. A release must take latest over, so only a newer RELEASE holds one back. + const held = latest === undefined ? null : parseMinted(latest); + if (held?.sha7 === null && newerRelease(held.release, parseMinted(version).release)) { + return { + publish: false, + version, + reason: `the registry's latest is ${latest}, newer than ${version}, so this rerun of an older release publishes nothing (npm publish would move latest back)`, + }; + } + return { publish: true, version }; +} + +/** The registry's record of `name`, or null while it has never been published; any other answer than 200 or 404 throws. + * The URL carries a fresh query string on every read: the registry's CDN serves a packument from cache for up to + * 300 s (cache-control: public, max-age=300, and a request's no-cache is ignored), and a cache key that no earlier + * request had misses it, so the record comes from the origin, a publish just landed included. */ +async function fetchPackument(registry: string, name: string): Promise { + const url = `${registry.replace(/\/$/, "")}/${name.replaceAll("/", "%2F")}`; + const response = await fetch( + `${url}?fresh=${Date.now()}-${Math.random().toString(36).slice(2)}`, + { + headers: { accept: "application/json" }, + }, + ); + if (response.status === 404) { + return null; + } + if (!response.ok) { + throw new Error( + `the registry answered ${response.status} for ${name} (${url}); refusing to publish without knowing what it holds.`, + ); + } + const body: unknown = await response.json(); + const record = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + if (!record(body) || !record(body.versions) || !record(body["dist-tags"])) { + throw new Error( + `the registry's record of ${name} (${url}) is not a packument (an object with versions and dist-tags records); refusing to publish without knowing what it holds.`, + ); + } + const tags = body["dist-tags"]; + for (const [tag, value] of Object.entries(tags)) { + if (typeof value !== "string") { + throw new Error( + `the registry's record of ${name} names dist-tag ${tag} as ${JSON.stringify(value)}, not a version; refusing to publish without knowing what it holds.`, + ); + } + } + return { versions: body.versions, "dist-tags": tags as Record }; +} + +function packageFieldAt(cwd: string, treeish: string, field: "name" | "version"): string { + const pkg = JSON.parse(git(cwd, "show", `${treeish}:package.json`)) as Record; + return String(pkg[field]); +} + +/** The version npm publishes for a release: package.json's, which must be the + * tag's (the hook held the manifest to the tag; package.json is a separate file + * release-please rewrites, and it is the one npm reads). */ +function releaseVersionAt(cwd: string, treeish: string, tag: string): string { + const version = packageFieldAt(cwd, treeish, "version"); + if (`v${version}` !== tag) { + throw new Error( + `package.json at the release source is version ${version}, but the release tag is ${tag}; refusing to publish a version this source did not release.`, + ); + } + return version; +} + +export type NpmVerdictOptions = { + cwd: string; + /** The commit whose build is published; the checkout must be at it. */ + sourceSha: string; + /** The registry's base URL, where the package's record is read. */ + registry: string; +} & ({ channel: "next" } | { channel: "stable"; tag: string }); + +export async function npmVerdict( + options: NpmVerdictOptions, +): Promise { + const { cwd, sourceSha, registry } = options; + let version: string; + if (options.channel === "next") { + version = prereleaseVersionOf({ cwd, sourceSha }); + } else { + assertCheckoutAt(cwd, sourceSha); + version = releaseVersionAt(cwd, "HEAD", options.tag); + } + const packument = await fetchPackument(registry, packageFieldAt(cwd, "HEAD", "name")); + return options.channel === "next" + ? nextPublishVerdict(cwd, sourceSha, version, packument) + : stablePublishVerdict(version, packument); +} + +export type ConfirmVerdict = + /** The registry's record shows the version this run published, and next names no older pre-release than it should. */ + | { outcome: "settled"; version: string; reads: number } + /** The record still lacks the version after every read: a following run may read one without it. */ + | { outcome: "unsettled"; version: string; reason: string } + /** The record shows the version and a pre-release of a descendant, and next names neither that nor a later one. */ + | { outcome: "behind"; version: string; reason: string }; + +export interface NpmConfirmOptions { + cwd: string; + /** The commit whose build this run published; the checkout must be at it. */ + sourceSha: string; + /** The registry's base URL, where the package's record is read. */ + registry: string; + /** How many times the record is read while it lacks the version, and the pause between reads. */ + attempts: number; + delayMs: number; +} + +/** + * After `npm publish --tag next`: the record is read until it shows the version, so the job holds the npm-publish + * lane until the next holder's verdict can see this publish (npm makes a publish readable asynchronously; a verdict + * read in that gap would move next back). Once it shows, and a descendant's pre-release is on the record, next + * must name a descendant's, or this stale run moved it back. A drift is reported, not repaired: trusted publishing (OIDC) + * authenticates `npm publish` alone, not `npm dist-tag add` (npm/cli#8547); the next release-PR refresh publishes and + * moves next forward, and a rerun of the reporting run publishes nothing and passes. + */ +export async function npmConfirm(options: NpmConfirmOptions): Promise { + const { cwd, sourceSha, registry, attempts, delayMs } = options; + if (!Number.isInteger(attempts) || attempts < 1) { + throw new Error(`the read attempts must be a positive integer, not ${attempts}`); + } + const version = prereleaseVersionOf({ cwd, sourceSha }); + const name = packageFieldAt(cwd, "HEAD", "name"); + for (let read = 1; ; read++) { + // A read that fails counts as a read that did not show the version: the lane is held through the budget + // either way, and the last failure is the one reported. + let packument: Packument | null; + try { + packument = await fetchPackument(registry, name); + } catch (error) { + if (read === attempts) { + throw error; + } + await sleep(delayMs); + continue; + } + if (packument !== null && version in packument.versions) { + const { descendants, newest } = placePublished(cwd, sourceSha, packument); + const next = packument["dist-tags"].next; + if (newest !== null && !descendants.some((descendant) => descendant.version === next)) { + return { + outcome: "behind", + version, + reason: + `the registry's next is ${next ?? "unset"} while it holds ${newest.version}, whose source ${newest.sha.slice(0, 7)} ` + + `is a descendant of ${sourceSha.slice(0, 7)} on main; this stale run moved next back, and the next release-PR ` + + `refresh moves it forward (npm dist-tag add ${name}@${newest.version} next repairs it by hand)`, + }; + } + return { outcome: "settled", version, reads: read }; + } + if (read === attempts) { + return { + outcome: "unsettled", + version, + reason: `the registry's record still lacks ${version} after ${attempts} reads over ${Math.round(((attempts - 1) * delayMs) / 1000)} s; a run judged before it shows may move next back, and the release-PR refresh after it moves next forward`, + }; + } + await sleep(delayMs); + } +} + +function endedHow(result: SpawnSyncReturns): string { + const how = result.status === null ? `died of ${result.signal}` : `exited ${result.status}`; + return result.error === undefined ? how : `${how} (${result.error.message})`; +} + +function npm(cwd: string, env: Record, ...args: string[]): void { + const result = spawnSync("npm", args, { cwd, env: { ...process.env, ...env }, stdio: "inherit" }); + if (result.status !== 0) { + throw new Error(`npm ${args.join(" ")} ${endedHow(result)}; see npm's output above.`); + } +} + +export type NpmPublishOptions = { + cwd: string; + /** The commit whose build is published; the checkout must be at it. */ + sourceSha: string; + /** The registry's base URL, where the package's record is read. */ + registry: string; +} & ( + | { channel: "next"; confirm: Pick } + | { channel: "stable"; tag: string } +); + +export type NpmPublishResult = + | { published: false; version: string; reason: string } + | { published: true; channel: "stable"; version: string } + | { published: true; channel: "next"; version: string; confirmed: ConfirmVerdict }; + +/** + * The publish step as one call: the channel's verdict, the publish it allows, and for next the confirmation that + * holds the lane until the record shows the version. The verdict's notices reach `notice` before npm runs, so a + * publish that fails still leaves them on the log. scripts.prepare is dropped from the published manifest: it + * installs lefthook, a devDependency the tarball does not carry, and npm blocks install scripts from a + * provenance-attested package anyway. GITHUB_SHA on the publish is the commit npm's provenance names. + */ +export async function npmPublish( + options: NpmPublishOptions, + notice: (line: string) => void, +): Promise { + const { cwd, sourceSha, registry } = options; + const verdict = await npmVerdict(options); + for (const line of "notices" in verdict ? verdict.notices : []) { + notice(line); + } + if (!verdict.publish) { + return { published: false, version: verdict.version, reason: verdict.reason }; + } + const { version } = verdict; + if (options.channel === "stable") { + npm(cwd, {}, "pkg", "delete", "scripts.prepare"); + npm(cwd, { GITHUB_SHA: sourceSha }, "publish"); + return { published: true, channel: "stable", version }; + } + npm(cwd, {}, "version", version, "--no-git-tag-version"); + npm(cwd, {}, "pkg", "delete", "scripts.prepare"); + npm(cwd, { GITHUB_SHA: sourceSha }, "publish", "--tag", "next"); + const confirmed = await npmConfirm({ cwd, sourceSha, registry, ...options.confirm }); + return { published: true, channel: "next", version, confirmed }; +} + +/** Trusted publishing (and its provenance) exists from npm 11.5.1 on. */ +export const NPM_FLOOR = "11.5.1"; + +/** Bun's own semver, so a two-digit minor orders numerically and a prerelease of the floor sits below it; a + * string that is no version at all (nothing npm --version prints) counts as below it and reaches the refusal. */ +function belowFloor(version: string, floor: string): boolean { + return !Bun.semver.satisfies(version, `>=${floor}`); +} + +/** An npm at or above `floor` on PATH: an older bundled npm is upgraded once (`npm install -g npm@latest`, on the + * step's log), then held to the floor; `atFloor` false is the version still below it after that. */ +export function npmFloor(floor = NPM_FLOOR): { version: string; atFloor: boolean } { + const read = (): string => + execFileSync("npm", ["--version"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "inherit"], + }).trim(); + let version = read(); + if (belowFloor(version, floor)) { + const install = spawnSync("npm", ["install", "-g", "npm@latest"], { stdio: "inherit" }); + if (install.status !== 0) { + throw new Error( + `npm ${version} is below ${floor} and npm install -g npm@latest ${endedHow(install)}; the publish needs npm ${floor} or newer.`, + ); + } + version = read(); + } + return { version, atFloor: !belowFloor(version, floor) }; +} diff --git a/.github/scripts/release-pipeline/tags.ts b/.github/scripts/release-pipeline/tags.ts new file mode 100644 index 00000000..7afe9d0d --- /dev/null +++ b/.github/scripts/release-pipeline/tags.ts @@ -0,0 +1,522 @@ +/** + * The packaged commits and the refs that name them: a main commit's package under its build tag, the version tag + * minted once on the release's package, and the pointers (latest, vX) that move forward along main alone. Every + * push is a create-once or a lease, judged by re-reading origin, never by git's words. + */ + +import { execFileSync } from "node:child_process"; +import { existsSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { + assertFullHistory, + BOT_IDENTITY, + git, + gitWithEnv, + isAncestor, + mainPosition, + manifestVersionAt, + push, + tryGit, +} from "./git.js"; + +const MANIFEST = "package.json"; +/** What a packaged commit carries beyond its source (a directory entry stages every file under it). */ +const PACKAGED_PATHS = ["lib/index.js", "lib/settings.schema.json", "lib/pkg/"] as const; +/** What every packaged commit must carry as non-empty regular files. */ +const REQUIRED_BUILT_FILES = [ + "lib/index.js", + "lib/settings.schema.json", + "lib/pkg/index.js", +] as const; +const PACKAGED = "lib/index.js, lib/settings.schema.json, and lib/pkg/"; +const LATEST_REF = "refs/tags/latest"; +const BUILD_TAG_PREFIX = "refs/tags/build/"; +const BUILD_TAG = /^refs\/tags\/build\/([1-9]\d*)\.[0-9a-f]{7}$/; +/** How many build tags stay; releases and latest keep their own commits. */ +export const KEPT_BUILD_TAGS = 10; +/** Never --depth: a depth-limited fetch marks the commit it lands on shallow and cuts the parent link every package + * check reads. Blobs arrive on demand, or with the commit on a server without filter support. */ +const TAG_FETCH = ["--filter=blob:none"]; + +const PUSH_ATTEMPTS = 3; + +/** Every ref derivation passes through this parse, so a malformed tag stops the pipeline instead of minting "v2" + * from "v2.1-rc.0". */ +function releaseMajor(tag: string): string { + const match = tag.match(/^v(\d+)\.\d+\.\d+$/); + if (!match) { + throw new Error( + `tag ${JSON.stringify(tag)} is not a vX.Y.Z release tag; refusing to derive version refs from it.`, + ); + } + return `v${match[1]}`; +} + +/** The required build files as non-empty REGULAR files: a symlink or a gitlink at that path has a size too, and no build. */ +function assertCarries(cwd: string, treeish: string, what: string, remedy: string): void { + for (const file of REQUIRED_BUILT_FILES) { + // ls-tree answers a missing path with empty output and exit 0; a failing call must propagate. + const entry = git(cwd, "ls-tree", "-l", treeish, "--", file); + const [mode = "", , , size] = entry.split(/\s+/); + if ((mode !== "100644" && mode !== "100755") || Number(size) === 0) { + throw new Error( + `${what} does not carry a non-empty regular-file ${file} (${entry === "" ? "no entry" : `entry ${entry.split("\t")[0]}`}); refusing to point a consumable ref at an unpackaged commit; ${remedy}`, + ); + } + } +} + +/** The scripts npm's git fetcher (pacote) takes as a signal to run `npm install --include=dev` and the prepare + * lifecycle in a `github:` dependency's checkout; the packaged commit ships the build already. */ +const PREPARATION_SCRIPTS = ["prepare", "prepack", "build", "preinstall", "install", "postinstall"]; + +/** sourceSha's tree plus `addBuild`'s entries, with package.json's preparation scripts removed, assembled in a + * private index so nothing else can enter and the checkout's own index stays untouched. */ +function packagedTreeOf( + cwd: string, + sourceSha: string, + addBuild: (env: Record) => void, +): string { + const indexFile = join(git(cwd, "rev-parse", "--absolute-git-dir"), "release-pipeline.index"); + const env = { GIT_INDEX_FILE: indexFile }; + try { + gitWithEnv(cwd, env, "read-tree", sourceSha); + const text = tryGit(cwd, "show", `${sourceSha}:${MANIFEST}`); + const pkg = text === null ? null : (JSON.parse(text) as { scripts?: Record }); + if (pkg?.scripts && PREPARATION_SCRIPTS.some((name) => name in (pkg.scripts ?? {}))) { + for (const name of PREPARATION_SCRIPTS) { + delete pkg.scripts[name]; + } + const blob = execFileSync("git", ["hash-object", "-w", "--stdin"], { + cwd, + input: `${JSON.stringify(pkg, null, 2)}\n`, + encoding: "utf8", + }).trim(); + gitWithEnv(cwd, env, "update-index", "--add", "--cacheinfo", `100644,${blob},${MANIFEST}`); + } + addBuild(env); + return gitWithEnv(cwd, env, "write-tree"); + } finally { + rmSync(indexFile, { force: true }); + } +} + +/** + * `packaged` is a package of `sourceSha` as this pipeline mints them: its child (the parent edge is the one record + * of the source), carrying the source's tree plus the build outputs and the stripped manifest, nothing else, every + * required build file a regular file. With `built`, the tree this checkout's fresh build produced, the packaged tree + * must be that tree byte for byte (a tree id names every byte and mode under it). + */ +function assertPackageOf( + cwd: string, + packaged: string, + sourceSha: string, + what: string, + remedy: string, + built?: string, +): void { + const parents = git(cwd, "log", "-1", "--format=%P", packaged).split(" ").filter(Boolean); + if (parents.length !== 1 || parents[0] !== sourceSha) { + throw new Error( + `${what} has ${parents.length === 0 ? "no parent" : `parent ${parents.join(", ")}`}, so it is no package of ${sourceSha} (a packaged commit is that commit's child); ${remedy}`, + ); + } + const actual = git(cwd, "rev-parse", `${packaged}^{tree}`); + if (built !== undefined && actual === built) { + return; + } + // Rebuilt from the source with the packaged commit's own build entries: tree identity, so an empty subtree a + // path diff cannot list still differs. + const entries = git(cwd, "ls-tree", "-r", packaged, "--", ...PACKAGED_PATHS) + .split("\n") + .filter(Boolean); + const expected = packagedTreeOf(cwd, sourceSha, (env) => { + for (const line of entries) { + const [meta = "", path = ""] = line.split("\t"); + const [mode = "", , blob = ""] = meta.split(/\s+/); + gitWithEnv(cwd, env, "update-index", "--add", "--cacheinfo", `${mode},${blob},${path}`); + } + }); + if (actual !== expected) { + throw new Error( + `${what} is not ${sourceSha} plus ${PACKAGED}, minus ${MANIFEST}'s preparation scripts, alone: its tree is ${actual}, the rebuilt one is ${expected} (git diff ${expected} ${packaged} lists what deviates); ${remedy}`, + ); + } + assertCarries(cwd, packaged, what, remedy); + if (built !== undefined) { + throw new Error( + `${what} packages ${sourceSha}, but its tree ${actual} is not the tree ${built} this checkout's build packages, ` + + `so the two differ under ${PACKAGED}: either the commit was not built from this source or the build is not ` + + `reproducible. Diff the two trees by hand; ${remedy}`, + ); + } +} + +/** The checkout must BE sourceSha with a clean worktree: that is what makes the build outputs a build of that source + * rather than of a by-hand edit. They are gitignored, so they never show as pending. */ +function builtTree(cwd: string, sourceSha: string): string { + const head = git(cwd, "rev-parse", "HEAD"); + if (head !== sourceSha) { + throw new Error(`the checkout is at ${head}, not the source commit ${sourceSha} to package.`); + } + const dirty = git(cwd, "status", "--porcelain").split("\n").filter(Boolean); + if (dirty.length > 0) { + throw new Error( + `the worktree has pending changes beyond ${PACKAGED} (${dirty.join("; ")}); the build must be a build of ${sourceSha} alone - commit, stash, or clean them first.`, + ); + } + // -f: main gitignores the build outputs; a path the build left out is reported by the carry check, not by git add. + const built = PACKAGED_PATHS.filter((path) => existsSync(join(cwd, path))); + const tree = packagedTreeOf(cwd, sourceSha, (env) => { + if (built.length > 0) { + gitWithEnv(cwd, env, "add", "-f", "--", ...built); + } + }); + assertCarries(cwd, tree, `the build of ${sourceSha}`, "run the build before packaging."); + return tree; +} + +/** A plain fetch does not deepen a shallow clone, so ancestry against this head holds only on a full checkout. */ +function fetchMainHead(cwd: string): string { + git(cwd, "fetch", "--quiet", "origin", "refs/heads/main"); + return git(cwd, "rev-parse", "FETCH_HEAD"); +} + +function assertOnMain(cwd: string, sourceSha: string, refusal: string): void { + const mainHead = fetchMainHead(cwd); + if (!isAncestor(cwd, sourceSha, mainHead)) { + throw new Error( + `${sourceSha} is not on origin's main (its head is ${mainHead}); refusing to ${refusal}.`, + ); + } +} + +/** A ref as origin advertises it: the id a lease holds against, and the commit it peels to (an annotated tag's id + * is not its commit's). Both empty when the ref does not exist. */ +function observeRemote(cwd: string, ref: string): { id: string; peeled: string } { + let id = ""; + let peeled = ""; + for (const line of git(cwd, "ls-remote", "origin", ref, `${ref}^{}`).split("\n")) { + const [sha = "", name] = line.split("\t"); + if (name === ref) { + id = sha; + } else if (name === `${ref}^{}`) { + peeled = sha; + } + } + return { id, peeled: peeled === "" ? id : peeled }; +} + +/** Bring an observed ref's commit into this clone by the ref's name (origin serves no fetch by arbitrary sha). The + * ref can move or vanish between the observation and the fetch; the caller re-observes and decides again then. A + * fetch that fails with the ref standing where it was read is thrown. */ +function fetchObserved(cwd: string, ref: string, observedId: string): boolean { + try { + git(cwd, "fetch", "--quiet", ...TAG_FETCH, "origin", `+${ref}:${ref}`); + } catch (error) { + if (observeRemote(cwd, ref).id === observedId) { + throw error; + } + return false; + } + return git(cwd, "rev-parse", ref) === observedId; +} + +/** A packaged commit and the main commit it packages (its parent). */ +interface Packaged { + commit: string; + source: string; +} + +interface EnsuredTag extends Packaged { + created: boolean; + ref: string; +} + +/** + * A tag that exists exactly once: an existing one is fetched and `verify`d, an absent one is created with a plain + * push (no force, no lease) on the commit `mint` returns. git refuses the push once the tag exists, so the loser of + * two runs verifies the winner's commit as a rerun does. A ref that vanishes or moves mid-read is read again, and so + * is a refused create whose ref is absent on the re-read (a rival created and pruned it in between, or the push was + * refused for good: the two look alike, so the refusal is thrown only once every attempt is spent). + */ +function ensureTag( + cwd: string, + ref: string, + source: string, + mint: () => string, + verify: (peeled: string) => void, +): EnsuredTag { + let refused: Error | null = null; + for (let attempt = 1; attempt <= PUSH_ATTEMPTS; attempt++) { + const observed = observeRemote(cwd, ref); + if (observed.id !== "") { + if (!fetchObserved(cwd, ref, observed.id)) { + continue; + } + verify(observed.peeled); + return { created: false, ref, commit: observed.peeled, source }; + } + const commit = mint(); + refused = push(cwd, "origin", `${commit}:${ref}`); + if (refused === null) { + return { created: true, ref, commit, source }; + } + } + throw ( + refused ?? + new Error( + `${ref} kept changing under this run through ${PUSH_ATTEMPTS} reads; something keeps creating and deleting it - rerun this job once it settles.`, + ) + ); +} + +const BUILD_REMEDY = + "no run replaces a packaged commit it did not mint; if the build is wrong, delete the tag by hand and rerun."; + +/** sourceSha's packaged commit under its build tag: this checkout's build `tree` as the source's child, minted once. + * Both parts of the name are the commit's own, so every run for one commit names one tag. */ +function ensurePackaged(cwd: string, sourceSha: string, tree: string, runUrl?: string): EnsuredTag { + const ref = `${BUILD_TAG_PREFIX}${mainPosition(cwd, sourceSha).count}.${sourceSha.slice(0, 7)}`; + return ensureTag( + cwd, + ref, + sourceSha, + () => + gitWithEnv( + cwd, + BOT_IDENTITY, + "commit-tree", + "-p", + sourceSha, + "-m", + `build: main at ${git(cwd, "rev-parse", "--short", sourceSha)}`, + ...(runUrl === undefined ? [] : ["-m", `Workflow-run: ${runUrl}`]), + tree, + ), + (peeled) => assertPackageOf(cwd, peeled, sourceSha, `${ref} (${peeled})`, BUILD_REMEDY, tree), + ); +} + +/** The build tags beyond the `keep` newest by position, deleted in one push. Two runs pruning at once commute: the + * server answers the deletion of a ref a rival deleted first with a warning, not a refusal. A ref under build/ this + * pipeline would not name stops the prune: sorted in, it could push a genuine tag out of the window. */ +export function pruneBuildTags(cwd: string, keep = KEPT_BUILD_TAGS): string[] { + const refs = git(cwd, "ls-remote", "origin", `${BUILD_TAG_PREFIX}*`) + .split("\n") + .map((line) => line.split("\t")[1] ?? "") + .filter((ref) => ref !== "" && !ref.endsWith("^{}")) + .map((ref) => { + const position = Number(ref.match(BUILD_TAG)?.[1]); + if (Number.isNaN(position)) { + throw new Error( + `origin holds ${ref}, which is not a build/. tag this pipeline names; delete it by hand.`, + ); + } + return { ref, position }; + }) + .sort((a, b) => b.position - a.position || a.ref.localeCompare(b.ref)) + .slice(keep) + .map((tag) => tag.ref); + if (refs.length > 0) { + git(cwd, "push", "origin", ...refs.map((ref) => `:${ref}`)); + } + return refs; +} + +export interface PointerMove { + ref: string; + /** Where the pointer is when the move ends. */ + sha: string; + changed: boolean; + reason: string; +} + +/** + * The one way a pointer (latest, vX) moves: forward along main, never back. A pointer's source is its commit's + * parent when that parent is on main; the candidate's source is on main by its caller's check. + * + * A value left in place must be a package of the commit it is read as packaging, or a hand-pushed bare child of a + * newer commit would stand as "already past". Main's head is read AFTER the pointer on every pass: a pointer a rival + * moved to a newer commit's package has that commit on main by then. + */ +export function movePointer(cwd: string, ref: string, candidate: Packaged): PointerMove { + for (let attempt = 1; attempt <= PUSH_ATTEMPTS; attempt++) { + const observed = observeRemote(cwd, ref); + const at = observed.peeled; + if (observed.id !== "") { + if (!fetchObserved(cwd, ref, observed.id)) { + continue; + } + const mainHead = fetchMainHead(cwd); + const parents = git(cwd, "log", "-1", "--format=%P", at).split(" ").filter(Boolean); + const current = parents.length === 1 ? parents[0] : undefined; + // A pointer whose commit is no child of a main commit (the retired chain's tip, a hand-pushed bare root) is replaced, not judged. + if (current !== undefined && isAncestor(cwd, current, mainHead)) { + if (isAncestor(cwd, candidate.source, current)) { + assertPackageOf(cwd, at, current, `${ref} (${at})`, "inspect it by hand."); + if ( + current === candidate.source && + git(cwd, "rev-parse", `${at}^{tree}`) !== + git(cwd, "rev-parse", `${candidate.commit}^{tree}`) + ) { + throw new Error( + `${ref} is at ${at}, another package of ${candidate.source} than ${candidate.commit} with another tree; two builds of one main commit exist - inspect both by hand.`, + ); + } + return { + ref, + sha: at, + changed: false, + reason: `${ref} already at ${at}, packaging ${current}, which is ${candidate.source} or past it`, + }; + } + } + } + const refused = push( + cwd, + `--force-with-lease=${ref}:${observed.id}`, + "origin", + `${candidate.commit}:${ref}`, + ); + if (refused === null) { + return { + ref, + sha: candidate.commit, + changed: true, + reason: `${ref}: moved to ${candidate.commit}${at === "" ? "" : ` from ${at}`}`, + }; + } + if (observeRemote(cwd, ref).id === observed.id) { + throw refused; + } + } + throw new Error( + `could not move ${ref} after ${PUSH_ATTEMPTS} compare-and-swap attempts; something keeps moving it concurrently - rerun this job once it settles.`, + ); +} + +interface PackageCommitOptions { + cwd: string; + /** The green main commit this run judged; the checkout must be at it with the bundle built. */ + sourceSha: string; + /** Provenance trailer for a packaged commit this run mints (the workflow run URL). */ + runUrl?: string; +} + +interface PackageCommitResult extends EnsuredTag { + /** The build tags this run deleted, beyond the kept window. */ + pruned: string[]; + latest: PointerMove; +} + +/** The green push's step: the commit's package under its build tag, the window pruned, latest moved forward. */ +export function packageCommit(options: PackageCommitOptions): PackageCommitResult { + const { cwd, sourceSha, runUrl } = options; + assertFullHistory( + cwd, + "package-commit", + "the commit's position on main and whether latest's source lies on its history cannot be judged on a truncated one.", + ); + const tree = builtTree(cwd, sourceSha); + assertOnMain(cwd, sourceSha, "package a commit main does not hold"); + const packaged = ensurePackaged(cwd, sourceSha, tree, runUrl); + const pruned = pruneBuildTags(cwd); + return { ...packaged, pruned, latest: movePointer(cwd, LATEST_REF, packaged) }; +} + +interface PackageOptions { + cwd: string; + tag: string; + /** The release's merge commit, resolved from the draft (not necessarily this run's own push); the tag's recorded source. */ + sourceSha: string; + /** Provenance trailer for a packaged commit this run has to mint (the workflow run URL). */ + runUrl?: string; +} + +interface PackagedRelease { + created: boolean; + packagedSha: string; + /** The build tags this run deleted, beyond the kept window. */ + pruned: string[]; + latest: PointerMove; +} + +export const FROZEN = + "the release-tags ruleset freezes version tags, so no rerun can replace it - inspect it by hand."; + +/** + * Mint the release's version tag on its packaged commit exactly once; the checkout must be the merge commit with the + * bundle freshly built. A rerun finds the tag on origin and holds it to this build instead, so no rerun can move or + * replace a version tag, then prunes and reconciles latest as post-green does, healing a run that died between the + * pushes (and giving @latest a value even where post-green cannot push). + * + * source off origin's main -> stop before any push: the frozen tag would be poisoned + * the build tag packages it -> tagged there (normally minted by post-green earlier in this run) + * no build tag (pruned, or skipped) -> minted here through post-green's path; a package the window has moved past goes again at once + */ +export function packageRelease(options: PackageOptions): PackagedRelease { + const { cwd, tag, sourceSha, runUrl } = options; + releaseMajor(tag); + assertFullHistory( + cwd, + "package", + "the merge commit's position on main and whether latest's source lies on its history cannot be judged on a truncated one.", + ); + const tree = builtTree(cwd, sourceSha); + // A hand recovery with the wrong TAG, or a draft pointing at the wrong commit, must stop before an immutable tag is minted. + const manifest = manifestVersionAt(cwd, "HEAD"); + if (tag !== `v${manifest}`) { + throw new Error( + `tag ${tag} does not match the manifest version ${JSON.stringify(manifest)} at ${sourceSha}; refusing to package a version this source did not release.`, + ); + } + assertOnMain(cwd, sourceSha, "package, tag, or publish a commit main does not hold"); + const ref = `refs/tags/${tag}`; + const tagged = ensureTag( + cwd, + ref, + sourceSha, + () => ensurePackaged(cwd, sourceSha, tree, runUrl).commit, + (peeled) => assertPackageOf(cwd, peeled, sourceSha, `${ref} (${peeled})`, FROZEN, tree), + ); + const pruned = pruneBuildTags(cwd); + return { + created: tagged.created, + packagedSha: tagged.commit, + pruned, + latest: movePointer(cwd, LATEST_REF, tagged), + }; +} + +interface RetagMajorOptions { + cwd: string; + tag: string; + sourceSha: string; +} + +interface RetaggedMajor { + major: string; + packagedSha: string; + move: PointerMove; +} + +/** The version tag is read from origin afresh and judged from its objects alone (the byte check against a fresh + * build was package's, a step earlier); the major then moves through movePointer, so a rerun of an old release's + * job leaves a newer release's major where it is. */ +export function retagMajor(options: RetagMajorOptions): RetaggedMajor { + const { cwd, tag, sourceSha } = options; + assertFullHistory( + cwd, + "retag-major", + "whether the release and the major's current source lie on main cannot be judged on a truncated one.", + ); + const ref = `refs/tags/${tag}`; + git(cwd, "fetch", "--quiet", ...TAG_FETCH, "origin", `+${ref}:${ref}`); + const packagedSha = git(cwd, "rev-parse", `${ref}^{}`); + assertOnMain(cwd, sourceSha, "bless a release main does not hold"); + assertPackageOf(cwd, packagedSha, sourceSha, `${ref} (${packagedSha})`, FROZEN); + const major = releaseMajor(tag); + const move = movePointer(cwd, `refs/tags/${major}`, { commit: packagedSha, source: sourceSha }); + return { major, packagedSha, move }; +} diff --git a/.github/workflows/update-release-pr.yml b/.github/workflows/update-release-pr.yml index 99cae720..df5caca3 100644 --- a/.github/workflows/update-release-pr.yml +++ b/.github/workflows/update-release-pr.yml @@ -3,7 +3,7 @@ # release cut. Inside a called workflow github.sha is the caller's: the push to main that release-please judged, the # head the refresh was built on. Two independent jobs share that sha and nothing else: # -# anchor -> the boundary anchor (anchorReleasePr in .github/scripts/release-pipeline.ts) inside the PR branch +# anchor -> the boundary anchor (anchorReleasePr in .github/scripts/release-pipeline/anchor.ts) inside the PR branch # publish-next -> the library pre-release to npm under the `next` dist-tag # # The publish lives here because release-please's refresh IS the releasable signal: release-please-config.json leaves @@ -100,7 +100,7 @@ jobs: run: bun run build:schema && bun run build:lib # The verdict is a safety on npm state, not the decision to publish (the refresh that called this workflow made # that); the publish then holds the lane until the record shows the version, so the next holder's verdict sees it. - # GITHUB_SHA is the commit npm's provenance names. npmPublish in .github/scripts/release-pipeline.ts answers: + # GITHUB_SHA is the commit npm's provenance names. npmPublish in .github/scripts/release-pipeline/npm.ts answers: # the version already on the record (a rerun) -> skip # a pre-release of a descendant on the record (a stale retry) -> skip, so next never moves back # a source the checkout cannot place -> ignored with a notice diff --git a/.github/workflows/update-release.yml b/.github/workflows/update-release.yml index f15714fa..60540607 100644 --- a/.github/workflows/update-release.yml +++ b/.github/workflows/update-release.yml @@ -1,7 +1,7 @@ # The update stage of the release pipeline: repo-owned, called by the managed ci.yml between release-please # cutting the DRAFT release and the attested publish. This hook, not release-please, mints the tag: main is # source-only, so every consumable ref must point at a packaged commit, the merge commit's child carrying its build -# (the topology is in .github/scripts/release-pipeline.ts; release-please-config.json sets `draft` and pins +# (the topology is in .github/scripts/release-pipeline/tags.ts; release-please-config.json sets `draft` and pins # `force-tag-creation` off). # # resolve the merge commit from the draft's target commitish -> build the bundle, the schema, and the library diff --git a/test/scripts/release-pipeline-build.test.ts b/test/scripts/release-pipeline-build.test.ts index e355a95f..6cf604f1 100644 --- a/test/scripts/release-pipeline-build.test.ts +++ b/test/scripts/release-pipeline-build.test.ts @@ -14,7 +14,7 @@ import { packageRelease, pruneBuildTags, retagMajor, -} from "../../.github/scripts/release-pipeline.js"; +} from "../../.github/scripts/release-pipeline/tags.js"; import { buildTagOf, buildTags, diff --git a/test/scripts/release-pipeline-npm.test.ts b/test/scripts/release-pipeline-npm.test.ts new file mode 100644 index 00000000..08489b87 --- /dev/null +++ b/test/scripts/release-pipeline-npm.test.ts @@ -0,0 +1,997 @@ +/** + * The npm side of the release pipeline against the fixture repositories and a registry served from the test process: + * the pre-release version a main commit mints, the publish verdicts and the confirmation, the npm-publish step, and + * the npm floor. + */ +import { describe, expect, setDefaultTimeout, test } from "bun:test"; +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { type MainPosition, mainPosition } from "../../.github/scripts/release-pipeline/git.js"; +import { + type NextVerdict, + nextPublishVerdict, + npmConfirm, + npmVerdict, + type Packument, + type PublishVerdict, + prereleaseVersion, + prereleaseVersionOf, + stablePublishVerdict, +} from "../../.github/scripts/release-pipeline/npm.js"; +import { ROOT } from "../root.js"; +import { withTempDir } from "../temp-dir.js"; +import { + checkoutOf, + clone, + commitAll, + type Fixture, + git, + installReleasePipelineFixture, + pushGreenCommit, + seedFixture, + shallowClone, + subcommand, + write, +} from "./release-pipeline-fixture.js"; + +// Dozens of git spawns per test time out bun's 5s default under parallel machine load. +setDefaultTimeout(60_000); +installReleasePipelineFixture(); + +/** The version grammar as semver.org states it: what npm holds a version to + * before it normalizes one (a bare all-digit identifier loses its leading zero). */ +const SEMVER = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/; + +describe("prereleaseVersion", () => { + const sha = "b8df084c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a"; + const at = (count: number, date = "20260913"): MainPosition => ({ count, date }); + const minted: [string, MainPosition, string, string][] = [ + ["2.0.0", at(446), sha, "2.0.1-main.446.20260913.gb8df084"], + ["2.9.9", at(7, "20250101"), sha, "2.9.10-main.7.20250101.gb8df084"], + ["0.0.0", at(1), sha, "0.0.1-main.1.20260913.gb8df084"], + // The sha7 that would be rewritten to 123456 as a bare identifier. + [ + "2.0.0", + at(446), + "0123456789abcdef0123456789abcdef01234567", + "2.0.1-main.446.20260913.g0123456", + ], + ]; + test.each(minted)("manifest %s at %j of %s mints %s", (manifest, position, source, expected) => { + const version = prereleaseVersion(manifest, position, source); + expect(version).toBe(expected); + expect(version).toMatch(SEMVER); + }); + + const refusedInputs: [string, [string, MainPosition, string], RegExp][] = [ + ["a manifest version with a pre-release", ["2.0.0-rc.1", at(446), sha], /is not X\.Y\.Z/], + ["a manifest version missing its patch", ["2.0", at(446), sha], /is not X\.Y\.Z/], + ["a commit count of zero", ["2.0.0", at(0), sha], /not a positive integer/], + ["a fractional commit count", ["2.0.0", at(1.5), sha], /not a positive integer/], + ["a short sha", ["2.0.0", at(446), "b8df084"], /not a full commit sha/], + ["an upper-case sha", ["2.0.0", at(446), sha.toUpperCase()], /not a full commit sha/], + ]; + test.each(refusedInputs)("%s is refused", (_name, args, error) => { + expect(() => prereleaseVersion(...args)).toThrow(error); + }); + + /** A commit's committer date as UTC YYYYMMDD, by git's own formatter: the pipeline computes it from %ct instead. */ + function utcDate(cwd: string, sha: string): string { + return execFileSync("git", ["log", "-1", "--format=%cd", "--date=format-local:%Y%m%d", sha], { + cwd, + encoding: "utf8", + env: { ...process.env, TZ: "UTC" }, + }).trim(); + } + + /** The version the fixture's 2.1.0 manifest mints for a main commit at the given first-parent count. */ + const versionOf = (cwd: string, sha: string, count: number): string => + `2.1.1-main.${count}.${utcDate(cwd, sha)}.g${sha.slice(0, 7)}`; + + /** Late on the 13th in New York is the 14th in UTC: the merge commit below is stamped with it. */ + const COMMITTED = "2026-09-13T23:30:00-04:00"; + + /** A two-commit topic branch merged into main with a merge commit, pushed, and fetched into the work clone: + * the one shape of main history where the first-parent count and the plain count differ. */ + function mergeTopicOnMain(fx: Fixture): string { + const later = clone(fx.root, fx.origin, "later"); + git(later, "checkout", "--quiet", "-b", "topic"); + write(later, "src/topic.ts", "export const topic = 1;\n"); + commitAll(later, "feat: topic, part one"); + write(later, "src/topic.ts", "export const topic = 2;\n"); + commitAll(later, "feat: topic, part two"); + git(later, "checkout", "--quiet", "main"); + execFileSync("git", ["merge", "--quiet", "--no-ff", "--no-edit", "topic"], { + cwd: later, + env: { ...process.env, GIT_COMMITTER_DATE: COMMITTED }, + }); + const merged = git(later, "rev-parse", "HEAD"); + git(later, "push", "--quiet", "origin", "HEAD:refs/heads/main"); + git(fx.work, "fetch", "--quiet", "origin"); + return merged; + } + + test("the position counts main's commits along first parents under the source, and dates it in UTC", () => { + const fx = seedFixture(); + const merged = mergeTopicOnMain(fx); + // Five commits are reachable from the merge; three lie on main's first-parent line. + expect(git(fx.work, "rev-list", "--count", merged)).toBe("5"); + expect(mainPosition(fx.work, fx.seedSha)).toEqual({ + count: 1, + date: utcDate(fx.work, fx.seedSha), + }); + expect(mainPosition(fx.work, fx.mergeSha)).toEqual({ + count: 2, + date: utcDate(fx.work, fx.mergeSha), + }); + expect(mainPosition(fx.work, merged)).toEqual({ count: 3, date: "20260914" }); + }); + + test("the checkout's version names the manifest at HEAD and the source's position, which HEAD must be; a rerun mints the same string", () => { + const fx = seedFixture(); + expect(prereleaseVersionOf({ cwd: fx.work, sourceSha: fx.mergeSha })).toBe( + versionOf(fx.work, fx.mergeSha, 2), + ); + expect(() => prereleaseVersionOf({ cwd: fx.work, sourceSha: fx.seedSha })).toThrow( + `the checkout is at ${fx.mergeSha}, not the source commit ${fx.seedSha} whose build is published.`, + ); + const merged = mergeTopicOnMain(fx); + const judge = checkoutOf(fx, "judge", merged, "packaged-bundle-bytes-2\n"); + const first = prereleaseVersionOf({ cwd: judge, sourceSha: merged }); + expect(first).toBe(`2.1.1-main.3.20260914.g${merged.slice(0, 7)}`); + const rerun = checkoutOf(fx, "judge-rerun", merged, "packaged-bundle-bytes-3\n"); + expect(prereleaseVersionOf({ cwd: rerun, sourceSha: merged })).toBe(first); + }); + + test("a shallow checkout is refused: its count would stop at the shallow boundary", () => { + const fx = seedFixture(); + const checker = shallowClone(fx, "shallow"); + const head = git(checker, "rev-parse", "HEAD"); + expect(() => prereleaseVersionOf({ cwd: checker, sourceSha: head })).toThrow( + "the pre-release version needs the full history (fetch-depth: 0) and this checkout is shallow: the count of commits under the source would stop at the shallow boundary.", + ); + }); + + test("the subcommand prints the version alone on stdout from GITHUB_SHA alone, and nothing there when it fails", async () => { + const fx = seedFixture(); + expect(await subcommand(fx.work, { GITHUB_SHA: fx.mergeSha }, "prerelease-version")).toEqual({ + stdout: `${versionOf(fx.work, fx.mergeSha, 2)}\n`, + stderr: "", + status: 0, + }); + expect(await subcommand(fx.work, { GITHUB_SHA: undefined }, "prerelease-version")).toEqual({ + stdout: "", + stderr: + 'release-pipeline prerelease-version: GITHUB_SHA is required for "prerelease-version"\n', + status: 1, + }); + }); + + test("a latest this pipeline never minted stops the stable verdict, not ordered", () => { + expect(() => + stablePublishVerdict("2.1.0", { versions: {}, "dist-tags": { latest: "2.0.1-beta.1" } }), + ).toThrow(/not a version this pipeline mints/); + }); + + const registry = (versions: string[], tags: Record): Packument => ({ + versions: Object.fromEntries(versions.map((v) => [v, {}])), + "dist-tags": tags, + }); + + /** The registry states the next guard meets, around one source: the fixture's release merge, judged from the + * work clone after main grew two commits past it and a branch left it unmerged. */ + function mainAround(fx: Fixture): { + own: string; + ancestor: string; + descendant: string; + further: string; + unrelated: string; + } { + const after = pushGreenCommit(fx, "after", "packaged-bundle-bytes-2\n"); + const further = pushGreenCommit(fx, "further", "packaged-bundle-bytes-3\n"); + const side = clone(fx.root, fx.origin, "side"); + git(side, "checkout", "--quiet", fx.seedSha); + write(side, "src/side.ts", "export const side = 1;\n"); + const sideSha = commitAll(side, "feat: off main"); + git(side, "push", "--quiet", "origin", "HEAD:refs/heads/side"); + git(fx.work, "fetch", "--quiet", "origin"); + return { + own: versionOf(fx.work, fx.mergeSha, 2), + ancestor: versionOf(fx.work, fx.seedSha, 1), + descendant: versionOf(fx.work, after.sha, 3), + further: versionOf(fx.work, further.sha, 4), + unrelated: versionOf(fx.work, sideSha, 2), + }; + } + + test("next: a published pre-release is placed by its source's ancestry; a rerun, or a descendant's pre-release, holds the run back, and nothing else does", () => { + const fx = seedFixture(); + const main = mainAround(fx); + const source7 = fx.mergeSha.slice(0, 7); + const sha7 = (version: string): string => version.slice(-7); + const staleReason = (version: string): string => + `the registry already holds ${version}, whose source ${sha7(version)} is a descendant of ${source7} on main, so this stale run publishes nothing (npm publish --tag next would move next back)`; + const stale = (version: string): NextVerdict => ({ + publish: false, + version: main.own, + reason: staleReason(version), + notices: [], + }); + const goes = (...notices: string[]): NextVerdict => ({ + publish: true, + version: main.own, + notices, + }); + const unresolved = "2.1.1-main.9.20260901.g0000000"; + const cases: [string, Packument | null, NextVerdict][] = [ + ["a package the registry has never seen", null, goes()], + ["the first pre-release after a release", registry(["2.1.0"], { latest: "2.1.0" }), goes()], + [ + "an ancestor's pre-release on next", + registry(["2.1.0", main.ancestor], { latest: "2.1.0", next: main.ancestor }), + goes(), + ], + [ + "next naming this run's own version while the record lacks it: a rerun, npm would refuse the version", + registry(["2.1.0"], { latest: "2.1.0", next: main.own }), + { + publish: false, + version: main.own, + reason: `${main.own} is already on the registry`, + notices: [], + }, + ], + [ + "next naming a release, which carries no source", + registry(["2.1.0"], { latest: "2.1.0", next: "2.1.0" }), + goes(), + ], + [ + "a descendant's pre-release with other position identifiers (the hand bootstrap's shape): the sha alone places it", + registry([`2.1.1-main.0.g${sha7(main.descendant)}`], { + latest: `2.1.1-main.0.g${sha7(main.descendant)}`, + next: `2.1.1-main.0.g${sha7(main.descendant)}`, + }), + stale(`2.1.1-main.0.g${sha7(main.descendant)}`), + ], + [ + "a rerun of a run that already published", + registry(["2.1.0", main.own], { latest: "2.1.0", next: main.own }), + { + publish: false, + version: main.own, + reason: `${main.own} is already on the registry`, + notices: [], + }, + ], + [ + "a descendant's pre-release, whatever the dist-tags name", + registry(["2.1.0", main.ancestor, main.descendant], { + latest: "2.1.0", + next: main.ancestor, + }), + stale(main.descendant), + ], + [ + "two descendants' pre-releases: the furthest along main is named", + registry(["2.1.0", main.further, main.descendant], { latest: "2.1.0", next: main.further }), + stale(main.further), + ], + [ + "a release that shipped after this commit, with no pre-release of its merge", + registry(["2.1.0", "2.2.0"], { latest: "2.2.0" }), + goes(), + ], + [ + "a pre-release naming a commit this checkout lacks, on next", + registry(["2.1.0", unresolved], { latest: "2.1.0", next: unresolved }), + goes(`${unresolved} names 0000000, which is no commit in this checkout; ignored`), + ], + [ + "a pre-release naming a commit off this source's line of main, on next", + registry(["2.1.0", main.unrelated], { latest: "2.1.0", next: main.unrelated }), + goes( + `${main.unrelated} names ${sha7(main.unrelated)}, which is neither an ancestor nor a descendant of ${source7} on main; ignored`, + ), + ], + [ + "a hand-published version this pipeline never minted, which no dist-tag names", + registry(["2.1.0", "2.1.1-beta.1"], { latest: "2.1.0" }), + goes(), + ], + ]; + for (const [name, packument, expected] of cases) { + expect({ name, ...nextPublishVerdict(fx.work, fx.mergeSha, main.own, packument) }).toEqual({ + name, + ...expected, + }); + } + // A checkout git cannot read is not a checkout without the commit: the same record that skipped above stops + // the guard from a directory that is no repository, instead of publishing over the descendant. + const nowhere = join(fx.root, "not-a-repository"); + mkdirSync(nowhere); + expect(() => + nextPublishVerdict( + nowhere, + fx.mergeSha, + main.own, + registry(["2.1.0", main.descendant], { latest: "2.1.0", next: main.descendant }), + ), + ).toThrow( + /^git rev-parse --verify --quiet [0-9a-f]{7}\^\{commit\} failed: fatal: not a git repository/, + ); + }); + + const stableVerdicts: [string, string, Packument | null, PublishVerdict][] = [ + ["a package the registry has never seen", "2.1.0", null, { publish: true, version: "2.1.0" }], + [ + "the release after the bootstrap pre-release", + "2.1.0", + registry(["2.0.1-main.0.g0000000"], { + latest: "2.0.1-main.0.g0000000", + next: "2.0.1-main.0.g0000000", + }), + { publish: true, version: "2.1.0" }, + ], + [ + "a newer release", + "2.1.0", + registry(["2.0.0", "2.0.1-main.412.20260901.g1111111"], { + latest: "2.0.0", + next: "2.0.1-main.412.20260901.g1111111", + }), + { publish: true, version: "2.1.0" }, + ], + [ + "a rerun of the release's job", + "2.1.0", + registry(["2.0.0", "2.1.0"], { latest: "2.1.0" }), + { + publish: false, + version: "2.1.0", + reason: "2.1.0 is already on the registry", + }, + ], + [ + "a release taking latest over from a bootstrap pre-release that sorts above it", + "2.1.0", + registry(["2.1.1-main.0.g0000000"], { + latest: "2.1.1-main.0.g0000000", + next: "2.1.1-main.0.g0000000", + }), + { publish: true, version: "2.1.0" }, + ], + [ + "a rerun of an older release's job after a newer release", + "2.1.0", + registry(["2.0.0", "2.2.0"], { latest: "2.2.0" }), + { + publish: false, + version: "2.1.0", + reason: + "the registry's latest is 2.2.0, newer than 2.1.0, so this rerun of an older release publishes nothing (npm publish would move latest back)", + }, + ], + ]; + test.each(stableVerdicts)("stable: %s", (_name, version, packument, expected) => { + expect(stablePublishVerdict(version, packument)).toEqual(expected); + }); + + test("stable: a built package.json that is not the tag's version stops before the registry is asked", async () => { + const fx = seedFixture(); + const asked = await withRegistry({ status: 404 }, async (registry, requests) => { + await expect( + npmVerdict({ + cwd: fx.work, + channel: "stable", + sourceSha: fx.mergeSha, + tag: "v2.2.0", + registry, + }), + ).rejects.toThrow( + "package.json at the release source is version 2.1.0, but the release tag is v2.2.0; refusing to publish a version this source did not release.", + ); + return requests; + }); + expect(asked).toEqual([]); + }); + + type Answer = { status: number; body?: unknown }; + /** A registry for the fixture's package on a local port: one answer, or a sequence served in order with its last + * answer repeated. Each request is recorded as its path and query. */ + function withRegistry( + answers: Answer | Answer[], + body: (url: string, requests: string[]) => Promise, + ): Promise { + const sequence = Array.isArray(answers) ? answers : [answers]; + const requests: string[] = []; + const server = Bun.serve({ + port: 0, + fetch(request) { + const { pathname, search } = new URL(request.url); + requests.push(pathname + search); + const answer = sequence[Math.min(requests.length, sequence.length) - 1] as Answer; + return answer.body === undefined + ? new Response("", { status: answer.status }) + : Response.json(answer.body, { status: answer.status }); + }, + }); + return body(`http://127.0.0.1:${server.port}`, requests).finally(() => server.stop(true)); + } + + /** The fixture's registry record holding both channels' versions, the run's own next version included. */ + const holding = (own: string): Answer => ({ + status: 200, + body: { + versions: { "2.1.0": {}, "2.2.0": {}, [own]: {} }, + "dist-tags": { latest: "2.2.0", next: own }, + }, + }); + + test.each< + [ + label: string, + options: { channel: "next" } | { channel: "stable"; tag: string }, + answer: (own: string) => Answer, + expected: (own: string) => NextVerdict | PublishVerdict, + ] + >([ + [ + "404 is an unpublished package", + { channel: "next" }, + () => ({ status: 404 }), + (own) => ({ publish: true, version: own, notices: [] }), + ], + [ + "a stable version the record holds is skipped", + { channel: "stable", tag: "v2.1.0" }, + holding, + () => ({ publish: false, version: "2.1.0", reason: "2.1.0 is already on the registry" }), + ], + [ + "a next version the record holds is skipped", + { channel: "next" }, + holding, + (own) => ({ + publish: false, + version: own, + reason: `${own} is already on the registry`, + notices: [], + }), + ], + ])( + "the verdict reads the registry's record of the package named in package.json past the CDN cache: %s", + async (_label, options, answer, expected) => { + const fx = seedFixture(); + const own = versionOf(fx.work, fx.mergeSha, 2); + const verdict = await withRegistry(answer(own), async (registry, requests) => { + const result = await npmVerdict({ + cwd: fx.work, + sourceSha: fx.mergeSha, + registry, + ...options, + }); + return { result, requests }; + }); + expect(verdict.result).toEqual(expected(own)); + // A query string no earlier request carried: the CDN caches a packument by URL for up to 300 s and misses on it. + expect(verdict.requests).toEqual([ + expect.stringMatching(/^\/@scope%2Fpkg\?fresh=\d+-[0-9a-z]+$/), + ]); + }, + ); + + const malformed: [string, unknown, RegExp | string][] = [ + ["an empty object", {}, "is not a packument (an object with versions and dist-tags records)"], + ["an array", [], /is not a packument/], + ["versions as a list", { versions: ["2.1.0"], "dist-tags": {} }, /is not a packument/], + [ + "a dist-tag naming a non-string", + { versions: {}, "dist-tags": { latest: 210 } }, + /names dist-tag latest as 210, not a version/, + ], + ]; + test.each(malformed)( + "a 200 body that is %s stops the verdict, not an empty registry", + async (_name, body, error) => { + const fx = seedFixture(); + await withRegistry({ status: 200, body }, async (registry) => { + await expect( + npmVerdict({ + cwd: fx.work, + channel: "stable", + sourceSha: fx.mergeSha, + tag: "v2.1.0", + registry, + }), + ).rejects.toThrow(error); + }); + }, + ); + + test("a registry that answers anything but 200 or 404 stops the verdict instead of publishing blind", async () => { + const fx = seedFixture(); + await withRegistry({ status: 503 }, async (registry) => { + await expect( + npmVerdict({ + cwd: fx.work, + channel: "stable", + sourceSha: fx.mergeSha, + tag: "v2.1.0", + registry, + }), + ).rejects.toThrow( + `the registry answered 503 for @scope/pkg (${registry}/@scope%2Fpkg); refusing to publish without knowing what it holds.`, + ); + }); + }); + + /** The fixture's published version, and an ancestor's pre-release next named before it. */ + const published = (fx: Fixture) => versionOf(fx.work, fx.mergeSha, 2); + const older = (fx: Fixture) => versionOf(fx.work, fx.seedSha, 1); + /** A descendant's pre-release: main grew one commit past the source, fetched into the work clone. */ + function newer(fx: Fixture): string { + const after = pushGreenCommit(fx, "after", "packaged-bundle-bytes-2\n"); + git(fx.work, "fetch", "--quiet", "origin"); + return versionOf(fx.work, after.sha, 3); + } + /** The drift the confirmation reports when next stayed on this run's version while a descendant's is on the record. */ + const behind = (fx: Fixture, ahead: string): string => + `the registry's next is ${published(fx)} while it holds ${ahead}, whose source ${ahead.slice(-7)} is a descendant ` + + `of ${fx.mergeSha.slice(0, 7)} on main; this stale run moved next back, and the next release-PR refresh moves it forward ` + + `(npm dist-tag add @scope/pkg@${ahead} next repairs it by hand)`; + + describe("npm-confirm after a next publish", () => { + const confirm = (fx: Fixture, registry: string, attempts = 5) => + npmConfirm({ cwd: fx.work, sourceSha: fx.mergeSha, registry, attempts, delayMs: 0 }); + + test("a record that lags the publish is read again until it shows the version, each read past the CDN cache", async () => { + const fx = seedFixture(); + const lagging = registry(["2.1.0", older(fx)], { latest: "2.1.0", next: older(fx) }); + const caughtUp = registry(["2.1.0", older(fx), published(fx)], { + latest: "2.1.0", + next: published(fx), + }); + const result = await withRegistry( + [ + { status: 200, body: lagging }, + { status: 200, body: lagging }, + { status: 200, body: caughtUp }, + ], + async (url, requests) => ({ verdict: await confirm(fx, url), requests }), + ); + expect(result.verdict).toEqual({ outcome: "settled", version: published(fx), reads: 3 }); + expect(result.requests).toHaveLength(3); + expect(new Set(result.requests).size).toBe(3); + }); + + test("the reads stop at the bound while the record still lacks the version, whether it lags or is 404", async () => { + const fx = seedFixture(); + const lagging = registry(["2.1.0", older(fx)], { latest: "2.1.0", next: older(fx) }); + const unsettled = { + outcome: "unsettled" as const, + version: published(fx), + reason: `the registry's record still lacks ${published(fx)} after 3 reads over 0 s; a run judged before it shows may move next back, and the release-PR refresh after it moves next forward`, + }; + const lagged = await withRegistry({ status: 200, body: lagging }, async (url, requests) => ({ + verdict: await confirm(fx, url, 3), + requests, + })); + expect(lagged.verdict).toEqual(unsettled); + expect(lagged.requests).toHaveLength(3); + const missing = await withRegistry({ status: 404 }, async (url, requests) => ({ + verdict: await confirm(fx, url, 3), + requests, + })); + expect(missing.verdict).toEqual(unsettled); + expect(missing.requests).toHaveLength(3); + }); + + test("next behind a descendant's pre-release the record holds is reported, never moved", async () => { + const fx = seedFixture(); + const ahead = newer(fx); + const drifted = registry(["2.1.0", older(fx), ahead, published(fx)], { + latest: "2.1.0", + next: published(fx), + }); + await withRegistry({ status: 200, body: drifted }, async (url) => { + expect(await confirm(fx, url)).toEqual({ + outcome: "behind", + version: published(fx), + reason: behind(fx, ahead), + }); + }); + }); + + test("next already on a descendant's pre-release is no drift: a later run moved it forward past this one", async () => { + const fx = seedFixture(); + const ahead = newer(fx); + const overtaken = registry(["2.1.0", published(fx), ahead], { + latest: "2.1.0", + next: ahead, + }); + await withRegistry({ status: 200, body: overtaken }, async (url) => { + expect(await confirm(fx, url)).toEqual({ + outcome: "settled", + version: published(fx), + reads: 1, + }); + }); + }); + + test("a newer release the record holds is not a drift: next sits below latest until the next release-PR refresh", async () => { + const fx = seedFixture(); + const released = registry(["2.1.0", published(fx), "2.2.0"], { + latest: "2.2.0", + next: published(fx), + }); + await withRegistry({ status: 200, body: released }, async (url) => { + expect(await confirm(fx, url)).toEqual({ + outcome: "settled", + version: published(fx), + reads: 1, + }); + }); + }); + + test("a read the registry fails is a read that did not show the version: the lane is held through the budget", async () => { + const fx = seedFixture(); + const caughtUp = registry(["2.1.0", published(fx)], { latest: "2.1.0", next: published(fx) }); + const recovered = await withRegistry( + [{ status: 503 }, { status: 503 }, { status: 200, body: caughtUp }], + async (url, requests) => ({ verdict: await confirm(fx, url), requests }), + ); + expect(recovered.verdict).toEqual({ outcome: "settled", version: published(fx), reads: 3 }); + expect(recovered.requests).toHaveLength(3); + }); + + test("a registry that answers anything but 200 or 404 on every read stops the confirmation after the budget", async () => { + const fx = seedFixture(); + const requests = await withRegistry({ status: 503 }, async (url, requests) => { + await expect(confirm(fx, url, 3)).rejects.toThrow( + `the registry answered 503 for @scope/pkg (${url}/@scope%2Fpkg); refusing to publish without knowing what it holds.`, + ); + return requests; + }); + expect(requests).toHaveLength(3); + await expect( + npmConfirm({ + cwd: fx.work, + sourceSha: fx.mergeSha, + registry: "http://127.0.0.1:9", + attempts: 0, + delayMs: 0, + }), + ).rejects.toThrow("the read attempts must be a positive integer, not 0"); + }); + }); + + describe("the npm-publish subcommand", () => { + /** An npm first on PATH that prints every call with the GITHUB_SHA it saw and, when told, fails one command. */ + function withNpm( + body: (path: string) => Promise, + failing?: { command: string; status: number }, + ): Promise { + return withTempDir("npm-publish-stub-", (dir) => { + writeFileSync( + join(dir, "npm"), + [ + "#!/bin/sh", + 'echo "npm $* (GITHUB_SHA=$GITHUB_SHA)"', + ...(failing === undefined + ? [] + : [`[ "$1" = "${failing.command}" ] && exit ${failing.status}`]), + "exit 0", + "", + ].join("\n"), + { mode: 0o755 }, + ); + return body(`${dir}:${process.env.PATH ?? ""}`); + }); + } + /** The step's environment: the source, the local registry, the stub npm, no pause between the confirmation's reads. */ + const env = (fx: Fixture, url: string, path: string) => ({ + GITHUB_SHA: fx.mergeSha, + TAG: "v2.1.0", + NPM_REGISTRY_URL: url, + NPM_CONFIRM_PAUSE_MS: "0", + PATH: path, + }); + /** The npm calls a publish makes, as the stub reports them: every call sees the source, the publish names it. */ + const nextPublish = (fx: Fixture, own: string): string => + [ + `npm version ${own} --no-git-tag-version (GITHUB_SHA=${fx.mergeSha})`, + `npm pkg delete scripts.prepare (GITHUB_SHA=${fx.mergeSha})`, + `npm publish --tag next (GITHUB_SHA=${fx.mergeSha})`, + "", + ].join("\n"); + const stablePublish = (fx: Fixture): string => + [ + `npm pkg delete scripts.prepare (GITHUB_SHA=${fx.mergeSha})`, + `npm publish (GITHUB_SHA=${fx.mergeSha})`, + "", + ].join("\n"); + const run = (fx: Fixture, url: string, path: string, channel: string) => + subcommand(fx.work, env(fx, url, path), "npm-publish", channel); + + test( + "next: a publish verdict sets the version, drops scripts.prepare, publishes under next, then holds the lane " + + "until the record shows the version; the verdict's notices go to stderr", + async () => { + const fx = seedFixture(); + const own = published(fx); + const unresolved = "2.1.1-main.9.20260901.g0000000"; + const judged = registry(["2.1.0", unresolved], { latest: "2.1.0", next: unresolved }); + const lagging = registry(["2.1.0", older(fx)], { latest: "2.1.0", next: older(fx) }); + const converged = registry(["2.1.0", own], { latest: "2.1.0", next: own }); + const started = performance.now(); + const result = await withNpm((path) => + withRegistry( + [ + { status: 200, body: judged }, + { status: 200, body: lagging }, + { status: 200, body: converged }, + ], + async (url, requests) => ({ step: await run(fx, url, path, "next"), requests }), + ), + ); + expect(result.step).toEqual({ + stdout: + `${nextPublish(fx, own)}::notice::${own} is on the registry after 2 reads; ` + + "next is not behind a descendant's pre-release\n", + stderr: `${unresolved} names 0000000, which is no commit in this checkout; ignored\n`, + status: 0, + }); + // One read for the verdict, two for the confirmation; the bound is the control: a run that ignored + // NPM_CONFIRM_PAUSE_MS would pause 20 s between those two. + expect(result.requests).toHaveLength(3); + expect(performance.now() - started).toBeLessThan(10_000); + }, + ); + + test.each<[string, (fx: Fixture) => { record: Packument; reason: string }]>([ + [ + "a rerun whose version the record holds", + (fx) => ({ + record: registry(["2.1.0", published(fx)], { latest: "2.1.0", next: published(fx) }), + reason: `${published(fx)} is already on the registry`, + }), + ], + [ + "a stale run behind a descendant's pre-release", + (fx) => { + const ahead = newer(fx); + return { + record: registry(["2.1.0", ahead], { latest: "2.1.0", next: ahead }), + reason: + `the registry already holds ${ahead}, whose source ${ahead.slice(-7)} is a descendant of ` + + `${fx.mergeSha.slice(0, 7)} on main, so this stale run publishes nothing (npm publish --tag next ` + + "would move next back)", + }; + }, + ], + ])("next: %s calls npm not at all and raises a notice", async (_name, state) => { + const fx = seedFixture(); + const { record, reason } = state(fx); + const step = await withNpm((path) => + withRegistry({ status: 200, body: record }, (url) => run(fx, url, path, "next")), + ); + expect(step).toEqual({ stdout: `::notice::${reason}\n`, stderr: "", status: 0 }); + }); + + test("next: a record that never shows the version within the bound warns, and the step passes", async () => { + const fx = seedFixture(); + const own = published(fx); + const lagging = registry(["2.1.0", older(fx)], { latest: "2.1.0", next: older(fx) }); + const result = await withNpm((path) => + withRegistry([{ status: 404 }, { status: 200, body: lagging }], async (url, requests) => ({ + step: await run(fx, url, path, "next"), + requests, + })), + ); + expect(result.step).toEqual({ + stdout: + `${nextPublish(fx, own)}::warning::the registry's record still lacks ${own} after 15 reads over 0 s; ` + + "a run judged before it shows may move next back, and the release-PR refresh after it moves next forward\n", + stderr: "", + status: 0, + }); + expect(result.requests).toHaveLength(16); + }); + + test("next: next left behind a descendant's pre-release fails the step with the drift named, after the publish", async () => { + const fx = seedFixture(); + const own = published(fx); + const ahead = newer(fx); + const drifted = registry(["2.1.0", ahead, own], { latest: "2.1.0", next: own }); + const step = await withNpm((path) => + withRegistry([{ status: 404 }, { status: 200, body: drifted }], (url) => + run(fx, url, path, "next"), + ), + ); + expect(step).toEqual({ + stdout: `${nextPublish(fx, own)}::error::${behind(fx, ahead)}\n`, + stderr: "", + status: 1, + }); + }); + + test("next: an npm publish that fails ends the step with its status named, after the verdict's notices, and nothing confirms", async () => { + const fx = seedFixture(); + const unresolved = "2.1.1-main.9.20260901.g0000000"; + const judged = registry(["2.1.0", unresolved], { latest: "2.1.0", next: unresolved }); + const result = await withNpm( + (path) => + withRegistry({ status: 200, body: judged }, async (url, requests) => ({ + step: await run(fx, url, path, "next"), + requests, + })), + { command: "publish", status: 3 }, + ); + expect(result.step).toEqual({ + stdout: nextPublish(fx, published(fx)), + stderr: + `${unresolved} names 0000000, which is no commit in this checkout; ignored\n` + + "release-pipeline npm-publish: npm publish --tag next exited 3; see npm's output above.\n", + status: 1, + }); + expect(result.requests).toHaveLength(1); + }); + + test.each<[string, Packument | null, string]>([ + [ + "a publish verdict drops scripts.prepare and publishes under the default dist-tag", + null, + "", + ], + [ + "a skip verdict calls npm not at all and warns", + { versions: { "2.1.0": {} }, "dist-tags": { latest: "2.1.0" } }, + "::warning::2.1.0 is already on the registry\n", + ], + ])("stable: %s", async (_name, record, annotation) => { + const fx = seedFixture(); + const result = await withNpm((path) => + withRegistry( + record === null ? { status: 404 } : { status: 200, body: record }, + async (url, requests) => ({ step: await run(fx, url, path, "stable"), requests }), + ), + ); + expect(result.step).toEqual({ + stdout: record === null ? stablePublish(fx) : annotation, + stderr: "", + status: 0, + }); + // A release confirms nothing: one read, for the verdict. + expect(result.requests).toHaveLength(1); + }); + + test.each<[string, string[], Record, string]>([ + ["no channel", [], {}, "npm-publish takes the channel, next or stable, not null"], + [ + "a release without its tag", + ["stable"], + { TAG: undefined }, + 'TAG is required for "npm-publish"', + ], + [ + "a pause that is not a whole number of milliseconds", + ["next"], + { NPM_CONFIRM_PAUSE_MS: "soon" }, + 'NPM_CONFIRM_PAUSE_MS must be a whole number of milliseconds, not "soon"', + ], + ])( + "%s is refused before the registry is read or npm runs", + async (_name, args, override, message) => { + const fx = seedFixture(); + const asked = await withNpm((path) => + withRegistry({ status: 404 }, async (url, requests) => { + expect( + await subcommand( + fx.work, + { ...env(fx, url, path), ...override }, + "npm-publish", + ...args, + ), + ).toEqual({ + stdout: "", + stderr: `release-pipeline npm-publish: ${message}\n`, + status: 1, + }); + return requests; + }), + ); + expect(asked).toEqual([]); + }, + ); + }); +}); + +describe("npmFloor", () => { + /** A scripted npm first on PATH: `--version` prints the version file, `install -g npm@latest` logs itself and + * replaces that file with the planned upgrade, or exits with the planned status when the plan says `fail `. */ + function withNpm(dir: string, version: string, after: string): string { + writeFileSync(join(dir, "version"), `${version}\n`); + writeFileSync(join(dir, "after"), `${after}\n`); + writeFileSync( + join(dir, "npm"), + [ + "#!/bin/sh", + `here="${dir}"`, + 'if [ "$1" = "--version" ]; then cat "$here/version"; exit 0; fi', + 'printf "%s\\n" "$*" >> "$here/calls.log"', + 'read -r kind code < "$here/after"', + 'if [ "$kind" = "fail" ]; then exit "$code"; fi', + 'cp "$here/after" "$here/version"', + "", + ].join("\n"), + { mode: 0o755 }, + ); + return dir; + } + const installs = (dir: string): string[] => + existsSync(join(dir, "calls.log")) + ? readFileSync(join(dir, "calls.log"), "utf8").split("\n").filter(Boolean) + : []; + + test.each([ + ["at the floor", "11.5.1", "11.5.1", 0, [], "", ""], + [ + "above it by a two-digit minor (numeric, not textual, order)", + "11.10.0", + "11.10.0", + 0, + [], + "", + "", + ], + [ + "below it, with an upgrade that reaches it", + "10.9.2", + "11.6.0", + 0, + ["install -g npm@latest"], + "", + "", + ], + [ + "below it, with an upgrade that does not", + "10.9.2", + "10.9.2", + 1, + ["install -g npm@latest"], + "::error::npm 10.9.2 cannot publish through OIDC; trusted publishing needs npm 11.5.1 or newer.\n", + "", + ], + [ + "below it, with an upgrade that fails", + "10.9.2", + "fail 7", + 1, + ["install -g npm@latest"], + "", + "release-pipeline npm-floor: npm 10.9.2 is below 11.5.1 and npm install -g npm@latest exited 7; the publish needs npm 11.5.1 or newer.\n", + ], + [ + "at a prerelease of the floor, with an upgrade that still prints no version", + "11.5.1-pre.0", + "not-a-version", + 1, + ["install -g npm@latest"], + "::error::npm not-a-version cannot publish through OIDC; trusted publishing needs npm 11.5.1 or newer.\n", + "", + ], + ])( + "an npm %s: the upgrade runs once at most, the floor holding is silent, and the floor failing is the annotation", + (_, version, after, status, upgrades, stdout, stderr) => + withTempDir("npm-floor-", async (dir) => { + const npm = withNpm(dir, version, after); + expect( + await subcommand(ROOT, { PATH: `${npm}:${process.env.PATH ?? ""}` }, "npm-floor"), + ).toEqual({ stdout, stderr, status }); + expect(installs(npm)).toEqual(upgrades); + }), + ); +}); diff --git a/test/scripts/release-pipeline.test.ts b/test/scripts/release-pipeline.test.ts index 1f3a700e..87990949 100644 --- a/test/scripts/release-pipeline.test.ts +++ b/test/scripts/release-pipeline.test.ts @@ -11,22 +11,13 @@ import { anchorCheck, anchorReleasePr, boundaryCheck, +} from "../../.github/scripts/release-pipeline/anchor.js"; +import { FROZEN, - type MainPosition, - mainPosition, - type NextVerdict, - nextPublishVerdict, - npmConfirm, - npmVerdict, - type Packument, - type PublishVerdict, packageCommit, packageRelease, - prereleaseVersion, - prereleaseVersionOf, retagMajor, - stablePublishVerdict, -} from "../../.github/scripts/release-pipeline.js"; +} from "../../.github/scripts/release-pipeline/tags.js"; import { ROOT } from "../root.js"; import { withTempDir } from "../temp-dir.js"; import { @@ -59,7 +50,6 @@ import { rivalPackage, seedFixture, shallowClone, - subcommand, withPushPlans, write, writeBuild, @@ -1245,960 +1235,3 @@ describe("release configuration contract", () => { }).toEqual({ draft: true, forceTagCreation: false, skipGithubRelease: false }); }); }); - -/** The version grammar as semver.org states it: what npm holds a version to - * before it normalizes one (a bare all-digit identifier loses its leading zero). */ -const SEMVER = - /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/; - -describe("prereleaseVersion", () => { - const sha = "b8df084c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a"; - const at = (count: number, date = "20260913"): MainPosition => ({ count, date }); - const minted: [string, MainPosition, string, string][] = [ - ["2.0.0", at(446), sha, "2.0.1-main.446.20260913.gb8df084"], - ["2.9.9", at(7, "20250101"), sha, "2.9.10-main.7.20250101.gb8df084"], - ["0.0.0", at(1), sha, "0.0.1-main.1.20260913.gb8df084"], - // The sha7 that would be rewritten to 123456 as a bare identifier. - [ - "2.0.0", - at(446), - "0123456789abcdef0123456789abcdef01234567", - "2.0.1-main.446.20260913.g0123456", - ], - ]; - test.each(minted)("manifest %s at %j of %s mints %s", (manifest, position, source, expected) => { - const version = prereleaseVersion(manifest, position, source); - expect(version).toBe(expected); - expect(version).toMatch(SEMVER); - }); - - const refusedInputs: [string, [string, MainPosition, string], RegExp][] = [ - ["a manifest version with a pre-release", ["2.0.0-rc.1", at(446), sha], /is not X\.Y\.Z/], - ["a manifest version missing its patch", ["2.0", at(446), sha], /is not X\.Y\.Z/], - ["a commit count of zero", ["2.0.0", at(0), sha], /not a positive integer/], - ["a fractional commit count", ["2.0.0", at(1.5), sha], /not a positive integer/], - ["a short sha", ["2.0.0", at(446), "b8df084"], /not a full commit sha/], - ["an upper-case sha", ["2.0.0", at(446), sha.toUpperCase()], /not a full commit sha/], - ]; - test.each(refusedInputs)("%s is refused", (_name, args, error) => { - expect(() => prereleaseVersion(...args)).toThrow(error); - }); - - /** A commit's committer date as UTC YYYYMMDD, by git's own formatter: the pipeline computes it from %ct instead. */ - function utcDate(cwd: string, sha: string): string { - return execFileSync("git", ["log", "-1", "--format=%cd", "--date=format-local:%Y%m%d", sha], { - cwd, - encoding: "utf8", - env: { ...process.env, TZ: "UTC" }, - }).trim(); - } - - /** The version the fixture's 2.1.0 manifest mints for a main commit at the given first-parent count. */ - const versionOf = (cwd: string, sha: string, count: number): string => - `2.1.1-main.${count}.${utcDate(cwd, sha)}.g${sha.slice(0, 7)}`; - - /** Late on the 13th in New York is the 14th in UTC: the merge commit below is stamped with it. */ - const COMMITTED = "2026-09-13T23:30:00-04:00"; - - /** A two-commit topic branch merged into main with a merge commit, pushed, and fetched into the work clone: - * the one shape of main history where the first-parent count and the plain count differ. */ - function mergeTopicOnMain(fx: Fixture): string { - const later = clone(fx.root, fx.origin, "later"); - git(later, "checkout", "--quiet", "-b", "topic"); - write(later, "src/topic.ts", "export const topic = 1;\n"); - commitAll(later, "feat: topic, part one"); - write(later, "src/topic.ts", "export const topic = 2;\n"); - commitAll(later, "feat: topic, part two"); - git(later, "checkout", "--quiet", "main"); - execFileSync("git", ["merge", "--quiet", "--no-ff", "--no-edit", "topic"], { - cwd: later, - env: { ...process.env, GIT_COMMITTER_DATE: COMMITTED }, - }); - const merged = git(later, "rev-parse", "HEAD"); - git(later, "push", "--quiet", "origin", "HEAD:refs/heads/main"); - git(fx.work, "fetch", "--quiet", "origin"); - return merged; - } - - test("the position counts main's commits along first parents under the source, and dates it in UTC", () => { - const fx = seedFixture(); - const merged = mergeTopicOnMain(fx); - // Five commits are reachable from the merge; three lie on main's first-parent line. - expect(git(fx.work, "rev-list", "--count", merged)).toBe("5"); - expect(mainPosition(fx.work, fx.seedSha)).toEqual({ - count: 1, - date: utcDate(fx.work, fx.seedSha), - }); - expect(mainPosition(fx.work, fx.mergeSha)).toEqual({ - count: 2, - date: utcDate(fx.work, fx.mergeSha), - }); - expect(mainPosition(fx.work, merged)).toEqual({ count: 3, date: "20260914" }); - }); - - test("the checkout's version names the manifest at HEAD and the source's position, which HEAD must be; a rerun mints the same string", () => { - const fx = seedFixture(); - expect(prereleaseVersionOf({ cwd: fx.work, sourceSha: fx.mergeSha })).toBe( - versionOf(fx.work, fx.mergeSha, 2), - ); - expect(() => prereleaseVersionOf({ cwd: fx.work, sourceSha: fx.seedSha })).toThrow( - `the checkout is at ${fx.mergeSha}, not the source commit ${fx.seedSha} whose build is published.`, - ); - const merged = mergeTopicOnMain(fx); - const judge = checkoutOf(fx, "judge", merged, "packaged-bundle-bytes-2\n"); - const first = prereleaseVersionOf({ cwd: judge, sourceSha: merged }); - expect(first).toBe(`2.1.1-main.3.20260914.g${merged.slice(0, 7)}`); - const rerun = checkoutOf(fx, "judge-rerun", merged, "packaged-bundle-bytes-3\n"); - expect(prereleaseVersionOf({ cwd: rerun, sourceSha: merged })).toBe(first); - }); - - test("a shallow checkout is refused: its count would stop at the shallow boundary", () => { - const fx = seedFixture(); - const checker = shallowClone(fx, "shallow"); - const head = git(checker, "rev-parse", "HEAD"); - expect(() => prereleaseVersionOf({ cwd: checker, sourceSha: head })).toThrow( - "the pre-release version needs the full history (fetch-depth: 0) and this checkout is shallow: the count of commits under the source would stop at the shallow boundary.", - ); - }); - - test("the subcommand prints the version alone on stdout from GITHUB_SHA alone, and nothing there when it fails", async () => { - const fx = seedFixture(); - expect(await subcommand(fx.work, { GITHUB_SHA: fx.mergeSha }, "prerelease-version")).toEqual({ - stdout: `${versionOf(fx.work, fx.mergeSha, 2)}\n`, - stderr: "", - status: 0, - }); - expect(await subcommand(fx.work, { GITHUB_SHA: undefined }, "prerelease-version")).toEqual({ - stdout: "", - stderr: - 'release-pipeline prerelease-version: GITHUB_SHA is required for "prerelease-version"\n', - status: 1, - }); - }); - - test("a latest this pipeline never minted stops the stable verdict, not ordered", () => { - expect(() => - stablePublishVerdict("2.1.0", { versions: {}, "dist-tags": { latest: "2.0.1-beta.1" } }), - ).toThrow(/not a version this pipeline mints/); - }); - - const registry = (versions: string[], tags: Record): Packument => ({ - versions: Object.fromEntries(versions.map((v) => [v, {}])), - "dist-tags": tags, - }); - - /** The registry states the next guard meets, around one source: the fixture's release merge, judged from the - * work clone after main grew two commits past it and a branch left it unmerged. */ - function mainAround(fx: Fixture): { - own: string; - ancestor: string; - descendant: string; - further: string; - unrelated: string; - } { - const after = pushGreenCommit(fx, "after", "packaged-bundle-bytes-2\n"); - const further = pushGreenCommit(fx, "further", "packaged-bundle-bytes-3\n"); - const side = clone(fx.root, fx.origin, "side"); - git(side, "checkout", "--quiet", fx.seedSha); - write(side, "src/side.ts", "export const side = 1;\n"); - const sideSha = commitAll(side, "feat: off main"); - git(side, "push", "--quiet", "origin", "HEAD:refs/heads/side"); - git(fx.work, "fetch", "--quiet", "origin"); - return { - own: versionOf(fx.work, fx.mergeSha, 2), - ancestor: versionOf(fx.work, fx.seedSha, 1), - descendant: versionOf(fx.work, after.sha, 3), - further: versionOf(fx.work, further.sha, 4), - unrelated: versionOf(fx.work, sideSha, 2), - }; - } - - test("next: a published pre-release is placed by its source's ancestry; a rerun, or a descendant's pre-release, holds the run back, and nothing else does", () => { - const fx = seedFixture(); - const main = mainAround(fx); - const source7 = fx.mergeSha.slice(0, 7); - const sha7 = (version: string): string => version.slice(-7); - const staleReason = (version: string): string => - `the registry already holds ${version}, whose source ${sha7(version)} is a descendant of ${source7} on main, so this stale run publishes nothing (npm publish --tag next would move next back)`; - const stale = (version: string): NextVerdict => ({ - publish: false, - version: main.own, - reason: staleReason(version), - notices: [], - }); - const goes = (...notices: string[]): NextVerdict => ({ - publish: true, - version: main.own, - notices, - }); - const unresolved = "2.1.1-main.9.20260901.g0000000"; - const cases: [string, Packument | null, NextVerdict][] = [ - ["a package the registry has never seen", null, goes()], - ["the first pre-release after a release", registry(["2.1.0"], { latest: "2.1.0" }), goes()], - [ - "an ancestor's pre-release on next", - registry(["2.1.0", main.ancestor], { latest: "2.1.0", next: main.ancestor }), - goes(), - ], - [ - "next naming this run's own version while the record lacks it: a rerun, npm would refuse the version", - registry(["2.1.0"], { latest: "2.1.0", next: main.own }), - { - publish: false, - version: main.own, - reason: `${main.own} is already on the registry`, - notices: [], - }, - ], - [ - "next naming a release, which carries no source", - registry(["2.1.0"], { latest: "2.1.0", next: "2.1.0" }), - goes(), - ], - [ - "a descendant's pre-release with other position identifiers (the hand bootstrap's shape): the sha alone places it", - registry([`2.1.1-main.0.g${sha7(main.descendant)}`], { - latest: `2.1.1-main.0.g${sha7(main.descendant)}`, - next: `2.1.1-main.0.g${sha7(main.descendant)}`, - }), - stale(`2.1.1-main.0.g${sha7(main.descendant)}`), - ], - [ - "a rerun of a run that already published", - registry(["2.1.0", main.own], { latest: "2.1.0", next: main.own }), - { - publish: false, - version: main.own, - reason: `${main.own} is already on the registry`, - notices: [], - }, - ], - [ - "a descendant's pre-release, whatever the dist-tags name", - registry(["2.1.0", main.ancestor, main.descendant], { - latest: "2.1.0", - next: main.ancestor, - }), - stale(main.descendant), - ], - [ - "two descendants' pre-releases: the furthest along main is named", - registry(["2.1.0", main.further, main.descendant], { latest: "2.1.0", next: main.further }), - stale(main.further), - ], - [ - "a release that shipped after this commit, with no pre-release of its merge", - registry(["2.1.0", "2.2.0"], { latest: "2.2.0" }), - goes(), - ], - [ - "a pre-release naming a commit this checkout lacks, on next", - registry(["2.1.0", unresolved], { latest: "2.1.0", next: unresolved }), - goes(`${unresolved} names 0000000, which is no commit in this checkout; ignored`), - ], - [ - "a pre-release naming a commit off this source's line of main, on next", - registry(["2.1.0", main.unrelated], { latest: "2.1.0", next: main.unrelated }), - goes( - `${main.unrelated} names ${sha7(main.unrelated)}, which is neither an ancestor nor a descendant of ${source7} on main; ignored`, - ), - ], - [ - "a hand-published version this pipeline never minted, which no dist-tag names", - registry(["2.1.0", "2.1.1-beta.1"], { latest: "2.1.0" }), - goes(), - ], - ]; - for (const [name, packument, expected] of cases) { - expect({ name, ...nextPublishVerdict(fx.work, fx.mergeSha, main.own, packument) }).toEqual({ - name, - ...expected, - }); - } - // A checkout git cannot read is not a checkout without the commit: the same record that skipped above stops - // the guard from a directory that is no repository, instead of publishing over the descendant. - const nowhere = join(fx.root, "not-a-repository"); - mkdirSync(nowhere); - expect(() => - nextPublishVerdict( - nowhere, - fx.mergeSha, - main.own, - registry(["2.1.0", main.descendant], { latest: "2.1.0", next: main.descendant }), - ), - ).toThrow( - /^git rev-parse --verify --quiet [0-9a-f]{7}\^\{commit\} failed: fatal: not a git repository/, - ); - }); - - const stableVerdicts: [string, string, Packument | null, PublishVerdict][] = [ - ["a package the registry has never seen", "2.1.0", null, { publish: true, version: "2.1.0" }], - [ - "the release after the bootstrap pre-release", - "2.1.0", - registry(["2.0.1-main.0.g0000000"], { - latest: "2.0.1-main.0.g0000000", - next: "2.0.1-main.0.g0000000", - }), - { publish: true, version: "2.1.0" }, - ], - [ - "a newer release", - "2.1.0", - registry(["2.0.0", "2.0.1-main.412.20260901.g1111111"], { - latest: "2.0.0", - next: "2.0.1-main.412.20260901.g1111111", - }), - { publish: true, version: "2.1.0" }, - ], - [ - "a rerun of the release's job", - "2.1.0", - registry(["2.0.0", "2.1.0"], { latest: "2.1.0" }), - { - publish: false, - version: "2.1.0", - reason: "2.1.0 is already on the registry", - }, - ], - [ - "a release taking latest over from a bootstrap pre-release that sorts above it", - "2.1.0", - registry(["2.1.1-main.0.g0000000"], { - latest: "2.1.1-main.0.g0000000", - next: "2.1.1-main.0.g0000000", - }), - { publish: true, version: "2.1.0" }, - ], - [ - "a rerun of an older release's job after a newer release", - "2.1.0", - registry(["2.0.0", "2.2.0"], { latest: "2.2.0" }), - { - publish: false, - version: "2.1.0", - reason: - "the registry's latest is 2.2.0, newer than 2.1.0, so this rerun of an older release publishes nothing (npm publish would move latest back)", - }, - ], - ]; - test.each(stableVerdicts)("stable: %s", (_name, version, packument, expected) => { - expect(stablePublishVerdict(version, packument)).toEqual(expected); - }); - - test("stable: a built package.json that is not the tag's version stops before the registry is asked", async () => { - const fx = seedFixture(); - const asked = await withRegistry({ status: 404 }, async (registry, requests) => { - await expect( - npmVerdict({ - cwd: fx.work, - channel: "stable", - sourceSha: fx.mergeSha, - tag: "v2.2.0", - registry, - }), - ).rejects.toThrow( - "package.json at the release source is version 2.1.0, but the release tag is v2.2.0; refusing to publish a version this source did not release.", - ); - return requests; - }); - expect(asked).toEqual([]); - }); - - type Answer = { status: number; body?: unknown }; - /** A registry for the fixture's package on a local port: one answer, or a sequence served in order with its last - * answer repeated. Each request is recorded as its path and query. */ - function withRegistry( - answers: Answer | Answer[], - body: (url: string, requests: string[]) => Promise, - ): Promise { - const sequence = Array.isArray(answers) ? answers : [answers]; - const requests: string[] = []; - const server = Bun.serve({ - port: 0, - fetch(request) { - const { pathname, search } = new URL(request.url); - requests.push(pathname + search); - const answer = sequence[Math.min(requests.length, sequence.length) - 1] as Answer; - return answer.body === undefined - ? new Response("", { status: answer.status }) - : Response.json(answer.body, { status: answer.status }); - }, - }); - return body(`http://127.0.0.1:${server.port}`, requests).finally(() => server.stop(true)); - } - - /** The fixture's registry record holding both channels' versions, the run's own next version included. */ - const holding = (own: string): Answer => ({ - status: 200, - body: { - versions: { "2.1.0": {}, "2.2.0": {}, [own]: {} }, - "dist-tags": { latest: "2.2.0", next: own }, - }, - }); - - test.each< - [ - label: string, - options: { channel: "next" } | { channel: "stable"; tag: string }, - answer: (own: string) => Answer, - expected: (own: string) => NextVerdict | PublishVerdict, - ] - >([ - [ - "404 is an unpublished package", - { channel: "next" }, - () => ({ status: 404 }), - (own) => ({ publish: true, version: own, notices: [] }), - ], - [ - "a stable version the record holds is skipped", - { channel: "stable", tag: "v2.1.0" }, - holding, - () => ({ publish: false, version: "2.1.0", reason: "2.1.0 is already on the registry" }), - ], - [ - "a next version the record holds is skipped", - { channel: "next" }, - holding, - (own) => ({ - publish: false, - version: own, - reason: `${own} is already on the registry`, - notices: [], - }), - ], - ])( - "the verdict reads the registry's record of the package named in package.json past the CDN cache: %s", - async (_label, options, answer, expected) => { - const fx = seedFixture(); - const own = versionOf(fx.work, fx.mergeSha, 2); - const verdict = await withRegistry(answer(own), async (registry, requests) => { - const result = await npmVerdict({ - cwd: fx.work, - sourceSha: fx.mergeSha, - registry, - ...options, - }); - return { result, requests }; - }); - expect(verdict.result).toEqual(expected(own)); - // A query string no earlier request carried: the CDN caches a packument by URL for up to 300 s and misses on it. - expect(verdict.requests).toEqual([ - expect.stringMatching(/^\/@scope%2Fpkg\?fresh=\d+-[0-9a-z]+$/), - ]); - }, - ); - - const malformed: [string, unknown, RegExp | string][] = [ - ["an empty object", {}, "is not a packument (an object with versions and dist-tags records)"], - ["an array", [], /is not a packument/], - ["versions as a list", { versions: ["2.1.0"], "dist-tags": {} }, /is not a packument/], - [ - "a dist-tag naming a non-string", - { versions: {}, "dist-tags": { latest: 210 } }, - /names dist-tag latest as 210, not a version/, - ], - ]; - test.each(malformed)( - "a 200 body that is %s stops the verdict, not an empty registry", - async (_name, body, error) => { - const fx = seedFixture(); - await withRegistry({ status: 200, body }, async (registry) => { - await expect( - npmVerdict({ - cwd: fx.work, - channel: "stable", - sourceSha: fx.mergeSha, - tag: "v2.1.0", - registry, - }), - ).rejects.toThrow(error); - }); - }, - ); - - test("a registry that answers anything but 200 or 404 stops the verdict instead of publishing blind", async () => { - const fx = seedFixture(); - await withRegistry({ status: 503 }, async (registry) => { - await expect( - npmVerdict({ - cwd: fx.work, - channel: "stable", - sourceSha: fx.mergeSha, - tag: "v2.1.0", - registry, - }), - ).rejects.toThrow( - `the registry answered 503 for @scope/pkg (${registry}/@scope%2Fpkg); refusing to publish without knowing what it holds.`, - ); - }); - }); - - /** The fixture's published version, and an ancestor's pre-release next named before it. */ - const published = (fx: Fixture) => versionOf(fx.work, fx.mergeSha, 2); - const older = (fx: Fixture) => versionOf(fx.work, fx.seedSha, 1); - /** A descendant's pre-release: main grew one commit past the source, fetched into the work clone. */ - function newer(fx: Fixture): string { - const after = pushGreenCommit(fx, "after", "packaged-bundle-bytes-2\n"); - git(fx.work, "fetch", "--quiet", "origin"); - return versionOf(fx.work, after.sha, 3); - } - /** The drift the confirmation reports when next stayed on this run's version while a descendant's is on the record. */ - const behind = (fx: Fixture, ahead: string): string => - `the registry's next is ${published(fx)} while it holds ${ahead}, whose source ${ahead.slice(-7)} is a descendant ` + - `of ${fx.mergeSha.slice(0, 7)} on main; this stale run moved next back, and the next release-PR refresh moves it forward ` + - `(npm dist-tag add @scope/pkg@${ahead} next repairs it by hand)`; - - describe("npm-confirm after a next publish", () => { - const confirm = (fx: Fixture, registry: string, attempts = 5) => - npmConfirm({ cwd: fx.work, sourceSha: fx.mergeSha, registry, attempts, delayMs: 0 }); - - test("a record that lags the publish is read again until it shows the version, each read past the CDN cache", async () => { - const fx = seedFixture(); - const lagging = registry(["2.1.0", older(fx)], { latest: "2.1.0", next: older(fx) }); - const caughtUp = registry(["2.1.0", older(fx), published(fx)], { - latest: "2.1.0", - next: published(fx), - }); - const result = await withRegistry( - [ - { status: 200, body: lagging }, - { status: 200, body: lagging }, - { status: 200, body: caughtUp }, - ], - async (url, requests) => ({ verdict: await confirm(fx, url), requests }), - ); - expect(result.verdict).toEqual({ outcome: "settled", version: published(fx), reads: 3 }); - expect(result.requests).toHaveLength(3); - expect(new Set(result.requests).size).toBe(3); - }); - - test("the reads stop at the bound while the record still lacks the version, whether it lags or is 404", async () => { - const fx = seedFixture(); - const lagging = registry(["2.1.0", older(fx)], { latest: "2.1.0", next: older(fx) }); - const unsettled = { - outcome: "unsettled" as const, - version: published(fx), - reason: `the registry's record still lacks ${published(fx)} after 3 reads over 0 s; a run judged before it shows may move next back, and the release-PR refresh after it moves next forward`, - }; - const lagged = await withRegistry({ status: 200, body: lagging }, async (url, requests) => ({ - verdict: await confirm(fx, url, 3), - requests, - })); - expect(lagged.verdict).toEqual(unsettled); - expect(lagged.requests).toHaveLength(3); - const missing = await withRegistry({ status: 404 }, async (url, requests) => ({ - verdict: await confirm(fx, url, 3), - requests, - })); - expect(missing.verdict).toEqual(unsettled); - expect(missing.requests).toHaveLength(3); - }); - - test("next behind a descendant's pre-release the record holds is reported, never moved", async () => { - const fx = seedFixture(); - const ahead = newer(fx); - const drifted = registry(["2.1.0", older(fx), ahead, published(fx)], { - latest: "2.1.0", - next: published(fx), - }); - await withRegistry({ status: 200, body: drifted }, async (url) => { - expect(await confirm(fx, url)).toEqual({ - outcome: "behind", - version: published(fx), - reason: behind(fx, ahead), - }); - }); - }); - - test("next already on a descendant's pre-release is no drift: a later run moved it forward past this one", async () => { - const fx = seedFixture(); - const ahead = newer(fx); - const overtaken = registry(["2.1.0", published(fx), ahead], { - latest: "2.1.0", - next: ahead, - }); - await withRegistry({ status: 200, body: overtaken }, async (url) => { - expect(await confirm(fx, url)).toEqual({ - outcome: "settled", - version: published(fx), - reads: 1, - }); - }); - }); - - test("a newer release the record holds is not a drift: next sits below latest until the next release-PR refresh", async () => { - const fx = seedFixture(); - const released = registry(["2.1.0", published(fx), "2.2.0"], { - latest: "2.2.0", - next: published(fx), - }); - await withRegistry({ status: 200, body: released }, async (url) => { - expect(await confirm(fx, url)).toEqual({ - outcome: "settled", - version: published(fx), - reads: 1, - }); - }); - }); - - test("a read the registry fails is a read that did not show the version: the lane is held through the budget", async () => { - const fx = seedFixture(); - const caughtUp = registry(["2.1.0", published(fx)], { latest: "2.1.0", next: published(fx) }); - const recovered = await withRegistry( - [{ status: 503 }, { status: 503 }, { status: 200, body: caughtUp }], - async (url, requests) => ({ verdict: await confirm(fx, url), requests }), - ); - expect(recovered.verdict).toEqual({ outcome: "settled", version: published(fx), reads: 3 }); - expect(recovered.requests).toHaveLength(3); - }); - - test("a registry that answers anything but 200 or 404 on every read stops the confirmation after the budget", async () => { - const fx = seedFixture(); - const requests = await withRegistry({ status: 503 }, async (url, requests) => { - await expect(confirm(fx, url, 3)).rejects.toThrow( - `the registry answered 503 for @scope/pkg (${url}/@scope%2Fpkg); refusing to publish without knowing what it holds.`, - ); - return requests; - }); - expect(requests).toHaveLength(3); - await expect( - npmConfirm({ - cwd: fx.work, - sourceSha: fx.mergeSha, - registry: "http://127.0.0.1:9", - attempts: 0, - delayMs: 0, - }), - ).rejects.toThrow("the read attempts must be a positive integer, not 0"); - }); - }); - - describe("the npm-publish subcommand", () => { - /** An npm first on PATH that prints every call with the GITHUB_SHA it saw and, when told, fails one command. */ - function withNpm( - body: (path: string) => Promise, - failing?: { command: string; status: number }, - ): Promise { - return withTempDir("npm-publish-stub-", (dir) => { - writeFileSync( - join(dir, "npm"), - [ - "#!/bin/sh", - 'echo "npm $* (GITHUB_SHA=$GITHUB_SHA)"', - ...(failing === undefined - ? [] - : [`[ "$1" = "${failing.command}" ] && exit ${failing.status}`]), - "exit 0", - "", - ].join("\n"), - { mode: 0o755 }, - ); - return body(`${dir}:${process.env.PATH ?? ""}`); - }); - } - /** The step's environment: the source, the local registry, the stub npm, no pause between the confirmation's reads. */ - const env = (fx: Fixture, url: string, path: string) => ({ - GITHUB_SHA: fx.mergeSha, - TAG: "v2.1.0", - NPM_REGISTRY_URL: url, - NPM_CONFIRM_PAUSE_MS: "0", - PATH: path, - }); - /** The npm calls a publish makes, as the stub reports them: every call sees the source, the publish names it. */ - const nextPublish = (fx: Fixture, own: string): string => - [ - `npm version ${own} --no-git-tag-version (GITHUB_SHA=${fx.mergeSha})`, - `npm pkg delete scripts.prepare (GITHUB_SHA=${fx.mergeSha})`, - `npm publish --tag next (GITHUB_SHA=${fx.mergeSha})`, - "", - ].join("\n"); - const stablePublish = (fx: Fixture): string => - [ - `npm pkg delete scripts.prepare (GITHUB_SHA=${fx.mergeSha})`, - `npm publish (GITHUB_SHA=${fx.mergeSha})`, - "", - ].join("\n"); - const run = (fx: Fixture, url: string, path: string, channel: string) => - subcommand(fx.work, env(fx, url, path), "npm-publish", channel); - - test( - "next: a publish verdict sets the version, drops scripts.prepare, publishes under next, then holds the lane " + - "until the record shows the version; the verdict's notices go to stderr", - async () => { - const fx = seedFixture(); - const own = published(fx); - const unresolved = "2.1.1-main.9.20260901.g0000000"; - const judged = registry(["2.1.0", unresolved], { latest: "2.1.0", next: unresolved }); - const lagging = registry(["2.1.0", older(fx)], { latest: "2.1.0", next: older(fx) }); - const converged = registry(["2.1.0", own], { latest: "2.1.0", next: own }); - const started = performance.now(); - const result = await withNpm((path) => - withRegistry( - [ - { status: 200, body: judged }, - { status: 200, body: lagging }, - { status: 200, body: converged }, - ], - async (url, requests) => ({ step: await run(fx, url, path, "next"), requests }), - ), - ); - expect(result.step).toEqual({ - stdout: - `${nextPublish(fx, own)}::notice::${own} is on the registry after 2 reads; ` + - "next is not behind a descendant's pre-release\n", - stderr: `${unresolved} names 0000000, which is no commit in this checkout; ignored\n`, - status: 0, - }); - // One read for the verdict, two for the confirmation; the bound is the control: a run that ignored - // NPM_CONFIRM_PAUSE_MS would pause 20 s between those two. - expect(result.requests).toHaveLength(3); - expect(performance.now() - started).toBeLessThan(10_000); - }, - ); - - test.each<[string, (fx: Fixture) => { record: Packument; reason: string }]>([ - [ - "a rerun whose version the record holds", - (fx) => ({ - record: registry(["2.1.0", published(fx)], { latest: "2.1.0", next: published(fx) }), - reason: `${published(fx)} is already on the registry`, - }), - ], - [ - "a stale run behind a descendant's pre-release", - (fx) => { - const ahead = newer(fx); - return { - record: registry(["2.1.0", ahead], { latest: "2.1.0", next: ahead }), - reason: - `the registry already holds ${ahead}, whose source ${ahead.slice(-7)} is a descendant of ` + - `${fx.mergeSha.slice(0, 7)} on main, so this stale run publishes nothing (npm publish --tag next ` + - "would move next back)", - }; - }, - ], - ])("next: %s calls npm not at all and raises a notice", async (_name, state) => { - const fx = seedFixture(); - const { record, reason } = state(fx); - const step = await withNpm((path) => - withRegistry({ status: 200, body: record }, (url) => run(fx, url, path, "next")), - ); - expect(step).toEqual({ stdout: `::notice::${reason}\n`, stderr: "", status: 0 }); - }); - - test("next: a record that never shows the version within the bound warns, and the step passes", async () => { - const fx = seedFixture(); - const own = published(fx); - const lagging = registry(["2.1.0", older(fx)], { latest: "2.1.0", next: older(fx) }); - const result = await withNpm((path) => - withRegistry([{ status: 404 }, { status: 200, body: lagging }], async (url, requests) => ({ - step: await run(fx, url, path, "next"), - requests, - })), - ); - expect(result.step).toEqual({ - stdout: - `${nextPublish(fx, own)}::warning::the registry's record still lacks ${own} after 15 reads over 0 s; ` + - "a run judged before it shows may move next back, and the release-PR refresh after it moves next forward\n", - stderr: "", - status: 0, - }); - expect(result.requests).toHaveLength(16); - }); - - test("next: next left behind a descendant's pre-release fails the step with the drift named, after the publish", async () => { - const fx = seedFixture(); - const own = published(fx); - const ahead = newer(fx); - const drifted = registry(["2.1.0", ahead, own], { latest: "2.1.0", next: own }); - const step = await withNpm((path) => - withRegistry([{ status: 404 }, { status: 200, body: drifted }], (url) => - run(fx, url, path, "next"), - ), - ); - expect(step).toEqual({ - stdout: `${nextPublish(fx, own)}::error::${behind(fx, ahead)}\n`, - stderr: "", - status: 1, - }); - }); - - test("next: an npm publish that fails ends the step with its status named, after the verdict's notices, and nothing confirms", async () => { - const fx = seedFixture(); - const unresolved = "2.1.1-main.9.20260901.g0000000"; - const judged = registry(["2.1.0", unresolved], { latest: "2.1.0", next: unresolved }); - const result = await withNpm( - (path) => - withRegistry({ status: 200, body: judged }, async (url, requests) => ({ - step: await run(fx, url, path, "next"), - requests, - })), - { command: "publish", status: 3 }, - ); - expect(result.step).toEqual({ - stdout: nextPublish(fx, published(fx)), - stderr: - `${unresolved} names 0000000, which is no commit in this checkout; ignored\n` + - "release-pipeline npm-publish: npm publish --tag next exited 3; see npm's output above.\n", - status: 1, - }); - expect(result.requests).toHaveLength(1); - }); - - test.each<[string, Packument | null, string]>([ - [ - "a publish verdict drops scripts.prepare and publishes under the default dist-tag", - null, - "", - ], - [ - "a skip verdict calls npm not at all and warns", - { versions: { "2.1.0": {} }, "dist-tags": { latest: "2.1.0" } }, - "::warning::2.1.0 is already on the registry\n", - ], - ])("stable: %s", async (_name, record, annotation) => { - const fx = seedFixture(); - const result = await withNpm((path) => - withRegistry( - record === null ? { status: 404 } : { status: 200, body: record }, - async (url, requests) => ({ step: await run(fx, url, path, "stable"), requests }), - ), - ); - expect(result.step).toEqual({ - stdout: record === null ? stablePublish(fx) : annotation, - stderr: "", - status: 0, - }); - // A release confirms nothing: one read, for the verdict. - expect(result.requests).toHaveLength(1); - }); - - test.each<[string, string[], Record, string]>([ - ["no channel", [], {}, "npm-publish takes the channel, next or stable, not null"], - [ - "a release without its tag", - ["stable"], - { TAG: undefined }, - 'TAG is required for "npm-publish"', - ], - [ - "a pause that is not a whole number of milliseconds", - ["next"], - { NPM_CONFIRM_PAUSE_MS: "soon" }, - 'NPM_CONFIRM_PAUSE_MS must be a whole number of milliseconds, not "soon"', - ], - ])( - "%s is refused before the registry is read or npm runs", - async (_name, args, override, message) => { - const fx = seedFixture(); - const asked = await withNpm((path) => - withRegistry({ status: 404 }, async (url, requests) => { - expect( - await subcommand( - fx.work, - { ...env(fx, url, path), ...override }, - "npm-publish", - ...args, - ), - ).toEqual({ - stdout: "", - stderr: `release-pipeline npm-publish: ${message}\n`, - status: 1, - }); - return requests; - }), - ); - expect(asked).toEqual([]); - }, - ); - }); -}); - -describe("npmFloor", () => { - /** A scripted npm first on PATH: `--version` prints the version file, `install -g npm@latest` logs itself and - * replaces that file with the planned upgrade, or exits with the planned status when the plan says `fail `. */ - function withNpm(dir: string, version: string, after: string): string { - writeFileSync(join(dir, "version"), `${version}\n`); - writeFileSync(join(dir, "after"), `${after}\n`); - writeFileSync( - join(dir, "npm"), - [ - "#!/bin/sh", - `here="${dir}"`, - 'if [ "$1" = "--version" ]; then cat "$here/version"; exit 0; fi', - 'printf "%s\\n" "$*" >> "$here/calls.log"', - 'read -r kind code < "$here/after"', - 'if [ "$kind" = "fail" ]; then exit "$code"; fi', - 'cp "$here/after" "$here/version"', - "", - ].join("\n"), - { mode: 0o755 }, - ); - return dir; - } - const installs = (dir: string): string[] => - existsSync(join(dir, "calls.log")) - ? readFileSync(join(dir, "calls.log"), "utf8").split("\n").filter(Boolean) - : []; - - test.each([ - ["at the floor", "11.5.1", "11.5.1", 0, [], "", ""], - [ - "above it by a two-digit minor (numeric, not textual, order)", - "11.10.0", - "11.10.0", - 0, - [], - "", - "", - ], - [ - "below it, with an upgrade that reaches it", - "10.9.2", - "11.6.0", - 0, - ["install -g npm@latest"], - "", - "", - ], - [ - "below it, with an upgrade that does not", - "10.9.2", - "10.9.2", - 1, - ["install -g npm@latest"], - "::error::npm 10.9.2 cannot publish through OIDC; trusted publishing needs npm 11.5.1 or newer.\n", - "", - ], - [ - "below it, with an upgrade that fails", - "10.9.2", - "fail 7", - 1, - ["install -g npm@latest"], - "", - "release-pipeline npm-floor: npm 10.9.2 is below 11.5.1 and npm install -g npm@latest exited 7; the publish needs npm 11.5.1 or newer.\n", - ], - [ - "at a prerelease of the floor, with an upgrade that still prints no version", - "11.5.1-pre.0", - "not-a-version", - 1, - ["install -g npm@latest"], - "::error::npm not-a-version cannot publish through OIDC; trusted publishing needs npm 11.5.1 or newer.\n", - "", - ], - ])( - "an npm %s: the upgrade runs once at most, the floor holding is silent, and the floor failing is the annotation", - (_, version, after, status, upgrades, stdout, stderr) => - withTempDir("npm-floor-", async (dir) => { - const npm = withNpm(dir, version, after); - expect( - await subcommand(ROOT, { PATH: `${npm}:${process.env.PATH ?? ""}` }, "npm-floor"), - ).toEqual({ stdout, stderr, status }); - expect(installs(npm)).toEqual(upgrades); - }), - ); -}); diff --git a/test/workflows/checks-workflow.test.ts b/test/workflows/checks-workflow.test.ts index 22c64013..aab819a7 100644 --- a/test/workflows/checks-workflow.test.ts +++ b/test/workflows/checks-workflow.test.ts @@ -5,7 +5,7 @@ import { describe, expect, test } from "bun:test"; import { parse as parseYaml } from "yaml"; -import { RELEASE_PR_BRANCH_PREFIX } from "../../.github/scripts/release-pipeline.js"; +import { RELEASE_PR_BRANCH_PREFIX } from "../../.github/scripts/release-pipeline/anchor.js"; import { headRefPrefixes, headRefPrefixesIn } from "./head-ref.js"; import { type Workflow, workflowText } from "./workflow-loader.js"; diff --git a/test/workflows/npm-publish-workflows.test.ts b/test/workflows/npm-publish-workflows.test.ts index eb11582d..79765633 100644 --- a/test/workflows/npm-publish-workflows.test.ts +++ b/test/workflows/npm-publish-workflows.test.ts @@ -4,7 +4,7 @@ * repository package.json names; both take one lane; the stable one runs after every other job of its workflow; neither hands npm a * token, as the library page promises; the steps they share are the same text; both publish to one registry. The * probe also runs under bash against stubs, since no pin shows what a branch does; the publish and the floor guard - * are the script's npm-publish and npm-floor subcommands, whose branches test/scripts/release-pipeline.test.ts runs. + * are the script's npm-publish and npm-floor subcommands, whose branches test/scripts/release-pipeline-npm.test.ts runs. * * The static guards catch ACCIDENTAL drift: a guard, lane, or env edited in plain YAML. Deliberately hiding a token or a second * publisher behind other syntax is out of scope. @@ -56,12 +56,14 @@ const stepNamed = (job: RunJob, name: string): Step => job.steps.find((step) => step.name === name), `step ${name}`, ); -/** The one floor guard both publishers run; the floor itself is NPM_FLOOR in release-pipeline.ts. */ +/** The one floor guard both publishers run; the floor itself is NPM_FLOOR in release-pipeline/npm.ts. */ const FLOOR_GUARD = "bun .github/scripts/release-pipeline.ts npm-floor"; const FLOOR_GUARD_NAME = "Require an npm that publishes through OIDC"; -/** The pipeline script's text, where the floor is defined. */ +/** The pipeline script's text, where the subcommands are dispatched, and its npm module's, where the floor is defined. */ const pipelineSource = (): string => readFileSync(join(ROOT, ".github", "scripts", "release-pipeline.ts"), "utf8"); +const npmSource = (): string => + readFileSync(join(ROOT, ".github", "scripts", "release-pipeline", "npm.ts"), "utf8"); const setupNode = (job: RunJob): Step => must( job.steps.find((step) => step.uses?.startsWith("actions/setup-node@")), @@ -393,7 +395,7 @@ describe("the npm floor guard", () => { } const source = pipelineSource(); expect(source).toContain('case "npm-floor":'); - const floors = [...source.matchAll(/^export const NPM_FLOOR = "(\d+\.\d+\.\d+)";$/gm)].map( + const floors = [...npmSource().matchAll(/^export const NPM_FLOOR = "(\d+\.\d+\.\d+)";$/gm)].map( (m) => m[1] ?? "", ); expect(floors).toHaveLength(1); diff --git a/test/workflows/post-green-workflow.test.ts b/test/workflows/post-green-workflow.test.ts index 1a01d49b..4901d057 100644 --- a/test/workflows/post-green-workflow.test.ts +++ b/test/workflows/post-green-workflow.test.ts @@ -15,7 +15,7 @@ import { describe, expect, test } from "bun:test"; import { execFileSync } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { SOURCE_OUTPUT } from "../../.github/scripts/release-pipeline.js"; +import { SOURCE_OUTPUT } from "../../.github/scripts/release-pipeline/draft.js"; import { ROOT } from "../root.js"; import { withTempDir } from "../temp-dir.js"; import { type Job, readWorkflow, type Step, type Workflow } from "./workflow-loader.js";