From dc6b447c0797dd8a0078b22601ff6528d36adef9 Mon Sep 17 00:00:00 2001 From: Vincent Koc <25068+vincentkoc@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:20:45 -0700 Subject: [PATCH] fix(release): verify named backport provenance --- .../scripts/verify-release-notes.mjs | 102 ++++++++++++------ test/scripts/verify-release-notes.test.ts | 28 ++++- 2 files changed, 92 insertions(+), 38 deletions(-) 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 7c047d8b5545..a22b2b04c12d 100644 --- a/.agents/skills/openclaw-changelog-update/scripts/verify-release-notes.mjs +++ b/.agents/skills/openclaw-changelog-update/scripts/verify-release-notes.mjs @@ -849,19 +849,14 @@ export function canonicalMainCommitMatches(commit, candidates) { } const pullRequestOrigins = new Set(backportPullRequestOrigins(commit.body)); - const pullRequestMatches = candidates.filter( - (candidate) => - referencesIn(`${candidate.subject}\n${candidate.body ?? ""}`).some((number) => - pullRequestOrigins.has(number), - ) && - authorsMatch(commit, candidate) && - pathsOverlap(commit.changedPaths, candidate.changedPaths), - ); - if (pullRequestMatches.length === 1) { - return [pullRequestMatches[0].hash]; - } - if (pullRequestMatches.length > 1) { - return []; + if (pullRequestOrigins.size > 0) { + const pullRequestMatches = candidates.filter( + (candidate) => + (candidate.pullRequests ?? []).some((number) => pullRequestOrigins.has(number)) && + authorsMatch(commit, candidate) && + pathsOverlap(commit.changedPaths, candidate.changedPaths), + ); + return pullRequestMatches.length === 1 ? [pullRequestMatches[0].hash] : []; } const subject = normalizedCommitSubject(commit.subject); @@ -1088,20 +1083,17 @@ function sourceCommits(base, target, mainRef) { const mainCommits = canonicalMainCommits(base, mainRef); const mainCommitsByHash = new Map(mainCommits.map((commit) => [commit.hash, commit])); const mainCommitsBySubject = new Map(); - const mainCommitsByPullRequest = new Map(); for (const commit of mainCommits) { const subject = normalizedCommitSubject(commit.subject); const matches = mainCommitsBySubject.get(subject) ?? []; matches.push(commit); mainCommitsBySubject.set(subject, matches); - for (const number of referencesIn(`${commit.subject}\n${commit.body}`)) { - const pullRequestMatches = mainCommitsByPullRequest.get(number) ?? []; - pullRequestMatches.push(commit); - mainCommitsByPullRequest.set(number, pullRequestMatches); - } } const canonicalMainCommitsByReleaseCommit = new Map(); + const namedMainPullRequestsByReleaseCommit = new Map(); const canonicalMainHashes = new Set(); + const mainAssociationCandidateHashes = new Set(); + const pendingCanonicalMatches = []; const changedPathsByCommit = new Map(); const withChangedPaths = (commit) => { if (!changedPathsByCommit.has(commit.hash)) { @@ -1118,22 +1110,59 @@ function sourceCommits(base, target, mainRef) { const explicit = cherryPickOrigins(commit.body) .map((origin) => mainCommitsByHash.get(origin)) .filter(Boolean); + if (explicit.length > 0) { + const matches = [...new Set(explicit.map((candidate) => candidate.hash))]; + canonicalMainCommitsByReleaseCommit.set(commit.hash, matches); + for (const hash of matches) { + canonicalMainHashes.add(hash); + } + continue; + } + const releaseCommit = withChangedPaths(commit); + const pullRequestOrigins = backportPullRequestOrigins(commit.body); const candidates = new Map( - [ - ...(mainCommitsBySubject.get(normalizedCommitSubject(commit.subject)) ?? []), - ...backportPullRequestOrigins(commit.body).flatMap( - (number) => mainCommitsByPullRequest.get(number) ?? [], - ), - ].map((candidate) => [candidate.hash, candidate]), + (mainCommitsBySubject.get(normalizedCommitSubject(commit.subject)) ?? []).map((candidate) => [ + candidate.hash, + candidate, + ]), + ); + if (pullRequestOrigins.length > 0) { + for (const candidate of mainCommits) { + if (!authorsMatch(releaseCommit, candidate)) { + continue; + } + const mainCandidate = withChangedPaths(candidate); + if (!pathsOverlap(releaseCommit.changedPaths, mainCandidate.changedPaths)) { + continue; + } + candidates.set(candidate.hash, candidate); + mainAssociationCandidateHashes.add(candidate.hash); + } + } + pendingCanonicalMatches.push({ candidates, commit: releaseCommit, pullRequestOrigins }); + } + const candidateMainPullRequests = resolveAssociatedPullRequests( + [...mainAssociationCandidateHashes], + Number.POSITIVE_INFINITY, + ); + for (const { candidates, commit, pullRequestOrigins } of pendingCanonicalMatches) { + const matches = canonicalMainCommitMatches( + commit, + [...candidates.values()].map((candidate) => ({ + ...withChangedPaths(candidate), + pullRequests: candidateMainPullRequests.get(candidate.hash) ?? [], + })), ); - const matches = - explicit.length > 0 - ? [...new Set(explicit.map((candidate) => candidate.hash))] - : canonicalMainCommitMatches( - withChangedPaths(commit), - [...candidates.values()].map(withChangedPaths), - ); canonicalMainCommitsByReleaseCommit.set(commit.hash, matches); + if (pullRequestOrigins.length > 0 && matches.length === 1) { + const associatedPullRequests = candidateMainPullRequests.get(matches[0]) ?? []; + const namedPullRequests = pullRequestOrigins.filter((number) => + associatedPullRequests.includes(number), + ); + if (namedPullRequests.length > 0) { + namedMainPullRequestsByReleaseCommit.set(commit.hash, namedPullRequests); + } + } for (const hash of matches) { canonicalMainHashes.add(hash); } @@ -1147,9 +1176,12 @@ function sourceCommits(base, target, mainRef) { const nonRevertPullRequests = new Set(); for (const commit of activeCommits) { const currentPullRequests = activePullRequests.get(commit.hash) ?? []; - const mainPullRequests = (canonicalMainCommitsByReleaseCommit.get(commit.hash) ?? []).flatMap( - (hash) => canonicalMainPullRequests.get(hash) ?? [], - ); + const namedMainPullRequests = namedMainPullRequestsByReleaseCommit.get(commit.hash); + const mainPullRequests = + namedMainPullRequests ?? + (canonicalMainCommitsByReleaseCommit.get(commit.hash) ?? []).flatMap( + (hash) => canonicalMainPullRequests.get(hash) ?? [], + ); const matchedMainCommits = canonicalMainCommitsByReleaseCommit.get(commit.hash) ?? []; const associatedPullRequests = canonicalPullRequests( currentPullRequests, diff --git a/test/scripts/verify-release-notes.test.ts b/test/scripts/verify-release-notes.test.ts index 55b80963cc60..9e68b03da4a3 100644 --- a/test/scripts/verify-release-notes.test.ts +++ b/test/scripts/verify-release-notes.test.ts @@ -57,7 +57,8 @@ describe("release-note verification", () => { authorName: "Maintainer", changedPaths: new Set(["src/channel.ts"]), hash: "a".repeat(40), - subject: "fix(channel): preserve durable replies (#123)", + pullRequests: [123], + subject: "fix(channel): preserve durable replies", }; const explicitBackport = { authorEmail: "other@example.com", @@ -93,11 +94,21 @@ describe("release-note verification", () => { canonicalMainCommitMatches(pullRequestBackport, [ { ...mainCommit, + pullRequests: [], body: "Original main PR #123.", subject: "fix(channel): preserve durable replies", }, ]), - ).toEqual([mainCommit.hash]); + ).toEqual([]); + expect( + canonicalMainCommitMatches(pullRequestBackport, [ + { + ...mainCommit, + pullRequests: [999], + subject: "fix(channel): keep replies after renewal", + }, + ]), + ).toEqual([]); expect( canonicalMainCommitMatches( { ...pullRequestBackport, authorEmail: "other@example.com", authorName: "Other" }, @@ -116,9 +127,20 @@ describe("release-note verification", () => { expect( canonicalMainCommitMatches(pullRequestBackport, [ mainCommit, - { ...mainCommit, hash: "e".repeat(40) }, + { ...mainCommit, hash: "e".repeat(40), pullRequests: [123] }, ]), ).toEqual([]); + expect( + canonicalMainCommitMatches(pullRequestBackport, [ + mainCommit, + { + ...mainCommit, + body: "Original main PR #123.", + hash: "f".repeat(40), + pullRequests: [], + }, + ]), + ).toEqual([mainCommit.hash]); expect(canonicalPullRequests([456], [123])).toEqual([123]); });