igneum/tools/ship-app.mjs
igneum-labs 1065b81d05 relay: three auth tiers, signed run tasks, machine secrets, retention; clients on headers; TZ=UTC and curl -K checks (X23 X24 X25 X26 X27 X28 X29 G13 G14)
Relay (X23, X27): the intake key is its own tier (upload and file drops only, RELAY_INTAKE_COMPAT=0 closes it);
a run task needs an Ed25519 signature by the Mac run key over {to, nonce, body sha256, flags} (RELAY_RUN_PUB,
401 without) and an HMAC tag with the target's machine secret that the agent verifies before anything runs;
results and registration are bound to the machine the secret proves (403 on a forged from).
X24: every client and Mac tool sends x-relay-token as a header to /api/relay?fn=; the path token stays for the
phone page only. X25: the agent arms the logon task only for a restart a task asked for and disarms on start
and exit. X26: 30-day retention with blob deletion, feed capped at 100, the dl base as RELAY_DL_BASE held by the
agent, never in a body. X28: GET inbox never acks (POST inbox does), RELAY-REBOOT on its own line and only with a
reboot flag, 120/min and 10 failed auths/min per IP, no username or folder on register, WSL sudo scoped to
apt-get and dpkg with SETENV, no password on a command line. X29: the intake key reaches curl through -K in
upload.sh and both upload-log.bat; tools/ci/curl-header-check.sh fails the class. G14: TZ=UTC in ship-app.mjs
and publish-jobs.sh; tools/ci/commit-tz-check.sh fails the class; history-rewrite.md names the .old-2026-10-05
files as the values in the history. The handler moved to relay/lib/handler.mjs with injected sql and blobs
(relay/lib/blob.mjs holds @vercel/blob) so relay/test/handler.test.mjs drives it without a database:
47 tests across 6 suites, all green.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-05 18:46:13 +00:00

705 lines
56 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] [--public]
// 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; with --public every file and alias of dl/public/
// 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.
//
// --public (testnet launch, 5 October 2026, packaging/ota/publish-public.sh): the manifest step also publishes the version
// into dl/public/ (no token in any URL: the site's download buttons and the per-platform aliases under /public/), the
// same deploy carries it, and verify checks every public file and alias. The token folders are never touched by it.
//
// 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() };
}
// TZ=UTC: every commit this tool makes carries +0000, never the local offset (review round 4, ledger G14)
const git = (args, cwd = ROOT) => runSync('git', args, { cwd, env: { TZ: 'UTC' } });
// 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 PUBLIC = !!flags.public;
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', 'payload-inputs.zip', 'payload-inputs.json', 'payload-inputs.json.sig', 'payload-inputs.sha256', 'igneum-windows-ci.json', 'igneum-prove-wsl2.zip', 'igneum-jobs.signed.json'];
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'}${PUBLIC ? ' --public' : ''}${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) -------------------------------------------
// the extra arguments the FIRST publish-manifest.sh call takes for the public folder (the second, --dl-both, call never
// does: dl/public/ derives from the current token folder once)
function publicArgs(isPublic) { return isPublic ? ['--public'] : []; }
// 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]));
args.push(...publicArgs(PUBLIC));
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' : ''}${PUBLIC ? '; and into dl/public/ with public URLs, its own signed manifest, the /public/ aliases rewritten (publish-public.sh --app [--wallet])' : ''}`);
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 (PUBLIC) {
const pm = JSON.parse(readFileSync(join(DLSITE, 'dl', 'public', 'igneum-app-latest.json'), 'utf8'));
if (pm.version !== VERSION) throw new Error(`dl/public/igneum-app-latest.json says ${pm.version}, not ${VERSION}`);
const diffs = manifestDifferences(m, pm, BASE, 'https://dl.igneum.network/dl/public');
if (diffs.length) throw new Error(`the public manifest differs beyond the folder and the publish time:\n ${diffs.join('\n ')}`);
say(` dl/public/: ${VERSION} ${Object.keys(pm.platforms).join('+')}, same fields, URLs under /dl/public/`);
}
if (!BOTH) return done('manifest', 'ok', `${VERSION} ${Object.keys(m.platforms).join('+')}, signed and verified locally${PUBLIC ? ', and in dl/public/' : ''}`);
// 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>/' : ''}${PUBLIC ? '; every file and alias of dl/public/ (publish-public.sh --verify)' : ''}`);
const failures = await verifyFolder(DEST, BASE, files, 'current');
if (BOTH) failures.push(...await verifyFolder(DEST_NEXT, BASE_NEXT, files, 'next'));
if (PUBLIC) {
const r = await run('bash', [join(ROOT, 'packaging', 'ota', 'publish-public.sh'), '--verify', '--no-prune']);
if (r.code !== 0) failures.push('public:folder');
}
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' : ''}${PUBLIC ? '; dl/public/ and the /public/ aliases serve the local bytes' : ''}`);
}
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(' | '));
// 6. --public: the first manifest call carries --public and nothing else changes; the public manifest is compared like the next folder's
check('public args', publicArgs(true).join(' ') === '--public' && publicArgs(false).length === 0);
const pub = JSON.parse(JSON.stringify(first)); pub.published_at = '2026-10-05T12:02:00Z'; pub.platforms.mac.url = 'https://dl.igneum.network/dl/public/Igneum-Miner-0.3.6.dmg';
check('a public manifest that differs only by folder and time agrees', manifestDifferences(first, pub, 'https://dl.igneum.network/dl/OLD', 'https://dl.igneum.network/dl/public').length === 0);
} 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); });