Merge rotation-2 (d5986d2) into release-0.3.6: packaged config from files, --dl-both, logs --rotation; windows.yml keeps the G13 signed-inputs step after the packaged configuration step

This commit is contained in:
igneum-labs 2026-10-05 08:33:37 +00:00
commit 20bf44cdbb
16 changed files with 1176 additions and 124 deletions

View file

@ -20,6 +20,16 @@
# this checkout, all before anything is built from them (review round 4, R4.5.2, ledger G13). The verified
# manifest, its signature and the runner's record go up as the igneum-windows-inputs artifact, which
# packaging/windows/fetch-ci-artifacts.sh re-verifies on the Mac before it will sign an update manifest.
# The Mac side of the loop is packaging/windows/fetch-ci-artifacts.sh (gh run download into the downloads folder).
#
# The packaged configuration (rotation phase 2, 5 October 2026; docs/plans/rotation-phase-2.md): the runner writes the
# repository secrets to the same files the Mac keeps under ~/.config/igneum, and make-payload.sh picks them exactly as
# on the Mac (packaging/mac/packaged-config.sh: a .next file wins when present).
# LOG_INTAKE_KEY required: the intake key the payload ships (gh secret set LOG_INTAKE_KEY < ~/.config/igneum/log-intake-key)
# LOG_INTAKE_KEY_NEXT optional, during a rotation: the next key; when set it is the one the payload ships
# DL_TOKEN required: the folder the inputs come from, and the manifest folder when no DL_TOKEN_NEXT
# DL_TOKEN_NEXT optional, during a rotation: the manifest folder the payload checks
# After a rotation the owner sets LOG_INTAKE_KEY and DL_TOKEN to the new values and deletes the two _NEXT secrets.
name: windows-ci
on:
push:
@ -116,23 +126,44 @@ jobs:
cargo build --release --locked
ls -la target/release/igneum-app.exe
- name: packaged configuration (the secrets as the files packaged-config.sh reads; values never echoed)
shell: bash
env:
DL_TOKEN: ${{ secrets.DL_TOKEN }}
DL_TOKEN_NEXT: ${{ secrets.DL_TOKEN_NEXT }}
LOG_INTAKE_KEY: ${{ secrets.LOG_INTAKE_KEY }}
LOG_INTAKE_KEY_NEXT: ${{ secrets.LOG_INTAKE_KEY_NEXT }}
run: |
set -euo pipefail
mkdir -p "$HOME/.config/igneum"
if [ -z "${DL_TOKEN:-}" ]; then
echo "::error::the DL_TOKEN repository secret is not set. On the Mac: tr -d '[:space:]' < ~/.config/igneum/dl-token | gh secret set DL_TOKEN --repo igneum-network/igneum"
exit 1
fi
if [ -z "${LOG_INTAKE_KEY:-}" ] && [ -z "${LOG_INTAKE_KEY_NEXT:-}" ]; then
echo "::error::neither LOG_INTAKE_KEY nor LOG_INTAKE_KEY_NEXT is set; the payload would ship without an intake key. On the Mac: tr -d '[:space:]' < ~/.config/igneum/log-intake-key | gh secret set LOG_INTAKE_KEY --repo igneum-network/igneum"
exit 1
fi
printf '%s' "$DL_TOKEN" > "$HOME/.config/igneum/dl-token"
[ -n "${DL_TOKEN_NEXT:-}" ] && printf '%s' "$DL_TOKEN_NEXT" > "$HOME/.config/igneum/dl-token.next"
[ -n "${LOG_INTAKE_KEY:-}" ] && printf '%s' "$LOG_INTAKE_KEY" > "$HOME/.config/igneum/log-intake-key"
[ -n "${LOG_INTAKE_KEY_NEXT:-}" ] && printf '%s' "$LOG_INTAKE_KEY_NEXT" > "$HOME/.config/igneum/log-intake-key.next"
chmod 600 "$HOME"/.config/igneum/*
echo "files: $(ls "$HOME/.config/igneum" | tr '\n' ' ')"
bash packaging/mac/packaged-config.sh --test
- name: payload inputs (payload-inputs.zip from the downloads host, signature, hashes and node commit verified)
shell: bash
env:
DL_TOKEN: ${{ secrets.DL_TOKEN }}
run: |
set -euo pipefail
if [ -z "${DL_TOKEN:-}" ]; then
echo "::error::the DL_TOKEN repository secret is not set. On the Mac: tr -d '[:space:]' < ~/.config/igneum/dl-token | gh secret set DL_TOKEN --repo igneum-network/igneum"
exit 1
fi
base="https://dl.igneum.network/dl/$DL_TOKEN"
signer="app/igneum-app/target/release/igneum-ota-sign.exe"
[ -x "$signer" ] || { echo "::error::$signer was not built by the engine step"; exit 1; }
pin="packaging/windows/node-source.pin"
[ -s "$pin" ] || { echo "::error::$pin is missing: push-inputs.sh writes it, commit it with the inputs push"; exit 1; }
mkdir -p build/inputs "$HOME/.config/igneum"
printf '%s' "$DL_TOKEN" > "$HOME/.config/igneum/dl-token" # make-payload.sh reads it for the update manifest URL
mkdir -p build/inputs # ~/.config/igneum/dl-token was written by the packaged configuration step
curl -fsSL --retry 3 -o build/payload-inputs.json "$base/payload-inputs.json"
curl -fsSL --retry 3 -o build/payload-inputs.json.sig "$base/payload-inputs.json.sig"
curl -fsSL --retry 3 -o build/payload-inputs.zip "$base/payload-inputs.zip"

View file

@ -158,16 +158,100 @@ pub struct Packaged {
/// height, 4 October 2026: `{"difficulty_v2_activation_daa": N}`). Absent or empty = no override file.
#[serde(default)]
pub node_override_params: Option<serde_json::Value>,
/// Where the key and the manifest came from, for the log header: "packaged", "file <name>" or "none". Never
/// serialised (the packaged file does not carry them; nothing sends this struct to the UI).
#[serde(skip)]
pub key_source: String,
#[serde(skip)]
pub manifest_source: String,
}
/// The downloads host; the manifest of a folder is `<host>/dl/<token>/igneum-app-latest.json`
/// (packaging/mac/packaged-config.sh builds the same URL).
pub const DL_HOST: &str = "https://dl.igneum.network";
/// The intake a key file points at when the packaged file names no intake (a developer run).
pub const DEFAULT_INTAKE_URL: &str = "https://igneum-six.vercel.app/api/log";
/// The manifest URL for a downloads token; empty for an empty token.
pub fn manifest_url_for_token(token: &str) -> String {
let t = token.trim();
if t.is_empty() {
String::new()
} else {
format!("{DL_HOST}/dl/{t}/igneum-app-latest.json")
}
}
/// The downloads token inside a manifest URL of the standard shape, or None.
pub fn token_of_manifest_url(url: &str) -> Option<&str> {
let rest = url.strip_prefix(DL_HOST)?.strip_prefix("/dl/")?;
let (token, file) = rest.split_once('/')?;
(file == "igneum-app-latest.json" && !token.is_empty()).then_some(token)
}
/// A secret from a file: trimmed, None when the file is missing or blank.
pub fn read_secret_file(path: &Path) -> Option<String> {
let t = std::fs::read_to_string(path).ok()?;
let t = t.trim();
(!t.is_empty()).then(|| t.to_string())
}
/// The first 8 hex of sha256 over a value: what logs and the console show instead of the value
/// (`tr -d '[:space:]' < file | shasum -a 256 | cut -c1-8` gives the same on the Mac).
pub fn fingerprint8(value: &str) -> String {
use sha2::Digest;
crate::manifest::hex_encode(&sha2::Sha256::digest(value.as_bytes()))[..8].to_string()
}
impl Packaged {
pub fn load(candidates: &[PathBuf]) -> Packaged {
for c in candidates {
if let Some(p) = std::fs::read_to_string(c).ok().and_then(|t| serde_json::from_str::<Packaged>(&t).ok()) {
if let Some(mut p) = std::fs::read_to_string(c).ok().and_then(|t| serde_json::from_str::<Packaged>(&t).ok()) {
p.key_source = if p.log_intake_key.is_empty() { "none".into() } else { "packaged".into() };
p.manifest_source = if p.update_manifest.is_empty() { "none".into() } else { "packaged".into() };
return p;
}
}
Packaged::default()
Packaged { key_source: "none".into(), manifest_source: "none".into(), ..Packaged::default() }
}
/// Rotation phase 2 (5 October 2026, docs/plans/rotation-phase-2.md): the same two variables the packagers honour
/// (packaging/mac/packaged-config.sh) work on a running engine, so a developer run or a build that was packaged
/// with the old values can report to the rotated intake and check the rotated folder without a repackage:
/// IGNEUM_INTAKE_KEY_FILE names a file holding the key, IGNEUM_DL_TOKEN_FILE a file holding the downloads token.
/// A variable that is unset, or names a missing or blank file, changes nothing.
pub fn with_env_overrides(self) -> Packaged {
let file = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty()).map(PathBuf::from);
self.with_file_overrides(file("IGNEUM_INTAKE_KEY_FILE").as_deref(), file("IGNEUM_DL_TOKEN_FILE").as_deref())
}
pub fn with_file_overrides(mut self, key_file: Option<&Path>, token_file: Option<&Path>) -> Packaged {
let name = |p: &Path| p.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| p.display().to_string());
if let Some(key) = key_file.and_then(read_secret_file) {
self.log_intake_key = key;
if self.log_intake_url.is_empty() {
self.log_intake_url = DEFAULT_INTAKE_URL.into();
}
self.key_source = format!("file {}", name(key_file.unwrap()));
}
if let Some(token) = token_file.and_then(read_secret_file) {
self.update_manifest = manifest_url_for_token(&token);
self.manifest_source = format!("file {}", name(token_file.unwrap()));
}
self
}
/// The log header line: the intake URL with the key's fingerprint and the manifest URL with the folder's
/// fingerprint, each with its source; the values themselves never appear (the log is uploaded).
pub fn describe(&self) -> String {
let key = if self.log_intake_key.is_empty() { "no key".to_string() } else { format!("key {}", fingerprint8(&self.log_intake_key)) };
let intake = if self.log_intake_url.is_empty() { "none".to_string() } else { self.log_intake_url.clone() };
let (manifest, folder) = match token_of_manifest_url(&self.update_manifest) {
Some(t) => (self.update_manifest.replace(t, "<token>"), format!("folder {}", fingerprint8(t))),
None if self.update_manifest.is_empty() => ("none".to_string(), "no folder".to_string()),
None => (self.update_manifest.clone(), "custom".to_string()),
};
format!("config: intake {intake} {key} ({}); manifest {manifest} {folder} ({})", self.key_source, self.manifest_source)
}
}
@ -242,6 +326,107 @@ impl Runtime {
mod tests {
use super::*;
fn tmp(name: &str, content: &str) -> PathBuf {
// tests run in parallel: every file name is unique to its call
static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let d = std::env::temp_dir().join(format!("igneum-config-test-{}-{n}-{}", std::process::id(), name));
std::fs::write(&d, content).unwrap();
d
}
fn packaged(key: &str, token: &str) -> Packaged {
let json = format!(r#"{{"update_manifest":"{}","log_intake_url":"https://igneum-six.vercel.app/api/log","log_intake_key":"{}"}}"#, manifest_url_for_token(token), key);
let p = tmp("packaged.json", &json);
let out = Packaged::load(&[p.clone()]);
let _ = std::fs::remove_file(p);
out
}
#[test]
fn manifest_url_round_trips_through_the_token() {
assert_eq!(manifest_url_for_token("abc123"), "https://dl.igneum.network/dl/abc123/igneum-app-latest.json");
assert_eq!(manifest_url_for_token(" abc123\n"), "https://dl.igneum.network/dl/abc123/igneum-app-latest.json");
assert_eq!(manifest_url_for_token(""), "");
assert_eq!(token_of_manifest_url("https://dl.igneum.network/dl/abc123/igneum-app-latest.json"), Some("abc123"));
assert_eq!(token_of_manifest_url("https://dl.igneum.network/dl/abc123/other.json"), None);
assert_eq!(token_of_manifest_url("http://127.0.0.1:8080/dl/t/igneum-app-latest.json"), None);
assert_eq!(token_of_manifest_url(""), None);
}
#[test]
fn secret_files_are_trimmed_and_blank_means_none() {
let f = tmp("key", " thekey0123456789abcdef \n");
assert_eq!(read_secret_file(&f).as_deref(), Some("thekey0123456789abcdef"));
std::fs::write(&f, " \n").unwrap();
assert_eq!(read_secret_file(&f), None);
let _ = std::fs::remove_file(&f);
assert_eq!(read_secret_file(Path::new("/nonexistent/igneum/key")), None);
}
#[test]
fn fingerprint_matches_shasum() {
// printf abc | shasum -a 256 | cut -c1-8
assert_eq!(fingerprint8("abc"), "ba7816bf");
assert_eq!(fingerprint8("").len(), 8);
}
#[test]
fn load_records_the_sources() {
let p = packaged("oldkey0123456789abcdef", "oldtok");
assert_eq!(p.key_source, "packaged");
assert_eq!(p.manifest_source, "packaged");
let none = Packaged::load(&[PathBuf::from("/nonexistent/igneum-app.json")]);
assert_eq!(none.key_source, "none");
assert_eq!(none.manifest_source, "none");
assert!(none.update_manifest.is_empty() && none.log_intake_key.is_empty());
}
#[test]
fn file_overrides_replace_the_key_and_the_folder() {
let key = tmp("log-intake-key.next", "newkey0123456789abcdef\n");
let tok = tmp("dl-token.next", "newtok\n");
let p = packaged("oldkey0123456789abcdef", "oldtok").with_file_overrides(Some(&key), Some(&tok));
assert_eq!(p.log_intake_key, "newkey0123456789abcdef");
assert_eq!(p.update_manifest, "https://dl.igneum.network/dl/newtok/igneum-app-latest.json");
assert_eq!(p.log_intake_url, "https://igneum-six.vercel.app/api/log");
assert!(p.key_source.starts_with("file ") && p.key_source.ends_with("log-intake-key.next"), "{}", p.key_source);
assert!(p.manifest_source.ends_with("dl-token.next"), "{}", p.manifest_source);
// only the key: the folder stays packaged
let p = packaged("oldkey0123456789abcdef", "oldtok").with_file_overrides(Some(&key), None);
assert_eq!(p.update_manifest, "https://dl.igneum.network/dl/oldtok/igneum-app-latest.json");
assert_eq!(p.manifest_source, "packaged");
// a missing or blank file changes nothing
let blank = tmp("blank", "\n");
let p = packaged("oldkey0123456789abcdef", "oldtok").with_file_overrides(Some(&blank), Some(Path::new("/nonexistent/dl-token")));
assert_eq!(p.log_intake_key, "oldkey0123456789abcdef");
assert_eq!(p.update_manifest, "https://dl.igneum.network/dl/oldtok/igneum-app-latest.json");
assert_eq!(p.key_source, "packaged");
// a developer run with no packaged file at all: the key file brings the default intake
let p = Packaged::load(&[PathBuf::from("/nonexistent/igneum-app.json")]).with_file_overrides(Some(&key), Some(&tok));
assert_eq!(p.log_intake_url, DEFAULT_INTAKE_URL);
assert_eq!(p.update_manifest, "https://dl.igneum.network/dl/newtok/igneum-app-latest.json");
for f in [key, tok, blank] {
let _ = std::fs::remove_file(f);
}
}
#[test]
fn describe_never_carries_the_values() {
let p = packaged("oldkey0123456789abcdef", "oldtok");
let d = p.describe();
assert!(!d.contains("oldkey"), "{d}");
assert!(!d.contains("oldtok"), "{d}");
assert!(d.contains("<token>/igneum-app-latest.json"), "{d}");
assert!(d.contains(&format!("key {}", fingerprint8("oldkey0123456789abcdef"))), "{d}");
assert!(d.contains(&format!("folder {}", fingerprint8("oldtok"))), "{d}");
assert!(d.contains("(packaged)"), "{d}");
let none = Packaged::load(&[PathBuf::from("/nonexistent/igneum-app.json")]).describe();
assert!(none.contains("no key") && none.contains("no folder") && none.contains("(none)"), "{none}");
let custom = Packaged { update_manifest: "http://127.0.0.1:9/dl/t/igneum-app-latest.json".into(), ..Packaged::default() }.describe();
assert!(custom.contains("custom"), "{custom}");
}
#[test]
fn packaged_carries_the_node_override_params() {
let p: Packaged = serde_json::from_str(r#"{"update_manifest":"","node_override_params":{"difficulty_v2_activation_daa":123456}}"#).unwrap();

View file

@ -593,6 +593,9 @@ impl Engine {
let v = crate::detect::node_version(&self.bins.node);
self.st().node.version = v.clone();
self.shared.log(&self.shared.upload_header());
// which intake and which downloads folder this build reports to and checks (fingerprints, never the values;
// rotation phase 2 reads this line from every machine's upload: docs/plans/rotation-phase-2.md)
self.shared.log(&self.shared.packaged.describe());
// the prover service (proving v0): its own thread, idle until the setting is on
crate::prover::start(self.shared.clone(), self.bins.dir.clone());
self.shared.log(&format!("node binary: {} ({v})", self.bins.node.display()));

View file

@ -88,7 +88,7 @@ fn main() {
}
}
}
let packaged = config::Packaged::load(&candidates);
let packaged = config::Packaged::load(&candidates).with_env_overrides();
let settings = config::Settings::load(&runtime.app_dir.join("settings.json"));
// the per-launch token: 32 hex characters from the OS

View file

@ -46,7 +46,7 @@ v4 changes the chain's formats, so it starts fresh from genesis and every node m
4. You extract `igneum-windows-v4.zip` to a fresh folder and start it.
5. We watch the live page: blocks, then the first lock after the window fills.
Packages (all three rebuilt 08:56 BST with generator v2 and the 2/3 floor, hosted): `https://dl.igneum.network/dl/***DL-TOKEN-REMOVED***/igneum-windows-v4.zip` (your PC, node and miners), `igneum-node-windows-v4.zip`, and `Igneum-Miner-0.2.0.dmg` for Sam, which dials the seed node first. The proof run for your 5090: `igneum-prove-wsl2.zip`, same folder; needs a reboot for WSL2 and twenty minutes of setup.
Packages (all three rebuilt 08:56 BST with generator v2 and the 2/3 floor, hosted): `https://dl.igneum.network/dl/<token>/igneum-windows-v4.zip` (your PC, node and miners), `igneum-node-windows-v4.zip`, and `Igneum-Miner-0.2.0.dmg` for Sam, which dials the seed node first. The proof run for your 5090: `igneum-prove-wsl2.zip`, same folder; needs a reboot for WSL2 and twenty minutes of setup.
## What went wrong tonight, plainly

View file

@ -0,0 +1,273 @@
# Rotation phase 2: the 0.3.6 handover, the deletion of the old folder and key, the fresh repository (5 October 2026)
Internal. Phase 1 (4 October, 19:25 UTC) generated the NEXT intake key and the NEXT downloads token into
`~/.config/igneum/log-intake-key.next` and `~/.config/igneum/dl-token.next`, taught `site/api/log.mjs` to accept
`LOG_INTAKE_KEY_NEXT` next to `LOG_INTAKE_KEY`, and staged a second downloads folder `dl/<new token>/` with the
installers of the day. Phase 2 is this file: the 0.3.6 build carries the new values, every installed 0.3.5 is carried
across while the old folder still serves, then the old folder and the old key die, and only then the history is
rewritten into a fresh repository (owner's decision, 5 October 2026; `docs/plans/history-rewrite.md`, option B).
No value is written here. Each is named by its fingerprint, the first 8 hex of sha256 over the trimmed value
(`tr -d '[:space:]' < ~/.config/igneum/<file> | shasum -a 256 | cut -c1-8`; the app logs the same 8 characters):
| Value | File | Fingerprint | Where it lives today |
|---|---|---|---|
| old intake key | `~/.config/igneum/log-intake-key` | `e2005de8` | every installed app's `igneum-app.json` (0.3.0 to 0.3.5); the site project's `LOG_INTAKE_KEY`; 6 tracked files until this branch, 8 commits of the history |
| new intake key | `~/.config/igneum/log-intake-key.next` | `477bb0ef` | nowhere yet (the site accepts it once `LOG_INTAKE_KEY_NEXT` is set) |
| old downloads token | `~/.config/igneum/dl-token` | `df66a82c` | every installed app's manifest URL; `dl/<old>/` holds every version 0.1.0 to 0.3.5, the jobs file, the CI inputs; the `DL_TOKEN` repository secret; 1 commit of the history (`docs/plans/morning-2026-10-04.md`, masked on this branch) |
| new downloads token | `~/.config/igneum/dl-token.next` | `ed9c4d2e` | `dl/<new>/` with the 0.3.3 installers and the WSL2 zip only, no manifest, no jobs file, no CI inputs |
## 1. What this branch changes (`rotation-2`)
| File | Change |
|---|---|
| `packaging/mac/packaged-config.sh` | no key literal any more. `IGNEUM_INTAKE_KEY_FILE` and `IGNEUM_DL_TOKEN_FILE` name the files; each defaults to the `.next` file when it exists, else the plain file. Prints file names, lengths and fingerprints, never values. `--test` runs its 23 checks on temporary files |
| `packaging/windows/make-payload.sh` | sources `packaged-config.sh` and calls `write_packaged_config` (it used to `sed` the key out of that file) |
| `.github/workflows/windows.yml` | a "packaged configuration" step writes the repository secrets `DL_TOKEN`, `DL_TOKEN_NEXT`, `LOG_INTAKE_KEY`, `LOG_INTAKE_KEY_NEXT` to the same files under `~/.config/igneum` on the runner, so `make-payload.sh` picks them exactly as on the Mac; `LOG_INTAKE_KEY` or `LOG_INTAKE_KEY_NEXT` is now required (the key no longer comes from the tree) |
| `app/igneum-app/src/config.rs` | `Packaged::with_env_overrides()` honours the same two variables on a running engine (a developer run, or a package built with the old values); `describe()` is the new header line `config: intake <url> key <fp> (<source>); manifest <url with the token masked> folder <fp> (<source>)`; `fingerprint8`, `manifest_url_for_token`, `token_of_manifest_url`, `read_secret_file`; 6 new unit tests |
| `app/igneum-app/src/main.rs`, `engine.rs` | the overrides applied at load; the `config:` line logged right after the `IGNEUM-APP` header at every engine start (so every upload carries it) |
| `tools/ship-app.mjs` | `--dl-both`: a `mirror` step copies the version's files and the folder-level files into `dl/<dl-token.next>/`, the `manifest` step publishes a second manifest there (`--dest`, `--base-url`, carrying the first manifest's `override`, `tuning` and `min_supported_version`, compared field by field), one deploy, `verify` checks both folders; self-test covers the three helpers |
| `tools/logs.mjs` | `--rotation`: every app machine's version and header fingerprints against the `.next` files, exit 1 while any machine is behind; `--self-test` |
| `infra/gpu-bench/upload.sh`, `proving/windows-wsl2/prove-block.sh`, `prove-shard.sh`, `proto-cuda/windows-app/upload-log.bat`, `proto-cuda/windows-miner/upload-log.bat` | the key literal removed: environment (`IGNEUM_LOG_KEY` or `IGNEUM_INTAKE_KEY`, which the app's job runner already sets), `IGNEUM_INTAKE_KEY_FILE`, or `igneum-log-key.txt` next to the .bat; the tree carries neither value now (`git grep` of both reads 0 files) |
| `tools/repo/fresh-repo.sh` | the history rewrite of `docs/plans/history-rewrite.md` section 2 as one script with the verification greps and the printed push commands (section 6 below) |
| `docs/plans/history-rewrite.md` | brought over from `testnet-prep` unchanged, so this branch carries the plan it executes |
## 2. The handover, as designed
An installed app reads `igneum-app.json` next to its engine (macOS `Contents/Resources`, Windows the install folder):
the manifest URL and the intake key. The OTA path replaces the whole bundle or runs the whole installer, and both
carry a new `igneum-app.json`, so the values travel with the version. Nothing is cached in the app data folder.
| Step | 0.3.5 on a machine (old folder, old key) | 0.3.6 (new folder, new key) |
|---|---|---|
| hourly check | fetches `dl/<old>/igneum-app-latest.json`: 0.3.6 is there (published in BOTH folders), signed by the same key | fetches `dl/<new>/igneum-app-latest.json`: itself |
| download | the URL inside the old folder's manifest, `dl/<old>/Igneum-Miner-0.3.6.dmg` or `-Setup-0.3.6.exe` (byte-identical to the new folder's copy) | nothing |
| apply | the new bundle or installer brings `igneum-app.json` with the NEW manifest URL and the NEW key | |
| after restart | reports to the intake with the new key (`LOG_INTAKE_KEY_NEXT` accepts it); its header reads `key 477bb0ef` and `folder ed9c4d2e`; next check hits the NEW folder | the same |
| jobs | `igneum-jobs.json` is read next to the manifest, so the mirror step copies the jobs file and its signature into the new folder; any job published while both folders live goes to both (section 3d) | |
The window: every 0.3.5 machine must apply 0.3.6 before the old folder goes. Machines apply in their own minute of the
hour and only when the node is synced, so the window is hours, not minutes. The old folder and the old key stay until
`node tools/logs.mjs --rotation` reads 0 behind (section 4). Nothing is deleted on a schedule.
## 3. The publish, exactly
Everything below runs from the main checkout after this branch is merged to master. `DLSITE` is the downloads folder
(`~/.config/igneum/dlsite-dir`), `OLD` and `NEW` the two tokens read from their files; neither is ever typed.
### 3a. Before the cut (by hand, once)
```
# the site must accept both keys (names only are listed; the value is piped from the file)
cd site && npx --yes vercel@latest --global-config ~/.config/igneum/vercel link --scope igneum --project igneum --yes
npx --yes vercel@latest --global-config ~/.config/igneum/vercel env ls --scope igneum # LOG_INTAKE_KEY must be there; is LOG_INTAKE_KEY_NEXT?
tr -d '[:space:]' < ~/.config/igneum/log-intake-key.next | npx --yes vercel@latest --global-config ~/.config/igneum/vercel env add LOG_INTAKE_KEY_NEXT production --scope igneum
cd .. && git push origin master # or any production deploy: the function reads the variable at the next deploy
# confirm: a POST with the NEXT key is accepted (200 with an id), the old one still is too
curl -s -o /dev/null -w '%{http_code}\n' -X POST -H "x-igneum-key: $(tr -d '[:space:]' < ~/.config/igneum/log-intake-key.next)" -H 'Content-Type: application/json' -d '{"label":"rotation-check","machine":"mac","run_id":"rotation-check","lines":"next key accepted"}' https://igneum-six.vercel.app/api/log
# the Windows build on GitHub needs the same files (the runner writes the secrets to ~/.config/igneum; windows.yml)
gh auth switch --user igneum-labs
tr -d '[:space:]' < ~/.config/igneum/log-intake-key | gh secret set LOG_INTAKE_KEY --repo igneum-network/igneum
tr -d '[:space:]' < ~/.config/igneum/log-intake-key.next | gh secret set LOG_INTAKE_KEY_NEXT --repo igneum-network/igneum
tr -d '[:space:]' < ~/.config/igneum/dl-token.next | gh secret set DL_TOKEN_NEXT --repo igneum-network/igneum
gh secret list --repo igneum-network/igneum # DL_TOKEN, DL_TOKEN_NEXT, LOG_INTAKE_KEY, LOG_INTAKE_KEY_NEXT
# the new folder exists and is empty of a manifest (the mirror step fills it)
DLSITE="$(tr -d '[:space:]' < ~/.config/igneum/dlsite-dir)"; NEW="$(tr -d '[:space:]' < ~/.config/igneum/dl-token.next)"
ls "$DLSITE/dl/$NEW"
packaging/mac/packaged-config.sh --test # 23 checks
```
### 3b. The cut: one command, both folders
```
node tools/ship-app.mjs 0.3.6 --node <fork worktree> --notes "<one line>" --dl-both --dry-run # the plan, nothing written
node tools/ship-app.mjs 0.3.6 --node <fork worktree> --notes "<one line>" --dl-both
```
With `--dl-both` the steps are: preflight (also: `dl-token.next` present and different, the folder exists) > bump >
inputs > commit and push (the Windows build starts; its payload step runs `packaged-config.sh --test` and packages
the `.next` values because the `_NEXT` secrets are set) > ci > fetch (installer and zip into the OLD folder) > dmg
(`build-dmg.sh` packages `igneum-app.json` from the `.next` files by default) > copy (DMG into the OLD folder) >
mirror (DMG, installer, zip, `igneum-windows-ci.json`, `igneum-jobs.json` and `.sig`, `payload-inputs.{zip,json,sha256}`,
`igneum-prove-wsl2.zip` into the NEW folder, sha256-checked) > manifest (section 3c, both) > deploy (one) > verify
(both folders: HEAD and GET of every file, both manifests byte-identical to the local ones and verifying, every
platform URL inside its own folder) > console.
The build itself prints which files it packaged, for example `intake key: ~/.config/igneum/log-intake-key.next (31 chars,
fingerprint 477bb0ef)` and `manifest: ~/.config/igneum/dl-token.next (10 chars, fingerprint ed9c4d2e) -> https://dl.igneum.network/dl/<token>/igneum-app-latest.json`.
A build that says `e2005de8` or `df66a82c` packaged the old values: stop, the `.next` files were not found.
### 3c. The same by hand with `packaging/ota/publish-manifest.sh` (what the manifest step runs)
`publish-manifest.sh` writes the OLD folder by default (it reads `~/.config/igneum/dl-token`); `--dest <folder>` and
`--base-url <url>` aim it at the NEW folder. With `--dest` it carries `consensus.override`, `tuning` and
`min_supported_version` over from the manifest already in THAT folder, which is none, so the second call must pass
what the first manifest carries. `--deploy` is refused with `--dest`; one deploy of the whole folder follows.
Run by hand, `publish-manifest.sh` prints the manifest it wrote, URLs included, so the terminal shows the token
path (it always has); `ship-app.mjs` scrubs both tokens from every line, which is the reason to prefer 3b.
```
DLSITE="$(tr -d '[:space:]' < ~/.config/igneum/dlsite-dir)"
OLD="$(tr -d '[:space:]' < ~/.config/igneum/dl-token)"; NEW="$(tr -d '[:space:]' < ~/.config/igneum/dl-token.next)"
V=0.3.6; NOTES="<one line>"
# 1. the OLD folder (default dest and base): the files are there from fetch and copy
packaging/ota/publish-manifest.sh --version $V --notes "$NOTES" --no-deploy \
--mac "$DLSITE/dl/$OLD/Igneum-Miner-$V.dmg" --win "$DLSITE/dl/$OLD/Igneum-Miner-Setup-$V.exe"
# 2. the NEW folder: the same bytes copied in, the same override, tuning and min_supported read from the first manifest
cp "$DLSITE/dl/$OLD/Igneum-Miner-$V.dmg" "$DLSITE/dl/$OLD/Igneum-Miner-Setup-$V.exe" "$DLSITE/dl/$OLD/igneum-windows-app.zip" \
"$DLSITE/dl/$OLD/igneum-windows-ci.json" "$DLSITE/dl/$OLD/igneum-jobs.json" "$DLSITE/dl/$OLD/igneum-jobs.json.sig" \
"$DLSITE/dl/$OLD/payload-inputs.zip" "$DLSITE/dl/$OLD/payload-inputs.json" "$DLSITE/dl/$OLD/payload-inputs.sha256" \
"$DLSITE/dl/$OLD/igneum-prove-wsl2.zip" "$DLSITE/dl/$NEW/"
M="$DLSITE/dl/$OLD/igneum-app-latest.json"
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 o else "")' "$M")"
MINSUP="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("min_supported_version",""))' "$M")"
python3 -c 'import json,sys; t=json.load(open(sys.argv[1])).get("tuning"); open(sys.argv[2],"w").write(json.dumps(t)) if t else None' "$M" /tmp/tuning-$V.json
packaging/ota/publish-manifest.sh --version $V --notes "$NOTES" --no-deploy \
--dest "$DLSITE/dl/$NEW" --base-url "https://dl.igneum.network/dl/$NEW" \
--mac "$DLSITE/dl/$NEW/Igneum-Miner-$V.dmg" --win "$DLSITE/dl/$NEW/Igneum-Miner-Setup-$V.exe" \
${OVERRIDE:+--override "$OVERRIDE"} ${MINSUP:+--min-supported "$MINSUP"} $([ -s /tmp/tuning-$V.json ] && echo --tuning /tmp/tuning-$V.json || echo --no-tuning)
rm -f /tmp/tuning-$V.json
# the two manifests must differ only in published_at and the folder inside the URLs
diff <(python3 -c 'import json,sys; m=json.load(open(sys.argv[1])); m.pop("published_at"); print(json.dumps(m,sort_keys=True,indent=1).replace(sys.argv[2],"T"))' "$M" "$OLD") \
<(python3 -c 'import json,sys; m=json.load(open(sys.argv[1])); m.pop("published_at"); print(json.dumps(m,sort_keys=True,indent=1).replace(sys.argv[2],"T"))' "$DLSITE/dl/$NEW/igneum-app-latest.json" "$NEW") && echo "same fields"
# 3. one deploy, then both live checks (each: reachable, byte-identical to the local file, signature verifies)
(cd "$DLSITE" && npx --yes vercel@latest --global-config ~/.config/igneum/vercel deploy --prod --yes)
packaging/ota/publish-manifest.sh --verify-only
packaging/ota/publish-manifest.sh --verify-only --dest "$DLSITE/dl/$NEW" --base-url "https://dl.igneum.network/dl/$NEW"
```
### 3d. Jobs while both folders live
`packaging/ota/publish-jobs.sh` writes `dl/<old>/igneum-jobs.json` by default and takes the same `--dest` and
`--base-url`. Until the old folder is deleted, every `add` or `expire` is published twice, the second time with
`--dest "$DLSITE/dl/$NEW" --base-url "https://dl.igneum.network/dl/$NEW"`, then one deploy. A job whose
`zip_url` names the old folder keeps working until that folder goes; publish new jobs with URLs in the new folder.
## 4. Verification: every machine's header shows the new intake path
The engine logs two header lines at every start, and the restart after an OTA apply logs them again, so the latest
upload of every machine carries them:
```
IGNEUM-APP version=0.3.6 machine=<id8> platform=<os> node=<igneumd version>
config: intake https://igneum-six.vercel.app/api/log key 477bb0ef (packaged); manifest https://dl.igneum.network/dl/<token>/igneum-app-latest.json folder ed9c4d2e (packaged)
```
```
node tools/logs.mjs --rotation # one row per app machine: version, key fp, folder fp, sources, state; exit 1 while any is behind
node tools/logs.mjs <run_id> | grep -E 'IGNEUM-APP|config: intake' # one machine in full
```
Done means: every row `moved` (key `477bb0ef`, folder `ed9c4d2e`, version 0.3.6), none `OLD`, and the `unknown` rows
(a machine whose last upload predates this header, or a machine that has stopped for good) accounted for by name.
Today the table shows 6 app machines (three `win-`, three `mac-`), all 0.3.5 or older, all `unknown` because 0.3.5
has no `config:` line. The console's Machines tab (`relay/`) shows the versions the same way.
Also check, once, that the intake stores an upload under the new key from a real machine (the row's `last_received`
moves after the restart), and that `node tools/logs.mjs` lists no new `rotation-check` rows beyond the one from 3a.
## 5. The deletion, after section 4 reads 0 behind
In this order, each step checked before the next:
```
DLSITE="$(tr -d '[:space:]' < ~/.config/igneum/dlsite-dir)"
OLD="$(tr -d '[:space:]' < ~/.config/igneum/dl-token)"; NEW="$(tr -d '[:space:]' < ~/.config/igneum/dl-token.next)"
node tools/logs.mjs --rotation || { echo "machines still behind"; false; }
# 1. the old folder: gone from the downloads host (one deploy); the new one still serves
mv "$DLSITE/dl/$OLD" "$HOME/igneum-dl-old-$(date -u +%Y%m%d)" # kept outside the site for a week, then rm -rf
(cd "$DLSITE" && npx --yes vercel@latest --global-config ~/.config/igneum/vercel deploy --prod --yes)
curl -s -o /dev/null -w '%{http_code}\n' "https://dl.igneum.network/dl/$OLD/igneum-app-latest.json" # 404
packaging/ota/publish-manifest.sh --verify-only --dest "$DLSITE/dl/$NEW" --base-url "https://dl.igneum.network/dl/$NEW" # still live
# 2. the local files: the NEXT values become the plain ones (every script's default), the old ones kept dated
mv ~/.config/igneum/log-intake-key ~/.config/igneum/log-intake-key.old-$(date -u +%Y%m%d)
mv ~/.config/igneum/log-intake-key.next ~/.config/igneum/log-intake-key
mv ~/.config/igneum/dl-token ~/.config/igneum/dl-token.old-$(date -u +%Y%m%d)
mv ~/.config/igneum/dl-token.next ~/.config/igneum/dl-token
packaging/ota/publish-manifest.sh --verify-only # now reads the new token by default: live, verified
# 3. the intake: the old key dropped (LOG_INTAKE_KEY becomes the new value, LOG_INTAKE_KEY_NEXT removed), redeployed
cd site
npx --yes vercel@latest --global-config ~/.config/igneum/vercel env rm LOG_INTAKE_KEY production --scope igneum --yes
tr -d '[:space:]' < ~/.config/igneum/log-intake-key | npx --yes vercel@latest --global-config ~/.config/igneum/vercel env add LOG_INTAKE_KEY production --scope igneum
npx --yes vercel@latest --global-config ~/.config/igneum/vercel env rm LOG_INTAKE_KEY_NEXT production --scope igneum --yes
cd .. && git push origin master # or a production deploy
# the old key is dead (401), the new one lives (200)
curl -s -o /dev/null -w '%{http_code}\n' -X POST -H "x-igneum-key: $(tr -d '[:space:]' < ~/.config/igneum/log-intake-key.old-$(date -u +%Y%m%d))" -H 'Content-Type: application/json' -d '{"label":"rotation-check","machine":"mac","run_id":"rotation-check","lines":"old key must be refused"}' https://igneum-six.vercel.app/api/log
curl -s -o /dev/null -w '%{http_code}\n' -X POST -H "x-igneum-key: $(tr -d '[:space:]' < ~/.config/igneum/log-intake-key)" -H 'Content-Type: application/json' -d '{"label":"rotation-check","machine":"mac","run_id":"rotation-check","lines":"new key accepted"}' https://igneum-six.vercel.app/api/log
# 4. the GitHub secrets: the plain names carry the new values, the _NEXT names go
tr -d '[:space:]' < ~/.config/igneum/dl-token | gh secret set DL_TOKEN --repo igneum-network/igneum
tr -d '[:space:]' < ~/.config/igneum/log-intake-key | gh secret set LOG_INTAKE_KEY --repo igneum-network/igneum
gh secret delete DL_TOKEN_NEXT --repo igneum-network/igneum; gh secret delete LOG_INTAKE_KEY_NEXT --repo igneum-network/igneum
# 5. the app machines keep reporting (a last look, an hour later)
node tools/logs.mjs --rotation
```
The relay is not involved: since round 4 (X23) it has its own key (`~/.config/igneum/relay-key`, `RELAY_KEY`), and
the intake key only reports. The old launcher packages under `proto-cuda/windows-app` and `windows-miner` (0.2.0)
carried the old key in `upload-log.bat`; those machines are the `unknown` rows of section 4 and upload nothing after
step 3, which is the intent.
## 6. The fresh repository, after section 5
The old values are dead values once section 5 is done; only then is the rewrite worth running. The owner's two
settings come first: rename the GitHub login `igneum-labs` to a neutral handle (the numeric noreply id 337424239
stays, so the rewritten author line is `<new> <337424239+<new>@users.noreply.github.com>`), and remove the second
login from the organisation's owners. Then, from the main checkout with every agent frozen:
```
gh pr list --repo igneum-network/igneum # must be empty
git worktree list > ~/igneum-worktrees-$(date -u +%Y%m%d).txt
IGNEUM_FILTER_REPO=<dir>/git_filter_repo.py tools/repo/fresh-repo.sh --new-login <new handle> --new-repo igneum-network/<name> \
[--public-claude-md <scrubbed CLAUDE.md>] --work ~/igneum-rewrite
```
The script clones `origin` afresh (mirror, no hardlinks), reads the personal identities and the second login from
the history and the four secret values from `~/.config/igneum`, writes the rule files 0600 and removes them after
the pass, runs the one `git-filter-repo` invocation of `docs/plans/history-rewrite.md` section 2 (drop the four
internal files, replace the secrets and the names, mailmap both personal identities to the login, every offset to
`+0000`, optionally the public `CLAUDE.md` in every commit), then demands zero for: secret lines in any blob,
identity lines in any blob, identity lines in commit metadata, stamps not `+0000`, commits touching the dropped
files, identities other than the login, the old login in any blob (with `--new-login`). It prints the push commands
and runs none of them: `gh repo create igneum-network/<name> --private`, `git remote add origin` in the clone,
`git push --mirror origin`, then the GitHub secrets (section 3a), the Vercel Git connection moved to the new
repository, the old repository archived, the main checkout re-cloned and every worktree re-created from its
rewritten branch.
### Dry run of 5 October 2026 (throwaway mirror clone of the main checkout under the session scratchpad, nothing pushed)
| Count | Before | After |
|---|---|---|
| commits (all refs) | 410 | 353 (57 commits that only touched the four dropped files are gone) |
| refs | 49 | 29 (filter-repo drops the remote-tracking refs of the mirror) |
| author and committer identities | 3 | 1 (`igneum-labs <337424239+[removed]>`) |
| stamps not +0000 | 660 of 820 | 0 of 706 |
| commits touching the four dropped files | 53 | 0 |
| secret lines in any blob (old key, new key, old token, new token) | 21 | 0 |
| identity lines in any blob (first name outside the login, surname, personal addresses, second login, the other businesses) | 1839 | 0 |
| identity lines in commit metadata | 171 | 0 |
| standing login lines in any blob | 94 | 61 (0 with `--new-login` after the rename) |
| pass run time | | 67 s; 4 min with the clone and the greps |
The report sits next to the clone (`<work>/report.txt`, with `commit-map`, 411 lines). The throwaway clone was
removed after the run; nothing left the Mac.
## 7. The order for the afternoon
1. Merge `rotation-2` into master (the Windows build on that push packages with the `_NEXT` secrets only when they
exist: set them first, section 3a, or expect the payload step to fail on the missing `LOG_INTAKE_KEY`).
2. Section 3a: the site's `LOG_INTAKE_KEY_NEXT`, the four repository secrets, the curl check.
3. Section 3b: `--dry-run`, then the cut with `--dl-both`.
4. Section 4 through the afternoon: `node tools/logs.mjs --rotation` until 0 behind (the slot rule means an hour or
two for a synced fleet; a machine that is off waits for its owner).
5. Section 5: the deletion, in order.
6. The owner's two GitHub settings (login rename, one owner); then section 6, the fresh repository, from a frozen tree.

View file

@ -5,7 +5,14 @@
# ./upload.sh <logfile> <label> [run_id]
set -euo pipefail
IGNEUM_LOG_URL="${IGNEUM_LOG_URL:-https://igneum-six.vercel.app/api/log}"
IGNEUM_LOG_KEY="${IGNEUM_LOG_KEY:-***INTAKE-KEY-REMOVED***}"
# the key: IGNEUM_LOG_KEY, else the file named by IGNEUM_INTAKE_KEY_FILE, else ~/.config/igneum/log-intake-key.next when
# staged, else ~/.config/igneum/log-intake-key (rotation phase 2, 5 October 2026: no key literal in the repository)
if [ -z "${IGNEUM_LOG_KEY:-}" ]; then
kf="${IGNEUM_INTAKE_KEY_FILE:-}"
[ -n "$kf" ] || { [ -f "$HOME/.config/igneum/log-intake-key.next" ] && kf="$HOME/.config/igneum/log-intake-key.next" || kf="$HOME/.config/igneum/log-intake-key"; }
[ -f "$kf" ] && IGNEUM_LOG_KEY="$(tr -d '[:space:]' < "$kf")" || IGNEUM_LOG_KEY=""
fi
[ -n "$IGNEUM_LOG_KEY" ] || { echo "upload: no intake key (set IGNEUM_LOG_KEY or IGNEUM_INTAKE_KEY_FILE)"; exit 3; }
[ $# -ge 2 ] || { echo "usage: upload.sh <logfile> <label> [run_id]"; exit 1; }
file="$1"; label="$2"; run_id="${3:-${IGNEUM_RUN_ID:-$label-$(date -u +%Y%m%d-%H%M)}}"
[ -f "$file" ] || { echo "upload: file not found: $file"; exit 2; }

View file

@ -1,27 +1,70 @@
#!/bin/bash
# The configuration the packagers write next to the engine (igneum-app.json): the update manifest URL (the download
# token is read from ~/.config/igneum/dl-token and never lives in the repo), the log intake (the key only authorises
# log uploads and is meant to ship, as the 0.2.0 launchers did), the live page. Sourced by build-dmg.sh; the Windows
# make-payload.sh reads LOG_KEY from this file.
LOG_URL="https://igneum-six.vercel.app/api/log"
LOG_KEY="***INTAKE-KEY-REMOVED***"
# The configuration the packagers write next to the engine (igneum-app.json): the update manifest URL, the log intake,
# the live page. Sourced by packaging/mac/build-dmg.sh and packaging/windows/make-payload.sh (on the Mac and on the
# GitHub runner alike). Run on its own (`packaging/mac/packaged-config.sh --test`) it checks itself.
#
# No secret lives in this file (rotation phase 2, 5 October 2026: the intake key used to be a literal here and is in
# eight commits of the history; docs/plans/rotation-phase-2.md and docs/plans/history-rewrite.md). Both values come
# from files named by two environment variables, each defaulting to the NEXT value when one is staged:
#
# IGNEUM_INTAKE_KEY_FILE the log intake key (authorises log uploads only; it ships inside every package).
# Default: ~/.config/igneum/log-intake-key.next when that file exists, else
# ~/.config/igneum/log-intake-key.
# IGNEUM_DL_TOKEN_FILE the downloads path token: the manifest is https://dl.igneum.network/dl/<token>/igneum-app-latest.json.
# Default: ~/.config/igneum/dl-token.next when that file exists, else ~/.config/igneum/dl-token.
#
# So the 0.3.6 build carries the rotated key and checks the manifest in the NEW folder with no flag at all, while a
# build that must target the old folder says so: IGNEUM_DL_TOKEN_FILE=~/.config/igneum/dl-token. When the rotation is
# over, `mv log-intake-key.next log-intake-key` and `mv dl-token.next dl-token` make the defaults the plain files
# again. A missing or empty token file disables the update check (the note says so); a missing or empty key file
# disables log uploads. Values are never printed, only the file names and the values' lengths.
LOG_URL="${IGNEUM_INTAKE_URL:-https://igneum-six.vercel.app/api/log}"
LIVE_PAGE="https://igneum.network/live"
DOWNLOAD_PAGE="https://igneum.network/#mine"
TOKEN_FILE="$HOME/.config/igneum/dl-token"
DL_HOST="https://dl.igneum.network"
# Consensus parameters pinned into the package for the bundled node: igneum-app.json "node_override_params"; the engine
# writes them to <app data>/override-params.json and starts igneumd with --override-params-file. Empty = no override
# file, the node runs the network's defaults. Difficulty v2 (4 Oct 2026): the version that bundles igneumd v2 must
# carry the devnet's activation height here, the same N as every other devnet node, before it is cut (Mac and CI alike:
# make-payload.sh reads this line). Rule and order: docs/plans/difficulty-v2-rollout-devnet.md.
# make-payload.sh sources this file). Rule and order: docs/plans/difficulty-v2-rollout-devnet.md.
# Example: NODE_OVERRIDE_PARAMS='{"difficulty_v2_activation_daa": 123456}'
NODE_OVERRIDE_PARAMS=''
# writes the JSON to $1
# igneum_secret_file <env var name> <base name> -> the file to read: the variable when set, else <base>.next when it
# exists, else <base>; IGNEUM_CONFIG_DIR (tests) replaces ~/.config/igneum
igneum_secret_file() {
local var="$1" base="$2" dir="${IGNEUM_CONFIG_DIR:-$HOME/.config/igneum}" set_value=""
set_value="${!var:-}"
if [ -n "$set_value" ]; then printf '%s' "$set_value"
elif [ -f "$dir/$base.next" ]; then printf '%s' "$dir/$base.next"
else printf '%s' "$dir/$base"
fi
}
# igneum_read_trimmed <file> -> the file's content without whitespace, or nothing when the file is missing or blank
igneum_read_trimmed() {
[ -f "$1" ] && tr -d '[:space:]' < "$1" || true
}
# igneum_manifest_url <token> -> the manifest URL for that downloads folder; nothing for an empty token
igneum_manifest_url() {
[ -n "$1" ] && printf '%s/dl/%s/igneum-app-latest.json' "$DL_HOST" "$1" || true
}
# igneum_fingerprint <value> -> the first 8 hex of sha256 over the value, for logs and the app's header (never the value)
igneum_fingerprint() {
printf '%s' "$1" | { shasum -a 256 2>/dev/null || sha256sum; } | cut -c1-8
}
# writes the JSON to $1; prints which files were used (names), the lengths and the fingerprints, never the values
write_packaged_config() {
local out="$1" token="" manifest=""
[ -f "$TOKEN_FILE" ] && token="$(tr -d '[:space:]' < "$TOKEN_FILE")"
[ -n "$token" ] && manifest="https://dl.igneum.network/dl/$token/igneum-app-latest.json"
[ -n "$manifest" ] || echo "note: no $TOKEN_FILE; the update check is disabled in this build"
local out="$1" token="" manifest="" key="" key_file="" token_file=""
key_file="$(igneum_secret_file IGNEUM_INTAKE_KEY_FILE log-intake-key)"
token_file="$(igneum_secret_file IGNEUM_DL_TOKEN_FILE dl-token)"
key="$(igneum_read_trimmed "$key_file")"
token="$(igneum_read_trimmed "$token_file")"
manifest="$(igneum_manifest_url "$token")"
if [ -n "$key" ]; then echo "intake key: $key_file (${#key} chars, fingerprint $(igneum_fingerprint "$key"))"
else echo "note: no key in $key_file; log uploads are disabled in this build"; fi
if [ -n "$manifest" ]; then echo "manifest: $token_file (${#token} chars, fingerprint $(igneum_fingerprint "$token")) -> ${manifest//$token/<token>}"
else echo "note: no token in $token_file; the update check is disabled in this build"; fi
local override_line=""
[ -n "$NODE_OVERRIDE_PARAMS" ] && override_line=" \"node_override_params\": $NODE_OVERRIDE_PARAMS,"
cat > "$out" <<JSON
@ -29,9 +72,61 @@ write_packaged_config() {
$override_line
"update_manifest": "$manifest",
"log_intake_url": "$LOG_URL",
"log_intake_key": "$LOG_KEY",
"log_intake_key": "$key",
"live_page": "$LIVE_PAGE",
"download_page": "$DOWNLOAD_PAGE"
}
JSON
}
# ---- self-test: packaging/mac/packaged-config.sh --test (temporary files only; nothing under ~/.config is read) -----
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
set -euo pipefail
[ "${1:-}" = "--test" ] || { echo "usage: $0 --test (otherwise source this file)" >&2; exit 2; }
T="$(mktemp -d)"; trap 'rm -rf "$T"' EXIT
PY="$(command -v python3 || command -v python)" # the GitHub Windows runner's Git Bash may only have python
fails=0
check() { if [ "$2" = "$3" ]; then echo " ok $1"; else echo " FAIL $1: got '$2', want '$3'"; fails=$((fails + 1)); fi; }
export IGNEUM_CONFIG_DIR="$T/cfg"; mkdir -p "$T/cfg"
unset IGNEUM_INTAKE_KEY_FILE IGNEUM_DL_TOKEN_FILE
# 1. nothing staged: the plain files
check "default key file is the plain one" "$(igneum_secret_file IGNEUM_INTAKE_KEY_FILE log-intake-key)" "$T/cfg/log-intake-key"
check "default token file is the plain one" "$(igneum_secret_file IGNEUM_DL_TOKEN_FILE dl-token)" "$T/cfg/dl-token"
# 2. a .next file wins
printf 'nextkeyvalue0123456789abcdef\n' > "$T/cfg/log-intake-key.next"
printf 'plainkeyvalue0123456789abcdef\n' > "$T/cfg/log-intake-key"
printf 'tok2new\n' > "$T/cfg/dl-token.next"
printf 'tok1old\n' > "$T/cfg/dl-token"
check ".next key file wins when present" "$(igneum_secret_file IGNEUM_INTAKE_KEY_FILE log-intake-key)" "$T/cfg/log-intake-key.next"
check ".next token file wins when present" "$(igneum_secret_file IGNEUM_DL_TOKEN_FILE dl-token)" "$T/cfg/dl-token.next"
# 3. the variable beats both
check "the variable names the file" "$(IGNEUM_DL_TOKEN_FILE="$T/cfg/dl-token" igneum_secret_file IGNEUM_DL_TOKEN_FILE dl-token)" "$T/cfg/dl-token"
# 4. reading trims; a missing file reads as nothing
check "trimmed read" "$(igneum_read_trimmed "$T/cfg/dl-token.next")" "tok2new"
check "missing file reads empty" "$(igneum_read_trimmed "$T/cfg/none")" ""
check "manifest url" "$(igneum_manifest_url tok2new)" "$DL_HOST/dl/tok2new/igneum-app-latest.json"
check "empty token gives no manifest" "$(igneum_manifest_url '')" ""
check "fingerprint is 8 hex" "$(igneum_fingerprint abc | grep -cE '^[0-9a-f]{8}$')" "1"
check "fingerprint of abc" "$(igneum_fingerprint abc)" "ba7816bf"
# 5. the written JSON: defaults pick the .next pair; the values land; nothing printed carries them
out="$(write_packaged_config "$T/a.json")"
check "output names the .next key file" "$(printf '%s' "$out" | grep -c 'log-intake-key.next')" "1"
check "output never carries the key" "$(printf '%s' "$out" | grep -c 'nextkeyvalue')" "0"
check "output never carries the token" "$(printf '%s' "$out" | grep -c 'tok2new')" "0"
check "json carries the .next key" "$("$PY" -c 'import json,sys; print(json.load(open(sys.argv[1]))["log_intake_key"])' "$T/a.json")" "nextkeyvalue0123456789abcdef"
check "json manifest points at the .next folder" "$("$PY" -c 'import json,sys; print(json.load(open(sys.argv[1]))["update_manifest"])' "$T/a.json")" "$DL_HOST/dl/tok2new/igneum-app-latest.json"
check "json has no override line" "$("$PY" -c 'import json,sys; print("node_override_params" in json.load(open(sys.argv[1])))' "$T/a.json")" "False"
# 6. the variables aim a build at the old folder and the old key
out="$(IGNEUM_INTAKE_KEY_FILE="$T/cfg/log-intake-key" IGNEUM_DL_TOKEN_FILE="$T/cfg/dl-token" write_packaged_config "$T/b.json")"
check "old-folder build: manifest" "$("$PY" -c 'import json,sys; print(json.load(open(sys.argv[1]))["update_manifest"])' "$T/b.json")" "$DL_HOST/dl/tok1old/igneum-app-latest.json"
check "old-folder build: key" "$("$PY" -c 'import json,sys; print(json.load(open(sys.argv[1]))["log_intake_key"])' "$T/b.json")" "plainkeyvalue0123456789abcdef"
# 7. missing files: notes, empty fields, valid JSON
out="$(IGNEUM_INTAKE_KEY_FILE="$T/cfg/nokey" IGNEUM_DL_TOKEN_FILE="$T/cfg/notoken" write_packaged_config "$T/c.json")"
check "missing key noted" "$(printf '%s' "$out" | grep -c 'log uploads are disabled')" "1"
check "missing token noted" "$(printf '%s' "$out" | grep -c 'update check is disabled')" "1"
check "missing files give empty fields" "$("$PY" -c 'import json,sys; j=json.load(open(sys.argv[1])); print(j["update_manifest"]+"|"+j["log_intake_key"])' "$T/c.json")" "|"
# 8. the override line
NODE_OVERRIDE_PARAMS='{"difficulty_v2_activation_daa": 123456}' write_packaged_config "$T/d.json" >/dev/null
check "override params land" "$("$PY" -c 'import json,sys; print(json.load(open(sys.argv[1]))["node_override_params"]["difficulty_v2_activation_daa"])' "$T/d.json")" "123456"
if [ "$fails" = 0 ]; then echo "packaged-config: all checks passed"; else echo "packaged-config: $fails check(s) failed"; exit 1; fi
fi

View file

@ -13,7 +13,9 @@
# agent when they exist (searched in a few places; IGNEUM_WORKERS_DIR overrides); without them
# the engine builds the CUDA worker from proto-cuda\ on the PC (needs the CUDA Toolkit and MSVC)
# proto-cuda\, proto-opencl\ the worker sources and build.bat for that fallback
# igneum-app.json the update manifest URL (token from ~/.config/igneum/dl-token, never in the repo), the log intake
# igneum-app.json the update manifest URL and the log intake key, from the files named by IGNEUM_DL_TOKEN_FILE and
# IGNEUM_INTAKE_KEY_FILE (defaults: the .next files under ~/.config/igneum when staged, else the
# plain ones; packaging/mac/packaged-config.sh, sourced here; never in the repo)
# stop-igneum.ps1 what the installer runs before an upgrade and on uninstall
# app\windows\ host.cpp, host.rc, version.h, BUILD-APP.bat (the window host is built on the PC or by CI)
# Igneum Miner.exe the window host, when app/windows/dist/ holds one (BUILD-APP.bat ran before this script)
@ -31,7 +33,8 @@ REL="${IGNEUM_WIN_RELEASE:-$ROOT/vendor/igneum-node/target-integration/x86_64-pc
ENGINE="${IGNEUM_APP_EXE:-$ROOT/app/igneum-app/target/x86_64-pc-windows-gnu/release/igneum-app.exe}"
MINGW=/opt/homebrew/opt/mingw-w64/toolchain-x86_64/x86_64-w64-mingw32
STAGE="$HERE/igneum-windows-app"
TOKEN_FILE="$HOME/.config/igneum/dl-token"
# the packaged configuration (the manifest URL from the token file, the intake key from the key file, NODE_OVERRIDE_PARAMS)
. "$ROOT/packaging/mac/packaged-config.sh"
[ -f "$ENGINE" ] || { echo "no $ENGINE: build it first (cd app/igneum-app && cargo build --release --target x86_64-pc-windows-gnu, see proto-cuda/windows-node/cross-build.sh for the environment)" >&2; exit 1; }
[ -f "$REL/igneumd.exe" ] || { echo "no $REL/igneumd.exe; cross-compile the node first" >&2; exit 1; }
@ -94,27 +97,9 @@ else echo "warning: no Linux igneum-prove-host/igneum-prove-export in $PROVE_LIN
cp "$ROOT"/proving/windows-wsl2/*.sh "$ROOT"/proving/windows-wsl2/*.ps1 "$ROOT"/proving/windows-wsl2/*.bat "$ROOT/proving/windows-wsl2/README.txt" "$STAGE/wsl2/"
cp "$ROOT"/proving/fixtures/*.json "$STAGE/wsl2/fixtures/"
# the packaged configuration: the download token stays out of the repo
TOKEN=""
[ -f "$TOKEN_FILE" ] && TOKEN="$(tr -d '[:space:]' < "$TOKEN_FILE")"
MANIFEST=""
[ -n "$TOKEN" ] && MANIFEST="https://dl.igneum.network/dl/$TOKEN/igneum-app-latest.json"
[ -n "$MANIFEST" ] || echo "note: no $TOKEN_FILE; the update check is disabled in this build"
LOG_KEY="$(sed -n 's/^LOG_KEY="\(.*\)"/\1/p' "$ROOT/packaging/mac/packaged-config.sh")"
# the pinned consensus parameters (packaged-config.sh NODE_OVERRIDE_PARAMS, e.g. the difficulty v2 activation height)
NODE_OVERRIDE_PARAMS="$(sed -n "s/^NODE_OVERRIDE_PARAMS='\(.*\)'/\1/p" "$ROOT/packaging/mac/packaged-config.sh")"
OVERRIDE_LINE=""
[ -n "$NODE_OVERRIDE_PARAMS" ] && OVERRIDE_LINE=" \"node_override_params\": $NODE_OVERRIDE_PARAMS,"
cat > "$STAGE/igneum-app.json" <<JSON
{
$OVERRIDE_LINE
"update_manifest": "$MANIFEST",
"log_intake_url": "https://igneum-six.vercel.app/api/log",
"log_intake_key": "$LOG_KEY",
"live_page": "https://igneum.network/live",
"download_page": "https://igneum.network/#mine"
}
JSON
# the packaged configuration: the token and the key stay out of the repo (packaged-config.sh prints the file names and
# the fingerprints, never the values)
write_packaged_config "$STAGE/igneum-app.json"
cat > "$STAGE/README.txt" <<TXT
Igneum Miner $VERSION for Windows (payload). Devnet v4. Test network; nothing is bought or sold.
Nobody from Igneum will ever ask for your seed.

View file

@ -3,10 +3,16 @@ rem Igneum miner log uploader. Sends the last 256 KB of a log file to the Igneum
rem so Claude on the Mac can read it (node tools/logs.mjs). Needs Windows 10 or 11 (curl.exe, PowerShell).
rem Usage: upload-log.bat <logfile> <label> [run_id]
rem run_id falls back to the IGNEUM_RUN_ID environment variable, then to <label>-<date>-<time>.
rem The key below only authorises log uploads. It is meant to ship inside this package.
rem The key only authorises log uploads. It is not in the repository (rotation phase 2, 5 October 2026): it comes from
rem the IGNEUM_LOG_KEY environment variable, else from igneum-log-key.txt next to this script (one line; the packager
rem writes it from the file IGNEUM_INTAKE_KEY_FILE names), else the upload is refused with exit 3.
setlocal
set "IGNEUM_LOG_URL=https://igneum-six.vercel.app/api/log"
set "IGNEUM_LOG_KEY=***INTAKE-KEY-REMOVED***"
if "%IGNEUM_LOG_KEY%"=="" if exist "%~dp0igneum-log-key.txt" set /p IGNEUM_LOG_KEY=<"%~dp0igneum-log-key.txt"
if "%IGNEUM_LOG_KEY%"=="" (
echo upload-log: no intake key: set IGNEUM_LOG_KEY or put igneum-log-key.txt next to this script
exit /b 3
)
if "%~1"=="" goto usage
if "%~2"=="" goto usage

View file

@ -1,41 +1,47 @@
@echo off
rem Igneum miner log uploader. Sends the last 256 KB of a log file to the Igneum log intake
rem so Claude on the Mac can read it (node tools/logs.mjs). Needs Windows 10 or 11 (curl.exe, PowerShell).
rem Usage: upload-log.bat <logfile> <label> [run_id]
rem run_id falls back to the IGNEUM_RUN_ID environment variable, then to <label>-<date>-<time>.
rem The key below only authorises log uploads. It is meant to ship inside this package.
setlocal
set "IGNEUM_LOG_URL=https://igneum-six.vercel.app/api/log"
set "IGNEUM_LOG_KEY=***INTAKE-KEY-REMOVED***"
if "%~1"=="" goto usage
if "%~2"=="" goto usage
if not exist "%~1" (
echo upload-log: file not found: %~1
exit /b 2
)
set "LOGFILE=%~f1"
set "LABEL=%~2"
set "RUNID=%~3"
if "%RUNID%"=="" set "RUNID=%IGNEUM_RUN_ID%"
set "OUT=%TEMP%\igneum-upload-%RANDOM%.json"
powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference='Stop'; $fs=New-Object IO.FileStream($env:LOGFILE,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::ReadWrite); $b=New-Object byte[] $fs.Length; [void]$fs.Read($b,0,$b.Length); $fs.Close(); $n=[Math]::Min($b.Length,262144); $t=[Text.Encoding]::UTF8.GetString($b,$b.Length-$n,$n); $r=$env:RUNID; if (-not $r) { $r=$env:LABEL + '-' + (Get-Date -Format 'yyyyMMdd-HHmm') }; $o=@{label=$env:LABEL;machine=$env:COMPUTERNAME;run_id=$r;lines=$t}; [IO.File]::WriteAllText($env:OUT,(ConvertTo-Json $o -Compress),(New-Object Text.UTF8Encoding $false)); Write-Host ('upload-log: run_id ' + $r + ', ' + $n + ' bytes')"
if errorlevel 1 (
echo upload-log: could not read or encode %LOGFILE%
exit /b 3
)
curl.exe -sS --max-time 60 -X POST "%IGNEUM_LOG_URL%" -H "Content-Type: application/json" -H "x-igneum-key: %IGNEUM_LOG_KEY%" --data-binary "@%OUT%"
set "RC=%ERRORLEVEL%"
echo.
del "%OUT%" >nul 2>&1
if not "%RC%"=="0" (
echo upload-log: curl failed with code %RC%
exit /b %RC%
)
exit /b 0
:usage
echo Usage: upload-log.bat ^<logfile^> ^<label^> [run_id]
exit /b 1
@echo off
rem Igneum miner log uploader. Sends the last 256 KB of a log file to the Igneum log intake
rem so Claude on the Mac can read it (node tools/logs.mjs). Needs Windows 10 or 11 (curl.exe, PowerShell).
rem Usage: upload-log.bat <logfile> <label> [run_id]
rem run_id falls back to the IGNEUM_RUN_ID environment variable, then to <label>-<date>-<time>.
rem The key only authorises log uploads. It is not in the repository (rotation phase 2, 5 October 2026): it comes from
rem the IGNEUM_LOG_KEY environment variable, else from igneum-log-key.txt next to this script (one line; the packager
rem writes it from the file IGNEUM_INTAKE_KEY_FILE names), else the upload is refused with exit 3.
setlocal
set "IGNEUM_LOG_URL=https://igneum-six.vercel.app/api/log"
if "%IGNEUM_LOG_KEY%"=="" if exist "%~dp0igneum-log-key.txt" set /p IGNEUM_LOG_KEY=<"%~dp0igneum-log-key.txt"
if "%IGNEUM_LOG_KEY%"=="" (
echo upload-log: no intake key: set IGNEUM_LOG_KEY or put igneum-log-key.txt next to this script
exit /b 3
)
if "%~1"=="" goto usage
if "%~2"=="" goto usage
if not exist "%~1" (
echo upload-log: file not found: %~1
exit /b 2
)
set "LOGFILE=%~f1"
set "LABEL=%~2"
set "RUNID=%~3"
if "%RUNID%"=="" set "RUNID=%IGNEUM_RUN_ID%"
set "OUT=%TEMP%\igneum-upload-%RANDOM%.json"
powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference='Stop'; $fs=New-Object IO.FileStream($env:LOGFILE,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::ReadWrite); $b=New-Object byte[] $fs.Length; [void]$fs.Read($b,0,$b.Length); $fs.Close(); $n=[Math]::Min($b.Length,262144); $t=[Text.Encoding]::UTF8.GetString($b,$b.Length-$n,$n); $r=$env:RUNID; if (-not $r) { $r=$env:LABEL + '-' + (Get-Date -Format 'yyyyMMdd-HHmm') }; $o=@{label=$env:LABEL;machine=$env:COMPUTERNAME;run_id=$r;lines=$t}; [IO.File]::WriteAllText($env:OUT,(ConvertTo-Json $o -Compress),(New-Object Text.UTF8Encoding $false)); Write-Host ('upload-log: run_id ' + $r + ', ' + $n + ' bytes')"
if errorlevel 1 (
echo upload-log: could not read or encode %LOGFILE%
exit /b 3
)
curl.exe -sS --max-time 60 -X POST "%IGNEUM_LOG_URL%" -H "Content-Type: application/json" -H "x-igneum-key: %IGNEUM_LOG_KEY%" --data-binary "@%OUT%"
set "RC=%ERRORLEVEL%"
echo.
del "%OUT%" >nul 2>&1
if not "%RC%"=="0" (
echo upload-log: curl failed with code %RC%
exit /b %RC%
)
exit /b 0
:usage
echo Usage: upload-log.bat ^<logfile^> ^<label^> [run_id]
exit /b 1

View file

@ -20,13 +20,22 @@ nvidia-smi --query-gpu=name,memory.total,driver_version --format=csv,noheader 2>
echo "cpu: $(nproc) cores, ram: $(free -g | awk '/Mem:/ {print $2}') GB visible to WSL"
upload() {
# Last 256 KB of the log to the intake, same URL and key as upload-log.bat (log uploads only).
# Last 256 KB of the log to the intake (log uploads only); key and URL from the environment, see below.
python3 - "$LOG" "$RUN_ID" <<'PY'
import json, socket, sys, urllib.request
import json, os, socket, sys, urllib.request
path, run_id = sys.argv[1], sys.argv[2]
# the key and the intake come from the environment (the app's job runner sets IGNEUM_INTAKE_KEY and IGNEUM_INTAKE_URL;
# by hand: export them, or IGNEUM_INTAKE_KEY_FILE naming a file); no key literal lives in the repository
key = os.environ.get("IGNEUM_INTAKE_KEY", "").strip()
if not key and os.environ.get("IGNEUM_INTAKE_KEY_FILE"):
try: key = open(os.environ["IGNEUM_INTAKE_KEY_FILE"]).read().strip()
except OSError: key = ""
if not key:
print("upload skipped: no IGNEUM_INTAKE_KEY in the environment"); sys.exit(0)
url = os.environ.get("IGNEUM_INTAKE_URL", "").strip() or "https://igneum-six.vercel.app/api/log"
data = open(path, 'rb').read()[-262144:].decode('utf-8', 'replace')
body = json.dumps({"label": "prove-" + socket.gethostname(), "machine": socket.gethostname(), "run_id": run_id, "lines": data}).encode()
req = urllib.request.Request("https://igneum-six.vercel.app/api/log", data=body, headers={"Content-Type": "application/json", "x-igneum-key": "***INTAKE-KEY-REMOVED***"})
req = urllib.request.Request(url, data=body, headers={"Content-Type": "application/json", "x-igneum-key": key})
try:
with urllib.request.urlopen(req, timeout=60) as r:
print("upload:", r.status, r.read()[:200].decode('utf-8', 'replace'))

View file

@ -21,13 +21,22 @@ nvidia-smi --query-gpu=name,memory.total,driver_version --format=csv,noheader 2>
echo "cpu: $(nproc) cores, ram: $(free -g | awk '/Mem:/ {print $2}') GB visible to WSL"
upload() {
# Last 256 KB of the log to the intake, same URL and key as upload-log.bat (log uploads only).
# Last 256 KB of the log to the intake (log uploads only); key and URL from the environment, see below.
python3 - "$LOG" "$RUN_ID" <<'PY'
import json, socket, sys, urllib.request
import json, os, socket, sys, urllib.request
path, run_id = sys.argv[1], sys.argv[2]
# the key and the intake come from the environment (the app's job runner sets IGNEUM_INTAKE_KEY and IGNEUM_INTAKE_URL;
# by hand: export them, or IGNEUM_INTAKE_KEY_FILE naming a file); no key literal lives in the repository
key = os.environ.get("IGNEUM_INTAKE_KEY", "").strip()
if not key and os.environ.get("IGNEUM_INTAKE_KEY_FILE"):
try: key = open(os.environ["IGNEUM_INTAKE_KEY_FILE"]).read().strip()
except OSError: key = ""
if not key:
print("upload skipped: no IGNEUM_INTAKE_KEY in the environment"); sys.exit(0)
url = os.environ.get("IGNEUM_INTAKE_URL", "").strip() or "https://igneum-six.vercel.app/api/log"
data = open(path, 'rb').read()[-262144:].decode('utf-8', 'replace')
body = json.dumps({"label": "shards-" + socket.gethostname(), "machine": socket.gethostname(), "run_id": run_id, "lines": data}).encode()
req = urllib.request.Request("https://igneum-six.vercel.app/api/log", data=body, headers={"Content-Type": "application/json", "x-igneum-key": "***INTAKE-KEY-REMOVED***"})
req = urllib.request.Request(url, data=body, headers={"Content-Type": "application/json", "x-igneum-key": key})
try:
with urllib.request.urlopen(req, timeout=60) as r:
print("upload:", r.status, r.read()[:200].decode('utf-8', 'replace'))

View file

@ -3,9 +3,58 @@
// node tools/logs.mjs list runs: label, machine, run_id, last received, total bytes
// node tools/logs.mjs <run_id> print the latest upload for that run
// node tools/logs.mjs <run_id> --all print every upload for that run, oldest first
// node tools/logs.mjs --rotation rotation phase 2 (docs/plans/rotation-phase-2.md): per app machine (labels mac-*,
// win-*), the version and the "config:" header line of its latest upload (the
// intake key's fingerprint and the downloads folder's fingerprint), against the
// fingerprints of ~/.config/igneum/log-intake-key.next and dl-token.next; exit 1
// while any machine still reports with the old values
// node tools/logs.mjs --self-test the header parser on sample lines
// Reads DATABASE_URL from ~/.config/igneum/env. No dependencies: Neon HTTP SQL over fetch.
import { readFileSync } from 'node:fs';
import { readFileSync, existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { createHash } from 'node:crypto';
// the two header lines the engine logs at every start (app/igneum-app/src/engine.rs run(), config.rs describe()):
// IGNEUM-APP version=0.3.6 machine=1ccfe586 platform=windows node=...
// config: intake https://.../api/log key 477bb0ef (packaged); manifest https://.../dl/<token>/igneum-app-latest.json folder ed9c4d2e (packaged)
// The LAST occurrence wins (the restart after an OTA apply logs them again). Fields missing from the upload read ''.
export function parseRotation(lines) {
const out = { version: '', keyFp: '', keySource: '', folderFp: '', manifestSource: '' };
for (const line of String(lines).split('\n')) {
let m = /IGNEUM-APP version=(\S+)/.exec(line);
if (m) out.version = m[1];
m = /config: intake \S+ (?:key ([0-9a-f]{8})|no key) \((packaged|file [^)]+|none)\); manifest \S+ (?:folder ([0-9a-f]{8})|no folder|custom) \((packaged|file [^)]+|none)\)/.exec(line);
if (m) { out.keyFp = m[1] || ''; out.keySource = m[2]; out.folderFp = m[3] || ''; out.manifestSource = m[4]; }
}
return out;
}
// the first 8 hex of sha256 over the trimmed file content; '' when the file is missing (the app's config::fingerprint8)
export function fingerprintFile(path) {
if (!existsSync(path)) return '';
const v = readFileSync(path, 'utf8').trim();
return v ? createHash('sha256').update(v).digest('hex').slice(0, 8) : '';
}
if (process.argv[2] === '--self-test') {
let fails = 0;
const check = (name, ok, detail = '') => { console.log(` ${ok ? 'ok ' : 'FAIL'} ${name}${detail ? ': ' + detail : ''}`); if (!ok) fails++; };
const sample = ['1 Igneum Miner 0.3.6 on pc (machine id 1ccfe586abcdef01), devnet (run win-1ccfe586-x)',
'1 IGNEUM-APP version=0.3.5 machine=1ccfe586 platform=windows node=igneumd_0.3.5',
'1 config: intake https://igneum-six.vercel.app/api/log key e2005de8 (packaged); manifest https://dl.igneum.network/dl/<token>/igneum-app-latest.json folder df66a82c (packaged)',
'2 update: applied', '2 IGNEUM-APP version=0.3.6 machine=1ccfe586 platform=windows node=igneumd_0.3.6',
'2 config: intake https://igneum-six.vercel.app/api/log key 477bb0ef (file log-intake-key.next); manifest https://dl.igneum.network/dl/<token>/igneum-app-latest.json folder ed9c4d2e (packaged)'].join('\n');
const r = parseRotation(sample);
check('the last header wins', r.version === '0.3.6' && r.keyFp === '477bb0ef' && r.folderFp === 'ed9c4d2e', JSON.stringify(r));
check('sources are read', r.keySource === 'file log-intake-key.next' && r.manifestSource === 'packaged', JSON.stringify(r));
const none = parseRotation('1 config: intake none no key (none); manifest none no folder (none)\n');
check('a build without key or folder reads empty fingerprints', none.keyFp === '' && none.folderFp === '' && none.keySource === 'none', JSON.stringify(none));
const custom = parseRotation('1 config: intake https://x/api/log key 01234567 (packaged); manifest http://127.0.0.1:9/dl/t/igneum-app-latest.json custom (packaged)\n');
check('a custom manifest URL reads no folder', custom.keyFp === '01234567' && custom.folderFp === '', JSON.stringify(custom));
check('an old upload without the config line reads version only', (() => { const o = parseRotation('1 IGNEUM-APP version=0.3.4 machine=a platform=mac node=x\n'); return o.version === '0.3.4' && o.keyFp === '' && o.keySource === ''; })());
check('fingerprintFile of a missing file is empty', fingerprintFile('/nonexistent/igneum/key') === '');
console.log(fails ? `${fails} check(s) failed` : 'all checks passed');
process.exit(fails ? 1 : 0);
}
process.stdout.on('error', e => { if (e.code === 'EPIPE') process.exit(0); throw e; });
@ -28,6 +77,30 @@ async function sql(query, params = []) {
const [runId, flag] = process.argv.slice(2);
if (runId === '--rotation') {
const cfg = `${homedir()}/.config/igneum`;
const want = { key: fingerprintFile(`${cfg}/log-intake-key.next`) || fingerprintFile(`${cfg}/log-intake-key`), folder: fingerprintFile(`${cfg}/dl-token.next`) || fingerprintFile(`${cfg}/dl-token`) };
const old = { key: fingerprintFile(`${cfg}/log-intake-key`), folder: fingerprintFile(`${cfg}/dl-token`) };
// the latest upload per app label (mac-<id8>, win-<id8>); the header lines sit in the first bytes, the config line
// may repeat after an OTA restart, so the whole upload is parsed
const rows = await sql(`
SELECT DISTINCT ON (label) label, machine, run_id, received_at, lines
FROM miner_logs WHERE label LIKE 'mac-%' OR label LIKE 'win-%'
ORDER BY label, received_at DESC`);
if (!rows.length) { console.log('No app uploads yet.'); process.exit(1); }
let moved = 0, stale = 0;
const table = rows.map(r => {
const p = parseRotation(r.lines);
const ok = p.keyFp === want.key && p.folderFp === want.folder;
if (ok) moved++; else stale++;
return { label: r.label, machine: r.machine, version: p.version || '?', key: p.keyFp || '-', folder: p.folderFp || '-', sources: [p.keySource, p.manifestSource].filter(Boolean).join(' / ') || '-', last_received: new Date(r.received_at).toISOString().replace('T', ' ').slice(0, 19) + ' UTC', state: ok ? 'moved' : p.keyFp === old.key || p.folderFp === old.folder ? 'OLD' : 'unknown' };
}).sort((a, b) => a.state.localeCompare(b.state) || a.label.localeCompare(b.label));
console.table(table);
console.log(`expected: key ${want.key || '?'} folder ${want.folder || '?'} (from the .next files when they exist); old: key ${old.key || '?'} folder ${old.folder || '?'}`);
console.log(`${moved} machine(s) on the new key and folder, ${stale} not yet; a machine silent for over a day is listed by its last upload`);
process.exit(stale ? 1 : 0);
}
if (!runId) {
const rows = await sql(`
SELECT run_id, max(label) AS label, max(machine) AS machine, count(*)::int AS uploads,

253
tools/repo/fresh-repo.sh Executable file
View file

@ -0,0 +1,253 @@
#!/usr/bin/env bash
# The history rewrite of docs/plans/history-rewrite.md, section 2, as one script: a fresh mirror clone, one
# git-filter-repo pass with the plan's rules, the greps that must read zero, and the commands (printed, never run)
# that create the fresh repository under the organisation and push the rewritten refs there (the owner's decision of
# 5 October 2026: option B, a fresh repository, never a force-push over the old one).
#
# tools/repo/fresh-repo.sh [--source <url|path>] [--work <dir>] [--new-repo <org/name>] [--new-login <login>]
# [--public-claude-md <file>] [--clean]
#
# --source what to clone (default: this checkout's origin URL; a local path makes a throwaway dry run)
# --work where the clone and the report go (default: a fresh directory under $TMPDIR); never inside a checkout
# --new-repo the repository the printed commands create (default: igneum-network/igneum-core)
# --new-login the renamed GitHub login (the owner renames it first; the numeric noreply id stays): every author
# line and every file mention of the standing login is rewritten to it, and the identity grep then
# demands zero hits for the old login too. Without it the standing login stays and is reported as such
# --public-claude-md a scrubbed CLAUDE.md that replaces the file in EVERY commit (docs/fud-fixes.md section 5 step 2)
# --clean remove the work directory at the end (the default keeps it: the push runs from that clone)
#
# Reads (never prints): ~/.config/igneum/log-intake-key, log-intake-key.next, dl-token, dl-token.next (those that
# exist) for the secret rules and the secret grep; the personal identities and the second owner login are read from
# the history itself (every author or committer that is not the standing login). The rule files are written 0600
# in a 0700 directory and removed (rm -P) as soon as the pass has run. Nothing is pushed; nothing in --source changes.
#
# Needs git-filter-repo 2.38 or later: `git filter-repo` on PATH, or IGNEUM_FILTER_REPO=<path to git_filter_repo.py>
# (pip: python3 -m pip install --target <dir> git-filter-repo). TZ is forced to UTC for everything this script runs.
set -euo pipefail
export TZ=UTC
HERE="$(cd "$(dirname "$0")" && pwd)"
ROOT="$(cd "$HERE/../.." && pwd)"
STANDING_LOGIN="${IGNEUM_STANDING_LOGIN:-igneum-labs}"
ORG="igneum-network"
SOURCE="" WORK="" NEW_REPO="$ORG/igneum-core" NEW_LOGIN="" PUBLIC_CLAUDE="" CLEAN=0
while [ $# -gt 0 ]; do
case "$1" in
--source) SOURCE="$2"; shift 2 ;;
--work) WORK="$2"; shift 2 ;;
--new-repo) NEW_REPO="$2"; shift 2 ;;
--new-login) NEW_LOGIN="$2"; shift 2 ;;
--public-claude-md) PUBLIC_CLAUDE="$2"; shift 2 ;;
--clean) CLEAN=1; shift ;;
-h|--help) sed -n '2,24p' "$0"; exit 0 ;;
*) echo "unknown argument: $1" >&2; exit 2 ;;
esac
done
[ -n "$SOURCE" ] || SOURCE="$(git -C "$ROOT" remote get-url origin)"
[ -n "$WORK" ] || WORK="$(mktemp -d "${TMPDIR:-/tmp}/igneum-fresh-repo.XXXXXX")"
case "$WORK" in /*) ;; *) WORK="$PWD/$WORK" ;; esac
mkdir -p "$WORK"
CLONE="$WORK/clone"
[ ! -e "$CLONE" ] || { echo "$CLONE exists; the pass runs on a fresh clone only (remove it or give another --work)" >&2; exit 1; }
if [ -n "$PUBLIC_CLAUDE" ]; then [ -f "$PUBLIC_CLAUDE" ] || { echo "no $PUBLIC_CLAUDE" >&2; exit 1; }; PUBLIC_CLAUDE="$(cd "$(dirname "$PUBLIC_CLAUDE")" && pwd)/$(basename "$PUBLIC_CLAUDE")"; fi
case "$NEW_LOGIN" in *[!A-Za-z0-9-]*) echo "--new-login must be a GitHub login (letters, digits, hyphens)" >&2; exit 2 ;; esac
[ "$NEW_LOGIN" != "$STANDING_LOGIN" ] || NEW_LOGIN=""
# the filter
if [ -n "${IGNEUM_FILTER_REPO:-}" ]; then FILTER=(python3 "$IGNEUM_FILTER_REPO")
elif git filter-repo --version >/dev/null 2>&1; then FILTER=(git filter-repo)
elif python3 -c 'import git_filter_repo' 2>/dev/null; then FILTER=(python3 -m git_filter_repo)
else echo "git-filter-repo is not installed: python3 -m pip install --target <dir> git-filter-repo, then IGNEUM_FILTER_REPO=<dir>/git_filter_repo.py" >&2; exit 1; fi
command -v perl >/dev/null || { echo "perl is needed for the greps" >&2; exit 1; }
say() { printf '%s\n' "$*" | tee -a "$WORK/report.txt"; }
: > "$WORK/report.txt"
say "fresh-repo: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
say "source: $SOURCE"
say "work: $WORK"
say "filter: ${FILTER[*]} ($("${FILTER[@]}" --version 2>/dev/null | head -1 || echo '?'))"
# ---- 1. the fresh mirror clone ------------------------------------------------------------------------------------
git clone --quiet --mirror --no-hardlinks "$SOURCE" "$CLONE"
cd "$CLONE"
# ---- 2. the values, read at run time, never printed ------------------------------------------------------------------
umask 077
RULES="$WORK/rules"; mkdir -p "$RULES"; chmod 700 "$RULES"
cleanup_rules() { if [ -d "$RULES" ]; then for f in "$RULES"/*; do [ -f "$f" ] && { rm -P "$f" 2>/dev/null || rm -f "$f"; }; done; rmdir "$RULES" 2>/dev/null || true; fi; }
trap cleanup_rules EXIT
# the standing login's noreply address, from the history
STANDING_EMAIL="$(git log --all --format='%ae%n%ce' | grep -E "^[0-9]+\+$STANDING_LOGIN@users\.noreply\.github\.com$" | sort -u | head -1 || true)"
[ -n "$STANDING_EMAIL" ] || { echo "the history carries no commit by $STANDING_LOGIN (set IGNEUM_STANDING_LOGIN)" >&2; exit 1; }
STANDING_ID="${STANDING_EMAIL%%+*}"
if [ -n "$NEW_LOGIN" ]; then TARGET_LOGIN="$NEW_LOGIN"; else TARGET_LOGIN="$STANDING_LOGIN"; fi
TARGET_EMAIL="$STANDING_ID+$TARGET_LOGIN@users.noreply.github.com"
TARGET_IDENT="$TARGET_LOGIN <$TARGET_EMAIL>"
# every other identity: "name|email" pairs (author and committer)
PERSONAL_PAIRS="$(git log --all --format='%an|%ae%n%cn|%ce' | grep -v "|$STANDING_EMAIL$" | sort -u || true)"
PERSONAL_EMAILS="$(printf '%s\n' "$PERSONAL_PAIRS" | awk -F'|' 'NF==2{print $2}' | sort -u)"
PERSONAL_NAMES="$(printf '%s\n' "$PERSONAL_PAIRS" | awk -F'|' 'NF==2{print $1}' | grep -v "^$STANDING_LOGIN$" | sort -u || true)"
FIRST_NAMES="$(printf '%s\n' "$PERSONAL_NAMES" | awk 'NF>=1{print $1}' | sort -u)"
LAST_NAMES="$(printf '%s\n' "$PERSONAL_NAMES" | awk 'NF>=2{print $NF}' | sort -u)"
SECOND_LOGINS="$(printf '%s\n' "$PERSONAL_EMAILS" | sed -nE 's/^[0-9]+\+([A-Za-z0-9-]+)@users\.noreply\.github\.com$/\1/p' | grep -v "^$STANDING_LOGIN$" | sort -u || true)"
# the other businesses named in the plan (brand names, not people)
OTHER_BUSINESSES='[other-business]|[other-business]|[other-business]|[other-business]|[other-business]'
# the secrets: whichever of the four files exist
SECRET_FILES=(); for n in log-intake-key log-intake-key.next dl-token dl-token.next; do [ -f "$HOME/.config/igneum/$n" ] && SECRET_FILES+=("$HOME/.config/igneum/$n"); done
say "standing login: $STANDING_LOGIN (noreply id $STANDING_ID)${NEW_LOGIN:+ -> $NEW_LOGIN}"
say "personal identities in the history: $(printf '%s\n' "$PERSONAL_PAIRS" | grep -c . || true) (names $(printf '%s\n' "$PERSONAL_NAMES" | grep -c . || true), addresses $(printf '%s\n' "$PERSONAL_EMAILS" | grep -c . || true), second owner logins $(printf '%s\n' "$SECOND_LOGINS" | grep -c . || true))"
say "secret files for the rules: ${#SECRET_FILES[@]} of 4"
# the rule files
REPLACE="$RULES/replace.txt"; MAILMAP="$RULES/mailmap"; IDENT="$RULES/identity.pl"; SECRETS="$RULES/secrets.pl"
: > "$REPLACE"; : > "$MAILMAP"; : > "$IDENT"; : > "$SECRETS"
for f in ${SECRET_FILES[@]+"${SECRET_FILES[@]}"}; do
v="$(tr -d '[:space:]' < "$f")"; [ ${#v} -ge 8 ] || continue
case "$(basename "$f")" in log-intake-key*) tag='***INTAKE-KEY-REMOVED***' ;; *) tag='***DL-TOKEN-REMOVED***' ;; esac
printf 'literal:%s==>%s\n' "$v" "$tag" >> "$REPLACE"
printf '%s\n' "$v" | sed 's/[][\\.*^$?+(){}|/]/\\&/g' >> "$SECRETS" # a regex for the scanner: metacharacters escaped (never \Q, which qr// does not expand from a variable)
done
while IFS='|' read -r name email; do
[ -n "$email" ] || continue
printf 'literal:%s <%s>==>%s\n' "$name" "$email" "$TARGET_IDENT" >> "$REPLACE"
printf '%s <%s> <%s>\n' "$TARGET_LOGIN" "$TARGET_EMAIL" "$email" >> "$MAILMAP"
done <<< "$PERSONAL_PAIRS"
while IFS= read -r email; do
[ -n "$email" ] || continue
printf 'literal:%s==>[removed]\n' "$email" >> "$REPLACE"
printf '%s\n' "$email" | sed 's/[][\\.*^$?+(){}|/]/\\&/g' >> "$IDENT"
done <<< "$PERSONAL_EMAILS"
if [ -n "$NEW_LOGIN" ]; then
printf 'literal:%s==>%s\n' "$STANDING_EMAIL" "$TARGET_EMAIL" >> "$REPLACE"
printf '%s <%s> <%s>\n' "$TARGET_LOGIN" "$TARGET_EMAIL" "$STANDING_EMAIL" >> "$MAILMAP"
printf 'regex:\\b%s\\b==>%s\n' "$STANDING_LOGIN" "$NEW_LOGIN" >> "$REPLACE"
printf '\\b%s\\b\n' "$STANDING_LOGIN" >> "$IDENT"
fi
while IFS= read -r first; do
[ -n "$first" ] || continue
printf 'regex:\\b%s%ss\\b==>the project lead%ss\n' "$first" "'" "'" >> "$REPLACE"
while IFS= read -r last; do [ -n "$last" ] && printf 'regex:\\b%s\\s+%s\\b==>the project lead\n' "$first" "$last" >> "$REPLACE"; done <<< "$LAST_NAMES"
printf 'regex:\\b%s\\b==>the project lead\n' "$first" >> "$REPLACE"
done <<< "$FIRST_NAMES"
while IFS= read -r last; do
[ -n "$last" ] || continue
printf 'regex:\\b%s\\b==>[removed]\n' "$last" >> "$REPLACE"
printf '\\b%s\\b\n' "$last" >> "$IDENT"
done <<< "$LAST_NAMES"
while IFS= read -r first; do
[ -n "$first" ] || continue
# the lower-case user-name form (Windows and WSL paths, the browser profile), never the standing login's suffix
printf 'regex:(?i)(?<!%s-)\\b%s\\b==>[user]\n' "${STANDING_LOGIN%%-*}" "$first" >> "$REPLACE"
printf '(?<!%s-)\\b%s\\b\n' "${STANDING_LOGIN%%-*}" "$first" >> "$IDENT"
done <<< "$FIRST_NAMES"
while IFS= read -r login; do
[ -n "$login" ] || continue
printf 'regex:(?i)\\b%s\\b==>[second-owner-login]\n' "$login" >> "$REPLACE"
printf '\\b%s\\b\n' "$login" >> "$IDENT"
done <<< "$SECOND_LOGINS"
printf 'regex:(?i)\\b(%s)\\b==>[other-business]\n' "$OTHER_BUSINESSES" >> "$REPLACE"
printf '\\b(%s)\\b\n' "$OTHER_BUSINESSES" >> "$IDENT"
say "rules: $(grep -c . "$REPLACE") replace lines, $(grep -c . "$MAILMAP") mailmap lines, $(grep -c . "$IDENT") identity patterns, $(grep -c . "$SECRETS") secret patterns (files under $RULES, removed at exit)"
# ---- 3. the counts, before and after ---------------------------------------------------------------------------------
# every blob in the object store (reachable or not: before the pass the mirror holds everything, after it filter-repo's gc
# has pruned), one count of matching lines over a pattern file (perl regexes, case-insensitive)
scan_blobs() {
git cat-file --batch-all-objects --batch-check='%(objectname) %(objecttype)' --unordered 2>/dev/null | awk '$2 == "blob" { print $1 }' \
| git cat-file --batch 2>/dev/null \
| perl -ne 'BEGIN { open(P, "<", shift) or die; @p = map { chomp; qr/$_/i } grep { /\S/ } <P>; $n = 0 } for my $p (@p) { if ($_ =~ $p) { $n++; last } } END { print "$n\n" }' "$1"
}
scan_meta() { git log --all --format='%an%n%ae%n%cn%n%ce%n%s%n%b' | perl -ne 'BEGIN { open(P, "<", shift) or die; @p = map { chomp; qr/$_/i } grep { /\S/ } <P>; $n = 0 } for my $p (@p) { if ($_ =~ $p) { $n++; last } } END { print "$n\n" }' "$1"; }
DROPPED=(docs/fud-ledger.md docs/fud-fixes.md docs/review site/ledger.html)
counts() { # <label>
local label="$1"
say ""
say "[$label]"
say " commits (all refs): $(git rev-list --all --count)"
say " refs: $(git for-each-ref | wc -l | tr -d ' ')"
say " author+committer identities: $(git log --all --format='%an <%ae>%n%cn <%ce>' | sort -u | wc -l | tr -d ' ')"
say " stamps not +0000 (of $(git log --all --format='%ad%n%cd' --date=raw | wc -l | tr -d ' ')): $(git log --all --format='%ad%n%cd' --date=raw | grep -vc ' +0000$' || true)"
say " commits touching the dropped files: $(git log --all --format=%H -- "${DROPPED[@]}" | sort -u | wc -l | tr -d ' ')"
say " secret lines in any blob: $(scan_blobs "$SECRETS")"
say " identity lines in any blob: $(scan_blobs "$IDENT")"
say " identity lines in commit metadata: $(scan_meta "$IDENT")"
say " standing login lines in any blob: $(printf '\\b%s\\b\n' "$STANDING_LOGIN" > "$RULES/login.pl"; scan_blobs "$RULES/login.pl")${NEW_LOGIN:+ (must be 0 with --new-login)}"
}
counts "before"
# ---- 4. the pass --------------------------------------------------------------------------------------------------
say ""
say "running: ${FILTER[*]} --force --invert-paths ${DROPPED[*]/#/--path } --replace-text <rules> --replace-message <rules> --mailmap <rules> --commit-callback <offsets to +0000>${PUBLIC_CLAUDE:+ --file-info-callback <CLAUDE.md from $PUBLIC_CLAUDE>}"
PATH_ARGS=(); for p in "${DROPPED[@]}"; do PATH_ARGS+=(--path "$p"); done
CALLBACK_ARGS=()
if [ -n "$PUBLIC_CLAUDE" ]; then
cat > "$RULES/file-info.py" <<PY
if filename == b'CLAUDE.md':
if 'claude' not in value.data:
value.data['claude'] = value.insert_file_with_contents(open('$PUBLIC_CLAUDE', 'rb').read())
return (filename, mode, value.data['claude'])
return (filename, mode, blob_id)
PY
CALLBACK_ARGS+=(--file-info-callback "$RULES/file-info.py")
fi
T0=$(date +%s)
"${FILTER[@]}" --force --quiet \
--invert-paths "${PATH_ARGS[@]}" \
--replace-text "$REPLACE" \
--replace-message "$REPLACE" \
--mailmap "$MAILMAP" \
--commit-callback '
for attr in ("author_date", "committer_date"):
d = getattr(commit, attr); parts = d.split(b" ")
if len(parts) == 2 and parts[1] != b"+0000":
setattr(commit, attr, parts[0] + b" +0000")
' ${CALLBACK_ARGS[@]+"${CALLBACK_ARGS[@]}"}
say "pass done in $(( $(date +%s) - T0 )) s"
[ -f .git/filter-repo/commit-map ] && cp .git/filter-repo/commit-map "$WORK/commit-map" && say "commit-map: $WORK/commit-map ($(grep -c . "$WORK/commit-map") lines; keep it with the private notes)"
[ -f filter-repo/commit-map ] && cp filter-repo/commit-map "$WORK/commit-map" && say "commit-map: $WORK/commit-map ($(grep -c . "$WORK/commit-map") lines; keep it with the private notes)"
# ---- 5. the verification: every count that must read zero ------------------------------------------------------------
counts "after"
FAIL=0
must_zero() { local what="$1" n="$2"; if [ "$n" != "0" ]; then say " FAIL $what: $n (must be 0)"; FAIL=1; else say " ok $what: 0"; fi; }
say ""
say "[verdict]"
must_zero "secret lines in any blob" "$(scan_blobs "$SECRETS")"
must_zero "identity lines in any blob" "$(scan_blobs "$IDENT")"
must_zero "identity lines in commit metadata" "$(scan_meta "$IDENT")"
must_zero "stamps not +0000" "$(git log --all --format='%ad%n%cd' --date=raw | grep -vc ' +0000$' || true)"
must_zero "commits touching the dropped files" "$(git log --all --format=%H -- "${DROPPED[@]}" | sort -u | wc -l | tr -d ' ')"
must_zero "identities other than $TARGET_IDENT" "$(git log --all --format='%an <%ae>%n%cn <%ce>' | sort -u | grep -vcF "$TARGET_IDENT" || true)"
[ -n "$NEW_LOGIN" ] && must_zero "old login $STANDING_LOGIN in any blob" "$(scan_blobs "$RULES/login.pl")"
if [ -n "$PUBLIC_CLAUDE" ]; then
for ref in $(git for-each-ref --format='%(refname)' refs/heads | head -3); do
if git cat-file -p "$ref:CLAUDE.md" 2>/dev/null | cmp -s - "$PUBLIC_CLAUDE"; then say " ok CLAUDE.md on $ref is the public text"; else say " FAIL CLAUDE.md on $ref is not the public text"; FAIL=1; fi
done
fi
cleanup_rules; trap - EXIT
if [ "$FAIL" = 1 ]; then say ""; say "NOT CLEAN: fix the rules and run again on a fresh clone (nothing was pushed)"; [ "$CLEAN" = 1 ] && rm -rf "$WORK"; exit 1; fi
# ---- 6. the commands that create the fresh repository and push (printed, never run) ---------------------------------
say ""
say "clean. The push, when the owner says so (option B of docs/plans/history-rewrite.md section 5; every line by hand):"
say ""
say " # 1. freeze: every agent has committed and pushed; gh pr list --repo $ORG/igneum is empty; git worktree list recorded"
say " gh auth switch --user $TARGET_LOGIN && gh auth status"
say " # 2. the fresh repository (private; the name is the owner's; igneum-core is the suggestion)"
say " gh repo create $NEW_REPO --private --description 'Igneum: the GPU-mined zkEVM L1' --disable-wiki"
say " # 3. push every rewritten ref from the clone (the pass removed its origin remote on purpose)"
say " cd $CLONE"
say " git remote add origin https://github.com/$NEW_REPO.git"
say " git push --mirror origin"
say " # 4. after the push, on GitHub: default branch master; Settings > Secrets: DL_TOKEN, DL_TOKEN_NEXT, LOG_INTAKE_KEY,"
say " # LOG_INTAKE_KEY_NEXT (tr -d '[:space:]' < ~/.config/igneum/<file> | gh secret set <NAME> --repo $NEW_REPO);"
say " # Vercel project igneum (team igneum): Git > disconnect $ORG/igneum, connect $NEW_REPO, production branch master;"
say " # archive $ORG/igneum (Settings > Archive), keep it private; never delete it the same day"
say " # 5. re-clone the main checkout from the new history and re-create every worktree from its rewritten branch:"
say " cd ~/Projects && mv igneum igneum-old-history && git clone https://github.com/$NEW_REPO.git igneum"
say " # for each worktree: git -C ~/Projects/igneum worktree add ../igneum-wt-<name> <branch>; vendor/ is copied back by hand (gitignored)"
say " # 6. TZ=UTC in every shell that commits; tools/ci/identity-check.sh and the +0100 count stay the daily check"
[ "$CLEAN" = 1 ] && { cd /; rm -rf "$WORK"; say "work directory removed (--clean)"; } || say "report: $WORK/report.txt; clone kept at $CLONE"
exit 0

View file

@ -4,7 +4,7 @@
//
// node tools/ship-app.mjs 0.3.4 --node <fork worktree> [--notes "..."] [--dry-run] [--from <step>]
// [--skip-windows | --skip-mac] [--node-commit <sha>] [--win-release <dir>] [--mac-release <dir>]
// [--min-supported 0.3.0] [--activation-height N --deadline-note "..."]
// [--min-supported 0.3.0] [--activation-height N --deadline-note "..."] [--dl-both]
// node tools/ship-app.mjs --check the six version files agree (exit 1 when they do not)
// node tools/ship-app.mjs --self-test the bump, on a scratch copy of the version files
//
@ -18,11 +18,21 @@
// fetch packaging/windows/fetch-ci-artifacts.sh <run>: the installer and the payload zip into the downloads folder
// dmg packaging/mac/build-dmg.sh under tools/lock/with-lock.sh build (nice 19, 4 cargo jobs)
// copy the DMG into the downloads folder
// manifest packaging/ota/publish-manifest.sh --no-deploy: canonical JSON, signed, signature verified locally
// mirror --dl-both only: the version's files and the folder-level files (jobs, payload inputs, CI record) into the
// NEXT token folder, dl/<dl-token.next>/, so both folders carry the same bytes
// manifest packaging/ota/publish-manifest.sh --no-deploy: canonical JSON, signed, signature verified locally; with
// --dl-both a second manifest in the NEXT folder (--dest, --base-url) carrying the same override, tuning and
// min_supported, checked field by field against the first
// deploy the downloads folder with the Vercel CLI (one deploy carries the files and the manifest together)
// verify HEAD and GET of the three files (size and sha256 against the local copies), the live manifest and its signature
// verify HEAD and GET of the three files (size and sha256 against the local copies), the live manifest and its signature;
// with --dl-both the same for the NEXT folder
// console one console item (tools/console.mjs post --kind build) with version, sizes and hashes, then sync-dl
//
// --dl-both (rotation phase 2, 5 October 2026, docs/plans/rotation-phase-2.md): the downloads token is being rotated.
// Installed apps check the OLD folder (dl-token); the new build checks the NEW one (dl-token.next, what
// packaging/mac/packaged-config.sh packages by default while that file exists). The version is published in BOTH
// folders so the old apps find the update and the new ones find their folder; one deploy, both verified.
//
// Secrets: ~/.config/igneum/dl-token, dlsite-dir, relay-token, relay-key, ota-signing-key, vercel/ are read by this
// tool or by the scripts it calls and never printed; every output line is scrubbed of the tokens. State that is not a
// secret (commit, run id, bump time) lives in ~/.cache/igneum/ship/<version>.json. gh auth switch --user igneum-labs runs
@ -107,7 +117,7 @@ function checkVersionFiles(root) {
}
// ---- output: every line scrubbed of the tokens -------------------------------------------------------------------
const SECRETS = ['dl-token', 'relay-token', 'relay-key', 'log-intake-key'].map(cfg).filter(s => s.length >= 8);
const SECRETS = ['dl-token', 'dl-token.next', 'relay-token', 'relay-key', 'log-intake-key', 'log-intake-key.next'].map(cfg).filter(s => s.length >= 8);
const scrub = s => SECRETS.reduce((t, k) => t.split(k).join('<token>'), String(s));
const say = (...a) => console.log(scrub(a.join(' ')));
const fmtSize = n => n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`;
@ -154,7 +164,7 @@ for (let i = 0; i < argv.length; i++) {
if (a.startsWith('--')) { const k = a.slice(2); const next = argv[i + 1]; if (next !== undefined && !next.startsWith('--')) { flags[k] = next; i++; } else flags[k] = true; }
else pos.push(a);
}
const STEPS = ['preflight', 'bump', 'inputs', 'commit', 'ci', 'fetch', 'dmg', 'copy', 'manifest', 'deploy', 'verify', 'console'];
const STEPS = ['preflight', 'bump', 'inputs', 'commit', 'ci', 'fetch', 'dmg', 'copy', 'mirror', 'manifest', 'deploy', 'verify', 'console'];
if (flags['self-test']) { process.exit(selfTest()); }
if (flags.check) {
@ -166,7 +176,7 @@ if (flags.check) {
}
const VERSION = pos[0];
if (!VERSION || !isVersion(VERSION)) { console.error('usage: node tools/ship-app.mjs <major.minor.patch> --node <fork worktree> [--notes "..."] [--dry-run] [--from <step>] [--skip-windows|--skip-mac]\n node tools/ship-app.mjs --check | --self-test'); process.exit(2); }
if (!VERSION || !isVersion(VERSION)) { console.error('usage: node tools/ship-app.mjs <major.minor.patch> --node <fork worktree> [--notes "..."] [--dry-run] [--from <step>] [--skip-windows|--skip-mac] [--dl-both]\n node tools/ship-app.mjs --check | --self-test'); process.exit(2); }
if (!flags.node) { console.error('--node <fork worktree> is required (the igneum-node worktree the node and miner were built from)'); process.exit(2); }
if (flags.from && !STEPS.includes(flags.from)) { console.error(`--from must be one of: ${STEPS.join(', ')}`); process.exit(2); }
if (flags['skip-windows'] && flags['skip-mac']) { console.error('--skip-windows and --skip-mac together leave nothing to ship'); process.exit(2); }
@ -179,6 +189,14 @@ const TOKEN = cfg('dl-token');
const DLSITE = process.env.IGNEUM_DLSITE || cfg('dlsite-dir');
const DEST = DLSITE && TOKEN ? join(DLSITE, 'dl', TOKEN) : '';
const BASE = `https://dl.igneum.network/dl/${TOKEN}`;
// --dl-both: the NEXT folder, from ~/.config/igneum/dl-token.next
const BOTH = !!flags['dl-both'];
const TOKEN_NEXT = BOTH ? cfg('dl-token.next') : '';
const DEST_NEXT = BOTH && DLSITE && TOKEN_NEXT ? join(DLSITE, 'dl', TOKEN_NEXT) : '';
const BASE_NEXT = `https://dl.igneum.network/dl/${TOKEN_NEXT}`;
// the folder-level files the apps and the CI read next to the manifest (jobs, the CI's inputs, the CI record, the
// WSL2 prover zip): mirrored into the NEXT folder when present in the current one
const FOLDER_FILES = ['igneum-jobs.json', 'igneum-jobs.json.sig', 'payload-inputs.zip', 'payload-inputs.json', 'payload-inputs.sha256', 'igneum-windows-ci.json', 'igneum-prove-wsl2.zip'];
const DMG_NAME = `Igneum-Miner-${VERSION}.dmg`;
const SETUP_NAME = `Igneum-Miner-Setup-${VERSION}.exe`;
const ZIP_NAME = 'igneum-windows-app.zip';
@ -193,7 +211,42 @@ const WIN_RELEASE = flags['win-release'] ? resolve(flags['win-release']) : first
const MAC_RELEASE = flags['mac-release'] ? resolve(flags['mac-release']) : firstDir([join(NODE_DIR, 'target-integration', 'release'), join(NODE_DIR, 'target', 'release')], 'igneumd');
const WIN_INPUTS = ['igneumd.exe', 'igneum-miner.exe'].map(n => join(WIN_RELEASE, n));
const WORKERS = [join(ROOT, 'proto-cuda', 'nvrtc', 'igneum-worker-cuda.exe'), join(ROOT, 'proto-opencl', 'igneum-worker-opencl.exe')];
const retryCmd = step => `node tools/ship-app.mjs ${VERSION} --node ${flags.node}${flags.notes ? ` --notes ${JSON.stringify(flags.notes)}` : ''}${WIN ? '' : ' --skip-windows'}${MAC ? '' : ' --skip-mac'}${flags['node-commit'] ? ` --node-commit ${flags['node-commit']}` : ''} --from ${step}`;
const retryCmd = step => `node tools/ship-app.mjs ${VERSION} --node ${flags.node}${flags.notes ? ` --notes ${JSON.stringify(flags.notes)}` : ''}${WIN ? '' : ' --skip-windows'}${MAC ? '' : ' --skip-mac'}${flags['node-commit'] ? ` --node-commit ${flags['node-commit']}` : ''}${BOTH ? ' --dl-both' : ''} --from ${step}`;
// ---- --dl-both helpers (pure; the self-test runs them on scratch folders) -------------------------------------------
// which of `names` must be copied from src to dst: 'copy' (missing or different bytes), 'same', or 'absent' (not in src)
function mirrorPlan(src, dst, names) {
return names.map(name => {
const a = join(src, name), b = join(dst, name);
if (!existsSync(a)) return { name, action: 'absent' };
if (existsSync(b) && sha256(a) === sha256(b)) return { name, action: 'same' };
return { name, action: 'copy' };
});
}
// the arguments the second publish-manifest.sh call takes so the NEXT folder's manifest carries what the first one
// carries (override, tuning, min_supported are otherwise carried over from the manifest already in THAT folder, which
// is older or missing): [args, tuningFile|null]
function secondManifestArgs(first, dest, base, tmpDir) {
const args = ['--dest', dest, '--base-url', base];
const o = first.consensus && first.consensus.override;
if (o && typeof o === 'object' && Object.keys(o).length) args.push('--override', JSON.stringify(o));
if (first.min_supported_version) args.push('--min-supported', String(first.min_supported_version));
let tuningFile = null;
if (first.tuning && typeof first.tuning === 'object' && first.tuning.cards) { tuningFile = join(tmpDir, 'tuning.json'); writeFileSync(tuningFile, JSON.stringify(first.tuning)); args.push('--tuning', tuningFile); }
else args.push('--no-tuning');
return [args, tuningFile];
}
// the two manifests must agree on everything except published_at and the folder in the URLs: the differences, [] when none
function manifestDifferences(a, b, baseA, baseB) {
const diffs = [];
const norm = (m, base) => { const c = JSON.parse(JSON.stringify(m)); delete c.published_at; for (const e of Object.values(c.platforms || {})) if (typeof e.url === 'string') e.url = e.url.replace(base, '<base>'); return c; };
const x = norm(a, baseA), y = norm(b, baseB);
for (const k of new Set([...Object.keys(x), ...Object.keys(y)])) {
const sx = JSON.stringify(x[k] === undefined ? null : x[k]), sy = JSON.stringify(y[k] === undefined ? null : y[k]);
if (sx !== sy) diffs.push(`${k}: ${sx.slice(0, 80)} vs ${sy.slice(0, 80)}`);
}
return diffs;
}
const results = []; // the final table
let dryProblems = 0; // a dry run lists preflight problems and goes on with the plan; its exit code says so
const done = (step, result, detail = '') => { results.push([step, result, detail]); say(`[${step}] ${result}${detail ? ': ' + detail : ''}`); };
@ -244,6 +297,12 @@ async function preflight() {
for (const n of ['dl-token', 'dlsite-dir', 'ota-signing-key', 'ota-signing-key.pub', 'relay-token', 'relay-key']) if (!existsSync(join(CFG, n))) problems.push(`no ~/.config/igneum/${n}`);
if (!existsSync(join(CFG, 'vercel'))) problems.push('no ~/.config/igneum/vercel (the Vercel login for the downloads host)');
if (!DEST || !existsSync(DEST)) problems.push(`no downloads folder at <dlsite>/dl/<token> (~/.config/igneum/dlsite-dir says ${DLSITE || 'nothing'})`);
if (BOTH) {
if (!TOKEN_NEXT) problems.push('--dl-both needs ~/.config/igneum/dl-token.next (the next downloads token)');
else if (TOKEN_NEXT === TOKEN) problems.push('--dl-both: dl-token.next equals dl-token; nothing to rotate');
else if (!DEST_NEXT || !existsSync(DEST_NEXT)) problems.push('--dl-both: no folder at <dlsite>/dl/<dl-token.next>; mkdir it first (an empty folder is fine)');
if (!existsSync(join(CFG, 'log-intake-key.next'))) notes.push('no ~/.config/igneum/log-intake-key.next: the packagers ship the current intake key (packaged-config.sh)');
}
// gh account (a read; the real steps switch before every call)
const st = runSync('gh', ['auth', 'status']).out;
const active = /Logged in to github\.com account (\S+) \(keyring\)\n\s+- Active account: true/.exec(st);
@ -262,6 +321,7 @@ async function preflight() {
['mac binaries', MAC ? ['igneumd', 'igneum-miner'].map(n => existsSync(join(MAC_RELEASE, n)) ? `${n} ${fmtSize(sizeOf(join(MAC_RELEASE, n)))}` : `${n} MISSING`).join(', ') : 'skipped'],
['workers', WORKERS.map(w => existsSync(w) ? basename(w) : `${basename(w)} missing`).join(', ')],
['downloads folder', DEST ? DEST.replace(TOKEN, '<token>') : 'none'],
['next folder', BOTH ? (DEST_NEXT ? DEST_NEXT.replace(TOKEN_NEXT, '<token.next>') : 'MISSING') : 'not used (no --dl-both)'],
['gh active', `${ghActive}${ghActive === GH_USER ? '' : ` (switched to ${GH_USER} before every call)`}`],
['live inputs', live.inputs ? `node ${live.inputs.node_source_commit} built ${live.inputs.built_at}` : 'none'],
['live ci', live.ci ? `${live.ci.installer} (${live.ci.run.split('/').pop()})` : 'none'],
@ -402,19 +462,50 @@ async function copy() {
done('copy', 'ok', `${DMG_NAME} ${fmtSize(sizeOf(dst))} copied`);
}
async function mirror() {
if (!BOTH) return done('mirror', 'skipped', 'no --dl-both');
const names = [MAC && DMG_NAME, WIN && SETUP_NAME, WIN && ZIP_NAME, ...FOLDER_FILES].filter(Boolean);
const plan = mirrorPlan(DEST, DEST_NEXT, names);
const copies = plan.filter(p => p.action === 'copy'), same = plan.filter(p => p.action === 'same'), absent = plan.filter(p => p.action === 'absent');
const summary = `${copies.length} to copy (${copies.map(p => p.name).join(', ') || 'none'}), ${same.length} same, ${absent.length} absent in the current folder${absent.length ? ` (${absent.map(p => p.name).join(', ')})` : ''}`;
if (DRY) return done('mirror', 'would', `copy into dl/<token.next>/: ${summary}`);
for (const name of [MAC && DMG_NAME, WIN && SETUP_NAME].filter(Boolean)) if (!existsSync(join(DEST, name))) throw new Error(`missing ${name} in the current folder; ${retryCmd(name.endsWith('.dmg') ? 'copy' : 'fetch')}`);
for (const p of copies) copyFileSync(join(DEST, p.name), join(DEST_NEXT, p.name));
const after = mirrorPlan(DEST, DEST_NEXT, names).filter(p => p.action === 'copy');
if (after.length) throw new Error(`still differ after the copy: ${after.map(p => p.name).join(', ')}`);
done('mirror', copies.length ? 'ok' : 'already', summary);
}
async function manifest() {
const args = ['--version', VERSION, '--notes', NOTES, '--no-deploy'];
if (MAC) args.push('--mac', join(DEST, DMG_NAME));
if (WIN) args.push('--win', join(DEST, SETUP_NAME));
for (const k of ['min-supported', 'activation-height', 'deadline-note', 'channel']) if (flags[k]) args.push(`--${k}`, String(flags[k]));
const cmd = `packaging/ota/publish-manifest.sh ${args.map(a => a.includes(' ') ? JSON.stringify(a) : a).join(' ')}`;
if (DRY) return done('manifest', 'would', `run ${cmd.replace(TOKEN, '<token>')} (signs, verifies the signature; ${WIN && MAC ? 'both platforms' : WIN ? 'windows entry, mac carried over when the live manifest is this version' : 'mac entry, windows carried over when the live manifest is this version'})`);
if (DRY) return done('manifest', 'would', `run ${cmd.replace(TOKEN, '<token>')} (signs, verifies the signature; ${WIN && MAC ? 'both platforms' : WIN ? 'windows entry, mac carried over when the live manifest is this version' : 'mac entry, windows carried over when the live manifest is this version'})${BOTH ? '; then the same for dl/<token.next>/ with --dest and --base-url, carrying this manifest\'s override, tuning and min_supported; the two compared field by field' : ''}`);
for (const p of [MAC && join(DEST, DMG_NAME), WIN && join(DEST, SETUP_NAME)].filter(Boolean)) if (!existsSync(p)) throw new Error(`missing ${p.replace(TOKEN, '<token>')}; ${retryCmd(p.endsWith('.dmg') ? 'copy' : 'fetch')}`);
const r = await run('bash', [join(ROOT, 'packaging', 'ota', 'publish-manifest.sh'), ...args]);
if (r.code !== 0) throw new Error(`publish-manifest.sh exited ${r.code}; retry: ${cmd.replace(TOKEN, '<token>')}`);
const m = JSON.parse(readFileSync(join(DEST, 'igneum-app-latest.json'), 'utf8'));
if (m.version !== VERSION) throw new Error(`the written manifest says ${m.version}`);
done('manifest', 'ok', `${VERSION} ${Object.keys(m.platforms).join('+')}, signed and verified locally`);
if (!BOTH) return done('manifest', 'ok', `${VERSION} ${Object.keys(m.platforms).join('+')}, signed and verified locally`);
// the NEXT folder: the same entries from its own copies, the same override, tuning and min_supported
const tmp = mkdtempSync(join(tmpdir(), 'igneum-ship-m-'));
try {
const [extra, tuningFile] = secondManifestArgs(m, DEST_NEXT, BASE_NEXT, tmp);
const args2 = ['--version', VERSION, '--notes', NOTES, '--no-deploy', ...extra];
if (MAC) args2.push('--mac', join(DEST_NEXT, DMG_NAME));
if (WIN) args2.push('--win', join(DEST_NEXT, SETUP_NAME));
for (const k of ['activation-height', 'deadline-note', 'channel']) if (flags[k]) args2.push(`--${k}`, String(flags[k]));
const cmd2 = `packaging/ota/publish-manifest.sh ${args2.map(a => a.includes(' ') || a.startsWith('{') ? JSON.stringify(a) : a).join(' ')}`;
const r2 = await run('bash', [join(ROOT, 'packaging', 'ota', 'publish-manifest.sh'), ...args2]);
if (r2.code !== 0) throw new Error(`publish-manifest.sh (next folder) exited ${r2.code}; retry: ${cmd2.replace(TOKEN_NEXT, '<token.next>')}`);
const m2 = JSON.parse(readFileSync(join(DEST_NEXT, 'igneum-app-latest.json'), 'utf8'));
const diffs = manifestDifferences(m, m2, BASE, BASE_NEXT);
if (diffs.length) throw new Error(`the two manifests differ beyond the folder and the publish time:\n ${diffs.join('\n ')}`);
if (tuningFile) say(` tuning carried into the next folder (${Object.keys(m.tuning.cards).length} card model(s))`);
done('manifest', 'ok', `${VERSION} ${Object.keys(m.platforms).join('+')} in both folders, signed, verified locally, same fields`);
} finally { rmSync(tmp, { recursive: true, force: true }); }
}
async function deploy() {
@ -426,53 +517,62 @@ async function deploy() {
done('deploy', 'ok', 'downloads folder deployed');
}
async function verify() {
const files = [MAC && DMG_NAME, WIN && SETUP_NAME, WIN && ZIP_NAME].filter(Boolean);
if (DRY) return done('verify', 'would', `HEAD and GET ${files.join(', ')} (size and sha256 against the local files), GET the manifest and its .sig, igneum-ota-sign verify`);
// one folder: HEAD and GET of the files against the local copies, the manifest's bytes and signature; the failures
async function verifyFolder(dest, base, files, label) {
const rows = [['file', 'local', 'HEAD', 'sha256']];
const failures = [];
const tmp = mkdtempSync(join(tmpdir(), 'igneum-ship-'));
try {
for (const name of files) {
const local = join(DEST, name); const want = sha256(local); const size = sizeOf(local);
const local = join(dest, name); const want = sha256(local); const size = sizeOf(local);
let h, got = '';
for (let attempt = 1; attempt <= 3; attempt++) {
h = await head(`${BASE}/${name}`);
h = await head(`${base}/${name}`);
if (h.status === 200 && (h.length === null || h.length === size)) {
const r = await run('curl', ['-fsSL', '--retry', '3', '-o', join(tmp, name), `${BASE}/${name}`], { quiet: true });
const r = await run('curl', ['-fsSL', '--retry', '3', '-o', join(tmp, name), `${base}/${name}`], { quiet: true });
if (r.code === 0) { got = sha256(join(tmp, name)); if (got === want) break; }
}
if (attempt < 3) { say(` ${name}: not matching yet (HTTP ${h.status}, length ${h.length}, sha ${got ? got.slice(0, 12) : '-'}); again in 15 s`); await sleep(15000); }
}
const ok = h.status === 200 && (h.length === null || h.length === size) && got === want;
rows.push([name, `${size} B ${want.slice(0, 12)}`, `${h.status} ${h.length === null ? '(no length)' : h.length + ' B'}`, got === want ? `ok ${want.slice(0, 12)}` : `MISMATCH ${got.slice(0, 12) || 'no body'}`]);
if (!ok) failures.push(name);
if (!ok) failures.push(`${label}:${name}`);
state.files = state.files || {}; state.files[name] = { size, sha256: want, served: ok };
}
// the manifest: bytes and signature, through the same verifier the apps use
const mr = await run('curl', ['-fsSL', '-o', join(tmp, 'm.json'), `${BASE}/igneum-app-latest.json`], { quiet: true });
const sr = await run('curl', ['-fsSL', '-o', join(tmp, 'm.sig'), `${BASE}/igneum-app-latest.json.sig`], { quiet: true });
const mr = await run('curl', ['-fsSL', '-o', join(tmp, 'm.json'), `${base}/igneum-app-latest.json`], { quiet: true });
const sr = await run('curl', ['-fsSL', '-o', join(tmp, 'm.sig'), `${base}/igneum-app-latest.json.sig`], { quiet: true });
let mline = 'not reachable';
if (mr.code === 0 && sr.code === 0) {
const v = await run(SIGNER, ['verify', join(CFG, 'ota-signing-key.pub'), join(tmp, 'm.json'), join(tmp, 'm.sig')], { quiet: true });
const m = JSON.parse(readFileSync(join(tmp, 'm.json'), 'utf8'));
const same = readFileSync(join(tmp, 'm.json')).equals(readFileSync(join(DEST, 'igneum-app-latest.json')));
const same = readFileSync(join(tmp, 'm.json')).equals(readFileSync(join(dest, 'igneum-app-latest.json')));
const plat = Object.entries(m.platforms || {}).map(([k, e]) => `${k} ${e.sha256.slice(0, 12)}`).join(', ');
mline = `${m.version} ${v.code === 0 ? 'signature ok' : 'SIGNATURE FAILS'}${same ? '' : ' (DIFFERS from the local manifest)'} ${plat}`;
if (v.code !== 0 || m.version !== VERSION || !same) failures.push('manifest');
state.manifest = { version: m.version, published_at: m.published_at, platforms: m.platforms };
} else failures.push('manifest');
const inFolder = Object.values(m.platforms || {}).every(e => typeof e.url === 'string' && e.url.startsWith(base + '/'));
mline = `${m.version} ${v.code === 0 ? 'signature ok' : 'SIGNATURE FAILS'}${same ? '' : ' (DIFFERS from the local manifest)'}${inFolder ? '' : ' (URLS POINT OUTSIDE THIS FOLDER)'} ${plat}`;
if (v.code !== 0 || m.version !== VERSION || !same || !inFolder) failures.push(`${label}:manifest`);
if (!state.manifest || label === 'current') state.manifest = { version: m.version, published_at: m.published_at, platforms: m.platforms };
} else failures.push(`${label}:manifest`);
rows.push(['igneum-app-latest.json', '', '', mline]);
} finally { rmSync(tmp, { recursive: true, force: true }); }
say(` ${label} folder: ${label === 'next' ? 'dl/<token.next>/' : 'dl/<token>/'}`);
table(rows);
return failures;
}
async function verify() {
const files = [MAC && DMG_NAME, WIN && SETUP_NAME, WIN && ZIP_NAME].filter(Boolean);
if (DRY) return done('verify', 'would', `HEAD and GET ${files.join(', ')} (size and sha256 against the local files), GET the manifest and its .sig, igneum-ota-sign verify${BOTH ? '; the same for dl/<token.next>/' : ''}`);
const failures = await verifyFolder(DEST, BASE, files, 'current');
if (BOTH) failures.push(...await verifyFolder(DEST_NEXT, BASE_NEXT, files, 'next'));
saveState();
if (failures.length) throw new Error(`not served as expected: ${failures.join(', ')}; the deploy may still be propagating. Retry: ${retryCmd('deploy')}`);
done('verify', 'ok', `${files.length} files and the manifest match the local copies`);
done('verify', 'ok', `${files.length} files and the manifest match the local copies${BOTH ? ' in both folders' : ''}`);
}
async function consoleStep() {
const lines = Object.entries(state.files || {}).map(([n, f]) => `${n} ${f.size} B sha256 ${f.sha256}`);
const body = [NOTES, ...lines, state.runId ? `Windows CI https://github.com/${REPO}/actions/runs/${state.runId}` : '', state.sha ? `commit ${state.sha.slice(0, 12)}, node fork ${state.forkCommit || '?'}` : ''].filter(Boolean).join('\n');
const body = [NOTES, ...lines, state.runId ? `Windows CI https://github.com/${REPO}/actions/runs/${state.runId}` : '', state.sha ? `commit ${state.sha.slice(0, 12)}, node fork ${state.forkCommit || '?'}` : '', BOTH ? 'published in both downloads folders (token rotation)' : ''].filter(Boolean).join('\n');
const meta = { version: VERSION, files: state.files || {}, run: state.runId || null, commit: state.sha || null, fork: state.forkCommit || null, manifest_published_at: state.manifest ? state.manifest.published_at : null };
if (DRY) return done('console', 'would', `tools/console.mjs post --kind build --key ship:${VERSION} --title "Igneum Miner ${VERSION} shipped" (sizes, hashes, run, commit), then sync-dl`);
const r = await run('node', [join(ROOT, 'tools', 'console.mjs'), 'post', '--kind', 'build', '--key', `ship:${VERSION}`, '--title', `Igneum Miner ${VERSION} shipped (${[MAC && 'mac', WIN && 'windows'].filter(Boolean).join('+')})`, '--body', body, '--meta', JSON.stringify(meta)], { quiet: true });
@ -482,7 +582,7 @@ async function consoleStep() {
}
// ---- the runner ----------------------------------------------------------------------------------------------------
const IMPL = { preflight, bump, inputs, commit, ci, fetch: fetchStep, dmg, copy, manifest, deploy, verify, console: consoleStep };
const IMPL = { preflight, bump, inputs, commit, ci, fetch: fetchStep, dmg, copy, mirror, manifest, deploy, verify, console: consoleStep };
async function main() {
const start = flags.from ? STEPS.indexOf(flags.from) : 0;
// preflight always runs: it is reads only and the later steps need its facts (fork commit, state)
@ -555,6 +655,23 @@ function selfTest() {
check('1.2 is refused', refused);
check('comma helpers round-trip', toComma('0.3.4') === '0,3,4,0' && fromComma('0,3,4,0') === '0.3.4' && fromComma('0,3,4,1') === '0,3,4,1');
check('version compare', cmpVersion('0.3.4', '0.3.3') > 0 && cmpVersion('0.10.0', '0.9.9') > 0 && cmpVersion('1.0.0', '1.0.0') === 0);
// 5. --dl-both helpers on two scratch folders
const a = join(dir, 'dl', 'old'), b = join(dir, 'dl', 'new');
mkdirSync(a, { recursive: true }); mkdirSync(b, { recursive: true });
writeFileSync(join(a, 'x.dmg'), 'dmg bytes'); writeFileSync(join(a, 'same.json'), 'same'); writeFileSync(join(b, 'same.json'), 'same');
writeFileSync(join(a, 'differs.zip'), 'v2'); writeFileSync(join(b, 'differs.zip'), 'v1');
const plan = Object.fromEntries(mirrorPlan(a, b, ['x.dmg', 'same.json', 'differs.zip', 'absent.sig']).map(p => [p.name, p.action]));
check('mirror plan: missing -> copy, same -> same, different -> copy, absent -> absent', plan['x.dmg'] === 'copy' && plan['same.json'] === 'same' && plan['differs.zip'] === 'copy' && plan['absent.sig'] === 'absent', JSON.stringify(plan));
const first = { version: '0.3.6', published_at: '2026-10-05T12:00:00Z', channel: 'devnet', notes: 'n', min_supported_version: '0.3.0', platforms: { mac: { url: 'https://dl.igneum.network/dl/OLD/Igneum-Miner-0.3.6.dmg', sha256: 'aa', size: 1, kind: 'dmg' } }, consensus: { activation_height: null, deadline_note: '', override: { difficulty_v2_activation_daa: 33000 } }, tuning: { cards: { 'RTX 5090': { v: 1 } } } };
const [args, tuningFile] = secondManifestArgs(first, '/dest', 'https://dl.igneum.network/dl/NEW', dir);
check('second manifest args carry override, min_supported and tuning', args.includes('--override') && args[args.indexOf('--override') + 1] === '{"difficulty_v2_activation_daa":33000}' && args.includes('--min-supported') && args[args.indexOf('--min-supported') + 1] === '0.3.0' && args.includes('--tuning') && tuningFile && JSON.parse(readFileSync(tuningFile, 'utf8')).cards['RTX 5090'].v === 1, args.join(' '));
const [args0] = secondManifestArgs({ version: '0.3.6', platforms: {} }, '/dest', 'https://x', dir);
check('second manifest args without override or tuning say --no-tuning and no --override', args0.includes('--no-tuning') && !args0.includes('--override') && !args0.includes('--min-supported'), args0.join(' '));
const second = JSON.parse(JSON.stringify(first)); second.published_at = '2026-10-05T12:01:00Z'; second.platforms.mac.url = 'https://dl.igneum.network/dl/NEW/Igneum-Miner-0.3.6.dmg';
check('two manifests that differ only by folder and time agree', manifestDifferences(first, second, 'https://dl.igneum.network/dl/OLD', 'https://dl.igneum.network/dl/NEW').length === 0);
second.consensus.override.difficulty_v2_activation_daa = 1; delete second.tuning;
const d = manifestDifferences(first, second, 'https://dl.igneum.network/dl/OLD', 'https://dl.igneum.network/dl/NEW');
check('a changed override and a dropped tuning are reported', d.length === 2 && d.some(x => x.startsWith('consensus')) && d.some(x => x.startsWith('tuning')), d.join(' | '));
} finally { rmSync(dir, { recursive: true, force: true }); }
say(fails ? `${fails} check(s) failed` : 'all checks passed');
return fails ? 1 : 0;