#!/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 [--notes "..."] [--dry-run] [--from ] [--until ] // [--skip-windows | --skip-mac] [--node-commit ] [--win-release ] [--mac-release ] // [--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 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 : " 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 : 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//, 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/.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: /(CFBundleShortVersionString<\/key>\s*)([^<]+)(<\/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(''), 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}${url.includes('?') ? '&' : '?'}t=${Date.now()}`, { headers: { 'Cache-Control': 'no-cache' } }); if (!r.ok) return null; return r.json().catch(() => null); } // the state keeps the fork commit as the preflight read it (short with --branch, full with --node-commit); the inputs // manifest carries the full one (6 October 2026, 0.3.15: `!==` on short against full re-pushed the inputs twice and failed) const sameCommit = (a, b) => !!a && !!b && (String(a).startsWith(String(b)) || String(b).startsWith(String(a))); // ---- 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 --node (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 --node [--notes "..."] [--dry-run] [--from ] [--skip-windows|--skip-mac] [--dl-both]\n node tools/ship-app.mjs --check | --self-test'); process.exit(2); } if (!flags.node) { console.error('--node is required (the igneum-node worktree the node and miner were built from)'); process.exit(2); } if (flags.until && !STEPS.includes(flags.until)) { console.error(`--until must be one of: ${STEPS.join(', ')}`); 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, ''); 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 upstream = `origin/${flags.branch || 'master'}`; const behind = git(['rev-parse', '--verify', '-q', upstream]).code === 0 ? git(['rev-list', '--count', `HEAD..${upstream}`]).out : '0'; if (behind !== '0') problems.push(`this tree is ${behind} commit(s) behind ${upstream}; 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 /dl/ (~/.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 /dl/; 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, '') : 'none'], ['next folder', BOTH ? (DEST_NEXT ? DEST_NEXT.replace(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 && sameCommit(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 || !sameCommit(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 ${flags.branch || '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/${flags.branch || '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`); // the push goes to the branch this run was given and only there (6 October 2026: `git push origin master` from a release // worktree pushed the shared checkout's local master ref, said "pushed to origin/master" from its own arithmetic, and the // Windows build was then looked for on master; master merges are main's) const target = flags.branch || 'master'; const pushed = git(['rev-parse', '--verify', '-q', `origin/${target}`]).code === 0 && git(['merge-base', '--is-ancestor', sha, `origin/${target}`]).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', `HEAD:refs/heads/${target}`]); if (p.code !== 0) throw new Error(`git push origin HEAD:${target} exited ${p.code}; retry: gh auth switch --user ${GH_USER} && git push origin HEAD:refs/heads/${target}`); } state.sha = sha; saveState(); done('commit', pushed && !changed.length ? 'already' : 'ok', `${sha.slice(0, 12)} ${pushed ? 'was on' : 'pushed to'} origin/${target}`); } 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 ${flags.branch || 'master'}`); const d = await gh(['workflow', 'run', WORKFLOW, '--repo', REPO, '--ref', flags.branch || '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 || ''}`; 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, '')}`); 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//: ${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, '')} (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// 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, '')}; ${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, '')}`); 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, '')}`); 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//' : 'dl//'}`); 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//' : ''}${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) let todo = start === 0 ? STEPS : ['preflight', ...STEPS.slice(start)]; // --until : stage a cut (6 October 2026, 0.3.15: everything built, signed and verified locally, nothing deployed // until the gate's PASS and the project lead's go); the run ends after that step and says what it left for the resume if (flags.until) { if (!STEPS.includes(flags.until)) { console.error(`--until must be one of: ${STEPS.join(', ')}`); process.exit(2); } const cut = todo.indexOf(flags.until); if (cut >= 0) { const left = todo.slice(cut + 1); todo = todo.slice(0, cut + 1); say(`staging: the run stops after ${flags.until}; left for the resume (--from ${left[0] || '-'}): ${left.join(', ') || 'nothing'}`); } } 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); });