#!/usr/bin/env node /** * Waits for the `v` tag build and verifies the draft GitHub release * it creates: run conclusion, draft status, authored body, and the complete * required asset set. * * node tools/release/verify-draft-release.mjs # package.json version * node tools/release/verify-draft-release.mjs 0.24.0 * node tools/release/verify-draft-release.mjs --no-wait 0.24.0 * * Read-only: it never publishes, edits, or deletes anything. Publishing the * release stays a manual act after this check and the installer smoke tests. * * The required set mirrors what `.github/workflows/build-and-make.yaml` * uploads for a complete matrix build (verified against a real full run). * When the build matrix gains or loses a target, update REQUIRED_ASSET_RULES * in the same PR. */ import { execFileSync, spawnSync } from 'node:child_process'; import { readFileSync, realpathSync } from 'node:fs'; import path from 'node:path'; import process from 'node:process'; import { fileURLToPath } from 'node:url'; import { extractPublicSection } from './extract-changelog-section.mjs'; import { REPO_URL } from './release-notes.mjs'; const workspaceRoot = path.resolve( path.dirname(fileURLToPath(import.meta.url)), '../..' ); export const REPO_SLUG = REPO_URL.replace('https://github.com/', ''); const WORKFLOW = 'build-and-make.yaml'; const CLI_USAGE = 'Usage: verify-draft-release.mjs [--no-wait] [--repo owner/name] []'; /** * @param {string} version bare semver * @returns {{ label: string, matches: (name: string) => boolean }[]} */ export function requiredAssetRules(version) { const exact = (label, name) => ({ label: `${label} (${name})`, matches: (candidate) => candidate === name, }); const rules = []; for (const arch of ['x64', 'arm64']) { for (const extension of ['dmg', 'zip']) { const base = `iptvnator-${version}-mac-${arch}.${extension}`; rules.push(exact('macOS', base)); rules.push(exact('macOS blockmap', `${base}.blockmap`)); } } const windowsSetup = `iptvnator-${version}-windows-x64-setup.exe`; rules.push(exact('Windows', windowsSetup)); rules.push(exact('Windows blockmap', `${windowsSetup}.blockmap`)); for (const arch of ['amd64', 'arm64', 'armv7l']) { rules.push(exact('DEB', `iptvnator-${version}-linux-${arch}.deb`)); } for (const arch of ['x86_64', 'arm64', 'armv7l']) { rules.push( exact('AppImage', `iptvnator-${version}-linux-${arch}.AppImage`) ); } for (const arch of ['amd64', 'armhf']) { rules.push(exact('Snap', `iptvnator-${version}-linux-${arch}.snap`)); } rules.push(exact('RPM', `iptvnator-${version}-linux-x86_64.rpm`)); rules.push(exact('Flatpak', `iptvnator-${version}-linux-x86_64.flatpak`)); // Electron Builder has shipped both pacman artifact shapes; accept either. // 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 (${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 [ 'latest.yml', 'latest-mac.yml', 'latest-linux.yml', 'latest-linux-arm.yml', 'latest-linux-arm64.yml', ]) { rules.push(exact('Updater metadata', name)); } rules.push( exact('Source archive', 'linux-frame-copy-runtime-sources.tar.xz') ); return rules; } /** * @param {string[]} assetNames names attached to the release * @param {string} version bare semver * @returns {{ missing: string[], extras: string[] }} `missing` lists unmet * rule labels; `extras` lists assets no rule claims (informational only — * a new build target shows up here before the rules learn about it) */ export function verifyReleaseAssets(assetNames, version) { const rules = requiredAssetRules(version); const missing = rules .filter((rule) => !assetNames.some((name) => rule.matches(name))) .map((rule) => rule.label); const extras = assetNames.filter( (name) => !rules.some((rule) => rule.matches(name)) ); return { missing, extras }; } /** * @param {string[]} args * @returns {{ version: string | null, wait: boolean, repo: string } | null} * `version: null` means "use package.json"; null result means bad usage */ export function parseVerifyArguments(args) { const options = { version: null, wait: true, repo: REPO_SLUG }; const positional = []; for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (arg === '--no-wait') { options.wait = false; } else if (arg === '--repo') { const value = args[index + 1]; // 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; } options.repo = value; index += 1; } else if (arg === '--') { // pnpm forwards the npm-style separator verbatim; ignore it. } else if (arg.startsWith('--')) { return null; } else { positional.push(arg); } } if (positional.length > 1) { return null; } if (positional.length === 1) { const version = positional[0].replace(/^v/, ''); if (!/^\d+\.\d+\.\d+$/.test(version)) { return null; } options.version = version; } 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; } /** * The tag workflow appends GitHub's generated notes to the authored text * (`FULL_BODY` in build-and-make.yaml), so a non-empty `body` proves nothing * about the authored half — testing it for emptiness could never fail. The * authored text is the CHANGELOG section this repo committed before tagging, * so compare against that instead. * * @param {string} body the release body as published * @param {string} version * @param {{ readChangelog: Function }} io * @returns {string[]} report lines */ function verifyAuthoredBody(body, version, io) { const changelog = io.readChangelog(); if (changelog === null) { return [ 'NOTE: CHANGELOG.md is unreadable here, so the authored body was not verified.', ]; } const authored = extractPublicSection(changelog, version); if (authored === null) { return [ `WARNING: CHANGELOG.md has no section for ${version} — the tag build authors the body from it.`, ]; } if (authored === '') { return [ 'NOTE: internal-only release — no authored body is expected, only generated notes.', ]; } const normalize = (text) => text.replace(/\r\n/g, '\n').trim(); return normalize(body).includes(normalize(authored)) ? ['Authored changelog section present in the release body.'] : [ 'WARNING: the release body does not contain the authored CHANGELOG section — it may carry only GitHub-generated notes.', ]; } /** * Verification pipeline over an injectable gh boundary, so tests never touch * the network. `io.watchRun` streams `gh run watch` to the terminal and throws * 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, readChangelog: Function, progress: Function, sleep: Function }} io * @returns {Promise<{ exitCode: number, lines: string[] }>} */ export async function runVerification(options, io) { const { version, wait, repo } = options; const tag = `v${version}`; const lines = []; if (wait) { const run = await findTagRun({ repo, branch: tag }, io); if (run === null) { return { exitCode: 1, lines: [ `No ${WORKFLOW} run found for ${tag} in ${repo} after ${RUN_POLL_ATTEMPTS} attempts — was the tag pushed?`, ], }; } if (run.status !== 'completed') { io.progress(`Waiting for ${WORKFLOW} run ${run.databaseId} (${tag})…`); io.watchRun({ repo, runId: run.databaseId }); } else if (run.conclusion !== 'success') { return { exitCode: 1, lines: [ `${WORKFLOW} run for ${tag} completed with conclusion "${run.conclusion}" — fix the build before verifying assets.`, ], }; } } const release = io.viewRelease({ repo, tag }); if (release === null) { return { exitCode: 1, lines: [`No release found for ${tag} in ${repo}.`], }; } const publishedAlready = !release.isDraft; lines.push( publishedAlready ? `Release ${tag} is already published — this gate runs before publication.` : `Draft release ${tag} found.` ); lines.push(...verifyAuthoredBody(release.body ?? '', version, io)); const assetNames = release.assets.map((asset) => asset.name); const { missing, extras } = verifyReleaseAssets(assetNames, version); for (const extra of extras) { lines.push(`NOTE: unrecognized asset ${extra} (not required by the rules).`); } if (missing.length > 0) { lines.push(`Missing ${missing.length} required asset(s):`); lines.push(...missing.map((label) => ` - ${label}`)); return { exitCode: 1, lines }; } 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.' ); return { exitCode: 0, lines }; } function gh(args) { return execFileSync('gh', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], }); } const liveIo = { listRuns: ({ repo, workflow, branch }) => JSON.parse( gh([ 'run', 'list', '--repo', repo, '--workflow', workflow, '--branch', branch, '--limit', '1', '--json', 'databaseId,status,conclusion', ]) ), watchRun: ({ repo, runId }) => { const result = spawnSync( 'gh', ['run', 'watch', String(runId), '--repo', repo, '--exit-status'], { stdio: 'inherit' } ); // spawnSync reports a missing binary and a signalled child as // `status: null` rather than throwing. Blaming the build for either // would send the release manager after a build that is fine. if (result.error) { throw new Error(`could not run gh: ${result.error.message}`); } if (result.signal) { throw new Error( `gh run watch was interrupted (${result.signal}) — run ${runId} was not judged` ); } if (result.status !== 0) { throw new Error( `tag build run ${runId} failed — fix the build before verifying assets` ); } }, viewRelease: ({ repo, tag }) => { try { return JSON.parse( gh([ 'release', 'view', tag, '--repo', repo, '--json', 'name,isDraft,body,assets', ]) ); } catch (error) { if (/release not found/i.test(`${error.stderr ?? ''}`)) { return null; } throw error; } }, readChangelog: () => { try { return readFileSync( path.join(workspaceRoot, 'CHANGELOG.md'), 'utf8' ); } catch { return null; } }, progress: (message) => console.error(message), // Deliberately not unref'd: a pending promise does not hold the event // loop open, so an unref'd timer would let Node exit mid-poll — and an // empty event loop exits 0, turning a wait into a silent false success. sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), }; async function main() { const options = parseVerifyArguments(process.argv.slice(2)); if (options === null) { console.error(CLI_USAGE); process.exit(2); } if (options.version === null) { options.version = JSON.parse( readFileSync(path.join(workspaceRoot, 'package.json'), 'utf8') ).version; console.error(`Using version ${options.version} from package.json.`); } const { exitCode, lines } = await runVerification(options, liveIo); for (const line of lines) { console.log(line); } process.exit(exitCode); } // realpath on both sides: Node resolves symlinks for `import.meta.url` but // not for argv[1], so a checkout reached through a symlinked path (macOS // /tmp and /var are symlinks) made this publication gate a silent exit-0 // no-op — which reads exactly like a pass. const isDirectRun = (() => { if (!process.argv[1]) { return false; } try { return ( realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)) ); } catch { return false; } })(); if (isDirectRun) { main().catch((error) => { console.error(error.message); process.exit(1); }); }