From 1417632f735683ed4ff2424d6eddcf37fdc388ef Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 27 Aug 2026 09:51:29 +0200 Subject: [PATCH] fix(release): act on the second Codex pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four P2 findings, each with a regression test: - The authored-body check could never fail. The tag workflow appends GitHub's generated notes to the authored text (FULL_BODY in build-and-make.yaml), so `release.body` is never empty and testing it for emptiness proved nothing. It now compares the body against the local CHANGELOG.md public section — the actual authored source — and reports an internal-only release as such instead of warning about it. - A release with no highlights failed the documented card step, which necessarily included every internal-only release since validation forbids highlights there. Now the hero card is still rendered when public notes exist, an internal-only release writes nothing, and both exit 0. - Card output is keyed by the exact version rather than the minor slug (0.24.0 and 0.24.1 share a blog post but not a card set), and a run first removes the cards a previous run wrote there, so a renamed or dropped highlight cannot leave a stale image waiting to be published. Only files matching what this tool writes are removed. - Telegram could still truncate breaking changes into the counter when a release had no highlights, since the fitting loop trims the tail. Dropping a breaking entry now fails with an actionable error. Docs: docs/architecture/release-pipeline.md, .changes/README.md, CLAUDE.md and AGENTS.md updated for the new output path and the no-highlight and internal-only behavior. 180 tests passing. Co-Authored-By: Claude Fable 5 --- .changes/README.md | 8 ++- AGENTS.md | 2 +- CLAUDE.md | 2 +- docs/architecture/release-pipeline.md | 27 ++++++-- tools/release/generate-highlight-cards.mjs | 50 +++++++++++++-- tools/release/highlight-cards.mjs | 14 ++++- tools/release/highlight-cards.test.mjs | 66 +++++++++++++++++++- tools/release/release-announcements.mjs | 36 +++++++---- tools/release/release-announcements.test.mjs | 17 +++++ tools/release/verify-draft-release.mjs | 63 +++++++++++++++++-- tools/release/verify-draft-release.test.mjs | 64 +++++++++++++++++-- 11 files changed, 311 insertions(+), 38 deletions(-) diff --git a/.changes/README.md b/.changes/README.md index b6df9a890..02ca9addb 100644 --- a/.changes/README.md +++ b/.changes/README.md @@ -148,9 +148,11 @@ named action in `tools/release/capture-navigation.ts`). names one) plus a release hero card — for Telegram/Reddit previews and the blog `hero.jpg`. It reads screenshots from the published blog directory, so it runs after `release:screenshots` and, like the announcement formats, before -`--consume`. Output goes to `dist/release-highlight-cards//`; copying a -card into the website tree is a deliberate manual act. -`release:cards:dry-run` lists what would be rendered. +`--consume`. Output goes to `dist/release-highlight-cards/v/`, and a +rerun replaces the cards it previously wrote there; copying a card into the +website tree is a deliberate manual act. `release:cards:dry-run` lists what +would be rendered. A release without highlights still gets its hero card, and +an internal-only release writes nothing — neither is an error. ```bash pnpm nx run electron-backend:build-e2e # once, before capturing diff --git a/AGENTS.md b/AGENTS.md index 1a5b8d9fc..c1f5ed52e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,7 +71,7 @@ This file provides guidance to coding agents working in this repository. - CI enforces this: the "Release note gate" job in `.github/workflows/ci.yml` fails PRs that change runtime code without an added `.changes/*.md` or the label (policy in `tools/release/check-release-note-gate.mjs`; tests/e2e/website/mock-server/docs paths are auto-exempt). - The `release-notes` skill covers writing notes; the `release-cut` skill covers the full release sequence. Canonical contract — surfaces, ordering constraints, the required draft asset set: `docs/architecture/release-pipeline.md`. - Validate before finishing: `pnpm run release:notes:validate`. -- Announcement drafts and highlight cards are built from the same notes: `pnpm run release:notes:telegram` and `pnpm run release:notes:reddit` print paste-ready posts to stdout (Telegram is guaranteed to fit its 4096-character limit), and `pnpm run release:cards:generate` renders branded 1200×630 highlight cards plus a release hero into `dist/release-highlight-cards//`. All three read `highlight:` metadata that exists only in the note files, so they must run before `build-release-notes.mjs --consume`; the cards additionally need `release:screenshots` to have run. Nothing is posted or copied into the website tree automatically. +- Announcement drafts and highlight cards are built from the same notes: `pnpm run release:notes:telegram` and `pnpm run release:notes:reddit` print paste-ready posts to stdout (Telegram is guaranteed to fit its 4096-character limit), and `pnpm run release:cards:generate` renders branded 1200×630 highlight cards plus a release hero into `dist/release-highlight-cards/v/`. All three read `highlight:` metadata that exists only in the note files, so they must run before `build-release-notes.mjs --consume`; the cards additionally need `release:screenshots` to have run. Nothing is posted or copied into the website tree automatically. - Pushes to `master` and `v*` can publish Docker images. A `v*` tag build creates a draft GitHub release. - `pnpm run release:verify:draft` waits for that tag build (polling until the run is indexed, then `gh run watch`) and verifies the draft's status, authored body, and complete required asset set. It is read-only and deliberately fails on an already-published release, because it is the gate that runs before publication. - Publishing the GitHub release verifies its Snap assets and automatically uploads them to `edge`; installed-Snap smoke and candidate/stable promotion remain manual. diff --git a/CLAUDE.md b/CLAUDE.md index b95fe6998..579acf0b1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,7 +37,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - CI enforces this: the "Release note gate" job in `.github/workflows/ci.yml` fails PRs that change runtime code without an added `.changes/*.md` or the label (policy in `tools/release/check-release-note-gate.mjs`; tests/e2e/website/mock-server/docs paths are auto-exempt). - The `release-notes` skill covers writing notes; the `release-cut` skill covers the full release sequence. Canonical contract — surfaces, ordering constraints, the required draft asset set: `docs/architecture/release-pipeline.md`. - Validate before finishing: `pnpm run release:notes:validate`. -- Announcement drafts and highlight cards are built from the same notes: `pnpm run release:notes:telegram` and `pnpm run release:notes:reddit` print paste-ready posts to stdout (Telegram is guaranteed to fit its 4096-character limit), and `pnpm run release:cards:generate` renders branded 1200×630 highlight cards plus a release hero into `dist/release-highlight-cards//`. All three read `highlight:` metadata that exists only in the note files, so they must run before `build-release-notes.mjs --consume`; the cards additionally need `release:screenshots` to have run. Nothing is posted or copied into the website tree automatically. +- Announcement drafts and highlight cards are built from the same notes: `pnpm run release:notes:telegram` and `pnpm run release:notes:reddit` print paste-ready posts to stdout (Telegram is guaranteed to fit its 4096-character limit), and `pnpm run release:cards:generate` renders branded 1200×630 highlight cards plus a release hero into `dist/release-highlight-cards/v/`. All three read `highlight:` metadata that exists only in the note files, so they must run before `build-release-notes.mjs --consume`; the cards additionally need `release:screenshots` to have run. Nothing is posted or copied into the website tree automatically. - Pushes to `master` and `v*` can publish Docker images. A `v*` tag build creates a draft GitHub release. - `pnpm run release:verify:draft` waits for that tag build (polling until the run is indexed, then `gh run watch`) and verifies the draft's status, authored body, and complete required asset set. It is read-only and deliberately fails on an already-published release, because it is the gate that runs before publication. - Publishing the GitHub release verifies its Snap assets and automatically uploads them to `edge`; installed-Snap smoke and candidate/stable promotion remain manual. diff --git a/docs/architecture/release-pipeline.md b/docs/architecture/release-pipeline.md index 8add68cda..97028a772 100644 --- a/docs/architecture/release-pipeline.md +++ b/docs/architecture/release-pipeline.md @@ -51,7 +51,8 @@ Highlights drive three behaviors: - **Telegram** leads with them and folds everything else into a "…plus N more" counter. A `type: breaking` note is never folded, highlighted or not: announcing a breaking change as "fixes and improvements" is worse than a - longer post. + longer post. If the breaking changes alone cannot fit the 4096-character + limit, the render fails with an actionable error rather than dropping one. - **Reddit** gives each one an `## Highlights` subsection, with the remaining changes grouped below. - **The blog scaffold** uses the highlight as a ready `###` heading instead of @@ -90,8 +91,16 @@ Screenshots come only from the capture script running against the mock servers. Never publish one taken from a real playlist or account — streams, logos and metadata are copyrighted, and credentials must never reach a published image. -Output lands in `dist/`, outside version control. Copying a card into the -website tree is a deliberate manual act. +Output lands in `dist/release-highlight-cards/v/`, outside version +control — keyed by the exact version, because 0.24.0 and 0.24.1 share a blog +post but not a card set. A run first removes the cards a previous run left in +that directory (only files matching what this tool writes), so a renamed or +dropped highlight cannot leave a stale image waiting to be published. Copying a +card into the website tree is a deliberate manual act. + +A release with no `highlight:` notes is not an error: the hero card is still +rendered and the run exits 0. An internal-only release has nothing public to +put on a card and exits 0 having written nothing. ## Draft verification @@ -109,9 +118,15 @@ never publishes, edits or deletes. fails immediately. A missing `gh` binary and an interrupted watch are reported as themselves, not as a build failure — `spawnSync` surfaces both as `status: null`. -3. **Check the release.** Draft status, a non-empty authored body (empty is a - warning, since an internal-only release legitimately has one), and the - complete asset set below. +3. **Check the release.** Draft status, the authored body, and the complete + asset set below. + +The authored-body check compares the release body against the **local +`CHANGELOG.md` section**, not against emptiness. The tag workflow appends +GitHub's generated notes to the authored text (`FULL_BODY` in +`build-and-make.yaml`), so the body is never empty and an emptiness test could +never fail. An internal-only release, whose public section is legitimately +empty, is reported as such rather than warned about. An already-published release still gets its asset report — auditing one after the fact is useful — but **never a success exit**. Reporting a pass for a diff --git a/tools/release/generate-highlight-cards.mjs b/tools/release/generate-highlight-cards.mjs index e775529af..85c788176 100644 --- a/tools/release/generate-highlight-cards.mjs +++ b/tools/release/generate-highlight-cards.mjs @@ -14,7 +14,14 @@ * committing a card into the website tree is a deliberate manual act. */ -import { existsSync, mkdirSync, readFileSync, realpathSync } from 'node:fs'; +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + realpathSync, + rmSync, +} from 'node:fs'; import path from 'node:path'; import process from 'node:process'; import { fileURLToPath } from 'node:url'; @@ -25,6 +32,7 @@ import { buildHeroCardSvg, buildShotMaskSvg, CARD_HEIGHT, + isOwnedCardFile, SHOT_LEFT, SHOT_TOP, SHOT_WIDTH, @@ -141,6 +149,16 @@ async function prepareShotOverlay(screenshotPath) { export async function renderCards(plan, version, outputDir) { mkdirSync(outputDir, { recursive: true }); + // Remove the cards a previous run of THIS generator left behind, so a + // renamed or dropped highlight cannot leave a stale image sitting beside + // the current set waiting to be published. Only files matching what this + // tool writes are touched; anything else in the directory is left alone. + for (const entry of readdirSync(outputDir)) { + if (isOwnedCardFile(entry)) { + rmSync(path.join(outputDir, entry)); + } + } + const written = []; for (const job of plan.feature) { @@ -206,11 +224,21 @@ async function main() { theme: options.theme, }); + // Neither shape below is an error: an internal-only release is legal, and + // a release nobody marked a highlight on still deserves its hero card. + // Failing here would break the documented release sequence. + if (plan.publicNoteCount === 0) { + console.error( + 'Internal-only release: no public change to put on a card.' + ); + + return; + } + if (plan.feature.length === 0) { console.error( - 'No `highlight:` notes found — mark the headline changes in .changes/ first.' + 'No `highlight:` notes found — rendering the hero card only. Mark the headline changes in .changes/ to get feature cards.' ); - process.exit(1); } const missingShots = plan.feature.filter( @@ -228,9 +256,13 @@ async function main() { process.exit(1); } + // Keyed by the exact version, not the minor slug: 0.24.0 and 0.24.1 share + // a blog post but not a card set, and mixing them in one directory invites + // publishing the previous patch's card. const outputDir = path.resolve( workspaceRoot, - options.out ?? path.join('dist/release-highlight-cards', slug) + options.out ?? + path.join('dist/release-highlight-cards', `v${options.version}`) ); console.log(`${plan.feature.length} highlight card(s) + hero for v${options.version}:`); @@ -248,6 +280,16 @@ async function main() { return; } + if (existsSync(outputDir)) { + const stale = readdirSync(outputDir).filter(isOwnedCardFile); + + if (stale.length > 0) { + console.log( + `Replacing ${stale.length} card(s) from a previous run in the same directory.` + ); + } + } + const written = await renderCards(plan, options.version, outputDir); for (const file of written) { diff --git a/tools/release/highlight-cards.mjs b/tools/release/highlight-cards.mjs index 3fef9c768..149b9152a 100644 --- a/tools/release/highlight-cards.mjs +++ b/tools/release/highlight-cards.mjs @@ -110,7 +110,7 @@ export function wrapText(text, maxChars, maxLines) { * * @param {object[]} notes parsed `.changes` notes * @param {{ version: string, releaseSlug: string, screenshotsDir: string, theme: string }} options - * @returns {{ feature: object[], hero: object }} + * @returns {{ feature: object[], publicNoteCount: number, hero: object }} */ export function planHighlightCards(notes, options) { const { version, releaseSlug, screenshotsDir, theme } = options; @@ -147,6 +147,9 @@ export function planHighlightCards(notes, options) { return { feature, + // Public notes, not total: an internal-only release has nothing to put + // on a card, which is a legal release shape rather than an error. + publicNoteCount: ordered.length, hero: { fileName: 'hero.png', version, @@ -157,6 +160,15 @@ export function planHighlightCards(notes, options) { }; } +/** Files this generator owns in an output directory. */ +export function isOwnedCardFile(fileName) { + return ( + /^card-[a-z0-9-]+\.png$/.test(fileName) || + fileName === 'hero.png' || + fileName === 'hero.jpg' + ); +} + function backgroundDefs() { return [ '', diff --git a/tools/release/highlight-cards.test.mjs b/tools/release/highlight-cards.test.mjs index c141c0d3e..c82cb2415 100644 --- a/tools/release/highlight-cards.test.mjs +++ b/tools/release/highlight-cards.test.mjs @@ -1,5 +1,12 @@ import assert from 'node:assert/strict'; -import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + rmSync, + writeFileSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { after, describe, it } from 'node:test'; @@ -11,6 +18,7 @@ import { CARD_HEIGHT, CARD_WIDTH, escapeXml, + isOwnedCardFile, planHighlightCards, SHOT_TOP, TEXT_BOTTOM, @@ -153,6 +161,38 @@ describe('planHighlightCards', () => { ); }); + it('reports the public note count so an internal-only release is detectable', () => { + assert.equal( + planHighlightCards( + [note({ type: 'internal', body: 'Churn.' })], + planOptions + ).publicNoteCount, + 0 + ); + assert.equal( + planHighlightCards( + [note(), note({ type: 'internal', body: 'Churn.' })], + planOptions + ).publicNoteCount, + 1 + ); + }); + + it('claims only the files it writes', () => { + for (const owned of ['card-playback-up-next.png', 'hero.png', 'hero.jpg']) { + assert.equal(isOwnedCardFile(owned), true, owned); + } + + for (const foreign of [ + 'notes.txt', + 'card-Upper.png', + 'screenshot-dashboard-dark.png', + 'hero.webp', + ]) { + assert.equal(isOwnedCardFile(foreign), false, foreign); + } + }); + it('never plans a card for internal notes', () => { const plan = planHighlightCards( [note({ type: 'internal', highlight: 'Nope' })], @@ -359,4 +399,28 @@ describe('renderCards', () => { assert.equal(metadata.height, CARD_HEIGHT, file); } }); + + it('removes its own stale cards but leaves other files alone', async () => { + const outputDir = path.join(makeTempDir(), 'cards'); + + mkdirSync(outputDir, { recursive: true }); + writeFileSync(path.join(outputDir, 'card-removed-feature.png'), 'old'); + writeFileSync(path.join(outputDir, 'notes.txt'), 'keep me'); + + const plan = planHighlightCards( + [note({ highlight: 'Faster imports' })], + planOptions + ); + + await renderCards(plan, '0.24.0', outputDir); + + const remaining = readdirSync(outputDir).sort(); + + assert.deepEqual(remaining, [ + 'card-playback-up-next.png', + 'hero.jpg', + 'hero.png', + 'notes.txt', + ]); + }); }); diff --git a/tools/release/release-announcements.mjs b/tools/release/release-announcements.mjs index 1bd7d4787..2bffbd216 100644 --- a/tools/release/release-announcements.mjs +++ b/tools/release/release-announcements.mjs @@ -120,21 +120,35 @@ export function renderTelegramPost(notes, { version }) { .join('\n\n'); }; - // Drop trailing entries into the counter until the post fits. Highlights - // are hand-picked, so running out of room means the release manager chose - // too many — say so instead of silently cutting a chosen highlight. + // Drop trailing entries into the counter until the post fits. Two classes + // of entry are never allowed to fall in there: hand-picked highlights + // (the release manager chose too many) and breaking changes (a warning + // silently reported as "fixes and improvements" is worse than no post). for (let visible = lead.length; visible > 0; visible -= 1) { const post = buildPost(lead.slice(0, visible)); - if (post.length <= TELEGRAM_MESSAGE_LIMIT) { - if (leadIsHighlights && visible < lead.length) { - throw new Error( - `the ${lead.length} highlights do not fit Telegram's ${TELEGRAM_MESSAGE_LIMIT}-character limit — pick fewer or shorten their notes` - ); - } - - return post; + if (post.length > TELEGRAM_MESSAGE_LIMIT) { + continue; } + + const dropped = lead.slice(visible); + const droppedBreaking = dropped.filter( + (note) => note.type === 'breaking' + ).length; + + if (droppedBreaking > 0) { + throw new Error( + `${droppedBreaking} breaking change(s) do not fit Telegram's ${TELEGRAM_MESSAGE_LIMIT}-character limit — shorten those notes, or announce this release in several posts` + ); + } + + if (leadIsHighlights && dropped.length > 0) { + throw new Error( + `the ${lead.length} highlights do not fit Telegram's ${TELEGRAM_MESSAGE_LIMIT}-character limit — pick fewer or shorten their notes` + ); + } + + return post; } throw new Error( diff --git a/tools/release/release-announcements.test.mjs b/tools/release/release-announcements.test.mjs index c106b5372..928ac867b 100644 --- a/tools/release/release-announcements.test.mjs +++ b/tools/release/release-announcements.test.mjs @@ -144,6 +144,23 @@ describe('renderTelegramPost', () => { assert.doesNotMatch(post, /Stalker resume/); }); + it('refuses to fold a breaking change into the counter to make room', () => { + // No highlights, so the fitting loop is what truncates. A breaking + // change reported as "fixes and improvements" is not an option. + const notes = Array.from({ length: 14 }, (_, index) => + note({ + type: 'breaking', + body: `Breaking change ${index}: ${'detail '.repeat(50)}.`, + sourcePath: `.changes/breaking-${index}.md`, + }) + ); + + assert.throws( + () => renderTelegramPost(notes, { version: '0.24.0' }), + /breaking change\(s\) do not fit Telegram's 4096-character limit/ + ); + }); + it('returns null for an internal-only release instead of throwing', () => { assert.equal( renderTelegramPost([note({ type: 'internal' })], { diff --git a/tools/release/verify-draft-release.mjs b/tools/release/verify-draft-release.mjs index 5f4884cd0..2d5485d44 100644 --- a/tools/release/verify-draft-release.mjs +++ b/tools/release/verify-draft-release.mjs @@ -23,6 +23,7 @@ import path from 'node:path'; import process from 'node:process'; import { fileURLToPath } from 'node:url'; +import { extractPublicSection } from './extract-changelog-section.mjs'; import { REPO_URL } from './release-notes.mjs'; const workspaceRoot = path.resolve( @@ -212,6 +213,50 @@ async function findTagRun({ repo, branch }, io) { return null; } +/** + * The tag workflow appends GitHub's generated notes to the authored text + * (`FULL_BODY` in build-and-make.yaml), so a non-empty `body` proves nothing + * about the authored half — testing it for emptiness could never fail. The + * authored text is the CHANGELOG section this repo committed before tagging, + * so compare against that instead. + * + * @param {string} body the release body as published + * @param {string} version + * @param {{ readChangelog: Function }} io + * @returns {string[]} report lines + */ +function verifyAuthoredBody(body, version, io) { + const changelog = io.readChangelog(); + + if (changelog === null) { + return [ + 'NOTE: CHANGELOG.md is unreadable here, so the authored body was not verified.', + ]; + } + + const authored = extractPublicSection(changelog, version); + + if (authored === null) { + return [ + `WARNING: CHANGELOG.md has no section for ${version} — the tag build authors the body from it.`, + ]; + } + + if (authored === '') { + return [ + 'NOTE: internal-only release — no authored body is expected, only generated notes.', + ]; + } + + const normalize = (text) => text.replace(/\r\n/g, '\n').trim(); + + return normalize(body).includes(normalize(authored)) + ? ['Authored changelog section present in the release body.'] + : [ + 'WARNING: the release body does not contain the authored CHANGELOG section — it may carry only GitHub-generated notes.', + ]; +} + /** * Verification pipeline over an injectable gh boundary, so tests never touch * the network. `io.watchRun` streams `gh run watch` to the terminal and throws @@ -220,7 +265,7 @@ async function findTagRun({ repo, branch }, io) { * `io.sleep` paces the run poll. * * @param {{ version: string, wait: boolean, repo: string }} options - * @param {{ listRuns: Function, watchRun: Function, viewRelease: Function, progress: Function, sleep: Function }} io + * @param {{ listRuns: Function, watchRun: Function, viewRelease: Function, readChangelog: Function, progress: Function, sleep: Function }} io * @returns {Promise<{ exitCode: number, lines: string[] }>} */ export async function runVerification(options, io) { @@ -270,11 +315,7 @@ export async function runVerification(options, io) { : `Draft release ${tag} found.` ); - if (!release.body?.trim()) { - lines.push( - 'WARNING: authored release body is empty (expected only for an internal-only release).' - ); - } + lines.push(...verifyAuthoredBody(release.body ?? '', version, io)); const assetNames = release.assets.map((asset) => asset.name); const { missing, extras } = verifyReleaseAssets(assetNames, version); @@ -380,6 +421,16 @@ const liveIo = { throw error; } }, + readChangelog: () => { + try { + return readFileSync( + path.join(workspaceRoot, 'CHANGELOG.md'), + 'utf8' + ); + } catch { + return null; + } + }, progress: (message) => console.error(message), // Deliberately not unref'd: a pending promise does not hold the event // loop open, so an unref'd timer would let Node exit mid-poll — and an diff --git a/tools/release/verify-draft-release.test.mjs b/tools/release/verify-draft-release.test.mjs index b5d7de7d6..7232ce158 100644 --- a/tools/release/verify-draft-release.test.mjs +++ b/tools/release/verify-draft-release.test.mjs @@ -47,7 +47,9 @@ function release(overrides = {}) { return { name: 'v0.24.0', isDraft: true, - body: '### Features\n\n- entry', + // Shaped like the real thing: authored section, then GitHub's + // generated notes appended by the tag workflow. + body: '### Features\n\n- **playback** — Up Next rail.\n\n## What\'s Changed\n* chore by @bot in #1\n', assets: completeAssets('0.24.0').map((name) => ({ name })), ...overrides, }; @@ -66,6 +68,8 @@ function io(overrides = {}) { throw new Error('watchRun must not be called'); }, viewRelease: () => release(), + readChangelog: () => + '# Changelog\n\n\n\n# 0.24.0 (2026-08-01)\n\n### Features\n\n- **playback** — Up Next rail.\n', progress: (message) => progressLog.push(message), progressLog, sleep: () => Promise.resolve(), @@ -343,14 +347,66 @@ describe('runVerification', () => { ); }); - it('warns about an empty authored body', async () => { + it('confirms the authored changelog section inside the combined body', async () => { + const result = await runVerification(options, io()); + + assert.equal(result.exitCode, 0); + assert.match( + result.lines[1], + /Authored changelog section present in the release body\./ + ); + }); + + it('warns when the body carries only GitHub-generated notes', async () => { + // The tag workflow appends generated notes to the authored text, so a + // non-empty body proves nothing — this is the case an emptiness check + // could never catch. const result = await runVerification( options, - io({ viewRelease: () => release({ body: ' ' }) }) + io({ + viewRelease: () => + release({ body: "## What's Changed\n* chore by @bot in #1" }), + }) ); assert.equal(result.exitCode, 0); - assert.match(result.lines[1], /WARNING: authored release body is empty/); + assert.match( + result.lines[1], + /does not contain the authored CHANGELOG section/ + ); + }); + + it('expects no authored body for an internal-only release', async () => { + const result = await runVerification( + options, + io({ + readChangelog: () => + '# 0.24.0 (2026-08-01)\n\n
\nInternal changes\n\n- **deps** — bump.\n\n
\n', + viewRelease: () => + release({ body: "## What's Changed\n* chore by @bot in #1" }), + }) + ); + + assert.equal(result.exitCode, 0); + assert.match(result.lines[1], /internal-only release/); + }); + + it('warns when the changelog has no section for the version', async () => { + const result = await runVerification( + options, + io({ readChangelog: () => '# Changelog\n\n\n' }) + ); + + assert.match(result.lines[1], /CHANGELOG\.md has no section for 0\.24\.0/); + }); + + it('does not claim to have verified an unreadable changelog', async () => { + const result = await runVerification( + options, + io({ readChangelog: () => null }) + ); + + assert.match(result.lines[1], /NOTE: CHANGELOG\.md is unreadable/); }); it('notes unrecognized assets without failing', async () => {