diff --git a/.github/repo-platform-manifest.json b/.github/repo-platform-manifest.json index 92fc8200..43bc74a4 100644 --- a/.github/repo-platform-manifest.json +++ b/.github/repo-platform-manifest.json @@ -13,7 +13,7 @@ ".github/instructions/review.instructions.md": {"class": "managed", "hash": "3390ceea1180610460f4f2cf3e40af3921f1aba288f4c11c39fbcd780dcdfdf8"}, ".github/repo-platform-manifest.json": {"class": "managed", "hash": null, "commit": "0bfaaa6e1f528c1d99c4b41b8e0d84461bc9fb78"}, ".github/settings.local.yml": {"class": "starter"}, - ".github/settings.yml": {"class": "managed", "hash": "f9d4e717e3c008d018aff7017cb3d5c3baccc21f6447710f812da7a58cdac7fb"}, + ".github/settings.yml": {"class": "managed", "hash": "b038a2b43242a9d9fc8ab44df0ae89f3a1b1172d2ffd9a216cee2d5bc5d12554"}, ".github/workflows/auto-assign.yml": {"class": "managed", "hash": "42534cbfecfcc71acbee7f232d2c2b6c03b523e8158fdbac354d25a27aaf6510"}, ".github/workflows/auto-format.yml": {"class": "starter"}, ".github/workflows/checks.yml": {"class": "starter"}, diff --git a/.github/scripts/release-pipeline.ts b/.github/scripts/release-pipeline.ts index 18db15fd..40d0a49f 100644 --- a/.github/scripts/release-pipeline.ts +++ b/.github/scripts/release-pipeline.ts @@ -1,6 +1,6 @@ /** - * The release pipeline's git topology. main stays source-only, no version tag ever lands on it, and every ref a - * consumer names points at a packaged commit: the child of one main commit, carrying that commit's build. + * The release pipeline's git topology. main stays source-only, and every ref a consumer names points at a packaged + * commit: the child of one main commit, carrying that commit's build. A release puts one tag on main: its marker. * * packaged commit = parent: the main commit; tree: its tree + lib/index.js + lib/settings.schema.json + lib/pkg/, * package.json minus its preparation scripts @@ -8,27 +8,28 @@ * refs/tags/latest -> the packaged commit of the newest main commit * refs/tags/vX.Y.Z -> the packaged commit of the release's merge commit; never moved * refs/tags/vX -> the same commit, moved on each release in the line + * refs/tags/source-vX.Y.Z -> the release's merge commit itself, on main: the marker; never moved, never pushed here * * Every artifact is a function of its main commit alone, so runs for different commits never wait on each other * and a rerun mints the same name and verifies instead of appending. latest and vX move through movePointer alone. * Every subcommand is the whole `run:` of a workflow step; no step runs two. * - * release-please cuts the DRAFT release without a tag (`draft` on, `force-tag-creation` off) and creates or - * 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. + * release-please cuts the DRAFT release as source-vX.Y.Z, creates that tag on the merge commit, and finds its + * previous release by it (release-please-config.json: `draft` and `force-tag-creation` on, component `source`). The + * release hook parses the name into vX.Y.Z, checks the marker, mints the version refs, and renames the draft. Only a + * releasable commit creates or refreshes the release PR (always-update off), which publishes the `next` pre-release. * - * This file dispatches; the sections live under release-pipeline/ rather than lib/, which holds what several - * scripts share while these belong to this one. + * This file dispatches; its sections live under release-pipeline/, as lib/ holds only what several scripts share. * * Node builtins only: bun runs this before `bun install`. */ import { dispatch, type Handler } from "./lib/entry.js"; import { requireEnv, setOutput } from "./lib/workflow-step.js"; -import { anchorCheck, anchorReleasePr, boundaryCheck } from "./release-pipeline/anchor.js"; import { RELEASE_ASSETS, releaseAssets, + renameDraft, resolveSource, STEP_OUTPUTS, } from "./release-pipeline/draft.js"; @@ -121,18 +122,6 @@ export const COMMANDS: Record = { const result = retagMajor({ cwd, tag: requireEnv("TAG"), sourceSha: requireEnv("GITHUB_SHA") }); console.error(result.move.reason); }, - anchor: () => { - const result = anchorReleasePr({ cwd, sourceSha: requireEnv("GITHUB_SHA") }); - console.error(result.reason); - }, - "boundary-check": () => { - const result = boundaryCheck(cwd); - console.error(`boundary is fresh: ${result.boundary}`); - }, - "anchor-check": () => { - const result = anchorCheck(cwd); - console.error(`the release PR carries this cycle's anchor: ${result.boundary}`); - }, "package-commit": () => { const result = packageCommit({ cwd, @@ -170,24 +159,32 @@ export const COMMANDS: Record = { } }, "resolve-source": () => { - const source = resolveSource(requireEnv("TAG")); + const source = resolveSource(requireEnv("MARKER")); if ("refusal" in source) { console.log(`::error::${source.refusal}`); return 1; } - setOutput(STEP_OUTPUTS["resolve-source"][0], source.sha); + const [sha, tag] = STEP_OUTPUTS["resolve-source"]; + setOutput(sha, source.sha); + setOutput(tag, source.tag); }, "verify-assets": () => { - const tag = requireEnv("TAG"); - const assets = releaseAssets(tag); + const marker = requireEnv("MARKER"); + const assets = releaseAssets(marker); if (assets.join(" ") !== RELEASE_ASSETS.join(" ")) { console.log( - `::error::release ${tag} carries assets [${assets.join(" ")}], expected ` + + `::error::release ${marker} carries assets [${assets.join(" ")}], expected ` + `[${RELEASE_ASSETS.join(" ")}]; re-run package-release before anything publishes.`, ); return 1; } }, + "rename-draft": () => { + const marker = requireEnv("MARKER"); + const tag = requireEnv("TAG"); + renameDraft(marker, tag); + console.error(`release ${marker} now carries tag ${tag} and title ${tag}`); + }, }; if (import.meta.main) { diff --git a/.github/scripts/release-pipeline/anchor.ts b/.github/scripts/release-pipeline/anchor.ts deleted file mode 100644 index 9ca3bbb5..00000000 --- a/.github/scripts/release-pipeline/anchor.ts +++ /dev/null @@ -1,269 +0,0 @@ -/** - * 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"; - -/** 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"; - -/** 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--"; - -/** release-please's branch for main's release PR. */ -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's text; a missing file is refused naming it, any other read failure is thrown as it came. */ -function readConfigText(cwd: string): string { - try { - return 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.`, - ); - } -} - -/** What a JSON value that is no object holds, as the refusal names it. */ -function jsonKind(parsed: unknown): string { - if (Array.isArray(parsed)) { - return "an array"; - } - if (parsed === null) { - return "null"; - } - return `a ${typeof parsed}`; -} - -/** The config's JSON as an object; anything else is refused naming the file. */ -function parseConfigObject(text: string): Record { - 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)) { - throw new Error(`${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)); - 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 { - /** 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. */ - sourceSha: string; - - /** How many pushes a release-please refresh may overtake before the run gives up; 3 by default. */ - attempts?: number; -} - -export interface AnchorResult { - changed: boolean; - 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; - 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.", - ); -} - -/** 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. */ -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")) { - 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" }; - } - 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` }; - } - 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; - } - 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 */ -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) { - return result; - } - // 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.`, - ); -} - -/** 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. */ -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.`, - ); -} - -/** 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"]; - const latest = newestReleaseMerge(cwd); - if (latest === "") { - return noMergeVerdict(cwd, recorded); - } - 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 index daffb8ce..202925b3 100644 --- a/.github/scripts/release-pipeline/draft.ts +++ b/.github/scripts/release-pipeline/draft.ts @@ -1,42 +1,90 @@ /** - * The release hook's reads of the draft release through gh: the merge commit it targets, and the assets it carries - * before anything publishes. + * The release hook's work on the draft release through gh: the merge commit it targets and the version tag its name + * gives, the assets it carries before anything publishes, and the rename that hands it to the publish stage. */ -import { capture } from "../lib/workflow-step.js"; +import { spawnSync } from "node:child_process"; +import { capture, run } from "../lib/workflow-step.js"; import { FULL_SHA } from "./git.js"; +import { versionTagOf } from "./tags.js"; -/** What each subcommand writes to GITHUB_OUTPUT; test/workflows/post-green-workflow.test.ts judges the release hook's later reads of the resolved merge commit against this table. */ -export const STEP_OUTPUTS = { "resolve-source": ["sha"] } as const; +/** What each subcommand writes to GITHUB_OUTPUT; test/workflows/post-green-workflow.test.ts judges the release hook's later reads of them against this table. */ +export const STEP_OUTPUTS = { "resolve-source": ["sha", "tag"] } as const; /** 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.`, - }; +/** gh's whole stderr when no release, draft or published, carries the name it was asked for. */ +const NO_RELEASE = "release not found"; + +/** The two fields of the release `resolveSource` reads, under gh's names for them. */ +interface SourceView { + isDraft: boolean; + targetCommitish: string; +} + +/** The release named `marker` as gh reads it, or null when none carries the name. Any other failure ends the step with gh's stderr and status. */ +function viewSource(marker: string): SourceView | null { + const view = spawnSync("gh", ["release", "view", marker, "--json", "isDraft,targetCommitish"], { + stdio: ["inherit", "pipe", "pipe"], + encoding: "utf8", + }); + if (view.error !== undefined) { + throw view.error; + } + + if (view.status !== 0 && view.stderr.trim() === NO_RELEASE) { + return null; + } + + if (view.status !== 0) { + process.stderr.write(view.stderr); + process.exit(view.status ?? 1); + } + + return JSON.parse(view.stdout) as SourceView; +} + +/** The version tag the draft's marker name gives, and 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. + * + * no release under the name -> refused, the state the hook's rename leaves: nothing proceeds under the new name + * a published release -> refused: release-please published the marker's name itself (`"draft": false`) + * a target that is no sha -> refused before a checkout or a tag could name it (a branch name on a hand-made draft) + */ +export function resolveSource(marker: string): { sha: string; tag: string } | { refusal: string } { + const tag = versionTagOf(marker); + const view = viewSource(marker); + if (view === null) { + return { + refusal: + `no release is named ${marker}.\n` + + `If the release ${tag} exists, the hook already renamed the draft and this job is done: do not re-run it.`, + }; + } + + if (!view.isDraft) { + return { + refusal: + `release ${marker} is published, not a draft: users already see the marker's name as a release, so nothing is packaged from it.\n` + + 'Fix release-please-config.json by PR: "draft": true on package ".".', + }; + } + + if (!FULL_SHA.test(view.targetCommitish)) { + return { + refusal: `draft ${marker}'s target commitish is '${view.targetCommitish}', not a commit SHA; refusing to package an unidentified source.`, + }; + } + + return { sha: view.targetCommitish, tag }; } /** 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 { +export function releaseAssets(marker: string): string[] { + const view = JSON.parse(capture(["gh", "release", "view", marker, "--json", "assets"])) as { assets: { name: string }[]; }; return view.assets @@ -44,3 +92,9 @@ export function releaseAssets(tag: string): string[] { .filter((name) => name !== "attestation.json") .sort(); } + +/** The draft takes the version tag as its tag and title and stays a draft, which the managed publish stage publishes. + * --verify-tag refuses a tag origin lacks: GitHub would create it on main, at the draft's target, on publish. */ +export function renameDraft(marker: string, tag: string): void { + run(["gh", "release", "edit", marker, "--tag", tag, "--title", tag, "--verify-tag"]); +} diff --git a/.github/scripts/release-pipeline/tags.ts b/.github/scripts/release-pipeline/tags.ts index bd4a52c0..8a138215 100644 --- a/.github/scripts/release-pipeline/tags.ts +++ b/.github/scripts/release-pipeline/tags.ts @@ -2,7 +2,8 @@ * 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, a lease, or the prune's deletion, and a refused one is judged by re-reading origin, never - * by git's words. + * by git's words. The marker, release-please's own tag source-vX.Y.Z on a release's merge commit, is parsed and + * checked here and never pushed. */ import { execFileSync } from "node:child_process"; @@ -54,6 +55,12 @@ const TAG_FETCH = ["--filter=blob:none"]; /** Reads of a ref that keeps moving before a run gives up. */ const PUSH_ATTEMPTS = 3; +/** The marker's prefix: release-please creates `source-vX.Y.Z` on a release's merge commit on main and finds its previous release by it. */ +const MARKER_PREFIX = "source-"; + +/** A marker's name, capturing the version tag it names. */ +const MARKER = new RegExp(`^${MARKER_PREFIX}(v\\d+\\.\\d+\\.\\d+)$`); + /** 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 { @@ -66,6 +73,19 @@ function releaseMajor(tag: string): string { return `v${match[1]}`; } +/** The version tag a marker names (`source-v1.2.3` gives `v1.2.3`): the one parse of the tag the release hook is + * handed. Any other name is refused, so no ref derives from a name release-please's config stopped producing. */ +export function versionTagOf(handed: string): string { + const tag = handed.match(MARKER)?.[1]; + if (tag === undefined) { + throw new Error( + `the handed tag ${JSON.stringify(handed)} is not ${MARKER_PREFIX}vX.Y.Z, the name release-please gives a release here; refusing to derive a version tag from it.\n` + + 'Fix release-please-config.json by PR: "include-component-in-tag": true at the top, "component": "source" on package ".".', + ); + } + return tag; +} + /** 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) { @@ -486,7 +506,7 @@ interface PackageOptions { /** The checkout, at the merge commit with the bundle built. */ cwd: string; - /** The release's vX.Y.Z tag, as release-please cut the draft. */ + /** The release's vX.Y.Z tag: the version tag the draft's marker name gives. */ tag: string; /** The release's merge commit, resolved from the draft (not necessarily this run's own push); the tag's recorded source. */ @@ -510,6 +530,27 @@ interface PackagedRelease { latest: PointerMove; } +/** The marker of the release `tag` names must sit on its merge commit: release-please starts the next changelog at + * the marker, ignores a marker it could not create, and reports nothing for one on another commit. packageRelease + * calls this before its first push, and each refusal names the push that repairs it. */ +function assertMarker(cwd: string, tag: string, sourceSha: string): void { + const ref = `refs/tags/${MARKER_PREFIX}${tag}`; + const at = observeRemote(cwd, ref).peeled; + if (at === sourceSha) { + return; + } + if (at === "") { + throw new Error( + `${ref} is missing on origin; release-please finds this release by that tag on the release merge ${sourceSha}.\n` + + `Create it, then rerun this job:\ngit push origin ${sourceSha}:${ref}`, + ); + } + throw new Error( + `${ref} points at ${at}, not at the release merge ${sourceSha}; release-please would start the next changelog from the wrong commit.\n` + + `Move it as a repository admin (the source-tags ruleset freezes it), then rerun this job:\ngit push --force origin ${sourceSha}:${ref}`, + ); +} + export const FROZEN = "the release-tags ruleset freezes version tags, so no rerun can replace it - inspect it by hand."; @@ -540,6 +581,7 @@ export function packageRelease(options: PackageOptions): PackagedRelease { ); } assertOnMain(cwd, sourceSha, "package, tag, or publish a commit main does not hold"); + assertMarker(cwd, tag, sourceSha); const ref = `refs/tags/${tag}`; const tagged = ensureTag( cwd, diff --git a/.github/settings.local.yml b/.github/settings.local.yml index 1595e181..b87e24a3 100644 --- a/.github/settings.local.yml +++ b/.github/settings.local.yml @@ -105,3 +105,27 @@ rulesets: - type: update - type: non_fast_forward bypass_actors: [] + + # The release markers: release-please creates `source-vX.Y.Z` once, on a + # release's merge commit on main, and finds its previous release by it. + # A marker never moves (update, non_fast_forward) and never goes + # (deletion): a moved or missing one makes the next changelog wrong in + # silence. No creation rule, so the Actions token creates each one. The + # admin role may bypass, to repair a marker the release hook refused. + # `v*` does not match `source-v...`, so no ruleset above covers these. + - name: source-tags + target: tag + enforcement: active + conditions: + ref_name: + include: + - source-v* + exclude: [] + rules: + - type: deletion + - type: non_fast_forward + - type: update + bypass_actors: + - actor_id: 5 + actor_type: RepositoryRole + bypass_mode: always diff --git a/.github/settings.yml b/.github/settings.yml index 81df05ac..7c674b82 100644 --- a/.github/settings.yml +++ b/.github/settings.yml @@ -214,6 +214,22 @@ rulesets: - actor_id: 5 actor_type: RepositoryRole bypass_mode: always + - name: source-tags + target: tag + enforcement: active + conditions: + ref_name: + include: + - source-v* + exclude: [] + rules: + - type: deletion + - type: non_fast_forward + - type: update + bypass_actors: + - actor_id: 5 + actor_type: RepositoryRole + bypass_mode: always environments: - name: github-pages wait_timer: 0 diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 2022bdf6..50a6c436 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -31,40 +31,6 @@ jobs: - name: Test run: bun run test - # The freshness tripwire behind the boundary anchor (update-release-pr.yml): version tags live off main, so a lost - # 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 - boundary-check: - if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - # The newest release merge can be arbitrarily far behind HEAD. - fetch-depth: 0 - - uses: ./.github/actions/setup - with: - install: "false" - - name: Confirm last-release-sha matches the newest release merge - run: bun .github/scripts/release-pipeline.ts boundary-check - - # An unanchored release PR must be unmergeable here, not park the pipeline after its merge. - 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 - - uses: ./.github/actions/setup - with: - install: "false" - - name: Require the release PR to carry this cycle's anchor - 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 # declaration leaking a devDependency type fails here, not on a consumer's machine. # The consumer runs on the engines floor package.json advertises and on current node; tsdown needs a newer node diff --git a/.github/workflows/update-release-pr.yml b/.github/workflows/update-release-pr.yml index 599fc233..8a8f8570 100644 --- a/.github/workflows/update-release-pr.yml +++ b/.github/workflows/update-release-pr.yml @@ -1,18 +1,13 @@ # The release-PR hook of the release pipeline: repo-owned (a generated-once starter, never overwritten by sync), # called by the managed ci.yml whenever release-please creates or refreshes the release PR, independently of any # 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/anchor.ts) inside the PR branch -# publish-next -> the library pre-release to npm under the `next` dist-tag +# head the refresh was built on. Its one job, publish-next, publishes that commit's 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 # always-update off, so the PR is refreshed only when the release notes change, i.e. when a commit of a type its # changelog shows lands (feat, fix, perf, revert, or a breaking marker). A push of hidden types alone (test, docs, chore, # 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. name: Update Release PR @@ -20,28 +15,15 @@ on: workflow_call: inputs: pr_number: - description: The release PR's number + description: The release PR's number, declared because the managed ci.yml passes it and read by no job here required: true type: string head_branch: - description: The release PR's head branch (push updates back to it) + description: The release PR's head branch, declared because the managed ci.yml passes it and read by no job here required: true type: string jobs: - anchor: - runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: - contents: write - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: ./.github/actions/setup - with: - install: "false" - - name: Anchor the boundary inside the release PR branch - run: bun .github/scripts/release-pipeline.ts anchor - # The npm-publish lane exists because the registry has no compare-and-set: the pre-publish guard's read still holds # when its publish lands, whichever publisher (this job, update-release.yml's) took the lane before it. # no ACTIONS_ID_TOKEN_REQUEST_URL -> the caller granted none (a fork, a ceiling change); step one skips the rest @@ -49,7 +31,7 @@ jobs: # queue: max -> a lane keeps ONE pending job by default; a dropped job fails the call # guard reads the record -> the lane serializes but does not order; GitHub promises no queue order # confirm holds the lane -> a job ends once the registry shows its publish, so the next holder's guard sees it - # No permissions key: a job asking above the caller's ceiling fails the whole call, anchor included, so the OIDC + # No permissions key: a job asking above the caller's ceiling fails the whole call, so the OIDC # grant arrives by inheritance from the id-token: write the managed ci.yml gives this call, and a ceiling without # it reaches step one's warn-and-skip instead of a validation failure. publish-next: diff --git a/.github/workflows/update-release.yml b/.github/workflows/update-release.yml index f571c2c1..f64570e2 100644 --- a/.github/workflows/update-release.yml +++ b/.github/workflows/update-release.yml @@ -1,28 +1,29 @@ # 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/tags.ts; release-please-config.json sets `draft` and pins -# `force-tag-creation` off). +# cutting the DRAFT release and the attested publish. release-please names the draft source-vX.Y.Z and creates that +# tag, the marker, on the merge commit on main (release-please-config.json: `draft`, `force-tag-creation`, component +# `source`); it finds its previous release by it. This hook mints the tags a user pins: main is source-only, so each +# points at a packaged commit, the merge commit's child carrying its build (the topology is in +# .github/scripts/release-pipeline/tags.ts). # -# resolve the merge commit from the draft's target commitish -> build the bundle, the schema, and the library -# package -> find or mint the merge commit's packaged commit under its build tag, mint vX.Y.Z there ONCE, move latest forward -# retag-major -> move vX there (forward only), then upload bundle and schema to the draft (a published release freezes its assets) -# verify-release -> confirm the draft's assets; the managed publish stage then attests and flips the release live -# publish-npm -> publish the library build to npm as the stable version through trusted publishing, once the assets check out +# resolve-source -> the handed source-vX.Y.Z parsed ONCE into vX.Y.Z, and the merge commit read from the draft's target commitish +# package -> the marker held to the merge commit, then its packaged commit found or minted under its build tag, vX.Y.Z minted there ONCE, latest moved forward +# retag-major -> move vX there (forward only), then upload bundle and schema to the draft (a published release freezes its assets) +# verify-release -> confirm the draft's assets, then give the draft vX.Y.Z as its tag and title; the managed publish stage attests and publishes it +# publish-npm -> publish the library build to npm as the stable version through trusted publishing, once the assets check out # # Only ci.yml reaches this workflow, with the tag release-please just cut from its own merged PR, so a hand-edited -# manifest never mints a tag. A rerun converges: a version npm already holds is skipped, and once a NEWER release -# shipped, the major stays on it. +# manifest never mints a tag. A rerun of the failed jobs converges: a version npm already holds is skipped, and once a +# NEWER release shipped, the major stays on it. The draft answers to source-vX.Y.Z until the rename, to vX.Y.Z after. # Finishing a dead run by hand (prefer re-running its FAILED jobs: a hand run has no attestation.json, a packaged -# commit it mints carries no run trailer, and the npm publish has no hand path at all, only the workflow's OIDC -# identity may publish). NEVER publish before `package` has minted the tag: a tagless draft makes GitHub create -# vX.Y.Z lazily on main. +# commit it mints carries no run trailer, and only the workflow's OIDC identity may publish to npm). NEVER rename or +# publish the draft before `package` has minted the tag: GitHub creates a missing vX.Y.Z on main when the draft publishes. # git checkout (the draft's target commitish) # bun install --frozen-lockfile --ignore-scripts && bun run build # TAG=vX.Y.Z GITHUB_SHA= bun .github/scripts/release-pipeline.ts package # TAG=vX.Y.Z GITHUB_SHA= bun .github/scripts/release-pipeline.ts retag-major -# gh release upload vX.Y.Z lib/settings.schema.json lib/index.js --clobber +# gh release upload source-vX.Y.Z lib/settings.schema.json lib/index.js --clobber +# MARKER=source-vX.Y.Z TAG=vX.Y.Z bun .github/scripts/release-pipeline.ts rename-draft # gh release edit vX.Y.Z --draft=false name: Update Release @@ -31,7 +32,7 @@ on: workflow_call: inputs: tag: - description: The draft release's tag (recorded on the release; the ref itself is created here, on the build commit) + description: The tag release-please cut the draft under, source-vX.Y.Z (the marker, on the merge commit) required: true type: string @@ -45,6 +46,7 @@ jobs: contents: write outputs: source-sha: ${{ steps.source.outputs.sha }} + tag: ${{ steps.source.outputs.tag }} steps: # The run's own head, on main, supplies the script that resolves the release; the checkout below replaces it # with the release's source. @@ -59,7 +61,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} - TAG: ${{ inputs.tag }} + MARKER: ${{ inputs.tag }} run: bun .github/scripts/release-pipeline.ts resolve-source - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -74,27 +76,27 @@ jobs: # upload below relies on. - name: Create or verify the version tag on the build commit env: - TAG: ${{ inputs.tag }} + TAG: ${{ steps.source.outputs.tag }} SOURCE_SHA: ${{ steps.source.outputs.sha }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: GITHUB_SHA="$SOURCE_SHA" bun .github/scripts/release-pipeline.ts package - name: Move the major tag to the packaged commit env: - TAG: ${{ inputs.tag }} + TAG: ${{ steps.source.outputs.tag }} SOURCE_SHA: ${{ steps.source.outputs.sha }} run: GITHUB_SHA="$SOURCE_SHA" bun .github/scripts/release-pipeline.ts retag-major - name: Upload the packaged artifacts to the draft release env: GH_TOKEN: ${{ github.token }} - TAG: ${{ inputs.tag }} - run: gh release upload "$TAG" lib/settings.schema.json lib/index.js --clobber + MARKER: ${{ inputs.tag }} + run: gh release upload "$MARKER" lib/settings.schema.json lib/index.js --clobber verify-release: needs: [package-release] runs-on: ubuntu-latest timeout-minutes: 5 permissions: - contents: write # read-only in spirit; write is what makes drafts visible to gh + contents: write # drafts are visible to gh under write alone, and the last step edits the draft steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: ./.github/actions/setup @@ -107,13 +109,22 @@ jobs: env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} - TAG: ${{ inputs.tag }} + MARKER: ${{ inputs.tag }} run: bun .github/scripts/release-pipeline.ts verify-assets + # The hook's last write to the draft, after the mint: the managed publish stage publishes the draft under the + # tag it carries, and GitHub creates a tag origin lacks on main, at the draft's target. + - name: Give the draft the version tag as its tag and title + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + MARKER: ${{ inputs.tag }} + TAG: ${{ needs.package-release.outputs.tag }} + run: bun .github/scripts/release-pipeline.ts rename-draft # Publishes the release's library build to npm as the stable version through trusted publishing: no registry # token, provenance attached by npm. Behind verify-release, so nothing reaches the registry before the draft's # assets check out. Until the owner's one-time npm setup exists this job fails and holds the draft; a rerun converges. - # package-release sits in needs for its output alone (a job reads outputs only from its direct dependencies). + # package-release sits in needs for its outputs alone (a job reads outputs only from its direct dependencies). # The lane is the one update-release-pr.yml's publish-next takes: the registry has no compare-and-set, so the two # publishers never overlap and a verdict still holds when its publish lands; queue: max keeps every queued # publish (a lane drops all but one pending job by default). @@ -156,6 +167,6 @@ jobs: # recovered by a newer push's run has provenance naming two commits. - name: Publish the release to npm env: - TAG: ${{ inputs.tag }} + TAG: ${{ needs.package-release.outputs.tag }} SOURCE_SHA: ${{ needs.package-release.outputs.source-sha }} run: GITHUB_SHA="$SOURCE_SHA" bun .github/scripts/release-pipeline.ts npm-publish stable diff --git a/AGENTS.md b/AGENTS.md index 55bd93a5..c82593e5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,5 +61,6 @@ Code is the source of truth: this section holds only the rules and the decisions ### Releases - The `release` job in ci.yml stays out of all-green's `needs`, so a release only happens from a green main. -- Every consumable ref points at a packaged commit off main, and a pointer only ever moves forward along main; no tag ever lands on main. +- Every consumable ref points at a packaged commit off main, and a pointer only ever moves forward along main; no tag a user can pin lands on main. +- `source-vX.Y.Z` marks a release merge on main: release-please creates it and finds its previous release by it. The release hook checks it and never writes one. - The npm package publishes from the release hooks through trusted publishing; docs/reference/library.md states the versioning rules. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index efe80fa3..8baa330e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -62,5 +62,12 @@ bun run fuzz --seed --iterations 1 # replay one failing itera - Releases run downstream of the `all-green` gate: ci.yml calls the fleet's release workflow, so a release or a release-PR refresh only happens from a green `main`. - release-please does the version math, the changelog, the version pins, and the release PR; merging that PR cuts the release. +- Merge the release PR only after release-please has rebuilt it for the newest `fix` or `feat` on `main`: a commit that lands after the PR's notes were built ships in that release and appears in no changelog. +- release-please tags each release merge on `main` as `source-vX.Y.Z` and finds its previous release by that tag. It is a marker, not a pin: `main` carries no built action. The `source-tags` ruleset freezes it. - Every ref a `uses:` pin can name (`vX.Y.Z`, the moving major, `latest`) points at a packaged commit: the child of one `main` commit, carrying its tree plus the built bundle, schema, and library; every green push mints one under a `build/.` tag, the ten newest kept. The tags up to v2.0.0 point at `main` commits from when `main` committed the bundle. -- Three repo-owned hooks share the ref work: `post-green.yml` packages every green push under its `build/` tag, prunes the window, and moves `latest`; `update-release.yml` mints `vX.Y.Z` and moves the major, packages the merge commit when no build tag does, and reconciles `latest` and the build-tag window as post-green does; `update-release-pr.yml` anchors release-please's boundary (`last-release-sha`) and publishes the `next` pre-release to npm. The git topology lives in `.github/scripts/release-pipeline/tags.ts` and its test. + +Three repo-owned hooks share the ref work; the git topology lives in `.github/scripts/release-pipeline/tags.ts` and its test. + +- `post-green.yml` packages every green push under its `build/` tag, prunes the window, and moves `latest`. +- `update-release.yml` checks the release's `source-vX.Y.Z` marker, mints `vX.Y.Z`, and moves the major. It packages the merge commit when no build tag does, and reconciles `latest` and the build-tag window as post-green does. It then renames the draft release to `vX.Y.Z` and, last, publishes the release to npm. +- `update-release-pr.yml` publishes the `next` pre-release to npm. diff --git a/README.md b/README.md index a87e2cdf..02416841 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ Apply declarative repository settings from `.github/settings.yml`: a loud, state | `@vX.Y.Z` or a commit SHA | One release, frozen by a ruleset | Byte-stable behavior | | `@latest` | The newest green `main` commit, packaged; breaking changes arrive here unannounced, ahead of any release | Trying unreleased fixes | - Every pin points at a packaged commit: the child of one `main` commit, carrying its tree plus the built action. `main` is source-only and not runnable as an action. The tags up to v2.0.0 point at release commits on `main` from when `main` still committed the bundle. +- A `source-vX.Y.Z` tag marks a release's commit on `main` for the release tooling. It is not a pin: that commit carries no built action. - v2 activates settings keys that were inert on v1: `actions.oidc_customization_sub`, `actions.fork_pr_contributor_approval`, `actions.fork_pr_workflows_private_repos`, and `branches[].protection.required_signatures`. Audit them before moving a `@v1` pin; a stale `required_signatures: false` would remove a hand-enabled requirement. - Only the latest release is supported; fixes are not backported (see [SECURITY.md](.github/SECURITY.md)). Each major has an [upgrade guide](docs/upgrading/README.md). diff --git a/release-please-config.json b/release-please-config.json index 2cf909a0..84093da4 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -3,14 +3,15 @@ "bump-minor-pre-major": true, "bump-patch-for-minor-pre-major": true, "commit-search-depth": 5000, - "include-component-in-tag": false, - "last-release-sha": "6dbc9a96a69818e569f8372acac82bb152a7c389", + "include-component-in-tag": true, "packages": { ".": { "release-type": "simple", + "component": "source", + "pull-request-title-pattern": "chore${scope}: release ${version}", "initial-version": "1.0.0", "draft": true, - "force-tag-creation": false, + "force-tag-creation": true, "extra-files": [ "README.md", "docs/start/getting-started.md", diff --git a/test/scripts/release-pipeline-build.test.ts b/test/scripts/release-pipeline-build.test.ts index f8636fbd..d6dc8f83 100644 --- a/test/scripts/release-pipeline-build.test.ts +++ b/test/scripts/release-pipeline-build.test.ts @@ -45,6 +45,7 @@ import { plantCommitIn, positionOf, pushGreenCommit, + pushMarker, remoteRef, rivalPackage, seedFixture, @@ -609,6 +610,7 @@ describe("packageCommit", () => { write(later, "package.json", manifestJson("2.1.1")); const laterMerge = commitAll(later, "chore(main): release 2.1.1 (#44)"); git(later, "push", "--quiet", "origin", "HEAD:refs/heads/main"); + pushMarker(later, "2.1.1", laterMerge); writeBuild(later, "packaged-bundle-bytes-2\n"); const laterPackaged = packageCommit({ cwd: later, sourceSha: laterMerge }).commit; const greens: { dir: string; sha: string }[] = []; diff --git a/test/scripts/release-pipeline-fixture.ts b/test/scripts/release-pipeline-fixture.ts index 8668994e..d6b8c378 100644 --- a/test/scripts/release-pipeline-fixture.ts +++ b/test/scripts/release-pipeline-fixture.ts @@ -252,7 +252,7 @@ const PACKAGED_DIFF = "lib/index.js\nlib/pkg/index.d.ts\nlib/pkg/index.js\nlib/settings.schema.json\npackage.json"; /** The changelog release-please writes for a 2.1.0 release. */ -export const CHANGELOG_21 = `# Changelog +const CHANGELOG_21 = `# Changelog ## [2.1.0](https://example.invalid/compare/v2.0.0...v2.1.0) (2026-08-14) @@ -323,9 +323,14 @@ export function expectPackage( expect(identityOf(fx.origin, packaged)).toBe(BOT_IDENTITY); } +/** The marker: release-please's own tag for a release, as its cut creates it on the merge commit before the hook runs. */ +export function pushMarker(cwd: string, version: string, sha: string): void { + git(cwd, "push", "--quiet", "origin", `${sha}:refs/tags/source-v${version}`); +} + /** - * origin/main at the 2.0.0 release (seed) plus the squash-merged 2.1.0 release PR, with the bundle freshly "built" in the work clone: the state the - * packaging job sees. + * origin/main at the 2.0.0 release (seed) plus the squash-merged 2.1.0 release PR under its marker, with the bundle freshly "built" in the + * work clone: the state the packaging job sees. */ export function seedFixture(): Fixture { const root = mkdtempSync(join(tmpdir(), FIXTURE_PREFIX)); @@ -340,14 +345,7 @@ export function seedFixture(): Fixture { write( work, "release-please-config.json", - `${JSON.stringify( - { - "last-release-sha": "0000000000000000000000000000000000000000", - packages: { ".": { "release-type": "simple", draft: true } }, - }, - null, - 2, - )}\n`, + `${JSON.stringify({ packages: { ".": { "release-type": "simple", draft: true } } }, null, 2)}\n`, ); write(work, "CHANGELOG.md", CHANGELOG_21.replace(/## \[2\.1\.0\][\s\S]*?\n\n## /, "## ")); // A workflow file rides along in every packaged commit: its parent is the source, so no diff GitHub judges shows a workflow change. @@ -361,6 +359,7 @@ export function seedFixture(): Fixture { write(work, "package.json", manifestJson("2.1.0")); const mergeSha = commitAll(work, "chore(main): release 2.1.0 (#42)"); git(work, "push", "--quiet", "origin", "HEAD:refs/heads/main"); + pushMarker(work, "2.1.0", mergeSha); writeBuild(work, "packaged-bundle-bytes-1\n"); return { root, origin, work, seedSha, mergeSha }; } @@ -704,9 +703,6 @@ export function originHooks(fx: Fixture, hooks: { "pre-receive"?: string; update /** The moving pointer's ref. */ export const LATEST = "refs/tags/latest"; -/** The anchor push, as the shim logs it. */ -export const ANCHOR_PUSH = ["push", "origin", "HEAD:refs/heads/release-please--branches--main"]; - /** A create-once push of `commit` to `ref`, as the shim logs it. */ export const createOf = (commit: string, ref: string): string[] => [ "push", diff --git a/test/scripts/release-pipeline.test.ts b/test/scripts/release-pipeline.test.ts index 4ad437ab..80608883 100644 --- a/test/scripts/release-pipeline.test.ts +++ b/test/scripts/release-pipeline.test.ts @@ -7,25 +7,19 @@ import { describe, expect, setDefaultTimeout, test } from "bun:test"; import { execFileSync } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { basename, join } from "node:path"; -import { - anchorCheck, - anchorReleasePr, - boundaryCheck, -} from "../../.github/scripts/release-pipeline/anchor.js"; import { FROZEN, packageCommit, packageRelease, retagMajor, + versionTagOf, } from "../../.github/scripts/release-pipeline/tags.js"; import { ROOT } from "../root.js"; import { withTempDir } from "../temp-dir.js"; import { - ANCHOR_PUSH, BOT_IDENTITY, buildTagOf, buildTags, - CHANGELOG_21, checkoutOf, clone, commitAll, @@ -46,6 +40,7 @@ import { packagedOf, parentsOf, pushGreenCommit, + pushMarker, remoteRef, rivalPackage, seedFixture, @@ -132,9 +127,10 @@ describe("the fixture repositories", () => { const TAG = "refs/tags/v2.1.0"; const V2 = "refs/tags/v2"; +const MARKER = "refs/tags/source-v2.1.0"; describe("packageRelease", () => { - test("a fresh release mints the merge commit's package under its build tag, tags it, and creates latest; a rerun verifies it and pushes nothing", () => { + test("a fresh release mints the merge commit's package under its build tag, tags it, and creates latest, leaving the marker on the merge commit; a rerun verifies it and pushes nothing", () => { const fx = seedFixture(); let result: ReturnType | undefined; const pushes = withPushPlans(fx, [], () => { @@ -183,6 +179,7 @@ describe("packageRelease", () => { expect(rerunPushes).toEqual([]); expect(git(fx.origin, "rev-parse", `${TAG}^{}`)).toBe(packaged); expect(buildTags(fx)).toEqual([buildTag]); + expect(git(fx.origin, "rev-parse", MARKER)).toBe(fx.mergeSha); }); test("a release whose package post-green already minted is tagged there, with no second package", () => { @@ -398,18 +395,81 @@ describe("packageRelease", () => { }); test.each<[tag: string, error: RegExp]>([ + // A pre-release: no version ref derives from it. ["v2.1-rc.0", /not a vX\.Y\.Z release tag/], - // The shape release-please mints with include-component-in-tag on; the config keeps it off, and the hook refuses it either way. - ["github-settings-as-code-v2.1.0", /not a vX\.Y\.Z release tag/], + // The marker itself, unparsed: package takes the version tag resolve-source parsed from it. + ["source-v2.1.0", /not a vX\.Y\.Z release tag/], // Well-shaped, but not the version this source's manifest released. ["v2.2.0", /did not release/], ])("the tag %s mints nothing", (tag, error) => { const fx = seedFixture(); - expect(() => packageRelease({ cwd: fx.work, tag, sourceSha: fx.mergeSha })).toThrow(error); - expect(remoteRef(fx, `refs/tags/${tag}`)).toBe(""); + const pushes = withPushPlans(fx, [], () => { + expect(() => packageRelease({ cwd: fx.work, tag, sourceSha: fx.mergeSha })).toThrow(error); + }); + expect(pushes).toEqual([]); + expect(git(fx.origin, "rev-parse", MARKER)).toBe(fx.mergeSha); expect(buildTags(fx)).toEqual([]); }); + // release-please ignores a marker it could not create and reports nothing for one on another commit, so the hook + // holds the marker to the merge commit before its first push. + test.each< + [state: string, plant: (fx: Fixture) => string, repair: (fx: Fixture, at: string) => string] + >([ + [ + "missing on origin", + (fx) => { + git(fx.work, "push", "--quiet", "origin", `:${MARKER}`); + return ""; + }, + (fx) => + `${MARKER} is missing on origin; release-please finds this release by that tag on the release merge ${fx.mergeSha}.\n` + + "Create it, then rerun this job:\n" + + `git push origin ${fx.mergeSha}:${MARKER}`, + ], + [ + "on the packaged commit", + (fx) => { + const packaged = packageCommit({ cwd: fx.work, sourceSha: fx.mergeSha }).commit; + git(fx.work, "push", "--quiet", "--force", "origin", `${packaged}:${MARKER}`); + return packaged; + }, + (fx, at) => + `${MARKER} points at ${at}, not at the release merge ${fx.mergeSha}; release-please would start the next changelog from the wrong commit.\n` + + "Move it as a repository admin (the source-tags ruleset freezes it), then rerun this job:\n" + + `git push --force origin ${fx.mergeSha}:${MARKER}`, + ], + [ + "on another release's merge", + (fx) => { + git(fx.work, "push", "--quiet", "--force", "origin", `${fx.seedSha}:${MARKER}`); + return fx.seedSha; + }, + (fx, at) => + `${MARKER} points at ${at}, not at the release merge ${fx.mergeSha}; release-please would start the next changelog from the wrong commit.\n` + + "Move it as a repository admin (the source-tags ruleset freezes it), then rerun this job:\n" + + `git push --force origin ${fx.mergeSha}:${MARKER}`, + ], + ])( + "the marker %s stops the release before any push, naming its repair", + (_state, plant, repair) => { + const fx = seedFixture(); + const at = plant(fx); + const release = checkoutOf(fx, "release", fx.mergeSha, "packaged-bundle-bytes-1\n"); + let error: unknown; + const pushes = withPushPlans(fx, [], () => { + try { + packageRelease({ cwd: release, tag: "v2.1.0", sourceSha: fx.mergeSha }); + } catch (thrown) { + error = thrown; + } + }); + expect(error).toEqual(new Error(repair(fx, at))); + expect(pushes).toEqual([]); + expect(remoteRef(fx, TAG)).toBe(""); + }, + ); + // The build the packaged commit must carry, spoiled two ways; the shared check refuses both entry points before // any push. The entry shown for the empty file is git's empty blob at ls-tree's size column. const unbuilt = ["lib/index.js", "lib/settings.schema.json", "lib/pkg/index.js"].flatMap( @@ -475,6 +535,7 @@ function prepareNextRelease( write(dir, ".release-please-manifest.json", `${JSON.stringify({ ".": version }, null, 2)}\n`); const mergeSha = commitAll(dir, `chore(main): release ${version} (#${prNumber})`); git(dir, "push", "--quiet", "origin", "HEAD:refs/heads/main"); + pushMarker(dir, version, mergeSha); writeBuild(dir, bundle); return { dir, mergeSha }; } @@ -662,384 +723,6 @@ 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. */ -function createReleasePrBranch( - fx: Fixture, - from: string, - version: string, - push = true, -): { 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`); - 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}`, - ); - const sha = commitAll(dir, `chore(main): release ${version}`); - if (push) { - git( - dir, - "push", - "--quiet", - "--force", - "origin", - "HEAD:refs/heads/release-please--branches--main", - ); - } - return { dir, sha }; -} - -function releasePrConfig(fx: Fixture, name: string): { boundary: string; manifest: string } { - const check = clone(fx.root, fx.origin, name); - git(check, "checkout", "--quiet", "release-please--branches--main"); - const config = JSON.parse(readFileSync(join(check, "release-please-config.json"), "utf8")) as { - "last-release-sha": string; - }; - return { - boundary: config["last-release-sha"], - manifest: readFileSync(join(check, ".release-please-manifest.json"), "utf8"), - }; -} - -describe("anchorReleasePr", () => { - test("the boundary lands inside the release PR branch and merges onto main", () => { - const fx = seedFixture(); - const mainHead = fx.mergeSha; - createReleasePrBranch(fx, mainHead, "2.2.0"); - const worker = clone(fx.root, fx.origin, "anchor-worker"); - const result = anchorReleasePr({ cwd: worker, sourceSha: mainHead }); - expect(result.changed).toBe(true); - expect(identityOf(worker, "HEAD")).toBe(BOT_IDENTITY); - expect(localIdentity(worker)).toBe(FIXTURE_IDENTITY); - const check = clone(fx.root, fx.origin, "anchor-check"); - git(check, "checkout", "--quiet", "release-please--branches--main"); - const config = JSON.parse(readFileSync(join(check, "release-please-config.json"), "utf8")) as { - "last-release-sha": string; - }; - expect(config["last-release-sha"]).toBe(mainHead); - expect(readFileSync(join(check, ".release-please-manifest.json"), "utf8")).toContain("2.2.0"); - const again = anchorReleasePr({ - cwd: clone(fx.root, fx.origin, "anchor-again"), - sourceSha: mainHead, - }); - expect(again.changed).toBe(false); - git(check, "checkout", "--quiet", "main"); - git(check, "merge", "--quiet", "--squash", "release-please--branches--main"); - git(check, "commit", "--quiet", "-m", "chore(main): release 2.2.0 (#60)"); - git(check, "push", "--quiet", "origin", "HEAD:refs/heads/main"); - expect(boundaryCheck(check).boundary).toBe(mainHead); - }); - - test("no release PR branch is a no-op", () => { - const fx = seedFixture(); - const worker = clone(fx.root, fx.origin, "anchor-nobranch"); - const result = anchorReleasePr({ cwd: worker, sourceSha: fx.mergeSha }); - expect(result).toMatchObject({ changed: false, reason: "no release PR branch to anchor" }); - }); - - test("a moved main makes the anchor defer to the newer run", () => { - 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"); - }); - - test("a branch built on an older head is left for its own refresh to anchor", () => { - 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"); - }); - - test("a release-please refresh between anchors re-anchors from the same checkout", () => { - const fx = seedFixture(); - createReleasePrBranch(fx, fx.mergeSha, "2.2.0"); - const worker = clone(fx.root, fx.origin, "anchor-twice"); - expect(anchorReleasePr({ cwd: worker, sourceSha: fx.mergeSha }).changed).toBe(true); - // The refresh force-push wipes the anchor commit; the SAME clone must fetch the rewritten branch (a locally checked-out branch would make git - // refuse the fetch). - createReleasePrBranch(fx, fx.mergeSha, "2.3.0"); - expect(anchorReleasePr({ cwd: worker, sourceSha: fx.mergeSha }).changed).toBe(true); - expect(releasePrConfig(fx, "anchor-twice-check").boundary).toBe(fx.mergeSha); - }); - - test("a push overtaken by a refresh mid-anchor is reapplied on the refreshed branch", () => { - const fx = seedFixture(); - createReleasePrBranch(fx, fx.mergeSha, "2.2.0"); - const refresh = createReleasePrBranch(fx, fx.mergeSha, "2.3.0", false); - const worker = clone(fx.root, fx.origin, "anchor-raced"); - let result: ReturnType | undefined; - const pushes = withPushPlans( - fx, - [ - { - competitor: { - from: refresh.dir, - sha: refresh.sha, - ref: "refs/heads/release-please--branches--main", - }, - }, - ], - () => { - result = anchorReleasePr({ cwd: worker, sourceSha: fx.mergeSha }); - }, - ); - expect(result).toMatchObject({ - changed: true, - reason: `release-please--branches--main: anchored at ${fx.mergeSha}`, - }); - expect(pushes).toEqual([ANCHOR_PUSH, ANCHOR_PUSH]); - const anchored = releasePrConfig(fx, "anchor-raced-check"); - expect(anchored.boundary).toBe(fx.mergeSha); - expect(anchored.manifest).toContain("2.3.0"); - expect(git(fx.origin, "rev-parse", "refs/heads/release-please--branches--main^")).toBe( - refresh.sha, - ); - }); - - test("a push overtaken on every attempt gives up naming the rewriter", () => { - const fx = seedFixture(); - createReleasePrBranch(fx, fx.mergeSha, "2.2.0"); - const refreshes = ["2.3.0", "2.4.0", "2.5.0"].map((v) => - createReleasePrBranch(fx, fx.mergeSha, v, false), - ); - const worker = clone(fx.root, fx.origin, "anchor-lost"); - const pushes = withPushPlans( - fx, - refreshes.map((r) => ({ - competitor: { from: r.dir, sha: r.sha, ref: "refs/heads/release-please--branches--main" }, - })), - () => { - 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.", - ); - }, - ); - expect(pushes).toEqual([ANCHOR_PUSH, ANCHOR_PUSH, ANCHOR_PUSH]); - expect(git(fx.origin, "rev-parse", "refs/heads/release-please--branches--main")).toBe( - refreshes[2]?.sha ?? "", - ); - }); - - test.each(PERMANENT)( - "%s fails the first anchor push for good, with git's own words", - (_name, stderr) => { - const fx = seedFixture(); - const branch = createReleasePrBranch(fx, fx.mergeSha, "2.2.0"); - const worker = clone(fx.root, fx.origin, "anchor-denied"); - let error: unknown; - const pushes = withPushPlans(fx, [{ fail: { stderr, status: 128 } }], () => { - try { - anchorReleasePr({ cwd: worker, sourceSha: fx.mergeSha }); - } catch (thrown) { - error = thrown; - } - }); - expect(pushes).toEqual([ANCHOR_PUSH]); - expect(error).toEqual( - new Error( - `git push origin HEAD:refs/heads/release-please--branches--main failed: ${stderr.trim()}`, - ), - ); - expect(git(fx.origin, "rev-parse", "refs/heads/release-please--branches--main")).toBe( - branch.sha, - ); - }, - ); -}); - -describe("boundaryCheck", () => { - test("a boundary equal to the newest release merge passes", () => { - const fx = seedFixture(); - const dir = clone(fx.root, fx.origin, "boundary-eq"); - write( - dir, - "release-please-config.json", - `${JSON.stringify( - { - "last-release-sha": fx.mergeSha, - packages: { ".": { "release-type": "simple", draft: true } }, - }, - null, - 2, - )}\n`, - ); - commitAll(dir, "chore: align the fixture boundary"); - expect(boundaryCheck(dir).boundary).toBe(fx.mergeSha); - }); - - test("a stale boundary fails loudly with the repair value", () => { - 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/); - }); - - test("a history without release merges and no recorded boundary is pre-first-release", () => { - const fx = seedFixture(); - const dir = clone(fx.root, fx.origin, "boundary-none"); - git(dir, "checkout", "--quiet", fx.seedSha); - write( - dir, - "release-please-config.json", - `${JSON.stringify({ packages: { ".": { "release-type": "simple", draft: true } } }, null, 2)}\n`, - ); - commitAll(dir, "chore: bootstrap release-please before any release"); - expect(boundaryCheck(dir).boundary).toContain("no release merge"); - }); - - test("a recorded boundary that is not on this history fails naming that", () => { - const fx = seedFixture(); - 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/); - }); - - test("a shallow checkout is refused before any verdict", () => { - const fx = seedFixture(); - // The release merge is within depth 2 but its parent, the recorded boundary, is not: a truncated history would call it stale. - write( - fx.work, - "release-please-config.json", - `${JSON.stringify( - { - "last-release-sha": fx.seedSha, - packages: { ".": { "release-type": "simple", draft: true } }, - }, - null, - 2, - )}\n`, - ); - commitAll(fx.work, "chore: align the fixture boundary"); - git(fx.work, "push", "--quiet", "origin", "HEAD:refs/heads/main"); - const dir = join(fx.root, "boundary-shallow"); - execFileSync("git", ["clone", "--quiet", "--depth", "2", `file://${fx.origin}`, dir]); - expect(() => boundaryCheck(dir)).toThrow(/needs the full history.*shallow/); - }); - - test("a complete history holding the boundary but no recognizable merge names the matcher", () => { - const fx = seedFixture(); - const dir = clone(fx.root, fx.origin, "boundary-drift"); - git(dir, "checkout", "--quiet", fx.seedSha); - write( - dir, - "release-please-config.json", - `${JSON.stringify( - { - "last-release-sha": fx.seedSha, - packages: { ".": { "release-type": "simple", draft: true } }, - }, - null, - 2, - )}\n`, - ); - commitAll(dir, "chore(main): release: 2.1.0 (#42)"); - expect(() => boundaryCheck(dir)).toThrow(/history holds it.*RELEASE_SUBJECT/); - }); - - test("a boundary newer than the newest recognized merge is drift, not a rollback", () => { - const fx = seedFixture(); - const dir = clone(fx.root, fx.origin, "boundary-newer"); - write(dir, "src/marker.ts", "export const marker = 3;\n"); - const between = commitAll(dir, "feat: land between two releases"); - // The 2.2.0 release PR merged under a subject RELEASE_SUBJECT does not match, so the 2.1.0 merge stays the newest recognized one while the - // boundary sits after it. - write(dir, ".release-please-manifest.json", `${JSON.stringify({ ".": "2.2.0" }, null, 2)}\n`); - write( - dir, - "release-please-config.json", - `${JSON.stringify( - { - "last-release-sha": between, - packages: { ".": { "release-type": "simple", draft: true } }, - }, - null, - 2, - )}\n`, - ); - commitAll(dir, "chore(main): release v2.2.0 (#43)"); - expect(() => boundaryCheck(dir)).toThrow(/NEWER than.*must not be rolled back/); - }); - - test("a boundary on an unmerged branch is stale, not newer", () => { - const fx = seedFixture(); - const dir = clone(fx.root, fx.origin, "boundary-off-main"); - // A descendant of the release merge that never landed on main: the stale message's rollback IS the repair. - git(dir, "checkout", "--quiet", "-b", "side"); - write(dir, "src/marker.ts", "export const marker = 4;\n"); - const offMain = commitAll(dir, "feat: never merged"); - git(dir, "checkout", "--quiet", "main"); - write( - dir, - "release-please-config.json", - `${JSON.stringify( - { - "last-release-sha": offMain, - packages: { ".": { "release-type": "simple", draft: true } }, - }, - null, - 2, - )}\n`, - ); - commitAll(dir, "chore: record a boundary from the wrong branch"); - expect(() => boundaryCheck(dir)).toThrow(/stale boundary.*set last-release-sha/); - }); - - test("a subject that only shares the release prefix is not a release merge", () => { - const fx = seedFixture(); - const dir = clone(fx.root, fx.origin, "boundary-decoy"); - write( - dir, - "release-please-config.json", - `${JSON.stringify( - { - "last-release-sha": fx.mergeSha, - packages: { ".": { "release-type": "simple", draft: true } }, - }, - null, - 2, - )}\n`, - ); - commitAll(dir, "chore: align the fixture boundary"); - // Newer than the release merge and sharing its prefix, but not its shape: must neither become the boundary nor park the check. - write(dir, "src/marker.ts", "export const marker = 7;\n"); - commitAll(dir, "chore(main): release pipeline documentation"); - expect(boundaryCheck(dir).boundary).toBe(fx.mergeSha); - }); -}); - -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"); - const worker = clone(fx.root, fx.origin, "anchor-check-worker"); - expect(anchorReleasePr({ cwd: worker, sourceSha: fx.mergeSha }).changed).toBe(true); - const pr = clone(fx.root, fx.origin, "anchor-check-ok"); - git(pr, "checkout", "--quiet", "release-please--branches--main"); - expect(anchorCheck(pr).boundary).toBe(fx.mergeSha); - }); -}); - describe("the release hook's reads of the draft", () => { /** A gh first on PATH that logs its argv and answers with the planned stdout, or the planned stderr and status. */ type Gh = { stdout?: string; stderr?: string; status?: number }; @@ -1065,7 +748,12 @@ describe("the release hook's reads of the draft", () => { "release-pipeline", ROOT, join(dir, "runner"), - { TAG: "v2.1.0", ...env, PATH: `${bin}:${process.env.PATH ?? ""}` }, + { + MARKER: "source-v2.1.0", + TAG: "v2.1.0", + ...env, + PATH: `${bin}:${process.env.PATH ?? ""}`, + }, subcommand, ); const log = join(dir, "calls.log"); @@ -1074,13 +762,17 @@ describe("the release hook's reads of the draft", () => { }); } const sha = "b8df084c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a"; - const VIEW_SOURCE = "release view v2.1.0 --json targetCommitish --jq .targetCommitish"; - const VIEW_ASSETS = "release view v2.1.0 --json assets"; + const VIEW_SOURCE = "release view source-v2.1.0 --json isDraft,targetCommitish"; + const VIEW_ASSETS = "release view source-v2.1.0 --json assets"; + const RENAME = "release edit source-v2.1.0 --tag v2.1.0 --title v2.1.0 --verify-tag"; + /** gh's answer to VIEW_SOURCE, as it prints the two fields. */ + const release = (isDraft: boolean, targetCommitish: string): string => + `${JSON.stringify({ isDraft, targetCommitish })}\n`; const assets = (...names: string[]): string => `${JSON.stringify({ assets: names.map((name) => ({ name })) })}\n`; // The whole step per case: what gh was asked, what the step printed and wrote, and how it ended. The draft is - // read by tag alone, and a refusal is the annotation the shell step printed. + // read and renamed under the name release-please gave it, and a refusal is the annotation the shell step printed. test.each< [ name: string, @@ -1096,18 +788,39 @@ describe("the release hook's reads of the draft", () => { ] >([ [ - "resolve-source: a draft targeting the merge commit writes it as the step output", + "resolve-source: a draft targeting the merge commit writes it and the version tag source-v2.1.0 names as the step outputs", "resolve-source", - { stdout: `${sha}\n` }, - { stdout: "", stderr: "", status: 0, outputs: [`sha=${sha}`], calls: [VIEW_SOURCE] }, + { stdout: release(true, sha) }, + { + stdout: "", + stderr: "", + status: 0, + outputs: [`sha=${sha}`, "tag=v2.1.0"], + calls: [VIEW_SOURCE], + }, + ], + [ + // release-please publishes the release itself under `"draft": false`, before this hook runs. + "resolve-source: a published release is refused where a draft passes, naming the config key that hands the hook a draft", + "resolve-source", + { stdout: release(false, sha) }, + { + stdout: + "::error::release source-v2.1.0 is published, not a draft: users already see the marker's name as a release, so nothing is packaged from it.\n" + + 'Fix release-please-config.json by PR: "draft": true on package ".".\n', + stderr: "", + status: 1, + outputs: [], + calls: [VIEW_SOURCE], + }, ], [ "resolve-source: a draft targeting a branch name is refused before anything checks it out", "resolve-source", - { stdout: "main\n" }, + { stdout: release(true, "main") }, { stdout: - "::error::draft v2.1.0's target commitish is 'main', not a commit SHA; refusing to package an unidentified source.\n", + "::error::draft source-v2.1.0's target commitish is 'main', not a commit SHA; refusing to package an unidentified source.\n", stderr: "", status: 1, outputs: [], @@ -1115,10 +828,31 @@ describe("the release hook's reads of the draft", () => { }, ], [ - "resolve-source: a gh that fails ends the step with its status and stderr, writing no output", + // The hook's rename leaves no release under the marker's name, so a job re-run after it lands here. + "resolve-source: gh finding no release under the handed name is said in the hook's words, pointing at the renamed release", "resolve-source", - { stderr: "release not found\n", status: 4 }, - { stdout: "", stderr: "release not found\n", status: 4, outputs: [], calls: [VIEW_SOURCE] }, + { stderr: "release not found\n", status: 1 }, + { + stdout: + "::error::no release is named source-v2.1.0.\n" + + "If the release v2.1.0 exists, the hook already renamed the draft and this job is done: do not re-run it.\n", + stderr: "", + status: 1, + outputs: [], + calls: [VIEW_SOURCE], + }, + ], + [ + "resolve-source: any other gh failure ends the step with gh's status and stderr, writing no output", + "resolve-source", + { stderr: "HTTP 502: Bad Gateway (https://api.github.com/graphql)\n", status: 4 }, + { + stdout: "", + stderr: "HTTP 502: Bad Gateway (https://api.github.com/graphql)\n", + status: 4, + outputs: [], + calls: [VIEW_SOURCE], + }, ], [ "verify-assets: both packaged assets beside the attestation pass silently", @@ -1132,7 +866,7 @@ describe("the release hook's reads of the draft", () => { { stdout: assets("index.js") }, { stdout: - "::error::release v2.1.0 carries assets [index.js], expected [index.js settings.schema.json]; " + + "::error::release source-v2.1.0 carries assets [index.js], expected [index.js settings.schema.json]; " + "re-run package-release before anything publishes.\n", stderr: "", status: 1, @@ -1146,7 +880,7 @@ describe("the release hook's reads of the draft", () => { { stdout: assets() }, { stdout: - "::error::release v2.1.0 carries assets [], expected [index.js settings.schema.json]; " + + "::error::release source-v2.1.0 carries assets [], expected [index.js settings.schema.json]; " + "re-run package-release before anything publishes.\n", stderr: "", status: 1, @@ -1160,7 +894,7 @@ describe("the release hook's reads of the draft", () => { { stdout: assets("index.js", "settings.schema.json", "extra.txt") }, { stdout: - "::error::release v2.1.0 carries assets [extra.txt index.js settings.schema.json], expected " + + "::error::release source-v2.1.0 carries assets [extra.txt index.js settings.schema.json], expected " + "[index.js settings.schema.json]; re-run package-release before anything publishes.\n", stderr: "", status: 1, @@ -1174,84 +908,115 @@ describe("the release hook's reads of the draft", () => { { stderr: "release not found\n", status: 4 }, { stdout: "", stderr: "release not found\n", status: 4, outputs: [], calls: [VIEW_ASSETS] }, ], + [ + "rename-draft: the draft takes the version tag as its tag and title, held to a tag origin has, and the step logs both names", + "rename-draft", + {}, + { + stdout: "", + stderr: "release source-v2.1.0 now carries tag v2.1.0 and title v2.1.0\n", + status: 0, + outputs: [], + calls: [RENAME], + }, + ], + [ + "rename-draft: a gh that fails ends the step with its status and stderr", + "rename-draft", + { + stderr: "tag v2.1.0 doesn't exist in the repo o/r, aborting due to --verify-tag flag\n", + status: 1, + }, + { + stdout: "", + stderr: "tag v2.1.0 doesn't exist in the repo o/r, aborting due to --verify-tag flag\n", + status: 1, + outputs: [], + calls: [RENAME], + }, + ], ])("%s", async (_name, subcommand, gh, expected) => { expect(await runWithGh(gh, {}, subcommand)).toEqual(expected); }); - test.each(["resolve-source", "verify-assets"])( - "%s without TAG is refused before gh is asked", - async (subcommand) => { - expect(await runWithGh({ stdout: `${sha}\n` }, { TAG: undefined }, subcommand)).toEqual({ + test.each<[subcommand: string, unset: string]>([ + ["resolve-source", "MARKER"], + ["verify-assets", "MARKER"], + ])("%s without %s is refused before gh is asked", async (subcommand, unset) => { + expect( + await runWithGh({ stdout: release(true, sha) }, { [unset]: undefined }, subcommand), + ).toEqual({ + stdout: "", + stderr: `${unset} must be set for this step\n`, + status: 1, + outputs: [], + calls: [], + }); + }); + + test.each<[what: string, handed: string]>([ + ["the version tag itself", "v2.1.0"], + ["a name without the v", "source-2.1.0"], + ["another component's tag", "other-v2.1.0"], + ["a pre-release", "source-v2.1.0-rc.1"], + ["a two-part version", "source-v2.1"], + ["a word that is no tag", "release"], + ])( + "resolve-source: handed %s, it refuses the name before gh is asked and derives no version tag", + async (_what, handed) => { + expect( + await runWithGh({ stdout: release(true, sha) }, { MARKER: handed }, "resolve-source"), + ).toEqual({ stdout: "", - stderr: "TAG must be set for this step\n", + stderr: + `release-pipeline resolve-source: the handed tag "${handed}" is not source-vX.Y.Z, the name release-please gives a release here; ` + + "refusing to derive a version tag from it.\n" + + 'Fix release-please-config.json by PR: "include-component-in-tag": true at the top, "component": "source" on package ".".\n', status: 1, outputs: [], calls: [], }); }, ); -}); - -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", () => { + test("handed source-v2.1.0, the hook mints v2.1.0 on the merge commit's package and pushes no source-v* tag", async () => { 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.", - ); + const resolved = await runWithGh({ stdout: release(true, fx.mergeSha) }, {}, "resolve-source"); + expect(resolved.outputs).toEqual([`sha=${fx.mergeSha}`, "tag=v2.1.0"]); + const [sourceSha = "", tag = ""] = resolved.outputs.map((line) => line.slice(4)); + let step: ReturnType | undefined; + const pushes = withPushPlans(fx, [], () => { + step = runStep( + "release-pipeline", + fx.work, + join(fx.root, "runner"), + { TAG: tag, GITHUB_SHA: sourceSha, RUN_URL: undefined }, + "package", + ); + }); + expect(step?.status, step?.stderr).toBe(0); + const packaged = git(fx.origin, "rev-parse", `${TAG}^{}`); + expect(parentsOf(fx.origin, packaged)).toEqual([fx.mergeSha]); + expect(pushes).toEqual([ + createOf(packaged, buildTagOf(fx, fx.mergeSha)), + createOf(packaged, TAG), + moveOf(LATEST, "", packaged), + ]); + expect(git(fx.origin, "rev-parse", MARKER)).toBe(fx.mergeSha); }); }); 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 - // skip-github-release would leave release_created unfired, so the hook that packages and tags never runs. - // release-please resolves each knob per package first, then from the top level, then its default. - const config = JSON.parse(readFileSync(join(ROOT, "release-please-config.json"), "utf8")) as { - "skip-github-release"?: boolean; - packages: Record< - string, - { draft?: boolean; "force-tag-creation"?: boolean; "skip-github-release"?: boolean } - >; - }; + test("the name release-please gives a release under release-please-config.json is one the hook parses", () => { + // release-please names the root package's tag -v when the component is in the tag, else + // v; it reads each key per package first, then from the top level, and puts the component in by default. + type Naming = { "include-component-in-tag"?: boolean; component?: string }; + const config = JSON.parse( + readFileSync(join(ROOT, "release-please-config.json"), "utf8"), + ) as Naming & { packages: Record }; const root = config.packages["."]; - expect({ - draft: root?.draft, - forceTagCreation: root?.["force-tag-creation"], - skipGithubRelease: root?.["skip-github-release"] ?? config["skip-github-release"] ?? false, - }).toEqual({ draft: true, forceTagCreation: false, skipGithubRelease: false }); + const inTag = root?.["include-component-in-tag"] ?? config["include-component-in-tag"] ?? true; + const name = inTag && root?.component ? `${root.component}-v1.2.3` : "v1.2.3"; + expect(versionTagOf(name)).toBe("v1.2.3"); }); }); diff --git a/test/workflows/checks-workflow.test.ts b/test/workflows/checks-workflow.test.ts deleted file mode 100644 index 5439d580..00000000 --- a/test/workflows/checks-workflow.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** - * checks.yml against the code it runs: head_ref conditions that spell the release PR branch prefix the pipeline - * script owns (a drifted spelling skips the anchor-check on every release PR instead of failing there). - */ - -import { describe, expect, test } from "bun:test"; -import { parse as parseYaml } from "yaml"; -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"; - -/** The guard: one anchor-check step, its job gated on the constant, and no job or step condition spelling it otherwise. */ -function expectReleasePrefixes(wf: Workflow): void { - const anchorStepJobs = Object.values(wf.jobs).flatMap((job) => - (job.steps ?? []) - .filter((step) => (step.run ?? "").includes("release-pipeline.ts anchor-check")) - .map(() => job), - ); - expect(anchorStepJobs.length, "checks.yml must run anchor-check in exactly one step").toBe(1); - expect(headRefPrefixesIn(anchorStepJobs[0]?.if)).toEqual([RELEASE_PR_BRANCH_PREFIX]); - for (const literal of headRefPrefixes(wf)) { - expect(literal).toBe(RELEASE_PR_BRANCH_PREFIX); - } -} - -describe("checks.yml release PR branch spelling", () => { - const text = workflowText("checks.yml"); - - // Workflows cannot import the constant, so the head_ref conditions spell it by hand; a drifted spelling skips the anchor-check on every release PR - // instead of failing there. - test("the anchor-check step is gated on RELEASE_PR_BRANCH_PREFIX and nothing spells it otherwise", () => { - expectReleasePrefixes(parseYaml(text) as Workflow); - }); - - test.each<[string, (text: string) => Workflow, RegExp]>([ - [ - "a drifted spelling", - (text) => - parseYaml(text.replaceAll(`'${RELEASE_PR_BRANCH_PREFIX}'`, "'release-pls--'")) as Workflow, - /toEqual[\s\S]*"release-pls--"/, - ], - [ - "a missing anchor-check step", - (text) => { - const wf = parseYaml(text) as Workflow; - for (const job of Object.values(wf.jobs)) { - job.steps = job.steps?.filter((step) => !(step.run ?? "").includes("anchor-check")); - } - return wf; - }, - /must run anchor-check in exactly one step/, - ], - ])( - "%s fails the guard on the assertion that names it (negative control)", - (_case, mutate, failure) => { - expect(() => expectReleasePrefixes(mutate(text))).toThrow(failure); - }, - ); -}); - -describe("headRefPrefixes", () => { - test("collects job- and step-level literals in order and none where no condition tests head_ref", () => { - const wf = { - jobs: { - gate: { - if: "github.event_name == 'pull_request' && startsWith(github.head_ref, 'release-please--')", - steps: [ - { if: 'startsWith(github.head_ref, "feature/")' }, - { if: "startsWith ( github . head_ref ,\n 'hotfix/' )" }, - { if: "github.actor != 'dependabot[bot]'" }, - {}, - ], - }, - plain: { steps: [{}] }, - bare: {}, - }, - }; - expect(headRefPrefixes(wf)).toEqual(["release-please--", "feature/", "hotfix/"]); - expect(headRefPrefixes({ jobs: {} })).toEqual([]); - }); -}); diff --git a/test/workflows/head-ref.ts b/test/workflows/head-ref.ts deleted file mode 100644 index 65900501..00000000 --- a/test/workflows/head-ref.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Workflows cannot import RELEASE_PR_BRANCH_PREFIX, so their if: conditions spell it by hand; this pulls every literal they test github.head_ref - * against. - */ - -/** The parts of a parsed workflow the head_ref walk reads. */ -export interface HeadRefWorkflow { - jobs: Record }>; -} - -// Whitespace-tolerant at every token boundary GitHub's expression lexer allows, so a folded multiline if: still yields its literal. -const HEAD_REF_PREFIX = /startsWith\s*\(\s*github\s*\.\s*head_ref\s*,\s*(['"])([^'"]*)\1\s*\)/g; - -export function headRefPrefixesIn(condition: string | undefined): string[] { - return [...String(condition ?? "").matchAll(HEAD_REF_PREFIX)].map((m) => m[2] ?? ""); -} - -export function headRefPrefixes(wf: HeadRefWorkflow): string[] { - return Object.values(wf.jobs).flatMap((job) => - [job.if, ...(job.steps ?? []).map((step) => step.if)].flatMap(headRefPrefixesIn), - ); -} diff --git a/test/workflows/post-green-workflow.test.ts b/test/workflows/post-green-workflow.test.ts index d0f7bfc8..b420e1b4 100644 --- a/test/workflows/post-green-workflow.test.ts +++ b/test/workflows/post-green-workflow.test.ts @@ -159,7 +159,7 @@ function consumedGrants(step: Step): Array<[scope: string, why: string]> { const grants: Array<[string, string]> = []; if ( /\bgit\s+push\b|\bgh\s+release\s+(?:view|upload|edit|create)\b/.test(run) || - /release-pipeline\.ts (?:package|package-commit|retag-major|anchor|resolve-source|verify-assets)\b/.test( + /release-pipeline\.ts (?:package|package-commit|retag-major|rename-draft|resolve-source|verify-assets)\b/.test( run, ) ) { @@ -219,13 +219,6 @@ describe("the hooks' grants", () => { }); test.each<[string, string, string, Record, RegExp]>([ - [ - "an anchor job narrowed to read while its subcommand pushes", - "update-release-pr.yml", - "anchor", - { contents: "read" }, - /update-release-pr\.yml#anchor: .* needs contents: write/, - ], [ "a publish job without the OIDC grant its npm publish consumes", "update-release-pr.yml", @@ -577,10 +570,10 @@ describe("the probed hooks' wiring", () => { // a prefix bash reads as a command rather than an assignment are each detected and not read, so a hook step in any // of those shapes fails here instead of passing unjudged. for (const run of [ - "bun .github/scripts/release-pipeline.ts\nanchor", - "bun --silent .github/scripts/release-pipeline.ts anchor", - "bun run .github/scripts/release-pipeline.ts anchor", - "bun \\\n .github/scripts/release-pipeline.ts anchor", + "bun .github/scripts/release-pipeline.ts\npackage", + "bun --silent .github/scripts/release-pipeline.ts package", + "bun run .github/scripts/release-pipeline.ts package", + "bun \\\n .github/scripts/release-pipeline.ts package", '1PAT_SET="true" bun .github/scripts/post-green-steps.ts probe', ]) { expect([RUNS_SCRIPT.test(run), scriptStep({ run })], run).toEqual([true, undefined]); @@ -1026,7 +1019,7 @@ describe("the push probe", () => { "release-pipeline", ROOT, join(dir, "runner-temp"), - { TAG: "v2.1.0", PATH: `${bin}:${process.env.PATH ?? ""}` }, + { MARKER: "source-v2.1.0", PATH: `${bin}:${process.env.PATH ?? ""}` }, subcommand, ); expect( @@ -1041,6 +1034,8 @@ describe("the push probe", () => { test("the outputs each script declares are exactly the ones its subcommands' branches write, so the wiring relation reads the truth", async () => { const [, probe] = probeInvocation(); const sha = "b8df084c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a"; + const release = (isDraft: boolean, targetCommitish: string) => + JSON.stringify({ isDraft, targetCommitish }); const assets = (...names: string[]) => JSON.stringify({ assets: names.map((name) => ({ name })) }); // Every branch of every subcommand a script dispatches from a workflow step, as runs; a declared subcommand this @@ -1060,9 +1055,11 @@ describe("the push probe", () => { "resolve-source": async () => ( await Promise.all([ - runDraftRead("resolve-source", { stdout: `${sha}\n` }, 0), - runDraftRead("resolve-source", { stdout: "main\n" }, 1), - runDraftRead("resolve-source", { stderr: "release not found\n", status: 4 }, 4), + runDraftRead("resolve-source", { stdout: release(true, sha) }, 0), + runDraftRead("resolve-source", { stdout: release(false, sha) }, 1), + runDraftRead("resolve-source", { stdout: release(true, "main") }, 1), + runDraftRead("resolve-source", { stderr: "release not found\n", status: 1 }, 1), + runDraftRead("resolve-source", { stderr: "HTTP 502: Bad Gateway\n", status: 4 }, 4), ]) ).flat(), "verify-assets": async () => diff --git a/test/workflows/workflow-loader.ts b/test/workflows/workflow-loader.ts index 86b0d0ce..0a6c4c23 100644 --- a/test/workflows/workflow-loader.ts +++ b/test/workflows/workflow-loader.ts @@ -55,7 +55,7 @@ export interface Workflow { env?: Record; jobs: Record; } -export function workflowText(file: string): string { +function workflowText(file: string): string { return readFileSync(join(WORKFLOWS_DIR, file), "utf8"); }