From d73eb23b290fda4fb7162006cbdcbf96c45549aa Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 6 Aug 2026 19:22:16 +0800 Subject: [PATCH] fix(release): report seeded changelog provenance truthfully (#119897) * fix(release): report seeded changelog provenance truthfully * fix(release): reject legacy provenance formatting * fix(release): enforce complete provenance records --- .../skills/openclaw-changelog-update/SKILL.md | 8 + .../scripts/verify-release-notes.mjs | 63 +++++-- scripts/release-candidate-checklist.mjs | 38 +--- scripts/render-github-release-notes.d.mts | 13 ++ scripts/render-github-release-notes.mjs | 56 ++++++ .../release-candidate-checklist.test.ts | 35 +++- test/scripts/release-notes-ledger.test.ts | 174 +++++++++++++++++- .../render-github-release-notes.test.ts | 63 +++++++ test/scripts/verify-release-notes.test.ts | 16 +- 9 files changed, 410 insertions(+), 56 deletions(-) diff --git a/.agents/skills/openclaw-changelog-update/SKILL.md b/.agents/skills/openclaw-changelog-update/SKILL.md index 85ed8207209f..6ccb52ac73ec 100644 --- a/.agents/skills/openclaw-changelog-update/SKILL.md +++ b/.agents/skills/openclaw-changelog-update/SKILL.md @@ -69,11 +69,17 @@ every human `Thanks @...` attribution. audit; it contains every referenced PR, eligible contributor credit, inline issue context, every direct commit, and an editorial-eligibility classification for PRs and direct commits + - schema version 3 is the required ephemeral manifest contract. Regenerate + older manifests; version 2 is not a supported downstream-reader boundary - for a historical backfill, add `--seed-ref ` once so contribution records from the prior changelog are retained even when an older merged commit omitted its PR number; the verifier excludes records for work reverted after the base tag, including beta work reverted before the stable release + - generated provenance reports in-range PRs separately from retained + seed-only PRs, then states the unique row total. A PR present in both + inventories counts as in-range; never describe the seed-inclusive total + as work merged in the current release range - add repeatable `--shipped-ref ` when the reachable main closeout differs from the shipped tag or later forward-port commits re-associate PRs that were already released. Each tag is a cumulative @@ -150,6 +156,8 @@ every human `Thanks @...` attribution. PR references explicitly present in active commit subjects/bodies. It preserves author/co-author credit and any issue references in the original title + - the provenance arithmetic and unique total must match the rendered PR + rows exactly; candidate validation rejects malformed or forged counts - direct commits remain in the manifest with GitHub-resolved author, co-author, issue, and editorial-eligibility data. They inform grouped prose but are never rendered as a public `#### Direct commits` dump. Add diff --git a/.agents/skills/openclaw-changelog-update/scripts/verify-release-notes.mjs b/.agents/skills/openclaw-changelog-update/scripts/verify-release-notes.mjs index 555ba207cb79..c529e135340b 100644 --- a/.agents/skills/openclaw-changelog-update/scripts/verify-release-notes.mjs +++ b/.agents/skills/openclaw-changelog-update/scripts/verify-release-notes.mjs @@ -14,7 +14,9 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; import { extractChangelogReleaseSections, + formatContributionRecordProvenance, formatShippedBaselineExclusions, + parseContributionRecordProvenance, parseShippedBaselineExclusions, releaseNotesVersionForTag, verifyGithubReleaseNotes, @@ -671,10 +673,8 @@ export function contributionRecordTarget(section) { if (recordStart < 0) { return undefined; } - return section.source - .slice(recordStart) - .match(/^This audited record covers the complete \S+\.\.(?[0-9a-f]{40}) history:/mu) - ?.groups?.target; + const target = parseContributionRecordProvenance(section.source.slice(recordStart))?.target; + return target && /^[0-9a-f]{40}$/u.test(target) ? target : undefined; } export function pullRequestTitleFromCommitSubject(subject, number) { @@ -688,19 +688,12 @@ function completeContributionRecord(section, label) { fail(`${label} is missing ### Complete contribution record`); } const recordSource = section.source.slice(recordStart); - const provenance = recordSource.match( - /^This audited record covers the complete \S+\.\.[0-9a-f]{40} history: (?[0-9]+) merged PRs?\./mu, - ); - if (!provenance?.groups?.count) { + const provenance = parseContributionRecordProvenance(recordSource); + if (!provenance || !/^[0-9a-f]{40}$/u.test(provenance.target)) { fail(`${label} is missing exact complete contribution record provenance`); } const record = contributionRecordFor(section); - const declaredCount = Number(provenance.groups.count); - if (record.pullRequests.size !== declaredCount) { - fail( - `${label} contribution record declares ${declaredCount} PRs but contains ${record.pullRequests.size}`, - ); - } + const declaredCount = provenance.uniquePullRequests; return { record, declaredCount }; } @@ -2069,6 +2062,10 @@ export function ledgerFor( !revertedReferences.has(entry.number), ); const issues = entries.filter((entry) => entry.type === "Issue"); + const inRangePullRequestNumbers = new Set([ + ...sourcePullRequests, + ...[...sourceReferences].filter((number) => nodes.get(number)?.__typename === "PullRequest"), + ]); const legacyIssues = legacyIssuesByPullRequest(priorRecord, nodes); const records = pullRequests.map((entry) => { const priorEntry = priorRecord.pullRequests.get(entry.number); @@ -2097,11 +2094,20 @@ export function ledgerFor( thanks, }); }); + const inRangePullRequests = records.filter((entry) => + inRangePullRequestNumbers.has(entry.number), + ).length; + const retainedSeedOnlyPullRequests = records.length - inRangePullRequests; + const provenance = { + inRangePullRequests, + retainedSeedOnlyPullRequests, + uniquePullRequests: records.length, + }; const shippedBaselineLine = formatShippedBaselineExclusions(shippedBaselines); const ledger = [ "### Complete contribution record", "", - `This audited record covers the complete ${base}..${target} history: ${records.length} merged PRs. The generation manifest also supplies direct commits as editorial input; the grouped notes above prioritize user impact.`, + `${formatContributionRecordProvenance({ base, target, ...provenance })} The generation manifest also supplies direct commits as editorial input; the grouped notes above prioritize user impact.`, ...(shippedBaselineLine ? ["", shippedBaselineLine] : []), "", "#### Pull requests", @@ -2113,6 +2119,7 @@ export function ledgerFor( issues, ledger, pullRequests: records, + provenance, titleReferences: titleReferences(records), }; } @@ -2176,6 +2183,22 @@ export function ledgerChecks(section, pullRequests, nodes, directCommits, shippe return errors; } const ledger = section.source.slice(ledgerStart); + try { + const provenance = parseContributionRecordProvenance(ledger); + if (!provenance) { + errors.push("missing exact complete contribution record provenance"); + } else if ( + section.expectedProvenance && + provenance.inRangePullRequests !== undefined && + (provenance.inRangePullRequests !== section.expectedProvenance.inRangePullRequests || + provenance.retainedSeedOnlyPullRequests !== + section.expectedProvenance.retainedSeedOnlyPullRequests) + ) { + errors.push("contribution record provenance partition does not match generated inventory"); + } + } catch (error) { + errors.push(error instanceof Error ? error.message : String(error)); + } const expectedShippedBaselineLine = formatShippedBaselineExclusions(shippedBaselines); try { const sectionShippedBaselineLine = formatShippedBaselineExclusions( @@ -2312,7 +2335,7 @@ function manifestFor(options, source, ledger, directCommitRecords) { })); const unlinkedCommits = directCommits.filter((commit) => commit.references.length === 0); return { - schemaVersion: 2, + schemaVersion: 3, base: options.base, target: options.target, mergeBase: source.mergeBase, @@ -2320,7 +2343,7 @@ function manifestFor(options, source, ledger, directCommitRecords) { shippedBaselines: source.shippedBaselines, source: { references: ledger.entries.length, - pullRequests: ledger.pullRequests.length, + ...ledger.provenance, issues: ledger.issues.length, directCommits: directCommits.length, unlinkedCommits: unlinkedCommits.length, @@ -2589,7 +2612,7 @@ function main() { } const errors = ledgerChecks( - candidateSection, + { ...candidateSection, expectedProvenance: ledger.provenance }, ledger.pullRequests, nodes, relationships.directCommits, @@ -2619,7 +2642,7 @@ function main() { shippedBaselines: source.shippedBaselines, source: { references: references.length, - pullRequests: ledger.pullRequests.length, + ...ledger.provenance, issues: ledger.issues.length, directCommits: manifest.directCommits.length, unlinkedCommits: manifest.unlinkedCommits.length, @@ -2642,7 +2665,7 @@ function main() { ? `, GitHub snapshot ${githubSnapshotState.hits} hits/${githubSnapshotState.misses} misses` : ""; process.stdout.write( - `${options.version}: ${ledger.pullRequests.length} PRs, ${ledger.issues.length} issues, ${errors.length === 0 ? "verified" : `${errors.length} errors`}${snapshotSummary}\n`, + `${options.version}: ${ledger.provenance.uniquePullRequests} unique PRs (${ledger.provenance.inRangePullRequests} in-range + ${ledger.provenance.retainedSeedOnlyPullRequests} retained seed-only), ${ledger.issues.length} issues, ${errors.length === 0 ? "verified" : `${errors.length} errors`}${snapshotSummary}\n`, ); } if (errors.length > 0) { diff --git a/scripts/release-candidate-checklist.mjs b/scripts/release-candidate-checklist.mjs index a4d19d1f764e..e856a59bf217 100644 --- a/scripts/release-candidate-checklist.mjs +++ b/scripts/release-candidate-checklist.mjs @@ -30,6 +30,7 @@ import { extractChangelogReleaseSections, extractChangelogSection, formatShippedBaselineExclusions, + parseContributionRecordProvenance, parseShippedBaselineExclusions, releaseNotesSectionForTag, releaseNotesVersionForTag, @@ -751,36 +752,13 @@ function candidateContributionRecordPullRequests( Number(match.groups.number), ); const rows = new Set(rowNumbers); - if (rows.size !== rowNumbers.length) { - const seen = new Set(); - const duplicates = rowNumbers.filter((number) => { - if (seen.has(number)) { - return true; - } - seen.add(number); - return false; - }); - throw new Error( - `${label} contains duplicate contribution record PR rows: ${[...new Set(duplicates)] - .map((number) => `#${number}`) - .join(", ")}`, - ); - } + const provenance = parseContributionRecordProvenance(record); if (!requireExactProvenance) { return rows; } - const provenance = record.match( - /^This audited record covers the complete \S+\.\.[0-9a-f]{40} history: (?[0-9]+) merged PRs?\./mu, - ); - if (!provenance?.groups?.count) { + if (!provenance || !/^[0-9a-f]{40}$/u.test(provenance.target)) { throw new Error(`${label} is missing exact complete contribution record provenance`); } - const declaredCount = Number(provenance.groups.count); - if (rows.size !== declaredCount) { - throw new Error( - `${label} contribution record declares ${declaredCount} PRs but contains ${rows.size}`, - ); - } return rows; } @@ -884,12 +862,10 @@ export function validateCandidateChangelogProvenance({ section, `CHANGELOG.md ## ${sectionVersion}`, ); - const provenance = record.match( - /^This audited record covers the complete (?\S+)\.\.(?[0-9a-f]{40}) history:/mu, - ); - const base = provenance?.groups?.base; - const recordedTarget = provenance?.groups?.target; - if (!base || !recordedTarget) { + const provenance = parseContributionRecordProvenance(record); + const base = provenance?.base; + const recordedTarget = provenance?.target; + if (!base || !recordedTarget || !/^[0-9a-f]{40}$/u.test(recordedTarget)) { throw new Error( `CHANGELOG.md ## ${sectionVersion} is missing exact complete contribution record provenance`, ); diff --git a/scripts/render-github-release-notes.d.mts b/scripts/render-github-release-notes.d.mts index 9934df8f1af2..8d7ec01c9b6b 100644 --- a/scripts/render-github-release-notes.d.mts +++ b/scripts/render-github-release-notes.d.mts @@ -7,6 +7,12 @@ export function extractChangelogSection(changelog: unknown, version: unknown): u export function releaseNotesVersionForTag(tag: unknown): unknown; export function formatShippedBaselineExclusions(baselines: ShippedBaselineExclusion[]): string; export function parseShippedBaselineExclusions(section: string): ShippedBaselineExclusion[]; +export function formatContributionRecordProvenance( + provenance: ContributionRecordProvenance, +): string; +export function parseContributionRecordProvenance( + section: string, +): ContributionRecordProvenance | undefined; export function dedicatedSectionVersionForTag(tag: unknown): unknown; export function releaseNotesSectionForTag( changelog: unknown, @@ -69,3 +75,10 @@ export type ShippedBaselineExclusion = { count: number; pullRequests: number[]; }; +export type ContributionRecordProvenance = { + base: string; + target: string; + inRangePullRequests?: number; + retainedSeedOnlyPullRequests?: number; + uniquePullRequests: number; +}; diff --git a/scripts/render-github-release-notes.mjs b/scripts/render-github-release-notes.mjs index 4e4b5e2eefde..6c0ff94c3318 100644 --- a/scripts/render-github-release-notes.mjs +++ b/scripts/render-github-release-notes.mjs @@ -40,6 +40,62 @@ function validateTag(tag) { } } +export function formatContributionRecordProvenance(provenance) { + const { base, target, inRangePullRequests, retainedSeedOnlyPullRequests } = provenance; + if (inRangePullRequests === undefined || retainedSeedOnlyPullRequests === undefined) { + fail("canonical contribution record provenance requires split PR counts"); + } + const uniquePullRequests = inRangePullRequests + retainedSeedOnlyPullRequests; + const count = (value) => value.toLocaleString("en-US"); + const prs = (value) => `PR${value === 1 ? "" : "s"}`; + return `This audited record covers the complete ${base}..${target} history: ${count(inRangePullRequests)} in-range ${prs(inRangePullRequests)} + ${count(retainedSeedOnlyPullRequests)} retained seed-only ${prs(retainedSeedOnlyPullRequests)} = ${count(uniquePullRequests)} unique ${prs(uniquePullRequests)}.`; +} +export function parseContributionRecordProvenance(section) { + const rows = Array.from(section.matchAll(/^- \*\*PR #(\d+)\*\*/gmu), (match) => Number(match[1])); + const duplicate = rows.find((value, index) => rows.indexOf(value) !== index); + if (duplicate !== undefined) { + fail(`duplicate contribution record PR #${duplicate}`); + } + const line = section.match(/^This audited record covers the complete .+$/mu)?.[0]; + if (!line) { + return undefined; + } + const canonical = line.match( + /^This audited record covers the complete (?\S+)\.\.(?[0-9a-f]{40}) history: (?0|[1-9][0-9]{0,2}(?:,[0-9]{3})*) in-range PRs? \+ (?0|[1-9][0-9]{0,2}(?:,[0-9]{3})*) retained seed-only PRs? = (?0|[1-9][0-9]{0,2}(?:,[0-9]{3})*) unique PRs?\./u, + ); + const legacy = + canonical ?? + line.match( + /^This audited record covers the complete (?\S+)\.\.(?\S+) history: (?[0-9]+) merged PRs?\./u, + ); + if (!legacy?.groups) { + fail("release contribution record provenance is malformed"); + } + const number = (value) => Number(value.replaceAll(",", "")); + const provenance = { + base: legacy.groups.base, + target: legacy.groups.target, + uniquePullRequests: number(legacy.groups.unique), + }; + if (canonical?.groups) { + provenance.inRangePullRequests = number(canonical.groups.inRange); + provenance.retainedSeedOnlyPullRequests = number(canonical.groups.seedOnly); + if ( + provenance.inRangePullRequests + provenance.retainedSeedOnlyPullRequests !== + provenance.uniquePullRequests + ) { + fail("release contribution record provenance arithmetic is invalid"); + } + } + if (provenance.uniquePullRequests > 0 && !/^#### Pull requests\r?$/mu.test(section)) { + fail("positive contribution record requires a Pull requests section"); + } + if (rows.length !== provenance.uniquePullRequests) { + fail(`contribution record row count ${rows.length} != ${provenance.uniquePullRequests}`); + } + return provenance; +} + function githubReleaseBodySize(body) { return { characters: [...body].length, diff --git a/test/scripts/release-candidate-checklist.test.ts b/test/scripts/release-candidate-checklist.test.ts index 6429fbcb1182..294b45c44887 100644 --- a/test/scripts/release-candidate-checklist.test.ts +++ b/test/scripts/release-candidate-checklist.test.ts @@ -358,7 +358,38 @@ describe("release candidate checklist", () => { targetSha, isAncestor: () => true, }), - ).toThrow("duplicate contribution record PR rows: #123"); + ).toThrow("duplicate contribution record PR #123"); + }); + + it("rejects canonical provenance whose unique total does not match the PR rows", () => { + const targetSha = "b".repeat(40); + const changelog = [ + "# Changelog", + "", + "## 2026.7.1", + "", + "### Highlights", + "", + "- User-facing notes.", + "", + "### Complete contribution record", + "", + `This audited record covers the complete base..${targetSha} history: 1 in-range PR + 1 retained seed-only PR = 2 unique PRs.`, + "", + "#### Pull requests", + "", + "- **PR #123** fix: example.", + ].join("\n"); + + expect(() => + validateCandidateChangelogProvenance({ + changelog, + version: "2026.7.1", + tag: "v2026.7.1-beta.3", + targetSha, + isAncestor: () => true, + }), + ).toThrow("contribution record row count 1 != 2"); }); it("uses numbered historical record rows and skips Unreleased baseline rows", () => { @@ -379,7 +410,7 @@ describe("release candidate checklist", () => { "", "### Complete contribution record", "", - "This audited record covers the complete base..HEAD history: 0 merged PRs.", + "This audited record covers the complete base..HEAD history: 1 merged PR.", "", "#### Pull requests", "", diff --git a/test/scripts/release-notes-ledger.test.ts b/test/scripts/release-notes-ledger.test.ts index dddaa91b631b..c89953f374ce 100644 --- a/test/scripts/release-notes-ledger.test.ts +++ b/test/scripts/release-notes-ledger.test.ts @@ -6,6 +6,52 @@ import { renderContributionRecordEntry, } from "../../.agents/skills/openclaw-changelog-update/scripts/verify-release-notes.mjs"; +const targetSha = "a".repeat(40); + +function contributionLedger({ + nodes, + seededPullRequests = [], + sourcePullRequests = [], + sourceReferences = [], +}: { + nodes: Map>; + seededPullRequests?: number[]; + sourcePullRequests?: number[]; + sourceReferences?: number[]; +}) { + return ledgerFor( + "v2026.7.2-beta.7", + targetSha, + [...nodes.keys()], + nodes, + new Map(), + new Map(), + { issuesByPullRequest: new Map() }, + { + legacyIssues: new Map(), + pullRequests: new Map( + seededPullRequests.map((number) => [ + number, + { externalReferences: [], references: [], thanks: [] }, + ]), + ), + }, + new Set(sourcePullRequests), + sourceReferences, + [], + [], + new Set(), + [], + Date.parse("2026-08-05T00:00:00Z"), + ) as ReturnType & { + provenance: { + inRangePullRequests: number; + retainedSeedOnlyPullRequests: number; + uniquePullRequests: number; + }; + }; +} + describe("renderContributionRecordEntry", () => { it("keeps external and linked issue references without repeating PR title references", () => { expect( @@ -101,7 +147,7 @@ describe("renderContributionRecordEntry", () => { const result = ledgerFor( "v2026.6.11", - "HEAD", + targetSha, [125], nodes, new Map(), @@ -120,6 +166,126 @@ describe("renderContributionRecordEntry", () => { expect(result.ledger).toContain("- **PR #125** Thanks @carol and @alice and @bob."); }); + it("counts associated and PR-typed source refs before retained seed-only rows", () => { + const nodes = new Map( + [1, 2, 3].map((number) => [ + number, + { + __typename: "PullRequest", + closingIssuesReferences: { nodes: [] }, + mergedAt: "2026-08-04T00:00:00Z", + title: `fix: contribution ${number}`, + }, + ]), + ); + const result = contributionLedger({ + nodes, + seededPullRequests: [1, 3], + sourcePullRequests: [1], + sourceReferences: [2], + }); + + expect(result.provenance).toEqual({ + inRangePullRequests: 2, + retainedSeedOnlyPullRequests: 1, + uniquePullRequests: 3, + }); + expect(result.ledger).toContain("2 in-range PRs + 1 retained seed-only PR = 3 unique PRs."); + }); + + it("reports zero retained seed-only PRs when every row is in range", () => { + const nodes = new Map([ + [ + 1, + { + __typename: "PullRequest", + closingIssuesReferences: { nodes: [] }, + mergedAt: "2026-08-04T00:00:00Z", + title: "fix: in-range contribution", + }, + ], + ]); + const result = contributionLedger({ nodes, sourcePullRequests: [1] }); + + expect(result.provenance).toMatchObject({ + inRangePullRequests: 1, + retainedSeedOnlyPullRequests: 0, + uniquePullRequests: 1, + }); + }); + + it("reports all rows as retained seed-only when the release range has no PRs", () => { + const nodes = new Map( + [1, 2].map((number) => [ + number, + { + __typename: "PullRequest", + closingIssuesReferences: { nodes: [] }, + mergedAt: "2026-08-04T00:00:00Z", + title: `fix: seeded contribution ${number}`, + }, + ]), + ); + const result = contributionLedger({ nodes, seededPullRequests: [1, 2] }); + + expect(result.provenance).toMatchObject({ + inRangePullRequests: 0, + retainedSeedOnlyPullRequests: 2, + uniquePullRequests: 2, + }); + }); + + it("rejects a forged canonical range and seed partition", () => { + const source = [ + "## 2026.7.1", + "", + "### Highlights", + "", + "- Highlight one.", + "- Highlight two.", + "- Highlight three.", + "- Highlight four.", + "- Highlight five.", + "", + "### Changes", + "", + "### Fixes", + "", + "### Complete contribution record", + "", + `This audited record covers the complete base..${targetSha} history: 0 in-range PRs + 1 retained seed-only PR = 1 unique PR.`, + "", + "#### Pull requests", + "", + "- **PR #456**", + ].join("\n"); + const entry = { + number: 456, + title: "fix: example", + editorialEligible: true, + priorReferences: [], + externalReferences: [], + linkedIssues: [], + thanks: [], + }; + + expect( + ledgerChecks( + { + source, + expectedProvenance: { + inRangePullRequests: 1, + retainedSeedOnlyPullRequests: 0, + uniquePullRequests: 1, + }, + }, + [entry], + new Map([[456, { __typename: "PullRequest" }]]), + [], + ), + ).toContain("contribution record provenance partition does not match generated inventory"); + }); + it("retains references from a verbose record when the source title changes", () => { const record = contributionRecordFor({ source: [ @@ -159,6 +325,8 @@ describe("renderContributionRecordEntry", () => { "", "### Complete contribution record", "", + `This audited record covers the complete base..${targetSha} history: 1 merged PR.`, + "", "#### Pull requests", "", "- **PR #456** Related openclaw/imsg#141.", @@ -200,6 +368,8 @@ describe("renderContributionRecordEntry", () => { "", "### Complete contribution record", "", + `This audited record covers the complete base..${targetSha} history: 1 merged PR.`, + "", "#### Pull requests", "", line, @@ -239,6 +409,8 @@ describe("renderContributionRecordEntry", () => { "", "### Complete contribution record", "", + `This audited record covers the complete base..${targetSha} history: 1 merged PR.`, + "", "#### Pull requests", "", line, diff --git a/test/scripts/render-github-release-notes.test.ts b/test/scripts/render-github-release-notes.test.ts index f1575e2beb37..37ee84ec5e8e 100644 --- a/test/scripts/render-github-release-notes.test.ts +++ b/test/scripts/render-github-release-notes.test.ts @@ -3,7 +3,9 @@ import { GITHUB_RELEASE_BODY_MAX_BYTES, GITHUB_RELEASE_BODY_MAX_CHARACTERS, extractChangelogSection, + formatContributionRecordProvenance, formatShippedBaselineExclusions, + parseContributionRecordProvenance, parseShippedBaselineExclusions, releaseNotesVersionForTag, renderGithubReleaseNotes, @@ -40,6 +42,67 @@ function changelogFor(record: string): string { } describe("GitHub release-note rendering", () => { + it("round-trips canonical contribution provenance and accepts published legacy lines", () => { + const target = "a".repeat(40); + const singular = formatContributionRecordProvenance({ + base: "v2026.7.2-beta.7", + target, + inRangePullRequests: 1, + retainedSeedOnlyPullRequests: 0, + uniquePullRequests: 1, + }); + const commaSeparated = formatContributionRecordProvenance({ + base: "v2026.7.2-beta.7", + target, + inRangePullRequests: 1_234, + retainedSeedOnlyPullRequests: 56, + uniquePullRequests: 1_290, + }); + + expect(singular).toContain("1 in-range PR + 0 retained seed-only PRs = 1 unique PR."); + expect(commaSeparated).toContain( + "1,234 in-range PRs + 56 retained seed-only PRs = 1,290 unique PRs.", + ); + expect( + parseContributionRecordProvenance( + [singular, "", "#### Pull requests", "", "- **PR #123** fix: canonical example."].join( + "\n", + ), + ), + ).toEqual({ + base: "v2026.7.2-beta.7", + target, + inRangePullRequests: 1, + retainedSeedOnlyPullRequests: 0, + uniquePullRequests: 1, + }); + const legacy = parseContributionRecordProvenance( + [ + `This audited record covers the complete v2026.7.2-beta.6..02d06caeb0febe7ec3c0df1454b85c38f3fb27d1 history: 1 merged PR. The generation manifest also supplies direct commits as editorial input; the grouped notes above prioritize user impact.`, + "", + "#### Pull requests", + "", + "- **PR #123** fix: legacy example.", + ].join("\n"), + ); + expect(legacy).toMatchObject({ uniquePullRequests: 1 }); + expect(() => formatContributionRecordProvenance(legacy!)).toThrow("requires split PR counts"); + expect(() => + parseContributionRecordProvenance( + commaSeparated.replace("= 1,290 unique PRs", "= 1,291 unique PRs"), + ), + ).toThrow("provenance arithmetic is invalid"); + expect(() => + parseContributionRecordProvenance(commaSeparated.replace("1,234", "1234")), + ).toThrow("provenance is malformed"); + expect(() => + parseContributionRecordProvenance(singular.replace("1 in-range", "001 in-range")), + ).toThrow("provenance is malformed"); + expect(() => parseContributionRecordProvenance(singular)).toThrow( + "positive contribution record requires a Pull requests section", + ); + }); + it("emits the complete matching section including its version heading when it fits", () => { const rendered = renderGithubReleaseNotes({ changelog: changelogFor("- **PR #123** fix: example. Thanks @contributor."), diff --git a/test/scripts/verify-release-notes.test.ts b/test/scripts/verify-release-notes.test.ts index 61cec92ef9a6..f824c33d0fcd 100644 --- a/test/scripts/verify-release-notes.test.ts +++ b/test/scripts/verify-release-notes.test.ts @@ -74,7 +74,11 @@ describe("release-note verification", () => { "", "### Complete contribution record", "", - `This audited record covers the complete base..${target} history: 1 merged PR.`, + `This audited record covers the complete base..${target} history: 1 in-range PR + 0 retained seed-only PRs = 1 unique PR.`, + "", + "#### Pull requests", + "", + "- **PR #123** fix: example.", ].join("\n"), }), ).toBe(target); @@ -948,7 +952,15 @@ describe("release-note verification", () => { expect(result.status).toBe(1); expect(result.stdout).toContain("1 errors"); - expect(JSON.parse(readFileSync(manifestPath, "utf8")).version).toBe("2026.7.1"); + expect(JSON.parse(readFileSync(manifestPath, "utf8"))).toMatchObject({ + schemaVersion: 3, + version: "2026.7.1", + source: { + inRangePullRequests: 0, + retainedSeedOnlyPullRequests: 0, + uniquePullRequests: 0, + }, + }); expect(readFileSync(join(cwd, "CHANGELOG.md"), "utf8")).toBe(changelog); } finally { rmSync(cwd, { recursive: true, force: true });