fix(release): avoid duplicated contributor thanks

This commit is contained in:
morluto committed 2026-10-11 11:49:49 +08:00
1 parent 4003151ec1
commit 6b043dc3de
5 files changed
+97 -26

No files matched your search

+18 -11
View File
@@ -25,6 +25,7 @@ node scripts/release-notes.mjs inventory \
--manifest /tmp/rea-VERSION.contributions.json --manifest /tmp/rea-VERSION.contributions.json
node scripts/release-notes.mjs record \ node scripts/release-notes.mjs record \
--manifest /tmp/rea-VERSION.contributions.json \ --manifest /tmp/rea-VERSION.contributions.json \
--summary \
--output /tmp/rea-VERSION.record.md --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. PR/commit references required by the existing checkpoint validator.
Group related work by analyst workflow or user impact. Explain the resulting Group related work by analyst workflow or user impact. Explain the resulting
behavior; internal cleanup, tests, CI, and routine documentation generally behavior in short, concrete entries. Highlights summarize the release; avoid
belong only in the contribution record. Read ambiguous PRs/diffs before making 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. claims. Distinguish simulated-provider checks from real-provider verification.
Each bullet covering a PR carries its link and `Thanks @...` for its verified Credit each represented contribution once in the public notes with its link
human author, known co-authors, and confirmed closing-issue reporters. Grouping and `Thanks @...` for its verified human author, known co-authors, and confirmed
must preserve **all** those credits in the same bullet. Include the issue closing-issue reporters. Grouping preserves all those credits in one line.
links. Direct-commit bullets carry the commit link and known human credit. 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 Exclude GitHub bots and known agent accounts. REA's observed Anthropic and
Cursor co-author trailers resolve to `@claude` and `@cursoragent` with GitHub Cursor co-author trailers resolve to `@claude` and `@cursoragent` with GitHub
type `User`; the credit policy excludes them while retaining their metadata. 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 Use explicit GitHub links for cross-repository references to avoid ambiguous
`#NNN` numbers. Unknown handles stay unknown; they are not fabricated credit. `#NNN` numbers. Unknown handles stay unknown; they are not fabricated credit.
Append the generated `### Complete contribution record` verbatim as the last Append the generated `record --summary` provenance link verbatim at the end
section of this release. It accounts for every discovered in-range PR and of this release. The linked inventory accounts for every discovered in-range
direct contribution, including internal work omitted from the prose. Do not PR and direct contribution, including internal work omitted from the prose.
hand-edit generated credits to make validation pass. Refresh the inventory 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. when product history changes; a prose-only correction can reuse the snapshot.
## Finalize with Release Please ## Finalize with Release Please
+5 -1
View File
@@ -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 to turn the bot draft into grouped Highlights, Changes, and Fixes with inline
`Thanks @...` credit. `npm run release:notes -- inventory` collects the full `Thanks @...` credit. `npm run release:notes -- inventory` collects the full
selected Git range and paginated GitHub PR/author/closing-issue metadata; 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. 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 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 require an authenticated `gh`; record/check/render work offline from the saved
+33 -6
View File
@@ -101,10 +101,13 @@ export async function readReleaseHistory(run, base, target) {
// REA history maps Anthropic and Cursor's agent trailers to ordinary User actors. // REA history maps Anthropic and Cursor's agent trailers to ordinary User actors.
// Keep those source identities in the inventory while excluding automation credit. // Keep those source identities in the inventory while excluding automation credit.
const AGENT_ACCOUNTS = new Set(["claude", "cursoragent"]); 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) => const human = (value) =>
value?.__typename === "User" && value?.__typename === "User" &&
!value.login.endsWith("[bot]") && !value.login.endsWith("[bot]") &&
!AGENT_ACCOUNTS.has(value.login.toLowerCase()) !AGENT_ACCOUNTS.has(value.login.toLowerCase()) &&
!MAINTAINER_ACCOUNTS.has(value.login.toLowerCase())
? value.login ? value.login
: undefined; : undefined;
const unique = (values) => const unique = (values) =>
@@ -163,6 +166,12 @@ export function contributionRecord(raw) {
return `${lines.join("\n")}\n`; 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) { export function selectedReleaseNotes(text, version) {
const headings = [...text.matchAll(/^## \[([^\]]+)\].*$/gmu)]; const headings = [...text.matchAll(/^## \[([^\]]+)\].*$/gmu)];
if (headings[0]?.[1] !== version) if (headings[0]?.[1] !== version)
@@ -236,7 +245,8 @@ function requiredCredits(inventory, bullet, references) {
if (matches[0].parents.length > 1) continue; if (matches[0].parents.length > 1) continue;
for (const identity of matches[0].authors) { for (const identity of matches[0].authors) {
const handle = human(identity.user); 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; return required;
@@ -264,6 +274,8 @@ function verifyEditorialCredits(inventory, prose) {
]).map((handle) => handle.toLowerCase()), ]).map((handle) => handle.toLowerCase()),
); );
const errors = []; const errors = [];
const requiredByContext = new Map();
const thankedByContext = new Map();
for (const bullet of proseBullets(prose)) { for (const bullet of proseBullets(prose)) {
const references = bulletPrs(bullet, inventory.repository); const references = bulletPrs(bullet, inventory.repository);
const start = bullet.indexOf("Thanks "); const start = bullet.indexOf("Thanks ");
@@ -282,13 +294,21 @@ function verifyEditorialCredits(inventory, prose) {
bullet, bullet,
references, references,
)) { )) {
if (!thanked.has(handle.toLowerCase())) const required = requiredByContext.get(context) ?? new Set();
errors.push(`Missing Thanks @${handle} in bullet for ${context}`); 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) for (const handle of thanked)
if (!eligible.has(handle)) if (!eligible.has(handle))
errors.push(`Unverified human credit @${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; return errors;
} }
@@ -305,8 +325,15 @@ export function verifyReleaseNotes(raw, text, body) {
"Release notes exceed GitHub's body budget; shorten prose without dropping credit", "Release notes exceed GitHub's body budget; shorten prose without dropping credit",
); );
const record = contributionRecord(inventory).trim(); const record = contributionRecord(inventory).trim();
const start = notes.indexOf("### Complete contribution record"); const summary = contributionSummary(inventory).trim();
if (start < 0 || notes.slice(start).trim() !== record) 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( throw new Error(
"Contribution record differs from inventory; regenerate it with release-notes record", "Contribution record differs from inventory; regenerate it with release-notes record",
); );
+8 -3
View File
@@ -6,6 +6,7 @@ import { collectGitHubCredits } from "./lib/release-notes-github.mjs";
import { verifyNotesPublication } from "./lib/release-notes-publication.mjs"; import { verifyNotesPublication } from "./lib/release-notes-publication.mjs";
import { import {
contributionRecord, contributionRecord,
contributionSummary,
readReleaseHistory, readReleaseHistory,
releaseInventorySchema, releaseInventorySchema,
selectedReleaseNotes, selectedReleaseNotes,
@@ -73,6 +74,7 @@ async function main() {
"pr-body": { type: "string" }, "pr-body": { type: "string" },
source: { type: "string" }, source: { type: "string" },
output: { type: "string" }, output: { type: "string" },
summary: { type: "boolean", default: false },
}, },
}); });
const [command] = positionals; const [command] = positionals;
@@ -92,7 +94,7 @@ async function main() {
!values.manifest !values.manifest
) )
throw new Error( 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") { if (command === "inventory") {
await createInventory(values); await createInventory(values);
@@ -103,11 +105,14 @@ async function main() {
); );
await verifyInventoryHistory(run, inventory, values.source); await verifyInventoryHistory(run, inventory, values.source);
if (command === "record") { if (command === "record") {
const record = values.summary
? contributionSummary(inventory)
: contributionRecord(inventory);
if (values.output) if (values.output)
await writeFile(values.output, contributionRecord(inventory), { await writeFile(values.output, record, {
flag: "wx", flag: "wx",
}); });
else process.stdout.write(contributionRecord(inventory)); else process.stdout.write(record);
return; return;
} }
const notes = await readFile(values.notes, "utf8"); const notes = await readFile(values.notes, "utf8");
+33 -5
View File
@@ -83,6 +83,7 @@ async function fixture() {
author("platformbot", "Bot"), author("platformbot", "Bot"),
author("claude"), author("claude"),
author("cursoragent"), author("cursoragent"),
author("morluto"),
]), ]),
associatedPullRequests: page([]), 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).toContain("Thanks @dana.");
expect(f.record).not.toMatch( 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"); const body = join(f.directory, "body.md");
await writeFile( await writeFile(
@@ -211,6 +212,37 @@ it("inventories side branches and paginated credits, then checks and renders syn
await expect( await expect(
f.invoke(["render", "--manifest", f.manifest, "--output", output]), f.invoke(["render", "--manifest", f.manifest, "--output", output]),
).rejects.toMatchObject({ stderr: expect.stringContaining("EEXIST") }); ).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( await expect(
f.invoke([ f.invoke([
"inventory", "inventory",
@@ -245,10 +277,6 @@ it("rejects lost grouped credits, fabricated references, bots, and stale PR bodi
f.bullet + `\n- Fixed direct behavior (${directLink}).`, f.bullet + `\n- Fixed direct behavior (${directLink}).`,
"Missing Thanks @dana", "Missing Thanks @dana",
], ],
[
f.bullet + "\n- Fixed the reported problem (#51).",
"Missing Thanks @reporter",
],
[ [
f.bullet + " Thanks @automation[bot].", f.bullet + " Thanks @automation[bot].",
"Unverified human credit @automation[bot]", "Unverified human credit @automation[bot]",