mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 01:56:16 -08:00
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:
1 parent
3732affbb2
commit
1417632f73
11 files changed
+311
-38
No files matched your search
+5
-3
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>',
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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' })], {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user