fix(release): avoid unused root commit file lookups

This commit is contained in:
morluto
2026-10-24 11:23:42 +08:00
parent 0f411508c3
commit 8736fe3226
12 changed files with 3854 additions and 30 deletions
+43 -16
View File
@@ -81,12 +81,29 @@ jobs:
echo "frozen=true" >> "$GITHUB_OUTPUT"
fi
- uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 # v4.4.1
- name: Check out proposal controller and complete Git history
if: steps.editorial.outputs.frozen != 'true'
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
if: steps.editorial.outputs.frozen != 'true'
with:
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
target-branch: main
skip-github-release: true
node-version-file: .nvmrc
package-manager-cache: false
- name: Install proposal controller dependencies
if: steps.editorial.outputs.frozen != 'true'
run: npm ci
- name: Update release proposal
if: steps.editorial.outputs.frozen != 'true'
env:
GH_TOKEN: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
SOURCE_SHA: ${{ github.sha }}
run: node scripts/release-proposal.mjs --target-branch main --source-sha "$SOURCE_SHA"
merged-release:
name: Release reviewed main merge
@@ -259,15 +276,25 @@ jobs:
node scripts/release-notes.mjs publication --repository "$GH_REPO" \
--source "$SOURCE_SHA"
- uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 # v4.4.1
id: release
with:
- name: Prepare checkpoint release proposal
if: inputs.phase == 'prepare'
id: proposal
env:
# A dedicated credential starts PR CI automatically; the built-in
# token creates approval-required PR runs for maintainers to approve.
GH_TOKEN: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
RELEASE_BRANCH: ${{ inputs.release_branch }}
SOURCE_SHA: ${{ steps.branch.outputs.sha }}
run: node scripts/release-proposal.mjs --target-branch "$RELEASE_BRANCH" --source-sha "$SOURCE_SHA"
- uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 # v4.4.1
if: inputs.phase == 'publish'
id: release
with:
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
target-branch: ${{ inputs.release_branch }}
skip-github-release: ${{ inputs.phase == 'prepare' }}
skip-github-pull-request: ${{ inputs.phase == 'publish' }}
skip-github-release: false
skip-github-pull-request: true
- name: Require a merged release PR for publication
if: inputs.phase == 'publish' && steps.release.outputs.release_created != 'true'
@@ -287,26 +314,26 @@ jobs:
fi
- name: Check out release pull request
if: inputs.phase == 'prepare' && steps.release.outputs.prs_created == 'true'
if: inputs.phase == 'prepare' && steps.proposal.outputs.prs_created == 'true'
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
ref: ${{ fromJSON(steps.release.outputs.pr).headBranchName }}
ref: ${{ fromJSON(steps.proposal.outputs.pr).headBranchName }}
fetch-depth: 0
persist-credentials: false
- name: Set up Node.js for generated documentation
if: inputs.phase == 'prepare' && steps.release.outputs.prs_created == 'true'
if: inputs.phase == 'prepare' && steps.proposal.outputs.prs_created == 'true'
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
with:
node-version-file: .nvmrc
package-manager-cache: false
- name: Install dependencies
if: inputs.phase == 'prepare' && steps.release.outputs.prs_created == 'true'
if: inputs.phase == 'prepare' && steps.proposal.outputs.prs_created == 'true'
run: npm ci
- name: Validate prepared release version and migration notes
if: inputs.phase == 'prepare' && steps.release.outputs.prs_created == 'true'
if: inputs.phase == 'prepare' && steps.proposal.outputs.prs_created == 'true'
env:
RELEASE_BRANCH: ${{ inputs.release_branch }}
SOURCE_SHA: ${{ steps.branch.outputs.sha }}
@@ -316,11 +343,11 @@ jobs:
--source-sha "$SOURCE_SHA"
- name: Regenerate release documentation
if: inputs.phase == 'prepare' && steps.release.outputs.prs_created == 'true'
if: inputs.phase == 'prepare' && steps.proposal.outputs.prs_created == 'true'
run: npm run docs:generate
- name: Validate generated release documentation
if: inputs.phase == 'prepare' && steps.release.outputs.prs_created == 'true'
if: inputs.phase == 'prepare' && steps.proposal.outputs.prs_created == 'true'
run: npm run docs:check && git diff --exit-code
qualification:
+17
View File
@@ -90,6 +90,23 @@ releases are outside this recovery path.
For a release that needs an independently frozen application checkpoint, use
the optional manual path below. It retains explicit prepare and publish phases.
Automatic main proposals and manual checkpoint preparation use
`scripts/release-proposal.mjs` with the pinned release-please dependency.
The controller applies `patches/release-please+17.3.0.patch` only when invoked;
normal dependency installation and published-package installation do not apply it.
The patch avoids commit-file backfills and path splitting for a single root Node
package with the default changelog, default or always-bump-minor versioning,
no path exclusions and no explicit plugins. Other configurations retain the
upstream file attribution workflow. Associated PR metadata and commit overrides
still come from GitHub. The controller sizes commit search from the complete
selected Git history, and a missing known release boundary fails before proposal
creation. Publication continues through the pinned release-please action.
When upgrading release-please, review the upstream manifest implementation,
refresh the versioned patch and run the release proposal and workflow boundary
tests. Version mismatch or a patch that neither applies cleanly nor matches an
already-applied patch fails closed.
## Contributor-aware release notes
Use [the rea-changelog-update skill](https://github.com/morluto/rea/blob/main/.agents/skills/rea-changelog-update/SKILL.md)
File diff suppressed because it is too large Load Diff
+2205
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -397,6 +397,7 @@
"lint-staged": "16.4.0",
"oxfmt": "0.72.0",
"oxlint": "1.87.0",
"release-please": "17.3.0",
"rolldown": "1.2.12",
"tsrs": "0.18.0-ts7.1.0-dev.20260929",
"tsx": "4.23.15",
+71
View File
@@ -0,0 +1,71 @@
--- a/build/src/manifest.js
+++ b/build/src/manifest.js
@@ -261,9 +261,19 @@
this.logger.info('Collecting commits since all latest releases');
const commits = [];
this.logger.debug(`commit search depth: ${this.commitSearchDepth}`);
+ // The root receives every commit without path splitting. Node's strategy,
+ // default changelog and extra-file updaters do not inspect commit.files.
+ // Keep the general path for exclusions, plugins and other strategies.
+ const rootConfig = this.repositoryConfig[exports.ROOT_PROJECT_PATH];
+ const rootOnlyNode = Object.keys(this.repositoryConfig).length === 1 &&
+ rootConfig?.releaseType === 'node' &&
+ (!rootConfig.changelogType || rootConfig.changelogType === 'default') &&
+ (!rootConfig.versioning || ['default', 'always-bump-minor'].includes(rootConfig.versioning)) &&
+ !rootConfig.excludePaths?.length &&
+ this.plugins.length === 0;
const commitGenerator = this.github.mergeCommitIterator(this.targetBranch, {
maxResults: this.commitSearchDepth,
- backfillFiles: true,
+ backfillFiles: !rootOnlyNode,
batchSize: this.commitBatchSize,
});
const releaseShas = new Set(Object.values(releaseShasByPath));
@@ -272,6 +282,7 @@
// sha => release pull request
const releasePullRequestsBySha = {};
let releaseCommitsFound = 0;
+ let historyBoundaryFound = false;
for await (const commit of commitGenerator) {
if (releaseShas.has(commit.sha)) {
if (commit.pullRequest) {
@@ -284,14 +295,17 @@
}
if (this.lastReleaseSha && this.lastReleaseSha === commit.sha) {
this.logger.info(`Using configured lastReleaseSha ${this.lastReleaseSha} as last commit.`);
+ historyBoundaryFound = true;
break;
}
else if (needsBootstrap && commit.sha === this.bootstrapSha) {
this.logger.info(`Needed bootstrapping, found configured bootstrapSha ${this.bootstrapSha}`);
+ historyBoundaryFound = true;
break;
}
else if (!needsBootstrap && releaseCommitsFound >= expectedShas) {
// found enough commits
+ historyBoundaryFound = true;
break;
}
commits.push({
@@ -301,16 +315,18 @@
pullRequest: commit.pullRequest,
});
}
+ if (releaseCommitsFound < expectedShas && !historyBoundaryFound) {
+ throw new Error(`Incomplete release history: expected ${expectedShas} release boundaries, found ${releaseCommitsFound}. Refusing a partial release proposal.`);
+ }
if (releaseCommitsFound < expectedShas) {
this.logger.warn(`Expected ${expectedShas} commits, only found ${releaseCommitsFound}`);
}
// split commits by path
this.logger.info(`Splitting ${commits.length} commits by path`);
- const cs = new commit_split_1.CommitSplit({
+ const splitCommits = rootOnlyNode ? {} : new commit_split_1.CommitSplit({
includeEmpty: true,
packagePaths: Object.keys(this.repositoryConfig),
- });
- const splitCommits = cs.split(commits);
+ }).split(commits);
// limit paths to ones since the last release
let commitsPerPath = {};
for (const path in this.repositoryConfig) {
+5 -1
View File
@@ -53,7 +53,7 @@ export const releaseInventorySchema = z.object({
pullRequests: z.array(pullRequest),
});
export async function readReleaseHistory(run, base, target) {
export async function requireCompleteReleaseHistory(run) {
if (
(await run("git", ["rev-parse", "--is-shallow-repository"])).trim() !==
"false"
@@ -61,6 +61,10 @@ export async function readReleaseHistory(run, base, target) {
throw new Error(
"Release inventory requires complete Git history; fetch --unshallow first",
);
}
export async function readReleaseHistory(run, base, target) {
await requireCompleteReleaseHistory(run);
const resolve = async (ref) =>
sha.parse(
(
+6
View File
@@ -0,0 +1,6 @@
export function applyReleasePleasePatch(directory?: string): Promise<void>;
export function releaseProposalSearchDepth(
run: (command: string, args: string[]) => Promise<string>,
sourceSha: string,
): Promise<number>;
+61
View File
@@ -0,0 +1,61 @@
import { execFile } from "node:child_process";
import { readFile } from "node:fs/promises";
import { createRequire } from "node:module";
import { dirname, relative } from "node:path";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
import { requireCompleteReleaseHistory } from "./release-notes.mjs";
const execute = promisify(execFile);
const require = createRequire(import.meta.url);
const project = fileURLToPath(new URL("../../", import.meta.url));
const patch = fileURLToPath(
new URL("../../patches/release-please+17.3.0.patch", import.meta.url),
);
export async function applyReleasePleasePatch(
directory = dirname(require.resolve("release-please/package.json")),
) {
const metadata = JSON.parse(
await readFile(`${directory}/package.json`, "utf8"),
);
if (metadata.version !== "17.3.0") {
throw new Error(
"The release proposal patch requires release-please 17.3.0",
);
}
const args = ["--directory", relative(project, directory), patch];
const check = async (reverse) => {
try {
await execute(
"git",
["apply", "--check", ...(reverse ? ["--reverse"] : []), ...args],
{ cwd: project },
);
return true;
} catch (error) {
if (error.code === 1) return false;
throw error;
}
};
if (await check(false)) {
await execute("git", ["apply", ...args], { cwd: project });
} else if (!(await check(true))) {
throw new Error(
"The release proposal dependency patch does not match the installed source",
);
}
}
export async function releaseProposalSearchDepth(run, sourceSha) {
if (!/^[a-f0-9]{40}$/u.test(sourceSha ?? "")) {
throw new Error("Expected an exact release proposal source SHA");
}
await requireCompleteReleaseHistory(run);
const text = (await run("git", ["rev-list", "--count", sourceSha])).trim();
const count = Number(text);
if (!/^[1-9][0-9]*$/u.test(text) || !Number.isSafeInteger(count)) {
throw new Error("Git returned an invalid release history size");
}
return count;
}
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env node
import { execFile } from "node:child_process";
import { appendFile } from "node:fs/promises";
import { parseArgs, promisify } from "node:util";
import {
applyReleasePleasePatch,
releaseProposalSearchDepth,
} from "./lib/release-proposal.mjs";
const execute = promisify(execFile);
async function main() {
const { values } = parseArgs({
options: {
"target-branch": { type: "string" },
"source-sha": { type: "string" },
},
});
const repository = process.env.GITHUB_REPOSITORY;
const token = process.env.GH_TOKEN;
if (
!repository ||
!/^[\w.-]+\/[\w.-]+$/u.test(repository) ||
!token ||
!values["target-branch"]
) {
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,
{
commitSearchDepth: depth,
},
);
const prs = (await manifest.createPullRequests()).filter(Boolean);
if (process.env.GITHUB_OUTPUT) {
await appendFile(
process.env.GITHUB_OUTPUT,
`prs_created=${prs.length > 0}\n${prs.length ? `pr=${JSON.stringify(prs[0])}\nprs=${JSON.stringify(prs)}\n` : ""}`,
);
}
}
await main().catch((error) => {
process.stderr.write(
`Release proposal failed: ${error instanceof Error ? error.message : String(error)}\n`,
);
process.exitCode = 1;
});
@@ -0,0 +1,439 @@
import { execFile } from "node:child_process";
import { readFile, writeFile } from "node:fs/promises";
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
import { promisify } from "node:util";
import { beforeAll, expect, it } from "vitest";
import { z } from "zod";
import {
applyReleasePleasePatch,
releaseProposalSearchDepth,
} from "../../../scripts/lib/release-proposal.mjs";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const execute = promisify(execFile);
const require = createRequire(import.meta.url);
let upstream: typeof import("release-please");
const baseline = "a".repeat(40);
const latest = "b".repeat(40);
const silent = {
info() {},
warn() {},
error() {},
debug() {},
trace() {},
};
beforeAll(async () => {
await applyReleasePleasePatch();
upstream = await import("release-please");
});
function respond(value: unknown) {
return new Response(JSON.stringify(value), {
status: 200,
headers: { "content-type": "application/json" },
});
}
type CommitRecord = { sha: string; message: string; files: string[] };
function historyPage(
commits: CommitRecord[],
page: { num: number; cursor?: string | undefined },
pullRequest: boolean,
) {
const offset = Number(page.cursor ?? "0");
const selected = commits.slice(offset, offset + page.num);
return {
data: {
repository: {
ref: {
target: {
history: {
nodes: selected.map((commit) => ({
sha: commit.sha,
message: commit.message,
associatedPullRequests: {
nodes:
pullRequest && commit.sha === latest
? [
{
number: 42,
title: "Original title",
baseRefName: "main",
headRefName: "feature",
body: "BEGIN_COMMIT_OVERRIDE\nfix: attributed override (#42)\nEND_COMMIT_OVERRIDE",
mergeCommit: { oid: latest },
labels: { nodes: [{ name: "reviewed" }] },
files: {
nodes: [{ path: "src/feature.ts" }],
pageInfo: { hasNextPage: false },
},
},
]
: [],
},
})),
pageInfo: {
hasNextPage: offset + selected.length < commits.length,
endCursor: String(offset + selected.length),
},
},
},
},
},
},
};
}
async function releaseFixture({
config = {},
commits = [
{ sha: latest, message: "fix: direct change", files: ["docs/changed.md"] },
{ sha: baseline, message: "chore: release 6.7.0", files: ["package.json"] },
],
depth = 500,
pullRequest = false,
}: {
config?: Record<string, unknown>;
commits?: CommitRecord[];
depth?: number;
pullRequest?: boolean;
} = {}) {
const requests: { method: string; pathname: string }[] = [];
const pages: { num: number; cursor?: string | undefined }[] = [];
const configuration = {
"release-type": "node",
versioning: "always-bump-minor",
"include-v-in-tag": false,
packages: {
".": {
"package-name": "rea-agents",
"extra-files": [{ type: "generic", path: "version.txt" }],
},
},
...config,
};
const github = await upstream.GitHub.create({
owner: "fixture",
repo: "rea",
defaultBranch: "main",
logger: silent,
fetch: async (
input: string,
options: { method?: string; body?: string },
) => {
const url = new URL(input);
requests.push({
method: options.method ?? "GET",
pathname: url.pathname,
});
if (url.pathname === "/repos/fixture/rea/tags") return respond([]);
if (url.pathname === "/repos/fixture/rea/releases/generate-notes") {
return respond({ body: "Generated release notes" });
}
if (url.pathname === "/graphql") {
const query = z
.object({
query: z.string(),
variables: z.object({
num: z.number(),
cursor: z.string().optional(),
}),
})
.parse(JSON.parse(options.body ?? ""));
if (query.query.includes("query releases(")) {
return respond({
data: {
repository: {
releases: {
nodes: [
{
name: "6.7.0",
tag: { name: "rea-agents-6.7.0" },
tagCommit: { oid: baseline },
description: "",
isDraft: false,
url: "https://github.com/fixture/rea/releases/tag/rea-agents-6.7.0",
},
],
pageInfo: { hasNextPage: false },
},
},
},
});
}
if (!query.query.includes("query pullRequestsSince(")) {
throw new Error(`Unexpected GraphQL operation: ${query.query}`);
}
pages.push(query.variables);
return respond(historyPage(commits, query.variables, pullRequest));
}
const files: Record<string, unknown> = {
"release-please-config.json": configuration,
".release-please-manifest.json": { ".": "6.7.0", pkg: "6.7.0" },
"package.json": { name: "rea-agents", version: "6.7.0" },
"pkg/package.json": { name: "rea-agents", version: "6.7.0" },
};
const entries = Object.entries(files).map(([path, value], index) => ({
path,
sha: (index + 1).toString(16).padStart(40, "0"),
text: JSON.stringify(value),
}));
if (url.pathname === "/repos/fixture/rea/git/trees/main") {
return respond({
truncated: false,
sha: baseline,
tree: entries.map(({ path, sha, text }) => ({
path,
sha,
type: "blob",
mode: "100644",
size: text.length,
})),
});
}
const blobPrefix = "/repos/fixture/rea/git/blobs/";
if (url.pathname.startsWith(blobPrefix)) {
const entry = entries.find(
(item) => item.sha === url.pathname.slice(blobPrefix.length),
);
if (!entry) throw new Error("Unexpected blob read");
return respond({
content: Buffer.from(entry.text).toString("base64"),
encoding: "base64",
sha: entry.sha,
size: entry.text.length,
});
}
const commitPath = "/repos/fixture/rea/commits/";
if (url.pathname.startsWith(commitPath)) {
const commit = commits.find(
(item) => item.sha === url.pathname.slice(commitPath.length),
);
if (!commit) throw new Error("Unexpected commit lookup");
return respond({
files: commit.files.map((filename) => ({ filename })),
});
}
throw new Error(
`Unexpected HTTP request: ${options.method} ${url.pathname}`,
);
},
});
const manifest = await upstream.Manifest.fromManifest(
github,
"main",
undefined,
undefined,
{
commitSearchDepth: depth,
logger: silent,
},
);
return { manifest, github, requests, pages };
}
it("builds the root Node proposal without commit-file lookups and retains extra-file updates", async () => {
const fixture = await releaseFixture();
const candidates = await fixture.manifest.buildPullRequests();
expect(candidates).toHaveLength(1);
expect(candidates[0]?.body.toString()).toContain("direct change");
const version = candidates[0]?.updates.find(
(update) => update.path === "version.txt",
);
expect(
version?.updater.updateContent("6.7.0 // x-release-please-version"),
).toContain("6.8.0");
expect(
fixture.requests.filter((request) =>
request.pathname.includes("/commits/"),
),
).toEqual([]);
expect(fixture.pages.map((page) => page.num)).toEqual([10]);
const commits = [];
for await (const commit of fixture.github.mergeCommitIterator("main", {
backfillFiles: false,
}))
commits.push(commit);
expect(commits[0]?.files).toBeUndefined();
});
it("keeps real associated PR metadata, body overrides and attribution", async () => {
const fixture = await releaseFixture({ pullRequest: true });
const candidates = await fixture.manifest.buildPullRequests();
const body = candidates[0]?.body.toString();
expect(body).toContain("attributed override");
expect(body).toContain("https://github.com/fixture/rea/issues/42");
expect(body).not.toContain("direct change");
const commits = [];
for await (const commit of fixture.github.mergeCommitIterator("main", {
backfillFiles: false,
}))
commits.push(commit);
expect(commits[0]?.pullRequest).toMatchObject({
number: 42,
labels: ["reviewed"],
mergeCommitOid: latest,
body: expect.stringContaining("BEGIN_COMMIT_OVERRIDE"),
});
});
it.each([
{
name: "inherited exclusion",
config: { "exclude-paths": ["docs"] },
excluded: true,
},
{
name: "package exclusion",
config: {
packages: {
".": { "package-name": "rea-agents", "exclude-paths": ["docs"] },
},
},
excluded: true,
},
{
name: "explicit plugin",
config: { plugins: ["sentence-case"] },
excluded: false,
},
{
name: "nondefault changelog",
config: { "changelog-type": "github" },
excluded: false,
},
{
name: "other versioning strategy",
config: { versioning: "always-bump-patch" },
excluded: false,
},
{
name: "subpackage",
config: { packages: { pkg: { "package-name": "rea-agents" } } },
excluded: true,
},
])("retains file-backed semantics for $name", async ({ config, excluded }) => {
const fixture = await releaseFixture({ config });
const candidates = await fixture.manifest.buildPullRequests();
expect(
fixture.requests.filter((request) =>
request.pathname.includes("/commits/"),
),
).toHaveLength(2);
expect(candidates).toHaveLength(excluded ? 0 : 1);
});
it("collects beyond 500 commits and rejects an unobserved known release boundary", async () => {
const commits = Array.from({ length: 501 }, (_, index) => ({
sha: index.toString(16).padStart(40, "0"),
message: `fix: unreleased change ${index}`,
files: ["src/change.ts"],
}));
commits.push({
sha: baseline,
message: "chore: release 6.7.0",
files: ["package.json"],
});
const complete = await releaseFixture({ commits, depth: commits.length });
const candidates = await complete.manifest.buildPullRequests();
expect(candidates[0]?.body.toString()).toContain("unreleased change 500");
expect(complete.pages).toHaveLength(51);
expect(
complete.requests.filter((request) =>
request.pathname.includes("/commits/"),
),
).toEqual([]);
const truncated = await releaseFixture({ commits });
await expect(truncated.manifest.createPullRequests()).rejects.toThrow(
"Incomplete release history",
);
expect(
truncated.requests.every(
(request) => request.method === "GET" || request.pathname === "/graphql",
),
).toBe(true);
});
it("preserves a reached explicit last-release override", async () => {
const fixture = await releaseFixture({
config: { "last-release-sha": latest },
});
await expect(fixture.manifest.buildPullRequests()).resolves.toEqual([]);
});
it("preserves complete mixed bootstrap history with all known release boundaries", async () => {
const fixture = await releaseFixture({
config: {
packages: {
".": { "package-name": "rea-agents" },
pkg: { "package-name": "unreleased" },
},
},
});
await expect(fixture.manifest.buildPullRequests()).resolves.toHaveLength(1);
});
it("requires complete local history when sizing the upstream search", async () => {
const directory = await createTestTempDirectory("rea-release-depth-");
await execute("git", ["init", "--quiet", directory]);
await execute("git", [
"-C",
directory,
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"commit",
"--quiet",
"--allow-empty",
"-m",
"fixture",
]);
const run = async (command: string, args: string[]) =>
(await execute(command, args, { cwd: directory })).stdout;
const head = (await run("git", ["rev-parse", "HEAD"])).trim();
await expect(releaseProposalSearchDepth(run, head)).resolves.toBe(1);
await writeFile(join(directory, ".git/shallow"), `${head}\n`);
await expect(releaseProposalSearchDepth(run, head)).rejects.toThrow(
"complete Git history",
);
});
it("applies the exact dependency patch idempotently and rejects another version", async () => {
await expect(applyReleasePleasePatch()).resolves.toBeUndefined();
const directory = dirname(require.resolve("release-please/package.json"));
const metadata = await readFile(join(directory, "package.json"), "utf8");
expect(JSON.parse(metadata).version).toBe("17.3.0");
const fixture = await createTestTempDirectory("rea-release-patch-");
await writeFile(
join(fixture, "package.json"),
JSON.stringify({ version: "17.4.0" }),
);
await expect(applyReleasePleasePatch(fixture)).rejects.toThrow(
"requires release-please 17.3.0",
);
});
it("rejects a partial or mismatched dependency patch", async () => {
const directory = dirname(require.resolve("release-please/package.json"));
const manifest = join(directory, "build/src/manifest.js");
const original = await readFile(manifest, "utf8");
try {
await writeFile(
manifest,
original.replace(
"const rootOnlyNode =",
"const mismatchedRootOnlyNode =",
),
);
await expect(applyReleasePleasePatch()).rejects.toThrow(
"does not match the installed source",
);
} finally {
await writeFile(manifest, original);
}
});
@@ -88,7 +88,7 @@ it("validates the source before release creation and the prepared candidate befo
expect(candidate).toBeGreaterThan(action);
expect(candidate).toBeLessThan(generation);
expect(steps[candidate]?.if).toBe(
"inputs.phase == 'prepare' && steps.release.outputs.prs_created == 'true'",
"inputs.phase == 'prepare' && steps.proposal.outputs.prs_created == 'true'",
);
expect(steps[candidate]?.run).toContain("--stage candidate");
for (const name of [
@@ -110,16 +110,28 @@ it("keeps main pushes proposal-only and gates merged-release automation", async
});
const proposal = workflow.jobs["release-proposal"];
expect(proposal.if).toBe("github.event_name == 'push'");
const action = proposal.steps.find((step) =>
step.uses?.startsWith("googleapis/release-please-action@"),
const controller = proposal.steps.find((step) =>
step.run?.includes("scripts/release-proposal.mjs"),
);
expect(action?.with).toMatchObject({
token: "${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}",
"target-branch": "main",
"skip-github-release": true,
expect(controller?.env?.GH_TOKEN).toBe(
"${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}",
);
expect(controller?.run).toContain("--target-branch main");
expect(controller?.run).toContain('--source-sha "$SOURCE_SHA"');
expect(controller?.env?.SOURCE_SHA).toBe("${{ github.sha }}");
expect(controller?.if).toBe("steps.editorial.outputs.frozen != 'true'");
expect(
proposal.steps.some((step) =>
step.uses?.startsWith("googleapis/release-please-action@"),
),
).toBe(false);
expect(
proposal.steps.find((step) => step.uses?.startsWith("actions/checkout@"))
?.with,
).toMatchObject({
"fetch-depth": 0,
"persist-credentials": false,
});
expect(action?.with?.["skip-github-pull-request"]).not.toBe(true);
expect(action?.if).toBe("steps.editorial.outputs.frozen != 'true'");
expect(proposal.steps[0]?.run).toContain("rea:release-notes-finalized");
expect(proposal.steps[0]?.run).toContain("--paginate");
const merged = workflow.jobs["merged-release"];
@@ -368,10 +380,17 @@ it("keeps frozen candidate preparation and publication explicit", async () => {
expect(release?.with).toMatchObject({
token: "${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}",
"target-branch": "${{ inputs.release_branch }}",
"skip-github-release": "${{ inputs.phase == 'prepare' }}",
"skip-github-pull-request": "${{ inputs.phase == 'publish' }}",
"skip-github-release": false,
"skip-github-pull-request": true,
});
expect(release?.if).toBe("inputs.phase == 'publish'");
const steps = workflow.jobs["release-please"].steps;
const proposal = steps.find((step) => step.id === "proposal");
expect(proposal?.if).toBe("inputs.phase == 'prepare'");
expect(proposal?.run).toContain("scripts/release-proposal.mjs");
expect(proposal?.run).toContain('--target-branch "$RELEASE_BRANCH"');
expect(proposal?.env?.SOURCE_SHA).toBe("${{ steps.branch.outputs.sha }}");
expect(proposal?.run).toContain('--source-sha "$SOURCE_SHA"');
const notesIndex = steps.findIndex((step) =>
step.run?.includes("release-notes.mjs publication"),
);
@@ -513,7 +532,7 @@ it("binds npm and MCP publication to the same immutable release SHA", async () =
workflow.jobs["release-please"].steps.find((step) => step.name === name)
?.if,
).toBe(
"inputs.phase == 'prepare' && steps.release.outputs.prs_created == 'true'",
"inputs.phase == 'prepare' && steps.proposal.outputs.prs_created == 'true'",
);
}
const publishCommand = workflow.jobs.publish.steps.find(