539 lines
17 KiB
TypeScript
539 lines
17 KiB
TypeScript
import {
|
|
access,
|
|
chmod,
|
|
cp,
|
|
mkdir,
|
|
readFile,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import { delimiter, join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { afterAll, beforeAll, expect, it } from "vitest";
|
|
import { z } from "zod";
|
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
|
import {
|
|
createTestWorkspace,
|
|
removeTestWorkspace,
|
|
} from "../../support/workspace/workspaceFixture.js";
|
|
import {
|
|
executeWorkflowFixture as exec,
|
|
workflowGit as git,
|
|
} from "../../support/workflowGit.js";
|
|
|
|
const cli = fileURLToPath(
|
|
new URL("../../../scripts/release-notes.mjs", import.meta.url),
|
|
);
|
|
const actor = (login: string, __typename = "User") => ({ login, __typename });
|
|
const author = (login: string | null, type = "User") => ({
|
|
name: login ?? "Unknown",
|
|
email: "public@example.invalid",
|
|
user: login === null ? null : actor(login, type),
|
|
});
|
|
const page = (nodes: unknown[], cursor: string | null = null) => ({
|
|
nodes,
|
|
pageInfo: { hasNextPage: cursor !== null, endCursor: cursor },
|
|
});
|
|
|
|
interface ReleaseNotesSeed {
|
|
readonly directory: string;
|
|
readonly base: string;
|
|
readonly target: string;
|
|
}
|
|
|
|
let seed: ReleaseNotesSeed | undefined;
|
|
let seedWorkspaceRoot: string | undefined;
|
|
|
|
beforeAll(async () => {
|
|
const workspace = await createTestWorkspace("rea-release-notes-seed-");
|
|
seedWorkspaceRoot = workspace.root;
|
|
const directory = workspace.path("repository");
|
|
await mkdir(directory, { recursive: true });
|
|
await git(directory, ["init", "--initial-branch=main"]);
|
|
await writeFile(join(directory, "product.txt"), "base");
|
|
await writeFile(
|
|
join(directory, "package.json"),
|
|
JSON.stringify({ version: "6.3.0" }),
|
|
);
|
|
await writeFile(
|
|
join(directory, ".release-please-manifest.json"),
|
|
JSON.stringify({ ".": "6.3.0" }),
|
|
);
|
|
await git(directory, ["add", "."]);
|
|
await git(directory, ["commit", "-m", "chore: baseline"]);
|
|
const base = await git(directory, ["rev-parse", "HEAD"]);
|
|
await git(directory, ["tag", "rea-agents-6.3.0"]);
|
|
await git(directory, ["switch", "-c", "feature"]);
|
|
await writeFile(join(directory, "feature.txt"), "capability");
|
|
await git(directory, ["add", "."]);
|
|
await git(directory, ["commit", "-m", "feat: useful capability (#101)"]);
|
|
const feature = await git(directory, ["rev-parse", "HEAD"]);
|
|
await git(directory, ["switch", "main"]);
|
|
await writeFile(join(directory, "direct.txt"), "direct fix");
|
|
await git(directory, ["add", "."]);
|
|
await git(directory, ["commit", "-m", "fix: direct improvement"]);
|
|
const direct = await git(directory, ["rev-parse", "HEAD"]);
|
|
await git(directory, [
|
|
"merge",
|
|
"--no-ff",
|
|
"feature",
|
|
"-m",
|
|
"Merge pull request #101",
|
|
]);
|
|
const target = await git(directory, ["rev-parse", "HEAD"]);
|
|
const bin = join(directory, "bin");
|
|
await mkdir(bin);
|
|
const issue = (number: number, login: string) => ({
|
|
number,
|
|
url: `https://github.com/fixture/rea/issues/${number}`,
|
|
author: actor(login),
|
|
repository: { nameWithOwner: "fixture/rea" },
|
|
});
|
|
const metadata = {
|
|
commits: {
|
|
[feature]: {
|
|
oid: feature,
|
|
authors: page([author("alice")], "authors-2"),
|
|
associatedPullRequests: page(
|
|
[{ number: 999, mergeCommit: null }],
|
|
"prs-2",
|
|
),
|
|
},
|
|
[direct]: {
|
|
oid: direct,
|
|
authors: page([
|
|
author("dana"),
|
|
author("automation[bot]"),
|
|
author("platformbot", "Bot"),
|
|
author("claude"),
|
|
author("cursoragent"),
|
|
author("morluto"),
|
|
]),
|
|
associatedPullRequests: page([]),
|
|
},
|
|
[target]: {
|
|
oid: target,
|
|
authors: page([author("merger")]),
|
|
associatedPullRequests: page([
|
|
{ number: 101, mergeCommit: { oid: target } },
|
|
]),
|
|
},
|
|
},
|
|
prs: {
|
|
101: {
|
|
number: 101,
|
|
title: "feat: useful capability",
|
|
url: "https://github.com/fixture/rea/pull/101",
|
|
author: actor("alice"),
|
|
mergeCommit: { oid: target },
|
|
closingIssuesReferences: page([issue(51, "reporter")], "issues-2"),
|
|
},
|
|
},
|
|
continuations: {
|
|
"authors-2": { authors: page([author("bob"), author(null)]) },
|
|
"prs-2": {
|
|
associatedPullRequests: page([
|
|
{ number: 101, mergeCommit: { oid: target } },
|
|
]),
|
|
},
|
|
"issues-2": {
|
|
closingIssuesReferences: page([issue(52, "second-reporter")]),
|
|
},
|
|
},
|
|
};
|
|
await writeFile(join(directory, "github.json"), JSON.stringify(metadata));
|
|
await writeFile(
|
|
join(bin, "gh"),
|
|
`#!${process.execPath}
|
|
import { readFileSync } from 'node:fs';
|
|
if (!process.argv.includes('graphql')) {
|
|
const pr = JSON.parse(readFileSync(new URL('../.git/release-pr.json', import.meta.url), 'utf8'));
|
|
process.stdout.write(JSON.stringify(process.argv.includes('--slurp') ? [[pr]] : pr));
|
|
process.exit(0);
|
|
}
|
|
let input = ''; for await (const chunk of process.stdin) input += chunk;
|
|
const request = JSON.parse(input);
|
|
const fixture = JSON.parse(readFileSync(new URL('../github.json', import.meta.url), 'utf8'));
|
|
if (fixture.errors) { process.stdout.write(JSON.stringify({ errors: fixture.errors })); process.exit(0); }
|
|
const result = {};
|
|
if (request.variables.cursor) result.item = fixture.continuations[request.variables.cursor];
|
|
else {
|
|
for (const match of request.query.matchAll(/(c\\d+): object\\(oid:"([a-f0-9]+)"\\)/g)) result[match[1]] = fixture.commits[match[2]];
|
|
for (const match of request.query.matchAll(/(p\\d+): pullRequest\\(number:(\\d+)\\)/g)) result[match[1]] = fixture.prs[match[2]];
|
|
}
|
|
if (!Object.keys(result).length || Object.values(result).some(value => !value)) throw new Error('Unmodeled GitHub query');
|
|
process.stdout.write(JSON.stringify({data:{repository:result}}));
|
|
`,
|
|
);
|
|
await chmod(join(bin, "gh"), 0o755);
|
|
seed = { directory, base, target };
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (seedWorkspaceRoot !== undefined)
|
|
await removeTestWorkspace(seedWorkspaceRoot);
|
|
});
|
|
|
|
async function fixture() {
|
|
const initial = seed;
|
|
if (initial === undefined)
|
|
throw new Error("Release notes seed was not initialized");
|
|
const directory = await createTestTempDirectory("rea-release-notes-");
|
|
await cp(initial.directory, directory, {
|
|
recursive: true,
|
|
dereference: true,
|
|
});
|
|
const { base, target } = initial;
|
|
const bin = join(directory, "bin");
|
|
const manifest = join(directory, "inventory.json");
|
|
const invoke = (args: string[]) =>
|
|
exec(process.execPath, [cli, ...args], {
|
|
cwd: directory,
|
|
env: {
|
|
...process.env,
|
|
PATH: `${bin}${delimiter}${process.env.PATH ?? ""}`,
|
|
},
|
|
});
|
|
await invoke([
|
|
"inventory",
|
|
"--repository",
|
|
"fixture/rea",
|
|
"--base",
|
|
base,
|
|
"--target",
|
|
target,
|
|
"--version",
|
|
"6.4.0",
|
|
"--manifest",
|
|
manifest,
|
|
]);
|
|
const { stdout: record } = await invoke(["record", "--manifest", manifest]);
|
|
const bullet =
|
|
"- Added the capability (#101; fixes #51, #52). Thanks @alice, @bob, @reporter, @second-reporter.";
|
|
const notes = `## [6.4.0]\n\n### Highlights\n\n${bullet}\n\n### Changes\n\n### Fixes\n\n${record}`;
|
|
await writeFile(join(directory, "CHANGELOG.md"), notes);
|
|
return { directory, base, target, manifest, invoke, notes, record, bullet };
|
|
}
|
|
|
|
it("inventories side branches and paginated credits, then checks and renders synchronized notes", async () => {
|
|
const f = await fixture();
|
|
const inventory = z
|
|
.object({
|
|
commits: z.array(z.object({ sha: z.string() })),
|
|
pullRequests: z.array(z.object({ number: z.number() })),
|
|
})
|
|
.parse(JSON.parse(await readFile(f.manifest, "utf8")));
|
|
expect(inventory.commits).toHaveLength(3);
|
|
expect(inventory.pullRequests.map((pr) => pr.number)).toEqual([101]);
|
|
expect(f.record).toContain(
|
|
"Thanks @alice, @bob, @reporter, @second-reporter.",
|
|
);
|
|
expect(f.record).toContain("Thanks @dana.");
|
|
expect(f.record).not.toMatch(
|
|
/@(?:merger|automation|platformbot|claude|cursoragent|morluto)/u,
|
|
);
|
|
const body = join(f.directory, "body.md");
|
|
await writeFile(
|
|
body,
|
|
`## 🤖 I have created a release beep boop\n\n${f.notes}\n---\nThis PR was generated with [Release Please](https://github.com/googleapis/release-please).\n`,
|
|
);
|
|
const report = await f.invoke([
|
|
"check",
|
|
"--manifest",
|
|
f.manifest,
|
|
"--pr-body",
|
|
body,
|
|
]);
|
|
expect(JSON.parse(report.stdout)).toMatchObject({
|
|
commits: 3,
|
|
pullRequests: 1,
|
|
unresolvedAuthors: [{ name: "Unknown", user: null }],
|
|
});
|
|
const output = join(f.directory, "rendered.md");
|
|
await f.invoke(["render", "--manifest", f.manifest, "--output", output]);
|
|
expect(await readFile(output, "utf8")).toBe(f.notes.trim() + "\n");
|
|
await expect(
|
|
f.invoke(["render", "--manifest", f.manifest, "--output", output]),
|
|
).rejects.toMatchObject({ stderr: expect.stringContaining("EEXIST") });
|
|
const { stdout: summary } = await f.invoke([
|
|
"record",
|
|
"--manifest",
|
|
f.manifest,
|
|
"--summary",
|
|
]);
|
|
expect(summary).toContain("3 commits and 1 merged PR]");
|
|
expect(summary).toContain(
|
|
"/blob/rea-agents-6.4.0/docs/releases/6.4.0.contributions.json",
|
|
);
|
|
expect(summary).not.toContain("Thanks");
|
|
const conciseNotes = f.notes
|
|
.replace(f.record, summary)
|
|
.replace(
|
|
"### Changes\n",
|
|
"### Changes\n\n- Capability details (#101).\n- Related migration (#51).\n",
|
|
);
|
|
await writeFile(join(f.directory, "CHANGELOG.md"), conciseNotes);
|
|
await f.invoke(["check", "--manifest", f.manifest]);
|
|
await writeFile(
|
|
join(f.directory, "CHANGELOG.md"),
|
|
conciseNotes.replace(
|
|
"6.4.0.contributions.json",
|
|
"6.3.0.contributions.json",
|
|
),
|
|
);
|
|
await expect(
|
|
f.invoke(["check", "--manifest", f.manifest]),
|
|
).rejects.toMatchObject({
|
|
stderr: expect.stringContaining("Contribution record differs"),
|
|
});
|
|
await expect(
|
|
f.invoke([
|
|
"inventory",
|
|
"--repository",
|
|
"fixture/rea",
|
|
"--base",
|
|
f.base,
|
|
"--target",
|
|
f.target,
|
|
"--version",
|
|
"6.4.0",
|
|
"--manifest",
|
|
f.manifest,
|
|
]),
|
|
).rejects.toMatchObject({ stderr: expect.stringContaining("EEXIST") });
|
|
});
|
|
|
|
it("rejects lost grouped credits, fabricated references, bots, and stale PR bodies", async () => {
|
|
const f = await fixture();
|
|
const directLink =
|
|
/\[[a-f0-9]{7}\]\(https:\/\/github\.com\/fixture\/rea\/commit\/[a-f0-9]+\)/u.exec(
|
|
f.record,
|
|
)?.[0];
|
|
if (!directLink)
|
|
throw new Error("Fixture is missing its direct contribution");
|
|
for (const [replacement, error] of [
|
|
[f.bullet.replace(", @bob", ""), "Missing Thanks @bob"],
|
|
[
|
|
f.bullet.replace(", @bob", "") + "\n- Thanks @bob for other work.",
|
|
"Missing Thanks @bob",
|
|
],
|
|
[f.bullet + " (#999)", "Unknown release reference #999"],
|
|
[f.bullet + "a".repeat(65_536), "inline PR body limit"],
|
|
[f.bullet + "汉".repeat(43_000), "GitHub's body budget"],
|
|
[
|
|
f.bullet + `\n- Fixed direct behavior (${directLink}).`,
|
|
"Missing Thanks @dana",
|
|
],
|
|
[
|
|
f.bullet + " Thanks @automation[bot].",
|
|
"Unverified human credit @automation[bot]",
|
|
],
|
|
]) {
|
|
await writeFile(
|
|
join(f.directory, "CHANGELOG.md"),
|
|
f.notes.replace(f.bullet, replacement!),
|
|
);
|
|
await expect(
|
|
f.invoke(["check", "--manifest", f.manifest]),
|
|
).rejects.toMatchObject({ stderr: expect.stringContaining(error!) });
|
|
}
|
|
await writeFile(
|
|
join(f.directory, "CHANGELOG.md"),
|
|
f.notes.replace(
|
|
f.bullet,
|
|
f.bullet + "\n- See [#101](https://github.com/other/repo/pull/101).",
|
|
),
|
|
);
|
|
await f.invoke(["check", "--manifest", f.manifest]);
|
|
await writeFile(join(f.directory, "CHANGELOG.md"), f.notes);
|
|
const body = join(f.directory, "body.md");
|
|
await writeFile(
|
|
body,
|
|
f.notes.replace("Added the capability", "Stale capability"),
|
|
);
|
|
await expect(
|
|
f.invoke(["check", "--manifest", f.manifest, "--pr-body", body]),
|
|
).rejects.toMatchObject({
|
|
stderr: expect.stringContaining("PR body differs"),
|
|
});
|
|
await writeFile(
|
|
join(f.directory, "CHANGELOG.md"),
|
|
f.notes.replace("### Complete contribution record", "### Missing record"),
|
|
);
|
|
await expect(
|
|
f.invoke(["check", "--manifest", f.manifest]),
|
|
).rejects.toMatchObject({
|
|
stderr: expect.stringContaining("Contribution record differs"),
|
|
});
|
|
});
|
|
|
|
it("binds publication to complete recorded history and the reviewed base", async () => {
|
|
const f = await fixture();
|
|
await git(f.directory, ["add", "CHANGELOG.md"]);
|
|
await git(f.directory, ["commit", "-m", "chore: release notes"]);
|
|
const source = await git(f.directory, ["rev-parse", "HEAD"]);
|
|
await f.invoke(["check", "--manifest", f.manifest, "--source", source]);
|
|
await writeFile(join(f.directory, "product.txt"), "later product change");
|
|
await git(f.directory, ["add", "product.txt"]);
|
|
await git(f.directory, ["commit", "-m", "fix: later product change"]);
|
|
await expect(
|
|
f.invoke(["check", "--manifest", f.manifest, "--source", "HEAD"]),
|
|
).rejects.toMatchObject({ stderr: expect.stringContaining("base moved") });
|
|
const value = z
|
|
.object({ commits: z.array(z.unknown()) })
|
|
.passthrough()
|
|
.parse(JSON.parse(await readFile(f.manifest, "utf8")));
|
|
value.commits.pop();
|
|
await writeFile(f.manifest, JSON.stringify(value));
|
|
await expect(
|
|
f.invoke(["check", "--manifest", f.manifest]),
|
|
).rejects.toMatchObject({
|
|
stderr: expect.stringContaining("complete local Git history"),
|
|
});
|
|
});
|
|
|
|
it("checks the live merged PR and exact Git snapshot before either publication route", async () => {
|
|
const f = await fixture();
|
|
await mkdir(join(f.directory, "docs/releases"), { recursive: true });
|
|
await writeFile(
|
|
join(f.directory, "docs/releases/6.4.0.contributions.json"),
|
|
await readFile(f.manifest),
|
|
);
|
|
await writeFile(
|
|
join(f.directory, "package.json"),
|
|
JSON.stringify({ version: "6.4.0" }),
|
|
);
|
|
await git(f.directory, [
|
|
"add",
|
|
"CHANGELOG.md",
|
|
"package.json",
|
|
"docs/releases/6.4.0.contributions.json",
|
|
]);
|
|
await git(f.directory, ["commit", "-m", "chore: release 6.4.0"]);
|
|
const source = await git(f.directory, ["rev-parse", "HEAD"]);
|
|
const pr = {
|
|
number: 1399,
|
|
body: f.notes,
|
|
merged_at: "2026-10-11T00:00:00Z",
|
|
merge_commit_sha: source,
|
|
head: {
|
|
ref: "release-please--branches--main--components--rea-agents",
|
|
repo: { full_name: "fixture/rea" },
|
|
},
|
|
labels: [{ name: "rea:release-notes-finalized" }],
|
|
};
|
|
const prFile = join(f.directory, ".git/release-pr.json");
|
|
await writeFile(prFile, JSON.stringify(pr));
|
|
for (const selector of [[], ["--pr", "1399"]]) {
|
|
const result = await f.invoke([
|
|
"publication",
|
|
"--repository",
|
|
"fixture/rea",
|
|
"--source",
|
|
source,
|
|
...selector,
|
|
]);
|
|
expect(JSON.parse(result.stdout)).toMatchObject({
|
|
status: "verified",
|
|
version: "6.4.0",
|
|
commits: 3,
|
|
});
|
|
}
|
|
// Worktree edits cannot substitute for the reviewed source.
|
|
await writeFile(join(f.directory, "CHANGELOG.md"), "unreviewed working text");
|
|
await f.invoke([
|
|
"publication",
|
|
"--repository",
|
|
"fixture/rea",
|
|
"--source",
|
|
source,
|
|
]);
|
|
await writeFile(
|
|
prFile,
|
|
JSON.stringify({
|
|
...pr,
|
|
body: f.notes.replace("Added the capability", "Different release"),
|
|
}),
|
|
);
|
|
await expect(
|
|
f.invoke([
|
|
"publication",
|
|
"--repository",
|
|
"fixture/rea",
|
|
"--source",
|
|
source,
|
|
]),
|
|
).rejects.toMatchObject({
|
|
stderr: expect.stringContaining("PR body differs"),
|
|
});
|
|
await writeFile(prFile, JSON.stringify(pr));
|
|
await git(f.directory, ["rm", "docs/releases/6.4.0.contributions.json"]);
|
|
await git(f.directory, ["commit", "-m", "chore: remove inventory"]);
|
|
const missing = await git(f.directory, ["rev-parse", "HEAD"]);
|
|
await writeFile(prFile, JSON.stringify({ ...pr, merge_commit_sha: missing }));
|
|
await expect(
|
|
f.invoke([
|
|
"publication",
|
|
"--repository",
|
|
"fixture/rea",
|
|
"--source",
|
|
missing,
|
|
]),
|
|
).rejects.toMatchObject({
|
|
stderr: expect.stringContaining("missing its contribution inventory"),
|
|
});
|
|
await writeFile(
|
|
prFile,
|
|
JSON.stringify({ ...pr, merge_commit_sha: missing, labels: [] }),
|
|
);
|
|
const legacy = await f.invoke([
|
|
"publication",
|
|
"--repository",
|
|
"fixture/rea",
|
|
"--source",
|
|
missing,
|
|
]);
|
|
expect(JSON.parse(legacy.stdout)).toMatchObject({ status: "legacy" });
|
|
});
|
|
|
|
it("fails closed on partial GitHub errors and non-advancing pagination without saving an inventory", async () => {
|
|
const f = await fixture();
|
|
const metadataFile = join(f.directory, "github.json");
|
|
const metadata = JSON.parse(await readFile(metadataFile, "utf8")) as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
await writeFile(
|
|
metadataFile,
|
|
JSON.stringify({ ...metadata, errors: [{ message: "quota exceeded" }] }),
|
|
);
|
|
const output = join(f.directory, "failed.json");
|
|
const args = [
|
|
"inventory",
|
|
"--repository",
|
|
"fixture/rea",
|
|
"--base",
|
|
f.base,
|
|
"--target",
|
|
f.target,
|
|
"--version",
|
|
"6.4.0",
|
|
"--manifest",
|
|
output,
|
|
];
|
|
await expect(f.invoke(args)).rejects.toMatchObject({
|
|
stderr: expect.stringContaining("quota exceeded"),
|
|
});
|
|
await expect(access(output)).rejects.toMatchObject({ code: "ENOENT" });
|
|
const cyclic = {
|
|
...metadata,
|
|
continuations: {
|
|
"authors-2": { authors: page([author("bob")], "authors-2") },
|
|
},
|
|
};
|
|
await writeFile(metadataFile, JSON.stringify(cyclic));
|
|
await expect(f.invoke(args)).rejects.toMatchObject({
|
|
stderr: expect.stringContaining("non-advancing authors cursor"),
|
|
});
|
|
await expect(access(output)).rejects.toMatchObject({ code: "ENOENT" });
|
|
});
|