From c0dead837063a49902067cc2636b2947466e8d35 Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Thu, 12 Nov 2026 18:00:26 -0500 Subject: [PATCH 1/5] fix(release): find the last release by a marker tag on main release-please names its own tag source-vX.Y.Z, creates it on the release merge on main, and finds its previous release by it. The release hook parses the handed name into vX.Y.Z once, holds the marker to the merge commit before it mints anything, and last gives the draft vX.Y.Z as its tag and title. last-release-sha, the hook job that stamped it, and the two checks that judged it are deleted. --- .github/scripts/release-pipeline.ts | 38 +- .github/scripts/release-pipeline/anchor.ts | 269 -------- .github/scripts/release-pipeline/draft.ts | 37 +- .github/scripts/release-pipeline/tags.ts | 46 +- .github/settings.local.yml | 24 + .github/workflows/checks.yml | 34 - .github/workflows/update-release-pr.yml | 24 +- .github/workflows/update-release.yml | 61 +- AGENTS.md | 3 +- CONTRIBUTING.md | 9 +- README.md | 1 + release-please-config.json | 7 +- test/scripts/release-pipeline-build.test.ts | 2 + test/scripts/release-pipeline-fixture.ts | 24 +- test/scripts/release-pipeline.test.ts | 650 ++++++-------------- test/workflows/checks-workflow.test.ts | 81 --- test/workflows/head-ref.ts | 22 - test/workflows/post-green-workflow.test.ts | 19 +- test/workflows/workflow-loader.ts | 2 +- 19 files changed, 366 insertions(+), 987 deletions(-) delete mode 100644 .github/scripts/release-pipeline/anchor.ts delete mode 100644 test/workflows/checks-workflow.test.ts delete mode 100644 test/workflows/head-ref.ts diff --git a/.github/scripts/release-pipeline.ts b/.github/scripts/release-pipeline.ts index 18db15fdf..ba8df580c 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. The one tag on main is release-please's 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,12 +8,15 @@ * 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 + * release-please cuts the DRAFT release under the name source-vX.Y.Z and creates that tag on the merge commit (`draft` + * and `force-tag-creation` on, component `source`); it finds its previous release by the tag. The release hook parses + * the name into vX.Y.Z, checks the marker, mints the version refs, and renames the draft. release-please 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. * @@ -25,10 +28,10 @@ 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 +124,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 +161,29 @@ 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": () => { + renameDraft(requireEnv("MARKER"), requireEnv("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 9ca3bbb50..000000000 --- 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 daffb8ce6..70e74019d 100644 --- a/.github/scripts/release-pipeline/draft.ts +++ b/.github/scripts/release-pipeline/draft.ts @@ -1,42 +1,45 @@ /** - * The release hook's reads of the draft release through gh: the merge commit it targets, and the assets it carries - * before anything publishes. + * 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 { 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 } { +/** 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; 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(marker: string): { sha: string; tag: string } | { refusal: string } { + const tag = versionTagOf(marker); const target = capture([ "gh", "release", "view", - tag, + marker, "--json", "targetCommitish", "--jq", ".targetCommitish", ]); return FULL_SHA.test(target) - ? { sha: target } + ? { sha: target, tag } : { - refusal: `draft ${tag}'s target commitish is '${target}', not a commit SHA; refusing to package an unidentified source.`, + refusal: `draft ${marker}'s target commitish is '${target}', not a commit SHA; refusing to package an unidentified source.`, }; } /** The draft's asset names beside attestation.json, sorted. Publishing freezes the asset list, so an incomplete one * must stop while the release is still a draft (v2.0.0 shipped assetless exactly this way). */ -export function releaseAssets(tag: string): string[] { - const view = JSON.parse(capture(["gh", "release", "view", tag, "--json", "assets"])) as { +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 +47,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 bd4a52c0f..539e87884 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,26 @@ 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. */ +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."; @@ -520,6 +560,7 @@ export const FROZEN = * pushes (and giving @latest a value even where post-green cannot push). * * source off origin's main -> stop before any push: the frozen tag would be poisoned + * the marker off the merge commit -> stop before any push, naming the push that repairs it * the build tag packages it -> tagged there (normally minted by post-green earlier in this run) * no build tag (pruned, or skipped) -> minted here through post-green's path; a package the window has moved past goes again at once */ @@ -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 1595e1818..b87e24a38 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/workflows/checks.yml b/.github/workflows/checks.yml index 2022bdf68..50a6c436b 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 599fc2339..0fc59d18b 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 @@ -29,19 +24,6 @@ on: 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 f571c2c10..f64570e27 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 55bd93a57..c82593e54 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 efe80fa3e..cc978444a 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, reconciles `latest` and the build-tag window as post-green does, and last renames the draft release to `vX.Y.Z`. +- `update-release-pr.yml` publishes the `next` pre-release to npm. diff --git a/README.md b/README.md index a87e2cdf2..02416841d 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 2cf909a02..84093da48 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 f8636fbd7..d6dc8f836 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 8668994ea..d6b8c378d 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 4ad437ab6..cf69ef750 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,14 @@ 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 targetCommitish --jq .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"; 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,10 +785,16 @@ 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: "", + stderr: "", + status: 0, + outputs: [`sha=${sha}`, "tag=v2.1.0"], + calls: [VIEW_SOURCE], + }, ], [ "resolve-source: a draft targeting a branch name is refused before anything checks it out", @@ -1107,7 +802,7 @@ describe("the release hook's reads of the draft", () => { { stdout: "main\n" }, { 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: [], @@ -1132,7 +827,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 +841,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 +855,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 +869,109 @@ 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 stays a draft", + "rename-draft", + {}, + { stdout: "", stderr: "", 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({ - stdout: "", - stderr: "TAG must be set for this step\n", - status: 1, - outputs: [], - calls: [], - }); - }, - ); -}); + test.each<[subcommand: string, unset: string]>([ + ["resolve-source", "MARKER"], + ["verify-assets", "MARKER"], + ["rename-draft", "MARKER"], + ["rename-draft", "TAG"], + ])("%s without %s is refused before gh is asked", async (subcommand, unset) => { + expect(await runWithGh({ stdout: `${sha}\n` }, { [unset]: undefined }, subcommand)).toEqual({ + stdout: "", + stderr: `${unset} must be set for this step\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.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: `${sha}\n` }, { MARKER: handed }, "resolve-source")).toEqual( + { + stdout: "", + 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: [], + }, + ); }, ); - 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: `${fx.mergeSha}\n` }, {}, "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 5439d5802..000000000 --- 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 659005017..000000000 --- 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 d0f7bfc8b..e87d6017c 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( diff --git a/test/workflows/workflow-loader.ts b/test/workflows/workflow-loader.ts index 86b0d0ce8..0a6c4c23f 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"); } From 2c75ba660ecce32082bf37115a71336e6ff1f0dd Mon Sep 17 00:00:00 2001 From: repo-platform-sync Date: Thu, 12 Nov 2026 23:30:38 +0000 Subject: [PATCH 2/5] chore: sync repo-platform build 23550f798c9c --- .github/repo-platform-manifest.json | 2 +- .github/settings.yml | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/.github/repo-platform-manifest.json b/.github/repo-platform-manifest.json index 248473c2a..fd992cc2f 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": "23550f798c9c15aa044107c9be848467f223a96d"}, ".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/settings.yml b/.github/settings.yml index 81df05ac1..7c674b828 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 From 09518de735699c4b3103a495294345247a52c2ca Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Thu, 12 Nov 2026 18:54:55 -0500 Subject: [PATCH 3/5] fix(release): refuse a published or missing release in resolve-source, and log the rename - resolve-source reads the handed release's draft state and refuses a published one, naming the config key that hands the hook a draft. - resolve-source says in its own words when no release carries the handed name, as after the hook's rename, and points at the renamed release. - rename-draft prints the old name and the new one on success. - The pipeline header and the packageRelease comment fit their caps, and the header no longer says the marker is the only tag on main. - The input descriptions in update-release-pr.yml and the hook order in CONTRIBUTING.md say what the workflows do. - The two rename-draft rows that repeated the shared "must be set" message are deleted. --- .github/scripts/release-pipeline.ts | 19 ++--- .github/scripts/release-pipeline/draft.ts | 79 ++++++++++++++---- .github/scripts/release-pipeline/tags.ts | 4 +- .github/workflows/update-release-pr.yml | 4 +- CONTRIBUTING.md | 2 +- test/scripts/release-pipeline.test.ts | 93 ++++++++++++++++------ test/workflows/post-green-workflow.test.ts | 10 ++- 7 files changed, 153 insertions(+), 58 deletions(-) diff --git a/.github/scripts/release-pipeline.ts b/.github/scripts/release-pipeline.ts index ba8df580c..40d0a49fd 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, and every ref a consumer names points at a packaged - * commit: the child of one main commit, carrying that commit's build. The one tag on main is release-please's marker. + * 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 @@ -14,14 +14,12 @@ * 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 under the name source-vX.Y.Z and creates that tag on the merge commit (`draft` - * and `force-tag-creation` on, component `source`); it finds its previous release by the tag. The release hook parses - * the name into vX.Y.Z, checks the marker, mints the version refs, and renames the draft. release-please 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`. */ @@ -182,7 +180,10 @@ export const COMMANDS: Record = { } }, "rename-draft": () => { - renameDraft(requireEnv("MARKER"), requireEnv("TAG")); + const marker = requireEnv("MARKER"); + const tag = requireEnv("TAG"); + renameDraft(marker, tag); + console.error(`release ${marker} now carries tag ${tag} and title ${tag}`); }, }; diff --git a/.github/scripts/release-pipeline/draft.ts b/.github/scripts/release-pipeline/draft.ts index 70e74019d..363748f32 100644 --- a/.github/scripts/release-pipeline/draft.ts +++ b/.github/scripts/release-pipeline/draft.ts @@ -3,6 +3,7 @@ * gives, the assets it carries before anything publishes, and the rename that hands it to the publish stage. */ +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"; @@ -13,27 +14,71 @@ 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"]; +/** 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; anything but a full sha (a branch name on a hand-made draft) is - * refused before a checkout or a tag could name it. */ + * run can recover an older merge's pending release. + * + * no release under the name -> refused: the hook's rename took the name, and nothing proceeds under the new one + * 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 target = capture([ - "gh", - "release", - "view", - marker, - "--json", - "targetCommitish", - "--jq", - ".targetCommitish", - ]); - return FULL_SHA.test(target) - ? { sha: target, tag } - : { - refusal: `draft ${marker}'s target commitish is '${target}', not a commit SHA; refusing to package an unidentified source.`, - }; + const view = viewSource(marker); + if (view === null) { + return { + refusal: + `no release is named ${marker}.\n` + + `If the release ${tag} exists, this release is past this job: re-run only the jobs that failed.`, + }; + } + + 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 diff --git a/.github/scripts/release-pipeline/tags.ts b/.github/scripts/release-pipeline/tags.ts index 539e87884..8a1382157 100644 --- a/.github/scripts/release-pipeline/tags.ts +++ b/.github/scripts/release-pipeline/tags.ts @@ -531,7 +531,8 @@ interface PackagedRelease { } /** 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. */ + * 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; @@ -560,7 +561,6 @@ export const FROZEN = * pushes (and giving @latest a value even where post-green cannot push). * * source off origin's main -> stop before any push: the frozen tag would be poisoned - * the marker off the merge commit -> stop before any push, naming the push that repairs it * the build tag packages it -> tagged there (normally minted by post-green earlier in this run) * no build tag (pruned, or skipped) -> minted here through post-green's path; a package the window has moved past goes again at once */ diff --git a/.github/workflows/update-release-pr.yml b/.github/workflows/update-release-pr.yml index 0fc59d18b..8a8f8570c 100644 --- a/.github/workflows/update-release-pr.yml +++ b/.github/workflows/update-release-pr.yml @@ -15,11 +15,11 @@ 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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cc978444a..8baa330ef 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -69,5 +69,5 @@ bun run fuzz --seed --iterations 1 # replay one failing itera 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, reconciles `latest` and the build-tag window as post-green does, and last renames the draft release to `vX.Y.Z`. +- `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/test/scripts/release-pipeline.test.ts b/test/scripts/release-pipeline.test.ts index cf69ef750..4f457fc1e 100644 --- a/test/scripts/release-pipeline.test.ts +++ b/test/scripts/release-pipeline.test.ts @@ -762,9 +762,12 @@ describe("the release hook's reads of the draft", () => { }); } const sha = "b8df084c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a"; - const VIEW_SOURCE = "release view source-v2.1.0 --json targetCommitish --jq .targetCommitish"; + 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`; @@ -787,7 +790,7 @@ describe("the release hook's reads of the draft", () => { [ "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: release(true, sha) }, { stdout: "", stderr: "", @@ -796,10 +799,25 @@ describe("the release hook's reads of the draft", () => { 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 source-v2.1.0's target commitish is 'main', not a commit SHA; refusing to package an unidentified source.\n", @@ -810,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, this release is past this job: re-run only the jobs that failed.\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", @@ -870,10 +909,16 @@ describe("the release hook's reads of the draft", () => { { 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 stays a draft", + "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: "", status: 0, outputs: [], calls: [RENAME] }, + { + 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", @@ -897,10 +942,10 @@ describe("the release hook's reads of the draft", () => { test.each<[subcommand: string, unset: string]>([ ["resolve-source", "MARKER"], ["verify-assets", "MARKER"], - ["rename-draft", "MARKER"], - ["rename-draft", "TAG"], ])("%s without %s is refused before gh is asked", async (subcommand, unset) => { - expect(await runWithGh({ stdout: `${sha}\n` }, { [unset]: undefined }, subcommand)).toEqual({ + expect( + await runWithGh({ stdout: release(true, sha) }, { [unset]: undefined }, subcommand), + ).toEqual({ stdout: "", stderr: `${unset} must be set for this step\n`, status: 1, @@ -919,24 +964,24 @@ describe("the release hook's reads of the draft", () => { ])( "resolve-source: handed %s, it refuses the name before gh is asked and derives no version tag", async (_what, handed) => { - expect(await runWithGh({ stdout: `${sha}\n` }, { MARKER: handed }, "resolve-source")).toEqual( - { - stdout: "", - 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: [], - }, - ); + expect( + await runWithGh({ stdout: release(true, sha) }, { MARKER: handed }, "resolve-source"), + ).toEqual({ + stdout: "", + 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: [], + }); }, ); 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 resolved = await runWithGh({ stdout: `${fx.mergeSha}\n` }, {}, "resolve-source"); + 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; diff --git a/test/workflows/post-green-workflow.test.ts b/test/workflows/post-green-workflow.test.ts index e87d6017c..b420e1b47 100644 --- a/test/workflows/post-green-workflow.test.ts +++ b/test/workflows/post-green-workflow.test.ts @@ -1034,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 @@ -1053,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 () => From 6a96f66455d27492928d790c1bd4131124472015 Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Thu, 12 Nov 2026 19:04:37 -0500 Subject: [PATCH 4/5] fix(release): say which job can still be re-run once the draft is renamed --- .github/scripts/release-pipeline/draft.ts | 3 ++- test/scripts/release-pipeline.test.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/scripts/release-pipeline/draft.ts b/.github/scripts/release-pipeline/draft.ts index 363748f32..41a55d195 100644 --- a/.github/scripts/release-pipeline/draft.ts +++ b/.github/scripts/release-pipeline/draft.ts @@ -60,7 +60,8 @@ export function resolveSource(marker: string): { sha: string; tag: string } | { return { refusal: `no release is named ${marker}.\n` + - `If the release ${tag} exists, this release is past this job: re-run only the jobs that failed.`, + `If the release ${tag} exists, the hook already renamed the draft and this job is done: do not re-run it.\n` + + "Of this workflow's jobs, only publish-npm can still be re-run.", }; } diff --git a/test/scripts/release-pipeline.test.ts b/test/scripts/release-pipeline.test.ts index 4f457fc1e..304467bc5 100644 --- a/test/scripts/release-pipeline.test.ts +++ b/test/scripts/release-pipeline.test.ts @@ -835,7 +835,8 @@ describe("the release hook's reads of the draft", () => { { stdout: "::error::no release is named source-v2.1.0.\n" + - "If the release v2.1.0 exists, this release is past this job: re-run only the jobs that failed.\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" + + "Of this workflow's jobs, only publish-npm can still be re-run.\n", stderr: "", status: 1, outputs: [], From 2a324a2e59ecc7be52967244490709f0ac628a05 Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Thu, 12 Nov 2026 19:28:24 -0500 Subject: [PATCH 5/5] fix(release): keep the missing-release refusal to what the hook knows --- .github/scripts/release-pipeline/draft.ts | 5 ++--- test/scripts/release-pipeline.test.ts | 3 +-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/.github/scripts/release-pipeline/draft.ts b/.github/scripts/release-pipeline/draft.ts index 41a55d195..202925b36 100644 --- a/.github/scripts/release-pipeline/draft.ts +++ b/.github/scripts/release-pipeline/draft.ts @@ -49,7 +49,7 @@ function viewSource(marker: string): SourceView | null { * 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 hook's rename took the name, and nothing proceeds under the new one + * 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) */ @@ -60,8 +60,7 @@ export function resolveSource(marker: string): { sha: string; tag: string } | { 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.\n` + - "Of this workflow's jobs, only publish-npm can still be re-run.", + `If the release ${tag} exists, the hook already renamed the draft and this job is done: do not re-run it.`, }; } diff --git a/test/scripts/release-pipeline.test.ts b/test/scripts/release-pipeline.test.ts index 304467bc5..80608883b 100644 --- a/test/scripts/release-pipeline.test.ts +++ b/test/scripts/release-pipeline.test.ts @@ -835,8 +835,7 @@ describe("the release hook's reads of the draft", () => { { 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" + - "Of this workflow's jobs, only publish-npm can still be re-run.\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: [],