From 5ac2dbb55714c8827f3caaac1ad864649df744a0 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:38:24 +0000 Subject: [PATCH] The version pair (7 October 2026, the project lead's Mac on 0.3.21: interface 1.0.1, cut from 0.3.20 and carried into the 0.3.21 manifest, served over the packaged UI and brought the Prove switch defect back): tools/ui-ota/pair-check.mjs refuses a manifest whose ui.version differs from the ui_version of any app entry, or whose entries carry none (today's shape fails by design; self-test known-failed first); publish-manifest.sh stamps the tree's app/igneum-app/ui/VERSION on every new entry, carries it on carried entries and runs the check before signing, so a carried-over interface against a newer tree refuses the publish; ui/VERSION 1.0.2 for the 0.3.22 tree and the interface cut from it; the gate runs the self-test and the check on IGNEUM_MANIFEST when one is given Co-Authored-By: Claude Fable 5.1 --- app/igneum-app/ui/VERSION | 2 +- packaging/ota/publish-manifest.sh | 24 +++++++++++++------ tools/ci/pre-push.sh | 1 + tools/ui-ota/pair-check.mjs | 38 +++++++++++++++++++++++++++++++ 4 files changed, 57 insertions(+), 8 deletions(-) create mode 100644 tools/ui-ota/pair-check.mjs diff --git a/app/igneum-app/ui/VERSION b/app/igneum-app/ui/VERSION index 3eefcb9d..6d7de6e6 100644 --- a/app/igneum-app/ui/VERSION +++ b/app/igneum-app/ui/VERSION @@ -1 +1 @@ -1.0.0 +1.0.2 diff --git a/packaging/ota/publish-manifest.sh b/packaging/ota/publish-manifest.sh index 786627a8..811422a0 100755 --- a/packaging/ota/publish-manifest.sh +++ b/packaging/ota/publish-manifest.sh @@ -180,7 +180,7 @@ 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)" + 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"],e.get("ui_version","")]).rstrip() 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 @@ -255,20 +255,27 @@ if [ -n "$ACTIVATION" ]; then echo "note: the live DAA could not be read from /api/live; the activation height $ACTIVATION is not checked against it" >&2 fi fi -python3 - "$NEW" "$VERSION" "$CHANNEL" "$NOTES" "$MIN_SUPPORTED" "$ACTIVATION" "$DEADLINE" "$MAC_ENTRY" "$WIN_ENTRY" "${OVERRIDE:-}" "${TUNING:-}" "${UI:-}" "${DRIVERS:-}" <<'PY' +# the version pair: the UI tree this publish builds from stamps its interface version on every new app entry +UI_TREE_VERSION="$(tr -d '[:space:]' < "$ROOT/app/igneum-app/ui/VERSION" 2>/dev/null || true)" +python3 - "$NEW" "$VERSION" "$CHANNEL" "$NOTES" "$MIN_SUPPORTED" "$ACTIVATION" "$DEADLINE" "$MAC_ENTRY" "$WIN_ENTRY" "${OVERRIDE:-}" "${TUNING:-}" "${UI:-}" "${DRIVERS:-}" "${UI_TREE_VERSION:-}" <<'PY' import json, sys, datetime -out, version, channel, notes, min_supported, activation, deadline, mac, win, override, tuning, ui, drivers = sys.argv[1:14] +out, version, channel, notes, min_supported, activation, deadline, mac, win, override, tuning, ui, drivers, ui_tree = sys.argv[1:15] 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): +def entry(s, ui_version=None): if not s: return None - url, sha, size, kind = s.split() - return {"url": url, "sha256": sha, "size": int(size), "kind": kind} + parts = s.split() + url, sha, size, kind = parts[:4] + e = {"url": url, "sha256": sha, "size": int(size), "kind": kind} + # the version pair (tools/ui-ota/pair-check.mjs): the interface version of the UI tree this entry was built from + uv = parts[4] if len(parts) > 4 else ui_version + if uv: e["ui_version"] = uv + return e 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}, + "platforms": {k: v for k, v in (("mac", entry(mac, ui_tree)), ("windows", entry(win, ui_tree))) 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 {})}, @@ -281,6 +288,9 @@ if drivers: m["drivers"] = json.loads(drivers) open(out, "w").write(json.dumps(m, sort_keys=True, separators=(",", ":"), ensure_ascii=False)) PY +# the version pair (7 October 2026): the interface bundle and every app entry must come from one UI tree; a carried-over +# ui object against a newer tree, or an entry without its interface version, refuses the publish here +node "$ROOT/tools/ui-ota/pair-check.mjs" "$NEW" || { echo "refused: the manifest's interface and its app entries are not one UI tree (tools/ui-ota/pair-check.mjs); cut the interface from this tree with tools/ui-ota/publish.mjs --version $UI_TREE_VERSION, or --no-ui" >&2; exit 2; } "$SIGNER" sign "$KEY" "$NEW" > "$NEW.sig" "$SIGNER" verify "$PUB" "$NEW" "$NEW.sig" mv "$NEW" "$DEST/igneum-app-latest.json" diff --git a/tools/ci/pre-push.sh b/tools/ci/pre-push.sh index 54a0c7f7..cef60349 100755 --- a/tools/ci/pre-push.sh +++ b/tools/ci/pre-push.sh @@ -104,6 +104,7 @@ tree_checks() { run "explorer, emission and public stats unit tests" node --test site/lib/explorer.test.mjs site/lib/emission.test.mjs site/lib/money.test.mjs site/api/public-stats.test.mjs run "ship tool self-test" node tools/ship-app.mjs --self-test run "interface bundle publisher self-test (pack, and sign when the signer is built)" node tools/ui-ota/publish.mjs --self-test + run "the interface and the app entries are one UI tree (version pair; self-test, then the staged manifest when one is given)" bash -c 'node tools/ui-ota/pair-check.mjs --self-test && { [ -z "${IGNEUM_MANIFEST:-}" ] || node tools/ui-ota/pair-check.mjs "$IGNEUM_MANIFEST"; }' run "heat gate reader self-test (a known hold passes, a known drift fails)" node tools/heat-gate.mjs --self-test run "relay unit tests" node --test relay/test/parse.test.mjs relay/test/auth.test.mjs relay/test/wake.test.mjs relay/test/ember.test.mjs run "miner app notice strip and update card tests" node --test app/igneum-app/ui/notices.test.mjs app/igneum-app/ui/update-card.test.mjs app/igneum-app/ui/view.test.mjs app/igneum-app/ui/tune-line.test.mjs app/igneum-app/ui/ui-ota.test.mjs app/igneum-app/ui/fold.test.mjs diff --git a/tools/ui-ota/pair-check.mjs b/tools/ui-ota/pair-check.mjs new file mode 100644 index 00000000..b5da3600 --- /dev/null +++ b/tools/ui-ota/pair-check.mjs @@ -0,0 +1,38 @@ +#!/usr/bin/env node +// The version pair (7 October 2026, the project lead's Mac on 0.3.21: the Prove switch defect came back because interface 1.0.1, cut +// from 0.3.20, was carried into the 0.3.21 manifest and served over the packaged 0.3.21 UI). The rule, mechanical: the +// manifest's interface bundle is cut from the same UI tree as the app entry it ships with, so `ui.version` must equal the +// `ui_version` every platform entry carries (the tree's app/igneum-app/ui/VERSION at the app's publish, written by +// packaging/ota/publish-manifest.sh); a manifest with a `ui` object and no `ui_version` on an entry fails, as today's does. +// node tools/ui-ota/pair-check.mjs exit 1 with one line per mismatch +// node tools/ui-ota/pair-check.mjs --self-test known-failed first (today's shape, a mismatch), then a good pair +import { readFileSync } from 'node:fs'; + +export function pairProblems(m) { + const out = [], ui = m && m.ui && typeof m.ui === 'object' ? m.ui : null; + const plats = (m && m.platforms && typeof m.platforms === 'object') ? m.platforms : {}; + if (!ui) return out; // no interface channel: nothing can be mismatched (the kill switch state) + if (!/^\d+\.\d+\.\d+$/.test(String(ui.version || ''))) out.push('ui.version is not three-part: ' + ui.version); + for (const [name, e] of Object.entries(plats)) { + if (!e || typeof e !== 'object') continue; + if (!e.ui_version) out.push(`platforms.${name} (app ${m.version}) carries no ui_version: the entry cannot prove the interface ${ui.version} came from its tree`); + else if (e.ui_version !== ui.version) out.push(`platforms.${name} (app ${m.version}) was built with interface ${e.ui_version}, the manifest ships interface ${ui.version}`); + } + return out; +} + +if (process.argv[1] && import.meta.url === new URL(process.argv[1], 'file:').href) { + if (process.argv.includes('--self-test')) { + const today = { version: '0.3.21', ui: { version: '1.0.1', min_engine: '0.3.20' }, platforms: { mac: { url: 'x' }, windows: { url: 'y' } } }; + const mismatch = { version: '0.3.22', ui: { version: '1.0.1' }, platforms: { mac: { ui_version: '1.0.2' } } }; + const good = { version: '0.3.22', ui: { version: '1.0.2' }, platforms: { mac: { ui_version: '1.0.2' }, windows: { ui_version: '1.0.2' } } }; + const none = { version: '0.3.22', platforms: { mac: {} } }; + const a = pairProblems(today), b = pairProblems(mismatch), c = pairProblems(good), d = pairProblems(none); + if (a.length !== 2 || b.length !== 1 || c.length !== 0 || d.length !== 0) { console.error('pair-check self-test: wrong verdicts', { a, b, c, d }); process.exit(1); } + console.log('pair-check self-test: today\'s shape fails (' + a[0] + '), a mismatch fails, a matched pair passes, no ui passes'); process.exit(0); + } + const file = process.argv[2]; if (!file) { console.error('usage: pair-check.mjs | --self-test'); process.exit(2); } + const problems = pairProblems(JSON.parse(readFileSync(file, 'utf8'))); + if (problems.length) { for (const p of problems) console.error('pair-check: ' + p); process.exit(1); } + console.log('pair-check: the interface and every app entry are one UI tree'); +}