diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index 4ab49c49e..377b452e9 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -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" diff --git a/app/igneum-app/src/config.rs b/app/igneum-app/src/config.rs index bcb8ca9bb..5bdcfaf1b 100644 --- a/app/igneum-app/src/config.rs +++ b/app/igneum-app/src/config.rs @@ -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, + /// Where the key and the manifest came from, for the log header: "packaged", "file " 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 `/dl//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 { + 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::(&t).ok()) { + if let Some(mut p) = std::fs::read_to_string(c).ok().and_then(|t| serde_json::from_str::(&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, ""), 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("/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(); diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index cdad711d9..850754324 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -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())); diff --git a/app/igneum-app/src/main.rs b/app/igneum-app/src/main.rs index 6addd3103..c89747927 100644 --- a/app/igneum-app/src/main.rs +++ b/app/igneum-app/src/main.rs @@ -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 diff --git a/docs/plans/morning-2026-10-04.md b/docs/plans/morning-2026-10-04.md index aae69be6a..54458960b 100644 --- a/docs/plans/morning-2026-10-04.md +++ b/docs/plans/morning-2026-10-04.md @@ -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//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 diff --git a/docs/plans/rotation-phase-2.md b/docs/plans/rotation-phase-2.md new file mode 100644 index 000000000..123ede9ed --- /dev/null +++ b/docs/plans/rotation-phase-2.md @@ -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//` 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/ | 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//` 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//` 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 key (); manifest folder ()`; `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//`, 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//igneum-app-latest.json`: 0.3.6 is there (published in BOTH folders), signed by the same key | fetches `dl//igneum-app-latest.json`: itself | +| download | the URL inside the old folder's manifest, `dl//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 --notes "" --dl-both --dry-run # the plan, nothing written +node tools/ship-app.mjs 0.3.6 --node --notes "" --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//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 ` and +`--base-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="" + +# 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//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= platform= node= +config: intake https://igneum-six.vercel.app/api/log key 477bb0ef (packaged); manifest https://dl.igneum.network/dl//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 | 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 ` <337424239+@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=/git_filter_repo.py tools/repo/fresh-repo.sh --new-login --new-repo igneum-network/ \ + [--public-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/ --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 (`/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. diff --git a/infra/gpu-bench/upload.sh b/infra/gpu-bench/upload.sh index 7d416f2e7..eaf3dd6c5 100755 --- a/infra/gpu-bench/upload.sh +++ b/infra/gpu-bench/upload.sh @@ -5,7 +5,14 @@ # ./upload.sh