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 1/4] 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 3eefcb9dd..6d7de6e6a 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 786627a8e..811422a03 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 54a0c7f77..cef60349d 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 000000000..b5da3600e --- /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'); +} From 57138a3285407f156b38464412d4b4ca4fc60053 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:39:19 +0000 Subject: [PATCH 2/4] ota-102: the ui-ota test pins ui/VERSION 1.0.2; publish-manifest.sh --ui-pair-override ships an interface from another UI tree knowingly with the reason logged (tonight: interface 1.0.2 from the 0.3.22 tree to the 0.3.21 manifest, so every 0.3.21 app takes the Prove switch fix), the pair rule binding without it Co-Authored-By: Claude Fable 5.1 --- app/igneum-app/ui/ui-ota.test.mjs | 2 +- packaging/ota/publish-manifest.sh | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/app/igneum-app/ui/ui-ota.test.mjs b/app/igneum-app/ui/ui-ota.test.mjs index a19a97472..cde3aa70a 100644 --- a/app/igneum-app/ui/ui-ota.test.mjs +++ b/app/igneum-app/ui/ui-ota.test.mjs @@ -19,7 +19,7 @@ test('the embedded interface says built in; an over-the-air one says so with its assert.equal(w.eyebrow, 'over the air'); assert.equal(w.help, 'Built in: 1.0.0.'); assert.equal(V.interfaceWords({}).line, ''); - assert.equal(readFileSync(join(here, 'VERSION'), 'utf8').trim(), '1.0.0', 'the embedded interface version is three-part and in ui/VERSION'); + assert.equal(readFileSync(join(here, 'VERSION'), 'utf8').trim(), '1.0.2', 'the embedded interface version is three-part and in ui/VERSION (1.0.2: the 0.3.22 tree, 7 October 2026)'); }); test('the help line says what is pending, refused, held back by the switch, or skipped', () => { diff --git a/packaging/ota/publish-manifest.sh b/packaging/ota/publish-manifest.sh index 811422a03..cd4d4499b 100755 --- a/packaging/ota/publish-manifest.sh +++ b/packaging/ota/publish-manifest.sh @@ -67,6 +67,7 @@ while [ $# -gt 0 ]; do --drivers) DRIVERS_FILE="$2"; shift 2 ;; --no-drivers) NO_DRIVERS=1; shift ;; --ui) UI_FILE="$2"; shift 2 ;; + --ui-pair-override) UI_PAIR_OVERRIDE="$2"; shift 2 ;; # ship an interface from another UI tree knowingly, with the reason logged (7 Oct 2026: 1.0.2 to 0.3.21 apps) --no-ui) NO_UI=1; shift ;; --base-url) BASE="$2"; shift 2 ;; --dest) DEST="$2"; shift 2 ;; @@ -290,7 +291,10 @@ open(out, "w").write(json.dumps(m, sort_keys=True, separators=(",", ":"), ensure 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; } +if ! node "$ROOT/tools/ui-ota/pair-check.mjs" "$NEW"; then + if [ -n "${UI_PAIR_OVERRIDE:-}" ]; then echo "the interface is not from this manifest's UI tree; published anyway on --ui-pair-override: $UI_PAIR_OVERRIDE" >&2 + else 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, or say why with --ui-pair-override \"\"" >&2; exit 2; fi +fi "$SIGNER" sign "$KEY" "$NEW" > "$NEW.sig" "$SIGNER" verify "$PUB" "$NEW" "$NEW.sig" mv "$NEW" "$DEST/igneum-app-latest.json" From 9e42badf2375cef17f2fed44ea8224edd5ff4c73 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:41:20 +0000 Subject: [PATCH 3/4] ui-ota publish: --ui-pair-override passes through to the manifest writer (tonight's 1.0.2 to the 0.3.21 manifest) Co-Authored-By: Claude Fable 5.1 --- tools/ui-ota/publish.mjs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tools/ui-ota/publish.mjs b/tools/ui-ota/publish.mjs index fdae4c676..b23a5b982 100644 --- a/tools/ui-ota/publish.mjs +++ b/tools/ui-ota/publish.mjs @@ -6,6 +6,7 @@ // which deploys from the live folder; a cut is staged in a scratch copy (IGNEUM_DLSITE) as every other publish. // // node tools/ui-ota/publish.mjs --version 1.0.1 --min-engine 0.3.19 [--notes "one line"] [--dry-run] [--deploy] [--public] +// [--ui-pair-override "reason"] ship an interface from another UI tree knowingly (the pair rule) // node tools/ui-ota/publish.mjs --verify [--url https://dl.igneum.network/dl//igneum-app-latest.json] // the live manifest's ui entry against the bundle in the folder (the read-back) // node tools/ui-ota/publish.mjs --self-test pack, hash, sign and verify with a throwaway key in a scratch folder @@ -161,6 +162,8 @@ function main() { const pm = path.join(root, 'packaging/ota/publish-manifest.sh'); const args = ['--version', arg('--app-version', readFolderVersion(dest)), '--ui', uiJson, '--notes', notes || `interface ${version} over the air`]; if (flag('--deploy')) args.push('--deploy'); + // the version pair (packaging/ota/publish-manifest.sh): an interface from another UI tree ships only with a logged reason + if (arg('--ui-pair-override', '')) args.push('--ui-pair-override', arg('--ui-pair-override', '')); if (flag('--public')) args.push('--public'); console.log(`publish-manifest.sh ${args.join(' ').split(token).join('')}`); const r = spawnSync('bash', [pm, ...args], { stdio: 'inherit', env: { ...process.env, IGNEUM_DLSITE: dlsite } }); From 83b8c8bd7466c546ef40151aa00edac201b1ddf2 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:42:05 +0000 Subject: [PATCH 4/4] publish-manifest.sh: a carried app entry keeps its own ui_version or none; only a new entry is stamped from the tree (a carried 0.3.21 entry had taken the tree's 1.0.2 and passed the pair check falsely) Co-Authored-By: Claude Fable 5.1 --- packaging/ota/publish-manifest.sh | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packaging/ota/publish-manifest.sh b/packaging/ota/publish-manifest.sh index cd4d4499b..8c59699fb 100755 --- a/packaging/ota/publish-manifest.sh +++ b/packaging/ota/publish-manifest.sh @@ -181,7 +181,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"],e.get("ui_version","")]).rstrip() 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") or "-"]) 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 @@ -268,8 +268,9 @@ def entry(s, ui_version=None): 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 + # the version pair (tools/ui-ota/pair-check.mjs): a NEW entry is built from this tree and carries its interface version; + # a carried entry keeps its own ("-" = it had none, and none is stamped: the tree's version is not its proof) + uv = (None if parts[4] == "-" else parts[4]) if len(parts) > 4 else ui_version if uv: e["ui_version"] = uv return e m = {