Downloads: packaging/ota/publish-public.sh publishes the current installers, the HiveOS package and the two signed manifests into dl/public/ with no token in any URL, writes the four /public/ aliases as vercel.json rewrites and an unsigned index for the site; publish-manifest.sh --public and ship-app.mjs --public run it on every release (dry run and self-test cover it). Nothing removed from the token folders. Site: the miner and wallet buttons link the public aliases and show the version and size from the index, read at build time (site/downloads.json is the offline snapshot); TESTNET_OPEN in build.mjs drops the "Public testnet: not yet open" line on the go; the HiveOS Flight Sheet install line on the miner page; /faucet page. HiveOS: igneum-hive-0.3.8.tar.gz from the 0.3.8 node (2b6d23ef, PC build job) and the zig-built Linux workers. Faucet: site/api/faucet.mjs (10 IGN per address and per IP per day, Neon table faucet_grants, EIP-1559 transfer signed by site/lib/eth.mjs with no dependencies: keccak, RLP, secp256k1 with RFC 6979), FAUCET_KEY and FAUCET_RPC from the Vercel env only; 15 unit tests with a fake database and node, run in CI. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
253 lines
15 KiB
Bash
Executable file
253 lines
15 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
# The PUBLIC downloads path (5 October 2026, testnet launch): dl/public/ in the downloads folder holds only the current
|
|
# installers, the HiveOS package and the two signed manifests, with no token in any URL, plus one stable alias per
|
|
# platform that the site links:
|
|
#
|
|
# https://dl.igneum.network/public/igneum-miner-windows.exe -> dl/public/Igneum-Miner-Setup-<v>.exe
|
|
# https://dl.igneum.network/public/igneum-miner-mac.dmg -> dl/public/Igneum-Miner-<v>.dmg
|
|
# https://dl.igneum.network/public/igneum-miner-hive.tar.gz -> dl/public/igneum-hive-<v>.tar.gz
|
|
# https://dl.igneum.network/public/igneum-wallet-mac.dmg -> dl/public/Igneum-Wallet-<v>.dmg
|
|
#
|
|
# The aliases are rewrites in <dlsite>/vercel.json, written by this script from what dl/public/ holds, so they can
|
|
# never point at a file that is not there and they follow every release by themselves (the bytes exist once).
|
|
# Nothing is ever removed from the token folders; the public folder is pruned to the current files only.
|
|
#
|
|
# packaging/ota/publish-public.sh --app copy the files named by dl/<token>/igneum-app-latest.json, write
|
|
# dl/public/igneum-app-latest.json(.sig) with public URLs, signed
|
|
# packaging/ota/publish-public.sh --wallet the same for igneum-wallet-latest.json
|
|
# packaging/ota/publish-public.sh --hive <igneum-hive-<v>.tar.gz> copy the HiveOS package (plus its .sha256)
|
|
# packaging/ota/publish-public.sh --aliases only rewrite vercel.json from the folder (runs after every step above)
|
|
# --dry-run read everything, print the plan, write nothing --deploy deploy the downloads folder afterwards
|
|
# --verify HEAD every public file and alias, GET the manifests (also after --deploy) --no-prune keep old files
|
|
# --dest <dir> --base-url <url> a scratch folder instead of the downloads folder (tests)
|
|
#
|
|
# publish-manifest.sh --public calls this with --app (and --wallet when that manifest exists), so every future
|
|
# release lands here too. Reads ~/.config/igneum/dlsite-dir, dl-token (fresh on every run; it is being rotated),
|
|
# ota-signing-key(.pub); never prints a token.
|
|
set -euo pipefail
|
|
HERE="$(cd "$(dirname "$0")" && pwd)"
|
|
ROOT="$(cd "$HERE/../.." && pwd)"
|
|
CFG="$HOME/.config/igneum"
|
|
KEY="$CFG/ota-signing-key"; PUB_KEY="$CFG/ota-signing-key.pub"
|
|
SIGNER="${IGNEUM_OTA_SIGN:-$ROOT/app/igneum-app/target/release/igneum-ota-sign}" # a built signer elsewhere (another worktree)
|
|
HOST="https://dl.igneum.network"
|
|
|
|
DO_APP=0 DO_WALLET=0 HIVE="" DO_ALIASES=0 DRY=0 DEPLOY=0 VERIFY=0 PRUNE=1 DEST="" BASE="" TRIES=12
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--app) DO_APP=1; shift ;;
|
|
--wallet) DO_WALLET=1; shift ;;
|
|
--hive) HIVE="$2"; shift 2 ;;
|
|
--aliases) DO_ALIASES=1; shift ;;
|
|
--dry-run) DRY=1; shift ;;
|
|
--deploy) DEPLOY=1; shift ;;
|
|
--verify) VERIFY=1; shift ;;
|
|
--no-prune) PRUNE=0; shift ;;
|
|
--dest) DEST="$2"; shift 2 ;;
|
|
--base-url) BASE="$2"; shift 2 ;;
|
|
--tries) TRIES="$2"; shift 2 ;;
|
|
*) echo "unknown argument: $1" >&2; exit 2 ;;
|
|
esac
|
|
done
|
|
[ "$DO_APP$DO_WALLET$DO_ALIASES$VERIFY" != 0000 ] || [ -n "$HIVE" ] || { echo "nothing to do: --app, --wallet, --hive <file>, --aliases or --verify" >&2; exit 2; }
|
|
command -v python3 >/dev/null || { echo "python3 is needed" >&2; exit 1; }
|
|
[ -x "$SIGNER" ] || { echo "no $SIGNER (cargo build --release --bin igneum-ota-sign in app/igneum-app)" >&2; exit 1; }
|
|
|
|
TOKEN_FILE="$CFG/dl-token"
|
|
[ -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 "$CFG/dlsite-dir" ] && DLSITE="$(tr -d '[:space:]' < "$CFG/dlsite-dir")"; } || true
|
|
[ -n "$DLSITE" ] && [ -d "$DLSITE/dl/$TOKEN" ] || { echo "no downloads folder: ~/.config/igneum/dlsite-dir must hold dl/<token>/" >&2; exit 1; }
|
|
else
|
|
DLSITE="$DEST"; mkdir -p "$DLSITE/dl/$TOKEN"
|
|
fi
|
|
SRC="$DLSITE/dl/$TOKEN"
|
|
PUB="$DLSITE/dl/public"
|
|
[ -n "$BASE" ] || BASE="$HOST"
|
|
BASE="${BASE%/}"
|
|
BASE_PUB="$BASE/dl/public"
|
|
scrub() { sed "s#$TOKEN#<token>#g"; }
|
|
log() { printf '%s\n' "$*" | scrub; }
|
|
sha() { "$SIGNER" sha256 "$1" | cut -d' ' -f1; }
|
|
[ "$DRY" = 1 ] || mkdir -p "$PUB"
|
|
|
|
# one manifest: copy the files it names from the token folder, rewrite every URL to the public base, sign, verify
|
|
publish_manifest() { # <manifest name>
|
|
local name="$1" src="$SRC/$1" out="$PUB/$1" files f sum
|
|
[ -f "$src" ] || { log "no $src: nothing to publish for $name"; return 1; }
|
|
files="$(python3 -c 'import json,sys; m=json.load(open(sys.argv[1])); print("\n".join(e["url"].rsplit("/",1)[1] for e in m.get("platforms",{}).values()))' "$src")"
|
|
for f in $files; do
|
|
[ -f "$SRC/$f" ] || { log "ERROR: $name names $f but dl/<token>/$f is not there" >&2; return 1; }
|
|
sum="$(python3 -c 'import json,sys; m=json.load(open(sys.argv[1])); print([e["sha256"] for e in m["platforms"].values() if e["url"].endswith("/"+sys.argv[2])][0])' "$src" "$f")"
|
|
[ "$(sha "$SRC/$f")" = "$sum" ] || { log "ERROR: dl/<token>/$f does not match the sha256 in $name" >&2; return 1; }
|
|
if [ -f "$PUB/$f" ] && [ "$(sha "$PUB/$f")" = "$sum" ]; then log " $f: already in dl/public/ (same sha256)"
|
|
elif [ "$DRY" = 1 ]; then log " $f: would copy into dl/public/ ($(stat -f %z "$SRC/$f") B)"
|
|
else cp "$SRC/$f" "$PUB/$f.part" && mv "$PUB/$f.part" "$PUB/$f"; log " $f: copied into dl/public/ ($(stat -f %z "$PUB/$f") B)"; fi
|
|
done
|
|
# the public manifest: the same fields, URLs under the public base, canonical bytes, our signature
|
|
local tmp; tmp="$(mktemp)"
|
|
python3 - "$src" "$tmp" "$BASE_PUB" <<'PY'
|
|
import json, sys
|
|
src, out, base = sys.argv[1:4]
|
|
m = json.load(open(src))
|
|
for e in m.get("platforms", {}).values():
|
|
e["url"] = base + "/" + e["url"].rsplit("/", 1)[1]
|
|
open(out, "w").write(json.dumps(m, sort_keys=True, separators=(",", ":"), ensure_ascii=False))
|
|
PY
|
|
if grep -q "$TOKEN" "$tmp"; then rm -f "$tmp"; log "ERROR: the public $name would still carry the token" >&2; return 1; fi
|
|
if [ "$DRY" = 1 ]; then
|
|
log " $name: would write $(python3 -c 'import json,sys; m=json.load(open(sys.argv[1])); print(m.get("version"), "+".join(m.get("platforms",{})))' "$tmp") with URLs under $BASE_PUB, signed"
|
|
rm -f "$tmp"; return 0
|
|
fi
|
|
"$SIGNER" sign "$KEY" "$tmp" > "$tmp.sig"
|
|
"$SIGNER" verify "$PUB_KEY" "$tmp" "$tmp.sig" >/dev/null
|
|
chmod 644 "$tmp" "$tmp.sig"; mv "$tmp" "$out"; mv "$tmp.sig" "$out.sig"
|
|
log " $name: $(python3 -c 'import json,sys; m=json.load(open(sys.argv[1])); print(m.get("version"), "+".join(m.get("platforms",{})))' "$out") written, signed, verified; URLs under $BASE_PUB"
|
|
}
|
|
|
|
if [ "$DO_APP" = 1 ]; then log "app manifest -> dl/public/"; publish_manifest igneum-app-latest.json; fi
|
|
if [ "$DO_WALLET" = 1 ]; then log "wallet manifest -> dl/public/"; publish_manifest igneum-wallet-latest.json; fi
|
|
if [ -n "$HIVE" ]; then
|
|
[ -f "$HIVE" ] || { echo "missing: $HIVE" >&2; exit 1; }
|
|
hn="$(basename "$HIVE")"
|
|
case "$hn" in igneum-hive-*.tar.gz) ;; *) echo "$HIVE: the HiveOS package is igneum-hive-<version>.tar.gz (packaging/hive/make-hive-package.sh)" >&2; exit 1 ;; esac
|
|
log "HiveOS package -> dl/public/"
|
|
if [ -f "$PUB/$hn" ] && [ "$(sha "$PUB/$hn")" = "$(sha "$HIVE")" ]; then log " $hn: already in dl/public/ (same sha256)"
|
|
elif [ "$DRY" = 1 ]; then log " $hn: would copy into dl/public/ ($(stat -f %z "$HIVE") B, sha256 $(sha "$HIVE"))"
|
|
else cp "$HIVE" "$PUB/$hn.part" && mv "$PUB/$hn.part" "$PUB/$hn"; "$SIGNER" sha256 "$PUB/$hn" | awk -v n="$hn" '{print $1 " " n}' > "$PUB/$hn.sha256"; log " $hn: copied into dl/public/ ($(stat -f %z "$PUB/$hn") B, sha256 $(sha "$PUB/$hn"))"; fi
|
|
fi
|
|
|
|
# the aliases: what the two manifests and the newest HiveOS package in dl/public/ name, nothing else
|
|
ALIASES_JSON="$(python3 - "$PUB" "$DRY" <<'PY'
|
|
import json, os, re, sys
|
|
pub, dry = sys.argv[1], sys.argv[2] == "1"
|
|
def entry(name, plat):
|
|
p = os.path.join(pub, name)
|
|
if not os.path.exists(p): return None
|
|
e = json.load(open(p)).get("platforms", {}).get(plat)
|
|
if not e: return None
|
|
f = e["url"].rsplit("/", 1)[1]
|
|
return f if os.path.exists(os.path.join(pub, f)) or dry else None
|
|
hive = sorted([f for f in os.listdir(pub) if re.fullmatch(r"igneum-hive-\d+\.\d+\.\d+\.tar\.gz", f)],
|
|
key=lambda f: tuple(int(x) for x in re.findall(r"\d+", f)[:3])) if os.path.isdir(pub) else []
|
|
aliases = {
|
|
"igneum-miner-windows.exe": entry("igneum-app-latest.json", "windows"),
|
|
"igneum-miner-mac.dmg": entry("igneum-app-latest.json", "mac"),
|
|
"igneum-miner-hive.tar.gz": hive[-1] if hive else None,
|
|
"igneum-wallet-mac.dmg": entry("igneum-wallet-latest.json", "mac"),
|
|
}
|
|
print(json.dumps(aliases))
|
|
PY
|
|
)"
|
|
KEEP="$(python3 -c 'import json,sys; a=json.loads(sys.argv[1]); print(" ".join(v for v in a.values() if v))' "$ALIASES_JSON")"
|
|
log "aliases (vercel.json rewrites under /public/):"
|
|
python3 -c 'import json,sys; a=json.loads(sys.argv[1]); [print(" /public/%s -> %s" % (k, ("/dl/public/" + v) if v else "(no file yet; alias left out)")) for k,v in a.items()]' "$ALIASES_JSON" | scrub
|
|
VERCEL_JSON="$(python3 - "$ALIASES_JSON" <<'PY'
|
|
import json, sys
|
|
a = json.loads(sys.argv[1])
|
|
rewrites = [{"source": "/public/" + k, "destination": "/dl/public/" + v} for k, v in a.items() if v]
|
|
cfg = {
|
|
"cleanUrls": False,
|
|
"trailingSlash": False,
|
|
"rewrites": rewrites,
|
|
"headers": [
|
|
{"source": "/dl/public/(.*)\\.json", "headers": [{"key": "Cache-Control", "value": "public, max-age=60"}, {"key": "Access-Control-Allow-Origin", "value": "*"}]},
|
|
{"source": "/dl/public/(.*)\\.sig", "headers": [{"key": "Cache-Control", "value": "public, max-age=60"}, {"key": "Access-Control-Allow-Origin", "value": "*"}]},
|
|
{"source": "/public/(.*)", "headers": [{"key": "Cache-Control", "value": "public, max-age=300"}, {"key": "X-Content-Type-Options", "value": "nosniff"}]},
|
|
],
|
|
}
|
|
print(json.dumps(cfg, indent=2))
|
|
PY
|
|
)"
|
|
if [ "$DRY" = 1 ]; then log " would write $DLSITE/vercel.json ($(printf '%s' "$VERCEL_JSON" | grep -c '"source"') rules)"
|
|
else printf '%s\n' "$VERCEL_JSON" > "$DLSITE/vercel.json.new" && mv "$DLSITE/vercel.json.new" "$DLSITE/vercel.json"; log " wrote $DLSITE/vercel.json"; fi
|
|
|
|
# the index the site build reads (unsigned, a convenience: alias, file, version, size, sha256 per platform; the signed
|
|
# manifests stay the source of truth for the apps)
|
|
if [ "$DRY" = 0 ]; then
|
|
python3 - "$PUB" "$ALIASES_JSON" "$BASE" <<'PYIDX'
|
|
import json, os, re, sys, hashlib, datetime
|
|
pub, aliases, base = sys.argv[1], json.loads(sys.argv[2]), sys.argv[3]
|
|
def sha(p):
|
|
h = hashlib.sha256()
|
|
with open(p, "rb") as f:
|
|
for chunk in iter(lambda: f.read(1 << 20), b""): h.update(chunk)
|
|
return h.hexdigest()
|
|
def version_of(name, f):
|
|
if name.startswith("igneum-miner-hive"): return ".".join(re.findall(r"\d+", f)[:3])
|
|
m = json.load(open(os.path.join(pub, "igneum-wallet-latest.json" if name.startswith("igneum-wallet") else "igneum-app-latest.json")))
|
|
return m.get("version")
|
|
keys = {"igneum-miner-windows.exe": "miner-windows", "igneum-miner-mac.dmg": "miner-mac", "igneum-miner-hive.tar.gz": "miner-hive", "igneum-wallet-mac.dmg": "wallet-mac"}
|
|
files = {}
|
|
for alias, f in aliases.items():
|
|
if not f: continue
|
|
p = os.path.join(pub, f)
|
|
files[keys[alias]] = {"alias": "/public/" + alias, "file": f, "path": "/dl/public/" + f, "version": version_of(alias, f), "size": os.path.getsize(p), "sha256": sha(p)}
|
|
out = {"updated": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "base": base, "files": files}
|
|
tmp = os.path.join(pub, "igneum-downloads.json.new")
|
|
open(tmp, "w").write(json.dumps(out, indent=2, sort_keys=True) + "\n"); os.chmod(tmp, 0o644); os.replace(tmp, os.path.join(pub, "igneum-downloads.json"))
|
|
print(" wrote dl/public/igneum-downloads.json (%d platform(s))" % len(files))
|
|
PYIDX
|
|
fi
|
|
|
|
# prune: dl/public/ holds only the current files (the manifests, what they name, the newest HiveOS package and its sha256)
|
|
if [ "$PRUNE" = 1 ] && [ -d "$PUB" ]; then
|
|
for f in "$PUB"/*; do
|
|
[ -f "$f" ] || continue
|
|
n="$(basename "$f")"
|
|
case "$n" in igneum-app-latest.json|igneum-app-latest.json.sig|igneum-wallet-latest.json|igneum-wallet-latest.json.sig|igneum-downloads.json) continue ;; esac
|
|
keep=0; for k in $KEEP; do [ "$n" = "$k" ] || [ "$n" = "$k.sha256" ] && keep=1; done
|
|
if [ "$keep" = 0 ]; then
|
|
if [ "$DRY" = 1 ]; then log " would remove $n from dl/public/ (not current)"; else rm -f "$f"; log " removed $n from dl/public/ (not current)"; fi
|
|
fi
|
|
done
|
|
fi
|
|
if [ "$DRY" = 1 ]; then log "dry run: nothing written"; fi
|
|
|
|
if [ "$DEPLOY" = 1 ]; then
|
|
[ "$DRY" = 0 ] || { echo "--deploy and --dry-run together make no sense" >&2; exit 2; }
|
|
[ -z "$DEST" ] || { echo "--deploy needs the real downloads folder (no --dest)" >&2; exit 1; }
|
|
log "deploying $DLSITE"
|
|
(cd "$DLSITE" && npx --yes vercel@latest --global-config "$CFG/vercel" deploy --prod --yes 2>&1 | scrub; exit "${PIPESTATUS[0]}") \
|
|
|| { echo "the deploy failed (the Vercel CLI's exit status above); nothing verified" >&2; exit 1; }
|
|
VERIFY=1
|
|
fi
|
|
|
|
if [ "$VERIFY" = 1 ]; then
|
|
fail=0; t=0
|
|
check_one() { # <url> <local file> -> 0 when HEAD is 200 with the local size
|
|
local url="$1" f="$2" hdr code len size
|
|
size="$(stat -f %z "$f")"
|
|
hdr="$(curl -sI -L -H 'Cache-Control: no-cache' "$url" 2>/dev/null | tr -d '\r')"
|
|
code="$(printf '%s\n' "$hdr" | awk 'toupper($1) ~ /^HTTP\// {c=$2} END{print c+0}')"
|
|
len="$(printf '%s\n' "$hdr" | awk 'tolower($1)=="content-length:"{l=$2} END{print l+0}')"
|
|
printf ' %s %s B %s (local %s B)\n' "$code" "$len" "$url" "$size" | scrub
|
|
[ "$code" = 200 ] && [ "$len" = "$size" ]
|
|
}
|
|
while [ "$t" -lt "$TRIES" ]; do
|
|
t=$((t + 1)); fail=0
|
|
for f in "$PUB"/*; do
|
|
[ -f "$f" ] || continue
|
|
n="$(basename "$f")"
|
|
check_one "$BASE_PUB/$n" "$f" || fail=1
|
|
case "$n" in igneum-app-latest.json|igneum-wallet-latest.json)
|
|
tmp="$(mktemp)"; curl -fsSL -H 'Cache-Control: no-cache' -o "$tmp" "$BASE_PUB/$n" 2>/dev/null || true
|
|
if cmp -s "$tmp" "$f" && curl -fsSL -H 'Cache-Control: no-cache' -o "$tmp.sig" "$BASE_PUB/$n.sig" 2>/dev/null && "$SIGNER" verify "$PUB_KEY" "$tmp" "$tmp.sig" >/dev/null 2>&1; then echo " $n: byte-identical, signature OK"; else echo " $n: NOT the local bytes yet (or the signature fails)"; fail=1; fi
|
|
rm -f "$tmp" "$tmp.sig" ;;
|
|
igneum-downloads.json)
|
|
tmp="$(mktemp)"; curl -fsSL -H 'Cache-Control: no-cache' -o "$tmp" "$BASE_PUB/$n" 2>/dev/null || true
|
|
if cmp -s "$tmp" "$f"; then echo " $n: byte-identical"; else echo " $n: NOT the local bytes yet"; fail=1; fi
|
|
rm -f "$tmp" ;;
|
|
esac
|
|
done
|
|
for pair in $(python3 -c 'import json,sys; a=json.loads(sys.argv[1]); print(" ".join(k+"="+v for k,v in a.items() if v))' "$ALIASES_JSON"); do
|
|
check_one "$BASE/public/${pair%%=*}" "$PUB/${pair#*=}" || fail=1
|
|
done
|
|
[ "$fail" = 0 ] && break
|
|
[ "$t" -lt "$TRIES" ] && { echo " not all served yet (try $t of $TRIES); again in 10 s"; sleep 10; }
|
|
done
|
|
if [ "$fail" = 1 ]; then echo "public folder: NOT fully served after $t tries" >&2; exit 1; fi
|
|
echo "public folder verified: every file and alias answers 200 with the local size (try $t of $TRIES)"
|
|
fi
|