igneum/packaging/ota/publish-manifest.sh
2026-10-06 20:27:23 +00:00

258 lines
17 KiB
Bash
Executable file

#!/usr/bin/env bash
# Publishes the over-the-air update manifest for Igneum Miner: igneum-app-latest.json (canonical JSON, sorted keys,
# no whitespace) and its detached Ed25519 signature igneum-app-latest.json.sig in the downloads folder
# (dl/<token>/, next to the DMG and the installer), signed on this Mac with ~/.config/igneum/ota-signing-key. The
# apps verify the bytes with the public key compiled into app/igneum-app/src/manifest.rs before they parse anything.
#
# packaging/ota/publish-manifest.sh --version 0.3.1 --mac packaging/mac/dist/Igneum-Miner-0.3.1.dmg \
# [--win packaging/windows/dist/Igneum-Miner-Setup-0.3.1.exe] --notes "one line of what changed" \
# [--activation-height 120000 --deadline-note "difficulty v2"] [--min-supported 0.3.0] [--channel devnet] [--deploy]
# [--override '{"difficulty_v2_activation_daa":33000,"proving_v0_activation_daa":120000}']
# consensus.override: the exact object every app writes to its override.json (the node's
# --override-params-file), so it carries EVERY height switch, not just the new one;
# carried over from the current manifest when not given
# [--public] also publish into dl/public/ (no token: the site's download
# links, packaging/ota/publish-public.sh --app, plus --wallet
# when the wallet manifest is in the folder); the same deploy
# [--tuning tuning.json | --no-tuning] the fleet's per-card kernel tuning (tools/tuning.mjs writes it;
# docs/design/miner-tuning.md); carried over from the current
# manifest when not given, as is consensus.override
#
# A platform you do not pass is carried over from the manifest already in the folder when that one has the same
# version (the Windows build lands later than the Mac one: publish the Mac entry first, add the Windows entry when
# fetch-ci-artifacts.sh brings the installer), else left out; an app whose platform is missing does nothing.
# The installer or DMG is copied into the downloads folder when it is not there already.
# Without --deploy the script prints the deploy command for the main session; with --deploy it runs the Vercel CLI
# from the downloads folder and verifies the live manifest. Testing: --base-url http://127.0.0.1:<port>/dl/<token>
# and --dest <folder> write a manifest for a local server (the app accepts loopback http for this).
#
# Reads: ~/.config/igneum/ota-signing-key (private, 0600; make it once with
# app/igneum-app/target/release/igneum-ota-sign keygen ~/.config/igneum/ota-signing-key ~/.config/igneum/ota-signing-key.pub),
# ~/.config/igneum/dl-token, ~/.config/igneum/dlsite-dir (IGNEUM_DLSITE overrides), ~/.config/igneum/vercel for --deploy.
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
ROOT="$(cd "$HERE/../.." && pwd)"
export PATH="$HOME/.cargo/bin:$PATH"
KEY="$HOME/.config/igneum/ota-signing-key"
PUB="$HOME/.config/igneum/ota-signing-key.pub"
TOKEN_FILE="$HOME/.config/igneum/dl-token"
SIGNER="$ROOT/app/igneum-app/target/release/igneum-ota-sign"
VERSION="" MAC="" WIN="" NOTES="" ACTIVATION="" DEADLINE="" MIN_SUPPORTED="" CHANNEL="devnet" BASE="" DEST="" DEPLOY=0 VERIFY_ONLY=0 TRIES=12
# An activation height at or below the live DAA makes every app treat the update as urgent (manifest::fork_is_close counts
# any height already passed as "close"), which skips the slot, the patience and the busy rule; PC 1 took an install under a
# running job on 6 October 2026 on that path. The live DAA comes from the observer's exec status (igneum_getExecStatus,
# executedTipDaa) at 127.0.0.1:26790; a height at or below it is refused. IGNEUM_MANIFEST_ALLOW_PAST_HEIGHT=1 overrides for a
# deliberate replay. `--self-test-height` exercises the rule on a known-bad and a known-good value against DAA 1000.
height_rule() { # <height> <live daa> -> 0 ok, 1 refused
local h="$1" daa="$2"
[ -z "$h" ] && return 0
[[ "$h" =~ ^[0-9]+$ ]] || { echo "activation height $h is not a number" >&2; return 1; }
if [ "$h" -le "$daa" ]; then echo "activation height $h is at or below the live DAA $daa: every app would treat the update as urgent (fork_is_close); refused (IGNEUM_MANIFEST_ALLOW_PAST_HEIGHT=1 overrides a deliberate replay)" >&2; return 1; fi
return 0
}
if [ "${1:-}" = "--self-test-height" ]; then
height_rule 900 1000 2>/dev/null && { echo "self-test failed: a passed height was accepted"; exit 1; }
height_rule 1000 1000 2>/dev/null && { echo "self-test failed: the live height was accepted"; exit 1; }
height_rule 1001 1000 || { echo "self-test failed: a future height was refused"; exit 1; }
height_rule "" 1000 || { echo "self-test failed: no height was refused"; exit 1; }
echo "self-test passed: a height at or below the live DAA is refused, a future one and none pass"; exit 0
fi
OVERRIDE="" TUNING_FILE="" NO_TUNING=0 PUBLIC=0
while [ $# -gt 0 ]; do
case "$1" in
--version) VERSION="$2"; shift 2 ;;
--mac) MAC="$2"; shift 2 ;;
--win) WIN="$2"; shift 2 ;;
--notes) NOTES="$2"; shift 2 ;;
--activation-height) ACTIVATION="$2"; shift 2 ;;
--deadline-note) DEADLINE="$2"; shift 2 ;;
--override) OVERRIDE="$2"; shift 2 ;; # consensus.override: the exact JSON object every app writes to its override.json (all height switches, not just the new one)
--min-supported) MIN_SUPPORTED="$2"; shift 2 ;;
--channel) CHANNEL="$2"; shift 2 ;;
--tuning) TUNING_FILE="$2"; shift 2 ;;
--no-tuning) NO_TUNING=1; shift ;;
--base-url) BASE="$2"; shift 2 ;;
--dest) DEST="$2"; shift 2 ;;
--deploy) DEPLOY=1; shift ;;
--no-deploy) DEPLOY=0; shift ;;
--public) PUBLIC=1; shift ;; # dl/public/ too (publish-public.sh --app [--wallet]); needs the real downloads folder
--verify-only) VERIFY_ONLY=1; shift ;; # no write, no deploy: check the live manifest against the one in the folder
--tries) TRIES="$2"; shift 2 ;;
*) echo "unknown argument: $1" >&2; exit 2 ;;
esac
done
if [ -n "$ACTIVATION" ] && [ "${IGNEUM_MANIFEST_ALLOW_PAST_HEIGHT:-0}" != "1" ]; then
LIVE_DAA="$(curl -s -m 5 -X POST -H 'content-type: application/json' --data '{"jsonrpc":"2.0","id":1,"method":"igneum_getExecStatus","params":[]}' http://127.0.0.1:26790 2>/dev/null | python3 -c 'import json,sys; print(int(json.load(sys.stdin)["result"]["executedTipDaa"],16))' 2>/dev/null || true)"
[ -n "$LIVE_DAA" ] || { echo "cannot read the live DAA from the observer (127.0.0.1:26790) to check --activation-height $ACTIVATION; start it or set IGNEUM_MANIFEST_ALLOW_PAST_HEIGHT=1 knowingly" >&2; exit 1; }
height_rule "$ACTIVATION" "$LIVE_DAA" || exit 1
echo "activation height $ACTIVATION is above the live DAA $LIVE_DAA ($((ACTIVATION - LIVE_DAA)) ahead)"
fi
[ -n "$VERSION" ] || [ "$VERIFY_ONLY" = 1 ] || { echo "--version is required" >&2; exit 2; }
[ -n "$VERSION" ] || VERSION="(the folder's)"
[ "$VERIFY_ONLY" = 1 ] || case "$VERSION" in [0-9]*.[0-9]*.[0-9]*) ;; *) echo "--version must be major.minor.patch" >&2; exit 2 ;; esac
[ -f "$KEY" ] || { echo "no $KEY: run $SIGNER keygen $KEY $PUB once (the public key then goes into src/manifest.rs)" >&2; exit 1; }
[ -f "$PUB" ] || { echo "no $PUB" >&2; exit 1; }
[ -f "$TOKEN_FILE" ] || { echo "no $TOKEN_FILE" >&2; exit 1; }
TOKEN="$(tr -d '[:space:]' < "$TOKEN_FILE")"
if [ -z "$DEST" ]; then
DLSITE="${IGNEUM_DLSITE:-}"
[ -n "$DLSITE" ] || { [ -f "$HOME/.config/igneum/dlsite-dir" ] && DLSITE="$(tr -d '[:space:]' < "$HOME/.config/igneum/dlsite-dir")"; } || true
[ -n "$DLSITE" ] && [ -d "$DLSITE/dl/$TOKEN" ] || { echo "no downloads folder: set IGNEUM_DLSITE or ~/.config/igneum/dlsite-dir (must hold dl/<token>/)" >&2; exit 1; }
DEST="$DLSITE/dl/$TOKEN"
else
DLSITE=""
mkdir -p "$DEST"
fi
[ -n "$BASE" ] || BASE="https://dl.igneum.network/dl/$TOKEN"
BASE="${BASE%/}"
command -v python3 >/dev/null || { echo "python3 is needed for the canonical JSON" >&2; exit 1; }
# the signer, built from the app crate (it includes src/manifest.rs, so it signs what the app verifies)
if [ ! -x "$SIGNER" ]; then
echo "building igneum-ota-sign"
(cd "$ROOT/app/igneum-app" && nice -n 19 cargo build --release -j 4 --bin igneum-ota-sign --quiet)
fi
EMBEDDED="$("$SIGNER" embedded | sed -n 1p)"
OURS="$(tr -d '[:space:]' < "$PUB")"
if [ "$EMBEDDED" != "$OURS" ]; then
echo "the public key in app/igneum-app/src/manifest.rs ($EMBEDDED) is not $PUB ($OURS); the apps would refuse this manifest" >&2
exit 1
fi
# the platform entries: the files given here, else carried over from the current manifest at the same version
entry() { # <file> -> "url sha256 size kind"
local f="$1" name kind sum size
[ -f "$f" ] || { echo "missing: $f" >&2; exit 1; }
name="$(basename "$f")"
case "$name" in
*.dmg) kind="dmg" ;;
*.zip) kind="zip" ;;
*.exe) kind="inno-setup" ;;
*) echo "$f: not a .dmg, .zip or .exe" >&2; exit 1 ;;
esac
if [ "$(cd "$(dirname "$f")" && pwd)/$name" != "$DEST/$name" ]; then
cp "$f" "$DEST/$name"
fi
read -r sum size < <("$SIGNER" sha256 "$DEST/$name")
echo "$BASE/$name $sum $size $kind"
}
MAC_ENTRY=""; WIN_ENTRY=""
[ -n "$MAC" ] && MAC_ENTRY="$(entry "$MAC")"
[ -n "$WIN" ] && WIN_ENTRY="$(entry "$WIN")"
# The live file must be THIS file: byte-identical to the local one and verifying, with retries because the edge
# serves the previous deployment for some seconds (4 October 2026: the check used to accept any validly signed
# manifest, so a stale 0.3.3 at the edge would have passed as the 0.3.4 verification). Each failure names its reason.
# packaging/ota/publish-manifest.sh --verify-only [--tries N] [--base-url ...] runs only this check
verify_live_manifest() {
local tmp t=0 verdict=""
tmp="$(mktemp -d)"
while [ "$t" -lt "$TRIES" ]; do
t=$((t + 1)); verdict=""
if ! curl -fsSL -H 'Cache-Control: no-cache' -o "$tmp/m.json" "$BASE/igneum-app-latest.json"; then verdict="is not reachable"
elif ! curl -fsSL -H 'Cache-Control: no-cache' -o "$tmp/m.sig" "$BASE/igneum-app-latest.json.sig"; then verdict="has no reachable signature"
elif ! cmp -s "$tmp/m.json" "$DEST/igneum-app-latest.json"; then verdict="differs from the local one (live says $(python3 -c 'import json,sys; m=json.load(open(sys.argv[1])); print(m.get("version","?"), m.get("published_at","?"))' "$tmp/m.json" 2>/dev/null || echo unreadable), local $VERSION)"
elif ! "$SIGNER" verify "$PUB" "$tmp/m.json" "$tmp/m.sig" >/dev/null 2>&1; then verdict="does not verify against $PUB"
fi
[ -z "$verdict" ] && break
[ "$t" -lt "$TRIES" ] && sleep 5
done
rm -rf "$tmp"
if [ -n "$verdict" ]; then echo "the live manifest $verdict after $t tr$([ "$t" = 1 ] && echo y || echo ies) ($(( (t - 1) * 5 )) s); check the deploy output, then: $0 --verify-only" >&2; return 1; fi
echo "live manifest verified at ${BASE//$TOKEN/<token>}/igneum-app-latest.json (try $t of $TRIES): version $VERSION, byte-identical, signature OK"
}
if [ "$VERIFY_ONLY" = 1 ]; then
[ -f "$DEST/igneum-app-latest.json" ] || { echo "no manifest in $DEST" >&2; exit 1; }
[ "$VERSION" != "(the folder's)" ] || VERSION="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("version","?"))' "$DEST/igneum-app-latest.json")"
verify_live_manifest; exit $?
fi
OLD="$DEST/igneum-app-latest.json"
if [ -f "$OLD" ]; then
OLD_VERSION="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("version",""))' "$OLD" 2>/dev/null || true)"
if [ "$OLD_VERSION" = "$VERSION" ]; then
for p in mac windows; do
carried="$(python3 -c 'import json,sys; e=json.load(open(sys.argv[1])).get("platforms",{}).get(sys.argv[2]); print(" ".join([e["url"],e["sha256"],str(e["size"]),e["kind"]]) if e else "")' "$OLD" "$p" 2>/dev/null || true)"
if [ "$p" = mac ] && [ -z "$MAC_ENTRY" ] && [ -n "$carried" ]; then MAC_ENTRY="$carried"; echo "mac: carried over from the current manifest"; fi
if [ "$p" = windows ] && [ -z "$WIN_ENTRY" ] && [ -n "$carried" ]; then WIN_ENTRY="$carried"; echo "windows: carried over from the current manifest"; fi
done
fi
fi
[ -n "$MAC_ENTRY" ] || [ -n "$WIN_ENTRY" ] || { echo "nothing to publish: give --mac and/or --win" >&2; exit 2; }
if [ -z "$MIN_SUPPORTED" ] && [ -f "$OLD" ]; then
MIN_SUPPORTED="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("min_supported_version",""))' "$OLD" 2>/dev/null || true)"
fi
# consensus.override and tuning: given here, else carried over from the current manifest (whatever its version)
if [ -z "$OVERRIDE" ] && [ -f "$OLD" ]; then
OVERRIDE="$(python3 -c 'import json,sys; o=json.load(open(sys.argv[1])).get("consensus",{}).get("override"); print(json.dumps(o, sort_keys=True, separators=(",",":")) if isinstance(o, dict) and o else "")' "$OLD" 2>/dev/null || true)"
[ -n "$OVERRIDE" ] && echo "consensus.override: carried over from the current manifest: $OVERRIDE"
fi
TUNING=""
if [ -n "$TUNING_FILE" ]; then
[ -f "$TUNING_FILE" ] || { echo "missing: $TUNING_FILE" >&2; exit 1; }
TUNING="$(python3 -c 'import json,sys; t=json.load(open(sys.argv[1])); assert isinstance(t.get("cards"), dict), "tuning needs a cards object"; print(json.dumps(t, sort_keys=True, separators=(",",":")))' "$TUNING_FILE")"
elif [ "$NO_TUNING" = 0 ] && [ -f "$OLD" ]; then
TUNING="$(python3 -c 'import json,sys; t=json.load(open(sys.argv[1])).get("tuning"); print(json.dumps(t, sort_keys=True, separators=(",",":")) if isinstance(t, dict) and isinstance(t.get("cards"), dict) else "")' "$OLD" 2>/dev/null || true)"
[ -n "$TUNING" ] && echo "tuning: carried over from the current manifest ($(python3 -c 'import json,sys; print(len(json.loads(sys.argv[1])["cards"]))' "$TUNING") card model(s))"
fi
# canonical JSON: sorted keys, no whitespace; the signature is over these exact bytes
NEW="$DEST/igneum-app-latest.json.new"
python3 - "$NEW" "$VERSION" "$CHANNEL" "$NOTES" "$MIN_SUPPORTED" "$ACTIVATION" "$DEADLINE" "$MAC_ENTRY" "$WIN_ENTRY" "${OVERRIDE:-}" "${TUNING:-}" <<'PY'
import json, sys, datetime
out, version, channel, notes, min_supported, activation, deadline, mac, win, override, tuning = sys.argv[1:12]
override = json.loads(override) if override else None
if override is not None and (not isinstance(override, dict) or not override): raise SystemExit("--override must be a non-empty JSON object")
def entry(s):
if not s: return None
url, sha, size, kind = s.split()
return {"url": url, "sha256": sha, "size": int(size), "kind": kind}
m = {
"version": version,
"published_at": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"channel": channel,
"platforms": {k: v for k, v in (("mac", entry(mac)), ("windows", entry(win))) if v},
"min_supported_version": min_supported,
"notes": notes,
"consensus": {"activation_height": int(activation) if activation else None, "deadline_note": deadline, **({"override": override} if override is not None else {})},
}
if tuning:
m["tuning"] = json.loads(tuning)
open(out, "w").write(json.dumps(m, sort_keys=True, separators=(",", ":"), ensure_ascii=False))
PY
"$SIGNER" sign "$KEY" "$NEW" > "$NEW.sig"
"$SIGNER" verify "$PUB" "$NEW" "$NEW.sig"
mv "$NEW" "$DEST/igneum-app-latest.json"
mv "$NEW.sig" "$DEST/igneum-app-latest.json.sig"
echo "manifest: $DEST/igneum-app-latest.json"
cat "$DEST/igneum-app-latest.json"; echo
echo "signature: $(cat "$DEST/igneum-app-latest.json.sig")"
echo "key fingerprint: $("$SIGNER" fingerprint "$PUB" | tail -1)"
# --public: the same version into dl/public/ with public URLs (its own signed manifest), the aliases rewritten
if [ "$PUBLIC" = 1 ]; then
[ -n "$DLSITE" ] || { echo "--public needs the real downloads folder (no --dest)" >&2; exit 1; }
pub_args=(--app); [ -f "$DEST/igneum-wallet-latest.json" ] && pub_args+=(--wallet)
"$HERE/publish-public.sh" "${pub_args[@]}" || { echo "publish-public.sh failed; dl/public/ not updated" >&2; exit 1; }
fi
if [ "$DEPLOY" = 1 ]; then
[ -n "$DLSITE" ] || { echo "--deploy needs the real downloads folder (no --dest)" >&2; exit 1; }
echo "deploying $DLSITE"
(cd "$DLSITE" && npx --yes vercel@latest --global-config "$HOME/.config/igneum/vercel" deploy --prod --yes 2>&1 | sed "s#$TOKEN#<token>#g"; exit "${PIPESTATUS[0]}") \
|| { echo "the deploy failed (the Vercel CLI's exit status above); nothing verified" >&2; exit 1; }
verify_live_manifest || exit 1
if [ "$PUBLIC" = 1 ]; then "$HERE/publish-public.sh" --verify --no-prune || exit 1; fi
# the console's Builds tab (relay/): one build event and a fresh downloads listing; never fatal
node "$ROOT/tools/console.mjs" post --kind build --title "OTA manifest $VERSION ($CHANNEL) live" --body "$NOTES" >/dev/null 2>&1 || true
node "$ROOT/tools/console.mjs" sync-dl >/dev/null 2>&1 || true
else
if [ -n "$DLSITE" ]; then
echo "not deployed: cd $DLSITE && npx --yes vercel@latest --global-config ~/.config/igneum/vercel deploy --prod --yes"
echo "then the apps see it at $BASE/igneum-app-latest.json (checked hourly, and from Settings > Check now)"
else
echo "written to $DEST for $BASE (test manifest; not the downloads folder)"
fi
fi