fix(release): reject missing manifest release boundaries

This commit is contained in:
morluto
2026-10-25 17:25:18 +08:00
parent 160d821ac9
commit bc143125ed
3 changed files with 167 additions and 28 deletions
+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
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,
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
+19 -4
View File
@@ -105,7 +105,22 @@
}
--- a/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');
const commits = [];
this.logger.debug(`commit search depth: ${this.commitSearchDepth}`);
@@ -127,7 +142,7 @@
batchSize: this.commitBatchSize,
});
const releaseShas = new Set(Object.values(releaseShasByPath));
@@ -272,6 +283,7 @@
@@ -272,6 +291,7 @@
// sha => release pull request
const releasePullRequestsBySha = {};
let releaseCommitsFound = 0;
@@ -135,7 +150,7 @@
for await (const commit of commitGenerator) {
if (releaseShas.has(commit.sha)) {
if (commit.pullRequest) {
@@ -284,14 +296,17 @@
@@ -284,14 +304,17 @@
}
if (this.lastReleaseSha && this.lastReleaseSha === commit.sha) {
this.logger.info(`Using configured lastReleaseSha ${this.lastReleaseSha} as last commit.`);
@@ -153,7 +168,7 @@
break;
}
commits.push({
@@ -301,16 +316,18 @@
@@ -301,16 +324,18 @@
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 {
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",
},
],
nodes: releases,
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({
config = {},
commits = [
@@ -168,6 +194,9 @@ async function releaseFixture({
pullRequest = false,
historyFailures = [],
releaseFailures = [],
releasedVersions = { ".": "6.7.0", pkg: "6.7.0" },
releases = [releaseRecord("6.7.0")],
tags = [],
}: {
config?: Record<string, unknown>;
commits?: CommitRecord[];
@@ -175,6 +204,9 @@ async function releaseFixture({
pullRequest?: boolean;
historyFailures?: HistoryFailure[];
releaseFailures?: HistoryFailure["errors"][];
releasedVersions?: Record<string, string>;
releases?: ReturnType<typeof releaseRecord>[];
tags?: { name: string; commit: { sha: string } }[];
} = {}) {
const requests: { method: string; pathname: string }[] = [];
const pages: HistoryVariables[] = [];
@@ -182,6 +214,7 @@ async function releaseFixture({
const failures = [...historyFailures];
const releaseErrors = [...releaseFailures];
const configuration = { ...defaultConfiguration, ...config };
const entries = releaseFileEntries(configuration, releasedVersions);
const github = await upstream.GitHub.create({
owner: "fixture",
repo: "rea",
@@ -196,7 +229,7 @@ async function releaseFixture({
method: options.method ?? "GET",
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") {
return respond({ body: "Generated release notes" });
}
@@ -214,7 +247,7 @@ async function releaseFixture({
if (query.query.includes("query releases(")) {
const errors = releaseErrors.shift();
if (errors) return respond({ errors });
return respond(releasePage());
return respond(releasePage(releases));
}
if (!query.query.includes("query pullRequestsSince(")) {
throw new Error(`Unexpected GraphQL operation: ${query.query}`);
@@ -234,17 +267,6 @@ async function releaseFixture({
}
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,
@@ -503,6 +525,100 @@ it("collects beyond 500 commits and rejects an unobserved known release boundary
).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([
{
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 () => {
const fixture = await releaseFixture({
releasedVersions: { ".": "6.7.0", pkg: "0.0.0" },
config: {
packages: {
".": { "package-name": "rea-agents" },