igneum/tools/ship-app.mjs
igneum-labs 6cecbd4c67 jobs: one signed object (igneum-jobs.signed.json) so a file and a signature from two deployments can never pair
The 13:19:41Z refusal on PC 2: fetch_jobs took igneum-jobs.json and .sig in two requests while the edge was
still serving the previous deployment for one of them. The signer wraps the verified pair into one object and
reads it back; the app fetches that object (the pair only when none is published); publish-jobs.sh writes and
mirrors all three files and verifies every folder after the deploy; tools/jobs.mjs reads the envelope.
Tests: jobs.rs signed_envelope_binds_file_and_signature, packaging/ota/test-publish-jobs.sh (24 checks).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-05 15:52:34 +00:00

680 lines
54 KiB
JavaScript

#!/usr/bin/env node
// Cuts an Igneum Miner app version from one command. the project lead, 4 October 2026: 0.3.1, 0.3.2 and 0.3.3 each took eight hand
// steps and an hour; this is the one step. packaging/README-ship.md has the short version.
//
// node tools/ship-app.mjs 0.3.4 --node <fork worktree> [--notes "..."] [--dry-run] [--from <step>]
// [--skip-windows | --skip-mac] [--node-commit <sha>] [--win-release <dir>] [--mac-release <dir>]
// [--min-supported 0.3.0] [--activation-height N --deadline-note "..."] [--dl-both]
// node tools/ship-app.mjs --check the six version files agree (exit 1 when they do not)
// node tools/ship-app.mjs --self-test the bump, on a scratch copy of the version files
//
// Steps, in order (each one skips itself when its result is already there, so a rerun or --from <step> resumes):
// preflight trees clean, fork on the expected commit, tools, binaries, secrets present, gh account
// bump the six version files (one function, read back after writing)
// inputs packaging/windows/push-inputs.sh (the node exes and workers for the GitHub build), skipped when the live
// payload-inputs.json already carries these exact files from this fork commit
// commit commit the six files as "Igneum Miner <v>: <notes>" and push master (that push starts the Windows build)
// ci find the windows.yml run for that commit (or dispatch one), poll it with gh until green
// fetch packaging/windows/fetch-ci-artifacts.sh <run>: the installer and the payload zip into the downloads folder
// dmg packaging/mac/build-dmg.sh under tools/lock/with-lock.sh build (nice 19, 4 cargo jobs)
// copy the DMG into the downloads folder
// mirror --dl-both only: the version's files and the folder-level files (jobs, payload inputs, CI record) into the
// NEXT token folder, dl/<dl-token.next>/, so both folders carry the same bytes
// manifest packaging/ota/publish-manifest.sh --no-deploy: canonical JSON, signed, signature verified locally; with
// --dl-both a second manifest in the NEXT folder (--dest, --base-url) carrying the same override, tuning and
// min_supported, checked field by field against the first
// deploy the downloads folder with the Vercel CLI (one deploy carries the files and the manifest together)
// verify HEAD and GET of the three files (size and sha256 against the local copies), the live manifest and its signature;
// with --dl-both the same for the NEXT folder
// console one console item (tools/console.mjs post --kind build) with version, sizes and hashes, then sync-dl
//
// --dl-both (rotation phase 2, 5 October 2026, docs/plans/rotation-phase-2.md): the downloads token is being rotated.
// Installed apps check the OLD folder (dl-token); the new build checks the NEW one (dl-token.next, what
// packaging/mac/packaged-config.sh packages by default while that file exists). The version is published in BOTH
// folders so the old apps find the update and the new ones find their folder; one deploy, both verified.
//
// Secrets: ~/.config/igneum/dl-token, dlsite-dir, relay-token, relay-key, ota-signing-key, vercel/ are read by this
// tool or by the scripts it calls and never printed; every output line is scrubbed of the tokens. State that is not a
// secret (commit, run id, bump time) lives in ~/.cache/igneum/ship/<version>.json. gh auth switch --user igneum-labs runs
// before every gh call and before the push (the account drifted twice on 4 October). Zero dependencies.
import { readFileSync, writeFileSync, existsSync, statSync, mkdirSync, copyFileSync, rmSync, mkdtempSync } from 'node:fs';
import { spawn, spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { homedir, tmpdir } from 'node:os';
import { join, dirname, resolve, basename } from 'node:path';
import { fileURLToPath } from 'node:url';
process.stdout.on('error', e => { if (e.code === 'EPIPE') process.exit(0); throw e; });
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const REPO = 'igneum-network/igneum';
const GH_USER = 'igneum-labs';
const WORKFLOW = 'windows.yml';
const CFG = join(homedir(), '.config', 'igneum');
const cfg = n => { try { return readFileSync(join(CFG, n), 'utf8').trim(); } catch { return ''; } };
const STATE_DIR = join(homedir(), '.cache', 'igneum', 'ship');
// ---- the six version files: every pattern must match, every match must carry the same version ----------------------
// dot = "0.3.4"; comma = "0,3,4,0" (the Windows version blocks)
const VERSION_FILES = [
{ path: 'app/igneum-app/Cargo.toml', patterns: [{ re: /(\[package\][\s\S]*?^version = ")([^"]+)(")/m, kind: 'dot' }] },
{ path: 'app/igneum-app/Cargo.lock', patterns: [{ re: /(\[\[package\]\]\nname = "igneum-app"\nversion = ")([^"]+)(")/, kind: 'dot' }] },
{ path: 'app/windows/version.h', patterns: [
{ re: /(#define IGNEUM_HOST_VERSION_STR ")([^"]+)(")/, kind: 'dot' },
{ re: /(#define IGNEUM_HOST_VERSION_RC\s+)([0-9,]+)()/, kind: 'comma' }] },
{ path: 'app/igneum-app/resources/igneum-app.rc', patterns: [
{ re: /(^FILEVERSION\s+)([0-9,]+)()/m, kind: 'comma' },
{ re: /(^PRODUCTVERSION\s+)([0-9,]+)()/m, kind: 'comma' },
{ re: /(VALUE "FileVersion",\s+")([^"]+)(")/, kind: 'dot' },
{ re: /(VALUE "ProductVersion",\s+")([^"]+)(")/, kind: 'dot' }] },
{ path: 'packaging/windows/Igneum-Miner.iss', patterns: [{ re: /(#define AppVersion ")([^"]+)(")/, kind: 'dot' }] },
{ path: 'packaging/mac/app/Info.plist', patterns: [{ re: /(<key>CFBundleShortVersionString<\/key>\s*<string>)([^<]+)(<\/string>)/, kind: 'dot' }] },
];
const isVersion = v => /^\d+\.\d+\.\d+$/.test(v);
const toComma = v => v.split('.').join(',') + ',0';
const fromComma = c => { const p = c.split(','); return p.length === 4 && p[3] === '0' ? p.slice(0, 3).join('.') : c; };
const cmpVersion = (a, b) => { const x = a.split('.').map(Number), y = b.split('.').map(Number); for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] - y[i]; return 0; };
// reads one file: the versions it carries, one per pattern, in dotted form
function readVersions(text, file) {
return file.patterns.map(p => {
const m = p.re.exec(text);
if (!m) throw new Error(`${file.path}: pattern ${p.re} not found (the file changed shape; update VERSION_FILES)`);
return p.kind === 'comma' ? fromComma(m[2]) : m[2];
});
}
function writeVersion(text, file, v) {
for (const p of file.patterns) {
if (!p.re.test(text)) throw new Error(`${file.path}: pattern ${p.re} not found`);
text = text.replace(p.re, (_, a, _b, c) => a + (p.kind === 'comma' ? toComma(v) : v) + (c || ''));
}
return text;
}
// bumps every file under root to v, then reads each back and demands v everywhere; returns the per-file report
function bumpVersionFiles(root, v) {
if (!isVersion(v)) throw new Error(`not a major.minor.patch version: ${v}`);
const report = [];
for (const f of VERSION_FILES) {
const full = join(root, f.path);
const before = readFileSync(full, 'utf8');
const was = readVersions(before, f);
const after = writeVersion(before, f, v);
if (after !== before) writeFileSync(full, after);
const back = readVersions(readFileSync(full, 'utf8'), f);
if (!back.every(x => x === v)) throw new Error(`${f.path}: wrote ${v} but read back ${back.join(', ')}`);
report.push({ file: f.path, was: [...new Set(was)].join(', '), now: v, changed: after !== before });
}
return report;
}
// the check: what every file says; ok when one version everywhere
function checkVersionFiles(root) {
const rows = [];
for (const f of VERSION_FILES) {
const vs = readVersions(readFileSync(join(root, f.path), 'utf8'), f);
rows.push({ file: f.path, versions: [...new Set(vs)] });
}
const all = [...new Set(rows.flatMap(r => r.versions))];
return { rows, version: all.length === 1 ? all[0] : null, all };
}
// ---- output: every line scrubbed of the tokens -------------------------------------------------------------------
const SECRETS = ['dl-token', 'dl-token.next', 'relay-token', 'relay-key', 'log-intake-key', 'log-intake-key.next'].map(cfg).filter(s => s.length >= 8);
const scrub = s => SECRETS.reduce((t, k) => t.split(k).join('<token>'), String(s));
const say = (...a) => console.log(scrub(a.join(' ')));
const fmtSize = n => n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`;
const sha256 = p => createHash('sha256').update(readFileSync(p)).digest('hex');
const sizeOf = p => statSync(p).size;
const table = rows => { const w = []; for (const r of rows) r.forEach((c, i) => w[i] = Math.max(w[i] || 0, String(c).length)); for (const r of rows) say(' ' + r.map((c, i) => String(c).padEnd(w[i])).join(' ').trimEnd()); };
// ---- running things: streamed, scrubbed, with the exit code ------------------------------------------------------
function run(cmd, args, { cwd = ROOT, env = {}, quiet = false, input } = {}) {
return new Promise((res) => {
const p = spawn(cmd, args, { cwd, env: { ...process.env, PATH: `${homedir()}/.cargo/bin:/opt/homebrew/bin:${process.env.PATH}`, ...env }, stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'] });
let out = '';
const feed = chunk => { const s = chunk.toString(); out += s; if (!quiet) process.stdout.write(scrub(s)); };
p.stdout.on('data', feed); p.stderr.on('data', feed);
if (input !== undefined) { p.stdin.write(input); p.stdin.end(); }
p.on('error', e => res({ code: 127, out: out + e.message }));
p.on('close', code => res({ code: code ?? 1, out }));
});
}
function runSync(cmd, args, { cwd = ROOT, env = {} } = {}) {
const r = spawnSync(cmd, args, { cwd, env: { ...process.env, PATH: `${homedir()}/.cargo/bin:/opt/homebrew/bin:${process.env.PATH}`, ...env }, encoding: 'utf8' });
return { code: r.status ?? 1, out: ((r.stdout || '') + (r.stderr || '')).trim() };
}
const git = (args, cwd = ROOT) => runSync('git', args, { cwd });
// git status --porcelain: the paths only ("XY path"; the first line's leading space does not survive a trim)
const gitStatusPaths = (args, cwd = ROOT) => git(['status', '--porcelain', ...args], cwd).out.split('\n').filter(Boolean).map(l => l.replace(/^\s*\S+\s+/, ''));
const has = cmd => runSync('sh', ['-c', `command -v ${cmd}`]).code === 0;
// gh: the account switch first, every time (the rule), then the call
async function gh(args, { quiet = true } = {}) {
const sw = runSync('gh', ['auth', 'switch', '--user', GH_USER]);
if (sw.code !== 0) throw new Error(`gh auth switch --user ${GH_USER} failed: ${sw.out}`);
return run('gh', args, { quiet });
}
async function ghJson(args) { const r = await gh(args); if (r.code !== 0) throw new Error(`gh ${args.slice(0, 2).join(' ')} failed: ${r.out.trim()}`); return JSON.parse(r.out); }
const sleep = ms => new Promise(r => setTimeout(r, ms));
async function head(url) { const r = await fetch(url, { method: 'HEAD' }); return { status: r.status, length: r.headers.get('content-length') ? Number(r.headers.get('content-length')) : null }; }
async function getJson(url) { const r = await fetch(url); if (!r.ok) return null; return r.json().catch(() => null); }
// ---- arguments -----------------------------------------------------------------------------------------------------
const argv = process.argv.slice(2);
const flags = {}; const pos = [];
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a.startsWith('--')) { const k = a.slice(2); const next = argv[i + 1]; if (next !== undefined && !next.startsWith('--')) { flags[k] = next; i++; } else flags[k] = true; }
else pos.push(a);
}
const STEPS = ['preflight', 'bump', 'inputs', 'commit', 'ci', 'fetch', 'dmg', 'copy', 'mirror', 'manifest', 'deploy', 'verify', 'console'];
if (flags['self-test']) { process.exit(selfTest()); }
if (flags.check) {
const c = checkVersionFiles(ROOT);
table([['file', 'version'], ...c.rows.map(r => [r.file, r.versions.join(', ')])]);
if (c.version) { say(`ok: ${c.version} in all ${c.rows.length} files`); process.exit(0); }
say(`DISAGREE: ${c.all.join(' vs ')}; run: node tools/ship-app.mjs <version> --node <fork> (the bump step), or fix by hand`);
process.exit(1);
}
const VERSION = pos[0];
if (!VERSION || !isVersion(VERSION)) { console.error('usage: node tools/ship-app.mjs <major.minor.patch> --node <fork worktree> [--notes "..."] [--dry-run] [--from <step>] [--skip-windows|--skip-mac] [--dl-both]\n node tools/ship-app.mjs --check | --self-test'); process.exit(2); }
if (!flags.node) { console.error('--node <fork worktree> is required (the igneum-node worktree the node and miner were built from)'); process.exit(2); }
if (flags.from && !STEPS.includes(flags.from)) { console.error(`--from must be one of: ${STEPS.join(', ')}`); process.exit(2); }
if (flags['skip-windows'] && flags['skip-mac']) { console.error('--skip-windows and --skip-mac together leave nothing to ship'); process.exit(2); }
const DRY = !!flags['dry-run'];
const WIN = !flags['skip-windows'];
const MAC = !flags['skip-mac'];
const NOTES = flags.notes || `Igneum Miner ${VERSION}`;
const NODE_DIR = resolve(flags.node);
const TOKEN = cfg('dl-token');
const DLSITE = process.env.IGNEUM_DLSITE || cfg('dlsite-dir');
const DEST = DLSITE && TOKEN ? join(DLSITE, 'dl', TOKEN) : '';
const BASE = `https://dl.igneum.network/dl/${TOKEN}`;
// --dl-both: the NEXT folder, from ~/.config/igneum/dl-token.next
const BOTH = !!flags['dl-both'];
const TOKEN_NEXT = BOTH ? cfg('dl-token.next') : '';
const DEST_NEXT = BOTH && DLSITE && TOKEN_NEXT ? join(DLSITE, 'dl', TOKEN_NEXT) : '';
const BASE_NEXT = `https://dl.igneum.network/dl/${TOKEN_NEXT}`;
// the folder-level files the apps and the CI read next to the manifest (jobs, the CI's inputs, the CI record, the
// WSL2 prover zip): mirrored into the NEXT folder when present in the current one
const FOLDER_FILES = ['igneum-jobs.json', 'igneum-jobs.json.sig', 'igneum-jobs.signed.json', 'payload-inputs.zip', 'payload-inputs.json', 'payload-inputs.sha256', 'igneum-windows-ci.json', 'igneum-prove-wsl2.zip'];
const DMG_NAME = `Igneum-Miner-${VERSION}.dmg`;
const SETUP_NAME = `Igneum-Miner-Setup-${VERSION}.exe`;
const ZIP_NAME = 'igneum-windows-app.zip';
const DMG_DIST = join(ROOT, 'packaging', 'mac', 'dist', DMG_NAME);
const SIGNER = join(ROOT, 'app', 'igneum-app', 'target', 'release', 'igneum-ota-sign');
const STATE_FILE = join(STATE_DIR, `${VERSION}.json`);
const state = (() => { try { return JSON.parse(readFileSync(STATE_FILE, 'utf8')); } catch { return {}; } })();
const saveState = () => { if (DRY) return; mkdirSync(STATE_DIR, { recursive: true }); writeFileSync(STATE_FILE, JSON.stringify(state, null, 2)); };
// where the fork's binaries are: target-integration first (the devnet-v4 integration builds), then target
const firstDir = (cands, probe) => cands.find(d => existsSync(join(d, probe))) || cands[0];
const WIN_RELEASE = flags['win-release'] ? resolve(flags['win-release']) : firstDir([join(NODE_DIR, 'target-integration', 'x86_64-pc-windows-gnu', 'release'), join(NODE_DIR, 'target', 'x86_64-pc-windows-gnu', 'release')], 'igneumd.exe');
const MAC_RELEASE = flags['mac-release'] ? resolve(flags['mac-release']) : firstDir([join(NODE_DIR, 'target-integration', 'release'), join(NODE_DIR, 'target', 'release')], 'igneumd');
const WIN_INPUTS = ['igneumd.exe', 'igneum-miner.exe'].map(n => join(WIN_RELEASE, n));
const WORKERS = [join(ROOT, 'proto-cuda', 'nvrtc', 'igneum-worker-cuda.exe'), join(ROOT, 'proto-opencl', 'igneum-worker-opencl.exe')];
const retryCmd = step => `node tools/ship-app.mjs ${VERSION} --node ${flags.node}${flags.notes ? ` --notes ${JSON.stringify(flags.notes)}` : ''}${WIN ? '' : ' --skip-windows'}${MAC ? '' : ' --skip-mac'}${flags['node-commit'] ? ` --node-commit ${flags['node-commit']}` : ''}${BOTH ? ' --dl-both' : ''} --from ${step}`;
// ---- --dl-both helpers (pure; the self-test runs them on scratch folders) -------------------------------------------
// which of `names` must be copied from src to dst: 'copy' (missing or different bytes), 'same', or 'absent' (not in src)
function mirrorPlan(src, dst, names) {
return names.map(name => {
const a = join(src, name), b = join(dst, name);
if (!existsSync(a)) return { name, action: 'absent' };
if (existsSync(b) && sha256(a) === sha256(b)) return { name, action: 'same' };
return { name, action: 'copy' };
});
}
// the arguments the second publish-manifest.sh call takes so the NEXT folder's manifest carries what the first one
// carries (override, tuning, min_supported are otherwise carried over from the manifest already in THAT folder, which
// is older or missing): [args, tuningFile|null]
function secondManifestArgs(first, dest, base, tmpDir) {
const args = ['--dest', dest, '--base-url', base];
const o = first.consensus && first.consensus.override;
if (o && typeof o === 'object' && Object.keys(o).length) args.push('--override', JSON.stringify(o));
if (first.min_supported_version) args.push('--min-supported', String(first.min_supported_version));
let tuningFile = null;
if (first.tuning && typeof first.tuning === 'object' && first.tuning.cards) { tuningFile = join(tmpDir, 'tuning.json'); writeFileSync(tuningFile, JSON.stringify(first.tuning)); args.push('--tuning', tuningFile); }
else args.push('--no-tuning');
return [args, tuningFile];
}
// the two manifests must agree on everything except published_at and the folder in the URLs: the differences, [] when none
function manifestDifferences(a, b, baseA, baseB) {
const diffs = [];
const norm = (m, base) => { const c = JSON.parse(JSON.stringify(m)); delete c.published_at; for (const e of Object.values(c.platforms || {})) if (typeof e.url === 'string') e.url = e.url.replace(base, '<base>'); return c; };
const x = norm(a, baseA), y = norm(b, baseB);
for (const k of new Set([...Object.keys(x), ...Object.keys(y)])) {
const sx = JSON.stringify(x[k] === undefined ? null : x[k]), sy = JSON.stringify(y[k] === undefined ? null : y[k]);
if (sx !== sy) diffs.push(`${k}: ${sx.slice(0, 80)} vs ${sy.slice(0, 80)}`);
}
return diffs;
}
const results = []; // the final table
let dryProblems = 0; // a dry run lists preflight problems and goes on with the plan; its exit code says so
const done = (step, result, detail = '') => { results.push([step, result, detail]); say(`[${step}] ${result}${detail ? ': ' + detail : ''}`); };
// ---- the steps -----------------------------------------------------------------------------------------------------
async function preflight() {
const problems = []; const notes = [];
const c = checkVersionFiles(ROOT);
if (!c.version) notes.push(`the version files disagree (${c.all.join(' vs ')}); the bump step aligns them`);
const current = c.version || c.all.sort(cmpVersion).pop();
if (cmpVersion(VERSION, current) < 0) problems.push(`${VERSION} is lower than the tree's ${current}`);
if (cmpVersion(VERSION, current) === 0 && !flags.from && !DRY) notes.push(`the tree already says ${VERSION}; the bump is a no-op (a resume)`);
// this tree
const branch = git(['branch', '--show-current']).out;
if (branch !== (flags.branch || 'master')) problems.push(`this tree is on ${branch || 'a detached HEAD'}, not ${flags.branch || 'master'} (the Windows build runs on pushes to master)`);
const dirty = gitStatusPaths(['-uno']);
const versionPaths = new Set(VERSION_FILES.map(f => f.path));
const otherDirty = dirty.filter(p => !versionPaths.has(p));
if (otherDirty.length) problems.push(`this tree has ${otherDirty.length} modified tracked file(s) besides the version files; commit or stash them first:\n ${otherDirty.slice(0, 8).join('\n ')}`);
if (!DRY) { const f = git(['fetch', 'origin', 'master', '--quiet']); if (f.code !== 0) problems.push(`git fetch origin failed: ${f.out}`); }
const behind = git(['rev-list', '--count', 'HEAD..origin/master']).out;
if (behind !== '0') problems.push(`this tree is ${behind} commit(s) behind origin/master; git pull first`);
const headSha = git(['rev-parse', 'HEAD']).out;
// the fork
if (!existsSync(join(NODE_DIR, '.git'))) problems.push(`--node ${NODE_DIR} is not a git worktree`);
else {
const fd = gitStatusPaths(['-uno'], NODE_DIR);
if (fd.length) problems.push(`the fork worktree has ${fd.length} modified tracked file(s): ${fd.slice(0, 5).join(', ')}`);
const fh = git(['rev-parse', '--short', 'HEAD'], NODE_DIR).out;
state.forkCommit = fh; state.forkBranch = git(['branch', '--show-current'], NODE_DIR).out;
if (flags['node-commit'] && !git(['rev-parse', 'HEAD'], NODE_DIR).out.startsWith(flags['node-commit'])) problems.push(`the fork is on ${fh}, not --node-commit ${flags['node-commit']}`);
}
// binaries
const bins = [];
if (WIN) for (const p of WIN_INPUTS) bins.push([p, 'the Windows node fork build (proto-cuda/windows-node/cross-build.sh)']);
if (MAC) for (const n of ['igneumd', 'igneum-miner']) bins.push([join(MAC_RELEASE, n), 'the Mac node fork build (cargo build --release in the fork, target-integration)']);
for (const [p, why] of bins) if (!existsSync(p)) problems.push(`missing ${p}: ${why}`);
if (WIN) for (const w of WORKERS) if (!existsSync(w)) notes.push(`no ${w.replace(ROOT + '/', '')}: the PC builds that worker itself`);
if (MAC) {
for (const n of ['igneum-prove-host', 'igneum-prove-export']) if (!existsSync(join(ROOT, 'proving', 'igneum-prove', 'target', 'release', n))) notes.push(`no proving/igneum-prove/target/release/${n}: the DMG ships without the prover`);
for (const n of ['igneum.icns', 'igneum-volume.icns']) if (!existsSync(join(ROOT, 'brand', 'icons', n))) problems.push(`no brand/icons/${n}: python3 brand/icons/make-icons.py`);
if (!has('swiftc')) problems.push('swiftc is missing (xcode-select --install)');
if (!has('dmgbuild')) notes.push('dmgbuild is missing (pip3 install dmgbuild): the DMG would have no icon layout');
}
for (const t of ['gh', 'cargo', 'npx', 'zip', 'curl', 'python3']) if (!has(t)) problems.push(`${t} is not on PATH`);
if (!existsSync(SIGNER)) notes.push('igneum-ota-sign is not built yet; publish-manifest.sh builds it (cargo, about a minute)');
// secrets: presence only
for (const n of ['dl-token', 'dlsite-dir', 'ota-signing-key', 'ota-signing-key.pub', 'relay-token', 'relay-key']) if (!existsSync(join(CFG, n))) problems.push(`no ~/.config/igneum/${n}`);
if (!existsSync(join(CFG, 'vercel'))) problems.push('no ~/.config/igneum/vercel (the Vercel login for the downloads host)');
if (!DEST || !existsSync(DEST)) problems.push(`no downloads folder at <dlsite>/dl/<token> (~/.config/igneum/dlsite-dir says ${DLSITE || 'nothing'})`);
if (BOTH) {
if (!TOKEN_NEXT) problems.push('--dl-both needs ~/.config/igneum/dl-token.next (the next downloads token)');
else if (TOKEN_NEXT === TOKEN) problems.push('--dl-both: dl-token.next equals dl-token; nothing to rotate');
else if (!DEST_NEXT || !existsSync(DEST_NEXT)) problems.push('--dl-both: no folder at <dlsite>/dl/<dl-token.next>; mkdir it first (an empty folder is fine)');
if (!existsSync(join(CFG, 'log-intake-key.next'))) notes.push('no ~/.config/igneum/log-intake-key.next: the packagers ship the current intake key (packaged-config.sh)');
}
// gh account (a read; the real steps switch before every call)
const st = runSync('gh', ['auth', 'status']).out;
const active = /Logged in to github\.com account (\S+) \(keyring\)\n\s+- Active account: true/.exec(st);
const ghActive = active ? active[1] : 'unknown';
if (!st.includes(GH_USER)) problems.push(`gh has no ${GH_USER} login (gh auth login)`);
// what is live now
const live = { inputs: await getJson(`${BASE}/payload-inputs.json`), ci: await getJson(`${BASE}/igneum-windows-ci.json`), manifest: await getJson(`${BASE}/igneum-app-latest.json`) };
state.headAtPreflight = headSha;
say('');
say(`Igneum Miner ${VERSION}${DRY ? ' (dry run: reads only)' : ''}`);
table([
['tree', `${branch} ${headSha.slice(0, 12)}${dirty.length ? ` (${dirty.length} modified)` : ' (clean)'}`],
['version files', c.version ? `${c.version} in all ${c.rows.length}` : `DISAGREE ${c.rows.map(r => `${basename(r.file)}=${r.versions.join('/')}`).join(' ')}`],
['fork', `${NODE_DIR.replace(ROOT + '/', '')} ${state.forkCommit || '?'} (${state.forkBranch || '?'})`],
['windows exes', WIN ? WIN_INPUTS.map(p => existsSync(p) ? `${basename(p)} ${fmtSize(sizeOf(p))}` : `${basename(p)} MISSING`).join(', ') : 'skipped'],
['mac binaries', MAC ? ['igneumd', 'igneum-miner'].map(n => existsSync(join(MAC_RELEASE, n)) ? `${n} ${fmtSize(sizeOf(join(MAC_RELEASE, n)))}` : `${n} MISSING`).join(', ') : 'skipped'],
['workers', WORKERS.map(w => existsSync(w) ? basename(w) : `${basename(w)} missing`).join(', ')],
['downloads folder', DEST ? DEST.replace(TOKEN, '<token>') : 'none'],
['next folder', BOTH ? (DEST_NEXT ? DEST_NEXT.replace(TOKEN_NEXT, '<token.next>') : 'MISSING') : 'not used (no --dl-both)'],
['gh active', `${ghActive}${ghActive === GH_USER ? '' : ` (switched to ${GH_USER} before every call)`}`],
['live inputs', live.inputs ? `node ${live.inputs.node_source_commit} built ${live.inputs.built_at}` : 'none'],
['live ci', live.ci ? `${live.ci.installer} (${live.ci.run.split('/').pop()})` : 'none'],
['live manifest', live.manifest ? `${live.manifest.version} ${Object.keys(live.manifest.platforms || {}).join('+')} published ${live.manifest.published_at}` : 'none'],
['notes', NOTES],
]);
for (const n of notes) say(` note: ${n}`);
if (problems.length && DRY) {
// a dry run reads everything and still prints the plan; the problems are the first thing the real run would say
say(` ${problems.length} problem(s) the real run would stop on:`);
for (const p of problems) say(` - ${p}`);
dryProblems = problems.length;
return done('preflight', `${problems.length} problem(s)`, 'listed above; the plan follows');
}
if (problems.length) throw new Error(`preflight found ${problems.length} problem(s):\n - ${problems.join('\n - ')}`);
done('preflight', 'ok', `${c.version || 'versions disagree'} -> ${VERSION}, fork ${state.forkCommit}`);
}
async function bump() {
const c = checkVersionFiles(ROOT);
if (c.version === VERSION) return done('bump', 'already', `${VERSION} in all ${c.rows.length} files`);
if (DRY) return done('bump', 'would', `write ${VERSION} to ${VERSION_FILES.length} files (${c.rows.map(r => `${basename(r.file)} ${r.versions.join('/')}`).join(', ')})`);
const rep = bumpVersionFiles(ROOT, VERSION);
table([['file', 'was', 'now'], ...rep.map(r => [r.file, r.was, r.now + (r.changed ? '' : ' (unchanged)')])]);
state.bumpedAt = new Date().toISOString(); saveState();
done('bump', 'ok', `${VERSION} written and read back in ${rep.length} files`);
}
async function inputs() {
if (!WIN) return done('inputs', 'skipped', '--skip-windows');
const files = [...WIN_INPUTS, ...WORKERS.filter(existsSync)];
const live = await getJson(`${BASE}/payload-inputs.json`);
const same = live && live.node_source_commit === state.forkCommit && files.every(p => live.files && live.files[basename(p)] && live.files[basename(p)].sha256 === sha256(p));
if (same) return done('inputs', 'already', `payload-inputs.zip carries these files from fork ${state.forkCommit} (built ${live.built_at})`);
const cmd = `IGNEUM_WIN_RELEASE=${WIN_RELEASE} IGNEUM_NODE_SRC=${NODE_DIR} packaging/windows/push-inputs.sh`;
if (DRY) return done('inputs', 'would', `run ${cmd} (deploys the downloads folder)`);
const r = await run('bash', [join(ROOT, 'packaging', 'windows', 'push-inputs.sh')], { env: { IGNEUM_WIN_RELEASE: WIN_RELEASE, IGNEUM_NODE_SRC: NODE_DIR } });
if (r.code !== 0) throw new Error(`push-inputs.sh exited ${r.code}; retry by hand: ${cmd}`);
const after = await getJson(`${BASE}/payload-inputs.json`);
if (!after || after.node_source_commit !== state.forkCommit) throw new Error(`the live payload-inputs.json does not name fork ${state.forkCommit} after the push`);
done('inputs', 'ok', `payload-inputs.zip live, fork ${state.forkCommit}`);
}
async function commit() {
const paths = VERSION_FILES.map(f => f.path);
const changed = git(['status', '--porcelain', '--', ...paths]).out.split('\n').filter(Boolean);
const msg = `Igneum Miner ${VERSION}: ${NOTES}`;
if (changed.length) {
if (DRY) return done('commit', 'would', `git commit ${paths.length} files as "${msg}" and push origin master`);
const a = git(['add', '--', ...paths]); if (a.code !== 0) throw new Error(`git add failed: ${a.out}`);
const cm = git(['commit', '-m', msg]); if (cm.code !== 0) throw new Error(`git commit failed: ${cm.out}`);
} else if (DRY) { done('commit', 'would', 'nothing to commit (the files are committed); push if origin/master lacks HEAD'); return; }
const sha = git(['rev-parse', 'HEAD']).out;
const subject = git(['log', '-1', '--format=%s']).out;
if (!subject.startsWith(`Igneum Miner ${VERSION}`)) say(` note: HEAD is "${subject.slice(0, 80)}", not the version commit; the Windows build runs on whatever master is`);
const pushed = git(['merge-base', '--is-ancestor', sha, 'origin/master']).code === 0;
if (!pushed) {
const sw = runSync('gh', ['auth', 'switch', '--user', GH_USER]); if (sw.code !== 0) throw new Error(`gh auth switch failed: ${sw.out}`);
const p = await run('git', ['push', 'origin', 'master']);
if (p.code !== 0) throw new Error(`git push origin master exited ${p.code}; retry: gh auth switch --user ${GH_USER} && git push origin master`);
}
state.sha = sha; saveState();
done('commit', pushed && !changed.length ? 'already' : 'ok', `${sha.slice(0, 12)} ${pushed ? 'was on' : 'pushed to'} origin/master`);
}
async function ci() {
if (!WIN) return done('ci', 'skipped', '--skip-windows');
const sha = state.sha || git(['rev-parse', 'HEAD']).out;
if (DRY) return done('ci', 'would', `gh run list --workflow ${WORKFLOW} --commit ${sha.slice(0, 12)}, dispatch if none, poll every 30 s until green (gh auth switch before every call)`);
const fields = 'databaseId,status,conclusion,url,createdAt';
const list = async () => (await ghJson(['run', 'list', '--repo', REPO, '--workflow', WORKFLOW, '--commit', sha, '--limit', '5', '--json', fields]));
let runs = await list();
let pick = runs.find(r => r.conclusion === 'success') || runs.find(r => r.status !== 'completed') || runs[0];
if (pick && pick.conclusion === 'success') { state.runId = pick.databaseId; saveState(); return done('ci', 'already', `${pick.url} green`); }
if (!pick) {
say(` no ${WORKFLOW} run for ${sha.slice(0, 12)} yet; waiting up to 3 minutes for the push to start one`);
for (let i = 0; i < 12 && !pick; i++) { await sleep(15000); runs = await list(); pick = runs[0]; }
if (!pick) {
say(` dispatching: gh workflow run ${WORKFLOW} --ref master`);
const d = await gh(['workflow', 'run', WORKFLOW, '--repo', REPO, '--ref', 'master']); if (d.code !== 0) throw new Error(`gh workflow run failed: ${d.out.trim()}`);
for (let i = 0; i < 12 && !pick; i++) { await sleep(15000); runs = await list(); pick = runs[0]; }
if (!pick) throw new Error(`no run appeared for ${sha.slice(0, 12)}; check https://github.com/${REPO}/actions and retry: ${retryCmd('ci')}`);
}
}
if (pick.status === 'completed' && pick.conclusion !== 'success') throw new Error(`the run for this commit ended ${pick.conclusion}: ${pick.url}\n rerun it (gh run rerun ${pick.databaseId} --repo ${REPO} --failed) or push a fix, then: ${retryCmd('ci')}`);
say(` run ${pick.url} (${pick.status}); polling every 30 s, up to 90 minutes`);
const t0 = Date.now();
for (;;) {
const v = await ghJson(['run', 'view', String(pick.databaseId), '--repo', REPO, '--json', 'status,conclusion,url,jobs']);
const running = (v.jobs || []).filter(j => j.status === 'in_progress').map(j => { const s = (j.steps || []).find(x => x.status === 'in_progress'); return `${j.name.split(',')[0]}${s ? ' > ' + s.name.split(' (')[0] : ''}`; }).join('; ');
const mins = Math.round((Date.now() - t0) / 60000);
if (v.status === 'completed') {
if (v.conclusion !== 'success') throw new Error(`run ${v.url} ended ${v.conclusion} after ${mins} min; gh run view ${pick.databaseId} --repo ${REPO} --log-failed, then ${retryCmd('ci')}`);
state.runId = pick.databaseId; saveState();
return done('ci', 'ok', `${v.url} green after ${mins} min`);
}
say(` ${mins} min: ${v.status}${running ? ' (' + running + ')' : ''}`);
if (Date.now() - t0 > 90 * 60000) throw new Error(`still ${v.status} after 90 minutes: ${v.url}; retry: ${retryCmd('ci')}`);
await sleep(30000);
}
}
async function fetchStep() {
if (!WIN) return done('fetch', 'skipped', '--skip-windows');
const setup = join(DEST, SETUP_NAME);
const ciJson = (() => { try { return JSON.parse(readFileSync(join(DEST, 'igneum-windows-ci.json'), 'utf8')); } catch { return null; } })();
if (state.runId && existsSync(setup) && ciJson && String(ciJson.run || '').endsWith(`/${state.runId}`) && ciJson.installer === SETUP_NAME) return done('fetch', 'already', `${SETUP_NAME} ${fmtSize(sizeOf(setup))} from run ${state.runId}`);
const cmd = `OTA_SKIP=1 CONSOLE_SKIP=1 packaging/windows/fetch-ci-artifacts.sh ${state.runId || '<run id>'}`;
if (DRY) return done('fetch', 'would', `run ${cmd} (no deploy here; one deploy later)`);
if (!state.runId) throw new Error(`no run id for ${VERSION}; run the ci step first: ${retryCmd('ci')}`);
const sw = runSync('gh', ['auth', 'switch', '--user', GH_USER]); if (sw.code !== 0) throw new Error(`gh auth switch failed: ${sw.out}`);
const r = await run('bash', [join(ROOT, 'packaging', 'windows', 'fetch-ci-artifacts.sh'), String(state.runId)], { env: { OTA_SKIP: '1', CONSOLE_SKIP: '1' } });
if (r.code !== 0) throw new Error(`fetch-ci-artifacts.sh exited ${r.code}; retry: ${cmd}`);
if (!existsSync(setup)) throw new Error(`the run delivered no ${SETUP_NAME} (its Cargo.toml version differs?); ls ${DEST.replace(TOKEN, '<token>')}`);
done('fetch', 'ok', `${SETUP_NAME} ${fmtSize(sizeOf(setup))}, ${ZIP_NAME} ${fmtSize(sizeOf(join(DEST, ZIP_NAME)))}`);
}
async function dmg() {
if (!MAC) return done('dmg', 'skipped', '--skip-mac');
const fresh = existsSync(DMG_DIST) && (!state.bumpedAt || statSync(DMG_DIST).mtime.toISOString() > state.bumpedAt);
if (fresh && !flags.rebuild) return done('dmg', 'already', `${DMG_DIST.replace(ROOT + '/', '')} ${fmtSize(sizeOf(DMG_DIST))} (--rebuild forces)`);
const env = { NODE: join(MAC_RELEASE, 'igneumd'), MINER: join(MAC_RELEASE, 'igneum-miner') };
const cmd = `NODE=${env.NODE} MINER=${env.MINER} tools/lock/with-lock.sh build packaging/mac/build-dmg.sh`;
if (DRY) return done('dmg', 'would', `run ${cmd} (engine with cargo -j 4 at nice 19, window, worker; waits for the build lock)`);
const r = await run('bash', [join(ROOT, 'tools', 'lock', 'with-lock.sh'), 'build', join(ROOT, 'packaging', 'mac', 'build-dmg.sh')], { env });
if (r.code !== 0) throw new Error(`build-dmg.sh exited ${r.code}; retry: ${cmd}`);
if (!existsSync(DMG_DIST)) throw new Error(`build-dmg.sh ended without ${DMG_DIST}`);
done('dmg', 'ok', `${DMG_NAME} ${fmtSize(sizeOf(DMG_DIST))}`);
}
async function copy() {
if (!MAC) return done('copy', 'skipped', '--skip-mac');
const dst = join(DEST, DMG_NAME);
if (DRY) return done('copy', 'would', `copy ${DMG_NAME} into the downloads folder${existsSync(dst) ? ' (replacing the one there)' : ''}`);
if (!existsSync(DMG_DIST)) throw new Error(`no ${DMG_DIST}; ${retryCmd('dmg')}`);
if (existsSync(dst) && sha256(dst) === sha256(DMG_DIST)) return done('copy', 'already', `${DMG_NAME} is in the downloads folder with the same sha256`);
copyFileSync(DMG_DIST, dst);
done('copy', 'ok', `${DMG_NAME} ${fmtSize(sizeOf(dst))} copied`);
}
async function mirror() {
if (!BOTH) return done('mirror', 'skipped', 'no --dl-both');
const names = [MAC && DMG_NAME, WIN && SETUP_NAME, WIN && ZIP_NAME, ...FOLDER_FILES].filter(Boolean);
const plan = mirrorPlan(DEST, DEST_NEXT, names);
const copies = plan.filter(p => p.action === 'copy'), same = plan.filter(p => p.action === 'same'), absent = plan.filter(p => p.action === 'absent');
const summary = `${copies.length} to copy (${copies.map(p => p.name).join(', ') || 'none'}), ${same.length} same, ${absent.length} absent in the current folder${absent.length ? ` (${absent.map(p => p.name).join(', ')})` : ''}`;
if (DRY) return done('mirror', 'would', `copy into dl/<token.next>/: ${summary}`);
for (const name of [MAC && DMG_NAME, WIN && SETUP_NAME].filter(Boolean)) if (!existsSync(join(DEST, name))) throw new Error(`missing ${name} in the current folder; ${retryCmd(name.endsWith('.dmg') ? 'copy' : 'fetch')}`);
for (const p of copies) copyFileSync(join(DEST, p.name), join(DEST_NEXT, p.name));
const after = mirrorPlan(DEST, DEST_NEXT, names).filter(p => p.action === 'copy');
if (after.length) throw new Error(`still differ after the copy: ${after.map(p => p.name).join(', ')}`);
done('mirror', copies.length ? 'ok' : 'already', summary);
}
async function manifest() {
const args = ['--version', VERSION, '--notes', NOTES, '--no-deploy'];
if (MAC) args.push('--mac', join(DEST, DMG_NAME));
if (WIN) args.push('--win', join(DEST, SETUP_NAME));
for (const k of ['min-supported', 'activation-height', 'deadline-note', 'channel']) if (flags[k]) args.push(`--${k}`, String(flags[k]));
const cmd = `packaging/ota/publish-manifest.sh ${args.map(a => a.includes(' ') ? JSON.stringify(a) : a).join(' ')}`;
if (DRY) return done('manifest', 'would', `run ${cmd.replace(TOKEN, '<token>')} (signs, verifies the signature; ${WIN && MAC ? 'both platforms' : WIN ? 'windows entry, mac carried over when the live manifest is this version' : 'mac entry, windows carried over when the live manifest is this version'})${BOTH ? '; then the same for dl/<token.next>/ with --dest and --base-url, carrying this manifest\'s override, tuning and min_supported; the two compared field by field' : ''}`);
for (const p of [MAC && join(DEST, DMG_NAME), WIN && join(DEST, SETUP_NAME)].filter(Boolean)) if (!existsSync(p)) throw new Error(`missing ${p.replace(TOKEN, '<token>')}; ${retryCmd(p.endsWith('.dmg') ? 'copy' : 'fetch')}`);
const r = await run('bash', [join(ROOT, 'packaging', 'ota', 'publish-manifest.sh'), ...args]);
if (r.code !== 0) throw new Error(`publish-manifest.sh exited ${r.code}; retry: ${cmd.replace(TOKEN, '<token>')}`);
const m = JSON.parse(readFileSync(join(DEST, 'igneum-app-latest.json'), 'utf8'));
if (m.version !== VERSION) throw new Error(`the written manifest says ${m.version}`);
if (!BOTH) return done('manifest', 'ok', `${VERSION} ${Object.keys(m.platforms).join('+')}, signed and verified locally`);
// the NEXT folder: the same entries from its own copies, the same override, tuning and min_supported
const tmp = mkdtempSync(join(tmpdir(), 'igneum-ship-m-'));
try {
const [extra, tuningFile] = secondManifestArgs(m, DEST_NEXT, BASE_NEXT, tmp);
const args2 = ['--version', VERSION, '--notes', NOTES, '--no-deploy', ...extra];
if (MAC) args2.push('--mac', join(DEST_NEXT, DMG_NAME));
if (WIN) args2.push('--win', join(DEST_NEXT, SETUP_NAME));
for (const k of ['activation-height', 'deadline-note', 'channel']) if (flags[k]) args2.push(`--${k}`, String(flags[k]));
const cmd2 = `packaging/ota/publish-manifest.sh ${args2.map(a => a.includes(' ') || a.startsWith('{') ? JSON.stringify(a) : a).join(' ')}`;
const r2 = await run('bash', [join(ROOT, 'packaging', 'ota', 'publish-manifest.sh'), ...args2]);
if (r2.code !== 0) throw new Error(`publish-manifest.sh (next folder) exited ${r2.code}; retry: ${cmd2.replace(TOKEN_NEXT, '<token.next>')}`);
const m2 = JSON.parse(readFileSync(join(DEST_NEXT, 'igneum-app-latest.json'), 'utf8'));
const diffs = manifestDifferences(m, m2, BASE, BASE_NEXT);
if (diffs.length) throw new Error(`the two manifests differ beyond the folder and the publish time:\n ${diffs.join('\n ')}`);
if (tuningFile) say(` tuning carried into the next folder (${Object.keys(m.tuning.cards).length} card model(s))`);
done('manifest', 'ok', `${VERSION} ${Object.keys(m.platforms).join('+')} in both folders, signed, verified locally, same fields`);
} finally { rmSync(tmp, { recursive: true, force: true }); }
}
async function deploy() {
const cmd = `cd ${DLSITE} && npx --yes vercel@latest --global-config ~/.config/igneum/vercel deploy --prod --yes`;
if (DRY) return done('deploy', 'would', `run ${cmd} (one deploy: files and manifest together)`);
const r = await run('npx', ['--yes', 'vercel@latest', '--global-config', join(CFG, 'vercel'), 'deploy', '--prod', '--yes'], { cwd: DLSITE });
if (r.code !== 0) throw new Error(`vercel deploy exited ${r.code}; retry: ${cmd}`);
state.deployedAt = new Date().toISOString(); saveState();
done('deploy', 'ok', 'downloads folder deployed');
}
// one folder: HEAD and GET of the files against the local copies, the manifest's bytes and signature; the failures
async function verifyFolder(dest, base, files, label) {
const rows = [['file', 'local', 'HEAD', 'sha256']];
const failures = [];
const tmp = mkdtempSync(join(tmpdir(), 'igneum-ship-'));
try {
for (const name of files) {
const local = join(dest, name); const want = sha256(local); const size = sizeOf(local);
let h, got = '';
for (let attempt = 1; attempt <= 3; attempt++) {
h = await head(`${base}/${name}`);
if (h.status === 200 && (h.length === null || h.length === size)) {
const r = await run('curl', ['-fsSL', '--retry', '3', '-o', join(tmp, name), `${base}/${name}`], { quiet: true });
if (r.code === 0) { got = sha256(join(tmp, name)); if (got === want) break; }
}
if (attempt < 3) { say(` ${name}: not matching yet (HTTP ${h.status}, length ${h.length}, sha ${got ? got.slice(0, 12) : '-'}); again in 15 s`); await sleep(15000); }
}
const ok = h.status === 200 && (h.length === null || h.length === size) && got === want;
rows.push([name, `${size} B ${want.slice(0, 12)}`, `${h.status} ${h.length === null ? '(no length)' : h.length + ' B'}`, got === want ? `ok ${want.slice(0, 12)}` : `MISMATCH ${got.slice(0, 12) || 'no body'}`]);
if (!ok) failures.push(`${label}:${name}`);
state.files = state.files || {}; state.files[name] = { size, sha256: want, served: ok };
}
// the manifest: bytes and signature, through the same verifier the apps use
const mr = await run('curl', ['-fsSL', '-o', join(tmp, 'm.json'), `${base}/igneum-app-latest.json`], { quiet: true });
const sr = await run('curl', ['-fsSL', '-o', join(tmp, 'm.sig'), `${base}/igneum-app-latest.json.sig`], { quiet: true });
let mline = 'not reachable';
if (mr.code === 0 && sr.code === 0) {
const v = await run(SIGNER, ['verify', join(CFG, 'ota-signing-key.pub'), join(tmp, 'm.json'), join(tmp, 'm.sig')], { quiet: true });
const m = JSON.parse(readFileSync(join(tmp, 'm.json'), 'utf8'));
const same = readFileSync(join(tmp, 'm.json')).equals(readFileSync(join(dest, 'igneum-app-latest.json')));
const plat = Object.entries(m.platforms || {}).map(([k, e]) => `${k} ${e.sha256.slice(0, 12)}`).join(', ');
const inFolder = Object.values(m.platforms || {}).every(e => typeof e.url === 'string' && e.url.startsWith(base + '/'));
mline = `${m.version} ${v.code === 0 ? 'signature ok' : 'SIGNATURE FAILS'}${same ? '' : ' (DIFFERS from the local manifest)'}${inFolder ? '' : ' (URLS POINT OUTSIDE THIS FOLDER)'} ${plat}`;
if (v.code !== 0 || m.version !== VERSION || !same || !inFolder) failures.push(`${label}:manifest`);
if (!state.manifest || label === 'current') state.manifest = { version: m.version, published_at: m.published_at, platforms: m.platforms };
} else failures.push(`${label}:manifest`);
rows.push(['igneum-app-latest.json', '', '', mline]);
} finally { rmSync(tmp, { recursive: true, force: true }); }
say(` ${label} folder: ${label === 'next' ? 'dl/<token.next>/' : 'dl/<token>/'}`);
table(rows);
return failures;
}
async function verify() {
const files = [MAC && DMG_NAME, WIN && SETUP_NAME, WIN && ZIP_NAME].filter(Boolean);
if (DRY) return done('verify', 'would', `HEAD and GET ${files.join(', ')} (size and sha256 against the local files), GET the manifest and its .sig, igneum-ota-sign verify${BOTH ? '; the same for dl/<token.next>/' : ''}`);
const failures = await verifyFolder(DEST, BASE, files, 'current');
if (BOTH) failures.push(...await verifyFolder(DEST_NEXT, BASE_NEXT, files, 'next'));
saveState();
if (failures.length) throw new Error(`not served as expected: ${failures.join(', ')}; the deploy may still be propagating. Retry: ${retryCmd('deploy')}`);
done('verify', 'ok', `${files.length} files and the manifest match the local copies${BOTH ? ' in both folders' : ''}`);
}
async function consoleStep() {
const lines = Object.entries(state.files || {}).map(([n, f]) => `${n} ${f.size} B sha256 ${f.sha256}`);
const body = [NOTES, ...lines, state.runId ? `Windows CI https://github.com/${REPO}/actions/runs/${state.runId}` : '', state.sha ? `commit ${state.sha.slice(0, 12)}, node fork ${state.forkCommit || '?'}` : '', BOTH ? 'published in both downloads folders (token rotation)' : ''].filter(Boolean).join('\n');
const meta = { version: VERSION, files: state.files || {}, run: state.runId || null, commit: state.sha || null, fork: state.forkCommit || null, manifest_published_at: state.manifest ? state.manifest.published_at : null };
if (DRY) return done('console', 'would', `tools/console.mjs post --kind build --key ship:${VERSION} --title "Igneum Miner ${VERSION} shipped" (sizes, hashes, run, commit), then sync-dl`);
const r = await run('node', [join(ROOT, 'tools', 'console.mjs'), 'post', '--kind', 'build', '--key', `ship:${VERSION}`, '--title', `Igneum Miner ${VERSION} shipped (${[MAC && 'mac', WIN && 'windows'].filter(Boolean).join('+')})`, '--body', body, '--meta', JSON.stringify(meta)], { quiet: true });
if (r.code !== 0) throw new Error(`console post failed: ${r.out.trim()}; retry: ${retryCmd('console')}`);
await run('node', [join(ROOT, 'tools', 'console.mjs'), 'sync-dl'], { quiet: true });
done('console', 'ok', r.out.trim().split('\n').pop());
}
// ---- the runner ----------------------------------------------------------------------------------------------------
const IMPL = { preflight, bump, inputs, commit, ci, fetch: fetchStep, dmg, copy, mirror, manifest, deploy, verify, console: consoleStep };
async function main() {
const start = flags.from ? STEPS.indexOf(flags.from) : 0;
// preflight always runs: it is reads only and the later steps need its facts (fork commit, state)
const todo = start === 0 ? STEPS : ['preflight', ...STEPS.slice(start)];
if (start > 0) say(`resuming from ${flags.from} (state ${STATE_FILE})`);
const t0 = Date.now();
let failed = null;
for (const step of todo) {
try { await IMPL[step](); }
catch (e) {
failed = step;
results.push([step, 'FAILED', e.message.split('\n')[0]]);
say(`\n[${step}] FAILED: ${e.message}`);
if (step !== 'preflight') say(`retry: ${retryCmd(step)}`);
else say(`fix the problems above, then run the same command again`);
break;
}
}
say('');
say(`${DRY ? 'Plan' : failed ? 'Stopped' : 'Shipped'}: Igneum Miner ${VERSION} (${Math.round((Date.now() - t0) / 1000)} s)`);
table([['step', 'result', 'detail'], ...results.map(([s, r, d]) => [s, r, d.length > 110 ? d.slice(0, 107) + '...' : d])]);
if (failed) { const rest = STEPS.slice(STEPS.indexOf(failed) + 1); if (rest.length) say(`not run: ${rest.join(', ')}`); }
if (DRY && dryProblems) { say(`dry run: ${dryProblems} preflight problem(s) to fix before the real run`); process.exit(1); }
if (DRY) say('dry run: nothing was written; the same command without --dry-run ships it');
if (!DRY && !failed) {
const f = state.files || {};
say('');
table([['file', 'bytes', 'sha256'], ...Object.entries(f).map(([n, v]) => [n, v.size, v.sha256])]);
say(`manifest ${VERSION} published ${state.manifest ? state.manifest.published_at : '?'}; every app checks within the hour (Settings > Check now at once)`);
}
process.exit(failed ? 1 : 0);
}
// ---- --self-test: the bump on a scratch copy of the six files ---------------------------------------------------------
function selfTest() {
const dir = mkdtempSync(join(tmpdir(), 'igneum-ship-test-'));
let fails = 0;
const check = (name, ok, detail = '') => { say(` ${ok ? 'ok ' : 'FAIL'} ${name}${detail ? ': ' + detail : ''}`); if (!ok) fails++; };
try {
for (const f of VERSION_FILES) { mkdirSync(join(dir, dirname(f.path)), { recursive: true }); copyFileSync(join(ROOT, f.path), join(dir, f.path)); }
const originals = Object.fromEntries(VERSION_FILES.map(f => [f.path, readFileSync(join(ROOT, f.path), 'utf8')]));
say(`self-test in ${dir}`);
const before = checkVersionFiles(dir);
say(` tree versions: ${before.all.join(', ')}`);
// 1. bump to a version no file carries, read back
const rep = bumpVersionFiles(dir, '9.8.7');
check('bump to 9.8.7 touched every file', rep.every(r => r.changed), rep.filter(r => !r.changed).map(r => r.file).join(', '));
const after = checkVersionFiles(dir);
check('every pattern reads 9.8.7', after.version === '9.8.7', after.all.join(', '));
const rc = readFileSync(join(dir, 'app/igneum-app/resources/igneum-app.rc'), 'utf8');
check('rc carries the comma form 9,8,7,0 twice', (rc.match(/9,8,7,0/g) || []).length === 2);
check('rc carries the string form twice', (rc.match(/"9\.8\.7"/g) || []).length === 2);
const h = readFileSync(join(dir, 'app/windows/version.h'), 'utf8');
check('version.h has both forms', h.includes('"9.8.7"') && h.includes('9,8,7,0'));
const lock = readFileSync(join(dir, 'app/igneum-app/Cargo.lock'), 'utf8');
check('Cargo.lock changed only the igneum-app block', lock.split('\n').filter(l => l.startsWith('version = ')).filter(l => l.includes('9.8.7')).length === 1);
const toml = readFileSync(join(dir, 'app/igneum-app/Cargo.toml'), 'utf8');
check('Cargo.toml changed only the [package] version', (toml.match(/^version = "9\.8\.7"/gm) || []).length === 1 && toml.replace(/^version = "9\.8\.7"/m, '') === originals['app/igneum-app/Cargo.toml'].replace(/^version = "[^"]+"/m, ''));
// 2. the same bump again is a no-op
const again = bumpVersionFiles(dir, '9.8.7');
check('a second bump to 9.8.7 changes nothing', again.every(r => !r.changed));
// 3. back to each file's original version restores the bytes exactly (a file whose original differs keeps its own)
for (const f of VERSION_FILES) {
const orig = readVersions(originals[f.path], f)[0];
writeFileSync(join(dir, f.path), writeVersion(readFileSync(join(dir, f.path), 'utf8'), f, orig));
check(`${f.path} restored byte for byte at ${orig}`, readFileSync(join(dir, f.path), 'utf8') === originals[f.path]);
}
// 4. a bad version is refused
let refused = false; try { bumpVersionFiles(dir, '1.2'); } catch { refused = true; }
check('1.2 is refused', refused);
check('comma helpers round-trip', toComma('0.3.4') === '0,3,4,0' && fromComma('0,3,4,0') === '0.3.4' && fromComma('0,3,4,1') === '0,3,4,1');
check('version compare', cmpVersion('0.3.4', '0.3.3') > 0 && cmpVersion('0.10.0', '0.9.9') > 0 && cmpVersion('1.0.0', '1.0.0') === 0);
// 5. --dl-both helpers on two scratch folders
const a = join(dir, 'dl', 'old'), b = join(dir, 'dl', 'new');
mkdirSync(a, { recursive: true }); mkdirSync(b, { recursive: true });
writeFileSync(join(a, 'x.dmg'), 'dmg bytes'); writeFileSync(join(a, 'same.json'), 'same'); writeFileSync(join(b, 'same.json'), 'same');
writeFileSync(join(a, 'differs.zip'), 'v2'); writeFileSync(join(b, 'differs.zip'), 'v1');
const plan = Object.fromEntries(mirrorPlan(a, b, ['x.dmg', 'same.json', 'differs.zip', 'absent.sig']).map(p => [p.name, p.action]));
check('mirror plan: missing -> copy, same -> same, different -> copy, absent -> absent', plan['x.dmg'] === 'copy' && plan['same.json'] === 'same' && plan['differs.zip'] === 'copy' && plan['absent.sig'] === 'absent', JSON.stringify(plan));
const first = { version: '0.3.6', published_at: '2026-10-05T12:00:00Z', channel: 'devnet', notes: 'n', min_supported_version: '0.3.0', platforms: { mac: { url: 'https://dl.igneum.network/dl/OLD/Igneum-Miner-0.3.6.dmg', sha256: 'aa', size: 1, kind: 'dmg' } }, consensus: { activation_height: null, deadline_note: '', override: { difficulty_v2_activation_daa: 33000 } }, tuning: { cards: { 'RTX 5090': { v: 1 } } } };
const [args, tuningFile] = secondManifestArgs(first, '/dest', 'https://dl.igneum.network/dl/NEW', dir);
check('second manifest args carry override, min_supported and tuning', args.includes('--override') && args[args.indexOf('--override') + 1] === '{"difficulty_v2_activation_daa":33000}' && args.includes('--min-supported') && args[args.indexOf('--min-supported') + 1] === '0.3.0' && args.includes('--tuning') && tuningFile && JSON.parse(readFileSync(tuningFile, 'utf8')).cards['RTX 5090'].v === 1, args.join(' '));
const [args0] = secondManifestArgs({ version: '0.3.6', platforms: {} }, '/dest', 'https://x', dir);
check('second manifest args without override or tuning say --no-tuning and no --override', args0.includes('--no-tuning') && !args0.includes('--override') && !args0.includes('--min-supported'), args0.join(' '));
const second = JSON.parse(JSON.stringify(first)); second.published_at = '2026-10-05T12:01:00Z'; second.platforms.mac.url = 'https://dl.igneum.network/dl/NEW/Igneum-Miner-0.3.6.dmg';
check('two manifests that differ only by folder and time agree', manifestDifferences(first, second, 'https://dl.igneum.network/dl/OLD', 'https://dl.igneum.network/dl/NEW').length === 0);
second.consensus.override.difficulty_v2_activation_daa = 1; delete second.tuning;
const d = manifestDifferences(first, second, 'https://dl.igneum.network/dl/OLD', 'https://dl.igneum.network/dl/NEW');
check('a changed override and a dropped tuning are reported', d.length === 2 && d.some(x => x.startsWith('consensus')) && d.some(x => x.startsWith('tuning')), d.join(' | '));
} finally { rmSync(dir, { recursive: true, force: true }); }
say(fails ? `${fails} check(s) failed` : 'all checks passed');
return fails ? 1 : 0;
}
main().catch(e => { console.error(scrub(e.stack || e.message)); process.exit(1); });