258 lines
17 KiB
Bash
Executable file
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
|