diff --git a/.agents/skills/rea-changelog-update/SKILL.md b/.agents/skills/rea-changelog-update/SKILL.md new file mode 100644 index 000000000..7da966bce --- /dev/null +++ b/.agents/skills/rea-changelog-update/SKILL.md @@ -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. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 77ef18839..c60f32a2c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 95a9b9b3b..f7a65be58 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/docs/releasing.md b/docs/releasing.md index 5019bbfe2..fbe8ca5c1 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -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 diff --git a/package.json b/package.json index f045fe8de..0592256db 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/lib/release-notes-github.mjs b/scripts/lib/release-notes-github.mjs new file mode 100644 index 000000000..05b1eabc2 --- /dev/null +++ b/scripts/lib/release-notes-github.mjs @@ -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 }; +} diff --git a/scripts/lib/release-notes-publication.mjs b/scripts/lib/release-notes-publication.mjs new file mode 100644 index 000000000..8d4779053 --- /dev/null +++ b/scripts/lib/release-notes-publication.mjs @@ -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), + }; +} diff --git a/scripts/lib/release-notes.mjs b/scripts/lib/release-notes.mjs new file mode 100644 index 000000000..5078cbb07 --- /dev/null +++ b/scripts/lib/release-notes.mjs @@ -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(/(? + 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(", ")}`, + ); + } +} diff --git a/scripts/release-notes.mjs b/scripts/release-notes.mjs new file mode 100644 index 000000000..2893559e2 --- /dev/null +++ b/scripts/release-notes.mjs @@ -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; +}); diff --git a/tests/boundary/filesystem/releaseNotes.test.ts b/tests/boundary/filesystem/releaseNotes.test.ts new file mode 100644 index 000000000..cbbe2b1ec --- /dev/null +++ b/tests/boundary/filesystem/releaseNotes.test.ts @@ -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: "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 }, +}); + +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" }); +}); diff --git a/tests/boundary/filesystem/releaseWorkflow.test.ts b/tests/boundary/filesystem/releaseWorkflow.test.ts index ceadf525f..0bfebd1d2 100644 --- a/tests/boundary/filesystem/releaseWorkflow.test.ts +++ b/tests/boundary/filesystem/releaseWorkflow.test.ts @@ -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", );