fix(release): reject missing manifest release boundaries

This commit is contained in:
morluto committed 2026-10-25 17:25:18 +08:00
1 parent 160d821ac9
commit bc143125ed
3 files changed
+167 -28

No files matched your search

+7
View File
@@ -112,6 +112,13 @@ creation or retry mutations. An internal GraphQL error does not establish API
quota exhaustion or prove that query size caused the failure. Publication quota exhaustion or prove that query size caused the failure. Publication
continues through the pinned release-please action. continues through the pinned release-please action.
A nonzero manifest version must resolve to a GitHub release or tag before
proposal history is collected. If publication has not created that boundary
yet, preparation stops before opening or updating a PR; wait for publication
before retrying. Missing manifest versions and `0.0.0` still allow an initial
release. This prevents historical `Release-As` directives from being replayed
as unreleased changes.
When upgrading release-please, review the upstream manifest implementation, When upgrading release-please, review the upstream manifest implementation,
refresh the versioned patch and run the release proposal and workflow boundary 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 tests. Version mismatch or a patch that neither applies cleanly nor matches an
+19 -4
View File
@@ -105,7 +105,22 @@
} }
--- a/build/src/manifest.js --- a/build/src/manifest.js
+++ b/build/src/manifest.js +++ b/build/src/manifest.js
@@ -261,9 +261,20 @@ @@ -248,6 +248,14 @@
releasesFound++;
}
}
+ // A recorded version requires its release boundary, even while a
+ // concurrent publisher has not created the matching release/tag yet.
+ for (const path of Object.keys(strategiesByPath)) {
+ const expectedVersion = this.releasedVersions[path];
+ if (expectedVersion && expectedVersion.toString() !== '0.0.0' && !releasesByPath[path]) {
+ throw new Error(`Missing expected release for path ${path} at version ${expectedVersion.toString()}: no matching release or tag found. Refusing to bootstrap an already released package.`);
+ }
+ }
const needsBootstrap = releasesFound < expectedReleases;
if (releasesFound < expectedReleases) {
this.logger.warn(`Expected ${expectedReleases} releases, only found ${releasesFound}`);
@@ -261,9 +269,20 @@
this.logger.info('Collecting commits since all latest releases'); this.logger.info('Collecting commits since all latest releases');
const commits = []; const commits = [];
this.logger.debug(`commit search depth: ${this.commitSearchDepth}`); this.logger.debug(`commit search depth: ${this.commitSearchDepth}`);
@@ -127,7 +142,7 @@
batchSize: this.commitBatchSize, batchSize: this.commitBatchSize,
}); });
const releaseShas = new Set(Object.values(releaseShasByPath)); const releaseShas = new Set(Object.values(releaseShasByPath));
@@ -272,6 +283,7 @@ @@ -272,6 +291,7 @@
// sha => release pull request // sha => release pull request
const releasePullRequestsBySha = {}; const releasePullRequestsBySha = {};
let releaseCommitsFound = 0; let releaseCommitsFound = 0;
@@ -135,7 +150,7 @@
for await (const commit of commitGenerator) { for await (const commit of commitGenerator) {
if (releaseShas.has(commit.sha)) { if (releaseShas.has(commit.sha)) {
if (commit.pullRequest) { if (commit.pullRequest) {
@@ -284,14 +296,17 @@ @@ -284,14 +304,17 @@
} }
if (this.lastReleaseSha && this.lastReleaseSha === commit.sha) { if (this.lastReleaseSha && this.lastReleaseSha === commit.sha) {
this.logger.info(`Using configured lastReleaseSha ${this.lastReleaseSha} as last commit.`); this.logger.info(`Using configured lastReleaseSha ${this.lastReleaseSha} as last commit.`);
@@ -153,7 +168,7 @@
break; break;
} }
commits.push({ commits.push({
@@ -301,16 +316,18 @@ @@ -301,16 +324,18 @@
pullRequest: commit.pullRequest, pullRequest: commit.pullRequest,
}); });
} }
+141 -24
View File
@@ -124,21 +124,24 @@ function historyPage(
}; };
} }
function releasePage() { function releaseRecord(version: string, sha = baseline) {
const tagName = `rea-agents-${version}`;
return {
name: version,
tag: { name: tagName },
tagCommit: { oid: sha },
description: "",
isDraft: false,
url: `https://github.com/fixture/rea/releases/tag/${tagName}`,
};
}
function releasePage(releases: ReturnType<typeof releaseRecord>[]) {
return { return {
data: { data: {
repository: { repository: {
releases: { releases: {
nodes: [ nodes: releases,
{
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 }, pageInfo: { hasNextPage: false },
}, },
}, },
@@ -158,6 +161,29 @@ const defaultConfiguration = {
}, },
}; };
function releaseFileEntries(
configuration: Record<string, unknown>,
releasedVersions: Record<string, string>,
) {
const files: Record<string, unknown> = {
"release-please-config.json": configuration,
".release-please-manifest.json": releasedVersions,
"package.json": {
name: "rea-agents",
version: releasedVersions["."] ?? "0.0.0",
},
"pkg/package.json": {
name: "rea-agents",
version: releasedVersions.pkg ?? "0.0.0",
},
};
return Object.entries(files).map(([path, value], index) => ({
path,
sha: (index + 1).toString(16).padStart(40, "0"),
text: JSON.stringify(value),
}));
}
async function releaseFixture({ async function releaseFixture({
config = {}, config = {},
commits = [ commits = [
@@ -168,6 +194,9 @@ async function releaseFixture({
pullRequest = false, pullRequest = false,
historyFailures = [], historyFailures = [],
releaseFailures = [], releaseFailures = [],
releasedVersions = { ".": "6.7.0", pkg: "6.7.0" },
releases = [releaseRecord("6.7.0")],
tags = [],
}: { }: {
config?: Record<string, unknown>; config?: Record<string, unknown>;
commits?: CommitRecord[]; commits?: CommitRecord[];
@@ -175,6 +204,9 @@ async function releaseFixture({
pullRequest?: boolean; pullRequest?: boolean;
historyFailures?: HistoryFailure[]; historyFailures?: HistoryFailure[];
releaseFailures?: HistoryFailure["errors"][]; releaseFailures?: HistoryFailure["errors"][];
releasedVersions?: Record<string, string>;
releases?: ReturnType<typeof releaseRecord>[];
tags?: { name: string; commit: { sha: string } }[];
} = {}) { } = {}) {
const requests: { method: string; pathname: string }[] = []; const requests: { method: string; pathname: string }[] = [];
const pages: HistoryVariables[] = []; const pages: HistoryVariables[] = [];
@@ -182,6 +214,7 @@ async function releaseFixture({
const failures = [...historyFailures]; const failures = [...historyFailures];
const releaseErrors = [...releaseFailures]; const releaseErrors = [...releaseFailures];
const configuration = { ...defaultConfiguration, ...config }; const configuration = { ...defaultConfiguration, ...config };
const entries = releaseFileEntries(configuration, releasedVersions);
const github = await upstream.GitHub.create({ const github = await upstream.GitHub.create({
owner: "fixture", owner: "fixture",
repo: "rea", repo: "rea",
@@ -196,7 +229,7 @@ async function releaseFixture({
method: options.method ?? "GET", method: options.method ?? "GET",
pathname: url.pathname, pathname: url.pathname,
}); });
if (url.pathname === "/repos/fixture/rea/tags") return respond([]); if (url.pathname === "/repos/fixture/rea/tags") return respond(tags);
if (url.pathname === "/repos/fixture/rea/releases/generate-notes") { if (url.pathname === "/repos/fixture/rea/releases/generate-notes") {
return respond({ body: "Generated release notes" }); return respond({ body: "Generated release notes" });
} }
@@ -214,7 +247,7 @@ async function releaseFixture({
if (query.query.includes("query releases(")) { if (query.query.includes("query releases(")) {
const errors = releaseErrors.shift(); const errors = releaseErrors.shift();
if (errors) return respond({ errors }); if (errors) return respond({ errors });
return respond(releasePage()); return respond(releasePage(releases));
} }
if (!query.query.includes("query pullRequestsSince(")) { if (!query.query.includes("query pullRequestsSince(")) {
throw new Error(`Unexpected GraphQL operation: ${query.query}`); throw new Error(`Unexpected GraphQL operation: ${query.query}`);
@@ -234,17 +267,6 @@ async function releaseFixture({
} }
return respond(historyPage(commits, query.variables, pullRequest)); 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") { if (url.pathname === "/repos/fixture/rea/git/trees/main") {
return respond({ return respond({
truncated: false, truncated: false,
@@ -503,6 +525,100 @@ it("collects beyond 500 commits and rejects an unobserved known release boundary
).toBe(true); ).toBe(true);
}); });
it("rejects a missing manifest release before replaying a historical version override", async () => {
const fixture = await releaseFixture({
releasedVersions: { ".": "6.8.0" },
releases: [releaseRecord("6.7.0", "d".repeat(40))],
commits: [
{ sha: latest, message: "fix: current change", files: ["src/change.ts"] },
{
sha: baseline,
message: "chore: release 6.8.0",
files: ["package.json"],
},
{
sha: "c".repeat(40),
message: "fix: historical version override\n\nRelease-As: 0.4.0",
files: ["src/historical.ts"],
},
],
});
await expect(fixture.manifest.createPullRequests()).rejects.toThrow(
"Missing expected release",
);
expect(fixture.pages).toEqual([]);
expect(
fixture.requests.every(
(request) => request.method === "GET" || request.pathname === "/graphql",
),
).toBe(true);
});
it("uses a tag-only manifest release and excludes older version overrides", async () => {
const fixture = await releaseFixture({
releasedVersions: { ".": "6.8.0" },
releases: [],
tags: [{ name: "rea-agents-6.8.0", commit: { sha: baseline } }],
commits: [
{ sha: latest, message: "fix: current change", files: ["src/change.ts"] },
{
sha: baseline,
message: "chore: release 6.8.0",
files: ["package.json"],
},
{
sha: "c".repeat(40),
message: "fix: historical version override\n\nRelease-As: 0.4.0",
files: ["src/historical.ts"],
},
],
});
const candidates = await fixture.manifest.buildPullRequests();
expect(candidates).toHaveLength(1);
expect(candidates[0]?.body.toString()).toContain("current change");
expect(candidates[0]?.body.toString()).not.toContain(
"historical version override",
);
const version = candidates[0]?.updates.find(
(update) => update.path === "version.txt",
);
expect(
version?.updater.updateContent("6.8.0 // x-release-please-version"),
).toContain("6.9.0");
expect(
fixture.requests.some(
(request) => request.pathname === "/repos/fixture/rea/tags",
),
).toBe(true);
});
it.each(["omitted", "0.0.0"])(
"preserves initial bootstrap with an %s manifest version",
async (version) => {
const releasedVersions: Record<string, string> =
version === "omitted" ? {} : { ".": version };
const fixture = await releaseFixture({
releasedVersions,
releases: [],
commits: [
{
sha: latest,
message: "feat: initial change",
files: ["src/change.ts"],
},
],
});
const candidates = await fixture.manifest.buildPullRequests();
expect(candidates).toHaveLength(1);
const versionUpdate = candidates[0]?.updates.find(
(update) => update.path === "version.txt",
);
expect(
versionUpdate?.updater.updateContent("0.0.0 // x-release-please-version"),
).toContain("1.0.0");
},
);
it.each([ it.each([
{ {
name: "timestamped GraphQL internal error", name: "timestamped GraphQL internal error",
@@ -637,6 +753,7 @@ it("preserves a reached explicit last-release override", async () => {
it("preserves complete mixed bootstrap history with all known release boundaries", async () => { it("preserves complete mixed bootstrap history with all known release boundaries", async () => {
const fixture = await releaseFixture({ const fixture = await releaseFixture({
releasedVersions: { ".": "6.7.0", pkg: "0.0.0" },
config: { config: {
packages: { packages: {
".": { "package-name": "rea-agents" }, ".": { "package-name": "rea-agents" },