feat(release): add contributor-aware release notes workflow

This commit is contained in:
morluto
2026-10-11 10:19:16 +08:00
parent 8b4c3de3b3
commit 824ecf109d
11 changed files with 1510 additions and 0 deletions
@@ -0,0 +1,127 @@
---
name: rea-changelog-update
description: Prepare or rewrite REA release changelogs and GitHub release notes from pinned Git history, with verified contributor thanks and Release Please synchronization.
---
# REA changelog update
Use this for release-note preparation and contributor attribution. Read
[docs/releasing.md](../../../docs/releasing.md) from the repository root for
publication authority and the existing checkpoint checks. This skill prepares
notes; it does not authorize merging, tagging, or publishing.
## Inventory before prose
Fetch complete history and tags. Select the published baseline tag from
`.release-please-manifest.json` on the release PR's **base branch**, and pin that
branch's exact SHA as the editorial target. REA tags are `rea-agents-VERSION`.
Use the proposed version, including its prerelease suffix; do not change the
`always-bump-minor` strategy to imitate OpenClaw's calendar versions.
```bash
node scripts/release-notes.mjs inventory \
--repository morluto/rea --base rea-agents-PREVIOUS \
--target FULL_BASE_BRANCH_SHA --version VERSION \
--manifest /tmp/rea-VERSION.contributions.json
node scripts/release-notes.mjs record \
--manifest /tmp/rea-VERSION.contributions.json \
--output /tmp/rea-VERSION.record.md
```
Read the inventory, not just the existing bot draft. It includes side-branch
commits, GitHub-associated PRs whose merge commits are in the range, commit
authors/co-authors, and confirmed closing issues with their reporters. GitHub
connections are paginated. Network errors stop generation; rerun into a new
output file after recovery. Existing output files are never overwritten.
Unresolved commit identities are retained as names/emails with `user: null`;
never guess a handle or thank the merger as a substitute. The report preserves
unknowns. This is a reviewed metadata snapshot, not independent proof that a
human authored a patch. For unattributed identities, unusual merge strategies,
backports, reversions, or references that GitHub did not associate, inspect
the source PRs and diffs and report the limitation. The current collector uses
confirmed PR-closing relationships for issue-reporter credit; unrelated issue
mentions are not automatically credited. Do not claim that merely inventoried
work still ships after a revert.
## Write for users and retain credit
Rewrite only the newest `## [VERSION]` section in `CHANGELOG.md`, preserving its
version/date/link heading. Use single-line Markdown list items under:
- `### Highlights`: a few release-defining user outcomes; no quota for small releases.
- `### Changes`: capabilities and meaningful behavior changes.
- `### Fixes`: reliability, correctness, compatibility, and safety fixes.
- `### ⚠ BREAKING CHANGES`: retain when applicable, with migrations and original
PR/commit references required by the existing checkpoint validator.
Group related work by analyst workflow or user impact. Explain the resulting
behavior; internal cleanup, tests, CI, and routine documentation generally
belong only in the contribution record. Read ambiguous PRs/diffs before making
claims. Distinguish simulated-provider checks from real-provider verification.
Each bullet covering a PR carries its link and `Thanks @...` for its verified
human author, known co-authors, and confirmed closing-issue reporters. Grouping
must preserve **all** those credits in the same bullet. Include the issue
links. Direct-commit bullets carry the commit link and known human credit.
Exclude GitHub bots and known agent accounts. REA's observed Anthropic and
Cursor co-author trailers resolve to `@claude` and `@cursoragent` with GitHub
type `User`; the credit policy excludes them while retaining their metadata.
Do not copy OpenClaw's maintainer-specific exclusions.
Use explicit GitHub links for cross-repository references to avoid ambiguous
`#NNN` numbers. Unknown handles stay unknown; they are not fabricated credit.
Append the generated `### Complete contribution record` verbatim as the last
section of this release. It accounts for every discovered in-range PR and
direct contribution, including internal work omitted from the prose. Do not
hand-edit generated credits to make validation pass. Refresh the inventory
when product history changes; a prose-only correction can reuse the snapshot.
## Finalize with Release Please
Before changing the live release PR, have authorization for that PR update.
Local preparation and validation can proceed independently.
1. Add `rea:release-notes-finalized` to the selected open release PR **before**
editing its branch/body. Main proposal pushes respect this label. This is
our workflow guard, not Release Please's `autorelease: snooze` mechanism.
A proposal already running must finish before editing. Do not dispatch
manual preparation over finalized notes.
2. On that release branch, save the reviewed inventory as
`docs/releases/VERSION.contributions.json`. This is durable public release
provenance, not a temporary planning file. Do not add credentials or local
environment dumps. Keep the existing version artifacts aligned.
3. Run the existing checkpoint validator and the new checker:
```bash
node scripts/release-notes.mjs check \
--manifest docs/releases/VERSION.contributions.json
node scripts/release-notes.mjs render \
--manifest docs/releases/VERSION.contributions.json \
--output /tmp/rea-VERSION.notes.md
```
The checker supports inline PR bodies, including their surrounding metadata,
up to GitHub's 65,536-character limit. It fails on larger notes rather than
truncating credit; Release Please's overflow-link bodies are not integrated.
4. Update the release PR body with the rendered section while preserving its
Release Please header/footer and component/version structure. Use a body
file for `gh pr edit --body-file`; changing only `CHANGELOG.md` does not
change Release Please's release body. Push only authorized changes.
5. Freshly fetch the PR body to a file and run `check --pr-body FILE`. Review
the complete diff and wait for current CI. Publication checks the body
again before release creation. Avoid further body edits while publishing.
The finalization label freezes bot refreshes, not main. New product changes
after the inventory target require refreshing the notes before publication.
The workflow checks target ancestry and allows only release metadata paths
after that target. It also requires the inventory target to equal the reviewed
merge's first parent, so intervening main commits require a fresh inventory.
Review diffs within the allowed release metadata paths too: the path guard does
not prove that package dependency/script edits are version-only changes.
To resume automatic proposals, remove the finalization label and let the bot
regenerate; expect editorial changes to be replaced.
Existing releases without a contribution inventory retain their current
publication path. Never retag or republish a package to correct release prose.
+34
View File
@@ -35,7 +35,21 @@ jobs:
contents: write
pull-requests: write
steps:
- name: Respect finalized release notes
id: editorial
env:
GH_TOKEN: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
run: |
finalized=$(gh api --paginate "repos/${GH_REPO}/pulls?state=open&base=main&per_page=100" \
--jq '.[] | select(.head.repo.full_name == .base.repo.full_name) | select(.head.ref | startswith("release-please--branches--main--")) | select(any(.labels[]; .name == "rea:release-notes-finalized")) | .number')
if [[ -n "${finalized}" ]]; then
echo "Finalized release notes on PR ${finalized}; automatic proposal refresh is paused." >> "$GITHUB_STEP_SUMMARY"
echo "frozen=true" >> "$GITHUB_OUTPUT"
fi
- uses: googleapis/release-please-action@v4
if: steps.editorial.outputs.frozen != 'true'
with:
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
target-branch: main
@@ -80,6 +94,16 @@ jobs:
node scripts/verify-release-checkpoint.mjs \
--phase publish --release-branch main --source-sha "$SOURCE_SHA"
- name: Validate finalized notes before release creation
env:
GH_TOKEN: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
RELEASE_PR: ${{ github.event.pull_request.number }}
SOURCE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
run: |
node scripts/release-notes.mjs publication --repository "$GH_REPO" \
--source "$SOURCE_SHA" --pr "$RELEASE_PR"
- uses: googleapis/release-please-action@v4
id: release
with:
@@ -192,6 +216,16 @@ jobs:
--phase "$RELEASE_PHASE" --release-branch "$RELEASE_BRANCH"
--source-sha "$SOURCE_SHA"
- name: Validate manual finalized notes before release creation
if: inputs.phase == 'publish'
env:
GH_TOKEN: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
SOURCE_SHA: ${{ steps.branch.outputs.sha }}
run: |
node scripts/release-notes.mjs publication --repository "$GH_REPO" \
--source "$SOURCE_SHA"
- uses: googleapis/release-please-action@v4
id: release
with:
+5
View File
@@ -148,10 +148,15 @@ Generated fixtures and manifests remain ignored.
Use the [release guide](docs/releasing.md). Main pushes automatically refresh a
Release Please PR with the next version and changelog. Review its migration
notes, wait for the current CI checks to pass, and merge it into main to publish.
The Release workflow validates and tags that merge; both publishers build its
exact SHA. Ordinary main pushes cannot publish packages. An optional manual
`release/VERSION` checkpoint retains explicit preparation and publication.
For grouped release notes with verified contributor thanks, use the repository's
`rea-changelog-update` skill in `.agents/skills/rea-changelog-update/`. Its inventory,
credit checks, and finalization process are described in the release guide.
Keep new implementation commits on main for the next release. The workflow
owns packaged-artifact verification, npm publication, the published CLI/MCP
canary, and MCP Registry publication. Sync manual checkpoint metadata back to
+42
View File
@@ -15,6 +15,48 @@ the original run after a partial publication.
For a release that needs an independently frozen application checkpoint, use
the optional manual path below. It retains explicit prepare and publish phases.
## Contributor-aware release notes
Use [the rea-changelog-update skill](https://github.com/morluto/rea/blob/main/.agents/skills/rea-changelog-update/SKILL.md)
to turn the bot draft into grouped Highlights, Changes, and Fixes with inline
`Thanks @...` credit. `npm run release:notes -- inventory` collects the full
selected Git range and paginated GitHub PR/author/closing-issue metadata;
`record` produces the complete contribution record, `check` validates credits
and optional PR-body equality, and `render` writes the verified release section.
Run a subcommand without its required options to see its usage. GitHub reads
require an authenticated `gh`; record/check/render work offline from the saved
inventory and complete local Git history.
Save a finalized inventory on the release branch at
`docs/releases/VERSION.contributions.json`. Review unknown identities and
reverted/backported work explicitly; GitHub metadata does not establish the
quality of a user-facing claim. Preserve the existing breaking-change section
and checkpoint checks. The contribution inventory is a reviewed snapshot, not
a signed attestation; the offline checker verifies local range coverage and
credit consistency, not GitHub's authorship independently.
Before editing the live PR, add `rea:release-notes-finalized` to prevent ordinary
main-push proposals from overwriting the editorial notes. Wait for any already
running proposal to finish. The guard applies to main-push automation; do not
run manual checkpoint preparation over finalized notes. Remove the label to
resume bot regeneration. The label does not freeze the base branch: new base
commits require re-inventorying and re-reviewing the notes.
Update both the release branch's `CHANGELOG.md` and the PR body, preserving
Release Please's surrounding metadata. Before creating a release, the workflow
checks an inventory when present, requires its target to equal the reviewed
merge's first parent, and compares the freshly fetched PR body to the changelog.
It rejects changes outside the release metadata paths and stale credit records
before any tag or publication. Review the diffs within allowed metadata paths
as well, especially package dependencies and scripts. Existing candidates without inventories retain their current
checks. Merging still starts publication; a notes edit does not authorize a
merge or release by itself.
The checker supports inline Release Please PR bodies up to GitHub's 65,536
character limit, including the surrounding header/footer. Overflow-link PR
bodies need a separate integration and fail explicitly; credits are never
silently truncated.
## 1. Select a source for a manual checkpoint
Use the next minor version proposed by Release Please, including releases with
+1
View File
@@ -84,6 +84,7 @@
"third_party/pwndbg/LICENSE.md"
],
"scripts": {
"release:notes": "node scripts/release-notes.mjs",
"deps:check": "node scripts/check-dependency-install.mjs",
"metadata:generate": "node scripts/run-exclusive.mjs artifacts node scripts/generate-package-metadata.mjs",
"metadata:check": "node scripts/generate-package-metadata.mjs --check",
+149
View File
@@ -0,0 +1,149 @@
import { z } from "zod";
import {
releaseAuthorSchema as author,
releaseIssueSchema as issue,
releasePullRequestSchema,
} from "./release-notes.mjs";
const pageInfo = z.object({
hasNextPage: z.boolean(),
endCursor: z.string().nullable(),
});
const connection = (node) => z.object({ nodes: z.array(node), pageInfo });
const associated = z.object({
number: z.number().int().positive(),
mergeCommit: z.object({ oid: z.string() }).nullable(),
});
const pr = releasePullRequestSchema.omit({ issues: true }).extend({
closingIssuesReferences: connection(issue),
});
const PAGE = "pageInfo { hasNextPage endCursor }";
const ACTOR = "login __typename";
const AUTHORS = `authors(first:100, after:$cursor) { nodes { name email user { ${ACTOR} } } ${PAGE} }`;
const ASSOCIATED = `associatedPullRequests(first:100, after:$cursor) { nodes { number mergeCommit { oid } } ${PAGE} }`;
const ISSUES = `closingIssuesReferences(first:100, after:$cursor) { nodes { number url author { ${ACTOR} } repository { nameWithOwner } } ${PAGE} }`;
async function query(run, repository, selection, cursor = null) {
const [owner, name] = repository.split("/");
const response = JSON.parse(
await run(
"gh",
["api", "graphql", "--input", "-"],
JSON.stringify({
query: `query($owner:String!,$name:String!,$cursor:String) { repository(owner:$owner,name:$name) { ${selection} } }`,
variables: { owner, name, cursor },
}),
),
);
if (response.errors?.length)
throw new Error(
`GitHub inventory query failed: ${JSON.stringify(response.errors)}`,
);
if (!response.data?.repository)
throw new Error(`GitHub repository unavailable: ${repository}`);
return response.data.repository;
}
async function remainingPages(run, repository, selection, field) {
const initial = field.initial;
const nodes = [...initial.nodes];
let page = initial;
const cursors = new Set();
while (page.pageInfo.hasNextPage) {
const cursor = page.pageInfo.endCursor;
if (!cursor || cursors.has(cursor))
throw new Error(`GitHub returned a non-advancing ${field.name} cursor`);
cursors.add(cursor);
const response = await query(run, repository, selection, cursor);
page = field.schema.parse(response.item?.[field.name]);
nodes.push(...page.nodes);
}
return nodes;
}
/** GitHub's commit authors include co-authors; unresolved identities remain explicit. */
export async function collectGitHubCredits(run, repository, commits) {
const shipped = new Set(commits.map((commit) => commit.sha));
const numbers = new Set();
const result = [];
// Small query batches limit transient GraphQL work, never the history examined.
for (let offset = 0; offset < commits.length; offset += 20) {
const batch = commits.slice(offset, offset + 20);
const selection = batch
.map(
(commit, index) =>
`c${index}: object(oid:${JSON.stringify(commit.sha)}) { ... on Commit { oid ${AUTHORS} ${ASSOCIATED} } }`,
)
.join("\n");
const response = await query(run, repository, selection);
for (const [index, commit] of batch.entries()) {
const value = z
.object({
oid: z.literal(commit.sha),
authors: connection(author),
associatedPullRequests: connection(associated),
})
.parse(response[`c${index}`]);
const select = (fields) =>
`item: object(oid:${JSON.stringify(commit.sha)}) { ... on Commit { ${fields} } }`;
const authors = await remainingPages(run, repository, select(AUTHORS), {
name: "authors",
schema: connection(author),
initial: value.authors,
});
const associations = await remainingPages(
run,
repository,
select(ASSOCIATED),
{
name: "associatedPullRequests",
schema: connection(associated),
initial: value.associatedPullRequests,
},
);
const pullRequests = [
...new Set(
associations
.filter((item) => shipped.has(item.mergeCommit?.oid))
.map((item) => item.number),
),
];
for (const number of pullRequests) numbers.add(number);
result.push({ ...commit, authors, pullRequests });
}
}
const pullRequests = [];
const sorted = [...numbers].sort((a, b) => a - b);
for (let offset = 0; offset < sorted.length; offset += 20) {
const batch = sorted.slice(offset, offset + 20);
const fields = `number title url author { ${ACTOR} } mergeCommit { oid } ${ISSUES}`;
const response = await query(
run,
repository,
batch
.map(
(number, index) =>
`p${index}: pullRequest(number:${number}) { ${fields} }`,
)
.join("\n"),
);
for (const [index, number] of batch.entries()) {
const value = pr.parse(response[`p${index}`]);
if (value.number !== number || !shipped.has(value.mergeCommit.oid))
throw new Error(`PR #${number} changed outside the selected history`);
const issues = await remainingPages(
run,
repository,
`item: pullRequest(number:${number}) { ${ISSUES} }`,
{
name: "closingIssuesReferences",
schema: connection(issue),
initial: value.closingIssuesReferences,
},
);
const { closingIssuesReferences: _connection, ...metadata } = value;
pullRequests.push({ ...metadata, issues });
}
}
return { commits: result, pullRequests };
}
+118
View File
@@ -0,0 +1,118 @@
import { valid } from "semver";
import { z } from "zod";
import {
releaseInventorySchema,
verifyInventoryHistory,
verifyReleaseNotes,
} from "./release-notes.mjs";
const sha = z.string().regex(/^[a-f0-9]{40}$/u);
const repository = z.string().regex(/^[\w.-]+\/[\w.-]+$/u);
const pullRequest = z.object({
number: z.number().int().positive(),
body: z.string(),
merged_at: z.string().nullable(),
merge_commit_sha: sha.nullable(),
head: z.object({
ref: z.string(),
repo: z.object({ full_name: z.string() }).nullable(),
}),
labels: z.array(z.object({ name: z.string() })),
});
/** Read the reviewed Git snapshot and live PR before Release Please can mutate tags. */
export async function verifyNotesPublication(run, options) {
const repo = repository.parse(options.repository);
const source = sha.parse(
(
await run("git", [
"rev-parse",
"--verify",
"--end-of-options",
`${options.source}^{commit}`,
])
).trim(),
);
const read = (path) => run("git", ["show", `${source}:${path}`]);
const { version } = z
.object({ version: z.string() })
.parse(JSON.parse(await read("package.json")));
if (valid(version) !== version || version.includes("+"))
throw new Error("Invalid reviewed package version");
let candidates;
if (options.pr !== undefined) {
const number = z.coerce.number().int().positive().parse(options.pr);
candidates = [
pullRequest.parse(
JSON.parse(await run("gh", ["api", `repos/${repo}/pulls/${number}`])),
),
];
} else {
candidates = z
.array(z.array(pullRequest))
.parse(
JSON.parse(
await run("gh", [
"api",
"--paginate",
"--slurp",
`repos/${repo}/commits/${source}/pulls?per_page=100`,
]),
),
)
.flat();
}
const matching = candidates.filter(
(pr) =>
pr.merged_at !== null &&
pr.merge_commit_sha === source &&
pr.head.repo?.full_name.toLowerCase() === repo.toLowerCase() &&
pr.head.ref.startsWith("release-please--branches--"),
);
if (matching.length !== 1)
throw new Error(
"Expected exactly one merged Release Please PR for the reviewed source",
);
const pr = matching[0];
const path = `docs/releases/${version}.contributions.json`;
const present =
(
await run("git", ["ls-tree", "--name-only", source, "--", path])
).trim() === path;
if (!present) {
if (pr.labels.some((label) => label.name === "rea:release-notes-finalized"))
throw new Error(
"Finalized release PR is missing its contribution inventory",
);
return { status: "legacy", version, pullRequest: pr.number };
}
const inventory = releaseInventorySchema.parse(JSON.parse(await read(path)));
if (
inventory.version !== version ||
inventory.repository.toLowerCase() !== repo.toLowerCase()
)
throw new Error(
"Contribution inventory differs from the reviewed release version or repository",
);
const baseline = z
.object({ ".": z.string() })
.parse(
JSON.parse(
await run("git", ["show", `${source}^1:.release-please-manifest.json`]),
),
)["."];
if (valid(baseline) !== baseline || baseline.includes("+"))
throw new Error("Invalid release baseline version");
const baselineSha = (
await run("git", ["rev-parse", `refs/tags/rea-agents-${baseline}^{commit}`])
).trim();
if (inventory.base !== baselineSha)
throw new Error(
"Contribution inventory does not start at the published baseline tag",
);
await verifyInventoryHistory(run, inventory, source);
return {
status: "verified",
...verifyReleaseNotes(inventory, await read("CHANGELOG.md"), pr.body),
};
}
+415
View File
@@ -0,0 +1,415 @@
import { valid } from "semver";
import { z } from "zod";
const sha = z.string().regex(/^[a-f0-9]{40}$/u);
const actor = z.object({
login: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9-]*(?:\[bot\])?$/u),
__typename: z.string(),
});
const author = z.object({
name: z.string(),
email: z.string(),
user: actor.nullable(),
});
const commit = z.object({
sha,
subject: z.string(),
body: z.string(),
parents: z.array(sha),
authors: z.array(author),
pullRequests: z.array(z.number().int().positive()),
});
const issue = z.object({
number: z.number().int().positive(),
url: z.url(),
author: actor.nullable(),
repository: z.object({ nameWithOwner: z.string() }),
});
const pullRequest = z.object({
number: z.number().int().positive(),
title: z.string(),
url: z.url(),
author: actor.nullable(),
mergeCommit: z.object({ oid: sha }),
issues: z.array(issue),
});
export {
author as releaseAuthorSchema,
issue as releaseIssueSchema,
pullRequest as releasePullRequestSchema,
};
export const releaseInventorySchema = z.object({
schemaVersion: z.literal(1),
repository: z.string().regex(/^[\w.-]+\/[\w.-]+$/u),
version: z
.string()
.refine(
(value) => valid(value) === value && !value.includes("+"),
"Expected exact release SemVer",
),
base: sha,
target: sha,
commits: z.array(commit),
pullRequests: z.array(pullRequest),
});
export async function readReleaseHistory(run, base, target) {
if (
(await run("git", ["rev-parse", "--is-shallow-repository"])).trim() !==
"false"
)
throw new Error(
"Release inventory requires complete Git history; fetch --unshallow first",
);
const resolve = async (ref) =>
sha.parse(
(
await run("git", [
"rev-parse",
"--verify",
"--end-of-options",
`${ref}^{commit}`,
])
).trim(),
);
const baseSha = await resolve(base);
const targetSha = await resolve(target);
await run("git", ["merge-base", "--is-ancestor", baseSha, targetSha]);
const text = await run("git", [
"log",
"--reverse",
"--topo-order",
"-z",
"--format=%H%x00%s%x00%B%x00%P",
`${baseSha}..${targetSha}`,
]);
const fields = text.split("\0");
if (fields.pop() !== "" || fields.length % 4)
throw new Error("Malformed Git release history");
const commits = [];
for (let index = 0; index < fields.length; index += 4) {
commits.push({
sha: sha.parse(fields[index]),
subject: fields[index + 1],
body: fields[index + 2],
parents: fields[index + 3].split(" ").filter(Boolean),
});
}
return { base: baseSha, target: targetSha, commits };
}
// REA history maps Anthropic and Cursor's agent trailers to ordinary User actors.
// Keep those source identities in the inventory while excluding automation credit.
const AGENT_ACCOUNTS = new Set(["claude", "cursoragent"]);
const human = (value) =>
value?.__typename === "User" &&
!value.login.endsWith("[bot]") &&
!AGENT_ACCOUNTS.has(value.login.toLowerCase())
? value.login
: undefined;
const unique = (values) =>
[...new Set(values.filter(Boolean))].sort((a, b) =>
a.toLowerCase().localeCompare(b.toLowerCase()),
);
function prCredits(inventory, pr) {
return unique([
human(pr.author),
...pr.issues.map((item) => human(item.author)),
...inventory.commits
.filter(
(item) =>
item.parents.length === 1 && item.pullRequests.includes(pr.number),
)
.flatMap((item) => item.authors.map((identity) => human(identity.user))),
]);
}
function thanks(handles) {
return handles.length
? ` Thanks ${handles.map((handle) => `@${handle}`).join(", ")}.`
: "";
}
export function contributionRecord(raw) {
const inventory = releaseInventorySchema.parse(raw);
const lines = [
"### Complete contribution record",
"",
`History: \`${inventory.base}..${inventory.target}\`; ${inventory.pullRequests.length} merged PRs, ${inventory.commits.length} commits examined.`,
"",
];
for (const pr of inventory.pullRequests) {
const issues = pr.issues
.map(
(item) =>
`[${item.repository.nameWithOwner}#${item.number}](${item.url})`,
)
.join(", ");
lines.push(
`- **PR [#${pr.number}](${pr.url})**${issues ? ` Related ${issues}.` : ""}${thanks(prCredits(inventory, pr))}`,
);
}
const direct = inventory.commits.filter(
(item) => item.pullRequests.length === 0 && item.parents.length <= 1,
);
if (direct.length) {
lines.push("", "#### Direct contributions", "");
for (const item of direct)
lines.push(
`- [${item.sha.slice(0, 7)}](https://github.com/${inventory.repository}/commit/${item.sha})${thanks(unique(item.authors.map((identity) => human(identity.user))))}`,
);
}
return `${lines.join("\n")}\n`;
}
export function selectedReleaseNotes(text, version) {
const headings = [...text.matchAll(/^## \[([^\]]+)\].*$/gmu)];
if (headings[0]?.[1] !== version)
throw new Error(`Notes must start with release [${version}]`);
return text
.slice(headings[0].index, headings[1]?.index ?? text.length)
.trim();
}
// Notes have one Markdown list item per line; fenced examples are never credit sources.
function proseBullets(text) {
let fence;
return text.split("\n").filter((line) => {
const marker = /^\s*(`{3,}|~{3,})/u.exec(line)?.[1];
if (marker) {
if (!fence) fence = marker;
else if (marker[0] === fence[0] && marker.length >= fence.length)
fence = undefined;
return false;
}
return !fence && /^[-*] /u.test(line);
});
}
function bulletPrs(bullet, repository) {
// Resolve explicit links first so cross-repository numbers cannot acquire local credit.
const local = [];
const withoutLinks = bullet.replace(
/\[([^\]]*)\]\(https:\/\/github\.com\/([^/]+\/[^/]+)\/(?:pull|issues)\/(\d+)\)/gu,
(_match, _label, repo, number) => {
if (repo.toLowerCase() === repository.toLowerCase())
local.push(Number(number));
return "";
},
);
for (const match of withoutLinks.matchAll(/(?<![\w/])#(\d+)\b/gu))
local.push(Number(match[1]));
return new Set(local);
}
function requiredCredits(inventory, bullet, references) {
const required = [];
for (const pr of inventory.pullRequests.filter((item) =>
references.has(item.number),
)) {
for (const handle of prCredits(inventory, pr))
required.push({ handle, context: `PR #${pr.number}` });
}
for (const issue of inventory.pullRequests.flatMap((item) => item.issues)) {
const mentioned =
(issue.repository.nameWithOwner.toLowerCase() ===
inventory.repository.toLowerCase() &&
references.has(issue.number)) ||
bullet.includes(`](${issue.url})`);
const handle = human(issue.author);
if (mentioned && handle)
required.push({
handle,
context: `issue ${issue.repository.nameWithOwner}#${issue.number}`,
});
}
for (const match of bullet.matchAll(
/https:\/\/github\.com\/([^/]+\/[^/]+)\/commit\/([a-f0-9]{7,40})\b/gu,
)) {
if (match[1].toLowerCase() !== inventory.repository.toLowerCase()) continue;
const matches = inventory.commits.filter((item) =>
item.sha.startsWith(match[2]),
);
if (matches.length !== 1)
throw new Error(`Unknown or ambiguous release commit ${match[2]}`);
if (matches[0].parents.length > 1) continue;
for (const identity of matches[0].authors) {
const handle = human(identity.user);
if (handle) required.push({ handle, context: `commit ${match[2]}` });
}
}
return required;
}
function verifyEditorialCredits(inventory, prose) {
const known = new Set(inventory.pullRequests.map((pr) => pr.number));
const issues = new Set(
inventory.pullRequests.flatMap((pr) =>
pr.issues
.filter(
(item) =>
item.repository.nameWithOwner.toLowerCase() ===
inventory.repository.toLowerCase(),
)
.map((item) => item.number),
),
);
const eligible = new Set(
unique([
...inventory.pullRequests.flatMap((pr) => prCredits(inventory, pr)),
...inventory.commits.flatMap((item) =>
item.authors.map((identity) => human(identity.user)),
),
]).map((handle) => handle.toLowerCase()),
);
const errors = [];
for (const bullet of proseBullets(prose)) {
const references = bulletPrs(bullet, inventory.repository);
const start = bullet.indexOf("Thanks ");
const thanked = new Set(
[
...bullet
.slice(start < 0 ? bullet.length : start)
.matchAll(/@([A-Za-z0-9-]+(?:\[bot\])?)/gu),
].map((match) => match[1].toLowerCase()),
);
for (const number of references)
if (!known.has(number) && !issues.has(number))
errors.push(`Unknown release reference #${number}`);
for (const { handle, context } of requiredCredits(
inventory,
bullet,
references,
)) {
if (!thanked.has(handle.toLowerCase()))
errors.push(`Missing Thanks @${handle} in bullet for ${context}`);
}
for (const handle of thanked)
if (!eligible.has(handle))
errors.push(`Unverified human credit @${handle}`);
}
return errors;
}
export function verifyReleaseNotes(raw, text, body) {
const inventory = releaseInventorySchema.parse(raw);
const notes = selectedReleaseNotes(text, inventory.version);
// Release Please's inline issue/PR body limit (pull-request-overflow-handler).
if (notes.length > 65_536 || (body !== undefined && body.length > 65_536))
throw new Error(
"Release notes exceed GitHub's inline PR body limit; overflow-link bodies are not supported by this checker",
);
if (notes.length > 125_000 || Buffer.byteLength(notes) > 125_000)
throw new Error(
"Release notes exceed GitHub's body budget; shorten prose without dropping credit",
);
const record = contributionRecord(inventory).trim();
const start = notes.indexOf("### Complete contribution record");
if (start < 0 || notes.slice(start).trim() !== record)
throw new Error(
"Contribution record differs from inventory; regenerate it with release-notes record",
);
const prose = notes.slice(0, start);
const errors = verifyEditorialCredits(inventory, prose);
for (const heading of ["Highlights", "Changes", "Fixes"])
if (!prose.includes(`### ${heading}\n`))
errors.push(`Missing ${heading} section`);
if (body !== undefined) {
const bodyNotes = selectedReleaseNotes(body, inventory.version)
.replace(
/\n---\nThis PR was generated with \[Release Please\][\s\S]*$/u,
"",
)
.trim();
if (bodyNotes !== notes)
errors.push("Release PR body differs from CHANGELOG.md");
}
if (errors.length) throw new Error(errors.join("\n"));
return {
version: inventory.version,
pullRequests: inventory.pullRequests.length,
commits: inventory.commits.length,
unresolvedAuthors: inventory.commits.flatMap((item) =>
item.authors
.filter((identity) => identity.user === null)
.map((identity) => ({ sha: item.sha, ...identity })),
),
};
}
/** Reject stale inventories and product changes made after the editorial target. */
export async function verifyInventoryHistory(run, raw, source) {
const inventory = releaseInventorySchema.parse(raw);
const history = await readReleaseHistory(
run,
inventory.base,
inventory.target,
);
const original = inventory.commits.map(
({ authors: _authors, pullRequests: _prs, ...item }) => item,
);
if (JSON.stringify(history.commits) !== JSON.stringify(original))
throw new Error("Inventory does not match complete local Git history");
const shas = new Set(history.commits.map((item) => item.sha));
const numbers = new Set();
for (const pr of inventory.pullRequests) {
if (numbers.has(pr.number) || !shas.has(pr.mergeCommit.oid))
throw new Error(`Duplicate or out-of-range PR #${pr.number}`);
numbers.add(pr.number);
}
for (const item of inventory.commits)
for (const number of item.pullRequests)
if (!numbers.has(number))
throw new Error(`Commit refers to missing PR #${number}`);
if (source !== undefined) {
const resolved = sha.parse(
(
await run("git", [
"rev-parse",
"--verify",
"--end-of-options",
`${source}^{commit}`,
])
).trim(),
);
const parent = (await run("git", ["rev-parse", `${resolved}^1`])).trim();
if (parent !== inventory.target)
throw new Error(
"Release base moved after notes target; regenerate inventory for the reviewed merge's first parent",
);
await run("git", [
"merge-base",
"--is-ancestor",
inventory.target,
resolved,
]);
const allowed = new Set([
"CHANGELOG.md",
".release-please-manifest.json",
"package.json",
"package-lock.json",
"server.json",
"src/generatedPackageMetadata.ts",
"docs/installation.md",
`docs/releases/${inventory.version}.contributions.json`,
]);
const paths = (
await run("git", [
"diff",
"--name-only",
"--no-renames",
"-z",
inventory.target,
resolved,
])
)
.split("\0")
.filter(Boolean);
const product = paths.filter((path) => !allowed.has(path));
if (product.length)
throw new Error(
`Product changed after notes target; regenerate inventory: ${product.join(", ")}`,
);
}
}
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env node
import { execFile } from "node:child_process";
import { readFile, writeFile } from "node:fs/promises";
import { parseArgs, promisify } from "node:util";
import { collectGitHubCredits } from "./lib/release-notes-github.mjs";
import { verifyNotesPublication } from "./lib/release-notes-publication.mjs";
import {
contributionRecord,
readReleaseHistory,
releaseInventorySchema,
selectedReleaseNotes,
verifyInventoryHistory,
verifyReleaseNotes,
} from "./lib/release-notes.mjs";
const execFileAsync = promisify(execFile);
async function run(command, args, input) {
const pending = execFileAsync(command, args, {
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
});
pending.child.stdin.end(input);
return (await pending).stdout;
}
async function createInventory(values) {
if (!values.repository || !values.base || !values.target || !values.version)
throw new Error(
"inventory requires --repository, --base, --target and --version",
);
// Parse caller-selected boundaries before making network requests.
const history = await readReleaseHistory(run, values.base, values.target);
releaseInventorySchema.omit({ commits: true, pullRequests: true }).parse({
schemaVersion: 1,
repository: values.repository,
version: values.version,
base: history.base,
target: history.target,
});
const credits = await collectGitHubCredits(
run,
values.repository,
history.commits,
);
const inventory = releaseInventorySchema.parse({
schemaVersion: 1,
repository: values.repository,
version: values.version,
base: history.base,
target: history.target,
...credits,
});
await verifyInventoryHistory(run, inventory);
await writeFile(values.manifest, `${JSON.stringify(inventory, null, 2)}\n`, {
flag: "wx",
});
process.stdout.write(
`Inventoried ${inventory.commits.length} commits and ${inventory.pullRequests.length} merged PRs. Saved ${values.manifest}\n`,
);
}
async function main() {
const { values, positionals } = parseArgs({
allowPositionals: true,
options: {
repository: { type: "string" },
pr: { type: "string" },
base: { type: "string" },
target: { type: "string" },
version: { type: "string" },
manifest: { type: "string" },
notes: { type: "string", default: "CHANGELOG.md" },
"pr-body": { type: "string" },
source: { type: "string" },
output: { type: "string" },
},
});
const [command] = positionals;
if (command === "publication" && positionals.length === 1) {
if (!values.repository || !values.source)
throw new Error(
"publication requires --repository and --source; --pr selects the merged release PR",
);
process.stdout.write(
`${JSON.stringify(await verifyNotesPublication(run, values), null, 2)}\n`,
);
return;
}
if (
positionals.length !== 1 ||
!["inventory", "record", "check", "render"].includes(command) ||
!values.manifest
)
throw new Error(
"Usage: release-notes inventory|record|check|render --manifest FILE [--repository OWNER/REPO --base TAG --target SHA --version VERSION] [--notes FILE --pr-body FILE --source SHA --output FILE]",
);
if (command === "inventory") {
await createInventory(values);
return;
}
const inventory = releaseInventorySchema.parse(
JSON.parse(await readFile(values.manifest, "utf8")),
);
await verifyInventoryHistory(run, inventory, values.source);
if (command === "record") {
if (values.output)
await writeFile(values.output, contributionRecord(inventory), {
flag: "wx",
});
else process.stdout.write(contributionRecord(inventory));
return;
}
const notes = await readFile(values.notes, "utf8");
const body = values["pr-body"]
? await readFile(values["pr-body"], "utf8")
: undefined;
const report = verifyReleaseNotes(inventory, notes, body);
if (command === "check") {
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
return;
}
if (!values.output)
throw new Error(
"render requires --output (an existing file is never overwritten)",
);
const rendered = selectedReleaseNotes(notes, inventory.version);
await writeFile(values.output, `${rendered}\n`, { flag: "wx" });
}
await main().catch((error) => {
process.stderr.write(
`Release notes failed: ${error instanceof Error ? error.message : String(error)}\n`,
);
process.exitCode = 1;
});
@@ -0,0 +1,464 @@
import { access, chmod, mkdir, readFile, writeFile } from "node:fs/promises";
import { delimiter, join } from "node:path";
import { fileURLToPath } from "node:url";
import { expect, it } from "vitest";
import { z } from "zod";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.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: "[email protected]",
user: login === null ? null : actor(login, type),
});
const page = (nodes: unknown[], cursor: string | null = null) => ({
nodes,
pageInfo: { hasNextPage: cursor !== null, endCursor: cursor },
});
async function fixture() {
const directory = await createTestTempDirectory("rea-release-notes-");
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"),
]),
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);
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)/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") });
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 + " (#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 + "\n- Fixed the reported problem (#51).",
"Missing Thanks @reporter",
],
[
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" });
});
@@ -103,6 +103,9 @@ it("keeps main pushes proposal-only and gates merged-release automation", async
"skip-github-release": true,
});
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"];
expect(merged.if).toContain("github.event.pull_request.merged == true");
expect(merged.if).toContain("github.repository");
@@ -147,6 +150,12 @@ it("validates the reviewed merge before creating its release", async () => {
SOURCE_SHA: "${{ github.event.pull_request.merge_commit_sha }}",
RELEASE_SHA: "${{ steps.release.outputs.sha }}",
});
const notesIndex = steps.findIndex((step) =>
step.run?.includes("release-notes.mjs publication"),
);
expect(notesIndex).toBeGreaterThan(steps.indexOf(checkpoint!));
expect(notesIndex).toBeLessThan(steps.indexOf(release!));
expect(steps[notesIndex]?.run).toContain('--pr "$RELEASE_PR"');
});
it("keeps frozen candidate preparation and publication explicit", async () => {
@@ -171,6 +180,17 @@ it("keeps frozen candidate preparation and publication explicit", async () => {
"skip-github-release": "${{ inputs.phase == 'prepare' }}",
"skip-github-pull-request": "${{ inputs.phase == 'publish' }}",
});
const steps = workflow.jobs["release-please"].steps;
const notesIndex = steps.findIndex((step) =>
step.run?.includes("release-notes.mjs publication"),
);
expect(notesIndex).toBeGreaterThan(
steps.findIndex((step) =>
step.run?.includes("verify-release-checkpoint.mjs"),
),
);
expect(notesIndex).toBeLessThan(steps.indexOf(release!));
expect(steps[notesIndex]?.if).toBe("inputs.phase == 'publish'");
const catalogValidation = workflow.jobs["release-please"].steps.find(
(step) => step.name === "Validate generated release documentation",
);