From 4588175df794dab68237492c6c8591440579e9d6 Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Tue, 14 Jul 2026 03:20:14 -0700 Subject: [PATCH] build(release): add release-note renderer prerequisite --- scripts/render-github-release-notes.mjs | 431 ++++++++++++++++++ .../render-github-release-notes.test.ts | 397 ++++++++++++++++ 2 files changed, 828 insertions(+) create mode 100644 scripts/render-github-release-notes.mjs create mode 100644 test/scripts/render-github-release-notes.test.ts diff --git a/scripts/render-github-release-notes.mjs b/scripts/render-github-release-notes.mjs new file mode 100644 index 000000000000..71736f035063 --- /dev/null +++ b/scripts/render-github-release-notes.mjs @@ -0,0 +1,431 @@ +#!/usr/bin/env node + +import { readFileSync, writeFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +export const GITHUB_RELEASE_BODY_MAX_CHARACTERS = 125_000; +export const GITHUB_RELEASE_BODY_MAX_BYTES = 125_000; + +const CONTRIBUTION_RECORD_HEADING = "### Complete contribution record"; +const RELEASE_VERIFICATION_HEADING = "### Release verification"; +const SHIPPED_BASELINE_EXCLUSIONS_PREFIX = "Shipped baseline exclusions:"; +const OPENCLAW_RELEASE_TAG_PATTERN = + /^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(?:-(?:(?:alpha|beta)\.[1-9][0-9]*|[1-9][0-9]*))?$/u; +const RELEASE_HEADING_PATTERN = + /^## (?Unreleased|[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(?:-(?:(?:alpha|beta)\.[1-9][0-9]*|[1-9][0-9]*))?)\r?$/u; + +function fail(message) { + throw new Error(message); +} + +function normalizeTail(value) { + return value?.trim() ?? ""; +} + +function joinBody(notes, tail) { + const normalizedNotes = notes.trimEnd(); + const normalizedTail = normalizeTail(tail); + return normalizedTail ? `${normalizedNotes}\n\n${normalizedTail}` : normalizedNotes; +} + +function validateRepository(repository) { + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(repository)) { + fail(`invalid GitHub repository: ${repository}`); + } +} + +function validateTag(tag) { + if (!OPENCLAW_RELEASE_TAG_PATTERN.test(tag)) { + fail(`invalid release tag: ${tag}`); + } +} + +export function githubReleaseBodySize(body) { + return { + characters: [...body].length, + bytes: Buffer.byteLength(body, "utf8"), + }; +} + +export function fitsGithubReleaseBody(body) { + const size = githubReleaseBodySize(body); + return ( + size.characters <= GITHUB_RELEASE_BODY_MAX_CHARACTERS && + size.bytes <= GITHUB_RELEASE_BODY_MAX_BYTES + ); +} + +function releaseSections(changelog) { + const headings = []; + let offset = 0; + let fence; + for (const segment of changelog.split(/(?<=\n)/u)) { + const line = segment.replace(/\n$/u, ""); + const fenceMatch = line.match(/^\s*(?`{3,}|~{3,})/u); + if (fenceMatch?.groups?.marker) { + const marker = fenceMatch.groups.marker; + if (!fence) { + fence = marker; + } else if (marker[0] === fence[0] && marker.length >= fence.length) { + fence = undefined; + } + offset += segment.length; + continue; + } + if (!fence) { + if (line.startsWith("## ")) { + const releaseHeading = line.match(RELEASE_HEADING_PATTERN); + headings.push({ version: releaseHeading?.groups?.version, start: offset }); + } + } + offset += segment.length; + } + return headings + .map((heading, index) => ({ + version: heading.version, + start: heading.start, + end: headings[index + 1]?.start ?? changelog.length, + })) + .filter((heading) => heading.version); +} + +export function extractChangelogReleaseSections(changelog) { + return releaseSections(changelog).map(({ version, start, end }) => ({ + version, + source: changelog.slice(start, end).trimEnd(), + })); +} + +export function extractChangelogSection(changelog, version) { + const section = releaseSections(changelog).find((candidate) => candidate.version === version); + if (!section) { + fail(`CHANGELOG.md does not contain ## ${version}`); + } + return changelog.slice(section.start, section.end).trimEnd(); +} + +export function releaseNotesVersionForTag(tag) { + validateTag(tag); + return tag.replace(/^v/u, "").replace(/-(?:(?:alpha|beta)\.[1-9][0-9]*|[1-9][0-9]*)$/u, ""); +} + +function validateShippedBaselineRef(ref) { + if (!OPENCLAW_RELEASE_TAG_PATTERN.test(ref)) { + fail(`invalid shipped release tag: ${ref}`); + } +} + +export function formatShippedBaselineExclusions(baselines) { + if (baselines.length === 0) { + return ""; + } + const normalized = baselines + .map(({ ref, count, pullRequests }) => { + validateShippedBaselineRef(ref); + if (!Array.isArray(pullRequests)) { + fail(`missing shipped baseline PR inventory for ${ref}`); + } + const normalizedPullRequests = pullRequests.toSorted((a, b) => a - b); + if ( + normalizedPullRequests.some((number) => !Number.isSafeInteger(number) || number < 1) || + new Set(normalizedPullRequests).size !== normalizedPullRequests.length + ) { + fail(`invalid shipped baseline PR inventory for ${ref}`); + } + if (!Number.isSafeInteger(count) || count < 0 || count !== normalizedPullRequests.length) { + fail(`invalid shipped baseline exclusion count for ${ref}: ${count}`); + } + return { ref, count, pullRequests: normalizedPullRequests }; + }) + .toSorted((a, b) => (a.ref === b.ref ? 0 : a.ref < b.ref ? -1 : 1)); + const seen = new Set(); + for (const baseline of normalized) { + if (seen.has(baseline.ref)) { + fail(`duplicate shipped baseline exclusion: ${baseline.ref}`); + } + seen.add(baseline.ref); + } + return `${SHIPPED_BASELINE_EXCLUSIONS_PREFIX} ${normalized + .map(({ ref, count, pullRequests }) => + count === 0 + ? `${ref} (0 PRs)` + : `${ref} (${count} PRs: ${pullRequests.map((number) => `#${number}`).join(", ")})`, + ) + .join("; ")}.`; +} + +export function parseShippedBaselineExclusions(section) { + const lines = section.split(/\r?\n/u).filter((line) => line.startsWith("Shipped baseline")); + if (lines.length === 0) { + return []; + } + if (lines.length > 1) { + fail("release contribution record contains multiple shipped baseline exclusion lines"); + } + const match = lines[0].match(/^Shipped baseline exclusions: (?.+)\.$/u); + if (!match?.groups?.entries) { + fail("release contribution record contains malformed shipped baseline exclusions"); + } + const baselines = match.groups.entries.split("; ").map((entry) => { + const item = entry.match( + /^(?\S+) \((?0|[1-9][0-9]*) PRs(?:: (?#[1-9][0-9]*(?:, #[1-9][0-9]*)*))?\)$/u, + ); + if (!item?.groups?.ref || item.groups.count === undefined) { + fail(`release contribution record contains malformed shipped baseline exclusion: ${entry}`); + } + const count = Number(item.groups.count); + const pullRequests = item.groups.pullRequests + ? item.groups.pullRequests.split(", ").map((number) => Number(number.slice(1))) + : []; + return { ref: item.groups.ref, count, pullRequests }; + }); + if (formatShippedBaselineExclusions(baselines) !== lines[0]) { + fail("release contribution record shipped baseline exclusions are not canonical"); + } + return baselines; +} + +export function tagPinnedContributionRecordUrl(repository, tag) { + validateRepository(repository); + validateTag(tag); + return `https://github.com/${repository}/blob/${tag}/CHANGELOG.md#complete-contribution-record`; +} + +function headingIndexOutsideFences(markdown, heading) { + let offset = 0; + let fence; + for (const segment of markdown.split(/(?<=\n)/u)) { + const line = segment.replace(/\n$/u, ""); + const fenceMatch = line.match(/^\s*(?`{3,}|~{3,})/u); + if (fenceMatch?.groups?.marker) { + const marker = fenceMatch.groups.marker; + if (!fence) { + fence = marker; + } else if (marker[0] === fence[0] && marker.length >= fence.length) { + fence = undefined; + } + } else if (!fence && line === heading) { + return offset; + } + offset += segment.length; + } + return -1; +} + +function compactReleaseNotes(section, repository, tag) { + const recordIndex = headingIndexOutsideFences(section, CONTRIBUTION_RECORD_HEADING); + if (recordIndex < 0) { + fail( + "release notes exceed GitHub's body limit and cannot be compacted without a complete contribution record", + ); + } + const editorialNotes = section.slice(0, recordIndex).trimEnd(); + const contributionRecordUrl = tagPinnedContributionRecordUrl(repository, tag); + return [ + editorialNotes, + "", + CONTRIBUTION_RECORD_HEADING, + "", + `The full contribution record is available in the tag-pinned [CHANGELOG.md](${contributionRecordUrl}).`, + ].join("\n"); +} + +export function dedicatedSectionVersionForTag(tag) { + // Correction (vX-N) and alpha tags may carry their own exact changelog + // heading; beta and stable bodies must come from the stable base section. + const taggedVersion = tag.replace(/^v/u, ""); + if (/-beta\.[1-9][0-9]*$/u.test(taggedVersion)) { + return undefined; + } + return /-(?:alpha\.)?[1-9][0-9]*$/u.test(taggedVersion) ? taggedVersion : undefined; +} + +export function releaseNotesSectionForTag(changelog, version, tag) { + // Alpha and correction tags prefer their own exact heading when the + // changelog carries one; otherwise they fall back to the base version. + const dedicatedVersion = dedicatedSectionVersionForTag(tag); + if (dedicatedVersion && dedicatedVersion !== version) { + try { + return extractChangelogSection(changelog, dedicatedVersion); + } catch { + // No dedicated section; use the base version below. + } + } + try { + return extractChangelogSection(changelog, version); + } catch (error) { + if (!/-alpha\.[1-9][0-9]*$/u.test(tag)) { + throw error; + } + const unreleased = extractChangelogSection(changelog, "Unreleased"); + return unreleased.replace(/^## Unreleased\r?$/mu, `## ${version}`); + } +} + +export function renderGithubReleaseNotes({ + changelog, + version, + tag, + repository, + verification = "", +}) { + validateRepository(repository); + validateTag(tag); + const tagVersion = releaseNotesVersionForTag(tag); + if (tagVersion !== version) { + fail(`release tag ${tag} requires CHANGELOG.md version ${tagVersion}, got ${version}`); + } + const section = releaseNotesSectionForTag(changelog, version, tag); + const mode = fitsGithubReleaseBody(section) ? "full" : "compact"; + const baseBody = mode === "full" ? section : compactReleaseNotes(section, repository, tag); + if (!fitsGithubReleaseBody(baseBody)) { + const size = githubReleaseBodySize(baseBody); + fail( + `compacted release notes are still too large for GitHub: ${size.characters} characters, ${size.bytes} bytes`, + ); + } + const normalizedVerification = normalizeTail(verification); + const bodyWithVerification = joinBody(baseBody, normalizedVerification); + const verificationIncluded = + normalizedVerification !== "" && fitsGithubReleaseBody(bodyWithVerification); + const body = verificationIncluded ? bodyWithVerification : baseBody; + return { + body, + mode, + size: githubReleaseBodySize(body), + verificationIncluded, + verificationOmitted: normalizedVerification !== "" && !verificationIncluded, + }; +} + +export function verifyGithubReleaseNotes({ body, changelog, version, tag, repository }) { + const normalizedBody = body.trimEnd(); + const base = renderGithubReleaseNotes({ + changelog, + version, + tag, + repository, + }); + if (normalizedBody === base.body) { + return { + ...base, + matches: true, + actualSize: githubReleaseBodySize(normalizedBody), + }; + } + const verificationPrefix = `${base.body}\n\n${RELEASE_VERIFICATION_HEADING}`; + const verification = normalizedBody.startsWith(verificationPrefix) + ? normalizedBody.slice(base.body.length + 2) + : ""; + const expected = verification + ? renderGithubReleaseNotes({ + changelog, + version, + tag, + repository, + verification, + }) + : base; + return { + ...expected, + matches: normalizedBody === expected.body, + actualSize: githubReleaseBodySize(normalizedBody), + }; +} + +function usage() { + return `Usage: + node scripts/render-github-release-notes.mjs \\ + --changelog --tag --repository \\ + [--version ] [--verification-file ] [--output ] \\ + [--metadata-output ] +`; +} + +function parseArgs(argv) { + const options = {}; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--help" || arg === "-h") { + options.help = true; + continue; + } + if ( + arg === "--changelog" || + arg === "--version" || + arg === "--tag" || + arg === "--repository" || + arg === "--verification-file" || + arg === "--output" || + arg === "--metadata-output" + ) { + const value = argv[index + 1]; + if (!value || value.startsWith("--")) { + fail(`${arg} requires a value`); + } + const key = arg.slice(2).replace(/-([a-z])/gu, (_match, letter) => letter.toUpperCase()); + options[key] = value; + index += 1; + continue; + } + fail(`unknown argument: ${arg}`); + } + if (!options.help) { + for (const name of ["changelog", "tag", "repository"]) { + if (!options[name]) { + fail(`--${name} is required`); + } + } + if (options.metadataOutput && !options.output) { + fail("--metadata-output requires --output"); + } + } + return options; +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + process.stdout.write(usage()); + return; + } + const changelog = readFileSync(options.changelog, "utf8"); + const verification = options.verificationFile + ? readFileSync(options.verificationFile, "utf8") + : ""; + const rendered = renderGithubReleaseNotes({ + changelog, + version: options.version ?? releaseNotesVersionForTag(options.tag), + tag: options.tag, + repository: options.repository, + verification, + }); + if (options.output) { + writeFileSync(options.output, rendered.body); + if (options.metadataOutput) { + const metadata = { + mode: rendered.mode, + size: rendered.size, + verificationIncluded: rendered.verificationIncluded, + verificationOmitted: rendered.verificationOmitted, + }; + writeFileSync(options.metadataOutput, `${JSON.stringify(metadata, null, 2)}\n`); + } + process.stderr.write( + `release-notes: ${rendered.mode} body, ${rendered.size.characters} characters, ${rendered.size.bytes} bytes${ + rendered.verificationOmitted ? ", verification omitted at GitHub limit" : "" + }\n`, + ); + return; + } + process.stdout.write(rendered.body); +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} diff --git a/test/scripts/render-github-release-notes.test.ts b/test/scripts/render-github-release-notes.test.ts new file mode 100644 index 000000000000..f1575e2beb37 --- /dev/null +++ b/test/scripts/render-github-release-notes.test.ts @@ -0,0 +1,397 @@ +import { describe, expect, it } from "vitest"; +import { + GITHUB_RELEASE_BODY_MAX_BYTES, + GITHUB_RELEASE_BODY_MAX_CHARACTERS, + extractChangelogSection, + formatShippedBaselineExclusions, + parseShippedBaselineExclusions, + releaseNotesVersionForTag, + renderGithubReleaseNotes, + verifyGithubReleaseNotes, +} from "../../scripts/render-github-release-notes.mjs"; + +const repository = "openclaw/openclaw"; +const tag = "v2026.7.1-beta.3"; +const version = "2026.7.1"; + +function changelogFor(record: string): string { + return [ + "# Changelog", + "", + `## ${version}`, + "", + "### Highlights", + "", + "- A grouped user-facing highlight.", + "", + "### Fixes", + "", + "- A grouped user-facing fix.", + "", + "### Complete contribution record", + "", + record, + "", + "## 2026.6.11", + "", + "- Previous release.", + "", + ].join("\n"); +} + +describe("GitHub release-note rendering", () => { + it("emits the complete matching section including its version heading when it fits", () => { + const rendered = renderGithubReleaseNotes({ + changelog: changelogFor("- **PR #123** fix: example. Thanks @contributor."), + version, + tag, + repository, + }); + + expect(rendered.mode).toBe("full"); + expect(rendered.body).toBe( + [ + `## ${version}`, + "", + "### Highlights", + "", + "- A grouped user-facing highlight.", + "", + "### Fixes", + "", + "- A grouped user-facing fix.", + "", + "### Complete contribution record", + "", + "- **PR #123** fix: example. Thanks @contributor.", + ].join("\n"), + ); + }); + + it("replaces an oversized contribution record with a tag-pinned link", () => { + const oversizedRecord = `- **PR #123** ${"record-only-detail ".repeat(9_000)}`; + const rendered = renderGithubReleaseNotes({ + changelog: changelogFor(oversizedRecord), + version, + tag, + repository, + }); + + expect(rendered.mode).toBe("compact"); + expect(rendered.body).toContain(`## ${version}\n\n### Highlights`); + expect(rendered.body).toContain("- A grouped user-facing fix."); + expect(rendered.body).toContain("### Complete contribution record"); + expect(rendered.body).toContain( + "https://github.com/openclaw/openclaw/blob/v2026.7.1-beta.3/CHANGELOG.md#complete-contribution-record", + ); + expect(rendered.body).not.toContain("record-only-detail"); + expect(rendered.size.characters).toBeLessThanOrEqual(GITHUB_RELEASE_BODY_MAX_CHARACTERS); + expect(rendered.size.bytes).toBeLessThanOrEqual(GITHUB_RELEASE_BODY_MAX_BYTES); + }); + + it("keeps a fitting full section and omits only a proof tail that would overflow", () => { + const nearlyFullRecord = `- **PR #123** ${"x".repeat(124_500)}`; + const changelog = changelogFor(nearlyFullRecord); + const withoutProof = renderGithubReleaseNotes({ + changelog, + version, + tag, + repository, + }); + const withProof = renderGithubReleaseNotes({ + changelog, + version, + tag, + repository, + verification: `### Release verification\n\n- proof: ${"y".repeat(1_000)}`, + }); + + expect(withoutProof.mode).toBe("full"); + expect(withProof.mode).toBe("full"); + expect(withProof.verificationIncluded).toBe(false); + expect(withProof.verificationOmitted).toBe(true); + expect(withProof.body).toBe(withoutProof.body); + expect(withProof.body).not.toContain("### Release verification"); + expect(withProof.size.characters).toBeLessThanOrEqual(GITHUB_RELEASE_BODY_MAX_CHARACTERS); + expect(withProof.size.bytes).toBeLessThanOrEqual(GITHUB_RELEASE_BODY_MAX_BYTES); + }); + + it("uses the full form at exactly 125,000 bytes and compacts at 125,001", () => { + const seed = renderGithubReleaseNotes({ + changelog: changelogFor("x"), + version, + tag, + repository, + }); + const exactRecordLength = + GITHUB_RELEASE_BODY_MAX_BYTES - seed.size.bytes + Buffer.byteLength("x"); + const exact = renderGithubReleaseNotes({ + changelog: changelogFor("x".repeat(exactRecordLength)), + version, + tag, + repository, + }); + const over = renderGithubReleaseNotes({ + changelog: changelogFor("x".repeat(exactRecordLength + 1)), + version, + tag, + repository, + }); + + expect(exact.mode).toBe("full"); + expect(exact.size).toEqual({ + characters: GITHUB_RELEASE_BODY_MAX_CHARACTERS, + bytes: GITHUB_RELEASE_BODY_MAX_BYTES, + }); + expect(over.mode).toBe("compact"); + }); + + it("compacts when multibyte text exceeds the byte limit before the character limit", () => { + const rendered = renderGithubReleaseNotes({ + changelog: changelogFor("é".repeat(63_000)), + version, + tag, + repository, + }); + + expect(rendered.mode).toBe("compact"); + expect(rendered.size.bytes).toBeLessThanOrEqual(GITHUB_RELEASE_BODY_MAX_BYTES); + expect(rendered.size.characters).toBeLessThanOrEqual(GITHUB_RELEASE_BODY_MAX_CHARACTERS); + }); + + it("normalizes correction tags to the stable changelog section", () => { + expect(releaseNotesVersionForTag("v2026.7.1-2")).toBe("2026.7.1"); + const rendered = renderGithubReleaseNotes({ + changelog: changelogFor("- **PR #123** fix: correction."), + version, + tag: "v2026.7.1-2", + repository, + }); + + expect(rendered.body).toContain("## 2026.7.1"); + }); + + it("prefers a correction tag's dedicated changelog section when one exists", () => { + const changelog = [ + "# Changelog", + "", + "## 2026.7.1-2", + "", + "- Correction-only fix.", + "", + `## ${version}`, + "", + "- Stable release notes.", + "", + ].join("\n"); + const rendered = renderGithubReleaseNotes({ + changelog, + version, + tag: "v2026.7.1-2", + repository, + }); + + expect(rendered.body).toContain("## 2026.7.1-2"); + expect(rendered.body).toContain("Correction-only fix."); + expect(rendered.body).not.toContain("Stable release notes."); + }); + + it("round-trips canonical shipped baseline exclusions and rejects malformed metadata", () => { + const line = formatShippedBaselineExclusions([ + { ref: "v2026.6.11", count: 2, pullRequests: [108, 101] }, + { ref: "v2026.6.10-beta.2", count: 0, pullRequests: [] }, + ]); + + expect(line).toBe( + "Shipped baseline exclusions: v2026.6.10-beta.2 (0 PRs); v2026.6.11 (2 PRs: #101, #108).", + ); + expect(parseShippedBaselineExclusions(line)).toEqual([ + { ref: "v2026.6.10-beta.2", count: 0, pullRequests: [] }, + { ref: "v2026.6.11", count: 2, pullRequests: [101, 108] }, + ]); + expect(() => + parseShippedBaselineExclusions("Shipped baseline exclusion: v2026.6.11 (8 PRs)."), + ).toThrow("malformed shipped baseline exclusion"); + expect(() => + parseShippedBaselineExclusions("Shipped baseline exclusions: v2026.6.11 (2 PRs: #101)."), + ).toThrow("invalid shipped baseline exclusion count"); + }); + + it("rejects tag/version drift and legacy oversized ledger anchors", () => { + expect(() => + renderGithubReleaseNotes({ + changelog: changelogFor("- **PR #123** fix: example."), + version, + tag: "v2026.7.2-beta.1", + repository, + }), + ).toThrow("requires CHANGELOG.md version 2026.7.2"); + + expect(() => + renderGithubReleaseNotes({ + changelog: changelogFor( + `### Complete contribution ledger\n\n${"legacy ".repeat(20_000)}`, + ).replace("### Complete contribution record\n\n", ""), + version, + tag, + repository, + }), + ).toThrow("cannot be compacted without a complete contribution record"); + }); + + it("prefers an alpha tag's exact changelog heading over the Unreleased fallback", () => { + // Shipped alpha tags carry their own heading with no base section, + // matching the tagged CHANGELOG.md shape of v2026.6.20-alpha.1. + const changelog = [ + "# Changelog", + "", + "## 2026.7.1-alpha.2", + "", + "- Alpha-only fix.", + "", + "## 2026.6.11", + "", + "- Previous release.", + "", + ].join("\n"); + const rendered = renderGithubReleaseNotes({ + changelog, + version, + tag: "v2026.7.1-alpha.2", + repository, + }); + + expect(rendered.body).toContain("## 2026.7.1-alpha.2"); + expect(rendered.body).toContain("Alpha-only fix."); + expect(rendered.body).not.toContain("Previous release."); + }); + + it("permits the Unreleased fallback only for alpha tags", () => { + const changelog = changelogFor("- **PR #123** fix: example.").replace( + "## 2026.7.1", + "## Unreleased", + ); + const rendered = renderGithubReleaseNotes({ + changelog, + version, + tag: "v2026.7.1-alpha.1", + repository, + }); + + expect(rendered.body).toContain("## 2026.7.1"); + expect(rendered.body).not.toContain("## Unreleased"); + expect(() => + renderGithubReleaseNotes({ + changelog, + version, + tag, + repository, + }), + ).toThrow("CHANGELOG.md does not contain ## 2026.7.1"); + expect(() => + renderGithubReleaseNotes({ + changelog, + version: "foo", + tag: "vfoo-alpha.1", + repository, + }), + ).toThrow("invalid release tag"); + }); + + it("ignores fenced pseudo-headings and handles a release heading at EOF", () => { + const fenced = [ + `## ${version}`, + "", + "```md", + "## 2099.1.1", + "```", + "", + "### Fixes", + "", + "- Still in the current release.", + "", + "## 2026.6.11", + ].join("\n"); + + expect(extractChangelogSection(fenced, version)).toContain("- Still in the current release."); + expect(extractChangelogSection(`## ${version}`, version)).toBe(`## ${version}`); + }); + + it("compacts at the real contribution record instead of a fenced pseudo-heading", () => { + const changelog = changelogFor(`- **PR #123** ${"record-only-detail ".repeat(9_000)}`).replace( + "### Fixes", + ["```md", "### Complete contribution record", "```", "", "### Fixes"].join("\n"), + ); + const rendered = renderGithubReleaseNotes({ changelog, version, tag, repository }); + + expect(rendered.mode).toBe("compact"); + expect(rendered.body).toContain("```md\n### Complete contribution record\n```"); + expect(rendered.body).toContain("- A grouped user-facing fix."); + expect(rendered.body).not.toContain("record-only-detail"); + }); + + it("verifies the exact generated compact body and optional proof tail", () => { + const changelog = changelogFor(`- **PR #123** ${"z".repeat(130_000)}`); + const verification = "### Release verification\n\n- release SHA: `abc123`"; + const rendered = renderGithubReleaseNotes({ + changelog, + version, + tag, + repository, + verification, + }); + + expect( + verifyGithubReleaseNotes({ + body: rendered.body, + changelog, + version, + tag, + repository, + }), + ).toMatchObject({ matches: true, mode: "compact" }); + expect( + verifyGithubReleaseNotes({ + body: rendered.body.replace("tag-pinned", "mutable"), + changelog, + version, + tag, + repository, + }).matches, + ).toBe(false); + expect( + verifyGithubReleaseNotes({ + body: rendered.body, + changelog, + version, + tag: "v2026.7.1-beta.2", + repository, + }).matches, + ).toBe(false); + }); + + it("does not treat fenced verification headings as appended proof", () => { + const changelog = changelogFor( + [ + "```md", + "### Release verification", + "", + "- Example only.", + "```", + "", + "- **PR #123** fix: example.", + ].join("\n"), + ); + const rendered = renderGithubReleaseNotes({ changelog, version, tag, repository }); + + expect( + verifyGithubReleaseNotes({ + body: rendered.body, + changelog, + version, + tag, + repository, + }), + ).toMatchObject({ matches: true, verificationIncluded: false }); + }); +});