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
This commit is contained in:
Vincent Koc
2026-08-06 19:22:16 +08:00
committed by GitHub
parent 8c64ca24b1
commit d73eb23b29
9 changed files with 410 additions and 56 deletions
@@ -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",
"",
+173 -1
View File
@@ -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<number, Record<string, unknown>>;
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<typeof ledgerFor> & {
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,
@@ -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."),
+14 -2
View File
@@ -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 });