From af5ade4339e05d51f88fec6fad61a4a09fb8cce2 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:51:15 +0000 Subject: [PATCH] 2.0.2: PRODUCT=public|lab packaging switch, the kill-by-name refusal, the prove host's lease, the ten-minute check, the synced apply bound PRODUCT=public|lab (the founder's word at 20:21 UK): one variable in packaging/mac/packaged-config.sh that every packager reads: Igneum-Miner.iss AppId, AppName, folder and file name (/DProduct from build-installer.ps1 -Product), make-payload.sh and make-hive-package.sh names (payload folder, Hive CUSTOM_NAME and archive), build-dmg.sh bundle id, app and dmg names, the channel (igneum-2.0-devnet stays the public string), the manifest name and the signing root; the packager writes edition, channel and ota_root_hex into igneum-app.json and the engine names a mismatch on its update card. The job runner refuses a run-script body that ends a process by name (Stop-Process -Name, taskkill /IM, Get-Process | Stop-Process, pkill, killall): exit 77 with the matched line, in the signer and in the runner; a pid is the only way. Every igneum-prove-host spawn carries IGNEUM_PROVE_DEVICE, IGNEUM_PROVE_WORKLOAD, IGNEUM_PROVE_MEM_FREE_MB, IGNEUM_PROVE_MEM_BUDGET_MB and IGNEUM_PROVE_DEADLINE_S (the V6-07 contract); exit 78 reads "proving needs N GB free". The manifest check runs every ten minutes. A staged update applies at once on a synced node with an idle miner and within two minutes of the sync otherwise; Install now passes the finality guard; publish-manifest.sh --urgent. Co-Authored-By: Claude Fable 5.1 --- app/igneum-app/src/config.rs | 39 +++++++++++ app/igneum-app/src/device.rs | 95 +++++++++++++++++++++++++++ app/igneum-app/src/engine.rs | 8 +++ app/igneum-app/src/jobrun.rs | 6 ++ app/igneum-app/src/jobs.rs | 33 ++++++++++ app/igneum-app/src/manifest.rs | 57 ++++++++++++++-- app/igneum-app/src/ota.rs | 27 ++++++-- app/igneum-app/src/prover.rs | 81 ++++++++++++++++++++--- packaging/README-ship.md | 21 ++++++ packaging/hive/make-hive-package.sh | 13 ++-- packaging/mac/build-dmg.sh | 20 +++--- packaging/mac/packaged-config.sh | 52 ++++++++++++++- packaging/ota/publish-manifest.sh | 13 +++- packaging/windows/Igneum-Miner.iss | 42 ++++++++---- packaging/windows/build-installer.ps1 | 15 +++-- packaging/windows/make-payload.sh | 17 ++--- 16 files changed, 476 insertions(+), 63 deletions(-) diff --git a/app/igneum-app/src/config.rs b/app/igneum-app/src/config.rs index f862a9e99..2cc4762c3 100644 --- a/app/igneum-app/src/config.rs +++ b/app/igneum-app/src/config.rs @@ -344,6 +344,14 @@ fn machine_id(app_dir: &Path) -> String { /// Written by the packager (build-dmg.sh, make-payload.sh). Missing fields disable the feature. #[derive(Clone, Serialize, Deserialize, Default)] pub struct Packaged { + /// PRODUCT=public|lab (packaging/mac/packaged-config.sh, 8 October 2026): the edition the package was made for, its + /// channel and the signing root its engine must carry; empty in a package from before the switch. + #[serde(default)] + pub edition: String, + #[serde(default)] + pub channel: String, + #[serde(default)] + pub ota_root_hex: String, #[serde(default)] pub update_manifest: String, #[serde(default)] @@ -450,6 +458,22 @@ impl Packaged { self } + /// The words when the package was made for the other edition or another signing root (a public engine in a lab + /// package would never verify a lab manifest, and the other way round); None when the fields match or are absent. + pub fn edition_mismatch(&self) -> Option { + let mut faults = Vec::new(); + if !self.edition.is_empty() && self.edition != crate::edition::name() { + faults.push(format!("the package is the {} edition and this engine is the {} build", self.edition, crate::edition::name())); + } + if !self.channel.is_empty() && !crate::edition::channel_accepted(&self.channel) { + faults.push(format!("the package's channel {} is not this build's ({})", self.channel, crate::edition::channel())); + } + if !self.ota_root_hex.is_empty() && self.ota_root_hex != crate::edition::ota_key() { + faults.push(format!("the package's signing root {} is not this build's root {}", fingerprint8(&self.ota_root_hex), fingerprint8(crate::edition::ota_key()))); + } + if faults.is_empty() { None } else { Some(format!("package mismatch: {}; updates will not verify until the matching build is installed", faults.join("; "))) } + } + /// 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 { @@ -612,6 +636,21 @@ mod tests { out } + /// Known failed first (PRODUCT=public|lab, 8 October 2026): the packager writes the edition, the channel and the signing + /// root into igneum-app.json; an engine of the other build reports the mismatch in words (a public engine in a lab + /// package would never verify a lab manifest, and the other way round), and an older package without the fields says nothing. + #[test] + fn a_package_of_the_other_edition_is_named() { + let mine = Packaged { edition: crate::edition::name().into(), channel: crate::edition::channel().into(), ota_root_hex: crate::edition::ota_key().into(), ..Default::default() }; + assert_eq!(mine.edition_mismatch(), None); + assert_eq!(Packaged::default().edition_mismatch(), None, "a package without the fields says nothing"); + let other = Packaged { edition: "beta".into(), channel: "igneum-2.0-devnet-beta".into(), ota_root_hex: "ab".repeat(32), ..Default::default() }; + let w = other.edition_mismatch().expect("named"); + assert!(w.contains("beta") && w.contains(crate::edition::name()) && w.contains("root"), "{w}"); + let root_only = Packaged { edition: crate::edition::name().into(), ota_root_hex: "cd".repeat(32), ..Default::default() }; + assert!(root_only.edition_mismatch().unwrap().contains("signing root")); + } + #[test] fn a_measurement_engine_overrides_the_copied_settings_in_memory() { let mut s = Settings { remote_jobs: true, auto_update: true, prove: true, paused: true, sweep: false, power_control: true, address: "0xabc".into(), ..Default::default() }; diff --git a/app/igneum-app/src/device.rs b/app/igneum-app/src/device.rs index 34ef6608c..f13de09dd 100644 --- a/app/igneum-app/src/device.rs +++ b/app/igneum-app/src/device.rs @@ -136,6 +136,67 @@ impl Budget { } } +/// The job the host is admitted for (one per spawn): a shard proof, a segment aggregation, a whole chain run. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Workload { + Shard, + Aggregate, + Chain, +} + +impl Workload { + pub fn word(self) -> &'static str { + match self { + Workload::Shard => "shard", + Workload::Aggregate => "aggregate", + Workload::Chain => "chain", + } + } +} + +/// The exit code the host answers with when the budget is under its profile's floor (the V6-07 sub-lane's contract, +/// 8 October 2026): the engine records it on the lease and the card reads "proving needs N GB free". +pub const HOST_FLOOR_EXIT: i32 = 78; + +/// The five variables every igneum-prove-host spawn carries, exactly as the host reads them (the shipper's contract with +/// the V6-07 sub-lane, 8 October 2026): the card's ordinal as the host enumerates it, the one workload admitted, the free +/// memory the engine read at admission, the lease's grant (the host's hard ceiling) and the lease's deadline. +pub fn host_env(device: u32, workload: Workload, free_mib: u64, budget_mib: u64, deadline_s: u64) -> Vec<(&'static str, String)> { + vec![ + ("IGNEUM_PROVE_DEVICE", device.to_string()), + ("IGNEUM_PROVE_WORKLOAD", workload.word().to_string()), + ("IGNEUM_PROVE_MEM_FREE_MB", free_mib.to_string()), + ("IGNEUM_PROVE_MEM_BUDGET_MB", budget_mib.to_string()), + ("IGNEUM_PROVE_DEADLINE_S", deadline_s.to_string()), + ] +} + +/// The lease's grant for a proof on a card of `vram_mib` under `mode`: beside the miner the measured peak plus the +/// headroom; alone on the card everything above the free floor; nothing on a card that only mines. +pub fn proof_budget_mib(vram_mib: u64, mode: Mode) -> u64 { + match mode { + Mode::Simultaneous => PROOF_PEAK_MIB + PROOF_PEAK_MIB * HEADROOM_PCT / 100, + Mode::TimeShare | Mode::ProveOnly => vram_mib.saturating_sub(FREE_MIB), + Mode::MiningOnly => 0, + } +} + +/// The card's words after exit 78: the figure the host printed ("needs N GB" or "needs N MB"/"MiB"; the host's own line +/// is "RESULT memory_profile refused: proving needs N GB free on the card for the workload ( MiB +/// floor); MiB free") when it did, else the budget it was offered, in whole GB rounded up. +pub fn floor_refusal_words(out: &str, budget_mib: u64) -> String { + let printed = out.lines().rev().find_map(|l| { + let i = l.find("needs ")?; + let rest = &l[i + 6..]; + let n: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect(); + let n = n.parse::().ok().filter(|n| *n > 0)?; + let unit = rest[n.to_string().len()..].trim_start(); + Some(if unit.starts_with("GB") || unit.starts_with("GiB") { n } else { n.div_ceil(1024) }) + }); + let gb = printed.unwrap_or(budget_mib.div_ceil(1024)); + format!("proving needs {gb} GB free") +} + #[derive(Default, Debug)] pub struct Coordinator { leases: HashMap>, @@ -229,6 +290,40 @@ impl Coordinator { mod tests { use super::*; + /// Known failed first (the shipper's contract with the V6-07 sub-lane, 8 October 2026): every igneum-prove-host spawn + /// carries the five names, exactly as written, with the lease's figures; a spawn without them is the old shape. + #[test] + fn the_host_reads_its_lease_from_five_named_variables() { + let env = host_env(1, Workload::Shard, 11_800, 7_532, 600); + let names: Vec<&str> = env.iter().map(|(k, _)| *k).collect(); + assert_eq!(names, ["IGNEUM_PROVE_DEVICE", "IGNEUM_PROVE_WORKLOAD", "IGNEUM_PROVE_MEM_FREE_MB", "IGNEUM_PROVE_MEM_BUDGET_MB", "IGNEUM_PROVE_DEADLINE_S"]); + let values: Vec<&str> = env.iter().map(|(_, v)| v.as_str()).collect(); + assert_eq!(values, ["1", "shard", "11800", "7532", "600"]); + assert_eq!(host_env(0, Workload::Aggregate, 1, 2, 3)[1].1, "aggregate"); + assert_eq!(host_env(0, Workload::Chain, 1, 2, 3)[1].1, "chain"); + } + + /// The lease's grant per mode: the measured proof peak with headroom beside the miner; the card less the free floor + /// when the prover has the card to itself; nothing on a mining-only card. + #[test] + fn the_budget_is_the_grant_the_mode_allows() { + assert_eq!(proof_budget_mib(24_576, Mode::Simultaneous), PROOF_PEAK_MIB + PROOF_PEAK_MIB * HEADROOM_PCT / 100); + assert_eq!(proof_budget_mib(12_288, Mode::TimeShare), 12_288 - FREE_MIB); + assert_eq!(proof_budget_mib(8_192, Mode::ProveOnly), 8_192 - FREE_MIB); + assert_eq!(proof_budget_mib(8_192, Mode::MiningOnly), 0); + } + + /// Exit 78 from the host is the floor refusal: the card's words name the floor the host printed, else the budget. + #[test] + fn the_floor_refusal_names_the_memory_proving_needs() { + assert_eq!(HOST_FLOOR_EXIT, 78); + assert_eq!(floor_refusal_words("RESULT refused: profile needs 9216 MB free, 7532 offered", 7_532), "proving needs 9 GB free"); + // the host's own line (the V6-07 sub-lane, 8 October 2026): the GB figure is read as GB, not as MiB + assert_eq!(floor_refusal_words("RESULT memory_profile refused: proving needs 8 GB free on the card for the shard workload (7700 MiB floor); 6100 MiB free", 7_532), "proving needs 8 GB free"); + assert_eq!(floor_refusal_words("nothing useful", 7_532), "proving needs 8 GB free"); + assert_eq!(floor_refusal_words("", 12_000), "proving needs 12 GB free"); + } + #[test] fn the_modes_follow_the_measured_rows() { // nvidia-smi's figures: 32 GB 32,607; 24 GB 24,564; 16 GB 16,376; 12 GB 12,208; 8 GB 8,188; 6 GB 6,144 diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index 06ed8493e..42767721e 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -1042,6 +1042,12 @@ impl Engine { // 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()); + if let Some(w) = self.shared.packaged.edition_mismatch() { + self.shared.log(&w); + let mut st = self.shared.state.lock().unwrap(); + st.update.error = w; + st.update.status = "error".into(); + } // 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())); @@ -4174,6 +4180,8 @@ impl Engine { boundary_eta_s: if st.node.daa > 0 && st.program.boundary_daa > 0 { Some(st.program.eta_s) } else { None }, // a remote job in progress counts as busy: no update applies under it (src/jobrun.rs) miner_busy: self.miners.iter().any(|m| m.building) || st.mining.cards.iter().any(|c| c.enabled && c.state == "starting"), + // 2.0.2: no card mining or starting and nothing building: the staged update applies at once on a synced node + miner_idle: !self.miners.iter().any(|m| m.building) && !st.mining.cards.iter().any(|c| c.enabled && (c.state == "mining" || c.state == "starting")), // a remote job in progress holds the update, urgent or not (src/manifest.rs safe_to_apply; PC 1, 6 October 2026) job_active: self.jobs.active(), daa: st.node.daa, diff --git a/app/igneum-app/src/jobrun.rs b/app/igneum-app/src/jobrun.rs index 2c8141130..0e82eb1ff 100644 --- a/app/igneum-app/src/jobrun.rs +++ b/app/igneum-app/src/jobrun.rs @@ -1332,6 +1332,12 @@ fn run_script(shared: &Arc, job: &Job, sink: &Sink, jobs_dir: &Path, dat let dir = jobs_dir.join(&job.id); let shell = shell_for(job); let body = job.str_param("script").replace("\r\n", "\n"); + // the founder's word at 20:21 UK (8 October 2026): a body that ends a process by name never runs (the relay agent's + // Check-Task carries the same refusal, exit 77 with the matched line) + if let Some(line) = jobs::kill_by_name_line(&body) { + sink.line(&format!("refused: the script ends a process by name ({line}); a pid is the only way")); + return Ok(Done { status: "failed".into(), exit: 77, summary: format!("refused: the script ends a process by name ({line}); a pid is the only way"), extra: json!({ "refused": "kill_by_name", "line": line }) }); + } let script = dir.join(if shell == "powershell" { "script.ps1" } else { "script.sh" }); let text = if shell == "powershell" { body.replace('\n', "\r\n") } else { body }; std::fs::write(&script, if shell == "powershell" { [b"\xEF\xBB\xBF".as_slice(), text.as_bytes()].concat() } else { text.into_bytes() }).map_err(|e| format!("cannot write the script: {e}"))?; diff --git a/app/igneum-app/src/jobs.rs b/app/igneum-app/src/jobs.rs index 94d68b669..88cf03852 100644 --- a/app/igneum-app/src/jobs.rs +++ b/app/igneum-app/src/jobs.rs @@ -373,12 +373,36 @@ fn sha_ok(s: &str) -> bool { } /// Per-kind checks of the params, so a job that cannot run is refused at signing time. +/// The founder's word at 20:21 UK, 8 October 2026 (fifteen Mac processes killed by a grep that evening; by construction, +/// not by rule): a run-script body that ends a process by NAME is refused before it runs; a pid is the only way. The +/// shapes: `Stop-Process -Name`, `taskkill /IM`, `Get-Process -Name … | Stop-Process`, `pkill`, `killall`, as commands +/// (the first word of a line or of a pipeline stage), never as a word inside a string. Returns the matched line. +pub fn kill_by_name_line(script: &str) -> Option<&str> { + for raw in script.lines() { + let line = raw.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let lower = line.to_ascii_lowercase(); + let stage_starts = |word: &str| lower.split(['|', ';', '&']).any(|stage| { let st = stage.trim_start(); st == word || st.starts_with(&format!("{word} ")) || st.starts_with(&format!("{word}\t")) }); + let taskkill_im = lower.contains("taskkill") && lower.split_whitespace().any(|w| w == "/im"); + let stop_by_name = lower.contains("stop-process") && (lower.contains("-name") || lower.contains("get-process -name") || lower.contains("get-process ")); + if stage_starts("pkill") || stage_starts("killall") || taskkill_im || stop_by_name { + return Some(raw.trim()); + } + } + None +} + pub fn validate_params(job: &Job) -> Result<(), String> { match job.kind.as_str() { "run" => { if job.str_param("script").trim().is_empty() { return Err("run: params.script is empty".into()); } + if let Some(line) = kill_by_name_line(&job.str_param("script")) { + return Err(format!("run: the script ends a process by name ({line}); a pid is the only way (Stop-Process -Id, taskkill /PID, kill )")); + } let sh = job.str_param("shell"); if !sh.is_empty() && !["powershell", "bash"].contains(&sh.as_str()) { return Err(format!("run: shell '{sh}' is not powershell or bash")); @@ -860,6 +884,15 @@ mod tests { assert!(j("shard-benchmark", r#"{}"#).unwrap_err().contains("sha256")); assert!(j("shard-benchmark", &format!(r#"{{"sha256":"{}","fixtures":["../x"]}}"#, "b".repeat(64))).unwrap_err().contains("fixture")); assert!(j("run", r#"{"script":"ls","shell":"zsh"}"#).unwrap_err().contains("shell")); + // the founder's word at 20:21 UK, 8 October 2026 (by construction, not by rule): a run-script body that ends a + // process by name is refused before it runs; a pid is the only way. Known-failed first: every shape ran. + for body in ["Stop-Process -Name igneum-app -Force", "taskkill /IM igneum-app.exe /F", "Get-Process -Name igneumd | Stop-Process", "pkill -f igneumd", "killall igneum-worker", "echo ok\ntaskkill /f /im node.exe\necho done"] { + let e = j("run", &format!(r#"{{"script":{}}}"#, serde_json::Value::String(body.into()))).unwrap_err(); + assert!(e.contains("ends a process by name") && e.contains("pid"), "{body}: {e}"); + } + assert!(j("run", r#"{"script":"Stop-Process -Id 4242; taskkill /PID 4242 /F; kill -TERM $(cat run.pid)"}"#).is_ok(), "a pid is the way"); + assert_eq!(kill_by_name_line("echo one\nGet-Process -Name igneumd | Stop-Process\necho two"), Some("Get-Process -Name igneumd | Stop-Process")); + assert_eq!(kill_by_name_line("echo pkill is a word in a string, not a command: 'the pkill rule'"), None, "the shape, not the word"); } #[test] diff --git a/app/igneum-app/src/manifest.rs b/app/igneum-app/src/manifest.rs index 4585d30fc..47b3872fb 100644 --- a/app/igneum-app/src/manifest.rs +++ b/app/igneum-app/src/manifest.rs @@ -410,8 +410,18 @@ pub struct Moment { /// not, until the user presses Install now (`install_asked`) pub auto_update_off: bool, pub install_asked: bool, + /// 2.0.2 (the shipper, the founder's mini, 8 October 2026): no card is mining or starting; an idle or refused miner + /// is the strongest reason to apply, never a reason to wait. + pub miner_idle: bool, + /// How long the node has read synced, in seconds (0 when it does not). + pub synced_for_s: u64, } +/// With automatic updates on, a staged update applies within this many seconds of the node reading synced (and of the +/// staging, whichever is later), whatever the miner is doing: the machine's slot minute, the boundary guard and a +/// starting worker hold it no longer than this (2.0.2; the mini held 2.0.1 for its slot minute with an idle miner). +pub const SYNCED_APPLY_BOUND_S: u64 = 120; + /// No checkpoint lock for this long on a synced node = finality paused (the devnet locks every few minutes). pub const FINALITY_PAUSE_S: f64 = 15.0 * 60.0; @@ -430,19 +440,27 @@ pub fn safe_to_apply(m: &Moment) -> Result<(), String> { if m.auto_update_off && !m.install_asked { return Err("automatic updates are off: waiting for Install now".into()); } - // the finality rule next: a fork-close or unsupported urgency does not pass it, only the manifest's flag - if m.finality_paused && !m.manifest_urgent { + // the finality rule next: a fork-close or unsupported urgency does not pass it, only the manifest's flag or the + // operator's hand (Install now, 2.0.2: the held update can be the fix that restores the locks; the rule is for + // unattended machines) + if m.finality_paused && !m.manifest_urgent && !m.install_asked { return Err("waiting for finality: the network has not locked a checkpoint for 15 min; nothing installs on a chain that cannot lock".into()); } if m.job_active { return Err("a remote job is running; installing when it closes".into()); } - if m.urgent { + // the operator's Install now is urgent in its own right (the engine folds it into `urgent` too) + if m.urgent || m.install_asked { return Ok(()); } if m.network_drop_pct > NETWORK_DROP_HOLD_PCT { return Err(format!("the network lost {:.0}% of its identities in the last 10 minutes; holding the update", m.network_drop_pct)); } + // 2.0.2: a synced node with an idle miner applies at once; a mining one within SYNCED_APPLY_BOUND_S of the sync + // and the staging, slot or no slot, boundary or no boundary + if m.node_synced && (m.miner_idle || (m.synced_for_s >= SYNCED_APPLY_BOUND_S && m.ready_for_s >= SYNCED_APPLY_BOUND_S)) { + return Ok(()); + } if !m.slot_ok { return Err("waiting for this machine's own minute of the hour (machines take turns)".into()); } @@ -656,9 +674,37 @@ mod tests { assert!(!newer("0.3.1.2", "0.3.0")); } + /// Known failed first (the shipper, the founder's mini, 8 October 2026 20:30 to 20:40 UK): 2.0.1 staged at 20:30:22, + /// the node synced from 20:35, the miner idle the whole time, and nothing installed by 20:40 because the machine's + /// own minute of the hour had not come. The rule: with automatic updates on, a staged update applies within + /// SYNCED_APPLY_BOUND_S of the node reading synced, whatever the miner is doing; an idle or refused miner is the + /// strongest reason to apply, never a reason to wait. + #[test] + fn a_staged_update_applies_within_two_minutes_of_sync_and_at_once_on_an_idle_miner() { + let staged = Moment { node_synced: true, boundary_eta_s: Some(1800), miner_busy: false, job_active: false, ready_for_s: 300, urgent: false, slot_ok: false, network_drop_pct: 0.0, finality_paused: false, manifest_urgent: false, auto_update_off: false, install_asked: false, miner_idle: false, synced_for_s: 0 }; + assert_eq!(SYNCED_APPLY_BOUND_S, 120); + // the mini's case: node synced, miner idle, outside the slot: applies at once + assert!(safe_to_apply(&Moment { miner_idle: true, synced_for_s: 1, ..staged.clone() }).is_ok()); + // a mining miner: within the bound of the sync, outside the slot and inside the boundary guard, it still applies + assert!(safe_to_apply(&Moment { synced_for_s: SYNCED_APPLY_BOUND_S, boundary_eta_s: Some(30), ..staged.clone() }).is_ok()); + assert!(safe_to_apply(&Moment { synced_for_s: SYNCED_APPLY_BOUND_S - 1, ..staged.clone() }).unwrap_err().contains("own minute")); + // the bound counts from the later of the sync and the staging + assert!(safe_to_apply(&Moment { synced_for_s: 3600, ready_for_s: SYNCED_APPLY_BOUND_S - 1, ..staged.clone() }).is_err()); + assert!(safe_to_apply(&Moment { synced_for_s: 3600, ready_for_s: SYNCED_APPLY_BOUND_S, ..staged.clone() }).is_ok()); + // an unsynced node still waits, idle miner or not; a job, paused finality and updates-off still hold + assert!(safe_to_apply(&Moment { node_synced: false, miner_idle: true, synced_for_s: 0, ..staged.clone() }).is_err()); + assert!(safe_to_apply(&Moment { miner_idle: true, synced_for_s: 500, job_active: true, ..staged.clone() }).unwrap_err().contains("remote job")); + assert!(safe_to_apply(&Moment { miner_idle: true, synced_for_s: 500, finality_paused: true, ..staged.clone() }).unwrap_err().contains("finality")); + assert!(safe_to_apply(&Moment { miner_idle: true, synced_for_s: 500, auto_update_off: true, ..staged.clone() }).unwrap_err().contains("Install now")); + // 20:45 on the mini: the finality guard held the update that was the fix for the locks, and Install now could not + // pass it; the operator's hand outranks a rule for unattended machines (a fork-close urgency alone still does not) + assert!(safe_to_apply(&Moment { finality_paused: true, install_asked: true, ..staged.clone() }).is_ok()); + assert!(safe_to_apply(&Moment { finality_paused: true, urgent: true, install_asked: false, ..staged.clone() }).unwrap_err().contains("finality")); + } + #[test] fn safe_moments() { - let base = Moment { node_synced: true, boundary_eta_s: Some(1800), miner_busy: false, job_active: false, ready_for_s: 60, urgent: false, slot_ok: true, network_drop_pct: 0.0, finality_paused: false, manifest_urgent: false, auto_update_off: false, install_asked: false }; + let base = Moment { node_synced: true, boundary_eta_s: Some(1800), miner_busy: false, job_active: false, ready_for_s: 60, urgent: false, slot_ok: true, network_drop_pct: 0.0, finality_paused: false, manifest_urgent: false, auto_update_off: false, install_asked: false, miner_idle: false, synced_for_s: 60 }; assert!(safe_to_apply(&base).is_ok()); // the finality rule (Horizon frontier lane, 6 October 2026), the known-failed case: paused, no install; active, install; // paused with the manifest's urgent flag, install; paused with only a fork-close urgency, no install; patience never passes it @@ -687,7 +733,8 @@ mod tests { // patience: an unsynced node for 6 h applies anyway assert!(safe_to_apply(&Moment { node_synced: false, ready_for_s: SAFE_MOMENT_PATIENCE_S, ..base.clone() }).is_ok()); assert!(safe_to_apply(&Moment { node_synced: false, ready_for_s: SAFE_MOMENT_PATIENCE_S - 1, ..base.clone() }).is_err()); - // the machine's slot: outside it nothing applies, not even with patience; urgent ignores it + // the machine's slot: outside it nothing applies in the first SYNCED_APPLY_BOUND_S of a synced node (2.0.2; before + // that nothing applied outside it at all, not even with patience); urgent ignores it assert!(safe_to_apply(&Moment { slot_ok: false, ..base.clone() }).unwrap_err().contains("own minute")); assert!(safe_to_apply(&Moment { slot_ok: false, ready_for_s: SAFE_MOMENT_PATIENCE_S, ..base.clone() }).is_err()); assert!(safe_to_apply(&Moment { slot_ok: false, urgent: true, ..base.clone() }).is_ok()); diff --git a/app/igneum-app/src/ota.rs b/app/igneum-app/src/ota.rs index 250f8a2fe..c26796cd8 100644 --- a/app/igneum-app/src/ota.rs +++ b/app/igneum-app/src/ota.rs @@ -3,7 +3,7 @@ //! rule such as difficulty v2) reaches every node before its activation height. //! //! The loop, driven from the engine's tick: -//! check (on start, then hourly with jitter): fetch igneum-app-latest.json and its .sig, verify the Ed25519 +//! check (on start, then every ten minutes with jitter (2.0.2; hourly before)): fetch igneum-app-latest.json and its .sig, verify the Ed25519 //! signature with the key compiled into src/manifest.rs, parse, compare versions //! -> download (curl with resume into /app/updates/, then size and sha256 against the manifest) //! -> stage (macOS: mount the DMG or unpack the zip, copy the new bundle next to the running one, check its @@ -24,7 +24,7 @@ //! the previous version (macOS: the .previous bundle; Windows: the previous installer kept in updates/). //! //! Environment (tests): IGNEUM_APP_UPDATE_MANIFEST overrides the manifest URL from igneum-app.json, -//! IGNEUM_APP_UPDATE_CHECK_SECS the hourly interval, IGNEUM_APP_UPDATE_FIRST_SECS the delay of the first check. +//! IGNEUM_APP_UPDATE_CHECK_SECS the check interval (600 s), IGNEUM_APP_UPDATE_FIRST_SECS the delay of the first check. use crate::engine::{Cmd, Shared}; use crate::manifest::{self, Manifest, Moment, PlatformEntry}; @@ -37,7 +37,7 @@ use std::time::{Duration, Instant}; /// A manifest published this long before the engine started is a catch-up: the hourly rollout slot does not apply. const CATCH_UP_AFTER_S: u64 = 3600; const HEALTHY_AFTER_S: u64 = 90; -const CHECK_EVERY_S: u64 = 3600; +const CHECK_EVERY_S: u64 = 600; const RETRY_AFTER_ERROR_S: u64 = 600; pub enum Event { @@ -72,6 +72,8 @@ pub struct Ctx { pub finality_paused: bool, pub boundary_eta_s: Option, pub miner_busy: bool, + /// No card mines or starts (2.0.2): a staged update applies at once on a synced node. + pub miner_idle: bool, /// A remote job is running (src/jobrun.rs): the install holds, urgent or not. pub job_active: bool, pub daa: u64, @@ -102,6 +104,8 @@ pub struct Updater { busy: bool, next_check: Instant, ready_since: Option, + /// when the node last went from unsynced to synced (None while it is not): manifest::SYNCED_APPLY_BOUND_S counts from it + synced_since: Option, last_safe_check: Instant, install_asked: bool, pending: Option, @@ -165,6 +169,7 @@ impl Updater { busy: false, next_check: now + Duration::from_secs(first), ready_since: None, + synced_since: None, last_safe_check: now, install_asked: false, pending: None, @@ -614,8 +619,14 @@ impl Updater { } let slot_ok = minute == self.slot || catch_up || std::env::var("IGNEUM_APP_UPDATE_NO_SLOT").map(|v| v == "1").unwrap_or(false); let ready_for = self.ready_since.map(|t| now.duration_since(t).as_secs()).unwrap_or(0); + if ctx.node_synced { + self.synced_since.get_or_insert(now); + } else { + self.synced_since = None; + } + let synced_for = self.synced_since.map(|t| now.duration_since(t).as_secs()).unwrap_or(0); let manifest_urgent = self.manifest.as_ref().map(|m| m.urgent).unwrap_or(false); - let moment = Moment { node_synced: ctx.node_synced, boundary_eta_s: ctx.boundary_eta_s, miner_busy: ctx.miner_busy, job_active: ctx.job_active, ready_for_s: ready_for, urgent: urgent || self.install_asked, slot_ok, network_drop_pct , auto_update_off: !self.auto, install_asked: self.install_asked, finality_paused: ctx.finality_paused, manifest_urgent }; + let moment = Moment { node_synced: ctx.node_synced, boundary_eta_s: ctx.boundary_eta_s, miner_busy: ctx.miner_busy, job_active: ctx.job_active, ready_for_s: ready_for, urgent: urgent || self.install_asked, slot_ok, network_drop_pct , auto_update_off: !self.auto, install_asked: self.install_asked, finality_paused: ctx.finality_paused, manifest_urgent, miner_idle: ctx.miner_idle, synced_for_s: synced_for }; if !self.auto && !self.install_asked { // F14: off stays off, urgent or not; an unsupported version pauses mining (the engine reads update.hold_mining) let mut st = shared.state.lock().unwrap(); @@ -1248,6 +1259,14 @@ pub fn legacy_return_sequence(installer_exit: i32) -> Vec { #[cfg(test)] mod return_tests { use super::*; + /// Known failed first (8 October 2026, 2.0.2): the hourly check made a user wait up to an hour for an entry the + /// fleet published (the 2.0.1 entry reached a machine after 14 minutes only because its api was poked). + /// Ten minutes, the first check after start unchanged, the staged update still applied at the next safe moment. + #[test] + fn the_manifest_check_runs_every_ten_minutes() { + assert_eq!(CHECK_EVERY_S, 600); + assert_eq!(CHECK_EVERY_S / 6 + 1, 101, "the jitter window stays a sixth of the interval"); + } fn done(steps: &[ReturnStep]) -> &ReturnStep { steps.last().unwrap() } diff --git a/app/igneum-app/src/prover.rs b/app/igneum-app/src/prover.rs index 91898b7b3..cdead2c8f 100644 --- a/app/igneum-app/src/prover.rs +++ b/app/igneum-app/src/prover.rs @@ -31,6 +31,10 @@ use std::process::Command; use std::sync::Arc; use std::time::{Duration, Instant}; +/// The lease deadline a shard proof is given (the F07 budget row's clock: transfer, start, prove and rebuild inside +/// the exclusive window); a chain run and an aggregation carry their run limits. +const SHARD_DEADLINE_S: u64 = 600; + /// One shard the node lists for this machine's keys (`igneum_getAssignedShards`). #[derive(Clone, Debug, PartialEq, Eq)] pub struct Work { @@ -247,6 +251,42 @@ fn read_verifier(shared: &Shared) { /// every second and killed when the app quits, the setting goes off or `limit` passes (a proof must never outlive /// the app). Returns (exit ok, output). fn run_tool(shared: &Shared, t: &Tools, exe: &Path, args: &[String], env: &[(&str, &str)], limit: Duration, log: &Path) -> (bool, String) { + let (code, out) = run_tool_code(shared, t, exe, args, env, limit, log); + (code == Some(0), out) +} + +/// The lease every igneum-prove-host spawn carries (the V6-07 sub-lane's contract, src/device.rs host_env): the first +/// NVIDIA card as the host enumerates it, the free memory read now, the grant the card's mode allows, the deadline. +/// (free, budget, env) so the refusal words can name the budget; a machine without an NVIDIA card gets device 0 and +/// no grant, which the host reads as the CPU shape. +fn host_lease(shared: &Shared, workload: crate::device::Workload, deadline_s: u64) -> (u64, u64, Vec<(&'static str, String)>) { + let (index, vram, mining) = { + let st = shared.state.lock().unwrap(); + st.mining.cards.iter().filter(|c| c.vendor == "nvidia").min_by_key(|c| c.index).map(|c| (c.index as u32, c.vram_mb, c.enabled && c.state == "mining")).unwrap_or((0, 0, false)) + }; + let used = crate::detect::nvidia_memory_used().get(&index.to_string()).copied().unwrap_or(0); + let free = vram.saturating_sub(used); + let budget = crate::device::proof_budget_mib(vram, crate::device::mode(vram, mining)); + let env = crate::device::host_env(index, workload, free, budget, deadline_s); + shared.log(&format!("LEASE holder=prover device=nvidia:{index} workload={} free_mib={free} budget_mib={budget} deadline_s={deadline_s}", workload.word())); + (free, budget, env) +} + +/// The host's floor refusal (exit 78): the card's words, the LEASE line with the refusal, Some(words); None otherwise. +fn host_floor_refusal(shared: &Shared, code: Option, out: &str, budget: u64) -> Option { + if code != Some(crate::device::HOST_FLOOR_EXIT) { + return None; + } + let words = crate::device::floor_refusal_words(out, budget); + shared.log(&format!("LEASE holder=prover event=refused exit={} budget_mib={budget} words=\"{words}\"", crate::device::HOST_FLOOR_EXIT)); + set(shared, |p| { + p.status = "waiting".into(); + p.message = words.clone(); + }); + Some(words) +} + +fn run_tool_code(shared: &Shared, t: &Tools, exe: &Path, args: &[String], env: &[(&str, &str)], limit: Duration, log: &Path) -> (Option, String) { // Windows: a script file exports the environment and execs the host; the arguments travel as $1, $2... (the // single-quoted rule of src/wslhost.rs). The file lives until the run ends. let (mut cmd, _script) = if t.wsl { @@ -254,7 +294,7 @@ fn run_tool(shared: &Shared, t: &Tools, exe: &Path, args: &[String], env: &[(&st body.push_str(&format!("exec {} \"$@\"", crate::wslhost::sq(&exe.display().to_string()))); let file = match crate::wslhost::write_script("prove-run", &body) { Ok(f) => f, - Err(e) => return (false, format!("cannot write the WSL run script: {e}")), + Err(e) => return (None, format!("cannot write the WSL run script: {e}")), }; let argv: Vec<&str> = args.iter().map(|a| a.as_str()).collect(); let c = crate::wslhost::command(&crate::platform::tool("wsl"), crate::wslhost::DISTRO, None, &file.path, true, &argv); @@ -268,12 +308,12 @@ fn run_tool(shared: &Shared, t: &Tools, exe: &Path, args: &[String], env: &[(&st (c, None) }; crate::platform::quiet(&mut cmd); - let Ok(file) = std::fs::File::create(log) else { return (false, format!("cannot write {}", log.display())) }; - let Ok(err) = file.try_clone() else { return (false, "cannot clone the log handle".into()) }; + let Ok(file) = std::fs::File::create(log) else { return (None, format!("cannot write {}", log.display())) }; + let Ok(err) = file.try_clone() else { return (None, "cannot clone the log handle".into()) }; cmd.stdin(std::process::Stdio::null()).stdout(file).stderr(err); let mut child = match cmd.spawn() { Ok(c) => c, - Err(e) => return (false, format!("{}: {e}", exe.display())), + Err(e) => return (None, format!("{}: {e}", exe.display())), }; let started = Instant::now(); let status = loop { @@ -282,7 +322,7 @@ fn run_tool(shared: &Shared, t: &Tools, exe: &Path, args: &[String], env: &[(&st Ok(None) => {} Err(e) => { let _ = child.kill(); - return (false, format!("{}: {e}", exe.display())); + return (None, format!("{}: {e}", exe.display())); } } let stop = { @@ -298,8 +338,8 @@ fn run_tool(shared: &Shared, t: &Tools, exe: &Path, args: &[String], env: &[(&st }; let out = std::fs::read_to_string(log).unwrap_or_default(); match status { - Some(st) => (st.success(), out), - None => (false, format!("{} stopped after {} s (quit, proving switched off, or the {} s limit)", exe.display(), started.elapsed().as_secs(), limit.as_secs())), + Some(st) => (st.code().or(Some(-1)), out), + None => (None, format!("{} stopped after {} s (quit, proving switched off, or the {} s limit)", exe.display(), started.elapsed().as_secs(), limit.as_secs())), } } @@ -774,7 +814,14 @@ fn loop_forever(shared: Arc, bin_dir: PathBuf) { } set(&shared, |p| p.message = if t.cuda { "proving on the GPU".into() } else { "CPU prover: about five minutes a shard, 30 GB of RAM, paid only when no card proves first".into() }); let prover_env = if t.cuda { "cuda" } else { "cpu" }; - let (ok, out) = run_tool(&shared, t, &t.host, &[fix_p, "--mode".into(), "compressed".into(), "--shard".into(), w.shard.to_string(), "--prover".into(), payout.clone(), "--out".into(), res_p], &[("SP1_PROVER", prover_env), ("RUST_LOG", "off")], Duration::from_secs(3 * 3600), &dir.join(format!("prove-{}-{}.log", w.number, w.shard))); + let (_free, budget, lease) = host_lease(&shared, crate::device::Workload::Shard, SHARD_DEADLINE_S); + let mut env: Vec<(&str, &str)> = vec![("SP1_PROVER", prover_env), ("RUST_LOG", "off")]; + env.extend(lease.iter().map(|(k, v)| (*k, v.as_str()))); + let (code, out) = run_tool_code(&shared, t, &t.host, &[fix_p, "--mode".into(), "compressed".into(), "--shard".into(), w.shard.to_string(), "--prover".into(), payout.clone(), "--out".into(), res_p], &env, Duration::from_secs(3 * 3600), &dir.join(format!("prove-{}-{}.log", w.number, w.shard))); + if let Some(words) = host_floor_refusal(&shared, code, &out, budget) { + return Err(format!("prover: {words}")); + } + let ok = code == Some(0); if !ok || !results.exists() { let last = out.lines().rev().find(|l| l.contains("RESULT") || l.contains("rror")).unwrap_or("failed").to_string(); // the root-socket class (5 October 2026, PC 2 at 20:00Z and 21:25Z): a job that ran the host as root @@ -975,7 +1022,14 @@ fn prove_segment(shared: &Shared, t: &Tools, seg: &crate::segments::SegmentWork, args.push("--prev".into()); args.push(pf.to_string()); } - let (ok, out) = run_tool(shared, t, &t.host, &args, &[("SP1_PROVER", if t.cuda { "cuda" } else { "cpu" }), ("RUST_LOG", "off")], Duration::from_secs(3 * 3600), &dir.join("chain.log")); + let (_free, budget, lease) = host_lease(shared, crate::device::Workload::Chain, 3 * 3600); + let mut env: Vec<(&str, &str)> = vec![("SP1_PROVER", if t.cuda { "cuda" } else { "cpu" }), ("RUST_LOG", "off")]; + env.extend(lease.iter().map(|(k, v)| (*k, v.as_str()))); + let (code, out) = run_tool_code(shared, t, &t.host, &args, &env, Duration::from_secs(3 * 3600), &dir.join("chain.log")); + if let Some(words) = host_floor_refusal(shared, code, &out, budget) { + return Err(format!("chain: {words}")); + } + let ok = code == Some(0); if !ok || !results.exists() { let last_line = out.lines().rev().find(|l| l.contains("RESULT") || l.contains("rror")).unwrap_or("failed").to_string(); let hint = if last_line.contains("PermissionDenied") { " (a GPU-server socket /tmp/sp1-cuda-*.sock owned by another user: the root-socket class)" } else { "" }; @@ -1139,7 +1193,14 @@ fn aggregate_once(shared: &Shared, t: &Tools, label: &str, payout: &str, attempt args.push("--prev".into()); args.push(pf); } - let (ok, out) = run_tool(shared, t, &t.host, &args, &[("SP1_PROVER", if t.cuda { "cuda" } else { "cpu" }), ("RUST_LOG", "off")], Duration::from_secs(2 * 3600), &dir.join("aggregate.log")); + let (_free, budget, lease) = host_lease(shared, crate::device::Workload::Aggregate, 2 * 3600); + let mut env: Vec<(&str, &str)> = vec![("SP1_PROVER", if t.cuda { "cuda" } else { "cpu" }), ("RUST_LOG", "off")]; + env.extend(lease.iter().map(|(k, v)| (*k, v.as_str()))); + let (code, out) = run_tool_code(shared, t, &t.host, &args, &env, Duration::from_secs(2 * 3600), &dir.join("aggregate.log")); + if let Some(words) = host_floor_refusal(shared, code, &out, budget) { + return Err(format!("segment {first}..{last}: {words}")); + } + let ok = code == Some(0); if !ok || !results.exists() { return Err(format!("segment {first}..{last}: aggregator: {}", out.lines().rev().find(|l| l.contains("RESULT") || l.contains("rror")).unwrap_or("failed"))); } diff --git a/packaging/README-ship.md b/packaging/README-ship.md index a4c54d867..5db8b4268 100644 --- a/packaging/README-ship.md +++ b/packaging/README-ship.md @@ -135,3 +135,24 @@ One tree, two builds, chosen by the cargo feature `lab` on `app/igneum-app` (`sr Each build refuses a manifest of the other channel before staging it. The engine reports `edition`, `product` and `channel` in `api/state` and `channel=`/`edition=` on the IGNEUM-APP intake line, so the relay agent and the jobs publisher refuse a public engine by what it says about itself. `tools/build-remote.sh` passes `--features lab` through its cargo arguments; the variable travels with the shipper's build environment (`bs_repro_env`). + +### PRODUCT=public|lab (the founder's word at 20:21 UK, 8 October 2026) + +One variable, read by every packager through `packaging/mac/packaged-config.sh` (the table lives there and nowhere else): + +| | public (default) | lab | +|---|---|---| +| name | Igneum Miner | Igneum Miner Lab | +| channel | igneum-2.0-devnet | igneum-2.0-devnet-lab | +| manifest | dl//igneum-app-latest.json | dl//igneum-app-lab-latest.json | +| signing root | the release root (src/manifest.rs) | IGNEUM_LAB_PUBLIC_KEY or ~/.config/igneum/lab-signing/lab-signing-key.pub (refused when neither holds 64 hex) | +| Mac | Igneum Miner.app, network.igneum.miner, Igneum-Miner-.dmg | Igneum Miner Lab.app, network.igneum.miner.lab, Igneum-Miner-Lab-.dmg | +| Windows | AppId {A4C1F0E2-...}, Igneum-Miner-Setup-.exe, payload igneum-windows-app | AppId {5E2B7C41-...}, Igneum-Miner-Lab-Setup-.exe, payload igneum-windows-app-lab | +| Hive | igneum-hive-.tar.gz, CUSTOM_NAME=igneum | igneum-lab-hive-.tar.gz, CUSTOM_NAME=igneum-lab | + +`PRODUCT=lab packaging/mac/build-dmg.sh`, `PRODUCT=lab packaging/windows/make-payload.sh`, `PRODUCT=lab packaging/hive/make-hive-package.sh`, +and on the PC `build-installer.ps1 -Product lab` (or `$env:PRODUCT`), which passes `/DProduct=lab` to Inno. The engine inside a +lab package must be the lab build (`--features lab`): the packager writes `edition`, `channel` and `ota_root_hex` into +igneum-app.json and the engine reports a mismatch on its update card (`Packaged::edition_mismatch`), since the other +build would never verify that package's manifest. The two products share %LOCALAPPDATA%\igneum and the engine port on +one PC: a lab install belongs on a lab machine, not beside the public one. Hive has no update manifest (no OTA on a rig). diff --git a/packaging/hive/make-hive-package.sh b/packaging/hive/make-hive-package.sh index ece6c8ee8..df0be9faa 100755 --- a/packaging/hive/make-hive-package.sh +++ b/packaging/hive/make-hive-package.sh @@ -13,9 +13,12 @@ NODE_OUT="${NODE_OUT:-$REPO/infra/cross/out-v2}" WORKERS_OUT="${WORKERS_OUT:-$REPO/infra/cross/out-workers}" OUT="${OUT:-$HERE/build}" FAKE=0; [[ "${1:-}" == "--fake" ]] && FAKE=1 +# PRODUCT=public|lab (packaging/mac/packaged-config.sh): the Hive custom-miner name is the slug (igneum or igneum-lab), so +# the archive, the directory inside it, the config and the log paths are the product's own +. "$REPO/packaging/mac/packaged-config.sh" log() { printf '%s %s\n' "$(date -u +%H:%M:%S)" "$*"; } die() { log "ERROR: $*" >&2; exit 1; } -stage="$OUT/igneum"; rm -rf "$stage"; mkdir -p "$stage/bin" +stage="$OUT/$PRODUCT_SLUG"; rm -rf "$stage"; mkdir -p "$stage/bin" if [[ $FAKE == 1 ]]; then VERSION="${VERSION:-0.0.0-fake}" for b in igneumd igneum-miner igneum-worker-cuda igneum-worker-opencl; do printf '#!/bin/sh\necho fake %s "$@"\n' "$b" > "$stage/bin/$b"; chmod +x "$stage/bin/$b"; done @@ -35,17 +38,17 @@ else fi [[ -n "$VERSION" ]] || die "no version (VERSION=... or a version.txt with 'igneumd ')" cp "$HERE/h-config.sh" "$HERE/h-run.sh" "$HERE/h-stats.sh" "$HERE/README.md" "$stage/" -sed "s/^CUSTOM_VERSION=.*/CUSTOM_VERSION=$VERSION/" "$HERE/h-manifest.conf" > "$stage/h-manifest.conf" +sed -e "s/^CUSTOM_VERSION=.*/CUSTOM_VERSION=$VERSION/" -e "s/^CUSTOM_NAME=.*/CUSTOM_NAME=$PRODUCT_SLUG/" "$HERE/h-manifest.conf" > "$stage/h-manifest.conf" chmod +x "$stage"/h-*.sh "$stage"/bin/* for f in "$stage"/h-*.sh; do bash -n "$f"; done -tgz="$OUT/igneum-hive-$VERSION.tar.gz" -COPYFILE_DISABLE=1 tar -C "$OUT" --no-xattrs --no-mac-metadata -czf "$tgz" igneum 2>/dev/null || COPYFILE_DISABLE=1 tar -C "$OUT" -czf "$tgz" igneum +tgz="$OUT/$PRODUCT_SLUG-hive-$VERSION.tar.gz" +COPYFILE_DISABLE=1 tar -C "$OUT" --no-xattrs --no-mac-metadata -czf "$tgz" "$PRODUCT_SLUG" 2>/dev/null || COPYFILE_DISABLE=1 tar -C "$OUT" -czf "$tgz" "$PRODUCT_SLUG" rm -rf "$stage" sum="$(shasum -a 256 "$tgz" 2>/dev/null | awk '{print $1}' || sha256sum "$tgz" | awk '{print $1}')" log "wrote $tgz ($(du -h "$tgz" | cut -f1), sha256 $sum)" cat </igneum-hive-$VERSION.tar.gz (publish the archive on the download host) + Installation URL https:///$PRODUCT_SLUG-hive-$VERSION.tar.gz (publish the archive on the download host) Miner name igneum Wallet and worker 0x.%WORKER_NAME% Pool URL grpc://:26610 or local (the bundled node on the rig) diff --git a/packaging/mac/build-dmg.sh b/packaging/mac/build-dmg.sh index 656dab1e5..56c91679a 100755 --- a/packaging/mac/build-dmg.sh +++ b/packaging/mac/build-dmg.sh @@ -35,12 +35,13 @@ REBUILD_WORKER="${REBUILD_WORKER:-1}" ICONS="$ROOT/brand/icons" BUILD="$HERE/build" DIST="$HERE/dist" -DMG="$DIST/Igneum-Miner-$VERSION.dmg" -STAGE="$BUILD/dmg" -APP="$STAGE/Igneum Miner.app" -STAMP="$(date -u +%Y%m%d%H%M)" export PATH="$HOME/.cargo/bin:/opt/homebrew/bin:$PATH" +# PRODUCT=public|lab (packaged-config.sh): the app name, the dmg name, the volume, the bundle id . "$HERE/packaged-config.sh" +DMG="$DIST/$PRODUCT_FILE-$VERSION.dmg" +STAGE="$BUILD/dmg" +APP="$STAGE/$PRODUCT_NAME.app" +STAMP="$(date -u +%Y%m%d%H%M)" if [ ! -x "$NODE" ]; then if [ -x "$ROOT/vendor/igneum-node/target/release/kaspad" ]; then @@ -94,6 +95,9 @@ sed -e "s/VERSION_STAMP/$STAMP/" "$HERE/app/Info.plist" > "$APP/Contents/Info.pl # the short version is $VERSION whatever the template says (tools/ship-app.mjs keeps the template equal to Cargo.toml; # the sed that replaced a literal 0.3.0 here stopped matching at 0.3.1 and the bundles said 0.3.2 after that) plutil -replace CFBundleShortVersionString -string "$VERSION" "$APP/Contents/Info.plist" +plutil -replace CFBundleIdentifier -string "$PRODUCT_BUNDLE_ID" "$APP/Contents/Info.plist" +plutil -replace CFBundleName -string "$PRODUCT_NAME" "$APP/Contents/Info.plist" +plutil -replace CFBundleDisplayName -string "$PRODUCT_NAME" "$APP/Contents/Info.plist" plutil -lint "$APP/Contents/Info.plist" >/dev/null printf 'APPL????' > "$APP/Contents/PkgInfo" cp "$BUILD/window/Igneum Miner" "$APP/Contents/MacOS/Igneum Miner" @@ -130,17 +134,17 @@ v="$("$APP/Contents/MacOS/igneum-app" --version 2>&1 || true)"; case "$v" in "ig # the rest of the image cp "$HERE/dmg/README.txt" "$STAGE/README.txt" -cp "$HERE/app/Stop Igneum Miner.command" "$STAGE/Stop Igneum Miner.command" -chmod 755 "$STAGE/Stop Igneum Miner.command" +cp "$HERE/app/Stop Igneum Miner.command" "$STAGE/Stop $PRODUCT_NAME.command" +chmod 755 "$STAGE/Stop $PRODUCT_NAME.command" rm -f "$DMG" if command -v dmgbuild >/dev/null 2>&1; then - dmgbuild -s "$HERE/dmg/settings.py" -D "app=$APP" -D "stage=$STAGE" -D "icons=$ICONS" "Igneum Miner" "$DMG" + dmgbuild -s "$HERE/dmg/settings.py" -D "app=$APP" -D "stage=$STAGE" -D "icons=$ICONS" "$PRODUCT_NAME" "$DMG" else echo "note: dmgbuild is not installed (pip3 install dmgbuild); building a plain image without icon positions or background" ln -s /Applications "$STAGE/Applications" cp "$ICONS/igneum-volume.icns" "$STAGE/.VolumeIcon.icns" - hdiutil create -volname "Igneum Miner" -srcfolder "$STAGE" -ov -format ULFO -fs HFS+ "$DMG" >/dev/null + hdiutil create -volname "$PRODUCT_NAME" -srcfolder "$STAGE" -ov -format ULFO -fs HFS+ "$DMG" >/dev/null fi hdiutil verify "$DMG" >/dev/null echo "built $DMG ($(du -h "$DMG" | cut -f1), $(stat -f %z "$DMG") bytes, version $VERSION build $STAMP)" diff --git a/packaging/mac/packaged-config.sh b/packaging/mac/packaged-config.sh index 236fee1f0..9e7ee8b92 100644 --- a/packaging/mac/packaged-config.sh +++ b/packaging/mac/packaged-config.sh @@ -22,6 +22,38 @@ LOG_URL="${IGNEUM_INTAKE_URL:-https://igneum-six.vercel.app/api/log}" LIVE_PAGE="https://igneum.network/live" DOWNLOAD_PAGE="https://igneum.network/#mine" DL_HOST="https://dl.igneum.network" + +# ---- PRODUCT=public|lab (the founder's word at 20:21 UK, 8 October 2026): ONE variable the packagers read ------------- +# public: "Igneum Miner", the release root, channel igneum-2.0-devnet, manifest igneum-app-latest.json. +# lab: "Igneum Miner Lab", the lab signing root (IGNEUM_LAB_PUBLIC_KEY or ~/.config/igneum/lab-signing/lab-signing-key.pub, +# the hex only, never the private key), channel igneum-2.0-devnet-lab, manifest igneum-app-lab-latest.json in the +# same downloads folder, its own bundle id, installer AppId, install folder and package names, so a lab install +# never updates from, or over, the public one. The engine built for the package must be the matching build +# (cargo --features lab for lab): the engine reads the edition and the root hex back from igneum-app.json and +# refuses a mismatch (src/config.rs Packaged::edition_mismatch). +PRODUCT="${PRODUCT:-public}" +# the release root the engine compiles in (app/igneum-app/src/manifest.rs OTA_PUBLIC_KEY_HEX); the lab root is read, never written here +PUBLIC_OTA_ROOT_HEX="b3c9c5bd144e9d246dc0edf897387d4f3f7ca494cd47457123d1c2f892cabddd" +case "$PRODUCT" in + public) + PRODUCT_NAME="Igneum Miner"; PRODUCT_FILE="Igneum-Miner"; PRODUCT_SLUG="igneum" + PRODUCT_BUNDLE_ID="network.igneum.miner"; PRODUCT_CHANNEL="igneum-2.0-devnet"; PRODUCT_MANIFEST_NAME="igneum-app-latest.json" + PRODUCT_WINDOWS_APPID="{A4C1F0E2-6B8D-4E7A-9F31-2C5D8E7B9A01}"; PRODUCT_PAYLOAD_DIR="igneum-windows-app" ;; + lab) + PRODUCT_NAME="Igneum Miner Lab"; PRODUCT_FILE="Igneum-Miner-Lab"; PRODUCT_SLUG="igneum-lab" + PRODUCT_BUNDLE_ID="network.igneum.miner.lab"; PRODUCT_CHANNEL="igneum-2.0-devnet-lab"; PRODUCT_MANIFEST_NAME="igneum-app-lab-latest.json" + PRODUCT_WINDOWS_APPID="{5E2B7C41-9D3A-4F06-B8E7-61C4A2D9F0B3}"; PRODUCT_PAYLOAD_DIR="igneum-windows-app-lab" ;; + *) echo "PRODUCT must be public or lab (got '$PRODUCT')" >&2; exit 2 ;; +esac +# igneum_ota_root_hex -> the signing root the package's engine must carry: the release root, or the lab root from the +# variable or the key file (64 hex); empty with a note when a lab package has no root to read +igneum_ota_root_hex() { + if [ "$PRODUCT" = "public" ]; then printf '%s' "$PUBLIC_OTA_ROOT_HEX"; return 0; fi + local hex="${IGNEUM_LAB_PUBLIC_KEY:-}" + [ -n "$hex" ] || hex="$(igneum_read_trimmed "${IGNEUM_CONFIG_DIR:-$HOME/.config/igneum}/lab-signing/lab-signing-key.pub")" + printf '%s' "$hex" | grep -qE '^[0-9a-f]{64}$' || { echo "lab package: no lab root (IGNEUM_LAB_PUBLIC_KEY or ~/.config/igneum/lab-signing/lab-signing-key.pub, 64 hex)" >&2; return 1; } + printf '%s' "$hex" +} # Consensus parameters pinned into the package for the bundled node: igneum-app.json "node_override_params"; the engine # writes them to /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 @@ -55,7 +87,7 @@ igneum_read_trimmed() { } # igneum_manifest_url -> 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 + [ -n "$1" ] && printf '%s/dl/%s/%s' "$DL_HOST" "$1" "$PRODUCT_MANIFEST_NAME" || true } # igneum_fingerprint -> the first 8 hex of sha256 over the value, for logs and the app's header (never the value) igneum_fingerprint() { @@ -64,7 +96,9 @@ igneum_fingerprint() { # 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="" key="" key_file="" token_file="" + local out="$1" token="" manifest="" key="" key_file="" token_file="" root="" + root="$(igneum_ota_root_hex)" || return 1 + echo "product: $PRODUCT ($PRODUCT_NAME, channel $PRODUCT_CHANNEL, root fingerprint $(igneum_fingerprint "$root"))" 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")" @@ -83,6 +117,10 @@ write_packaged_config() { { $override_line $network_lines + "edition": "$PRODUCT", + "product": "$PRODUCT_NAME", + "channel": "$PRODUCT_CHANNEL", + "ota_root_hex": "$root", "update_manifest": "$manifest", "log_intake_url": "$LOG_URL", "log_intake_key": "$key", @@ -95,6 +133,8 @@ 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 + # --lab-probe : writes one config with the environment's PRODUCT (the self-test's child for the lab rows) + if [ "${1:-}" = "--lab-probe" ]; then write_packaged_config "$2"; exit $?; fi [ "${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 @@ -146,5 +186,13 @@ if [ "${BASH_SOURCE[0]}" = "$0" ]; then # 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" + # 9. PRODUCT=public|lab (8 October 2026): the table, the manifest name, the root, the refusal without a lab root + check "public edition lands" "$("$PY" -c 'import json,sys; j=json.load(open(sys.argv[1])); print(j["edition"], j["product"], j["channel"], j["ota_root_hex"])' "$T/a.json")" "public Igneum Miner igneum-2.0-devnet $PUBLIC_OTA_ROOT_HEX" + lab_out="$(PRODUCT=lab IGNEUM_LAB_PUBLIC_KEY="$(printf 'ab%.0s' $(seq 32))" bash "${BASH_SOURCE[0]}" --lab-probe "$T/lab.json" 2>&1)" + check "lab edition lands" "$("$PY" -c 'import json,sys; j=json.load(open(sys.argv[1])); print(j["edition"], j["product"], j["channel"], j["ota_root_hex"][:6])' "$T/lab.json")" "lab Igneum Miner Lab igneum-2.0-devnet-lab ababab" + check "lab manifest has its own name" "$("$PY" -c 'import json,sys; print(json.load(open(sys.argv[1]))["update_manifest"])' "$T/lab.json")" "$DL_HOST/dl/tok2new/igneum-app-lab-latest.json" + check "lab output never carries the root" "$(printf '%s' "$lab_out" | grep -c 'abababab')" "0" + check "lab without a root is refused" "$(PRODUCT=lab IGNEUM_LAB_PUBLIC_KEY= IGNEUM_CONFIG_DIR="$T/cfg" bash "${BASH_SOURCE[0]}" --lab-probe "$T/lab2.json" >/dev/null 2>&1; echo $?)" "1" + check "a bad PRODUCT is refused" "$(PRODUCT=beta bash "${BASH_SOURCE[0]}" --lab-probe "$T/lab3.json" >/dev/null 2>&1; echo $?)" "2" if [ "$fails" = 0 ]; then echo "packaged-config: all checks passed"; else echo "packaged-config: $fails check(s) failed"; exit 1; fi fi diff --git a/packaging/ota/publish-manifest.sh b/packaging/ota/publish-manifest.sh index 8c59699fb..d689428ca 100755 --- a/packaging/ota/publish-manifest.sh +++ b/packaging/ota/publish-manifest.sh @@ -7,6 +7,9 @@ # packaging/ota/publish-manifest.sh --version 0.3.1 --mac packaging/mac/dist/Igneum-Miner-0.3.1.dmg \ # [--win packaging/windows/dist/Igneum-Miner-Setup-0.3.1.exe] --notes "one line of what changed" \ # [--activation-height 120000 --deadline-note "difficulty v2"] [--min-supported 0.3.0] [--channel devnet] [--deploy] +# [--urgent] the manifest's own urgent flag (2.0.2, 8 October 2026): the apps install at the first +# safe moment, through the finality guard too (manifest::safe_to_apply); for the entry +# that IS the fix for a chain that cannot lock; beside, not instead of, --min-supported # [--allow-passed-activation] (Horizon polish Q4: an activation height at or below the live DAA is refused otherwise) # --self-test-height proves the height check on known values (33000 against 201776 refused; 300000 passes) and exits # [--override '{"difficulty_v2_activation_daa":33000,"proving_v0_activation_daa":120000}'] @@ -48,7 +51,7 @@ TOKEN_FILE="$HOME/.config/igneum/dl-token" SIGNER="$ROOT/app/igneum-app/target/release/igneum-ota-sign" VERSION="" MAC="" WIN="" NOTES="" ACTIVATION="" DEADLINE="" MIN_SUPPORTED="" CHANNEL="devnet" BASE="" DEST="" DEPLOY=0 VERIFY_ONLY=0 TRIES=12 -OVERRIDE="" TUNING_FILE="" NO_TUNING=0 PUBLIC=0 UI_FILE="" NO_UI=0 DRIVERS_FILE="" NO_DRIVERS=0 +OVERRIDE="" TUNING_FILE="" NO_TUNING=0 PUBLIC=0 UI_FILE="" NO_UI=0 DRIVERS_FILE="" NO_DRIVERS=0 URGENT=0 while [ $# -gt 0 ]; do case "$1" in --version) VERSION="$2"; shift 2 ;; @@ -61,6 +64,7 @@ while [ $# -gt 0 ]; do --deadline-note) DEADLINE="$2"; shift 2 ;; --override) OVERRIDE="$2"; shift 2 ;; # consensus.override: the exact JSON object every app writes to its override.json (all height switches, not just the new one) --min-supported) MIN_SUPPORTED="$2"; shift 2 ;; + --urgent) URGENT=1; shift ;; --channel) CHANNEL="$2"; shift 2 ;; --tuning) TUNING_FILE="$2"; shift 2 ;; --no-tuning) NO_TUNING=1; shift ;; @@ -258,9 +262,10 @@ if [ -n "$ACTIVATION" ]; then fi # the version pair: the UI tree this publish builds from stamps its interface version on every new app entry UI_TREE_VERSION="$(tr -d '[:space:]' < "$ROOT/app/igneum-app/ui/VERSION" 2>/dev/null || true)" -python3 - "$NEW" "$VERSION" "$CHANNEL" "$NOTES" "$MIN_SUPPORTED" "$ACTIVATION" "$DEADLINE" "$MAC_ENTRY" "$WIN_ENTRY" "${OVERRIDE:-}" "${TUNING:-}" "${UI:-}" "${DRIVERS:-}" "${UI_TREE_VERSION:-}" <<'PY' +[ "$URGENT" = 1 ] && echo "urgent: the apps install this entry at the first safe moment, finality guard included" +python3 - "$NEW" "$VERSION" "$CHANNEL" "$NOTES" "$MIN_SUPPORTED" "$ACTIVATION" "$DEADLINE" "$MAC_ENTRY" "$WIN_ENTRY" "${OVERRIDE:-}" "${TUNING:-}" "${UI:-}" "${DRIVERS:-}" "${UI_TREE_VERSION:-}" "$URGENT" <<'PY' import json, sys, datetime -out, version, channel, notes, min_supported, activation, deadline, mac, win, override, tuning, ui, drivers, ui_tree = sys.argv[1:15] +out, version, channel, notes, min_supported, activation, deadline, mac, win, override, tuning, ui, drivers, ui_tree, urgent = sys.argv[1:16] override = json.loads(override) if override else None if override is not None and (not isinstance(override, dict) or not override): raise SystemExit("--override must be a non-empty JSON object") def entry(s, ui_version=None): @@ -282,6 +287,8 @@ m = { "notes": notes, "consensus": {"activation_height": int(activation) if activation else None, "deadline_note": deadline, **({"override": override} if override is not None else {})}, } +if urgent == "1": + m["urgent"] = True if tuning: m["tuning"] = json.loads(tuning) if ui: diff --git a/packaging/windows/Igneum-Miner.iss b/packaging/windows/Igneum-Miner.iss index 539217f42..d1f3d19db 100644 --- a/packaging/windows/Igneum-Miner.iss +++ b/packaging/windows/Igneum-Miner.iss @@ -11,12 +11,28 @@ #ifndef AppVersion #define AppVersion "2.0.1" #endif -#define AppName "Igneum Miner" +; PRODUCT=public|lab (the founder's word, 8 October 2026; packaging/mac/packaged-config.sh is the table): build-installer.ps1 +; passes /DProduct=. The lab product has its own AppId, name, install folder and Start Menu group, so a lab +; install never lands on top of a public one and neither updates the other. +#ifndef Product + #define Product "public" +#endif +#if Product == "lab" + #define AppName "Igneum Miner Lab" + #define AppIdGuid "{5E2B7C41-9D3A-4F06-B8E7-61C4A2D9F0B3}" + #define SetupBase "Igneum-Miner-Lab-Setup" +#elif Product == "public" + #define AppName "Igneum Miner" + #define AppIdGuid "{A4C1F0E2-6B8D-4E7A-9F31-2C5D8E7B9A01}" + #define SetupBase "Igneum-Miner-Setup" +#else + #error Product must be public or lab +#endif #define Publisher "Igneum" #define Url "https://igneum.network" [Setup] -AppId={{A4C1F0E2-6B8D-4E7A-9F31-2C5D8E7B9A01} +AppId={{#AppIdGuid} AppName={#AppName} AppVersion={#AppVersion} AppVerName={#AppName} {#AppVersion} @@ -34,7 +50,7 @@ DefaultGroupName={#AppName} DisableProgramGroupPage=yes LicenseFile=LICENSE.txt OutputDir=dist -OutputBaseFilename=Igneum-Miner-Setup-{#AppVersion} +OutputBaseFilename={#SetupBase}-{#AppVersion} SetupIconFile={#ArtDir}\igneum.ico UninstallDisplayIcon={app}\igneum.ico UninstallDisplayName={#AppName} @@ -61,7 +77,7 @@ Name: "english"; MessagesFile: "compiler:Default.isl" [Messages] english.WelcomeLabel2=This will install [name/ver] on your computer.%n%nOne window runs your node and the GPU miner, and shows your hash rate, your blocks and the chain. Nothing is bought or sold on this network. -english.FinishedHeadingLabel=Igneum Miner is installed +english.FinishedHeadingLabel={#AppName} is installed [Tasks] Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}" @@ -77,16 +93,16 @@ Name: "autoupdate"; Description: "Update automatically (installs new versions at Source: "{#Payload}\*"; DestDir: "{app}"; Flags: recursesubdirs createallsubdirs ignoreversion; Excludes: "*.log,*.seeds,*.DS_Store,packs\*,build\*,dist\*" Source: "{#ArtDir}\igneum.ico"; DestDir: "{app}"; Flags: ignoreversion Source: "{#Payload}\stop-igneum.ps1"; Flags: dontcopy -Source: "wrappers\Stop Igneum Miner.cmd"; DestDir: "{app}"; Flags: ignoreversion +Source: "wrappers\Stop Igneum Miner.cmd"; DestDir: "{app}"; DestName: "Stop {#AppName}.cmd"; Flags: ignoreversion Source: "LICENSE.txt"; DestDir: "{app}"; Flags: ignoreversion [Icons] ; igneum-app.exe --launch hands over to "Igneum Miner.exe" (the window) when it is installed, else opens the dashboard in the browser. -Name: "{group}\Igneum Miner"; Filename: "{app}\igneum-app.exe"; Parameters: "--launch"; WorkingDir: "{app}"; IconFilename: "{app}\igneum.ico"; Comment: "Start the node and mine" -Name: "{group}\Stop Igneum Miner"; Filename: "{app}\Stop Igneum Miner.cmd"; WorkingDir: "{app}"; IconFilename: "{app}\igneum.ico"; Comment: "Stop the miner and the node" -Name: "{group}\Igneum Miner logs"; Filename: "{localappdata}\igneum\logs"; IconFilename: "{app}\igneum.ico"; Comment: "The folder the log files land in" -Name: "{group}\Uninstall Igneum Miner"; Filename: "{uninstallexe}"; IconFilename: "{app}\igneum.ico" -Name: "{autodesktop}\Igneum Miner"; Filename: "{app}\igneum-app.exe"; Parameters: "--launch"; WorkingDir: "{app}"; IconFilename: "{app}\igneum.ico"; Tasks: desktopicon +Name: "{group}\{#AppName}"; Filename: "{app}\igneum-app.exe"; Parameters: "--launch"; WorkingDir: "{app}"; IconFilename: "{app}\igneum.ico"; Comment: "Start the node and mine" +Name: "{group}\Stop {#AppName}"; Filename: "{app}\Stop {#AppName}.cmd"; WorkingDir: "{app}"; IconFilename: "{app}\igneum.ico"; Comment: "Stop the miner and the node" +Name: "{group}\{#AppName} logs"; Filename: "{localappdata}\igneum\logs"; IconFilename: "{app}\igneum.ico"; Comment: "The folder the log files land in" +Name: "{group}\Uninstall {#AppName}"; Filename: "{uninstallexe}"; IconFilename: "{app}\igneum.ico" +Name: "{autodesktop}\{#AppName}"; Filename: "{app}\igneum-app.exe"; Parameters: "--launch"; WorkingDir: "{app}"; IconFilename: "{app}\igneum.ico"; Tasks: desktopicon [Run] ; 0.3.22 (the project lead, 7 October 2026: "all the rights need to be done on install, and then on update if anything new"): the app's own @@ -96,7 +112,7 @@ Name: "{autodesktop}\Igneum Miner"; Filename: "{app}\igneum-app.exe"; Parameters Filename: "{app}\igneum-app.exe"; Parameters: "--rights"; Flags: waituntilterminated runasoriginaluser ; The inbound firewall rule needs an administrator and is asked for once by the app on its first run (declined = the node dials out and mines without it). ; Started as the signed-in user, not as administrator (the data lands in that user's %LOCALAPPDATA%). -Filename: "{app}\igneum-app.exe"; Parameters: "--launch"; Description: "Start Igneum Miner now"; Flags: postinstall nowait skipifsilent runasoriginaluser +Filename: "{app}\igneum-app.exe"; Parameters: "--launch"; Description: "Start {#AppName} now"; Flags: postinstall nowait skipifsilent runasoriginaluser ; The over-the-air updater (packaging/ota, src/ota.rs) runs this installer /VERYSILENT /IGNOTA=1 and the app must come back by itself. ; /IGNOTA=2 (helpers from 0.3.22 on, MF-11): the helper starts the app itself, polls the new engine and restores the kept ; exe set when nothing answers; this entry stays silent so the two never race for the single-instance window. @@ -170,7 +186,7 @@ end; // without touching the app; the detached run installs and removes the task at its end. The job's proof is the --version // wait and the detached log, not this process's exit code (1: "Setup failed to initialize"). const - DetachTask = 'Igneum Miner install'; + DetachTask = '{#AppName} install'; function DetachedRun: Boolean; begin @@ -352,7 +368,7 @@ begin Exec('powershell.exe', '-NoProfile -ExecutionPolicy Bypass -File "' + StopScript + '" -Install "' + OldDir + '"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode); if (OldDir <> '') and (not WizardSilent) and FileExists(OldDir + '\unins000.exe') then begin - if MsgBox('Igneum Miner now installs in your user folder, so updates need no administrator prompt.' + #13#10#13#10 + + if MsgBox('{#AppName} now installs in your user folder, so updates need no administrator prompt.' + #13#10#13#10 + 'An older copy is still in ' + OldDir + '. Remove it now? (one administrator prompt; your chain data, address and settings stay)', mbConfirmation, MB_YESNO) = IDYES then ShellExec('runas', OldDir + '\unins000.exe', '/VERYSILENT /SUPPRESSMSGBOXES /NORESTART', '', SW_HIDE, ewWaitUntilTerminated, ResultCode); diff --git a/packaging/windows/build-installer.ps1 b/packaging/windows/build-installer.ps1 index e5a98f526..bd834d2ec 100644 --- a/packaging/windows/build-installer.ps1 +++ b/packaging/windows/build-installer.ps1 @@ -14,6 +14,8 @@ param( [string]$Payload = '', [string]$PayloadUrl = $(if ($env:IGNEUM_PAYLOAD_URL) { $env:IGNEUM_PAYLOAD_URL } else { 'https://dl.igneum.network/igneum-windows-app.zip' }), [string]$Version = '0.3.0', + # PRODUCT=public|lab (packaging/mac/packaged-config.sh, 8 October 2026): the installer's AppId, AppName, folder and file name + [ValidateSet('public', 'lab')][string]$Product = $(if ($env:PRODUCT) { $env:PRODUCT } else { 'public' }), [switch]$NoRcedit, [switch]$NoWinget ) @@ -101,12 +103,15 @@ function Test-PayloadDir([string]$dir) { } return $true } +$productName = $(if ($Product -eq 'lab') { 'Igneum Miner Lab' } else { 'Igneum Miner' }) +$payloadName = $(if ($Product -eq 'lab') { 'igneum-windows-app-lab' } else { 'igneum-windows-app' }) +Say "product: $Product ($productName; payload folder $payloadName)" $source = $null if ($Payload) { if (-not (Test-PayloadDir $Payload)) { throw "-Payload $Payload does not hold START-IGNEUM.bat, the .ps1 files and both exes." } $source = (Resolve-Path $Payload).Path } else { - foreach ($dir in @((Join-Path $here 'igneum-windows-app'), (Join-Path $here '..\igneum-windows-app'), (Join-Path $here 'payload'), (Join-Path $here '..\..\igneum-windows-app'))) { + foreach ($dir in @((Join-Path $here $payloadName), (Join-Path $here "..\$payloadName"), (Join-Path $here 'payload'), (Join-Path $here "..\..\$payloadName"))) { if (Test-PayloadDir $dir) { $source = (Resolve-Path $dir).Path; break } } } @@ -152,7 +157,7 @@ foreach ($exeName in @('igneum-app.exe', 'igneumd.exe', 'igneum-miner.exe')) { $exe = Join-Path $payloadDir $exeName if (-not (Test-Path $exe)) { continue } $info = (Get-Item $exe).VersionInfo - if ($info.ProductName -eq 'Igneum Miner') { Say "$exeName already carries the Igneum Miner version block (relinked on the Mac)"; continue } + if ($info.ProductName -eq $productName) { Say "$exeName already carries the $productName version block (relinked on the Mac)"; continue } if ($NoRcedit) { Say "$exeName has no version block; -NoRcedit given, so Explorer shows it without the coin icon"; continue } if (-not $rcedit) { if ($env:RCEDIT -and (Test-Path $env:RCEDIT)) { $rcedit = $env:RCEDIT } @@ -161,10 +166,10 @@ foreach ($exeName in @('igneum-app.exe', 'igneumd.exe', 'igneum-miner.exe')) { if (-not (Test-Path $rcedit)) { Say "downloading rcedit from $rceditUrl"; Invoke-WebRequest -Uri $rceditUrl -OutFile $rcedit -UseBasicParsing } } } - $desc = switch ($exeName) { 'igneumd.exe' { 'Igneum Miner node' } 'igneum-app.exe' { 'Igneum Miner engine' } default { 'Igneum Miner' } } + $desc = switch ($exeName) { 'igneumd.exe' { "$productName node" } 'igneum-app.exe' { "$productName engine" } default { $productName } } $rcArgs = @($exe, '--set-icon', (Join-Path $art 'igneum.ico'), '--set-version-string', 'CompanyName', 'Igneum', - '--set-version-string', 'ProductName', 'Igneum Miner', + '--set-version-string', 'ProductName', $productName, '--set-version-string', 'FileDescription', $desc, '--set-version-string', 'OriginalFilename', $exeName, '--set-version-string', 'LegalCopyright', 'Igneum. Nothing is bought or sold.', @@ -177,7 +182,7 @@ foreach ($exeName in @('igneum-app.exe', 'igneumd.exe', 'igneum-miner.exe')) { # ---- 6. compile ------------------------------------------------------------------------------------------------------ $iss = Join-Path $here 'Igneum-Miner.iss' -$isccArgs = @('/Qp', "/DPayload=$payloadDir", "/DArtDir=$art", "/DAppVersion=$Version", "/O$dist", $iss) +$isccArgs = @('/Qp', "/DPayload=$payloadDir", "/DArtDir=$art", "/DAppVersion=$Version", "/DProduct=$Product", "/O$dist", $iss) Say "compiling $iss" & $iscc @isccArgs if ($LASTEXITCODE -ne 0) { throw "ISCC failed (exit $LASTEXITCODE)" } diff --git a/packaging/windows/make-payload.sh b/packaging/windows/make-payload.sh index d5eb86de0..a4f6b4b5e 100755 --- a/packaging/windows/make-payload.sh +++ b/packaging/windows/make-payload.sh @@ -26,15 +26,16 @@ set -euo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" ROOT="$(cd "$HERE/../.." && pwd)" VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' "$ROOT/app/igneum-app/Cargo.toml" | head -1)" -OUT="${1:-$HOME/Desktop/igneum-windows-app.zip}" +# the packaged configuration (the manifest URL from the token file, the intake key from the key file, NODE_OVERRIDE_PARAMS) +# and the PRODUCT=public|lab table (the payload folder igneum-windows-app or igneum-windows-app-lab, the product name) +. "$ROOT/packaging/mac/packaged-config.sh" +OUT="${1:-$HOME/Desktop/$PRODUCT_PAYLOAD_DIR.zip}" case "$OUT" in /*) ;; *) OUT="$PWD/$OUT" ;; esac mkdir -p "$(dirname "$OUT")" REL="${IGNEUM_WIN_RELEASE:-$ROOT/vendor/igneum-node/target-integration/x86_64-pc-windows-gnu/release}" 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" -# 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" +STAGE="$HERE/$PRODUCT_PAYLOAD_DIR" [ -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; } @@ -128,19 +129,19 @@ cp "$ROOT"/proving/fixtures/*.json "$STAGE/wsl2/fixtures/" # the fingerprints, never the values) write_packaged_config "$STAGE/igneum-app.json" cat > "$STAGE/README.txt" </dev/null 2>&1; then (cd "$HERE" && zip -qr "$OUT" "igneum-windows-app" -x '*.DS_Store') -elif command -v 7z >/dev/null 2>&1; then (cd "$HERE" && 7z a -tzip -bso0 -bsp0 "$OUT" "igneum-windows-app" '-xr!.DS_Store') +if command -v zip >/dev/null 2>&1; then (cd "$HERE" && zip -qr "$OUT" "$PRODUCT_PAYLOAD_DIR" -x '*.DS_Store') +elif command -v 7z >/dev/null 2>&1; then (cd "$HERE" && 7z a -tzip -bso0 -bsp0 "$OUT" "$PRODUCT_PAYLOAD_DIR" '-xr!.DS_Store') else echo "neither zip nor 7z is on PATH" >&2; exit 1; fi echo "staged $STAGE" ls -la "$OUT"