mirror of
https://github.com/morluto/rea
synced 2026-10-25 14:01:13 -04:00
fix(release): reject missing manifest release boundaries
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user