diff --git a/.agents/skills/rea-changelog-update/SKILL.md b/.agents/skills/rea-changelog-update/SKILL.md index 7da966bce..027682cfa 100644 --- a/.agents/skills/rea-changelog-update/SKILL.md +++ b/.agents/skills/rea-changelog-update/SKILL.md @@ -25,6 +25,7 @@ node scripts/release-notes.mjs inventory \ --manifest /tmp/rea-VERSION.contributions.json node scripts/release-notes.mjs record \ --manifest /tmp/rea-VERSION.contributions.json \ + --summary \ --output /tmp/rea-VERSION.record.md ``` @@ -56,25 +57,31 @@ version/date/link heading. Use single-line Markdown list items under: 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 +behavior in short, concrete entries. Highlights summarize the release; avoid +repeating their full descriptions in Changes. Internal cleanup, tests, CI, and +routine documentation generally belong only in the provenance file. 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. +Credit each represented contribution once in the public notes with its link +and `Thanks @...` for its verified human author, known co-authors, and confirmed +closing-issue reporters. Grouping preserves all those credits in one line. +When Highlights, detailed entries or migrations reference the same work, +put its thanks in one of those entries, not all of them. Include 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. +At the REA maintainer's request, omit `@morluto` self-thanks while preserving +authorship in the inventory. Other human contributors remain eligible. 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 +Append the generated `record --summary` provenance link verbatim at the end +of this release. The linked inventory accounts for every discovered in-range +PR and direct contribution, including internal work omitted from the prose. +Do not paste the full `record` output into public notes: it duplicates thanks +and obscures the user-facing changes. Full records remain available for review. +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 diff --git a/docs/releasing.md b/docs/releasing.md index fbe8ca5c1..37a28d532 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -21,7 +21,11 @@ Use [the rea-changelog-update skill](https://github.com/morluto/rea/blob/main/.a 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 +`record --summary` produces a compact provenance link for public notes, while +`record` produces the full record for review. Credit a contribution once even +when it appears in highlights, details and migrations; omit maintainer +`@morluto` self-thanks. Keep complete authorship metadata in the inventory +instead of duplicating a credit ledger in the release body. `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 diff --git a/scripts/lib/release-notes.mjs b/scripts/lib/release-notes.mjs index 5078cbb07..1fd5edc4b 100644 --- a/scripts/lib/release-notes.mjs +++ b/scripts/lib/release-notes.mjs @@ -101,10 +101,13 @@ export async function readReleaseHistory(run, base, target) { // 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"]); +// REA's maintainer requested external-contributor thanks without self-credit. +const MAINTAINER_ACCOUNTS = new Set(["morluto"]); const human = (value) => value?.__typename === "User" && !value.login.endsWith("[bot]") && - !AGENT_ACCOUNTS.has(value.login.toLowerCase()) + !AGENT_ACCOUNTS.has(value.login.toLowerCase()) && + !MAINTAINER_ACCOUNTS.has(value.login.toLowerCase()) ? value.login : undefined; const unique = (values) => @@ -163,6 +166,12 @@ export function contributionRecord(raw) { return `${lines.join("\n")}\n`; } +/** Keep the full authorship snapshot available without duplicating public thanks. */ +export function contributionSummary(raw) { + const inventory = releaseInventorySchema.parse(raw); + return `Full contribution record: [${inventory.commits.length} commits and ${inventory.pullRequests.length} merged PR${inventory.pullRequests.length === 1 ? "" : "s"}](https://github.com/${inventory.repository}/blob/rea-agents-${inventory.version}/docs/releases/${inventory.version}.contributions.json).\n`; +} + export function selectedReleaseNotes(text, version) { const headings = [...text.matchAll(/^## \[([^\]]+)\].*$/gmu)]; if (headings[0]?.[1] !== version) @@ -236,7 +245,8 @@ function requiredCredits(inventory, bullet, references) { 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]}` }); + if (handle) + required.push({ handle, context: `commit ${matches[0].sha}` }); } } return required; @@ -264,6 +274,8 @@ function verifyEditorialCredits(inventory, prose) { ]).map((handle) => handle.toLowerCase()), ); const errors = []; + const requiredByContext = new Map(); + const thankedByContext = new Map(); for (const bullet of proseBullets(prose)) { const references = bulletPrs(bullet, inventory.repository); const start = bullet.indexOf("Thanks "); @@ -282,13 +294,21 @@ function verifyEditorialCredits(inventory, prose) { bullet, references, )) { - if (!thanked.has(handle.toLowerCase())) - errors.push(`Missing Thanks @${handle} in bullet for ${context}`); + const required = requiredByContext.get(context) ?? new Set(); + required.add(handle); + requiredByContext.set(context, required); + const credited = thankedByContext.get(context) ?? new Set(); + for (const login of thanked) credited.add(login); + thankedByContext.set(context, credited); } for (const handle of thanked) if (!eligible.has(handle)) errors.push(`Unverified human credit @${handle}`); } + for (const [context, required] of requiredByContext) + for (const handle of required) + if (!thankedByContext.get(context)?.has(handle.toLowerCase())) + errors.push(`Missing Thanks @${handle} for ${context}`); return errors; } @@ -305,8 +325,15 @@ export function verifyReleaseNotes(raw, text, body) { "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) + const summary = contributionSummary(inventory).trim(); + const start = notes.endsWith(summary) + ? notes.length - summary.length + : notes.indexOf("### Complete contribution record"); + if ( + start < 0 || + (notes.slice(start).trim() !== record && + notes.slice(start).trim() !== summary) + ) throw new Error( "Contribution record differs from inventory; regenerate it with release-notes record", ); diff --git a/scripts/release-notes.mjs b/scripts/release-notes.mjs index 2893559e2..3eea0e529 100644 --- a/scripts/release-notes.mjs +++ b/scripts/release-notes.mjs @@ -6,6 +6,7 @@ import { collectGitHubCredits } from "./lib/release-notes-github.mjs"; import { verifyNotesPublication } from "./lib/release-notes-publication.mjs"; import { contributionRecord, + contributionSummary, readReleaseHistory, releaseInventorySchema, selectedReleaseNotes, @@ -73,6 +74,7 @@ async function main() { "pr-body": { type: "string" }, source: { type: "string" }, output: { type: "string" }, + summary: { type: "boolean", default: false }, }, }); const [command] = positionals; @@ -92,7 +94,7 @@ async function main() { !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]", + "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] [--summary for record]", ); if (command === "inventory") { await createInventory(values); @@ -103,11 +105,14 @@ async function main() { ); await verifyInventoryHistory(run, inventory, values.source); if (command === "record") { + const record = values.summary + ? contributionSummary(inventory) + : contributionRecord(inventory); if (values.output) - await writeFile(values.output, contributionRecord(inventory), { + await writeFile(values.output, record, { flag: "wx", }); - else process.stdout.write(contributionRecord(inventory)); + else process.stdout.write(record); return; } const notes = await readFile(values.notes, "utf8"); diff --git a/tests/boundary/filesystem/releaseNotes.test.ts b/tests/boundary/filesystem/releaseNotes.test.ts index cbbe2b1ec..deae92b55 100644 --- a/tests/boundary/filesystem/releaseNotes.test.ts +++ b/tests/boundary/filesystem/releaseNotes.test.ts @@ -83,6 +83,7 @@ async function fixture() { author("platformbot", "Bot"), author("claude"), author("cursoragent"), + author("morluto"), ]), associatedPullRequests: page([]), }, @@ -186,7 +187,7 @@ it("inventories side branches and paginated credits, then checks and renders syn ); expect(f.record).toContain("Thanks @dana."); expect(f.record).not.toMatch( - /@(?:merger|automation|platformbot|claude|cursoragent)/u, + /@(?:merger|automation|platformbot|claude|cursoragent|morluto)/u, ); const body = join(f.directory, "body.md"); await writeFile( @@ -211,6 +212,37 @@ it("inventories side branches and paginated credits, then checks and renders syn await expect( f.invoke(["render", "--manifest", f.manifest, "--output", output]), ).rejects.toMatchObject({ stderr: expect.stringContaining("EEXIST") }); + const { stdout: summary } = await f.invoke([ + "record", + "--manifest", + f.manifest, + "--summary", + ]); + expect(summary).toContain("3 commits and 1 merged PR]"); + expect(summary).toContain( + "/blob/rea-agents-6.4.0/docs/releases/6.4.0.contributions.json", + ); + expect(summary).not.toContain("Thanks"); + const conciseNotes = f.notes + .replace(f.record, summary) + .replace( + "### Changes\n", + "### Changes\n\n- Capability details (#101).\n- Related migration (#51).\n", + ); + await writeFile(join(f.directory, "CHANGELOG.md"), conciseNotes); + await f.invoke(["check", "--manifest", f.manifest]); + await writeFile( + join(f.directory, "CHANGELOG.md"), + conciseNotes.replace( + "6.4.0.contributions.json", + "6.3.0.contributions.json", + ), + ); + await expect( + f.invoke(["check", "--manifest", f.manifest]), + ).rejects.toMatchObject({ + stderr: expect.stringContaining("Contribution record differs"), + }); await expect( f.invoke([ "inventory", @@ -245,10 +277,6 @@ it("rejects lost grouped credits, fabricated references, bots, and stale PR bodi 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]",