diff --git a/.github/scripts/release-pipeline/anchor.ts b/.github/scripts/release-pipeline/anchor.ts index 9ca3bbb5..9b4938ae 100644 --- a/.github/scripts/release-pipeline/anchor.ts +++ b/.github/scripts/release-pipeline/anchor.ts @@ -1,7 +1,7 @@ /** * 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. + * so the squash merge lands it on main. The release PR is held to the commit its notes were built from, main to the + * cycle its newest release merge closed. The config is read once here and refused when malformed, never repaired. */ import { readFileSync, writeFileSync } from "node:fs"; @@ -20,6 +20,12 @@ import { /** release-please's config at the checkout root, the file the boundary is read from and written into. */ const CONFIG_FILE = "release-please-config.json"; +/** release-please's changelog at the checkout root: what a release merge adds to it is that release's notes. */ +const CHANGELOG_FILE = "CHANGELOG.md"; + +/** A commit as release-please's notes link it. */ +const COMMIT_LINK = /\/commit\/([0-9a-f]{40})\b/g; + /** 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+\))?$/; @@ -29,9 +35,45 @@ export const RELEASE_PR_BRANCH_PREFIX = "release-please--"; /** release-please's branch for main's release PR. */ const RELEASE_PR_BRANCH = `${RELEASE_PR_BRANCH_PREFIX}branches--main`; +/** Main as a full-history checkout of the release PR's head holds it. */ +const MAIN = "origin/main"; + +/** The repair of a release PR branch whose base cannot be read or trusted: release-please builds a new one once this one is gone. */ +const REBUILD = `Delete the branch, and the next green push to main has release-please rebuild the release PR and the hook anchor it:\n git push origin --delete ${RELEASE_PR_BRANCH}`; + /** 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 }; +/** A release commit: release-please's own on a release PR branch, its squash merge on main. */ +interface Release { + /** Its full sha, as `git log` prints %H. */ + sha: string; + + /** The X.Y.Z its subject names. */ + version: string; +} + +/** How a release PR branch's last-release-sha stands against the commit the branch is built on. + * anchored -> it is that commit + * missing -> main's own value: the branch has no anchor commit + * moved -> an older commit: the release commit moved after its stamp, or the value was edited back + * edited -> any other value */ +type AnchorState = "anchored" | "missing" | "moved" | "edited"; + +/** A refusal as an operator reads one in a CI log: what is wrong on the first line, then one repair per line, a + * command alone on the line under its repair. */ +function refusal(...lines: string[]): Error { + return new Error(lines.join("\n")); +} + +/** The refusal of a config that `where`, a checkout or a commit, does not hold. */ +function missingConfig(where: string): Error { + return refusal( + `${CONFIG_FILE} is missing from ${where}: the release pipeline reads release-please's boundary (last-release-sha) from it.`, + "Restore it by PR.", + ); +} + /** The config's text; a missing file is refused naming it, any other read failure is thrown as it came. */ function readConfigText(cwd: string): string { try { @@ -40,9 +82,7 @@ function readConfigText(cwd: string): string { 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.`, - ); + throw missingConfig(cwd); } } @@ -63,35 +103,51 @@ function parseConfigObject(text: string): Record { 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.`, + throw refusal( + `${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)) { - throw new Error(`${CONFIG_FILE} holds ${jsonKind(parsed)}, not a JSON object; fix it by PR.`); + throw refusal(`${CONFIG_FILE} holds ${jsonKind(parsed)}, not a JSON object.`, "Fix it by PR."); } return parsed as Record; } -/** 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 { - const config = parseConfigObject(readConfigText(cwd)); +/** `text` as release-please's config. The file is the user's and release-please's, so nothing here is repaired: a + * 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 parseReleaseConfig(text: string): ReleaseConfig { + const config = parseConfigObject(text); 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.`, + throw refusal( + `last-release-sha in ${CONFIG_FILE} is ${JSON.stringify(boundary)}, not a 40-hex commit sha.`, + "Fix it by PR.", ); } return config as ReleaseConfig; } +/** The config as the checkout holds it. */ +function readReleaseConfig(cwd: string): ReleaseConfig { + return parseReleaseConfig(readConfigText(cwd)); +} + +/** The config as the commit `rev` holds it. */ +function releaseConfigAt(cwd: string, rev: string): ReleaseConfig { + const text = tryGit(cwd, "show", `${rev}:${CONFIG_FILE}`); + if (text === null) { + throw missingConfig(`commit ${rev}`); + } + return parseReleaseConfig(text); +} + export interface AnchorOptions { /** The checkout the release PR branch is fetched into and the anchor commit made in. */ cwd: string; - /** Main's head this run tested; the merge parent the boundary records. */ + /** The main commit this run judged; the branch is anchored only when release-please built it on this commit. */ sourceSha: string; /** How many pushes a release-please refresh may overtake before the run gives up; 3 by default. */ @@ -103,9 +159,108 @@ export interface AnchorResult { reason: string; } -/** The anchor commit on the checked-out branch: last-release-sha set to `sourceSha`, committed under the bot identity. */ -function commitAnchor(cwd: string, config: ReleaseConfig, sourceSha: string): void { - config["last-release-sha"] = sourceSha; +/** The newest release commit `git log` lists for `revs` (HEAD when none), or undefined for none. 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 release commit. */ +function newestRelease(cwd: string, ...revs: string[]): Release | undefined { + const listed = git(cwd, "log", "--grep", "^chore(main): release ", "--format=%H%x09%s", ...revs); + for (const line of listed.split("\n")) { + const [sha, subject] = line.split("\t"); + const version = RELEASE_SUBJECT.exec(subject ?? "")?.[1]; + if (sha !== undefined && version !== undefined) { + return { sha, version }; + } + } + return undefined; +} + +/** The commit a release PR branch is built on: the first parent of the newest release commit on the branch's + * first-parent chain above `main`. The bound matters: a walk into main's own history would read main's last release + * merge on a branch that holds no release commit. */ +function releaseBase(cwd: string, main: string, branch: string): string { + const release = newestRelease(cwd, "--first-parent", `${main}..${branch}`); + if (release === undefined) { + throw refusal( + `the release PR branch holds no release commit ("chore(main): release X.Y.Z") on its first-parent chain above ${main}.`, + "The commit its notes were built from cannot be read: main was merged in with the branch as the second parent, or release-please did not build this branch.", + REBUILD, + ); + } + return git(cwd, "rev-parse", `${release.sha}^`); +} + +/** Where `recorded`, a release PR branch's last-release-sha, stands against `base`, the commit the branch is built + * on. The check and the command both ask here, so neither stamps what the other refuses. Telling an older commit + * from any other needs the history: a shallow checkout that must tell is refused, never guessed for. */ +function anchorState(cwd: string, recorded: string | undefined, base: string): AnchorState { + if (recorded === base) { + return "anchored"; + } + + if (recorded === releaseConfigAt(cwd, base)["last-release-sha"]) { + return "missing"; + } + + if (recorded === undefined) { + return "edited"; + } + + if (git(cwd, "rev-parse", "--is-shallow-repository") === "true") { + throw refusal( + `last-release-sha in ${CONFIG_FILE} is ${JSON.stringify(recorded)}, and this shallow checkout cannot tell whether that is an older commit than ${base}.`, + "Run the command from a clone that holds the full history:", + ` GITHUB_SHA=${base} bun .github/scripts/release-pipeline.ts anchor`, + ); + } + + return isAncestor(cwd, recorded, base) ? "moved" : "edited"; +} + +/** What an unanchored release PR is refused with. The moved state names the rebuild alone: stamping `base` there + * would leave every commit between the two out of this release's notes and out of the next cycle. */ +function notAnchored( + state: Exclude, + recorded: string | undefined, + base: string, +): Error { + const value = `last-release-sha in ${CONFIG_FILE} is ${JSON.stringify(recorded)}`; + if (state === "moved") { + return refusal( + `${value}, an older commit than ${base}, which this release PR is built on.`, + "The branch moved after it was anchored, or the anchor was edited: the notes may lack the commits between the two, so no stamp is offered.", + REBUILD, + ); + } + + const cause = { + missing: "the branch carries no anchor commit", + edited: "the anchor names another commit", + }; + return refusal( + `${value}, but release-please built this release PR on ${base}: ${cause[state]}.`, + "Stamp it from a clean checkout; the command leaves HEAD detached on the release PR branch, and its push re-runs the checks:", + ` GITHUB_SHA=${base} bun .github/scripts/release-pipeline.ts anchor`, + 'If it answers "already anchored", close and reopen the PR to run the checks on its head.', + ); +} + +/** The anchor is also a command an operator pastes into their own clone, where the checkout would carry uncommitted + * work along, and staged work into the anchor commit. */ +function refuseUncommittedChanges(cwd: string): void { + const dirty = git(cwd, "status", "--porcelain", "--untracked-files=no") + .split("\n") + .filter(Boolean); + if (dirty.length > 0) { + throw refusal( + `this checkout has uncommitted changes to tracked files (${dirty.join("; ")}), and anchoring checks the release PR branch out.`, + "Run the command from a clean checkout: commit the changes first, or use another clone.", + ); + } +} + +/** The anchor commit on the checked-out branch: last-release-sha set to `base`, committed under the bot identity. */ +function commitAnchor(cwd: string, config: ReleaseConfig, base: string): void { + config["last-release-sha"] = base; writeFileSync(join(cwd, CONFIG_FILE), `${JSON.stringify(config, null, 2)}\n`); git(cwd, "add", CONFIG_FILE); gitWithEnv( @@ -115,41 +270,44 @@ function commitAnchor(cwd: string, config: ReleaseConfig, sourceSha: string): vo "-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.", + "last-release-sha records the main commit this release's notes were built from, so the squash merge itself lands the next cycle's boundary on main: " + + "release-please lists the commits after it, and cannot find the boundary by tag, because version tags live on packaged commits that are not on main.", ); } -/** One attempt: the release PR branch fetched and checked out detached, anchored, and pushed. Undefined when a - * release-please refresh overtook the push, so the caller re-reads the branch; a push refused with the branch - * unmoved is thrown. */ +/** One attempt: the release PR branch fetched and, when it is built on `sourceSha` and its anchor is missing or + * edited, checked out detached, anchored, and pushed. Undefined when a release-please refresh overtook the push, so + * the caller re-reads the branch; a push refused with the branch unmoved is thrown. */ function anchorAttempt(cwd: string, sourceSha: string): AnchorResult | undefined { const branchRef = `refs/heads/${RELEASE_PR_BRANCH}`; 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")) { + + const base = releaseBase(cwd, sourceSha, "FETCH_HEAD"); + if (base !== sourceSha) { return { changed: false, - reason: "the release PR branch is not built on this head; its own refresh anchors", + reason: `the release PR branch is built on ${base}, not on ${sourceSha}; the anchor is the commit the branch is built on, so nothing is stamped`, }; } - const config = readReleaseConfig(cwd); - if (config["last-release-sha"] === sourceSha) { + + const config = releaseConfigAt(cwd, "FETCH_HEAD"); + const state = anchorState(cwd, config["last-release-sha"], sourceSha); + if (state === "anchored") { return { changed: false, reason: "already anchored" }; } - commitAnchor(cwd, config, sourceSha); - // 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` }; + if (state === "moved") { + throw notAnchored(state, config["last-release-sha"], sourceSha); } + + refuseUncommittedChanges(cwd); + // Detached, never a local branch: a retry after an overtaken push re-fetches, and git refuses to fetch into a checked-out ref. + // Unforced, ignored files included: git then refuses to overwrite a file of the operator's that the branch tracks. + git(cwd, "checkout", "--quiet", "--detach", "--no-overwrite-ignore", "FETCH_HEAD"); + commitAnchor(cwd, config, sourceSha); const refused = push(cwd, "origin", `HEAD:${branchRef}`); if (refused === null) { return { changed: true, reason: `${RELEASE_PR_BRANCH}: anchored at ${sourceSha}` }; @@ -160,17 +318,16 @@ function anchorAttempt(cwd: string, sourceSha: string): AnchorResult | undefined return undefined; } -/** 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 */ +/** The boundary rides INSIDE the release PR as last-release-sha = the main commit release-please built the branch + * on, so the squash merge lands it on main with no push to main and no admin credential. release-please lists the + * commits after it, so the next cycle starts where this release's notes end, whatever lands on main before the merge. + * + * Stamped only when that commit is `sourceSha`: release-please reads main's history for the notes, then main's head + * for the branch, and the platform proposes only while main stands at the run's commit. + * built on the run's commit -> nothing landed between the two reads, so the notes end at it + * built on another commit -> no stamp: this run cannot tell where the notes end */ 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` }; - } for (let attempt = 1; attempt <= attempts; attempt++) { const result = anchorAttempt(cwd, sourceSha); if (result !== undefined) { @@ -181,89 +338,134 @@ export function anchorReleasePr(options: AnchorOptions): AnchorResult { `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.`, + throw refusal( + `could not anchor ${RELEASE_PR_BRANCH} after ${attempts} attempts: something keeps rewriting the branch.`, + "Rerun this job once the branch settles.", ); } -/** The newest release merge reachable from HEAD, or "" for none. 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. */ -function newestReleaseMerge(cwd: string): string { - const listed = git(cwd, "log", "--grep", "^chore(main): release ", "--format=%H%x09%s"); - for (const line of listed.split("\n")) { - const [sha, subject] = line.split("\t"); - if (sha !== undefined && subject !== undefined && RELEASE_SUBJECT.test(subject)) { - return sha; - } - } - return ""; -} - /** The verdict with no release merge on the history: the pre-first-release state when nothing is recorded, else a - * refusal saying why the recorded boundary has no merge. */ + * refusal saying why the recorded boundary has no merge. A recorded boundary is never read as that state. */ function noMergeVerdict(cwd: string, recorded: string | undefined): { boundary: string } { 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 wrong = `last-release-sha in ${CONFIG_FILE} is ${JSON.stringify(recorded)}, but no release merge is reachable from HEAD.`; + if (isAncestor(cwd, recorded, "HEAD")) { + throw refusal( + wrong, + `This history holds that commit, so release-please's merge subject no longer matches ${RELEASE_SUBJECT}.`, + "Investigate RELEASE_SUBJECT in .github/scripts/release-pipeline/anchor.ts.", + ); + } + throw refusal( + wrong, + "That commit is not on this history at all: this is not main, or the boundary never landed.", + "Fix by PR when it never landed: remove last-release-sha, which a history with no release merge does not record.", ); } -/** 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. */ +/** The cycle a release merge closed, newest first: main's commits at or before `parent`, the merge's parent, and + * after the release merge before it. The first release merge on a history has none before it, so its cycle reaches + * back to the root. */ +function cycleOf(cwd: string, parent: string): string[] { + const previous = newestRelease(cwd, parent); + const range = + previous === undefined ? [parent] : ["--ancestry-path", `${previous.sha}..${parent}`]; + return git(cwd, "rev-list", "--topo-order", ...range) + .split("\n") + .filter(Boolean); +} + +/** The newest commit a release merge's notes list, or undefined when none reads so: of the commits its changelog + * diff links, the one of `cycle` that every other is an ancestor of. As the boundary it drops and repeats nothing: + * the notes hold every changelog-visible commit up to it, and none after it. */ +function newestListed(cwd: string, merge: string, cycle: string[]): string | undefined { + const diff = tryGit(cwd, "diff", "--unified=0", `${merge}^`, merge, "--", CHANGELOG_FILE) ?? ""; + const added = diff.split("\n").filter((line) => line.startsWith("+") && !line.startsWith("+++ ")); + const linked = new Set( + added.flatMap((line) => [...line.matchAll(COMMIT_LINK)].map((link) => link[1])), + ); + const listed = cycle.filter((sha) => linked.has(sha)); + const newest = listed[0]; + if (newest === undefined) { + return undefined; + } + + const beside = git(cwd, "rev-list", "--max-count=1", ...listed, `^${newest}`); + return beside === "" ? newest : undefined; +} + +/** On main, last-release-sha must be the newest release merge or a commit of the cycle it closed. Which commit is + * anchor-check's to judge: a squash merge no longer shows it. + * outside the cycle -> a lost anchor or a missed merge: refused, naming the newest commit the + * release's notes list + * NEWER than every recognized merge -> a missed merge or a hand edit: refused, 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.", + "a release merge or the recorded boundary can sit beyond its depth.", ); const recorded = readReleaseConfig(cwd)["last-release-sha"]; - const latest = newestReleaseMerge(cwd); - if (latest === "") { + const latest = newestRelease(cwd); + if (latest === undefined) { return noMergeVerdict(cwd, recorded); } - const parent = tryGit(cwd, "rev-parse", `${latest}^`); + + const parent = tryGit(cwd, "rev-parse", `${latest.sha}^`); + const cycle = parent === null ? [] : cycleOf(cwd, parent); if (recorded !== undefined) { - if (recorded === latest || (parent !== null && recorded === parent)) { + if (recorded === latest.sha || cycle.includes(recorded)) { 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}.`, + if (isAncestor(cwd, latest.sha, recorded) && isAncestor(cwd, recorded, "HEAD")) { + throw refusal( + `last-release-sha in ${CONFIG_FILE} is ${JSON.stringify(recorded)}, NEWER than ${latest.sha}, the newest release merge this check recognizes.`, + `A newer release merge's subject stopped matching ${RELEASE_SUBJECT}, or the boundary was edited by hand.`, + `Investigate RELEASE_SUBJECT, and never roll the boundary back to ${latest.sha} or into the cycle it closed.`, ); } } - 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}.`, - ); + + const wrong = `last-release-sha in ${CONFIG_FILE} is ${JSON.stringify(recorded)}, outside the cycle the newest release merge ${latest.sha} closed: an anchor was lost, or the value was edited.`; + const notes = `the newest commit release ${latest.version}'s notes list`; + const listed = newestListed(cwd, latest.sha, cycle); + if (listed === undefined) { + throw refusal( + wrong, + `Fix by PR: set last-release-sha to ${notes}; none could be read here, so find it in:`, + ` git show ${latest.sha} -- ${CHANGELOG_FILE}`, + ); + } + throw refusal(wrong, `Fix by PR: set last-release-sha to ${listed}, ${notes}.`); } -/** 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. */ +/** Run on the release PR's head with its history. last-release-sha must be the main commit release-please built the + * branch on, whatever landed on main since and however often main was merged into the branch, so an unanchored or + * wrongly anchored release PR is unmergeable instead of parking the pipeline after its merge. */ export function anchorCheck(cwd: string): { boundary: string } { + assertFullHistory( + cwd, + "anchor-check", + "the commit release-please built the branch on is read from the branch's own history.", + ); 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).`, + const base = releaseBase(cwd, MAIN, "HEAD"); + if (!isAncestor(cwd, base, MAIN)) { + throw refusal( + `release-please's commit on this release PR sits on ${base}, which is not on ${MAIN} as fetched here.`, + "First fetch main and run the check again:", + " git fetch origin", + "Only when it still fails: the branch was rebased, or a commit was stacked under its release commit.", + REBUILD, ); } - return { boundary: recorded }; + + const state = anchorState(cwd, recorded, base); + if (state !== "anchored") { + throw notAnchored(state, recorded, base); + } + return { boundary: base }; } diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 2022bdf6..fd73ea03 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -35,8 +35,8 @@ jobs: # anchor would have every release PR refresh computed from a stale boundary, and gating here parks the release job # before release-please can build a garbage release PR. Each half is its own job, gated on its event: a skipped job # leaves the checks caller, and so all-green, green. - # on main -> boundary-check: last-release-sha is the newest release merge or its parent - # on a release PR -> anchor-check: the PR carries the anchored NEXT boundary, which the main-side rule would reject + # on main -> boundary-check: last-release-sha is the newest release merge, or a commit of the cycle it closed + # on a release PR -> anchor-check: last-release-sha is the main commit release-please built the PR's branch on boundary-check: if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest @@ -49,20 +49,26 @@ jobs: - uses: ./.github/actions/setup with: install: "false" - - name: Confirm last-release-sha matches the newest release merge + - name: Confirm last-release-sha sits in the cycle the newest release merge closed run: bun .github/scripts/release-pipeline.ts boundary-check - # An unanchored release PR must be unmergeable here, not park the pipeline after its merge. + # An unanchored release PR must be unmergeable here, not park the pipeline after its merge. The checkout is the + # PR's head, so this job runs the BRANCH's copy of the script, not main's. anchor-check: if: github.event_name == 'pull_request' && startsWith(github.head_ref, 'release-please--') runs-on: ubuntu-latest timeout-minutes: 5 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # The commit the branch is built on is read from the branch's own chain, which the default checkout (the + # merge ref, at depth 1) does not hold. + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha }} - uses: ./.github/actions/setup with: install: "false" - - name: Require the release PR to carry this cycle's anchor + - name: Require the release PR to be anchored at the commit its notes were built from run: bun .github/scripts/release-pipeline.ts anchor-check # The packed tarball is imported from a fresh consumer project under Node and under tsc with skipLibCheck off, so a diff --git a/.github/workflows/update-release-pr.yml b/.github/workflows/update-release-pr.yml index 599fc233..20251e83 100644 --- a/.github/workflows/update-release-pr.yml +++ b/.github/workflows/update-release-pr.yml @@ -12,7 +12,7 @@ # build, ci, refactor) refreshes nothing and publishes nothing; no check of this repository's own decides a publish. # # The anchor is pushed with the default github.token, which starts no workflows, so the PR's checks do not re-run -# on the anchored head; anchor-check's failure message says to close and reopen the PR. +# on the anchored head by themselves; anchor-check's failure message says how to start them. name: Update Release PR diff --git a/test/scripts/release-pipeline.test.ts b/test/scripts/release-pipeline.test.ts index 4ad437ab..e4137e3c 100644 --- a/test/scripts/release-pipeline.test.ts +++ b/test/scripts/release-pipeline.test.ts @@ -50,6 +50,7 @@ import { rivalPackage, seedFixture, shallowClone, + subcommand, withPushPlans, write, writeBuild, @@ -662,20 +663,26 @@ describe("retagMajor", () => { ); }); -/** release-please's PR branch: manifest and changelog bumped to `version` on `from`; left unpushed for a competitor plan when `push` is false. */ +/** release-please's PR branch: manifest and changelog bumped to `version` on `from`, the notes linking the commits `listed` as release-please + * links them (none: one entry with a short sha, which names no commit); left unpushed for a competitor plan when `push` is false. */ function createReleasePrBranch( fx: Fixture, from: string, version: string, push = true, + listed: string[] = [], ): { dir: string; sha: string } { const dir = clone(fx.root, fx.origin, `rp-branch-${version}-${from.slice(0, 7)}`); git(dir, "checkout", "--quiet", "-B", "release-please--branches--main", from); write(dir, ".release-please-manifest.json", `${JSON.stringify({ ".": version }, null, 2)}\n`); + const links = listed.length === 0 ? ["abc1234"] : listed; + const entries = links.map( + (sha) => `* the fix ([${sha.slice(0, 7)}](https://example.invalid/commit/${sha}))\n`, + ); write( dir, "CHANGELOG.md", - `# Changelog\n\n## [${version}](https://example.invalid/compare) (2026-08-14)\n\n### Bug Fixes\n\n* the fix ([abc1234](https://example.invalid/commit/abc1234))\n${CHANGELOG_21}`, + `# Changelog\n\n## [${version}](https://example.invalid/compare) (2026-08-14)\n\n### Bug Fixes\n\n${entries.join("")}${CHANGELOG_21}`, ); const sha = commitAll(dir, `chore(main): release ${version}`); if (push) { @@ -703,10 +710,159 @@ function releasePrConfig(fx: Fixture, name: string): { boundary: string; manifes }; } +/** release-please's branch for main's release PR. */ +const RELEASE_BRANCH = "release-please--branches--main"; + +/** The boundary the seed fixture's config records: no commit. */ +const PLACEHOLDER = "0".repeat(40); + +/** What every refusal of an unreadable release PR branch ends with: the repair, its command on a line of its own. */ +const REBUILD = + "Delete the branch, and the next green push to main has release-please rebuild the release PR and the hook anchor it:\n" + + " git push origin --delete release-please--branches--main"; + +/** The fixture's release-please config recording `boundary`, or no boundary. */ +function configRecording(boundary?: string): string { + const recorded = boundary === undefined ? {} : { "last-release-sha": boundary }; + const packages = { ".": { "release-type": "simple", draft: true } }; + return `${JSON.stringify({ ...recorded, packages }, null, 2)}\n`; +} + +/** A commit of `subject` on origin's main, as a merged PR lands one. */ +function landOnMain(fx: Fixture, name: string, subject: string): string { + const dir = clone(fx.root, fx.origin, name); + write(dir, `docs/${name}.md`, `${subject}\n`); + const sha = commitAll(dir, subject); + git(dir, "push", "--quiet", "origin", "HEAD:refs/heads/main"); + return sha; +} + +/** GitHub's "Update branch": origin's main merged into the release PR branch as the second parent. */ +function updateReleasePrBranch(fx: Fixture, name: string): void { + const dir = clone(fx.root, fx.origin, name); + git(dir, "checkout", "--quiet", RELEASE_BRANCH); + const subject = `Merge branch 'main' into ${RELEASE_BRANCH}`; + git(dir, "merge", "--quiet", "--no-ff", "-m", subject, "origin/main"); + git(dir, "push", "--quiet", "origin", `HEAD:refs/heads/${RELEASE_BRANCH}`); +} + +/** GitHub's "Update with rebase": the release PR branch replayed onto origin's main, so release-please's commit sits on main's tip. */ +function rebaseReleasePrBranch(fx: Fixture, name: string): void { + const dir = clone(fx.root, fx.origin, name); + git(dir, "checkout", "--quiet", RELEASE_BRANCH); + git(dir, "rebase", "--quiet", "origin/main"); + git(dir, "push", "--quiet", "--force", "origin", `HEAD:refs/heads/${RELEASE_BRANCH}`); +} + +/** A commit on the release PR branch that sets last-release-sha to `boundary`: an anchor made by hand. */ +function anchorByHand(fx: Fixture, name: string, boundary: string): void { + const dir = clone(fx.root, fx.origin, name); + git(dir, "checkout", "--quiet", RELEASE_BRANCH); + write(dir, "release-please-config.json", configRecording(boundary)); + commitAll(dir, "chore: anchor by hand"); + git(dir, "push", "--quiet", "origin", `HEAD:refs/heads/${RELEASE_BRANCH}`); +} + +/** The release PR's head with its full history, detached: the checkout the anchor-check job makes. */ +function releasePrHead(fx: Fixture, name: string): string { + const dir = clone(fx.root, fx.origin, name); + git(dir, "checkout", "--quiet", "--detach", `origin/${RELEASE_BRANCH}`); + return dir; +} + +/** The release PR squash-merged onto origin's main, as GitHub lands it; a checkout of main at the merge. */ +function squashMergeReleasePr(fx: Fixture, name: string, version: string): string { + const dir = clone(fx.root, fx.origin, name); + git(dir, "merge", "--quiet", "--squash", `origin/${RELEASE_BRANCH}`); + git(dir, "commit", "--quiet", "-m", `chore(main): release ${version} (#60)`); + git(dir, "push", "--quiet", "origin", "HEAD:refs/heads/main"); + return dir; +} + +interface AnchoredPr { + /** A changelog-visible commit of the open cycle, older than `base`. */ + early: string; + + /** The main commit release-please built the branch on. */ + base: string; +} + +/** A release PR as a green run leaves one: two changelog-visible commits past the 2.1.0 merge, release-please's + * branch built on the second, and the hook's anchor on it. */ +function anchoredReleasePr(fx: Fixture): AnchoredPr { + const early = pushGreenCommit(fx, "early", "bundle-early\n").sha; + const base = pushGreenCommit(fx, "notes-base", "bundle-base\n").sha; + createReleasePrBranch(fx, base, "2.2.0"); + const hook = clone(fx.root, fx.origin, "hook"); + expect(anchorReleasePr({ cwd: hook, sourceSha: base }).changed).toBe(true); + return { early, base }; +} + +/** What `run` refuses with: its message, whole. */ +function refusalOf(run: () => unknown): string { + try { + run(); + } catch (error) { + return (error as Error).message; + } + return "no refusal"; +} + +/** The start of every refusal of a release PR's anchor: the value the branch records. */ +const recording = (recorded: string): string => + `last-release-sha in release-please-config.json is ${JSON.stringify(recorded)}`; + +/** anchor-check's refusal of a release PR built on `base` whose anchor the stamp at `base` repairs: none on the branch, or another commit. */ +function notAnchored(state: "missing" | "edited", recorded: string, base: string): Error { + const cause = { + missing: "the branch carries no anchor commit", + edited: "the anchor names another commit", + }; + return new Error( + [ + `${recording(recorded)}, but release-please built this release PR on ${base}: ${cause[state]}.`, + "Stamp it from a clean checkout; the command leaves HEAD detached on the release PR branch, and its push re-runs the checks:", + ` GITHUB_SHA=${base} bun .github/scripts/release-pipeline.ts anchor`, + 'If it answers "already anchored", close and reopen the PR to run the checks on its head.', + ].join("\n"), + ); +} + +/** The refusal, by the check and by the command alike, of a release PR whose anchor is older than the `base` it is built on: no stamp, the rebuild alone. */ +function movedAfterAnchoring(recorded: string, base: string): Error { + return new Error( + [ + `${recording(recorded)}, an older commit than ${base}, which this release PR is built on.`, + "The branch moved after it was anchored, or the anchor was edited: the notes may lack the commits between the two, so no stamp is offered.", + REBUILD, + ].join("\n"), + ); +} + +/** The refusal of a release PR branch with no release commit on its first-parent chain above `main`. */ +function noReleaseCommit(main: string): Error { + return new Error( + [ + `the release PR branch holds no release commit ("chore(main): release X.Y.Z") on its first-parent chain above ${main}.`, + "The commit its notes were built from cannot be read: main was merged in with the branch as the second parent, or release-please did not build this branch.", + REBUILD, + ].join("\n"), + ); +} + +/** boundary-check's refusal of a boundary outside the cycle `merge` closed; `repair` is the value to set, or how to find it. */ +function outsideCycle(recorded: string | undefined, merge: string, repair: string): Error { + return new Error( + `last-release-sha in release-please-config.json is ${JSON.stringify(recorded)}, outside the cycle the newest release merge ${merge} closed: ` + + `an anchor was lost, or the value was edited.\nFix by PR: set last-release-sha to ${repair}`, + ); +} + describe("anchorReleasePr", () => { test("the boundary lands inside the release PR branch and merges onto main", () => { const fx = seedFixture(); - const mainHead = fx.mergeSha; + // The platform's release job proposes on every green push but a release merge's own, so no release PR is built on the merge itself. + const mainHead = pushGreenCommit(fx, "notes-base", "bundle-base\n").sha; createReleasePrBranch(fx, mainHead, "2.2.0"); const worker = clone(fx.root, fx.origin, "anchor-worker"); const result = anchorReleasePr({ cwd: worker, sourceSha: mainHead }); @@ -739,30 +895,115 @@ describe("anchorReleasePr", () => { expect(result).toMatchObject({ changed: false, reason: "no release PR branch to anchor" }); }); - test("a moved main makes the anchor defer to the newer run", () => { + test("a hidden-type commit landing on main between the refresh and the hook job leaves the hook anchoring the commit the branch is built on", () => { const fx = seedFixture(); createReleasePrBranch(fx, fx.mergeSha, "2.2.0"); - const mover = clone(fx.root, fx.origin, "anchor-mover"); - write(mover, "src/marker.ts", "export const marker = 9;\n"); - commitAll(mover, "fix: land after the anchor run started"); - git(mover, "push", "--quiet", "origin", "HEAD:refs/heads/main"); - const worker = clone(fx.root, fx.origin, "anchor-late"); - const result = anchorReleasePr({ cwd: worker, sourceSha: fx.mergeSha }); - expect(result.changed).toBe(false); - expect(result.reason).toContain("the newer run anchors"); + // Depth 1 at the run's commit, as the hook job checks out, before main moves on. + const worker = shallowClone(fx, "anchor-late"); + landOnMain(fx, "hidden", "docs: land after the refresh, before the hook job"); + expect(anchorReleasePr({ cwd: worker, sourceSha: fx.mergeSha })).toEqual({ + changed: true, + reason: `release-please--branches--main: anchored at ${fx.mergeSha}`, + }); + expect(releasePrConfig(fx, "anchor-late-check").boundary).toBe(fx.mergeSha); }); - test("a branch built on an older head is left for its own refresh to anchor", () => { + test.each<[string, (fx: Fixture) => { run: string; builtOn: string }]>([ + [ + "an older commit than this run's", + (fx) => { + createReleasePrBranch(fx, fx.seedSha, "2.2.0"); + return { run: fx.mergeSha, builtOn: fx.seedSha }; + }, + ], + [ + "a newer commit than this run's, by a newer run's refresh", + (fx) => { + createReleasePrBranch(fx, fx.mergeSha, "2.2.0"); + const newer = pushGreenCommit(fx, "newer", "bundle-newer\n").sha; + createReleasePrBranch(fx, newer, "2.2.0"); + return { run: fx.mergeSha, builtOn: newer }; + }, + ], + [ + "an older commit, with this run's commit merged in by Update branch", + (fx) => { + createReleasePrBranch(fx, fx.mergeSha, "2.2.0"); + const tip = landOnMain(fx, "hidden", "docs: land after the refresh"); + updateReleasePrBranch(fx, "update-branch"); + return { run: tip, builtOn: fx.mergeSha }; + }, + ], + ])("a release PR branch built on %s is left unstamped", (_name, build) => { const fx = seedFixture(); - // The branch was refreshed from the SEED while main moved on to the 2.1.0 merge; anchoring mergeSha would record a boundary its content was not - // computed from. - createReleasePrBranch(fx, fx.seedSha, "2.2.0"); - const worker = clone(fx.root, fx.origin, "anchor-stale-branch"); - const result = anchorReleasePr({ cwd: worker, sourceSha: fx.mergeSha }); - expect(result.changed).toBe(false); - expect(result.reason).toContain("not built on this head"); + const { run, builtOn } = build(fx); + const before = git(fx.origin, "rev-parse", `refs/heads/${RELEASE_BRANCH}`); + const worker = clone(fx.root, fx.origin, "anchor-other-base"); + expect(anchorReleasePr({ cwd: worker, sourceSha: run })).toEqual({ + changed: false, + reason: `the release PR branch is built on ${builtOn}, not on ${run}; the anchor is the commit the branch is built on, so nothing is stamped`, + }); + expect(git(fx.origin, "rev-parse", `refs/heads/${RELEASE_BRANCH}`)).toBe(before); + }); + + test("a branch with no release commit above the run's commit is refused naming the shape", () => { + const fx = seedFixture(); + const dir = clone(fx.root, fx.origin, "not-release-please"); + write(dir, "src/marker.ts", "export const marker = 5;\n"); + commitAll(dir, "chore: not a release commit"); + git(dir, "push", "--quiet", "origin", `HEAD:refs/heads/${RELEASE_BRANCH}`); + const worker = clone(fx.root, fx.origin, "anchor-no-release"); + expect(() => anchorReleasePr({ cwd: worker, sourceSha: fx.mergeSha })).toThrow( + noReleaseCommit(fx.mergeSha), + ); }); + test("a checkout with uncommitted changes to tracked files is refused before the branch is checked out over them", () => { + const fx = seedFixture(); + createReleasePrBranch(fx, fx.mergeSha, "2.2.0"); + const work = "export const marker = 'uncommitted work';\n"; + const dirty = clone(fx.root, fx.origin, "operator-dirty"); + write(dirty, "src/marker.ts", work); + expect(() => anchorReleasePr({ cwd: dirty, sourceSha: fx.mergeSha })).toThrow( + new Error( + "this checkout has uncommitted changes to tracked files (M src/marker.ts), and anchoring checks the release PR branch out.\n" + + "Run the command from a clean checkout: commit the changes first, or use another clone.", + ), + ); + expect(readFileSync(join(dirty, "src/marker.ts"), "utf8")).toBe(work); + expect(git(dirty, "symbolic-ref", "--short", "HEAD")).toBe("main"); + // The control: the same command from a clean checkout anchors, and leaves HEAD detached as its repair text says. + const clean = clone(fx.root, fx.origin, "operator-clean"); + expect(anchorReleasePr({ cwd: clean, sourceSha: fx.mergeSha }).changed).toBe(true); + expect(git(clean, "rev-parse", "--abbrev-ref", "HEAD")).toBe("HEAD"); + expect(releasePrConfig(fx, "operator-check").boundary).toBe(fx.mergeSha); + }); + + test.each<[string, string]>([ + ["untracked", ""], + ["untracked and ignored by the clone's exclude file", "docs/guide.md\n"], + ])( + "the operator's own file at a path the release PR branch tracks, %s, survives: git refuses the checkout, and nothing is pushed", + (_name, excluded) => { + const fx = seedFixture(); + // The operator's clone predates a file main gained since, and holds a file of their own at its path. + const operator = clone(fx.root, fx.origin, "operator-untracked"); + const base = landOnMain(fx, "guide", "docs: add a guide"); + const branch = createReleasePrBranch(fx, base, "2.2.0"); + const work = "the operator's own notes, in no commit\n"; + write(operator, "docs/guide.md", work); + write(operator, ".git/info/exclude", excluded); + expect(() => anchorReleasePr({ cwd: operator, sourceSha: base })).toThrow( + /^git checkout --quiet --detach --no-overwrite-ignore FETCH_HEAD failed: [\s\S]*docs\/guide\.md/, + ); + expect({ + file: readFileSync(join(operator, "docs/guide.md"), "utf8"), + head: git(operator, "symbolic-ref", "--short", "HEAD"), + branch: git(fx.origin, "rev-parse", `refs/heads/${RELEASE_BRANCH}`), + }).toEqual({ file: work, head: "main", branch: branch.sha }); + }, + ); + test("a release-please refresh between anchors re-anchors from the same checkout", () => { const fx = seedFixture(); createReleasePrBranch(fx, fx.mergeSha, "2.2.0"); @@ -823,7 +1064,10 @@ describe("anchorReleasePr", () => { })), () => { expect(() => anchorReleasePr({ cwd: worker, sourceSha: fx.mergeSha })).toThrow( - "could not anchor release-please--branches--main after 3 attempts; something keeps rewriting the branch - rerun this job once the branch settles.", + new Error( + "could not anchor release-please--branches--main after 3 attempts: something keeps rewriting the branch.\n" + + "Rerun this job once the branch settles.", + ), ); }, ); @@ -880,10 +1124,107 @@ describe("boundaryCheck", () => { expect(boundaryCheck(dir).boundary).toBe(fx.mergeSha); }); - test("a stale boundary fails loudly with the repair value", () => { + test("a release PR merged with hidden-type commits between its anchor and the merge lands a boundary inside the cycle the merge closed", () => { const fx = seedFixture(); - // The seed fixture's config records a placeholder, not the 2.1.0 merge. - expect(() => boundaryCheck(fx.work)).toThrow(/stale boundary|set last-release-sha/); + const { base } = anchoredReleasePr(fx); + landOnMain(fx, "hidden-1", "docs: land after the release PR was anchored"); + landOnMain(fx, "hidden-2", "chore: land after it too"); + updateReleasePrBranch(fx, "update-branch"); + expect(boundaryCheck(squashMergeReleasePr(fx, "main-after", "2.2.0")).boundary).toBe(base); + }); + + test.each<[string, (fx: Fixture) => string]>([ + ["the previous release merge's parent", (fx) => fx.seedSha], + ["the previous release merge itself", (fx) => fx.mergeSha], + ])( + "a lost anchor leaves the previous cycle's boundary, %s, on main and is refused naming the newest commit the release's notes list, which keeps a visible commit the notes lack for the next cycle", + (_name, previousBoundary) => { + const fx = seedFixture(); + const previous = previousBoundary(fx); + const align = clone(fx.root, fx.origin, "align"); + write(align, "release-please-config.json", configRecording(previous)); + commitAll(align, "chore: record the boundary the 2.1.0 release landed"); + git(align, "push", "--quiet", "origin", "HEAD:refs/heads/main"); + // The release PR is never anchored, so its merge carries main's own value; a visible commit lands after its notes were built. + const listed = pushGreenCommit(fx, "listed", "bundle-listed\n").sha; + createReleasePrBranch(fx, listed, "2.2.0", true, [listed]); + const unlisted = pushGreenCommit(fx, "unlisted", "bundle-unlisted\n").sha; + const main = squashMergeReleasePr(fx, "main-after", "2.2.0"); + const merge = git(main, "rev-parse", "HEAD"); + /** What the next cycle reads from main with `boundary` recorded: the commits after it. */ + const nextCycle = (boundary: string): string[] => + git(main, "rev-list", `${boundary}..${merge}`).split("\n"); + + const refused = refusalOf(() => boundaryCheck(main)); + expect(refused).toContain(`set last-release-sha to ${listed},`); + expect(refused).toBe( + outsideCycle(previous, merge, `${listed}, the newest commit release 2.2.0's notes list.`) + .message, + ); + write(main, "release-please-config.json", configRecording(listed)); + commitAll(main, "fix(release): restore the boundary"); + expect(boundaryCheck(main).boundary).toBe(listed); + expect(nextCycle(listed)).toContain(unlisted); + // The control: the merge's parent passes the check too, and no cycle would ever list the commit. + expect(git(main, "rev-parse", `${merge}^`)).toBe(unlisted); + write(main, "release-please-config.json", configRecording(unlisted)); + commitAll(main, "fix(release): record the merge's parent instead"); + expect(boundaryCheck(main).boundary).toBe(unlisted); + expect(nextCycle(unlisted)).not.toContain(unlisted); + }, + ); + + test.each<[string, (fx: Fixture) => { main: string; version: string }]>([ + [ + "link no commit by its full sha", + // The seed fixture: its config records a placeholder, and its 2.1.0 notes link a short sha. + (fx) => ({ main: fx.work, version: "2.1.0" }), + ], + [ + "link only a commit of an older cycle", + (fx) => { + const base = pushGreenCommit(fx, "notes-base", "bundle-base\n").sha; + createReleasePrBranch(fx, base, "2.2.0", true, [fx.seedSha]); + return { main: squashMergeReleasePr(fx, "main-after", "2.2.0"), version: "2.2.0" }; + }, + ], + ])( + "a lost anchor whose release notes %s is refused naming no sha, and where to read the notes", + (_name, build) => { + const fx = seedFixture(); + const { main, version } = build(fx); + const merge = git(main, "rev-parse", "HEAD"); + expect(() => boundaryCheck(main)).toThrow( + outsideCycle( + PLACEHOLDER, + merge, + `the newest commit release ${version}'s notes list; none could be read here, so find it in:\n git show ${merge} -- CHANGELOG.md`, + ), + ); + }, + ); + + test("the first release merge on a history closes a cycle that reaches back to the root, and an absent boundary beside it is still refused", () => { + const fx = seedFixture(); + const dir = clone(fx.root, fx.origin, "boundary-first"); + git(dir, "checkout", "--quiet", fx.seedSha); + write(dir, "src/marker.ts", "export const marker = 2;\n"); + const base = commitAll(dir, "feat: the commit the first release's notes were built from"); + write(dir, "docs/hidden.md", "landed before the merge\n"); + commitAll(dir, "docs: land before the first release merge"); + write(dir, "release-please-config.json", configRecording(base)); + write( + dir, + "CHANGELOG.md", + `# Changelog\n\n## 2.1.0\n\n* the first ([${base.slice(0, 7)}](https://example.invalid/commit/${base}))\n`, + ); + const merge = commitAll(dir, "chore(main): release 2.1.0 (#42)"); + expect(boundaryCheck(dir).boundary).toBe(base); + write(dir, "release-please-config.json", configRecording()); + commitAll(dir, "chore: drop the boundary"); + expect(() => boundaryCheck(dir)).toThrow( + outsideCycle(undefined, merge, `${base}, the newest commit release 2.1.0's notes list.`), + ); }); test("a history without release merges and no recorded boundary is pre-first-release", () => { @@ -904,7 +1245,13 @@ describe("boundaryCheck", () => { const dir = clone(fx.root, fx.origin, "boundary-foreign"); // The seed commit records a placeholder boundary and has no release merge behind it. git(dir, "checkout", "--quiet", fx.seedSha); - expect(() => boundaryCheck(dir)).toThrow(/not on this history at all/); + expect(() => boundaryCheck(dir)).toThrow( + new Error( + `last-release-sha in release-please-config.json is "${PLACEHOLDER}", but no release merge is reachable from HEAD.\n` + + "That commit is not on this history at all: this is not main, or the boundary never landed.\n" + + "Fix by PR when it never landed: remove last-release-sha, which a history with no release merge does not record.", + ), + ); }); test("a shallow checkout is refused before any verdict", () => { @@ -946,7 +1293,13 @@ describe("boundaryCheck", () => { )}\n`, ); commitAll(dir, "chore(main): release: 2.1.0 (#42)"); - expect(() => boundaryCheck(dir)).toThrow(/history holds it.*RELEASE_SUBJECT/); + expect(refusalOf(() => boundaryCheck(dir)).split("\n")).toEqual([ + `last-release-sha in release-please-config.json is "${fx.seedSha}", but no release merge is reachable from HEAD.`, + expect.stringMatching( + /^This history holds that commit, so release-please's merge subject no longer matches \/.+\/\.$/, + ), + "Investigate RELEASE_SUBJECT in .github/scripts/release-pipeline/anchor.ts.", + ]); }); test("a boundary newer than the newest recognized merge is drift, not a rollback", () => { @@ -970,7 +1323,13 @@ describe("boundaryCheck", () => { )}\n`, ); commitAll(dir, "chore(main): release v2.2.0 (#43)"); - expect(() => boundaryCheck(dir)).toThrow(/NEWER than.*must not be rolled back/); + expect(refusalOf(() => boundaryCheck(dir)).split("\n")).toEqual([ + `last-release-sha in release-please-config.json is "${between}", NEWER than ${fx.mergeSha}, the newest release merge this check recognizes.`, + expect.stringMatching( + /^A newer release merge's subject stopped matching \/.+\/, or the boundary was edited by hand\.$/, + ), + `Investigate RELEASE_SUBJECT, and never roll the boundary back to ${fx.mergeSha} or into the cycle it closed.`, + ]); }); test("a boundary on an unmerged branch is stale, not newer", () => { @@ -994,7 +1353,7 @@ describe("boundaryCheck", () => { )}\n`, ); commitAll(dir, "chore: record a boundary from the wrong branch"); - expect(() => boundaryCheck(dir)).toThrow(/stale boundary.*set last-release-sha/); + expect(() => boundaryCheck(dir)).toThrow(/outside the cycle[\s\S]*set last-release-sha/); }); test("a subject that only shares the release prefix is not a release merge", () => { @@ -1021,14 +1380,6 @@ describe("boundaryCheck", () => { }); describe("anchorCheck", () => { - test("a release PR whose anchor is missing is unmergeable", () => { - const fx = seedFixture(); - createReleasePrBranch(fx, fx.mergeSha, "2.2.0"); - const pr = clone(fx.root, fx.origin, "anchor-check-missing"); - git(pr, "checkout", "--quiet", "release-please--branches--main"); - expect(() => anchorCheck(pr)).toThrow(/anchor is missing or stale/); - }); - test("an anchored release PR passes", () => { const fx = seedFixture(); createReleasePrBranch(fx, fx.mergeSha, "2.2.0"); @@ -1038,6 +1389,180 @@ describe("anchorCheck", () => { git(pr, "checkout", "--quiet", "release-please--branches--main"); expect(anchorCheck(pr).boundary).toBe(fx.mergeSha); }); + + test("an anchored release PR stays anchored when a hidden-type commit lands on main, and when main is merged into its branch", () => { + const fx = seedFixture(); + const { base } = anchoredReleasePr(fx); + landOnMain(fx, "hidden", "docs: land after the release PR was anchored"); + expect(anchorCheck(releasePrHead(fx, "pr-behind")).boundary).toBe(base); + updateReleasePrBranch(fx, "update-branch"); + expect(anchorCheck(releasePrHead(fx, "pr-updated")).boundary).toBe(base); + }); + + test.each<[string, (fx: Fixture, pr: AnchoredPr) => Error]>([ + [ + "a refresh dropped the anchor, so the file holds main's own boundary: the stamp repairs it", + (fx, { base }) => { + createReleasePrBranch(fx, base, "2.3.0"); + return notAnchored("missing", PLACEHOLDER, base); + }, + ], + [ + "the anchor was edited to main's tip after main was merged in: the stamp repairs it", + (fx, { base }) => { + const tip = landOnMain(fx, "hidden", "docs: land after the release PR was anchored"); + updateReleasePrBranch(fx, "update-branch"); + anchorByHand(fx, "by-hand", tip); + return notAnchored("edited", tip, base); + }, + ], + [ + "the anchor was edited to a sha that is on no history: the stamp repairs it", + (fx, { base }) => { + anchorByHand(fx, "by-hand", "1".repeat(40)); + return notAnchored("edited", "1".repeat(40), base); + }, + ], + [ + "the anchor was edited to an older commit of the cycle, which reads as a branch that moved: no stamp is offered", + (fx, { early, base }) => { + anchorByHand(fx, "by-hand", early); + return movedAfterAnchoring(early, base); + }, + ], + [ + "main was merged in with the branch as the second parent", + (fx) => { + landOnMain(fx, "hidden", "docs: land after the release PR was anchored"); + const dir = clone(fx.root, fx.origin, "wrong-way"); + const subject = "Merge the release branch into main's tip"; + git(dir, "merge", "--quiet", "--no-ff", "-m", subject, `origin/${RELEASE_BRANCH}`); + git(dir, "push", "--quiet", "origin", `HEAD:refs/heads/${RELEASE_BRANCH}`); + return noReleaseCommit("origin/main"); + }, + ], + [ + "release-please's commit sits on a commit that never reached main, anchored there", + (fx) => { + const dir = clone(fx.root, fx.origin, "off-main"); + write(dir, "src/marker.ts", "export const marker = 8;\n"); + const offMain = commitAll(dir, "feat: never merged"); + git(dir, "push", "--quiet", "origin", "HEAD:refs/heads/side"); + createReleasePrBranch(fx, offMain, "2.3.0"); + anchorByHand(fx, "by-hand", offMain); + return new Error( + [ + `release-please's commit on this release PR sits on ${offMain}, which is not on origin/main as fetched here.`, + "First fetch main and run the check again:", + " git fetch origin", + "Only when it still fails: the branch was rebased, or a commit was stacked under its release commit.", + REBUILD, + ].join("\n"), + ); + }, + ], + ])("a release PR is unmergeable once %s", (_name, mutate) => { + const fx = seedFixture(); + const pr = anchoredReleasePr(fx); + // The control: the same release PR passes before the change. + expect(anchorCheck(releasePrHead(fx, "pr-control")).boundary).toBe(pr.base); + const refusal = mutate(fx, pr); + expect(() => anchorCheck(releasePrHead(fx, "pr-judged"))).toThrow(refusal); + }); + + test("a release PR branch rebased onto a newer visible commit after it was anchored is refused by the check and by the command, which stamp nothing: its notes lack that commit", async () => { + const fx = seedFixture(); + const { base } = anchoredReleasePr(fx); + const unlisted = pushGreenCommit(fx, "unlisted", "bundle-unlisted\n").sha; + // The control: the anchored release PR passes while it sits on the commit its notes were built from. + expect(anchorCheck(releasePrHead(fx, "pr-control")).boundary).toBe(base); + rebaseReleasePrBranch(fx, "update-with-rebase"); + const moved = git(fx.origin, "rev-parse", `refs/heads/${RELEASE_BRANCH}`); + const refusal = movedAfterAnchoring(base, unlisted).message; + + const checked = refusalOf(() => anchorCheck(releasePrHead(fx, "pr-moved"))); + expect(checked).not.toContain("release-pipeline.ts anchor"); + expect(checked).toBe(refusal); + // The stamp the check no longer offers, run anyway: from a full clone, and from the hook job's depth-1 checkout, which cannot tell. + const byHand = await subcommand( + clone(fx.root, fx.origin, "operator"), + { GITHUB_SHA: unlisted }, + "anchor", + ); + const shallow = refusalOf(() => + anchorReleasePr({ cwd: shallowClone(fx, "hook-rerun"), sourceSha: unlisted }), + ); + expect({ + status: byHand.status, + said: byHand.stderr.trim(), + shallow, + head: git(fx.origin, "rev-parse", `refs/heads/${RELEASE_BRANCH}`), + }).toEqual({ + status: 1, + said: `release-pipeline anchor: ${refusal}`, + shallow: [ + `${recording(base)}, and this shallow checkout cannot tell whether that is an older commit than ${unlisted}.`, + "Run the command from a clone that holds the full history:", + ` GITHUB_SHA=${unlisted} bun .github/scripts/release-pipeline.ts anchor`, + ].join("\n"), + head: moved, + }); + + // The repair the refusal names: release-please's rebuild lists the commit and carries no anchor, so the same command stamps it. + createReleasePrBranch(fx, unlisted, "2.2.0", true, [base, unlisted]); + const rebuilt = clone(fx.root, fx.origin, "hook-after-rebuild"); + expect(anchorReleasePr({ cwd: rebuilt, sourceSha: unlisted }).changed).toBe(true); + expect(anchorCheck(releasePrHead(fx, "pr-rebuilt")).boundary).toBe(unlisted); + }); + + test('the command the refusal names, run from a clean checkout after main moved, anchors the release PR, and answers "already anchored" once it is', async () => { + const fx = seedFixture(); + const base = pushGreenCommit(fx, "notes-base", "bundle-base\n").sha; + // A refresh whose hook never ran, then a hidden-type commit: no later run of the pipeline anchors this PR. + createReleasePrBranch(fx, base, "2.2.0"); + landOnMain(fx, "hidden", "docs: land after the refresh"); + expect(() => anchorCheck(releasePrHead(fx, "pr-unanchored"))).toThrow( + notAnchored("missing", PLACEHOLDER, base), + ); + const operator = clone(fx.root, fx.origin, "operator"); + const repair = await subcommand(operator, { GITHUB_SHA: base }, "anchor"); + expect({ status: repair.status, said: repair.stderr.trim().split("\n").at(-1) }).toEqual({ + status: 0, + said: `release-please--branches--main: anchored at ${base}`, + }); + expect(anchorCheck(releasePrHead(fx, "pr-repaired")).boundary).toBe(base); + // The same command against the anchored branch pushes nothing and gives the answer the refusal quotes. + const anchored = git(fx.origin, "rev-parse", `refs/heads/${RELEASE_BRANCH}`); + const again = await subcommand(operator, { GITHUB_SHA: base }, "anchor"); + expect({ + status: again.status, + said: again.stderr.trim(), + head: git(fx.origin, "rev-parse", `refs/heads/${RELEASE_BRANCH}`), + }).toEqual({ status: 0, said: "already anchored", head: anchored }); + }); + + test("a shallow checkout is refused before any verdict", () => { + const fx = seedFixture(); + anchoredReleasePr(fx); + // Depth 1, as the job's checkout was before it asked for the history: the branch's own chain is not in it. + const dir = join(fx.root, "pr-shallow"); + const branch = ["--branch", RELEASE_BRANCH]; + execFileSync("git", [ + "clone", + "--quiet", + "--depth", + "1", + ...branch, + `file://${fx.origin}`, + dir, + ]); + expect(() => anchorCheck(dir)).toThrow( + new Error( + "anchor-check needs the full history (fetch-depth: 0) and this checkout is shallow: the commit release-please built the " + + "branch on is read from the branch's own history.", + ), + ); + }); }); describe("the release hook's reads of the draft", () => { @@ -1194,12 +1719,20 @@ 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;/], + [ + "not JSON", + "{ not json\n", + /^release-please-config\.json is not valid JSON \(.+\)\.\nFix it by PR\.$/, + ], + [ + "a JSON array", + "[]\n", + /^release-please-config\.json holds an array, not a JSON object\.\nFix it by PR\.$/, + ], [ "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;/, + /^last-release-sha in release-please-config\.json is 42, not a 40-hex commit sha\.\nFix it by PR\.$/, ], ]; test.each(malformed)( @@ -1229,8 +1762,10 @@ describe("the release-please config", () => { 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.", + new Error( + `release-please-config.json is missing from ${checker}: the release pipeline reads release-please's boundary (last-release-sha) from it.\n` + + "Restore it by PR.", + ), ); }); });