diff --git a/.agents/skills/openclaw-changelog-update/SKILL.md b/.agents/skills/openclaw-changelog-update/SKILL.md index d4357353742f..498ff43d0ccc 100644 --- a/.agents/skills/openclaw-changelog-update/SKILL.md +++ b/.agents/skills/openclaw-changelog-update/SKILL.md @@ -59,6 +59,10 @@ every human `Thanks @...` attribution. --write-ledger ``` + Add repeatable `--release-provenance '<40sha> -> #PR[, #PR]'` inputs when + release commits cannot carry provenance metadata. These use the same exact + marker grammar and current-main validation as commit-body markers. + The verifier automatically reuses public GitHub GraphQL responses from an exact base/target SHA snapshot under the worktree's git metadata. Iterative rewrites at the same target avoid repeated network discovery. Use 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 c529e135340b..da233beac773 100644 --- a/.agents/skills/openclaw-changelog-update/scripts/verify-release-notes.mjs +++ b/.agents/skills/openclaw-changelog-update/scripts/verify-release-notes.mjs @@ -96,6 +96,8 @@ Options: --main-ref Canonical mainline used to replace backport PRs. --seed-ref Use an existing release section as editorial input. --shipped-ref Exclude PRs already recorded by this shipped tag; repeatable. + --release-provenance #PR[, #PR]> + Supply an exact provenance marker; repeatable. --write-ledger Write the verified ledger back into CHANGELOG.md. --release-tag GitHub release tag to compare; repeatable with --check-github. --check-github Require each supplied GitHub release body to match. @@ -103,9 +105,10 @@ Options: --help Show this help text.`); } -function parseArgs(argv) { +export function parseArgs(argv) { const options = { releaseTags: [], + releaseProvenance: [], checkGithub: false, help: false, json: false, @@ -150,6 +153,7 @@ function parseArgs(argv) { arg === "--target" || arg === "--version" || arg === "--release-tag" || + arg === "--release-provenance" || arg === "--shipped-ref" || arg === "--github-snapshot" || arg === "--main-ref" || @@ -162,6 +166,8 @@ function parseArgs(argv) { } if (arg === "--release-tag") { options.releaseTags.push(value); + } else if (arg === "--release-provenance") { + options.releaseProvenance.push(value); } else if (arg === "--shipped-ref") { options.shippedRefs.push(value); } else if (arg === "--manifest") { @@ -851,39 +857,48 @@ function backportPullRequestOrigins(message) { ].map((match) => Number(match[1])); } -export function releaseProvenanceMarkers(message) { - const markers = []; - for (const line of message.split("\n")) { - if (!/^Release provenance:/i.test(line)) { - continue; - } - const match = line.match(/^Release provenance: ([0-9a-f]{40}) -> (#\d+(?:,\s*#\d+)*)\.?\s*$/i); - if (!match) { - fail(`invalid release provenance marker: ${line}`); - } - markers.push({ - commit: match[1].toLowerCase(), - pullRequests: [...match[2].matchAll(/#(\d+)/g)].map((reference) => Number(reference[1])), - }); +function releaseProvenanceMarker(line) { + const match = line.match(/^Release provenance: ([0-9a-f]{40}) -> (#\d+(?:,\s*#\d+)*)\.?\s*$/i); + if (!match) { + fail(`invalid release provenance marker: ${line}`); } - return markers; + return { + commit: match[1].toLowerCase(), + pullRequests: [...match[2].matchAll(/#(\d+)/g)].map((reference) => Number(reference[1])), + }; } -export function collectReleaseProvenanceOverrides(activeCommits) { +export function releaseProvenanceMarkers(message) { + return message + .split("\n") + .filter((line) => /^Release provenance:/i.test(line)) + .map(releaseProvenanceMarker); +} + +export function collectReleaseProvenanceOverrides(activeCommits, releaseProvenance = []) { const activeCommitHashes = new Set(activeCommits.map((commit) => commit.hash)); const overrides = new Map(); + const addMarker = (marker) => { + if (!activeCommitHashes.has(marker.commit)) { + fail(`release provenance marker targets commit outside the active range: ${marker.commit}`); + } + const existing = overrides.get(marker.commit); + if (existing && existing.join(",") !== marker.pullRequests.join(",")) { + fail(`conflicting release provenance markers for ${marker.commit}`); + } + overrides.set(marker.commit, marker.pullRequests); + }; for (const commit of activeCommits) { for (const marker of releaseProvenanceMarkers(commit.body)) { - if (!activeCommitHashes.has(marker.commit)) { - fail(`release provenance marker targets commit outside the active range: ${marker.commit}`); - } - const existing = overrides.get(marker.commit); - if (existing && existing.join(",") !== marker.pullRequests.join(",")) { - fail(`conflicting release provenance markers for ${marker.commit}`); - } - overrides.set(marker.commit, marker.pullRequests); + addMarker(marker); } } + for (const value of releaseProvenance) { + if (/[\r\n]/u.test(value)) { + fail(`invalid release provenance marker: Release provenance: ${value}`); + } + addMarker(releaseProvenanceMarker(`Release provenance: ${value}`)); + } return overrides; } @@ -1017,7 +1032,7 @@ function canonicalMainCommits(base, mainRef) { return commits; } -function sourceCommits(base, target, mainRef) { +function sourceCommits(base, target, mainRef, releaseProvenance = []) { const targetCommit = git(["rev-parse", `${target}^{commit}`]); if (!gitIsAncestor(base, targetCommit)) { fail(`release range base ${base} must be an ancestor of target ${target}`); @@ -1193,7 +1208,7 @@ function sourceCommits(base, target, mainRef) { activeCommits.map((commit) => commit.hash), targetTimestamp, ); - const provenanceOverrides = collectReleaseProvenanceOverrides(activeCommits); + const provenanceOverrides = collectReleaseProvenanceOverrides(activeCommits, releaseProvenance); const mainCommits = canonicalMainCommits(base, mainRef); const mainCommit = provenanceOverrides.size > 0 ? gitCommit(mainRef, true) : undefined; const mainCommitsByHash = new Map(mainCommits.map((commit) => [commit.hash, commit])); @@ -2412,7 +2427,12 @@ function main() { githubSnapshotState = initializeGithubSnapshot(options); const changelog = readFileSync("CHANGELOG.md", "utf8"); const section = sectionFor(changelog, options.version); - const source = sourceCommits(options.base, options.target, options.mainRef ?? "origin/main"); + const source = sourceCommits( + options.base, + options.target, + options.mainRef ?? "origin/main", + options.releaseProvenance, + ); const committedSection = optionalSectionFor( git(["show", `${source.target}:CHANGELOG.md`]), options.version, diff --git a/test/external-script-modules.d.ts b/test/external-script-modules.d.ts index ef47a9bac998..a73b4881283b 100644 --- a/test/external-script-modules.d.ts +++ b/test/external-script-modules.d.ts @@ -143,7 +143,12 @@ declare module "*openclaw-changelog-update/scripts/verify-release-notes.mjs" { ): Array<{ commit: string; pullRequests: number[] }>; export function collectReleaseProvenanceOverrides( activeCommits: Array<{ body: string; hash: string }>, + releaseProvenance?: string[], ): Map; + export function parseArgs(argv: string[]): { + releaseProvenance: string[]; + [key: string]: unknown; + }; export function resolvedReleasePullRequests( currentPullRequests: number[], mainPullRequests: number[], diff --git a/test/scripts/verify-release-notes.test.ts b/test/scripts/verify-release-notes.test.ts index f824c33d0fcd..312e2ff9a745 100644 --- a/test/scripts/verify-release-notes.test.ts +++ b/test/scripts/verify-release-notes.test.ts @@ -16,6 +16,7 @@ import { githubApiWithSnapshot, highlightCountError, isEligibleHandle, + parseArgs, persistGithubSnapshot, pullRequestTitleFromCommitSubject, releaseNoteReferences, @@ -275,6 +276,74 @@ describe("release-note verification", () => { ).toThrow(`conflicting release provenance markers for ${releaseCommit}`); }); + it("accepts repeatable CLI provenance without metadata commits", () => { + const mappings: Array<[string, number]> = [ + ["bdde3d1c6dd7cc415588a72cf27ebe27f83bfe47", 120085], + ["5090cae6d0cc3f2ec272b2448970c6238f525610", 120479], + ["8ff1724067c1dcfb9a63574e6f3771261033ffae", 120479], + ["0cd3075adf7cd201e17d25c95cbe190991f8aab1", 120538], + ]; + const payloads = mappings.map(([commit, pullRequest]) => `${commit} -> #${pullRequest}`); + const options = parseArgs([ + "--base", + "base", + "--target", + "target", + "--version", + "2026.8.1", + ...payloads.flatMap((payload) => ["--release-provenance", payload]), + ]); + + expect(options.releaseProvenance).toEqual(payloads); + expect( + collectReleaseProvenanceOverrides( + mappings.map(([hash]) => ({ body: "", hash })), + options.releaseProvenance, + ), + ).toEqual(new Map(mappings.map(([commit, pullRequest]) => [commit, [pullRequest]]))); + }); + + it("validates CLI provenance through the exact marker merge path", () => { + const activeCommit = "a".repeat(40); + + expect(() => + collectReleaseProvenanceOverrides([{ body: "", hash: activeCommit }], ["short -> #1"]), + ).toThrow("invalid release provenance marker"); + expect(() => + collectReleaseProvenanceOverrides( + [{ body: "", hash: activeCommit }], + [`${activeCommit} -> #1 trailing`], + ), + ).toThrow("invalid release provenance marker"); + expect(() => + collectReleaseProvenanceOverrides( + [{ body: "", hash: activeCommit }], + [`${activeCommit} -> #1\nRelease provenance: ${activeCommit} -> #2`], + ), + ).toThrow("invalid release provenance marker"); + for (const payload of [ + `${activeCommit} -> #1\n`, + `${activeCommit} -> #1,\n#2`, + `${activeCommit} -> #1\r\n`, + ]) { + expect(() => + collectReleaseProvenanceOverrides([{ body: "", hash: activeCommit }], [payload]), + ).toThrow("invalid release provenance marker"); + } + expect(() => + collectReleaseProvenanceOverrides( + [{ body: "", hash: activeCommit }], + [`${"b".repeat(40)} -> #1`], + ), + ).toThrow("release provenance marker targets commit outside the active range"); + expect(() => + collectReleaseProvenanceOverrides( + [{ body: `Release provenance: ${activeCommit} -> #1`, hash: activeCommit }], + [`${activeCommit} -> #2`], + ), + ).toThrow(`conflicting release provenance markers for ${activeCommit}`); + }); + it("requires release provenance PRs to be merged into current main", () => { const releaseCommit = "a".repeat(40); const mainCommit = "b".repeat(40);