diff --git a/.changes/README.md b/.changes/README.md index dcf8e4b35..a73a5972b 100644 --- a/.changes/README.md +++ b/.changes/README.md @@ -104,6 +104,9 @@ text guaranteed to fit the 4096-character limit, Reddit markdown with a suggested post title on the first line. Render and save them **before** `--consume` — the changelog keeps the entries, but the `highlight:` metadata lives only in the note files. Publishing is manual; nothing posts anywhere. +An internal-only release has nothing to announce: both formats then print an +explanation on stderr, leave stdout empty, and exit 0 — the same shape +`extract-changelog-section.mjs --public` uses for its empty public body. The release sequence is: bump the version → `release:notes:changelog` → `release:notes:blog` → `--consume` → commit → tag → push. The tag build then diff --git a/AGENTS.md b/AGENTS.md index 964f5274f..e45dbd3b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,11 +66,14 @@ This file provides guidance to coding agents working in this repository. - Name it `-.md`; `area` matches the conventional-commit scope. There is no version field — the release version is chosen at release time. - Write the body for a user, not a reviewer: "the player now remembers volume between episodes", not "hoist volume state into the session". Max 400 characters; depth belongs in the release blog post. - `type: internal` records invisible maintenance. Internal notes stay collapsed in `CHANGELOG.md`, are omitted from the blog scaffold, and are removed from the authored public GitHub body by `extract-changelog-section.mjs --public`; GitHub's generated commit list remains separate, so an internal-only release can have an empty authored body. +- `highlight: ` (max 80 characters, rejected on `type: internal`) marks a note as one of the release's two or three headline changes. Highlights lead the Telegram/Reddit announcement drafts, become ready-made blog section headings, and are the input the highlight-card generator renders from. A release where everything is a highlight has none. - Skip the note for test-only changes, docs, CI/workflow plumbing, and pure refactors with no behavior change. When skipping on a PR that touches `apps/**` or `libs/**`, apply the `no-release-note` label. - 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. - 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. - 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. - Release-post screenshots come only from the release capture script running against the mock servers. Never add a screenshot taken from a real playlist or account to `apps/website/public/blog/**` — real streams, logos, and metadata are copyrighted, and credentials must never reach a published image. - Final task summaries should state whether a release note was added or why it was skipped. diff --git a/CLAUDE.md b/CLAUDE.md index ab4c7e62d..e7109e7c5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,11 +32,14 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - Name it `-.md`; `area` matches the conventional-commit scope. There is no version field — the release version is chosen at release time. - Write the body for a user, not a reviewer: "the player now remembers volume between episodes", not "hoist volume state into the session". Max 400 characters; depth belongs in the release blog post. - `type: internal` records invisible maintenance. Internal notes stay collapsed in `CHANGELOG.md`, are omitted from the blog scaffold, and are removed from the authored public GitHub body by `extract-changelog-section.mjs --public`; GitHub's generated commit list remains separate, so an internal-only release can have an empty authored body. +- `highlight: ` (max 80 characters, rejected on `type: internal`) marks a note as one of the release's two or three headline changes. Highlights lead the Telegram/Reddit announcement drafts, become ready-made blog section headings, and are the input the highlight-card generator renders from. A release where everything is a highlight has none. - Skip the note for test-only changes, docs, CI/workflow plumbing, and pure refactors with no behavior change. When skipping on a PR that touches `apps/**` or `libs/**`, apply the `no-release-note` label. - 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. - 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. - 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. - Release-post screenshots come only from the release capture script running against the mock servers. Never add a screenshot taken from a real playlist or account to `apps/website/public/blog/**` — real streams, logos, and metadata are copyrighted, and credentials must never reach a published image. - Final task summaries should state whether a release note was added or why it was skipped. diff --git a/tools/release/build-release-notes.mjs b/tools/release/build-release-notes.mjs index de6780671..648a29bdb 100644 --- a/tools/release/build-release-notes.mjs +++ b/tools/release/build-release-notes.mjs @@ -241,6 +241,27 @@ function writeBlogScaffold(content, version, force) { return target; } +/** + * An internal-only release has nothing to announce publicly. That is a legal + * release shape, so it prints an explanation on stderr and leaves stdout + * empty — matching `extract-changelog-section.mjs --public`, which allows an + * empty public body for exactly the same case — rather than failing. + * + * @param {string | null} post + * @param {string} label + */ +function writeAnnouncement(post, label) { + if (post === null) { + console.error( + `Internal-only release: no public ${label} announcement to render.` + ); + + return; + } + + process.stdout.write(post.endsWith('\n') ? post : `${post}\n`); +} + function main() { const options = parseArgs(process.argv.slice(2)); const notesDir = path.resolve(workspaceRoot, options.dir); @@ -304,11 +325,11 @@ function main() { // rendered before `--consume`: the CHANGELOG keeps the entries, but // the `highlight:` metadata lives only in the note files. if (options.format === 'telegram') { - process.stdout.write(`${renderTelegramPost(notes, { version })}\n`); + writeAnnouncement(renderTelegramPost(notes, { version }), 'Telegram'); } if (options.format === 'reddit') { - process.stdout.write(renderRedditPost(notes, { version })); + writeAnnouncement(renderRedditPost(notes, { version }), 'Reddit'); } if (options.format === 'changelog') { diff --git a/tools/release/build-release-notes.test.mjs b/tools/release/build-release-notes.test.mjs index 86839af4a..8c513c7f1 100644 --- a/tools/release/build-release-notes.test.mjs +++ b/tools/release/build-release-notes.test.mjs @@ -6,7 +6,7 @@ */ import assert from 'node:assert/strict'; -import { execFileSync } from 'node:child_process'; +import { spawnSync } from 'node:child_process'; import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; @@ -36,24 +36,24 @@ function makeNotesDir() { } /** + * spawnSync rather than execFileSync: a successful run also writes to stderr + * (the resolved-version notice, the internal-only announcement explanation), + * and execFileSync only hands back stdout. + * * @returns {{ status: number, stdout: string, stderr: string }} */ function runCli(args) { - try { - const stdout = execFileSync(process.execPath, [CLI, ...args], { - cwd: workspaceRoot, - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - }); + const result = spawnSync(process.execPath, [CLI, ...args], { + cwd: workspaceRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); - return { status: 0, stdout, stderr: '' }; - } catch (error) { - return { - status: error.status ?? 1, - stdout: error.stdout ?? '', - stderr: error.stderr ?? '', - }; - } + return { + status: result.status ?? 1, + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + }; } after(() => { @@ -134,6 +134,32 @@ describe('build-release-notes CLI arguments', () => { assert.match(result.stdout, /- \*\*playback\*\* — An example note\./); }); + it('reports an internal-only release instead of failing on announcements', () => { + const directory = mkdtempSync(path.join(tmpdir(), 'release-cli-')); + + tempDirs.push(directory); + writeFileSync( + path.join(directory, 'deps-bump.md'), + '---\ntype: internal\narea: deps\n---\n\nBumped a parser.\n', + 'utf8' + ); + + for (const format of ['telegram', 'reddit']) { + const result = runCli([ + '--format', + format, + '--version', + '0.24.0', + '--dir', + directory, + ]); + + assert.equal(result.status, 0, format); + assert.equal(result.stdout, '', format); + assert.match(result.stderr, /Internal-only release/, format); + } + }); + it('still rejects a genuinely unknown argument', () => { const result = runCli(['--validate', '--nope']); diff --git a/tools/release/highlight-cards.mjs b/tools/release/highlight-cards.mjs index 42499863b..24ea5490a 100644 --- a/tools/release/highlight-cards.mjs +++ b/tools/release/highlight-cards.mjs @@ -112,9 +112,11 @@ export function planHighlightCards(notes, options) { const highlights = ordered.filter((note) => note.highlight); const feature = highlights.map((note) => { - const slug = - note.screenshot ?? - path.basename(note.sourcePath, '.md').toLowerCase(); + // Named after the note file, never the screenshot slug: filenames are + // unique within `.changes/`, while two highlights may legitimately + // point at the same manifest shot — naming cards after it would let + // one silently overwrite the other. + const slug = path.basename(note.sourcePath, '.md').toLowerCase(); return { slug, diff --git a/tools/release/highlight-cards.test.mjs b/tools/release/highlight-cards.test.mjs index 308cf64a8..9ffa6cb10 100644 --- a/tools/release/highlight-cards.test.mjs +++ b/tools/release/highlight-cards.test.mjs @@ -109,7 +109,7 @@ describe('planHighlightCards', () => { assert.deepEqual( plan.feature.map((job) => job.fileName), - ['card-settings-rework.png', 'card-up-next-rail.png'] + ['card-settings-rework.png', 'card-playback-up-next.png'] ); assert.equal( plan.feature[1].screenshotPath, @@ -123,6 +123,34 @@ describe('planHighlightCards', () => { assert.equal(plan.hero.counts, '1 breaking change · 1 feature · 1 fix'); }); + it('keeps card filenames unique when two highlights share a screenshot', () => { + const plan = planHighlightCards( + [ + note({ + highlight: 'Rail on wide windows', + screenshot: 'up-next-rail', + sourcePath: '.changes/playback-up-next.md', + }), + note({ + highlight: 'Rail progress bars', + screenshot: 'up-next-rail', + sourcePath: '.changes/playback-rail-progress.md', + }), + ], + planOptions + ); + + assert.deepEqual( + plan.feature.map((job) => job.fileName), + ['card-playback-up-next.png', 'card-playback-rail-progress.png'] + ); + // Both still read the same shared screenshot. + assert.equal( + plan.feature[0].screenshotPath, + plan.feature[1].screenshotPath + ); + }); + it('never plans a card for internal notes', () => { const plan = planHighlightCards( [note({ type: 'internal', highlight: 'Nope' })], @@ -278,7 +306,7 @@ describe('renderCards', () => { assert.deepEqual( written.map((file) => path.basename(file)), [ - 'card-up-next-rail.png', + 'card-playback-up-next.png', 'card-m3u-faster-imports.png', 'hero.png', 'hero.jpg', diff --git a/tools/release/release-announcements.mjs b/tools/release/release-announcements.mjs index 6d055a865..714b0771f 100644 --- a/tools/release/release-announcements.mjs +++ b/tools/release/release-announcements.mjs @@ -72,13 +72,20 @@ function moreLine(count) { * * @param {object[]} notes * @param {{ version: string }} options - * @returns {string} a post guaranteed to fit TELEGRAM_MESSAGE_LIMIT + * @returns {string | null} a post guaranteed to fit TELEGRAM_MESSAGE_LIMIT, + * or null for an internal-only release with nothing public to announce */ export function renderTelegramPost(notes, { version }) { const { highlights, rest } = splitHighlights(notes); const lead = highlights.length > 0 ? highlights : rest; const leadIsHighlights = highlights.length > 0; + // An internal-only release is a legal shape — its authored GitHub body is + // empty too — and there is simply nothing to announce publicly. + if (lead.length === 0) { + return null; + } + const footer = [ `⬇️ Download: ${releaseUrl(version)}`, `📝 Full notes: ${blogUrl(version)}`, @@ -137,11 +144,15 @@ export function renderTelegramPost(notes, { version }) { * * @param {object[]} notes * @param {{ version: string }} options - * @returns {string} + * @returns {string | null} null for an internal-only release */ export function renderRedditPost(notes, { version }) { const { highlights, rest } = splitHighlights(notes); + if (highlights.length === 0 && rest.length === 0) { + return null; + } + const title = highlights.length > 0 ? `IPTVnator v${version} — ${highlights.map((note) => note.highlight).join(', ')}` diff --git a/tools/release/release-announcements.test.mjs b/tools/release/release-announcements.test.mjs index 3944d0fee..538a1c728 100644 --- a/tools/release/release-announcements.test.mjs +++ b/tools/release/release-announcements.test.mjs @@ -126,6 +126,15 @@ describe('renderTelegramPost', () => { assert.match(post, /…plus \d+ more fixes and improvements\./); }); + it('returns null for an internal-only release instead of throwing', () => { + assert.equal( + renderTelegramPost([note({ type: 'internal' })], { + version: '0.24.0', + }), + null + ); + }); + it('refuses to silently drop a hand-picked highlight', () => { const notes = Array.from({ length: 30 }, (_, index) => note({ @@ -190,6 +199,15 @@ describe('renderRedditPost', () => { assert.doesNotMatch(post, /Internal churn/); }); + it('returns null for an internal-only release', () => { + assert.equal( + renderRedditPost([note({ type: 'internal' })], { + version: '0.24.0', + }), + null + ); + }); + it('links the patch blog post to its minor release page', () => { const post = renderRedditPost([note()], { version: '0.24.2' }); diff --git a/tools/release/verify-draft-release.mjs b/tools/release/verify-draft-release.mjs index 5ee490abe..f890c7b8f 100644 --- a/tools/release/verify-draft-release.mjs +++ b/tools/release/verify-draft-release.mjs @@ -79,13 +79,19 @@ export function requiredAssetRules(version) { rules.push(exact('Flatpak', `iptvnator-${version}-linux-x86_64.flatpak`)); // Electron Builder has shipped both pacman artifact shapes; accept either. - const pacmanPattern = new RegExp( - `^iptvnator-${version.replace(/\./g, '\\.')}-linux-(x64\\.pacman|x86_64\\.pkg\\.tar\\.[a-z0-9]+)$` - ); + // Compared as plain strings rather than through a regex built from the + // version: this function is exported, so escaping the interpolated value + // correctly would be a standing trap. Only the compression suffix, a + // literal pattern, is matched by regex. + const pacmanExact = `iptvnator-${version}-linux-x64.pacman`; + const pacmanPrefix = `iptvnator-${version}-linux-x86_64.pkg.tar.`; rules.push({ - label: `Pacman (iptvnator-${version}-linux-x64.pacman or .pkg.tar.*)`, - matches: (candidate) => pacmanPattern.test(candidate), + label: `Pacman (${pacmanExact} or …-linux-x86_64.pkg.tar.*)`, + matches: (candidate) => + candidate === pacmanExact || + (candidate.startsWith(pacmanPrefix) && + /^[a-z0-9]+$/.test(candidate.slice(pacmanPrefix.length))), }); for (const name of [ @@ -141,7 +147,9 @@ export function parseVerifyArguments(args) { } else if (arg === '--repo') { const value = args[index + 1]; - if (!value || value.startsWith('--')) { + // Shape-checked here so a typo fails with this script's usage + // line instead of an opaque gh error several calls later. + if (!value || !/^[\w.-]+\/[\w.-]+$/.test(value)) { return null; } @@ -173,36 +181,67 @@ export function parseVerifyArguments(args) { return options; } +/** A just-pushed tag's run is not immediately visible to the API. */ +export const RUN_POLL_ATTEMPTS = 10; +export const RUN_POLL_INTERVAL_MS = 6000; + +/** + * `gh run list` reports what is indexed right now — its `--limit` caps how + * many runs come back, it does not wait for one to appear. Run straight after + * `git push v`, the tag build is routinely not indexed yet, + * so poll for a bounded window before concluding the tag was never pushed. + * + * @returns {Promise} the newest run, or null after the window + */ +async function findTagRun({ repo, branch }, io) { + for (let attempt = 1; attempt <= RUN_POLL_ATTEMPTS; attempt += 1) { + const runs = io.listRuns({ repo, workflow: WORKFLOW, branch }); + + if (runs.length > 0) { + return runs[0]; + } + + if (attempt < RUN_POLL_ATTEMPTS) { + io.progress( + `No ${WORKFLOW} run for ${branch} yet (attempt ${attempt}/${RUN_POLL_ATTEMPTS}) — waiting…` + ); + await io.sleep(RUN_POLL_INTERVAL_MS); + } + } + + return null; +} + /** * Verification pipeline over an injectable gh boundary, so tests never touch - * the network. `io.watchRun` streams `gh run watch` to the terminal and - * throws on a failed run; the other two return parsed `--json` payloads. + * the network. `io.watchRun` streams `gh run watch` to the terminal and throws + * on a failed run, `io.listRuns`/`io.viewRelease` return parsed `--json` + * payloads, `io.progress` reports transient status while waiting, and + * `io.sleep` paces the run poll. * * @param {{ version: string, wait: boolean, repo: string }} options - * @param {{ listRuns: Function, watchRun: Function, viewRelease: Function }} io - * @returns {{ exitCode: number, lines: string[] }} + * @param {{ listRuns: Function, watchRun: Function, viewRelease: Function, progress: Function, sleep: Function }} io + * @returns {Promise<{ exitCode: number, lines: string[] }>} */ -export function runVerification(options, io) { +export async function runVerification(options, io) { const { version, wait, repo } = options; const tag = `v${version}`; const lines = []; if (wait) { - const runs = io.listRuns({ repo, workflow: WORKFLOW, branch: tag }); + const run = await findTagRun({ repo, branch: tag }, io); - if (runs.length === 0) { + if (run === null) { return { exitCode: 1, lines: [ - `No ${WORKFLOW} run found for ${tag} in ${repo} — was the tag pushed?`, + `No ${WORKFLOW} run found for ${tag} in ${repo} after ${RUN_POLL_ATTEMPTS} attempts — was the tag pushed?`, ], }; } - const run = runs[0]; - if (run.status !== 'completed') { - lines.push(`Waiting for ${WORKFLOW} run ${run.databaseId} (${tag})…`); + io.progress(`Waiting for ${WORKFLOW} run ${run.databaseId} (${tag})…`); io.watchRun({ repo, runId: run.databaseId }); } else if (run.conclusion !== 'success') { return { @@ -223,10 +262,12 @@ export function runVerification(options, io) { }; } + const publishedAlready = !release.isDraft; + lines.push( - release.isDraft - ? `Draft release ${tag} found.` - : `WARNING: release ${tag} is already published, not a draft.` + publishedAlready + ? `Release ${tag} is already published — this gate runs before publication.` + : `Draft release ${tag} found.` ); if (!release.body?.trim()) { @@ -252,6 +293,14 @@ export function runVerification(options, io) { lines.push( `All ${requiredAssetRules(version).length} required assets present (${assetNames.length} attached).` ); + + // A published release still gets its asset report — auditing one after the + // fact is useful — but never a success exit. Succeeding here would claim a + // pre-publication gate passed for a boundary already crossed. + if (publishedAlready) { + return { exitCode: 1, lines }; + } + lines.push( 'Next: verify the authored body text, smoke-test installers, then publish the release manually.' ); @@ -318,9 +367,14 @@ const liveIo = { throw error; } }, + 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 + // empty event loop exits 0, turning a wait into a silent false success. + sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), }; -function main() { +async function main() { const options = parseVerifyArguments(process.argv.slice(2)); if (options === null) { @@ -335,7 +389,7 @@ function main() { console.error(`Using version ${options.version} from package.json.`); } - const { exitCode, lines } = runVerification(options, liveIo); + const { exitCode, lines } = await runVerification(options, liveIo); for (const line of lines) { console.log(line); @@ -349,10 +403,8 @@ const isDirectRun = path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); if (isDirectRun) { - try { - main(); - } catch (error) { + main().catch((error) => { console.error(error.message); process.exit(1); - } + }); } diff --git a/tools/release/verify-draft-release.test.mjs b/tools/release/verify-draft-release.test.mjs index ceb2fa4c4..b5d7de7d6 100644 --- a/tools/release/verify-draft-release.test.mjs +++ b/tools/release/verify-draft-release.test.mjs @@ -4,6 +4,8 @@ import { describe, it } from 'node:test'; import { parseVerifyArguments, requiredAssetRules, + RUN_POLL_ATTEMPTS, + RUN_POLL_INTERVAL_MS, runVerification, verifyReleaseAssets, } from './verify-draft-release.mjs'; @@ -51,7 +53,11 @@ function release(overrides = {}) { }; } +/** `progressLog` collects transient status for assertions; runVerification + * only reads the known io methods and ignores the extra property. */ function io(overrides = {}) { + const progressLog = []; + return { listRuns: () => [ { databaseId: 42, status: 'completed', conclusion: 'success' }, @@ -60,6 +66,9 @@ function io(overrides = {}) { throw new Error('watchRun must not be called'); }, viewRelease: () => release(), + progress: (message) => progressLog.push(message), + progressLog, + sleep: () => Promise.resolve(), ...overrides, }; } @@ -167,6 +176,8 @@ describe('parseVerifyArguments', () => { ['--unknown'], ['--repo'], ['--repo', '--no-wait'], + ['--repo', 'no-slash'], + ['--repo', 'too/many/parts'], ]) { assert.equal(parseVerifyArguments(args), null, args.join(' ')); } @@ -176,8 +187,8 @@ describe('parseVerifyArguments', () => { describe('runVerification', () => { const options = { version: '0.24.0', wait: true, repo: '4gray/iptvnator' }; - it('passes a complete draft and points at the manual next steps', () => { - const result = runVerification(options, io()); + it('passes a complete draft and points at the manual next steps', async () => { + const result = await runVerification(options, io()); assert.equal(result.exitCode, 0); assert.match(result.lines[0], /Draft release v0\.24\.0 found\./); @@ -188,15 +199,61 @@ describe('runVerification', () => { assert.match(result.lines.at(-1), /publish the release manually/); }); - it('fails when no tag build run exists', () => { - const result = runVerification(options, io({ listRuns: () => [] })); + it('polls before concluding the tag run does not exist', async () => { + let calls = 0; + const slept = []; + const harness = io({ + listRuns: () => { + calls += 1; - assert.equal(result.exitCode, 1); - assert.match(result.lines[0], /No build-and-make\.yaml run found/); + return calls < 3 + ? [] + : [ + { + databaseId: 42, + status: 'completed', + conclusion: 'success', + }, + ]; + }, + sleep: (ms) => { + slept.push(ms); + + return Promise.resolve(); + }, + }); + const result = await runVerification(options, harness); + + assert.equal(calls, 3); + assert.deepEqual(slept, [RUN_POLL_INTERVAL_MS, RUN_POLL_INTERVAL_MS]); + assert.match(harness.progressLog[0], /No build-and-make\.yaml run for v0\.24\.0 yet/); + assert.equal(result.exitCode, 0); }); - it('fails on a completed run with a non-success conclusion', () => { - const result = runVerification( + it('gives up after the bounded poll window', async () => { + let calls = 0; + const result = await runVerification( + options, + io({ + listRuns: () => { + calls += 1; + + return []; + }, + }) + ); + + assert.equal(calls, RUN_POLL_ATTEMPTS); + assert.equal(result.exitCode, 1); + assert.match(result.lines[0], /No build-and-make\.yaml run found/); + assert.match( + result.lines[0], + new RegExp(`after ${RUN_POLL_ATTEMPTS} attempts`) + ); + }); + + it('fails on a completed run with a non-success conclusion', async () => { + const result = await runVerification( options, io({ listRuns: () => [ @@ -213,9 +270,9 @@ describe('runVerification', () => { assert.match(result.lines[0], /conclusion "failure"/); }); - it('watches an in-progress run before checking the release', () => { + it('watches an in-progress run before checking the release', async () => { const watched = []; - const result = runVerification( + const result = await runVerification( options, io({ listRuns: () => [ @@ -229,8 +286,8 @@ describe('runVerification', () => { assert.equal(result.exitCode, 0); }); - it('skips the run lookup entirely with --no-wait', () => { - const result = runVerification( + it('skips the run lookup entirely with --no-wait', async () => { + const result = await runVerification( { ...options, wait: false }, io({ listRuns: () => { @@ -242,8 +299,8 @@ describe('runVerification', () => { assert.equal(result.exitCode, 0); }); - it('fails with the missing-asset list', () => { - const result = runVerification( + it('fails with the missing-asset list', async () => { + const result = await runVerification( options, io({ viewRelease: () => @@ -260,8 +317,8 @@ describe('runVerification', () => { assert.match(result.lines.at(-1), /Flatpak/); }); - it('fails when the release does not exist', () => { - const result = runVerification( + it('fails when the release does not exist', async () => { + const result = await runVerification( options, io({ viewRelease: () => null }) ); @@ -270,19 +327,34 @@ describe('runVerification', () => { assert.match(result.lines[0], /No release found for v0\.24\.0/); }); - it('warns on a published release and an empty body but still verifies', () => { - const result = runVerification( + it('never reports success once the release is published', async () => { + const result = await runVerification( options, - io({ viewRelease: () => release({ isDraft: false, body: ' ' }) }) + io({ viewRelease: () => release({ isDraft: false }) }) + ); + + // The asset report is still useful for an after-the-fact audit, but a + // pre-publication gate must not pass after publication. + assert.equal(result.exitCode, 1); + assert.match(result.lines[0], /already published/); + assert.match(result.lines.at(-1), /All 27 required assets present/); + assert.ok( + !result.lines.some((line) => /publish the release manually/.test(line)) + ); + }); + + it('warns about an empty authored body', async () => { + const result = await runVerification( + options, + io({ viewRelease: () => release({ body: ' ' }) }) ); assert.equal(result.exitCode, 0); - assert.match(result.lines[0], /WARNING: release v0\.24\.0 is already published/); assert.match(result.lines[1], /WARNING: authored release body is empty/); }); - it('notes unrecognized assets without failing', () => { - const result = runVerification( + it('notes unrecognized assets without failing', async () => { + const result = await runVerification( options, io({ viewRelease: () =>