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 <noreply@anthropic.com>
This commit is contained in:
parent
c170779578
commit
33c430d93c
6 changed files with 633 additions and 5 deletions
61
packaging/README-ship.md
Normal file
61
packaging/README-ship.md
Normal file
|
|
@ -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 <sha>` 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 <v>: <notes>`, 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 <run>`: 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:<v>`) |
|
||||
|
||||
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 <dir>` | 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 <step>` | 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 <sha>` | the fork must be on this commit |
|
||||
| `--win-release <dir>`, `--mac-release <dir>` | 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 <name>` | 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/<version>.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).
|
||||
|
|
@ -11,7 +11,7 @@
|
|||
<key>CFBundleVersion</key>
|
||||
<string>VERSION_STAMP</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.3.2</string>
|
||||
<string>0.3.3</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
|
|
|
|||
|
|
@ -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|<string>0\.3\.0</string>|<string>$VERSION</string>|" "$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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
563
tools/ship-app.mjs
Normal file
563
tools/ship-app.mjs
Normal file
|
|
@ -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 <fork worktree> [--notes "..."] [--dry-run] [--from <step>]
|
||||
// [--skip-windows | --skip-mac] [--node-commit <sha>] [--win-release <dir>] [--mac-release <dir>]
|
||||
// [--min-supported 0.3.0] [--activation-height N --deadline-note "..."]
|
||||
// node tools/ship-app.mjs --check the six version files agree (exit 1 when they do not)
|
||||
// node tools/ship-app.mjs --self-test the bump, on a scratch copy of the version files
|
||||
//
|
||||
// Steps, in order (each one skips itself when its result is already there, so a rerun or --from <step> resumes):
|
||||
// preflight trees clean, fork on the expected commit, tools, binaries, secrets present, gh account
|
||||
// bump the six version files (one function, read back after writing)
|
||||
// inputs packaging/windows/push-inputs.sh (the node exes and workers for the GitHub build), skipped when the live
|
||||
// payload-inputs.json already carries these exact files from this fork commit
|
||||
// commit commit the six files as "Igneum Miner <v>: <notes>" and push master (that push starts the Windows build)
|
||||
// ci find the windows.yml run for that commit (or dispatch one), poll it with gh until green
|
||||
// fetch packaging/windows/fetch-ci-artifacts.sh <run>: the installer and the payload zip into the downloads folder
|
||||
// dmg packaging/mac/build-dmg.sh under tools/lock/with-lock.sh build (nice 19, 4 cargo jobs)
|
||||
// copy the DMG into the downloads folder
|
||||
// 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/<version>.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: /(<key>CFBundleShortVersionString<\/key>\s*<string>)([^<]+)(<\/string>)/, kind: 'dot' }] },
|
||||
];
|
||||
const isVersion = v => /^\d+\.\d+\.\d+$/.test(v);
|
||||
const toComma = v => v.split('.').join(',') + ',0';
|
||||
const fromComma = c => { const p = c.split(','); return p.length === 4 && p[3] === '0' ? p.slice(0, 3).join('.') : c; };
|
||||
const cmpVersion = (a, b) => { const x = a.split('.').map(Number), y = b.split('.').map(Number); for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] - y[i]; return 0; };
|
||||
|
||||
// reads one file: the versions it carries, one per pattern, in dotted form
|
||||
function readVersions(text, file) {
|
||||
return file.patterns.map(p => {
|
||||
const m = p.re.exec(text);
|
||||
if (!m) throw new Error(`${file.path}: pattern ${p.re} not found (the file changed shape; update VERSION_FILES)`);
|
||||
return p.kind === 'comma' ? fromComma(m[2]) : m[2];
|
||||
});
|
||||
}
|
||||
function writeVersion(text, file, v) {
|
||||
for (const p of file.patterns) {
|
||||
if (!p.re.test(text)) throw new Error(`${file.path}: pattern ${p.re} not found`);
|
||||
text = text.replace(p.re, (_, a, _b, c) => a + (p.kind === 'comma' ? toComma(v) : v) + (c || ''));
|
||||
}
|
||||
return text;
|
||||
}
|
||||
// bumps every file under root to v, then reads each back and demands v everywhere; returns the per-file report
|
||||
function bumpVersionFiles(root, v) {
|
||||
if (!isVersion(v)) throw new Error(`not a major.minor.patch version: ${v}`);
|
||||
const report = [];
|
||||
for (const f of VERSION_FILES) {
|
||||
const full = join(root, f.path);
|
||||
const before = readFileSync(full, 'utf8');
|
||||
const was = readVersions(before, f);
|
||||
const after = writeVersion(before, f, v);
|
||||
if (after !== before) writeFileSync(full, after);
|
||||
const back = readVersions(readFileSync(full, 'utf8'), f);
|
||||
if (!back.every(x => x === v)) throw new Error(`${f.path}: wrote ${v} but read back ${back.join(', ')}`);
|
||||
report.push({ file: f.path, was: [...new Set(was)].join(', '), now: v, changed: after !== before });
|
||||
}
|
||||
return report;
|
||||
}
|
||||
// the check: what every file says; ok when one version everywhere
|
||||
function checkVersionFiles(root) {
|
||||
const rows = [];
|
||||
for (const f of VERSION_FILES) {
|
||||
const vs = readVersions(readFileSync(join(root, f.path), 'utf8'), f);
|
||||
rows.push({ file: f.path, versions: [...new Set(vs)] });
|
||||
}
|
||||
const all = [...new Set(rows.flatMap(r => r.versions))];
|
||||
return { rows, version: all.length === 1 ? all[0] : null, all };
|
||||
}
|
||||
|
||||
// ---- output: every line scrubbed of the tokens -------------------------------------------------------------------
|
||||
const SECRETS = ['dl-token', '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('<token>'), String(s));
|
||||
const say = (...a) => console.log(scrub(a.join(' ')));
|
||||
const fmtSize = n => n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`;
|
||||
const sha256 = p => createHash('sha256').update(readFileSync(p)).digest('hex');
|
||||
const sizeOf = p => statSync(p).size;
|
||||
const table = rows => { const w = []; for (const r of rows) r.forEach((c, i) => w[i] = Math.max(w[i] || 0, String(c).length)); for (const r of rows) say(' ' + r.map((c, i) => String(c).padEnd(w[i])).join(' ').trimEnd()); };
|
||||
|
||||
// ---- running things: streamed, scrubbed, with the exit code ------------------------------------------------------
|
||||
function run(cmd, args, { cwd = ROOT, env = {}, quiet = false, input } = {}) {
|
||||
return new Promise((res) => {
|
||||
const p = spawn(cmd, args, { cwd, env: { ...process.env, PATH: `${homedir()}/.cargo/bin:/opt/homebrew/bin:${process.env.PATH}`, ...env }, stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'] });
|
||||
let out = '';
|
||||
const feed = chunk => { const s = chunk.toString(); out += s; if (!quiet) process.stdout.write(scrub(s)); };
|
||||
p.stdout.on('data', feed); p.stderr.on('data', feed);
|
||||
if (input !== undefined) { p.stdin.write(input); p.stdin.end(); }
|
||||
p.on('error', e => res({ code: 127, out: out + e.message }));
|
||||
p.on('close', code => res({ code: code ?? 1, out }));
|
||||
});
|
||||
}
|
||||
function runSync(cmd, args, { cwd = ROOT, env = {} } = {}) {
|
||||
const r = spawnSync(cmd, args, { cwd, env: { ...process.env, PATH: `${homedir()}/.cargo/bin:/opt/homebrew/bin:${process.env.PATH}`, ...env }, encoding: 'utf8' });
|
||||
return { code: r.status ?? 1, out: ((r.stdout || '') + (r.stderr || '')).trim() };
|
||||
}
|
||||
const git = (args, cwd = ROOT) => runSync('git', args, { cwd });
|
||||
// git status --porcelain: the paths only ("XY path"; the first line's leading space does not survive a trim)
|
||||
const gitStatusPaths = (args, cwd = ROOT) => git(['status', '--porcelain', ...args], cwd).out.split('\n').filter(Boolean).map(l => l.replace(/^\s*\S+\s+/, ''));
|
||||
const has = cmd => runSync('sh', ['-c', `command -v ${cmd}`]).code === 0;
|
||||
// gh: the account switch first, every time (the rule), then the call
|
||||
async function gh(args, { quiet = true } = {}) {
|
||||
const sw = runSync('gh', ['auth', 'switch', '--user', GH_USER]);
|
||||
if (sw.code !== 0) throw new Error(`gh auth switch --user ${GH_USER} failed: ${sw.out}`);
|
||||
return run('gh', args, { quiet });
|
||||
}
|
||||
async function ghJson(args) { const r = await gh(args); if (r.code !== 0) throw new Error(`gh ${args.slice(0, 2).join(' ')} failed: ${r.out.trim()}`); return JSON.parse(r.out); }
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
async function head(url) { const r = await fetch(url, { method: 'HEAD' }); return { status: r.status, length: r.headers.get('content-length') ? Number(r.headers.get('content-length')) : null }; }
|
||||
async function getJson(url) { const r = await fetch(url); if (!r.ok) return null; return r.json().catch(() => null); }
|
||||
|
||||
// ---- arguments -----------------------------------------------------------------------------------------------------
|
||||
const argv = process.argv.slice(2);
|
||||
const flags = {}; const pos = [];
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a.startsWith('--')) { const k = a.slice(2); const next = argv[i + 1]; if (next !== undefined && !next.startsWith('--')) { flags[k] = next; i++; } else flags[k] = true; }
|
||||
else pos.push(a);
|
||||
}
|
||||
const STEPS = ['preflight', 'bump', 'inputs', 'commit', 'ci', 'fetch', 'dmg', 'copy', 'manifest', 'deploy', 'verify', 'console'];
|
||||
|
||||
if (flags['self-test']) { process.exit(selfTest()); }
|
||||
if (flags.check) {
|
||||
const c = checkVersionFiles(ROOT);
|
||||
table([['file', 'version'], ...c.rows.map(r => [r.file, r.versions.join(', ')])]);
|
||||
if (c.version) { say(`ok: ${c.version} in all ${c.rows.length} files`); process.exit(0); }
|
||||
say(`DISAGREE: ${c.all.join(' vs ')}; run: node tools/ship-app.mjs <version> --node <fork> (the bump step), or fix by hand`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const VERSION = pos[0];
|
||||
if (!VERSION || !isVersion(VERSION)) { console.error('usage: node tools/ship-app.mjs <major.minor.patch> --node <fork worktree> [--notes "..."] [--dry-run] [--from <step>] [--skip-windows|--skip-mac]\n node tools/ship-app.mjs --check | --self-test'); process.exit(2); }
|
||||
if (!flags.node) { console.error('--node <fork worktree> is required (the igneum-node worktree the node and miner were built from)'); process.exit(2); }
|
||||
if (flags.from && !STEPS.includes(flags.from)) { console.error(`--from must be one of: ${STEPS.join(', ')}`); process.exit(2); }
|
||||
if (flags['skip-windows'] && flags['skip-mac']) { console.error('--skip-windows and --skip-mac together leave nothing to ship'); process.exit(2); }
|
||||
const DRY = !!flags['dry-run'];
|
||||
const WIN = !flags['skip-windows'];
|
||||
const MAC = !flags['skip-mac'];
|
||||
const NOTES = flags.notes || `Igneum Miner ${VERSION}`;
|
||||
const NODE_DIR = resolve(flags.node);
|
||||
const TOKEN = cfg('dl-token');
|
||||
const DLSITE = process.env.IGNEUM_DLSITE || cfg('dlsite-dir');
|
||||
const DEST = DLSITE && TOKEN ? join(DLSITE, 'dl', TOKEN) : '';
|
||||
const BASE = `https://dl.igneum.network/dl/${TOKEN}`;
|
||||
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 <dlsite>/dl/<token> (~/.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, '<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 || '<run id>'}`;
|
||||
if (DRY) return done('fetch', 'would', `run ${cmd} (no deploy here; one deploy later)`);
|
||||
if (!state.runId) throw new Error(`no run id for ${VERSION}; run the ci step first: ${retryCmd('ci')}`);
|
||||
const sw = runSync('gh', ['auth', 'switch', '--user', GH_USER]); if (sw.code !== 0) throw new Error(`gh auth switch failed: ${sw.out}`);
|
||||
const r = await run('bash', [join(ROOT, 'packaging', 'windows', 'fetch-ci-artifacts.sh'), String(state.runId)], { env: { OTA_SKIP: '1', CONSOLE_SKIP: '1' } });
|
||||
if (r.code !== 0) throw new Error(`fetch-ci-artifacts.sh exited ${r.code}; retry: ${cmd}`);
|
||||
if (!existsSync(setup)) throw new Error(`the run delivered no ${SETUP_NAME} (its Cargo.toml version differs?); ls ${DEST.replace(TOKEN, '<token>')}`);
|
||||
done('fetch', 'ok', `${SETUP_NAME} ${fmtSize(sizeOf(setup))}, ${ZIP_NAME} ${fmtSize(sizeOf(join(DEST, ZIP_NAME)))}`);
|
||||
}
|
||||
|
||||
async function dmg() {
|
||||
if (!MAC) return done('dmg', 'skipped', '--skip-mac');
|
||||
const fresh = existsSync(DMG_DIST) && (!state.bumpedAt || statSync(DMG_DIST).mtime.toISOString() > state.bumpedAt);
|
||||
if (fresh && !flags.rebuild) return done('dmg', 'already', `${DMG_DIST.replace(ROOT + '/', '')} ${fmtSize(sizeOf(DMG_DIST))} (--rebuild forces)`);
|
||||
const env = { NODE: join(MAC_RELEASE, 'igneumd'), MINER: join(MAC_RELEASE, 'igneum-miner') };
|
||||
const cmd = `NODE=${env.NODE} MINER=${env.MINER} tools/lock/with-lock.sh build packaging/mac/build-dmg.sh`;
|
||||
if (DRY) return done('dmg', 'would', `run ${cmd} (engine with cargo -j 4 at nice 19, window, worker; waits for the build lock)`);
|
||||
const r = await run('bash', [join(ROOT, 'tools', 'lock', 'with-lock.sh'), 'build', join(ROOT, 'packaging', 'mac', 'build-dmg.sh')], { env });
|
||||
if (r.code !== 0) throw new Error(`build-dmg.sh exited ${r.code}; retry: ${cmd}`);
|
||||
if (!existsSync(DMG_DIST)) throw new Error(`build-dmg.sh ended without ${DMG_DIST}`);
|
||||
done('dmg', 'ok', `${DMG_NAME} ${fmtSize(sizeOf(DMG_DIST))}`);
|
||||
}
|
||||
|
||||
async function copy() {
|
||||
if (!MAC) return done('copy', 'skipped', '--skip-mac');
|
||||
const dst = join(DEST, DMG_NAME);
|
||||
if (DRY) return done('copy', 'would', `copy ${DMG_NAME} into the downloads folder${existsSync(dst) ? ' (replacing the one there)' : ''}`);
|
||||
if (!existsSync(DMG_DIST)) throw new Error(`no ${DMG_DIST}; ${retryCmd('dmg')}`);
|
||||
if (existsSync(dst) && sha256(dst) === sha256(DMG_DIST)) return done('copy', 'already', `${DMG_NAME} is in the downloads folder with the same sha256`);
|
||||
copyFileSync(DMG_DIST, dst);
|
||||
done('copy', 'ok', `${DMG_NAME} ${fmtSize(sizeOf(dst))} copied`);
|
||||
}
|
||||
|
||||
async function manifest() {
|
||||
const args = ['--version', VERSION, '--notes', NOTES, '--no-deploy'];
|
||||
if (MAC) args.push('--mac', join(DEST, DMG_NAME));
|
||||
if (WIN) args.push('--win', join(DEST, SETUP_NAME));
|
||||
for (const k of ['min-supported', 'activation-height', 'deadline-note', 'channel']) if (flags[k]) args.push(`--${k}`, String(flags[k]));
|
||||
const cmd = `packaging/ota/publish-manifest.sh ${args.map(a => a.includes(' ') ? JSON.stringify(a) : a).join(' ')}`;
|
||||
if (DRY) return done('manifest', 'would', `run ${cmd.replace(TOKEN, '<token>')} (signs, verifies the signature; ${WIN && MAC ? 'both platforms' : WIN ? 'windows entry, mac carried over when the live manifest is this version' : 'mac entry, windows carried over when the live manifest is this version'})`);
|
||||
for (const p of [MAC && join(DEST, DMG_NAME), WIN && join(DEST, SETUP_NAME)].filter(Boolean)) if (!existsSync(p)) throw new Error(`missing ${p.replace(TOKEN, '<token>')}; ${retryCmd(p.endsWith('.dmg') ? 'copy' : 'fetch')}`);
|
||||
const r = await run('bash', [join(ROOT, 'packaging', 'ota', 'publish-manifest.sh'), ...args]);
|
||||
if (r.code !== 0) throw new Error(`publish-manifest.sh exited ${r.code}; retry: ${cmd.replace(TOKEN, '<token>')}`);
|
||||
const m = JSON.parse(readFileSync(join(DEST, 'igneum-app-latest.json'), 'utf8'));
|
||||
if (m.version !== VERSION) throw new Error(`the written manifest says ${m.version}`);
|
||||
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); });
|
||||
Loading…
Reference in a new issue