diff --git a/.github/scripts/release-pipeline.ts b/.github/scripts/release-pipeline.ts index 3d4d96d1..169c78c5 100644 --- a/.github/scripts/release-pipeline.ts +++ b/.github/scripts/release-pipeline.ts @@ -17,1341 +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. - * - * 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). + * This file dispatches; the sections live under release-pipeline/ rather than lib/, which holds what several + * scripts share while these belong to this one. * - * 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`; - -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 = JSON.parse(readFileSync(join(cwd, CONFIG_FILE), "utf8")) as { - "last-release-sha"?: unknown; - }; - 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 config = JSON.parse(readFileSync(join(cwd, CONFIG_FILE), "utf8")) as { - "last-release-sha"?: unknown; - }; - // 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; - } - } - 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") - ? `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 === 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}.`, - ); - } - 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 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]; - 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: String(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 5e8aea96..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, @@ -1182,6 +1172,49 @@ describe("the release hook's reads of the draft", () => { ); }); +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 @@ -1202,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";