mirror of
https://github.com/morluto/rea
synced 2026-10-27 14:42:18 -04:00
fix(release): avoid unused root commit file lookups
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
Generated
+2205
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
@@ -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(
|
||||
(
|
||||
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user