fix(release): act on the second Codex pass

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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-08-27 09:51:29 +02:00
1 parent 3732affbb2
commit 1417632f73
11 files changed
+311 -38

No files matched your search

+5 -3
View File
@@ -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/<vX-Y>/`; 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<version>/`, 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
+1 -1
View File
@@ -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/<vX-Y>/`. 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<version>/`. 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.
+1 -1
View File
@@ -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/<vX-Y>/`. 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<version>/`. 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.
+21 -6
View File
@@ -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<version>/`, 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
+46 -4
View File
@@ -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) {
+13 -1
View File
@@ -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 [
'<defs>',
+65 -1
View File
@@ -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',
]);
});
});
+25 -11
View File
@@ -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(
@@ -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' })], {
+57 -6
View File
@@ -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
+60 -4
View File
@@ -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<!-- next-release -->\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<details>\n<summary>Internal changes</summary>\n\n- **deps** — bump.\n\n</details>\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<!-- next-release -->\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 () => {