fix(ci): defer proposals for merged release commits

This commit is contained in:
morluto committed 2026-11-12 05:08:27 -05:00
1 parent d74549e495
commit 5eef239dfd
10 files changed
+304 -68

No files matched your search

+2 -1
View File
@@ -242,7 +242,8 @@ const PATH_SCOPES = {
"scripts/windows-capabilities.mjs",
"scripts/prepack.mjs",
"scripts/prepare.mjs",
"scripts/lib/release-proposal.mjs",
"scripts/lib/release-proposal.*",
"scripts/lib/release-pull-request.mjs",
"patches/release-please+17.3.0.patch",
"tests/boundary/filesystem/releaseProposal.test.ts",
"scripts/rea.mjs",
+5 -43
View File
@@ -1,5 +1,6 @@
import { valid } from "semver";
import { z } from "zod";
import { readMergedReleasePullRequest } from "./release-pull-request.mjs";
import {
releaseInventorySchema,
verifyInventoryHistory,
@@ -8,17 +9,6 @@ import {
const sha = z.string().regex(/^[a-f0-9]{40}$/u);
const repository = z.string().regex(/^[\w.-]+\/[\w.-]+$/u);
const pullRequest = z.object({
number: z.number().int().positive(),
body: z.string(),
merged_at: z.string().nullable(),
merge_commit_sha: sha.nullable(),
head: z.object({
ref: z.string(),
repo: z.object({ full_name: z.string() }).nullable(),
}),
labels: z.array(z.object({ name: z.string() })),
});
/** Read the reviewed Git snapshot and live PR before Release Please can mutate tags. */
export async function verifyNotesPublication(run, options) {
@@ -39,41 +29,13 @@ export async function verifyNotesPublication(run, options) {
.parse(JSON.parse(await read("package.json")));
if (valid(version) !== version || version.includes("+"))
throw new Error("Invalid reviewed package version");
let candidates;
if (options.pr !== undefined) {
const number = z.coerce.number().int().positive().parse(options.pr);
candidates = [
pullRequest.parse(
JSON.parse(await run("gh", ["api", `repos/${repo}/pulls/${number}`])),
),
];
} else {
candidates = z
.array(z.array(pullRequest))
.parse(
JSON.parse(
await run("gh", [
"api",
"--paginate",
"--slurp",
`repos/${repo}/commits/${source}/pulls?per_page=100`,
]),
),
)
.flat();
}
const matching = candidates.filter(
(pr) =>
pr.merged_at !== null &&
pr.merge_commit_sha === source &&
pr.head.repo?.full_name.toLowerCase() === repo.toLowerCase() &&
pr.head.ref.startsWith("release-please--branches--"),
);
if (matching.length !== 1)
const pr = await readMergedReleasePullRequest(run, { ...options, source });
if (pr === undefined)
throw new Error(
"Expected exactly one merged Release Please PR for the reviewed source",
);
const pr = matching[0];
if (pr.body === null)
throw new Error("The merged release PR has no release notes body");
const path = `docs/releases/${version}.contributions.json`;
const present =
(
+16
View File
@@ -6,3 +6,19 @@ export function releaseProposalSearchDepth(
run: (command: string, args: string[]) => Promise<string>,
sourceSha: string,
): Promise<number>;
export function createReleaseProposal<T>(
run: (command: string, args: string[]) => Promise<string>,
context: {
repository: string;
sourceSha: string;
targetBranch: string;
eventName: string | undefined;
},
loadManifest: (depth: number) => Promise<{
createPullRequests(): Promise<Array<T | undefined>>;
}>,
): Promise<
| { kind: "release-merge"; pullRequest: number }
| { kind: "proposal"; pullRequests: T[] }
>;
+20
View File
@@ -5,6 +5,7 @@ import { dirname, relative, sep } from "node:path";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
import { requireCompleteReleaseHistory } from "./release-notes.mjs";
import { readMergedReleasePullRequest } from "./release-pull-request.mjs";
const execute = promisify(execFile);
const require = createRequire(import.meta.url);
@@ -74,3 +75,22 @@ export async function releaseProposalSearchDepth(run, sourceSha) {
}
return count;
}
/** The merged-release workflow owns tag creation; its push needs no proposal. */
export async function createReleaseProposal(run, context, loadManifest) {
if (context.eventName === "push" && context.targetBranch === "main") {
const release = await readMergedReleasePullRequest(run, {
repository: context.repository,
source: context.sourceSha,
targetBranch: context.targetBranch,
});
if (release !== undefined)
return { kind: "release-merge", pullRequest: release.number };
}
const depth = await releaseProposalSearchDepth(run, context.sourceSha);
const manifest = await loadManifest(depth);
return {
kind: "proposal",
pullRequests: (await manifest.createPullRequests()).filter(Boolean),
};
}
+60
View File
@@ -0,0 +1,60 @@
import { z } from "zod";
const sha = z.string().regex(/^[a-f0-9]{40}$/u);
const repository = z.string().regex(/^[\w.-]+\/[\w.-]+$/u);
const pullRequest = z.object({
number: z.number().int().positive(),
body: z.string().nullable(),
merged_at: z.string().nullable(),
merge_commit_sha: sha.nullable(),
base: z.object({ ref: z.string() }),
head: z.object({
ref: z.string(),
repo: z.object({ full_name: z.string() }).nullable(),
}),
labels: z.array(z.object({ name: z.string() })),
});
/** Own REST release-PR identity for proposal admission and publication checks. */
export async function readMergedReleasePullRequest(run, options) {
const repo = repository.parse(options.repository);
const source = sha.parse(options.source);
let candidates;
if (options.pr !== undefined) {
const number = z.coerce.number().int().positive().parse(options.pr);
candidates = [
pullRequest.parse(
JSON.parse(await run("gh", ["api", `repos/${repo}/pulls/${number}`])),
),
];
} else {
candidates = z
.array(z.array(pullRequest))
.parse(
JSON.parse(
await run("gh", [
"api",
"--paginate",
"--slurp",
`repos/${repo}/commits/${source}/pulls?per_page=100`,
]),
),
)
.flat();
}
const matching = candidates.filter(
(pr) =>
pr.merged_at !== null &&
pr.merge_commit_sha === source &&
pr.head.repo?.full_name.toLowerCase() === repo.toLowerCase() &&
pr.head.ref.startsWith("release-please--branches--") &&
(options.targetBranch === undefined ||
(pr.base.ref === options.targetBranch &&
pr.head.ref.startsWith(
`release-please--branches--${options.targetBranch}--`,
))),
);
if (matching.length > 1)
throw new Error("Multiple merged Release Please PRs match the source");
return matching[0];
}
+34 -23
View File
@@ -4,8 +4,8 @@ import { appendFile } from "node:fs/promises";
import { parseArgs, promisify } from "node:util";
import {
applyReleasePleasePatch,
createReleaseProposal,
describeReleaseProposalError,
releaseProposalSearchDepth,
} from "./lib/release-proposal.mjs";
const execute = promisify(execFile);
@@ -27,33 +27,44 @@ async function main() {
) {
throw new Error("Expected GITHUB_REPOSITORY, GH_TOKEN and --target-branch");
}
const depth = await releaseProposalSearchDepth(
async (command, args) =>
(await execute(command, args, { encoding: "utf8" })).stdout,
values["source-sha"],
);
// Patch before import: only proposal generation uses this maintained upstream fix.
// Publication continues through the pinned release-please action.
await applyReleasePleasePatch();
const { GitHub, Manifest } = await import("release-please");
const [owner, repo] = repository.split("/");
const branch = values["target-branch"];
const github = await GitHub.create({
owner,
repo,
token,
defaultBranch: branch,
});
const manifest = await Manifest.fromManifest(
github,
branch,
undefined,
undefined,
const result = await createReleaseProposal(
async (command, args) =>
(
await execute(command, args, {
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
})
).stdout,
{
commitSearchDepth: depth,
repository,
targetBranch: branch,
sourceSha: values["source-sha"],
eventName: process.env.GITHUB_EVENT_NAME,
},
async (depth) => {
// Patch before import: publication uses the pinned action separately.
await applyReleasePleasePatch();
const { GitHub, Manifest } = await import("release-please");
const github = await GitHub.create({
owner,
repo,
token,
defaultBranch: branch,
});
return Manifest.fromManifest(github, branch, undefined, undefined, {
commitSearchDepth: depth,
});
},
);
const prs = (await manifest.createPullRequests()).filter(Boolean);
if (result.kind === "release-merge") {
const message = `Release PR #${result.pullRequest} merged at ${values["source-sha"]}; the release workflow owns publication. Proposal generation resumes on the next main push.\n`;
process.stdout.write(message);
if (process.env.GITHUB_STEP_SUMMARY)
await appendFile(process.env.GITHUB_STEP_SUMMARY, message);
}
const prs = result.kind === "proposal" ? result.pullRequests : [];
if (process.env.GITHUB_OUTPUT) {
await appendFile(
process.env.GITHUB_OUTPUT,