From 33c430d93c6a59cfcd48be112962b14b62c8732d Mon Sep 17 00:00:00 2001 From: igneum-josh <337424239+igneum-josh@users.noreply.github.com> Date: Sun, 4 Oct 2026 19:29:21 +0100 Subject: [PATCH] Ship tool: one command cuts an Igneum Miner version (tools/ship-app.mjs) Josh, 4 Oct 2026: 0.3.1, 0.3.2 and 0.3.3 each took eight hand steps and an hour. The tool runs them in order, each step idempotent and resumable (--from): preflight, bump (six version files, one function, read back), push-inputs, commit and push, the windows.yml run polled with gh (auth switch before every call), fetch, DMG under the build lock, copy, signed manifest, one deploy, HEAD/GET verification with sizes and sha256, one console item. --dry-run prints the plan, --check compares the version files, --self-test bumps a scratch copy. Secrets never printed. Found by --check: Igneum-Miner.iss and Info.plist were left at 0.3.2 when 0.3.3 was cut (CI passed -Version from Cargo.toml, so the installer was right; the Mac bundle said 0.3.2 because build-dmg.sh's sed only matched 0.3.0). Both aligned to 0.3.3; build-dmg.sh now stamps the version with plutil. fetch-ci-artifacts.sh: CONSOLE_SKIP=1. Co-Authored-By: Claude Fable 5.1 --- packaging/README-ship.md | 61 +++ packaging/mac/app/Info.plist | 2 +- packaging/mac/build-dmg.sh | 5 +- packaging/windows/Igneum-Miner.iss | 2 +- packaging/windows/fetch-ci-artifacts.sh | 5 +- tools/ship-app.mjs | 563 ++++++++++++++++++++++++ 6 files changed, 633 insertions(+), 5 deletions(-) create mode 100644 packaging/README-ship.md create mode 100644 tools/ship-app.mjs diff --git a/packaging/README-ship.md b/packaging/README-ship.md new file mode 100644 index 000000000..4606c707f --- /dev/null +++ b/packaging/README-ship.md @@ -0,0 +1,61 @@ +# Shipping an Igneum Miner version (packaging/README-ship.md) + +One command cuts a version for both platforms. 4 October 2026: 0.3.1, 0.3.2 and 0.3.3 each took eight hand steps and +an hour; `tools/ship-app.mjs` is those steps in order, each one checked and resumable. + + node tools/ship-app.mjs 0.3.4 --node vendor/igneum-node-v4 --notes "one line for the changelog" + +Add `--dry-run` first: it reads everything, prints the plan and writes nothing. + +## What it does + +| Step | What happens | Skips itself when | +|---|---|---| +| preflight | this tree clean and on master, the fork worktree clean (`--node-commit ` pins it), tools, binaries, secrets present, gh account, what is live | never (reads only) | +| bump | the six version files, written by one function and read back | the files already say the version | +| inputs | `packaging/windows/push-inputs.sh` with the fork's Windows exes (`IGNEUM_WIN_RELEASE`, `IGNEUM_NODE_SRC`) | the live `payload-inputs.json` carries these exact files from this fork commit | +| commit | `Igneum Miner : `, push master (the push starts the Windows build) | committed and on origin/master | +| ci | the `windows.yml` run for that commit (dispatched when the push started none), polled every 30 s | a green run for the commit exists | +| fetch | `packaging/windows/fetch-ci-artifacts.sh `: installer and payload zip into the downloads folder | the installer from that run is there | +| dmg | `packaging/mac/build-dmg.sh` under `tools/lock/with-lock.sh build` | the DMG is newer than the bump (`--rebuild` forces) | +| copy | the DMG into the downloads folder | same sha256 already there | +| manifest | `packaging/ota/publish-manifest.sh --no-deploy`: signed, signature verified locally | never (cheap) | +| deploy | the downloads folder, one Vercel deploy for the files and the manifest together | never | +| verify | HEAD and GET of the DMG, the installer and the zip (size and sha256 against the local copies); the live manifest through `igneum-ota-sign verify` | never | +| console | one `build` item on the console (version, sizes, hashes, run, commit), then `sync-dl` | never (upsert on `ship:`) | + +The six version files: `app/igneum-app/Cargo.toml`, `app/igneum-app/Cargo.lock`, `app/windows/version.h`, +`app/igneum-app/resources/igneum-app.rc`, `packaging/windows/Igneum-Miner.iss`, `packaging/mac/app/Info.plist`. +`node tools/ship-app.mjs --check` says whether they agree; `--self-test` runs the bump on a scratch copy. + +## Flags + +| Flag | Meaning | +|---|---| +| `--node ` | the igneum-node worktree the node and miner were built from (required); binaries from its `target-integration/`, else `target/` | +| `--notes "..."` | the manifest's changelog line and the commit message | +| `--dry-run` | reads only, prints the plan (exit 1 when preflight would stop the real run) | +| `--from ` | resume at that step (preflight runs again first); the failure message prints this command | +| `--skip-windows`, `--skip-mac` | one platform only (the other entry is carried over when the live manifest is the same version) | +| `--node-commit ` | the fork must be on this commit | +| `--win-release `, `--mac-release ` | other binary folders | +| `--min-supported`, `--activation-height`, `--deadline-note`, `--channel` | passed to `publish-manifest.sh` | +| `--rebuild` | build the DMG again even when a fresh one exists | +| `--branch ` | accept another branch than master (a dry run from a worktree; the Windows build still runs on pushes to master) | + +## When a step fails + +The tool stops, prints why and the `--from` command to retry. Nothing is skipped silently. State that is not a secret +(commit, run id, bump time, the hashes) is in `~/.cache/igneum/ship/.json`. + +Secrets come from `~/.config/igneum` (dl-token, dlsite-dir, ota-signing-key, relay token and key, the Vercel login) and are +never printed; every output line is scrubbed. `gh auth switch --user igneum-josh` runs before every gh call and before the +push. + +## What a cut needs before it starts + +- The node fork built for both targets in `--node`: `target-integration/release/{igneumd,igneum-miner}` and + `target-integration/x86_64-pc-windows-gnu/release/{igneumd,igneum-miner}.exe` (`proto-cuda/windows-node/cross-build.sh`). +- The prebuilt workers (`proto-cuda/nvrtc/igneum-worker-cuda.exe`, `proto-opencl/igneum-worker-opencl.exe`) and the prover + (`proving/igneum-prove/target/release/igneum-prove-{host,export}`); missing ones are noted, not fatal. +- The Mac on mains, nothing else building (the DMG step waits for the build lock). diff --git a/packaging/mac/app/Info.plist b/packaging/mac/app/Info.plist index 14f8da2b7..80f3f476e 100644 --- a/packaging/mac/app/Info.plist +++ b/packaging/mac/app/Info.plist @@ -11,7 +11,7 @@ CFBundleVersion VERSION_STAMP CFBundleShortVersionString - 0.3.2 + 0.3.3 CFBundlePackageType APPL CFBundleExecutable diff --git a/packaging/mac/build-dmg.sh b/packaging/mac/build-dmg.sh index 4243ced0d..a3868efc5 100755 --- a/packaging/mac/build-dmg.sh +++ b/packaging/mac/build-dmg.sh @@ -90,7 +90,10 @@ echo "building the window (app/mac/IgneumMiner.swift)" [ -x "$BUILD/window/Igneum Miner" ] || { echo "the window did not build"; exit 1; } # the bundle -sed -e "s/VERSION_STAMP/$STAMP/" -e "s|0\.3\.0|$VERSION|" "$HERE/app/Info.plist" > "$APP/Contents/Info.plist" +sed -e "s/VERSION_STAMP/$STAMP/" "$HERE/app/Info.plist" > "$APP/Contents/Info.plist" +# the short version is $VERSION whatever the template says (tools/ship-app.mjs keeps the template equal to Cargo.toml; +# the sed that replaced a literal 0.3.0 here stopped matching at 0.3.1 and the bundles said 0.3.2 after that) +plutil -replace CFBundleShortVersionString -string "$VERSION" "$APP/Contents/Info.plist" plutil -lint "$APP/Contents/Info.plist" >/dev/null printf 'APPL????' > "$APP/Contents/PkgInfo" cp "$BUILD/window/Igneum Miner" "$APP/Contents/MacOS/Igneum Miner" diff --git a/packaging/windows/Igneum-Miner.iss b/packaging/windows/Igneum-Miner.iss index a48059cad..e0594016f 100644 --- a/packaging/windows/Igneum-Miner.iss +++ b/packaging/windows/Igneum-Miner.iss @@ -9,7 +9,7 @@ #define ArtDir "..\..\brand\icons" #endif #ifndef AppVersion - #define AppVersion "0.3.2" + #define AppVersion "0.3.3" #endif #define AppName "Igneum Miner" #define Publisher "Igneum" diff --git a/packaging/windows/fetch-ci-artifacts.sh b/packaging/windows/fetch-ci-artifacts.sh index 481c60cda..08a71a997 100755 --- a/packaging/windows/fetch-ci-artifacts.sh +++ b/packaging/windows/fetch-ci-artifacts.sh @@ -46,8 +46,9 @@ JSON rm -rf "$TMP" echo "copied into $DEST:" ls -la "$DEST/$(basename "$SETUP")" "$DEST/igneum-windows-app.zip" -# the console's Builds tab (relay/): one build event per fetched CI run; never fatal -node "$(dirname "$0")/../../tools/console.mjs" post --kind build --title "Windows CI $(basename "$SETUP") fetched (run $RUN_ID)" --body "https://github.com/$REPO/actions/runs/$RUN_ID" >/dev/null 2>&1 || true +# the console's Builds tab (relay/): one build event per fetched CI run; never fatal. CONSOLE_SKIP=1 leaves it to the +# caller (tools/ship-app.mjs posts one item for the whole cut). +[ "${CONSOLE_SKIP:-0}" = 1 ] || node "$(dirname "$0")/../../tools/console.mjs" post --kind build --title "Windows CI $(basename "$SETUP") fetched (run $RUN_ID)" --body "https://github.com/$REPO/actions/runs/$RUN_ID" >/dev/null 2>&1 || true # the over-the-air manifest (packaging/ota): the Windows entry for this installer; the Mac entry of the same version is # carried over. OTA_NOTES= sets the changelog line; OTA_SKIP=1 leaves the manifest alone. if [ "${OTA_SKIP:-0}" != 1 ]; then diff --git a/tools/ship-app.mjs b/tools/ship-app.mjs new file mode 100644 index 000000000..16c926d8c --- /dev/null +++ b/tools/ship-app.mjs @@ -0,0 +1,563 @@ +#!/usr/bin/env node +// Cuts an Igneum Miner app version from one command. Josh, 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 ] +// [--skip-windows | --skip-mac] [--node-commit ] [--win-release ] [--mac-release ] +// [--min-supported 0.3.0] [--activation-height N --deadline-note "..."] +// 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 +// manifest packaging/ota/publish-manifest.sh --no-deploy: canonical JSON, signed, signature verified locally +// 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 +// console one console item (tools/console.mjs post --kind build) with version, sizes and hashes, then sync-dl +// +// 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-josh 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-josh'; +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', 'relay-token', 'relay-key', 'log-intake-key'].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() }; +} +const git = (args, cwd = ROOT) => runSync('git', args, { cwd }); +// git status --porcelain: the paths only ("XY path"; the first line's leading space does not survive a trim) +const gitStatusPaths = (args, cwd = ROOT) => git(['status', '--porcelain', ...args], cwd).out.split('\n').filter(Boolean).map(l => l.replace(/^\s*\S+\s+/, '')); +const has = cmd => runSync('sh', ['-c', `command -v ${cmd}`]).code === 0; +// gh: the account switch first, every time (the rule), then the call +async function gh(args, { quiet = true } = {}) { + const sw = runSync('gh', ['auth', 'switch', '--user', GH_USER]); + if (sw.code !== 0) throw new Error(`gh auth switch --user ${GH_USER} failed: ${sw.out}`); + return run('gh', args, { quiet }); +} +async function ghJson(args) { const r = await gh(args); if (r.code !== 0) throw new Error(`gh ${args.slice(0, 2).join(' ')} failed: ${r.out.trim()}`); return JSON.parse(r.out); } +const sleep = ms => new Promise(r => setTimeout(r, ms)); +async function head(url) { const r = await fetch(url, { method: 'HEAD' }); return { status: r.status, length: r.headers.get('content-length') ? Number(r.headers.get('content-length')) : null }; } +async function getJson(url) { const r = await fetch(url); if (!r.ok) return null; return r.json().catch(() => null); } + +// ---- arguments ----------------------------------------------------------------------------------------------------- +const argv = process.argv.slice(2); +const flags = {}; const pos = []; +for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a.startsWith('--')) { const k = a.slice(2); const next = argv[i + 1]; if (next !== undefined && !next.startsWith('--')) { flags[k] = next; i++; } else flags[k] = true; } + else pos.push(a); +} +const STEPS = ['preflight', 'bump', 'inputs', 'commit', 'ci', 'fetch', 'dmg', 'copy', '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]\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.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}`; +const DMG_NAME = `Igneum-Miner-${VERSION}.dmg`; +const SETUP_NAME = `Igneum-Miner-Setup-${VERSION}.exe`; +const ZIP_NAME = 'igneum-windows-app.zip'; +const DMG_DIST = join(ROOT, 'packaging', 'mac', 'dist', DMG_NAME); +const SIGNER = join(ROOT, 'app', 'igneum-app', 'target', 'release', 'igneum-ota-sign'); +const STATE_FILE = join(STATE_DIR, `${VERSION}.json`); +const state = (() => { try { return JSON.parse(readFileSync(STATE_FILE, 'utf8')); } catch { return {}; } })(); +const saveState = () => { if (DRY) return; mkdirSync(STATE_DIR, { recursive: true }); writeFileSync(STATE_FILE, JSON.stringify(state, null, 2)); }; +// where the fork's binaries are: target-integration first (the devnet-v4 integration builds), then target +const firstDir = (cands, probe) => cands.find(d => existsSync(join(d, probe))) || cands[0]; +const WIN_RELEASE = flags['win-release'] ? resolve(flags['win-release']) : firstDir([join(NODE_DIR, 'target-integration', 'x86_64-pc-windows-gnu', 'release'), join(NODE_DIR, 'target', 'x86_64-pc-windows-gnu', 'release')], 'igneumd.exe'); +const MAC_RELEASE = flags['mac-release'] ? resolve(flags['mac-release']) : firstDir([join(NODE_DIR, 'target-integration', 'release'), join(NODE_DIR, 'target', 'release')], 'igneumd'); +const WIN_INPUTS = ['igneumd.exe', 'igneum-miner.exe'].map(n => join(WIN_RELEASE, n)); +const WORKERS = [join(ROOT, 'proto-cuda', 'nvrtc', 'igneum-worker-cuda.exe'), join(ROOT, 'proto-opencl', 'igneum-worker-opencl.exe')]; +const retryCmd = step => `node tools/ship-app.mjs ${VERSION} --node ${flags.node}${flags.notes ? ` --notes ${JSON.stringify(flags.notes)}` : ''}${WIN ? '' : ' --skip-windows'}${MAC ? '' : ' --skip-mac'}${flags['node-commit'] ? ` --node-commit ${flags['node-commit']}` : ''} --from ${step}`; +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 /dl/ (~/.config/igneum/dlsite-dir says ${DLSITE || 'nothing'})`); + // 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'], + ['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 || ''}`; + 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 manifest() { + const args = ['--version', VERSION, '--notes', NOTES, '--no-deploy']; + if (MAC) args.push('--mac', join(DEST, DMG_NAME)); + if (WIN) args.push('--win', join(DEST, SETUP_NAME)); + for (const k of ['min-supported', 'activation-height', 'deadline-note', 'channel']) if (flags[k]) args.push(`--${k}`, String(flags[k])); + const cmd = `packaging/ota/publish-manifest.sh ${args.map(a => a.includes(' ') ? JSON.stringify(a) : a).join(' ')}`; + if (DRY) return done('manifest', 'would', `run ${cmd.replace(TOKEN, '')} (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'})`); + 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}`); + done('manifest', 'ok', `${VERSION} ${Object.keys(m.platforms).join('+')}, signed and verified locally`); +} + +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'); +} + +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`); + 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(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(', '); + mline = `${m.version} ${v.code === 0 ? 'signature ok' : 'SIGNATURE FAILS'}${same ? '' : ' (DIFFERS from the local manifest)'} ${plat}`; + if (v.code !== 0 || m.version !== VERSION || !same) failures.push('manifest'); + state.manifest = { version: m.version, published_at: m.published_at, platforms: m.platforms }; + } else failures.push('manifest'); + rows.push(['igneum-app-latest.json', '', '', mline]); + } finally { rmSync(tmp, { recursive: true, force: true }); } + table(rows); + 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`); +} + +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 || '?'}` : ''].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, 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); + } 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); });