fix(release): address review findings on the release tooling

CodeQL: the pacman rule built a regex from the interpolated version and
escaped only dots. It now compares plain strings, so the exported
requiredAssetRules() carries no escaping trap; only the literal compression
suffix stays a regex.

verify-draft-release:
- Never exits 0 once the release is published. The asset report still
  prints (auditing after the fact is useful), but a pre-publication gate
  must not report success for a boundary already crossed.
- Polls for the tag run before concluding it does not exist: `gh run list`
  reports what is indexed right now, and a run pushed seconds ago routinely
  is not. Adds injectable progress/sleep io, so runVerification is async.
- The live sleep timer is deliberately not unref'd — a pending promise does
  not hold the event loop open, and an empty loop exits 0, which would turn
  a mid-poll exit into a silent false success.
- `--repo` is shape-checked so a typo fails with the usage line.

Announcements: an internal-only release now returns null instead of
throwing a misleading "even a single entry exceeds the limit" error. The
CLI explains it on stderr, leaves stdout empty and exits 0 — the shape
extract-changelog-section.mjs --public already uses for the same case.

Highlight cards: card filenames derive from the note filename, never the
screenshot slug. Two highlights may legitimately share one manifest shot,
which previously made the second card overwrite the first.

Tests: the CLI harness moved to spawnSync — execFileSync discards stderr on
success, hiding exactly the messages these paths emit. Adds regressions for
each finding above (165 passing).

Docs: CLAUDE.md and AGENTS.md release sections (mirrored, verified
identical) now document the highlight field, the announcement/card
commands, and the draft verifier; .changes/README.md records the
internal-only announcement behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-08-27 08:57:00 +02:00
1 parent dcd28c1334
commit 4b30e0613a
11 files changed
+311 -72

No files matched your search

+3
View File
@@ -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
+3
View File
@@ -66,11 +66,14 @@ This file provides guidance to coding agents working in this repository.
- Name it `<area>-<short-slug>.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: <short headline>` (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/<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.
- 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.
+3
View File
@@ -32,11 +32,14 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- Name it `<area>-<short-slug>.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: <short headline>` (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/<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.
- 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.
+23 -2
View File
@@ -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') {
+41 -15
View File
@@ -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']);
+5 -3
View File
@@ -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,
+30 -2
View File
@@ -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',
+13 -2
View File
@@ -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(', ')}`
@@ -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' });
+78 -26
View File
@@ -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 <remote> v<version>`, the tag build is routinely not indexed yet,
* so poll for a bounded window before concluding the tag was never pushed.
*
* @returns {Promise<object | null>} 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);
}
});
}
+94 -22
View File
@@ -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: () =>