From bc143125edd223b7e0d315d5ea7efdc270c29f9e Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:25:18 +0800 Subject: [PATCH] fix(release): reject missing manifest release boundaries --- docs/releasing.md | 7 + patches/release-please+17.3.0.patch | 23 ++- .../filesystem/releaseProposal.test.ts | 165 +++++++++++++++--- 3 files changed, 167 insertions(+), 28 deletions(-) diff --git a/docs/releasing.md b/docs/releasing.md index 6c388d7211..f380856d0c 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -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 diff --git a/patches/release-please+17.3.0.patch b/patches/release-please+17.3.0.patch index f3a59d035a..9acd148174 100644 --- a/patches/release-please+17.3.0.patch +++ b/patches/release-please+17.3.0.patch @@ -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, }); } diff --git a/tests/boundary/filesystem/releaseProposal.test.ts b/tests/boundary/filesystem/releaseProposal.test.ts index 5f353e5472..f952091603 100644 --- a/tests/boundary/filesystem/releaseProposal.test.ts +++ b/tests/boundary/filesystem/releaseProposal.test.ts @@ -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[]) { 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, + releasedVersions: Record, +) { + const files: Record = { + "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; commits?: CommitRecord[]; @@ -175,6 +204,9 @@ async function releaseFixture({ pullRequest?: boolean; historyFailures?: HistoryFailure[]; releaseFailures?: HistoryFailure["errors"][]; + releasedVersions?: Record; + releases?: ReturnType[]; + 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 = { - "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 = + 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" },