Skip to content

Commit 9a61ceb

Browse files
committed
refactor(scripts): split release-pipeline along its sections
release-pipeline.ts held every section of the release pipeline in one 1562-line file. Its sections move to sibling modules under .github/scripts/release-pipeline/: git.ts (the git helpers every section shares, with ancestry, a commit's position on main, and the manifest read), tags.ts (packaged commits and the refs that name them), anchor.ts (release-please's boundary), npm.ts (the pre-release version, the verdicts, the publish, the floor), and draft.ts (the draft release's source and assets). The entry file keeps its path, the one the workflows and the test fixture invoke, and holds main() alone. The test file splits the same way: its npm half becomes test/scripts/release-pipeline-npm.test.ts. Every function keeps its text; only imports, exports, and file headers are new, and every importer and comment that named the old file follows the move.
1 parent 58b4269 commit 9a61ceb

14 files changed

Lines changed: 2466 additions & 2351 deletions

‎.github/scripts/release-pipeline.ts‎

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

0 commit comments

Comments
 (0)