mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 20:16:15 -08:00
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:
1 parent
dcd28c1334
commit
4b30e0613a
11 files changed
+311
-72
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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']);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' });
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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: () =>
|
||||
|
||||
Reference in new issue
Block a user