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 cebc03990c43..5fd554df4da8 100644 --- a/.agents/skills/openclaw-changelog-update/scripts/verify-release-notes.mjs +++ b/.agents/skills/openclaw-changelog-update/scripts/verify-release-notes.mjs @@ -2,6 +2,7 @@ import { execFileSync } from "node:child_process"; import { readFileSync, writeFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; const repo = "openclaw/openclaw"; const commitAssociationQueryBatchSize = 20; @@ -221,6 +222,42 @@ function referencesIn(text) { return references; } +function referenceLabelsIn(text) { + const labels = []; + for (const match of text.matchAll( + /(?[A-Za-z0-9_.-]+)\/(?[A-Za-z0-9_.-]+))?#(?\d+)/g, + )) { + const qualifiedRepository = match.groups?.owner + ? `${match.groups.owner}/${match.groups.name}` + : undefined; + labels.push( + !qualifiedRepository || qualifiedRepository.toLowerCase() === repo + ? `#${match.groups?.number}` + : `${qualifiedRepository}#${match.groups?.number}`, + ); + } + return labels; +} + +export function renderContributionRecordEntry(entry) { + const references = []; + appendUnique(references, referenceLabelsIn(entry.title)); + appendUnique( + references, + (entry.priorReferences ?? []).map((number) => `#${number}`), + ); + appendUnique(references, entry.externalReferences ?? []); + for (const issue of entry.linkedIssues) { + appendUnique(references, [`#${issue.number}`]); + } + const related = references.length > 0 ? ` Related ${references.join(", ")}.` : ""; + const attribution = + entry.thanks.length > 0 + ? ` Thanks ${entry.thanks.map((handle) => `@${handle}`).join(" and ")}.` + : ""; + return `- **PR #${entry.number}**${related}${attribution}`; +} + function closingReferencesIn(text) { const references = []; for (const match of text.matchAll( @@ -255,9 +292,19 @@ function handlesIn(text) { ); } -function relatedReferencesIn(line) { - const related = line.match(/\bRelated ((?:#\d+)(?:, #\d+)*)\./); - return related ? referencesIn(related[1]) : []; +function externalReferencesIn(text) { + return referenceLabelsIn(text).filter((reference) => !reference.startsWith("#")); +} + +function appendUnique(values, additions) { + const seen = new Set(values.map((value) => value.toLowerCase())); + for (const value of additions) { + const key = value.toLowerCase(); + if (!seen.has(key)) { + values.push(value); + seen.add(key); + } + } } function addContributionRecordEntry(entries, key, entry) { @@ -265,16 +312,18 @@ function addContributionRecordEntry(entries, key, entry) { if (!existing) { entries.set(key, { ...entry, + externalReferences: [...(entry.externalReferences ?? [])], references: [...entry.references], thanks: [...entry.thanks], }); return; } + appendUnique(existing.externalReferences, entry.externalReferences ?? []); appendReferences(existing.references, entry.references); addHandles(existing.thanks, entry.thanks); } -function contributionRecordFor(section) { +export function contributionRecordFor(section) { const result = { legacyIssues: new Map(), pullRequests: new Map() }; const recordStart = section.source.search(/\n### Complete contribution (?:ledger|record)\r?$/m); if (recordStart < 0) { @@ -301,8 +350,10 @@ function contributionRecordFor(section) { const number = explicitRecord?.[1] ?? legacyRecord?.[1]; if (number) { const value = Number(number); + const metadata = explicitRecord ? line.slice(explicitRecord[0].length) : line; addContributionRecordEntry(result.pullRequests, value, { - references: relatedReferencesIn(line), + externalReferences: externalReferencesIn(metadata), + references: referencesIn(metadata).filter((reference) => reference !== value), thanks: handlesIn(line), }); } @@ -345,6 +396,7 @@ function withoutRevertedContributionRecords(record, revertedReferences) { } addContributionRecordEntry(filtered.pullRequests, number, { ...entry, + externalReferences: entry.externalReferences, references: entry.references.filter((reference) => !revertedReferences.has(reference)), }); } @@ -1153,7 +1205,7 @@ function mergeIssues(...groups) { return [...entries.values()]; } -function ledgerFor( +export function ledgerFor( base, target, references, @@ -1203,6 +1255,8 @@ function ledgerFor( const issues = entries.filter((entry) => entry.type === "Issue"); const legacyIssues = legacyIssuesByPullRequest(priorRecord, nodes); const records = pullRequests.map((entry) => { + const priorEntry = priorRecord.pullRequests.get(entry.number); + const priorReferences = priorEntry?.references ?? []; const titleIssues = issueEntries(referencesIn(entry.title), nodes); const closingIssues = issueEntries( entry.closingIssuesReferences?.nodes.map((issue) => issue.number) ?? [], @@ -1212,33 +1266,23 @@ function ledgerFor( titleIssues, closingIssues, relationships.issuesByPullRequest.get(entry.number) ?? [], + issueEntries(priorReferences, nodes), issueEntries(legacyIssues.get(entry.number) ?? [], nodes, priorRecord.legacyIssues), ); - const titleIssueNumbers = new Set(titleIssues.map((issue) => issue.number)); - const relatedIssues = linkedIssues.filter((issue) => !titleIssueNumbers.has(issue.number)); const thanks = [...entry.thanks]; + addHandles(thanks, priorEntry?.thanks ?? []); for (const issue of linkedIssues) { addHandles(thanks, issue.thanks); } return { ...entry, ...editorialClassification(entry.title), + externalReferences: priorEntry?.externalReferences ?? [], linkedIssues, - relatedIssues, + priorReferences, thanks, }; }); - const renderEntry = (entry) => { - const attribution = - entry.thanks.length > 0 - ? ` Thanks ${entry.thanks.map((handle) => `@${handle}`).join(" and ")}.` - : ""; - const relatedIssues = - entry.relatedIssues.length > 0 - ? ` Related ${entry.relatedIssues.map((issue) => `#${issue.number}`).join(", ")}.` - : ""; - return `- **PR #${entry.number}** ${withSentenceEnding(entry.title)}${relatedIssues}${attribution}`; - }; const ledger = [ "### Complete contribution record", "", @@ -1246,7 +1290,7 @@ function ledgerFor( "", "#### Pull requests", "", - ...records.map((entry) => renderEntry(entry)), + ...records.map((entry) => renderContributionRecordEntry(entry)), ].join("\n"); return { entries, @@ -1267,7 +1311,7 @@ function replaceLedger(changelog, section, ledger, pullRequests, directCommits) return `${changelog.slice(0, section.start)}${replacement}${changelog.slice(section.end)}`; } -function ledgerChecks(section, pullRequests, nodes, directCommits) { +export function ledgerChecks(section, pullRequests, nodes, directCommits) { const errors = []; if (/@undefined\b/i.test(section.source)) { errors.push("release section contains invalid @undefined contributor credit"); @@ -1311,6 +1355,25 @@ function ledgerChecks(section, pullRequests, nodes, directCommits) { errors.push(`missing Thanks @${handle} for #${entry.number}`); } } + const expectedReferences = []; + appendUnique(expectedReferences, referenceLabelsIn(entry.title)); + appendUnique( + expectedReferences, + entry.priorReferences.map((number) => `#${number}`), + ); + appendUnique(expectedReferences, entry.externalReferences); + appendUnique( + expectedReferences, + entry.linkedIssues.map((issue) => `#${issue.number}`), + ); + const actualReferences = new Set( + referenceLabelsIn(line).map((reference) => reference.toLowerCase()), + ); + for (const reference of expectedReferences) { + if (!actualReferences.has(reference.toLowerCase())) { + errors.push(`missing ${reference} on contribution record for PR #${entry.number}`); + } + } } const editorialProse = section.source.slice(0, ledgerStart); for (const entry of pullRequests) { @@ -1402,6 +1465,8 @@ function manifestFor(options, source, ledger, directCommitRecords) { type: entry.type, editorialEligible: entry.editorialEligible, thanks: entry.thanks, + externalReferences: entry.externalReferences, + relatedReferences: [...new Set([...entry.priorReferences, ...referencesIn(entry.title)])], linkedIssues: entry.linkedIssues.map((issue) => ({ number: issue.number, title: issue.title, @@ -1620,4 +1685,6 @@ function main() { } } -main(); +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main(); +} diff --git a/.github/workflows/openclaw-release-publish.yml b/.github/workflows/openclaw-release-publish.yml index f3b73819bd5a..ca78ca515a9d 100644 --- a/.github/workflows/openclaw-release-publish.yml +++ b/.github/workflows/openclaw-release-publish.yml @@ -493,6 +493,20 @@ jobs: with: install-bun: "false" + - name: Prepare GitHub release notes + if: ${{ inputs.publish_openclaw_npm }} + env: + RELEASE_TAG: ${{ inputs.tag }} + TARGET_SHA: ${{ needs.resolve_release_target.outputs.sha }} + run: | + changelog_file="${RUNNER_TEMP}/CHANGELOG.md" + notes_file="${RUNNER_TEMP}/release-notes.md" + git show "${TARGET_SHA}:CHANGELOG.md" > "${changelog_file}" + node scripts/prepare-github-release-notes.mjs \ + --changelog "${changelog_file}" \ + --release-tag "${RELEASE_TAG}" \ + --output "${notes_file}" + - name: Write Android release approval if: ${{ !contains(inputs.tag, '-alpha.') && !contains(inputs.tag, '-beta.') }} env: @@ -943,31 +957,12 @@ jobs: } create_or_update_github_release() { - local release_version notes_version title notes_file changelog_file latest_arg prerelease_args + local release_version title notes_file latest_arg prerelease_args release_version="${RELEASE_TAG#v}" - notes_version="${release_version}" - if [[ "${notes_version}" =~ ^([0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*)-(alpha|beta)\.[1-9][0-9]*$ ]]; then - notes_version="${BASH_REMATCH[1]}" - fi title="openclaw ${release_version}" - changelog_file="${RUNNER_TEMP}/CHANGELOG.md" notes_file="${RUNNER_TEMP}/release-notes.md" - - git show "${TARGET_SHA}:CHANGELOG.md" > "${changelog_file}" - awk -v version="${notes_version}" ' - $0 == "## " version { in_section = 1; next } - /^## / && in_section { exit } - in_section { print } - ' "${changelog_file}" > "${notes_file}" - if [[ ! -s "${notes_file}" ]] && [[ "${RELEASE_TAG}" == *"-alpha."* || "${RELEASE_TAG}" == *"-beta."* ]]; then - awk ' - $0 == "## Unreleased" { in_section = 1; next } - /^## / && in_section { exit } - in_section { print } - ' "${changelog_file}" > "${notes_file}" - fi if [[ ! -s "${notes_file}" ]]; then - echo "CHANGELOG.md does not contain release notes for ${notes_version} or an Unreleased prerelease fallback." >&2 + echo "Prepared GitHub release notes are missing." >&2 exit 1 fi @@ -1349,6 +1344,7 @@ jobs: WINDOWS_LINE="${windows_line}" \ node --input-type=module <<'NODE' import { readFileSync, writeFileSync } from "node:fs"; + import { appendGitHubReleaseVerification } from "./scripts/prepare-github-release-notes.mjs"; const bodyFile = process.env.RELEASE_BODY_FILE; const notesFile = process.env.RELEASE_NOTES_FILE; @@ -1377,8 +1373,7 @@ jobs: ...(process.env.WINDOWS_LINE ? [process.env.WINDOWS_LINE] : []), ].join("\n"); - const withoutOldProof = body.replace(/\n?### Release verification\n[\s\S]*?(?=\n### |\n## |$)/, ""); - writeFileSync(notesFile, `${withoutOldProof.trimEnd()}\n\n${section}\n`); + writeFileSync(notesFile, appendGitHubReleaseVerification(body, section)); NODE gh release edit "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --notes-file "${notes_file}" diff --git a/scripts/prepare-github-release-notes.mjs b/scripts/prepare-github-release-notes.mjs new file mode 100644 index 000000000000..fcfa26b9051e --- /dev/null +++ b/scripts/prepare-github-release-notes.mjs @@ -0,0 +1,142 @@ +#!/usr/bin/env node + +import { readFileSync, writeFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +export const MAX_GITHUB_RELEASE_NOTES_CHARACTERS = 125_000; +export const GITHUB_RELEASE_VERIFICATION_RESERVE_CHARACTERS = 5_000; +export const MAX_GITHUB_RELEASE_SOURCE_NOTES_CHARACTERS = + MAX_GITHUB_RELEASE_NOTES_CHARACTERS - GITHUB_RELEASE_VERIFICATION_RESERVE_CHARACTERS; +export const MAX_GITHUB_RELEASE_NOTES_UTF8_BYTES = 125_000; +export const GITHUB_RELEASE_VERIFICATION_RESERVE_UTF8_BYTES = 5_000; +export const MAX_GITHUB_RELEASE_SOURCE_NOTES_UTF8_BYTES = + MAX_GITHUB_RELEASE_NOTES_UTF8_BYTES - GITHUB_RELEASE_VERIFICATION_RESERVE_UTF8_BYTES; + +function fail(message) { + throw new Error(message); +} + +function releaseNotesHeadings(releaseTag) { + const version = releaseTag.replace(/^v/, ""); + const betaBase = version.match(/^(\d{4}\.[1-9]\d*\.[1-9]\d*)-beta\.[1-9]\d*$/)?.[1]; + if (betaBase) { + return [betaBase]; + } + const correctionBase = version.match(/^(\d{4}\.[1-9]\d*\.[1-9]\d*)-[1-9]\d*$/)?.[1]; + if (correctionBase) { + return [version, correctionBase]; + } + if (/^\d{4}\.[1-9]\d*\.[1-9]\d*-alpha\.[1-9]\d*$/.test(version)) { + return [version, "Unreleased"]; + } + return [version]; +} + +function releaseSection(changelog, version) { + const lines = changelog.split(/\r?\n/); + const start = lines.indexOf(`## ${version}`); + if (start < 0) { + return undefined; + } + const next = lines.findIndex((line, index) => index > start && line.startsWith("## ")); + const end = next < 0 ? lines.length : next; + return lines.slice(start, end).join("\n").trimEnd(); +} + +export function prepareGitHubReleaseNotes(changelog, releaseTag) { + const headings = releaseNotesHeadings(releaseTag); + const heading = headings.find((candidate) => releaseSection(changelog, candidate)); + const notes = heading ? releaseSection(changelog, heading) : undefined; + if (!notes) { + fail(`CHANGELOG.md does not contain release notes for ${headings.join(" or ")}.`); + } + const bodyStart = notes.indexOf("\n"); + if (bodyStart < 0 || !notes.slice(bodyStart + 1).trim()) { + fail(`CHANGELOG.md release section for ${heading} does not contain release-note content.`); + } + const body = `${notes}\n`; + if (body.length > MAX_GITHUB_RELEASE_SOURCE_NOTES_CHARACTERS) { + fail( + `GitHub release notes are ${body.length} characters; the complete source section exceeds the ${MAX_GITHUB_RELEASE_SOURCE_NOTES_CHARACTERS}-character source budget required to reserve ${GITHUB_RELEASE_VERIFICATION_RESERVE_CHARACTERS} characters for release verification.`, + ); + } + const bodyBytes = Buffer.byteLength(body, "utf8"); + if (bodyBytes > MAX_GITHUB_RELEASE_SOURCE_NOTES_UTF8_BYTES) { + fail( + `GitHub release notes are ${bodyBytes} UTF-8 bytes; the complete source section exceeds the ${MAX_GITHUB_RELEASE_SOURCE_NOTES_UTF8_BYTES}-byte source safety budget required to reserve ${GITHUB_RELEASE_VERIFICATION_RESERVE_UTF8_BYTES} bytes for release verification.`, + ); + } + return body; +} + +export function appendGitHubReleaseVerification(notes, verificationSection) { + const proof = verificationSection.trim(); + if (!proof.startsWith("### Release verification\n")) { + fail("Release verification proof must start with the canonical heading."); + } + const proofSuffix = `\n\n${proof}\n`; + if (proofSuffix.length > GITHUB_RELEASE_VERIFICATION_RESERVE_CHARACTERS) { + fail( + `Release verification proof is ${proofSuffix.length} characters; it exceeds the reserved ${GITHUB_RELEASE_VERIFICATION_RESERVE_CHARACTERS}-character budget.`, + ); + } + const proofBytes = Buffer.byteLength(proofSuffix, "utf8"); + if (proofBytes > GITHUB_RELEASE_VERIFICATION_RESERVE_UTF8_BYTES) { + fail( + `Release verification proof is ${proofBytes} UTF-8 bytes; it exceeds the reserved ${GITHUB_RELEASE_VERIFICATION_RESERVE_UTF8_BYTES}-byte safety budget.`, + ); + } + const withoutOldProof = notes + .trimEnd() + .replace(/\n?### Release verification\n[\s\S]*?(?=\n### |\n## |$)/, "") + .trimEnd(); + const withProof = `${withoutOldProof}${proofSuffix}`; + if (withProof.length > MAX_GITHUB_RELEASE_NOTES_CHARACTERS) { + fail( + `GitHub release notes with verification are ${withProof.length} characters; they exceed GitHub's ${MAX_GITHUB_RELEASE_NOTES_CHARACTERS}-character release-body limit.`, + ); + } + const withProofBytes = Buffer.byteLength(withProof, "utf8"); + if (withProofBytes > MAX_GITHUB_RELEASE_NOTES_UTF8_BYTES) { + fail( + `GitHub release notes with verification are ${withProofBytes} UTF-8 bytes; they exceed the ${MAX_GITHUB_RELEASE_NOTES_UTF8_BYTES}-byte release-body safety limit.`, + ); + } + return withProof; +} + +function parseArgs(argv) { + const options = {}; + for (let index = 0; index < argv.length; index += 2) { + const name = argv[index]; + const value = argv[index + 1]; + if (!["--changelog", "--output", "--release-tag"].includes(name) || !value) { + fail( + "Usage: prepare-github-release-notes.mjs --changelog --release-tag --output ", + ); + } + options[name.slice(2)] = value; + } + for (const name of ["changelog", "output", "release-tag"]) { + if (!options[name]) { + fail(`--${name} is required`); + } + } + return options; +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + const notes = prepareGitHubReleaseNotes( + readFileSync(options.changelog, "utf8"), + options["release-tag"], + ); + writeFileSync(options.output, notes); + process.stdout.write( + `Prepared ${Buffer.byteLength(notes, "utf8")} bytes of GitHub release notes.\n`, + ); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main(); +} diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index 43e476224451..d16eafc67111 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -626,6 +626,10 @@ const GITHUB_WORKFLOW_OWNER_TEST_TARGETS = new Map([ ]); const TOOLING_SOURCE_TEST_TARGETS = new Map([ ["Dockerfile", ROOT_DOCKERFILE_TEST_TARGETS], + [ + ".agents/skills/openclaw-changelog-update/scripts/verify-release-notes.mjs", + ["test/scripts/release-notes-ledger.test.ts"], + ], [".crabbox.yaml", ["test/scripts/package-acceptance-workflow.test.ts"]], [".github/actions/detect-docs-changes/action.yml", ["test/scripts/ci-workflow-guards.test.ts"]], [ @@ -1433,6 +1437,10 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([ ["test/scripts/plugin-npm-runtime-build-args.test.ts"], ], ["scripts/package-changelog.mjs", ["test/scripts/package-changelog.test.ts"]], + [ + "scripts/prepare-github-release-notes.mjs", + ["test/scripts/prepare-github-release-notes.test.ts"], + ], ["scripts/package-mac-app.sh", ["test/scripts/package-mac-app.test.ts"]], ["scripts/package-mac-dist.sh", ["test/scripts/package-mac-dist.test.ts"]], [ diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index 9c56a5f49750..14ab7a1e85b6 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -1753,9 +1753,7 @@ describe("package artifact reuse", () => { "inputs.include_openwebui && inputs.docker_lanes == '' && (inputs.release_test_profile == 'stable' || inputs.release_test_profile == 'full')", ); expect(job["runs-on"]).toBe("blacksmith-32vcpu-ubuntu-2404"); - expect(job.env?.OPENCLAW_DOCKER_ALL_RELEASE_PROFILE).toBe( - "${{ inputs.release_test_profile }}", - ); + expect(job.env?.OPENCLAW_DOCKER_ALL_RELEASE_PROFILE).toBe("${{ inputs.release_test_profile }}"); expect(setupNode.with).toMatchObject({ "install-bun": "false", "install-deps": "false", @@ -2106,10 +2104,25 @@ describe("package artifact reuse", () => { expect(npmTelegramWorkflow).toContain("preflight-manifest.json"); expect(npmTelegramWorkflow).toContain("OPENCLAW_NPM_TELEGRAM_PACKAGE_DIR"); expect(npmTelegramWorkflow).toContain("package artifact digest mismatch"); - expect(workflow).toContain("Checkout release SHA"); + const publishSteps = workflowJob(RELEASE_PUBLISH_WORKFLOW, "publish").steps ?? []; + const setupIndex = publishSteps.findIndex((step) => step.name === "Setup Node environment"); + const notesIndex = publishSteps.findIndex( + (step) => step.name === "Prepare GitHub release notes", + ); + const androidApprovalIndex = publishSteps.findIndex( + (step) => step.name === "Write Android release approval", + ); + const dispatchIndex = publishSteps.findIndex( + (step) => step.name === "Dispatch publish workflows", + ); + expect(setupIndex).toBeGreaterThan(-1); + expect(notesIndex).toBeGreaterThan(setupIndex); + expect(androidApprovalIndex).toBeGreaterThan(notesIndex); + expect(dispatchIndex).toBeGreaterThan(notesIndex); + expect(publishSteps[notesIndex]?.if).toBe("${{ inputs.publish_openclaw_npm }}"); + expect(publishSteps[notesIndex]?.run).toContain("scripts/prepare-github-release-notes.mjs"); expect(workflow).toContain('git show "${TARGET_SHA}:CHANGELOG.md" > "${changelog_file}"'); - expect(workflow).toContain('$0 == "## Unreleased" { in_section = 1; next }'); - expect(workflow).toContain("Unreleased prerelease fallback"); + expect(workflow).not.toContain('awk -v version="${notes_version}"'); expect(workflow).not.toContain("gh api --repo"); expect(workflow).not.toContain("timeout-minutes: 360"); }); @@ -2530,6 +2543,8 @@ describe("package artifact reuse", () => { expect(releaseWorkflow).toContain("npm_telegram_run_id"); expect(releaseWorkflow).toContain('release_publish_run_id="${GITHUB_RUN_ID}"'); expect(releaseWorkflow).toContain("append_release_proof_to_github_release"); + expect(releaseWorkflow).toContain("appendGitHubReleaseVerification(body, section)"); + expect(releaseWorkflow).not.toContain("Release verification tail omitted"); expect(releaseWorkflow).toContain("guard_existing_public_release"); expect(releaseWorkflow).toContain( "already has a public GitHub release page without complete postpublish evidence", diff --git a/test/scripts/prepare-github-release-notes.test.ts b/test/scripts/prepare-github-release-notes.test.ts new file mode 100644 index 000000000000..4b8cf315ae53 --- /dev/null +++ b/test/scripts/prepare-github-release-notes.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from "vitest"; +import { + appendGitHubReleaseVerification, + GITHUB_RELEASE_VERIFICATION_RESERVE_CHARACTERS, + GITHUB_RELEASE_VERIFICATION_RESERVE_UTF8_BYTES, + MAX_GITHUB_RELEASE_NOTES_CHARACTERS, + MAX_GITHUB_RELEASE_NOTES_UTF8_BYTES, + MAX_GITHUB_RELEASE_SOURCE_NOTES_CHARACTERS, + MAX_GITHUB_RELEASE_SOURCE_NOTES_UTF8_BYTES, + prepareGitHubReleaseNotes, +} from "../../scripts/prepare-github-release-notes.mjs"; + +describe("prepareGitHubReleaseNotes", () => { + it("preserves the stable-base heading for prerelease notes", () => { + const changelog = [ + "## Unreleased", + "", + "Future work.", + "", + "## 2026.7.1", + "", + "Release work.", + "", + "## 2026.6.11", + "", + "Older work.", + ].join("\n"); + + expect(prepareGitHubReleaseNotes(changelog, "v2026.7.1-beta.3")).toBe( + "## 2026.7.1\n\nRelease work.\n", + ); + }); + + it("uses an exact alpha heading before the Unreleased fallback", () => { + const changelog = [ + "## Unreleased", + "", + "Future nightly work.", + "", + "## 2026.7.2-alpha.3", + "", + "Nightly release work.", + "", + "## 2026.7.1", + "", + "Stable work.", + ].join("\n"); + + expect(prepareGitHubReleaseNotes(changelog, "v2026.7.2-alpha.3")).toBe( + "## 2026.7.2-alpha.3\n\nNightly release work.\n", + ); + }); + + it("keeps the Unreleased fallback for alpha branches without an exact heading", () => { + const changelog = "## Unreleased\n\nNightly release work.\n\n## 2026.7.1\n\nStable work.\n"; + + expect(prepareGitHubReleaseNotes(changelog, "v2026.7.2-alpha.3")).toBe( + "## Unreleased\n\nNightly release work.\n", + ); + }); + + it("prefers an exact numeric correction heading", () => { + const changelog = [ + "## 2026.7.1-2", + "", + "Correction-specific work.", + "", + "## 2026.7.1", + "", + "Base release work.", + ].join("\n"); + + expect(prepareGitHubReleaseNotes(changelog, "v2026.7.1-2")).toBe( + "## 2026.7.1-2\n\nCorrection-specific work.\n", + ); + }); + + it("falls back to the base heading for numeric correction tags", () => { + const changelog = "## 2026.7.1\n\nBase release work.\n\n## 2026.6.11\n\nOlder work.\n"; + + expect(prepareGitHubReleaseNotes(changelog, "v2026.7.1-2")).toBe( + "## 2026.7.1\n\nBase release work.\n", + ); + }); + + it("requires the stable-base section for beta and stable releases", () => { + const changelog = "## Unreleased\n\nCandidate work.\n\n## 2026.6.11\n\nOlder work.\n"; + + expect(() => prepareGitHubReleaseNotes(changelog, "v2026.7.1-beta.3")).toThrow( + "does not contain release notes for 2026.7.1", + ); + expect(() => prepareGitHubReleaseNotes(changelog, "v2026.7.1")).toThrow( + "does not contain release notes for 2026.7.1", + ); + }); + + it("rejects a heading without release-note content", () => { + const changelog = "## 2026.7.1\n\n## 2026.6.11\n\nOlder work.\n"; + + expect(() => prepareGitHubReleaseNotes(changelog, "v2026.7.1-beta.3")).toThrow( + "does not contain release-note content", + ); + }); + + it("accepts multibyte source sections below the character and byte budgets", () => { + const changelog = `## 2026.7.1\n\n${"é".repeat(59_000)}\n`; + + expect(prepareGitHubReleaseNotes(changelog, "v2026.7.1-beta.3")).toHaveLength(59_014); + }); + + it("rejects multibyte source sections that exceed the UTF-8 byte budget", () => { + const changelog = `## 2026.7.1\n\n${"é".repeat(70_000)}\n`; + + expect(() => prepareGitHubReleaseNotes(changelog, "v2026.7.1-beta.3")).toThrow( + `complete source section exceeds the ${MAX_GITHUB_RELEASE_SOURCE_NOTES_UTF8_BYTES}-byte source safety budget`, + ); + }); + + it("accepts a source body exactly at the proof-reserving limit", () => { + const prefix = "## 2026.7.1\n\n"; + const content = "x".repeat(MAX_GITHUB_RELEASE_SOURCE_NOTES_CHARACTERS - prefix.length - 1); + + expect(prepareGitHubReleaseNotes(`${prefix}${content}\n`, "v2026.7.1-beta.3")).toHaveLength( + MAX_GITHUB_RELEASE_SOURCE_NOTES_CHARACTERS, + ); + }); + + it("rejects source sections that consume the required proof reserve", () => { + const prefix = "## 2026.7.1\n\n"; + const content = "x".repeat(MAX_GITHUB_RELEASE_SOURCE_NOTES_CHARACTERS - prefix.length); + const changelog = `${prefix}${content}\n`; + + expect(() => prepareGitHubReleaseNotes(changelog, "v2026.7.1-beta.3")).toThrow( + "complete source section exceeds the 120000-character source budget", + ); + }); + + it("appends and replaces canonical release verification within the reserve", () => { + const notes = "## 2026.7.1\n\nRelease work.\n\n### Release verification\n\n- stale proof\n"; + const proof = "### Release verification\n\n- current proof"; + + expect(appendGitHubReleaseVerification(notes, proof)).toBe( + "## 2026.7.1\n\nRelease work.\n\n### Release verification\n\n- current proof\n", + ); + }); + + it("rejects proof that exceeds its reserved capacity", () => { + const proof = `### Release verification\n\n${"x".repeat( + GITHUB_RELEASE_VERIFICATION_RESERVE_CHARACTERS, + )}`; + + expect(() => appendGitHubReleaseVerification("## 2026.7.1\n\nRelease work.\n", proof)).toThrow( + "exceeds the reserved 5000-character budget", + ); + }); + + it("rejects multibyte proof that exceeds its reserved byte capacity", () => { + const proof = `### Release verification\n\n${"é".repeat( + GITHUB_RELEASE_VERIFICATION_RESERVE_UTF8_BYTES / 2, + )}`; + + expect(() => appendGitHubReleaseVerification("## 2026.7.1\n\nRelease work.\n", proof)).toThrow( + "exceeds the reserved 5000-byte safety budget", + ); + }); + + it("keeps the combined body under GitHub's hard limit", () => { + const prefix = "## 2026.7.1\n\n"; + const content = "x".repeat(MAX_GITHUB_RELEASE_SOURCE_NOTES_CHARACTERS - prefix.length - 1); + const notes = prepareGitHubReleaseNotes(`${prefix}${content}\n`, "v2026.7.1"); + const proof = `### Release verification\n\n${"x".repeat( + GITHUB_RELEASE_VERIFICATION_RESERVE_CHARACTERS - 40, + )}`; + + expect(appendGitHubReleaseVerification(notes, proof).length).toBeLessThanOrEqual( + MAX_GITHUB_RELEASE_NOTES_CHARACTERS, + ); + expect( + Buffer.byteLength(appendGitHubReleaseVerification(notes, proof), "utf8"), + ).toBeLessThanOrEqual(MAX_GITHUB_RELEASE_NOTES_UTF8_BYTES); + }); +}); diff --git a/test/scripts/release-notes-ledger.test.ts b/test/scripts/release-notes-ledger.test.ts new file mode 100644 index 000000000000..8f3194fbbef4 --- /dev/null +++ b/test/scripts/release-notes-ledger.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, it } from "vitest"; +import { + contributionRecordFor, + ledgerChecks, + ledgerFor, + renderContributionRecordEntry, +} from "../../.agents/skills/openclaw-changelog-update/scripts/verify-release-notes.mjs"; + +describe("renderContributionRecordEntry", () => { + it("keeps source and linked issue references without repeating PR titles", () => { + expect( + renderContributionRecordEntry({ + number: 123, + title: "Fix local openclaw/openclaw#45 and openclaw/imsg#141", + linkedIssues: [{ number: 45 }, { number: 67 }], + thanks: ["alice", "bob"], + }), + ).toBe("- **PR #123** Related #45, openclaw/imsg#141, #67. Thanks @alice and @bob."); + }); + + it("deduplicates title references and retains seeded cross-repository references", () => { + expect( + renderContributionRecordEntry({ + number: 124, + title: "Fix #45, #45, and OpenClaw/imsg#141", + externalReferences: ["openclaw/imsg#141"], + priorReferences: [67], + linkedIssues: [{ number: 45 }], + thanks: [], + }), + ).toBe("- **PR #124** Related #45, OpenClaw/imsg#141, #67."); + }); + + it("renders every source PR even without issue references or credits", () => { + expect( + renderContributionRecordEntry({ + number: 456, + title: "Internal cleanup", + linkedIssues: [], + thanks: [], + }), + ).toBe("- **PR #456**"); + }); + + it("retains references and credits when a compact record is seeded again", () => { + const line = "- **PR #125** Related #45, openclaw/imsg#141. Thanks @alice and @bob."; + const record = contributionRecordFor({ + source: [ + "## 2026.7.1", + "", + "### Complete contribution record", + "", + "#### Pull requests", + "", + line, + ].join("\n"), + }); + const seeded = record.pullRequests.get(125); + + expect(seeded).toEqual({ + externalReferences: ["openclaw/imsg#141"], + references: [45], + thanks: ["alice", "bob"], + }); + expect( + renderContributionRecordEntry({ + number: 125, + title: "Title changed after release", + priorReferences: seeded?.references, + externalReferences: seeded?.externalReferences, + linkedIssues: [], + thanks: seeded?.thanks ?? [], + }), + ).toBe(line); + }); + + it("retains seeded credits when the production ledger is rebuilt", () => { + const priorRecord = contributionRecordFor({ + source: [ + "## 2026.7.1", + "", + "### Complete contribution record", + "", + "#### Pull requests", + "", + "- **PR #125** Thanks @alice and @bob.", + ].join("\n"), + }); + const nodes = new Map([ + [ + 125, + { + __typename: "PullRequest", + author: { __typename: "User", login: "carol" }, + closingIssuesReferences: { nodes: [] }, + mergedAt: "2026-07-08T00:00:00Z", + title: "fix: keep release credits", + }, + ], + ]); + + const result = ledgerFor( + "v2026.6.11", + "HEAD", + [125], + nodes, + new Map(), + new Map(), + { issuesByPullRequest: new Map() }, + priorRecord, + new Set([125]), + new Set(), + new Set(), + new Set(), + new Set(), + Date.parse("2026-07-09T00:00:00Z"), + ); + + expect(result.ledger).toContain("- **PR #125** Thanks @carol and @alice and @bob."); + }); + + it("retains references from a verbose record when the source title changes", () => { + const record = contributionRecordFor({ + source: [ + "## 2026.7.1", + "", + "### Complete contribution record", + "", + "#### Pull requests", + "", + "- **PR #126** Fix #46 and openclaw/imsg#142. Related #68. Thanks @alice.", + ].join("\n"), + }); + const seeded = record.pullRequests.get(126); + + expect(seeded).toEqual({ + externalReferences: ["openclaw/imsg#142"], + references: [46, 68], + thanks: ["alice"], + }); + }); + + it("requires complete reference tokens rather than matching substrings", () => { + const source = [ + "## 2026.7.1", + "", + "### Highlights", + "", + "### Changes", + "", + "### Fixes", + "", + "### Complete contribution record", + "", + "#### Pull requests", + "", + "- **PR #456** Related openclaw/imsg#141.", + ].join("\n"); + const entry = { + number: 456, + title: "Internal cleanup", + editorialEligible: false, + priorReferences: [45, 141], + externalReferences: [], + linkedIssues: [], + thanks: [], + }; + + expect( + ledgerChecks({ source }, [entry], new Map([[456, { __typename: "PullRequest" }]]), []), + ).toEqual([ + "missing #45 on contribution record for PR #456", + "missing #141 on contribution record for PR #456", + ]); + }); + + it("accepts case-only differences in cross-repository references", () => { + const line = "- **PR #127** Related OpenClaw/imsg#143."; + const source = [ + "## 2026.7.1", + "", + "### Highlights", + "", + "### Changes", + "", + "### Fixes", + "", + "### Complete contribution record", + "", + "#### Pull requests", + "", + line, + ].join("\n"); + const entry = { + number: 127, + title: "Internal cleanup", + editorialEligible: false, + priorReferences: [], + externalReferences: ["openclaw/imsg#143"], + linkedIssues: [], + thanks: [], + }; + + expect( + ledgerChecks({ source }, [entry], new Map([[127, { __typename: "PullRequest" }]]), []), + ).toEqual([]); + }); +});