|
| 1 | +/** |
| 2 | + * release-please's boundary: last-release-sha in release-please-config.json, anchored inside the release PR branch |
| 3 | + * so the squash merge lands it on main, checked fresh on main and present on the release PR. The config is read |
| 4 | + * once here and refused when malformed, never repaired. |
| 5 | + */ |
| 6 | + |
| 7 | +import { readFileSync, writeFileSync } from "node:fs"; |
| 8 | +import { join } from "node:path"; |
| 9 | +import { |
| 10 | + assertFullHistory, |
| 11 | + BOT_IDENTITY, |
| 12 | + FULL_SHA, |
| 13 | + git, |
| 14 | + gitWithEnv, |
| 15 | + isAncestor, |
| 16 | + push, |
| 17 | + tryGit, |
| 18 | +} from "./git.js"; |
| 19 | + |
| 20 | +const CONFIG_FILE = "release-please-config.json"; |
| 21 | + |
| 22 | +/** A squash-merged release-please PR's subject on main. Anchored at both ends wherever release merges are recognized: |
| 23 | + * a prefix match would let "chore(main): release pipeline documentation" impersonate one and park the boundary check. */ |
| 24 | +const RELEASE_SUBJECT = /^chore\(main\): release (\d+\.\d+\.\d+)(?: \(#\d+\))?$/; |
| 25 | + |
| 26 | +/** checks.yml's head_ref conditions spell this by hand; test/workflows/checks-workflow.test.ts pins them to it. */ |
| 27 | +export const RELEASE_PR_BRANCH_PREFIX = "release-please--"; |
| 28 | +const RELEASE_PR_BRANCH = `${RELEASE_PR_BRANCH_PREFIX}branches--main`; |
| 29 | + |
| 30 | +/** release-please's config with the one key this pipeline reads and writes; the rest is release-please's. */ |
| 31 | +type ReleaseConfig = Record<string, unknown> & { "last-release-sha"?: string }; |
| 32 | + |
| 33 | +/** The config as the checkout holds it. The file is the user's and release-please's, so nothing here is repaired: a |
| 34 | + * missing or malformed one, or a boundary that is no commit sha, is refused naming the file (an absent boundary is |
| 35 | + * the pre-first-release state and passes). */ |
| 36 | +function readReleaseConfig(cwd: string): ReleaseConfig { |
| 37 | + let text: string; |
| 38 | + try { |
| 39 | + text = readFileSync(join(cwd, CONFIG_FILE), "utf8"); |
| 40 | + } catch (error) { |
| 41 | + if ((error as { code?: unknown }).code !== "ENOENT") { |
| 42 | + throw error; |
| 43 | + } |
| 44 | + throw new Error( |
| 45 | + `${CONFIG_FILE} is missing from ${cwd}; the release pipeline reads release-please's boundary (last-release-sha) from it. Restore it by PR.`, |
| 46 | + ); |
| 47 | + } |
| 48 | + let parsed: unknown; |
| 49 | + try { |
| 50 | + parsed = JSON.parse(text); |
| 51 | + } catch (error) { |
| 52 | + throw new Error( |
| 53 | + `${CONFIG_FILE} is not valid JSON (${error instanceof Error ? error.message : String(error)}); fix it by PR.`, |
| 54 | + ); |
| 55 | + } |
| 56 | + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { |
| 57 | + const kind = Array.isArray(parsed) |
| 58 | + ? "an array" |
| 59 | + : parsed === null |
| 60 | + ? "null" |
| 61 | + : `a ${typeof parsed}`; |
| 62 | + throw new Error(`${CONFIG_FILE} holds ${kind}, not a JSON object; fix it by PR.`); |
| 63 | + } |
| 64 | + const config = parsed as Record<string, unknown>; |
| 65 | + const boundary = config["last-release-sha"]; |
| 66 | + if (boundary !== undefined && (typeof boundary !== "string" || !FULL_SHA.test(boundary))) { |
| 67 | + throw new Error( |
| 68 | + `last-release-sha in ${CONFIG_FILE} is ${JSON.stringify(boundary)}, not a 40-hex commit sha; fix it by PR.`, |
| 69 | + ); |
| 70 | + } |
| 71 | + return config as ReleaseConfig; |
| 72 | +} |
| 73 | + |
| 74 | +export interface AnchorOptions { |
| 75 | + cwd: string; |
| 76 | + /** Main's head this run tested; the merge parent the boundary records. */ |
| 77 | + sourceSha: string; |
| 78 | + attempts?: number; |
| 79 | +} |
| 80 | + |
| 81 | +export interface AnchorResult { |
| 82 | + changed: boolean; |
| 83 | + reason: string; |
| 84 | +} |
| 85 | + |
| 86 | +/** The boundary rides INSIDE the release PR as last-release-sha = main's current head, the future merge commit's |
| 87 | + * PARENT (known now, unlike the merge sha), so the squash merge lands it on main with no push to main and no admin credential. |
| 88 | + * the parent, not the merge -> the changelog walk then also includes the merge itself, a chore commit it hides anyway |
| 89 | + * a stale parent -> ruled out by the managed release-freshness gate: a PR must contain main's tip to merge */ |
| 90 | +export function anchorReleasePr(options: AnchorOptions): AnchorResult { |
| 91 | + const { cwd, sourceSha, attempts = 3 } = options; |
| 92 | + // If main moved, the newer push's run refreshes the branch and anchors the newer head. |
| 93 | + const head = git(cwd, "ls-remote", "origin", "refs/heads/main").split("\t")[0]; |
| 94 | + if (head !== sourceSha) { |
| 95 | + return { changed: false, reason: `main moved to ${head ?? "?"}; the newer run anchors` }; |
| 96 | + } |
| 97 | + const branchRef = `refs/heads/${RELEASE_PR_BRANCH}`; |
| 98 | + for (let attempt = 1; attempt <= attempts; attempt++) { |
| 99 | + const observed = git(cwd, "ls-remote", "origin", branchRef).split("\t")[0] ?? ""; |
| 100 | + if (observed === "") { |
| 101 | + return { changed: false, reason: "no release PR branch to anchor" }; |
| 102 | + } |
| 103 | + // Detached, never a local branch: a retry after an overtaken push re-fetches, and git refuses to fetch into a checked-out ref. |
| 104 | + git(cwd, "fetch", "--quiet", "--force", "origin", branchRef); |
| 105 | + git(cwd, "checkout", "--quiet", "--force", "--detach", "FETCH_HEAD"); |
| 106 | + // A branch still built from an older main must not be stamped with this head. A shallow history that cannot |
| 107 | + // prove ancestry no-ops the same way; the refresh that follows anchors. |
| 108 | + if (!isAncestor(cwd, sourceSha, "FETCH_HEAD")) { |
| 109 | + return { |
| 110 | + changed: false, |
| 111 | + reason: "the release PR branch is not built on this head; its own refresh anchors", |
| 112 | + }; |
| 113 | + } |
| 114 | + const config = readReleaseConfig(cwd); |
| 115 | + if (config["last-release-sha"] === sourceSha) { |
| 116 | + return { changed: false, reason: "already anchored" }; |
| 117 | + } |
| 118 | + config["last-release-sha"] = sourceSha; |
| 119 | + writeFileSync(join(cwd, CONFIG_FILE), `${JSON.stringify(config, null, 2)}\n`); |
| 120 | + git(cwd, "add", CONFIG_FILE); |
| 121 | + gitWithEnv( |
| 122 | + cwd, |
| 123 | + BOT_IDENTITY, |
| 124 | + "commit", |
| 125 | + "-m", |
| 126 | + "chore: anchor release-please to this release cycle's base", |
| 127 | + "-m", |
| 128 | + "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.", |
| 129 | + ); |
| 130 | + // A newer push's run may already have refreshed and anchored the branch before the fetch above: this run's commit |
| 131 | + // then descends from that anchor, pushes cleanly, and regresses the boundary to the older head. |
| 132 | + const headNow = git(cwd, "ls-remote", "origin", "refs/heads/main").split("\t")[0]; |
| 133 | + if (headNow !== sourceSha) { |
| 134 | + return { changed: false, reason: `main moved to ${headNow ?? "?"}; the newer run anchors` }; |
| 135 | + } |
| 136 | + const refused = push(cwd, "origin", `HEAD:${branchRef}`); |
| 137 | + if (refused === null) { |
| 138 | + return { changed: true, reason: `${RELEASE_PR_BRANCH}: anchored at ${sourceSha}` }; |
| 139 | + } |
| 140 | + if ((git(cwd, "ls-remote", "origin", branchRef).split("\t")[0] ?? "") === observed) { |
| 141 | + throw refused; |
| 142 | + } |
| 143 | + // release-please force-pushed a refresh mid-anchor; reapply on it. |
| 144 | + console.error( |
| 145 | + `anchor push attempt ${attempt}/${attempts} overtaken by a refresh; re-reading the branch`, |
| 146 | + ); |
| 147 | + } |
| 148 | + throw new Error( |
| 149 | + `could not anchor ${RELEASE_PR_BRANCH} after ${attempts} attempts; something keeps rewriting the branch - rerun this job once the branch settles.`, |
| 150 | + ); |
| 151 | +} |
| 152 | + |
| 153 | +/** On main, last-release-sha must be the newest release merge or its parent; anything else means an anchor was lost |
| 154 | + * or a release merge slipped past the pipeline, and every release PR refresh would compute from a stale boundary. |
| 155 | + * A boundary NEWER than every recognized merge is a missed merge or a hand edit; either way it is never rolled back. */ |
| 156 | +export function boundaryCheck(cwd: string): { boundary: string } { |
| 157 | + assertFullHistory( |
| 158 | + cwd, |
| 159 | + "boundary-check", |
| 160 | + "a release merge or the recorded boundary can sit beyond its depth, and no verdict on a truncated history holds.", |
| 161 | + ); |
| 162 | + const recorded = readReleaseConfig(cwd)["last-release-sha"]; |
| 163 | + // The grep only narrows (it matches any message line with the prefix); RELEASE_SUBJECT decides, so |
| 164 | + // "chore(main): release pipeline documentation" can neither become the boundary nor hide the real newest merge. |
| 165 | + const listed = git(cwd, "log", "--grep", "^chore(main): release ", "--format=%H%x09%s"); |
| 166 | + let latest = ""; |
| 167 | + for (const line of listed.split("\n")) { |
| 168 | + const [sha, subject] = line.split("\t"); |
| 169 | + if (sha !== undefined && subject !== undefined && RELEASE_SUBJECT.test(subject)) { |
| 170 | + latest = sha; |
| 171 | + break; |
| 172 | + } |
| 173 | + } |
| 174 | + if (latest === "") { |
| 175 | + if (recorded === undefined) { |
| 176 | + return { boundary: "none (no release merge on this history yet)" }; |
| 177 | + } |
| 178 | + const cause = isAncestor(cwd, recorded, "HEAD") |
| 179 | + ? `this history holds it, so release-please's merge subject no longer matches ${RELEASE_SUBJECT} (investigate RELEASE_SUBJECT)` |
| 180 | + : "it is not on this history at all (not main, or a boundary that never landed)"; |
| 181 | + throw new Error( |
| 182 | + `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.`, |
| 183 | + ); |
| 184 | + } |
| 185 | + const parent = tryGit(cwd, "rev-parse", `${latest}^`); |
| 186 | + if (recorded !== undefined) { |
| 187 | + if (recorded === latest || (parent !== null && recorded === parent)) { |
| 188 | + return { boundary: recorded }; |
| 189 | + } |
| 190 | + if (isAncestor(cwd, latest, recorded) && isAncestor(cwd, recorded, "HEAD")) { |
| 191 | + throw new Error( |
| 192 | + `last-release-sha in ${CONFIG_FILE} is ${JSON.stringify(recorded)}, NEWER than ` + |
| 193 | + `${latest}, the newest release merge whose subject matches ${RELEASE_SUBJECT}: either a ` + |
| 194 | + `newer release merge's subject stopped matching (investigate RELEASE_SUBJECT) or the ` + |
| 195 | + `boundary was edited by hand. It must not be rolled back to ${parent ?? latest}.`, |
| 196 | + ); |
| 197 | + } |
| 198 | + } |
| 199 | + throw new Error( |
| 200 | + `last-release-sha in ${CONFIG_FILE} is ${JSON.stringify(recorded)}, but the newest release ` + |
| 201 | + `merge on main is ${latest} (parent ${parent}); release PR refreshes would be computed ` + |
| 202 | + `from a stale boundary. Fix by PR: set last-release-sha to ${parent ?? latest}.`, |
| 203 | + ); |
| 204 | +} |
| 205 | + |
| 206 | +/** Run on the release PR's own checkout. The managed release-freshness gate proves the PR contains main's tip, not |
| 207 | + * that the anchor commit survived a release-please force-push, so requiring last-release-sha to equal origin's |
| 208 | + * CURRENT main tip makes an unanchored release PR unmergeable instead of parking the pipeline after its merge. */ |
| 209 | +export function anchorCheck(cwd: string): { boundary: string } { |
| 210 | + const recorded = readReleaseConfig(cwd)["last-release-sha"]; |
| 211 | + const tip = git(cwd, "ls-remote", "origin", "refs/heads/main").split("\t")[0] ?? ""; |
| 212 | + if (recorded !== tip) { |
| 213 | + throw new Error( |
| 214 | + `last-release-sha in ${CONFIG_FILE} is ${JSON.stringify(recorded)}, but main's tip is ` + |
| 215 | + `${tip}; the anchor is missing or stale, so merging would land a wrong boundary. ` + |
| 216 | + `The release pipeline's update-release-pr hook re-applies it on every release-PR refresh ` + |
| 217 | + `- wait for (or dispatch) the next green main run, then close/reopen the PR so its ` + |
| 218 | + `checks run on the anchored head (the anchor is pushed with the default token, which ` + |
| 219 | + `triggers no new checks).`, |
| 220 | + ); |
| 221 | + } |
| 222 | + return { boundary: recorded }; |
| 223 | +} |
0 commit comments