max / makenotwork
12 files changed,
+1063 insertions,
-17 deletions
| @@ -193,6 +193,7 @@ dependencies = [ | |||
| 193 | 193 | "semver", | |
| 194 | 194 | "serde", | |
| 195 | 195 | "serde_json", | |
| 196 | + | "sha2 0.10.9", | |
| 196 | 197 | "sqlx", | |
| 197 | 198 | "tempfile", | |
| 198 | 199 | "thiserror", |
| @@ -28,6 +28,7 @@ anyhow = "1.0.102" | |||
| 28 | 28 | thiserror = "2.0.18" | |
| 29 | 29 | chrono = { version = "0.4", features = ["serde"] } | |
| 30 | 30 | semver = { version = "1.0", features = ["serde"] } | |
| 31 | + | sha2 = "0.10" | |
| 31 | 32 | ||
| 32 | 33 | [dev-dependencies] | |
| 33 | 34 | async-trait = "0.1" |
| @@ -20,6 +20,12 @@ pub struct Config { | |||
| 20 | 20 | /// (`<logs_root>/<app>/<version>/<target>/<step>.log`). | |
| 21 | 21 | #[serde(default = "default_logs_root")] | |
| 22 | 22 | pub logs_root: PathBuf, | |
| 23 | + | /// Override the per-step wall-clock budget (in seconds) for EVERY step, | |
| 24 | + | /// replacing the per-kind defaults in `engine::step_budget`. Unset (the | |
| 25 | + | /// default) uses those. Mainly an escape hatch for a constrained host or a | |
| 26 | + | /// test that needs a short deadline. | |
| 27 | + | #[serde(default)] | |
| 28 | + | pub step_timeout_secs: Option<u64>, | |
| 23 | 29 | } | |
| 24 | 30 | ||
| 25 | 31 | fn default_logs_root() -> PathBuf { | |
| @@ -43,6 +49,7 @@ impl Config { | |||
| 43 | 49 | secrets_root: root.join("secrets"), | |
| 44 | 50 | dist_root: root.join("dist"), | |
| 45 | 51 | logs_root: root.join("logs"), | |
| 52 | + | step_timeout_secs: None, | |
| 46 | 53 | } | |
| 47 | 54 | } | |
| 48 | 55 | } |
| @@ -239,6 +239,13 @@ impl Version { | |||
| 239 | 239 | .map(Self) | |
| 240 | 240 | .map_err(|e| format!("invalid semver `{s}`: {e}")) | |
| 241 | 241 | } | |
| 242 | + | ||
| 243 | + | /// The `(major, minor, patch)` core, ignoring any prerelease/build suffix. | |
| 244 | + | /// Used to match a build against the plain `X.Y.Z` embedded in an artifact | |
| 245 | + | /// file name, which never carries the suffix. | |
| 246 | + | pub fn core(&self) -> (u64, u64, u64) { | |
| 247 | + | (self.0.major, self.0.minor, self.0.patch) | |
| 248 | + | } | |
| 242 | 249 | } | |
| 243 | 250 | ||
| 244 | 251 | impl fmt::Display for Version { |
| @@ -21,7 +21,9 @@ use anyhow::{Context as _, Result}; | |||
| 21 | 21 | use ops_core::live_log::LiveLog; | |
| 22 | 22 | use ops_exec::{Action, Executor, ObserveKind, Step as OpStep, SyncOpts}; | |
| 23 | 23 | use rhai::{Engine, EvalAltResult, Map}; | |
| 24 | + | use sha2::{Digest, Sha256}; | |
| 24 | 25 | use sqlx::SqlitePool; | |
| 26 | + | use std::collections::HashMap; | |
| 25 | 27 | use std::path::{Path, PathBuf}; | |
| 26 | 28 | use std::sync::atomic::{AtomicBool, Ordering}; | |
| 27 | 29 | use std::sync::{Arc, Mutex}; | |
| @@ -57,6 +59,39 @@ struct StepState { | |||
| 57 | 59 | /// recipe ignored the bool). Forces the step's recorded status to `Failed` | |
| 58 | 60 | /// and bars `publish` (the step-success ledger). | |
| 59 | 61 | failed: bool, | |
| 62 | + | /// Wall-clock deadline for this step. A command that runs past it fails the | |
| 63 | + | /// step (and unwinds the recipe) rather than wedging under the old | |
| 64 | + | /// whole-build guillotine, which a legitimate 5-target fan-out plus notary | |
| 65 | + | /// queueing could trip — mismarking every target failed while the blocking | |
| 66 | + | /// recipe bodies kept signing. | |
| 67 | + | deadline: std::time::Instant, | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | /// Per-step wall-clock budget: a generous ceiling that catches a wedged command | |
| 71 | + | /// (a hung ssh, a stuck notary poll) without killing legitimately slow work. | |
| 72 | + | /// The old design bounded the whole build at 2h; this bounds each step so one | |
| 73 | + | /// slow step can't be blamed on another and a fan-out of slow-but-fine targets | |
| 74 | + | /// isn't guillotined. `Config::step_timeout_secs` overrides these per-kind | |
| 75 | + | /// defaults for every step; see [`RecipeCtx::step_budget`]. | |
| 76 | + | fn default_step_budget(step: Step) -> std::time::Duration { | |
| 77 | + | use std::time::Duration; | |
| 78 | + | let mins = match step { | |
| 79 | + | Step::Checkout => 10, | |
| 80 | + | // clippy + full test suite, cold, on a workspace. | |
| 81 | + | Step::Prebuild => 45, | |
| 82 | + | // cargo tauri build, cold, universal bundles. | |
| 83 | + | Step::Build => 90, | |
| 84 | + | Step::Sign => 15, | |
| 85 | + | // Apple's notary queue + this step's bounded retries. | |
| 86 | + | Step::Notarize => 60, | |
| 87 | + | Step::Staple => 10, | |
| 88 | + | Step::Package => 30, | |
| 89 | + | Step::Verify => 10, | |
| 90 | + | // rsync of multi-GiB artifacts off the build host. | |
| 91 | + | Step::Collect => 30, | |
| 92 | + | Step::Publish => 20, | |
| 93 | + | }; | |
| 94 | + | Duration::from_secs(mins * 60) | |
| 60 | 95 | } | |
| 61 | 96 | ||
| 62 | 97 | /// Everything a recipe's host functions need, shared (Arc) into each closure. | |
| @@ -104,6 +139,16 @@ pub struct RecipeCtx { | |||
| 104 | 139 | /// and before publish so a superseded recipe stops promptly rather than | |
| 105 | 140 | /// running to completion (the blocking Rhai body can't be `abort()`ed). | |
| 106 | 141 | cancel: Arc<AtomicBool>, | |
| 142 | + | /// The all-targets-green publish gate (topology `require_all_targets`). | |
| 143 | + | /// `Some(declared)` ⇒ `publish` refuses unless every one of `declared` has a | |
| 144 | + | /// successful latest run for this `(app, version)`. `None` ⇒ gate off, each | |
| 145 | + | /// target publishes independently. | |
| 146 | + | all_green_required: Option<Vec<Target>>, | |
| 147 | + | /// sha256 of each artifact hashed at `collect`, keyed by file name. `publish` | |
| 148 | + | /// reads it to record `releases.artifact_hash` for the bytes it ships, so the | |
| 149 | + | /// hash is the one computed when the artifact landed rather than a re-read | |
| 150 | + | /// that could see a different file. Absent ⇒ `publish` hashes on demand. | |
| 151 | + | artifact_hashes: Mutex<HashMap<String, String>>, | |
| 107 | 152 | } | |
| 108 | 153 | ||
| 109 | 154 | impl RecipeCtx { | |
| @@ -156,6 +201,7 @@ impl RecipeCtx { | |||
| 156 | 201 | ota: Arc<OtaRegistry>, | |
| 157 | 202 | rt: Handle, | |
| 158 | 203 | cancel: Arc<AtomicBool>, | |
| 204 | + | all_green_required: Option<Vec<Target>>, | |
| 159 | 205 | ) -> Self { | |
| 160 | 206 | Self { | |
| 161 | 207 | app, | |
| @@ -176,6 +222,8 @@ impl RecipeCtx { | |||
| 176 | 222 | gatekeeper_ok: Mutex::new(None), | |
| 177 | 223 | failed_steps: Mutex::new(Vec::new()), | |
| 178 | 224 | cancel, | |
| 225 | + | all_green_required, | |
| 226 | + | artifact_hashes: Mutex::new(HashMap::new()), | |
| 179 | 227 | } | |
| 180 | 228 | } | |
| 181 | 229 | ||
| @@ -270,10 +318,65 @@ impl RecipeCtx { | |||
| 270 | 318 | step, | |
| 271 | 319 | log: Arc::new(AsyncMutex::new(log)), | |
| 272 | 320 | failed: false, | |
| 321 | + | deadline: std::time::Instant::now() + self.step_budget(step), | |
| 273 | 322 | }); | |
| 274 | 323 | Ok(()) | |
| 275 | 324 | } | |
| 276 | 325 | ||
| 326 | + | /// This build's budget for `step`: the `Config` override if set, else the | |
| 327 | + | /// per-kind default. | |
| 328 | + | fn step_budget(&self, step: Step) -> std::time::Duration { | |
| 329 | + | self.cfg | |
| 330 | + | .step_timeout_secs | |
| 331 | + | .map(std::time::Duration::from_secs) | |
| 332 | + | .unwrap_or_else(|| default_step_budget(step)) | |
| 333 | + | } | |
| 334 | + | ||
| 335 | + | /// The current step's wall-clock deadline (or a default if no step is open, | |
| 336 | + | /// which only happens before the first `step()` — commands then run under an | |
| 337 | + | /// implicit `Build` step opened by `ensure_step`). | |
| 338 | + | fn step_deadline(&self) -> std::time::Instant { | |
| 339 | + | self.current | |
| 340 | + | .lock() | |
| 341 | + | .unwrap() | |
| 342 | + | .as_ref() | |
| 343 | + | .map(|s| s.deadline) | |
| 344 | + | .unwrap_or_else(|| std::time::Instant::now() + self.step_budget(Step::Build)) | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | /// Drive `fut` on the runtime, but stop early on two conditions the recipe | |
| 348 | + | /// bodies otherwise cannot observe (they run synchronously on a blocking | |
| 349 | + | /// thread): the current step's deadline, and supersession by a newer build. | |
| 350 | + | /// Either turns into an error that fails the step and unwinds the recipe, so | |
| 351 | + | /// a wedged command no longer runs unbounded and a superseded build stops | |
| 352 | + | /// mid-step instead of only at the next step boundary. | |
| 353 | + | fn run_bounded<F, T>(&self, what: &str, fut: F) -> Result<T> | |
| 354 | + | where | |
| 355 | + | F: std::future::Future<Output = Result<T>>, | |
| 356 | + | { | |
| 357 | + | let deadline = self.step_deadline(); | |
| 358 | + | let cancel = self.cancel.clone(); | |
| 359 | + | self.rt.block_on(async move { | |
| 360 | + | tokio::pin!(fut); | |
| 361 | + | let watch = async { | |
| 362 | + | // Poll the cooperative cancel flag; the finalizer and a | |
| 363 | + | // superseding build both set it. Cheap next to a build step. | |
| 364 | + | while !cancel.load(Ordering::SeqCst) { | |
| 365 | + | tokio::time::sleep(std::time::Duration::from_millis(250)).await; | |
| 366 | + | } | |
| 367 | + | }; | |
| 368 | + | tokio::select! { | |
| 369 | + | r = &mut fut => r, | |
| 370 | + | _ = tokio::time::sleep_until(deadline.into()) => { | |
| 371 | + | Err(anyhow::anyhow!("`{what}` exceeded its per-step deadline")) | |
| 372 | + | } | |
| 373 | + | _ = watch => { | |
| 374 | + | Err(anyhow::anyhow!("build superseded by a newer request; aborting `{what}`")) | |
| 375 | + | } | |
| 376 | + | } | |
| 377 | + | }) | |
| 378 | + | } | |
| 379 | + | ||
| 277 | 380 | /// Flag the currently-open step as failed (no-op if none is open). Forces | |
| 278 | 381 | /// its recorded status to `Failed` at `finish_step` and adds it to the | |
| 279 | 382 | /// publish-barring ledger, even though the recipe kept running. | |
| @@ -374,8 +477,13 @@ impl RecipeCtx { | |||
| 374 | 477 | fn run(self: &Arc<Self>, host: &str, cmd: &str) -> Result<(i32, String)> { | |
| 375 | 478 | let sink = self.ensure_step()?; | |
| 376 | 479 | let exec = self.exec(host)?; | |
| 377 | - | let step = OpStep::shell(action_for(self.current_step()), cmd.to_string()); | |
| 378 | - | let out = self.rt.block_on(async move { | |
| 480 | + | let cur = self.current_step(); | |
| 481 | + | let step = OpStep::shell(action_for(cur), cmd.to_string()); | |
| 482 | + | // Bounded by the step's deadline and interruptible on supersession, so a | |
| 483 | + | // hung command fails its step instead of running unbounded, and a | |
| 484 | + | // superseded build stops mid-step rather than only at the next boundary. | |
| 485 | + | let label = format!("{cur} command on `{host}`"); | |
| 486 | + | let out = self.run_bounded(&label, async move { | |
| 379 | 487 | let mut guard = sink.lock().await; | |
| 380 | 488 | exec.run_streaming(&step, &mut *guard).await | |
| 381 | 489 | })?; | |
| @@ -563,6 +671,139 @@ fn version_from_cargo_toml(raw: &str) -> Result<String> { | |||
| 563 | 671 | .context("no `[package].version` or `[workspace.package].version` in Cargo.toml") | |
| 564 | 672 | } | |
| 565 | 673 | ||
| 674 | + | /// Cross-check every version source in a repo and confirm they all agree with | |
| 675 | + | /// the version being built, before a single host pulls or compiles. | |
| 676 | + | /// | |
| 677 | + | /// `version_from_repo` reads exactly one file, so a `tauri.conf.json` at 0.5.0 | |
| 678 | + | /// and a root `Cargo.toml` still at 0.4.0 build happily and file artifacts under | |
| 679 | + | /// whichever the runner happened to read. This reads every source present — | |
| 680 | + | /// `version_path` (when set), `src-tauri/tauri.conf.json`, and the root | |
| 681 | + | /// `Cargo.toml` — and fails loudly when any disagree, naming each file and its | |
| 682 | + | /// version. A source that is absent is skipped (a library crate with only a | |
| 683 | + | /// `Cargo.toml` has nothing to disagree with); the check never invents drift. | |
| 684 | + | /// | |
| 685 | + | /// Scope: the JSON/TOML sources bentod itself reads. The iOS `gen/apple/project.yml` | |
| 686 | + | /// path (rewritten by a build-time `sed`) is out of scope here — it is asserted at | |
| 687 | + | /// its own build step — but the same drift class motivated this guard. | |
| 688 | + | pub fn check_version_consistency( | |
| 689 | + | repo: &str, | |
| 690 | + | version_path: Option<&str>, | |
| 691 | + | expected: &Version, | |
| 692 | + | ) -> Result<()> { | |
| 693 | + | let root = expand_tilde(repo); | |
| 694 | + | // (human-readable source label, parsed version) for every source present. | |
| 695 | + | let mut found: Vec<(String, Version)> = Vec::new(); | |
| 696 | + | ||
| 697 | + | let mut consider = |rel: &str, raw: &str, as_json: bool| -> Result<()> { | |
| 698 | + | let ver = if as_json { | |
| 699 | + | version_from_tauri_json(raw) | |
| 700 | + | } else { | |
| 701 | + | version_from_cargo_toml(raw) | |
| 702 | + | }?; | |
| 703 | + | let parsed = Version::parse(&ver).map_err(|e| anyhow::anyhow!(e))?; | |
| 704 | + | found.push((rel.to_string(), parsed)); | |
| 705 | + | Ok(()) | |
| 706 | + | }; | |
| 707 | + | ||
| 708 | + | if let Some(vp) = version_path { | |
| 709 | + | let path = root.join(vp); | |
| 710 | + | let raw = std::fs::read_to_string(&path) | |
| 711 | + | .with_context(|| format!("reading version file {}", path.display()))?; | |
| 712 | + | consider(vp, &raw, vp.ends_with(".json"))?; | |
| 713 | + | } | |
| 714 | + | let tauri_conf = root.join("src-tauri").join("tauri.conf.json"); | |
| 715 | + | if version_path != Some("src-tauri/tauri.conf.json") && tauri_conf.exists() { | |
| 716 | + | let raw = std::fs::read_to_string(&tauri_conf) | |
| 717 | + | .with_context(|| format!("reading {}", tauri_conf.display()))?; | |
| 718 | + | consider("src-tauri/tauri.conf.json", &raw, true)?; | |
| 719 | + | } | |
| 720 | + | let cargo_toml = root.join("Cargo.toml"); | |
| 721 | + | if version_path != Some("Cargo.toml") && cargo_toml.exists() { | |
| 722 | + | // A Cargo.toml with neither `[package].version` nor | |
| 723 | + | // `[workspace.package].version` (a pure virtual workspace) carries no | |
| 724 | + | // version to check — skip it rather than fail. | |
| 725 | + | if let Ok(raw) = std::fs::read_to_string(&cargo_toml) | |
| 726 | + | && version_from_cargo_toml(&raw).is_ok() | |
| 727 | + | { | |
| 728 | + | consider("Cargo.toml", &raw, false)?; | |
| 729 | + | } | |
| 730 | + | } | |
| 731 | + | ||
| 732 | + | let disagree: Vec<&(String, Version)> = found.iter().filter(|(_, v)| v != expected).collect(); | |
| 733 | + | anyhow::ensure!( | |
| 734 | + | disagree.is_empty(), | |
| 735 | + | "version drift in {repo}: building {expected} but {}", | |
| 736 | + | disagree | |
| 737 | + | .iter() | |
| 738 | + | .map(|(src, v)| format!("{src} says {v}")) | |
| 739 | + | .collect::<Vec<_>>() | |
| 740 | + | .join(", ") | |
| 741 | + | ); | |
| 742 | + | Ok(()) | |
| 743 | + | } | |
| 744 | + | ||
| 745 | + | /// Every `X.Y.Z`-shaped version embedded in an artifact file name. Each maximal | |
| 746 | + | /// run of digits-and-dots contributes its first three numeric fields: | |
| 747 | + | /// `GoingsOn_0.5.0_aarch64.dmg` and `demo-9.9.9.bin` both yield one version (the | |
| 748 | + | /// trailing `.bin`/`.dmg` dot is tolerated), while `latest.json` yields `[]` and | |
| 749 | + | /// the `64` in `x86_64` is not three fields. Only the `major.minor.patch` core is | |
| 750 | + | /// taken; a prerelease/build suffix is separated by `-`/`+` and not needed here. | |
| 751 | + | fn versions_in_filename(name: &str) -> Vec<Version> { | |
| 752 | + | name.split(|c: char| !(c.is_ascii_digit() || c == '.')) | |
| 753 | + | .filter_map(|run| { | |
| 754 | + | let f: Vec<&str> = run.split('.').filter(|s| !s.is_empty()).collect(); | |
| 755 | + | if f.len() >= 3 && f[..3].iter().all(|s| s.chars().all(|c| c.is_ascii_digit())) { | |
| 756 | + | Version::parse(&format!("{}.{}.{}", f[0], f[1], f[2])).ok() | |
| 757 | + | } else { | |
| 758 | + | None | |
| 759 | + | } | |
| 760 | + | }) | |
| 761 | + | .collect() | |
| 762 | + | } | |
| 763 | + | ||
| 764 | + | /// Fail when a collected file's name embeds a version whose `major.minor.patch` | |
| 765 | + | /// is not the one being built. This is the guard against a stale checked-in | |
| 766 | + | /// artifact winning a glob: `ls -t <glob>` once let | |
| 767 | + | /// `AudioFiles-0.4.0-x86_64.AppImage` ship against 0.5.0. A file whose name | |
| 768 | + | /// carries no version (an updater `latest.json`, a `.sig`) is not asserted — | |
| 769 | + | /// there is nothing to compare. Compared on the core so a prerelease build's | |
| 770 | + | /// plain `X.Y.Z` in the filename still matches. | |
| 771 | + | fn assert_artifact_version(name: &str, expected: &Version) -> Result<()> { | |
| 772 | + | let versions = versions_in_filename(name); | |
| 773 | + | anyhow::ensure!( | |
| 774 | + | versions.is_empty() || versions.iter().any(|v| v.core() == expected.core()), | |
| 775 | + | "collected artifact `{name}` carries version {} but the build is {expected}; \ | |
| 776 | + | a stale artifact was left in the output dir — clean it so only {expected} remains", | |
| 777 | + | versions | |
| 778 | + | .iter() | |
| 779 | + | .map(ToString::to_string) | |
| 780 | + | .collect::<Vec<_>>() | |
| 781 | + | .join("/"), | |
| 782 | + | ); | |
| 783 | + | Ok(()) | |
| 784 | + | } | |
| 785 | + | ||
| 786 | + | /// sha256 of a file, lowercase hex. Streams in 64 KiB chunks so a multi-GiB | |
| 787 | + | /// bundle never lands in memory whole. | |
| 788 | + | fn sha256_file(path: &Path) -> Result<String> { | |
| 789 | + | let mut file = | |
| 790 | + | std::fs::File::open(path).with_context(|| format!("hashing {}", path.display()))?; | |
| 791 | + | let mut hasher = Sha256::new(); | |
| 792 | + | std::io::copy(&mut file, &mut hasher) | |
| 793 | + | .with_context(|| format!("reading {} to hash", path.display()))?; | |
| 794 | + | Ok(hex_lower(&hasher.finalize())) | |
| 795 | + | } | |
| 796 | + | ||
| 797 | + | /// Lowercase-hex encode without pulling in a hex crate. | |
| 798 | + | fn hex_lower(bytes: &[u8]) -> String { | |
| 799 | + | use std::fmt::Write as _; | |
| 800 | + | let mut s = String::with_capacity(bytes.len() * 2); | |
| 801 | + | for b in bytes { | |
| 802 | + | let _ = write!(s, "{b:02x}"); | |
| 803 | + | } | |
| 804 | + | s | |
| 805 | + | } | |
| 806 | + | ||
| 566 | 807 | /// Expand a leading `~/` to `$HOME`. Paths in the topology are written with `~`. | |
| 567 | 808 | pub fn expand_tilde(p: &str) -> PathBuf { | |
| 568 | 809 | if let Some(rest) = p.strip_prefix("~/") | |
| @@ -989,9 +1230,28 @@ impl RecipeCtx { | |||
| 989 | 1230 | // runs the transfer itself, as it always has. | |
| 990 | 1231 | let sync = self.host_sync(host)?; | |
| 991 | 1232 | let opts = SyncOpts::precompressed(); | |
| 992 | - | self.rt | |
| 993 | - | .block_on(async { sync.pull_glob(glob, &dest, &opts).await }) | |
| 994 | - | .with_context(|| format!("collect {glob} from `{host}`"))?; | |
| 1233 | + | // Bounded by the collect step's deadline (rsync of a multi-GiB artifact | |
| 1234 | + | // can wedge on a stalled transport) and interruptible on supersession. | |
| 1235 | + | let dest_pull = dest.clone(); | |
| 1236 | + | self.run_bounded(&format!("collect {glob} from `{host}`"), async move { | |
| 1237 | + | sync.pull_glob(glob, &dest_pull, &opts).await | |
| 1238 | + | }) | |
| 1239 | + | .with_context(|| format!("collect {glob} from `{host}`"))?; | |
| 1240 | + | // Assert the version and hash every collected file. This is where a | |
| 1241 | + | // stale artifact is caught: a file whose name embeds a different version | |
| 1242 | + | // fails the collect (rather than silently winning a later glob), and the | |
| 1243 | + | // sha256 recorded here is what `publish` writes into the release ledger. | |
| 1244 | + | for entry in | |
| 1245 | + | std::fs::read_dir(&dest).with_context(|| format!("listing collect dest {dest_s}"))? | |
| 1246 | + | { | |
| 1247 | + | let entry = entry?; | |
| 1248 | + | let name = entry.file_name().to_string_lossy().into_owned(); | |
| 1249 | + | assert_artifact_version(&name, &self.version)?; | |
| 1250 | + | if entry.file_type().map(|t| t.is_file()).unwrap_or(false) { | |
| 1251 | + | let digest = sha256_file(&entry.path())?; | |
| 1252 | + | self.artifact_hashes.lock().unwrap().insert(name, digest); | |
| 1253 | + | } | |
| 1254 | + | } | |
| 995 | 1255 | // Best-effort size accounting for the event. | |
| 996 | 1256 | events::emit( | |
| 997 | 1257 | &self.events, | |
| @@ -1005,6 +1265,48 @@ impl RecipeCtx { | |||
| 1005 | 1265 | Ok(()) | |
| 1006 | 1266 | } | |
| 1007 | 1267 | ||
| 1268 | + | /// The all-targets-green gate: err unless every declared target OTHER than | |
| 1269 | + | /// the one publishing has a latest `target_runs` row of `ok` for this | |
| 1270 | + | /// `(app, version)`. A sibling with no run, a running run, or a failed | |
| 1271 | + | /// latest run all block the publish, naming what is not green. | |
| 1272 | + | fn assert_siblings_green(self: &Arc<Self>, declared: &[Target]) -> Result<()> { | |
| 1273 | + | let me = self.clone(); | |
| 1274 | + | let (app_s, ver_s) = (self.app.to_string(), self.version.to_string()); | |
| 1275 | + | let rows: Vec<(String, String)> = self.rt.block_on(async move { | |
| 1276 | + | sqlx::query_as( | |
| 1277 | + | "SELECT target, status FROM target_runs tr | |
| 1278 | + | WHERE app = ?1 AND version = ?2 | |
| 1279 | + | AND id = (SELECT MAX(id) FROM target_runs | |
| 1280 | + | WHERE app = ?1 AND version = ?2 AND target = tr.target)", | |
| 1281 | + | ) | |
| 1282 | + | .bind(app_s) | |
| 1283 | + | .bind(ver_s) | |
| 1284 | + | .fetch_all(&me.pool) | |
| 1285 | + | .await | |
| 1286 | + | .unwrap_or_default() | |
| 1287 | + | }); | |
| 1288 | + | let status_of = |t: &Target| -> Option<String> { | |
| 1289 | + | let key = t.to_string(); | |
| 1290 | + | rows.iter() | |
| 1291 | + | .find(|(name, _)| name == &key) | |
| 1292 | + | .map(|(_, s)| s.clone()) | |
| 1293 | + | }; | |
| 1294 | + | let not_green: Vec<String> = declared | |
| 1295 | + | .iter() | |
| 1296 | + | .filter(|t| **t != self.target) // the publishing target is the last mile | |
| 1297 | + | .filter(|t| status_of(t).as_deref() != Some("ok")) | |
| 1298 | + | .map(|t| format!("{t} ({})", status_of(t).unwrap_or_else(|| "no run".into()))) | |
| 1299 | + | .collect(); | |
| 1300 | + | anyhow::ensure!( | |
| 1301 | + | not_green.is_empty(), | |
| 1302 | + | "all-targets-green gate: refusing to publish {} {} — not green: {}", | |
| 1303 | + | self.app, | |
| 1304 | + | self.version, | |
| 1305 | + | not_green.join(", "), | |
| 1306 | + | ); | |
| 1307 | + | Ok(()) | |
| 1308 | + | } | |
| 1309 | + | ||
| 1008 | 1310 | fn publish( | |
| 1009 | 1311 | self: &Arc<Self>, | |
| 1010 | 1312 | channel: &str, | |
| @@ -1021,6 +1323,15 @@ impl RecipeCtx { | |||
| 1021 | 1323 | !self.is_cancelled(), | |
| 1022 | 1324 | "build superseded by a newer request; refusing to publish" | |
| 1023 | 1325 | ); | |
| 1326 | + | // Opt-in all-targets-green gate: refuse a partial release. Every OTHER | |
| 1327 | + | // declared target of this (app, version) must have a successful latest | |
| 1328 | + | // run before this one ships, so macOS can't publish while windows is red | |
| 1329 | + | // or still building. The publishing target itself is the last mile (it | |
| 1330 | + | // reached publish, so its steps passed) and is not required to be green | |
| 1331 | + | // in the ledger yet. | |
| 1332 | + | if let Some(declared) = self.all_green_required.clone() { | |
| 1333 | + | self.assert_siblings_green(&declared)?; | |
| 1334 | + | } | |
| 1024 | 1335 | let backend = self | |
| 1025 | 1336 | .ota | |
| 1026 | 1337 | .get(channel) | |
| @@ -1111,6 +1422,16 @@ impl RecipeCtx { | |||
| 1111 | 1422 | // publishers can't race the read-then-insert because the latest-wins slot | |
| 1112 | 1423 | // (state::ActiveSlot) serializes them and a superseded run is cancelled | |
| 1113 | 1424 | // before it reaches publish. | |
| 1425 | + | // The artifact's hash, recorded so the release ledger says exactly which | |
| 1426 | + | // bytes shipped. Prefer the digest computed at `collect`; fall back to | |
| 1427 | + | // hashing the file now (an absolute-path artifact never routed through | |
| 1428 | + | // `collect`). A hash failure must not fail an already-published release, | |
| 1429 | + | // so degrade to NULL rather than erroring. | |
| 1430 | + | let artifact_hash: Option<String> = artifact_path | |
| 1431 | + | .file_name() | |
| 1432 | + | .and_then(|n| n.to_str()) | |
| 1433 | + | .and_then(|n| self.artifact_hashes.lock().unwrap().get(n).cloned()) | |
| 1434 | + | .or_else(|| sha256_file(&artifact_path).ok()); | |
| 1114 | 1435 | let me = self.clone(); | |
| 1115 | 1436 | let (app_s, target_s, ver_s, chan_s) = ( | |
| 1116 | 1437 | app.to_string(), | |
| @@ -1121,13 +1442,14 @@ impl RecipeCtx { | |||
| 1121 | 1442 | self.rt | |
| 1122 | 1443 | .block_on(async move { | |
| 1123 | 1444 | sqlx::query( | |
| 1124 | - | "INSERT OR IGNORE INTO releases (app, target, version, channel, published_at) | |
| 1125 | - | VALUES (?, ?, ?, ?, ?)", | |
| 1445 | + | "INSERT OR IGNORE INTO releases (app, target, version, channel, artifact_hash, published_at) | |
| 1446 | + | VALUES (?, ?, ?, ?, ?, ?)", | |
| 1126 | 1447 | ) | |
| 1127 | 1448 | .bind(app_s) | |
| 1128 | 1449 | .bind(target_s) | |
| 1129 | 1450 | .bind(ver_s) | |
| 1130 | 1451 | .bind(chan_s) | |
| 1452 | + | .bind(artifact_hash) | |
| 1131 | 1453 | .bind(Self::now()) | |
| 1132 | 1454 | .execute(&me.pool) | |
| 1133 | 1455 | .await | |
| @@ -1370,6 +1692,7 @@ mod tests { | |||
| 1370 | 1692 | Arc::new(OtaRegistry::standard("https://makenot.work")), | |
| 1371 | 1693 | tokio::runtime::Handle::current(), | |
| 1372 | 1694 | cancel.clone(), | |
| 1695 | + | None, | |
| 1373 | 1696 | )); | |
| 1374 | 1697 | // Cancelled: begin_step refuses before touching the DB (the ensure! is | |
| 1375 | 1698 | // ahead of any block_on, so this is safe to call from the async test). | |
| @@ -1403,6 +1726,7 @@ mod tests { | |||
| 1403 | 1726 | Arc::new(OtaRegistry::standard("https://makenot.work")), | |
| 1404 | 1727 | tokio::runtime::Handle::current(), | |
| 1405 | 1728 | Arc::new(AtomicBool::new(false)), | |
| 1729 | + | None, | |
| 1406 | 1730 | )); | |
| 1407 | 1731 | let engine = build_engine(ctx); | |
| 1408 | 1732 | engine.eval::<String>("feature_flags()").unwrap() | |
| @@ -1650,6 +1974,145 @@ mod tests { | |||
| 1650 | 1974 | ); | |
| 1651 | 1975 | } | |
| 1652 | 1976 | ||
| 1977 | + | // ---- version-source cross-check (drift preflight) ---- | |
| 1978 | + | ||
| 1979 | + | fn ver(s: &str) -> Version { | |
| 1980 | + | Version::parse(s).unwrap() | |
| 1981 | + | } | |
| 1982 | + | ||
| 1983 | + | #[test] | |
| 1984 | + | fn version_consistency_passes_when_all_sources_agree() { | |
| 1985 | + | let tmp = tempfile::tempdir().unwrap(); | |
| 1986 | + | let repo = tmp.path(); | |
| 1987 | + | std::fs::create_dir_all(repo.join("src-tauri")).unwrap(); | |
| 1988 | + | std::fs::write( | |
| 1989 | + | repo.join("src-tauri/tauri.conf.json"), | |
| 1990 | + | r#"{"version":"0.5.0"}"#, | |
| 1991 | + | ) | |
| 1992 | + | .unwrap(); | |
| 1993 | + | std::fs::write( | |
| 1994 | + | repo.join("Cargo.toml"), | |
| 1995 | + | "[package]\nname = \"app\"\nversion = \"0.5.0\"\n", | |
| 1996 | + | ) | |
| 1997 | + | .unwrap(); | |
| 1998 | + | check_version_consistency(repo.to_str().unwrap(), None, &ver("0.5.0")).unwrap(); | |
| 1999 | + | } | |
| 2000 | + | ||
| 2001 | + | #[test] | |
| 2002 | + | fn version_consistency_flags_tauri_vs_cargo_drift() { | |
| 2003 | + | // The concrete finding: tauri.conf.json bumped to 0.5.0 but the root | |
| 2004 | + | // Cargo.toml left at 0.4.0. version_from_repo (one file) would miss it. | |
| 2005 | + | let tmp = tempfile::tempdir().unwrap(); | |
| 2006 | + | let repo = tmp.path(); | |
| 2007 | + | std::fs::create_dir_all(repo.join("src-tauri")).unwrap(); | |
| 2008 | + | std::fs::write( | |
| 2009 | + | repo.join("src-tauri/tauri.conf.json"), | |
| 2010 | + | r#"{"version":"0.5.0"}"#, | |
| 2011 | + | ) | |
| 2012 | + | .unwrap(); | |
| 2013 | + | std::fs::write( | |
| 2014 | + | repo.join("Cargo.toml"), | |
| 2015 | + | "[package]\nname = \"app\"\nversion = \"0.4.0\"\n", | |
| 2016 | + | ) | |
| 2017 | + | .unwrap(); | |
| 2018 | + | let err = | |
| 2019 | + | check_version_consistency(repo.to_str().unwrap(), None, &ver("0.5.0")).unwrap_err(); | |
| 2020 | + | let msg = format!("{err:#}"); | |
| 2021 | + | assert!(msg.contains("Cargo.toml says 0.4.0"), "{msg}"); | |
| 2022 | + | } | |
| 2023 | + | ||
| 2024 | + | #[test] | |
| 2025 | + | fn version_consistency_flags_explicit_version_the_repo_does_not_reflect() { | |
| 2026 | + | let tmp = tempfile::tempdir().unwrap(); | |
| 2027 | + | let repo = tmp.path(); | |
| 2028 | + | std::fs::create_dir_all(repo.join("src-tauri")).unwrap(); | |
| 2029 | + | std::fs::write( | |
| 2030 | + | repo.join("src-tauri/tauri.conf.json"), | |
| 2031 | + | r#"{"version":"0.5.0"}"#, | |
| 2032 | + | ) | |
| 2033 | + | .unwrap(); | |
| 2034 | + | let err = | |
| 2035 | + | check_version_consistency(repo.to_str().unwrap(), None, &ver("9.9.9")).unwrap_err(); |
Lines truncated
| @@ -12,6 +12,24 @@ fn mnw_base_url() -> String { | |||
| 12 | 12 | ||
| 13 | 13 | #[tokio::main] | |
| 14 | 14 | async fn main() -> Result<()> { | |
| 15 | + | // `--check-config`: load the daemon config + topology (which reads every | |
| 16 | + | // app's in-repo manifest) and exit 0, or fail with the parse error and a | |
| 17 | + | // non-zero code. Run before swapping in a freshly built bentod so a schema | |
| 18 | + | // change that can't parse the live config fails the install instead of | |
| 19 | + | // bricking the daemon — a per-app-config move once left it down for 20h. | |
| 20 | + | // Mirrors `sandod --check-config`. | |
| 21 | + | if std::env::args().skip(1).any(|a| a == "--check-config") { | |
| 22 | + | let cfg = config::Config::load()?; | |
| 23 | + | let topo = topology::Topology::load(&cfg.topology_path)?; | |
| 24 | + | println!( | |
| 25 | + | "bentod config OK: {} host(s), {} app(s) from {}", | |
| 26 | + | topo.hosts.len(), | |
| 27 | + | topo.app.len(), | |
| 28 | + | cfg.topology_path.display() | |
| 29 | + | ); | |
| 30 | + | return Ok(()); | |
| 31 | + | } | |
| 32 | + | ||
| 15 | 33 | tracing_subscriber::fmt() | |
| 16 | 34 | .with_writer(std::io::stderr) | |
| 17 | 35 | .with_env_filter( | |
| @@ -66,6 +84,7 @@ async fn main() -> Result<()> { | |||
| 66 | 84 | ||
| 67 | 85 | let executors = Arc::new(state::build_executors(&topo)); | |
| 68 | 86 | let syncs = Arc::new(state::build_syncs(&topo)); | |
| 87 | + | let host_locks = state::build_host_locks(&topo); | |
| 69 | 88 | let app_state = state::AppState { | |
| 70 | 89 | pool, | |
| 71 | 90 | topo, | |
| @@ -77,6 +96,7 @@ async fn main() -> Result<()> { | |||
| 77 | 96 | syncs, | |
| 78 | 97 | active: Arc::new(tokio::sync::Mutex::new(HashMap::new())), | |
| 79 | 98 | api_token, | |
| 99 | + | host_locks, | |
| 80 | 100 | }; | |
| 81 | 101 | // Disk retention: prune logs_root + dist_root to the newest N versions per | |
| 82 | 102 | // app, at startup and every 6h, so neither root grows without bound. Skips |
| @@ -34,6 +34,7 @@ pub fn router(state: AppState) -> Router { | |||
| 34 | 34 | let open = Router::new() | |
| 35 | 35 | .route("/state", get(get_state)) | |
| 36 | 36 | .route("/status.json", get(get_status_json)) | |
| 37 | + | .route("/release/{app}/{version}", get(get_release)) | |
| 37 | 38 | .route("/logs/{app}/{version}/{target}/{step}", get(get_step_log)) | |
| 38 | 39 | .route("/events", get(events_ws)); | |
| 39 | 40 | ||
| @@ -150,14 +151,28 @@ async fn get_state(State(s): State<AppState>) -> Result<Json<StateView>> { | |||
| 150 | 151 | /// | |
| 151 | 152 | /// Shared by `/state` and `/status.json` so the two cannot disagree about what | |
| 152 | 153 | /// a build looks like. | |
| 154 | + | /// | |
| 155 | + | /// The matrix is the per-`(app, version)` RELEASE view, not one `builds` row's | |
| 156 | + | /// targets: for each target, the latest `target_runs` row for this app+version | |
| 157 | + | /// across EVERY build of that version. A `/retry` inserts a fresh single-target | |
| 158 | + | /// build, so keying on `build_id` would collapse a 5-target release to the one | |
| 159 | + | /// retried row and lose the answer to "did every target of this version ship". | |
| 160 | + | /// Aggregating by `(app, version)` keeps the full matrix and folds the retry's | |
| 161 | + | /// newer result into just that target's cell. | |
| 153 | 162 | async fn build_view(s: &AppState, b: &sqlx::sqlite::SqliteRow) -> Result<BuildView> { | |
| 154 | 163 | let build_id: i64 = b.get("id"); | |
| 164 | + | let app: String = b.get("app"); | |
| 165 | + | let version: String = b.get("version"); | |
| 155 | 166 | ||
| 156 | 167 | let target_rows = sqlx::query( | |
| 157 | - | "SELECT id, target, status, current_step, error FROM target_runs | |
| 158 | - | WHERE build_id = ? ORDER BY target", | |
| 168 | + | "SELECT id, target, status, current_step, error FROM target_runs tr | |
| 169 | + | WHERE app = ?1 AND version = ?2 | |
| 170 | + | AND id = (SELECT MAX(id) FROM target_runs | |
| 171 | + | WHERE app = ?1 AND version = ?2 AND target = tr.target) | |
| 172 | + | ORDER BY target", | |
| 159 | 173 | ) | |
| 160 | - | .bind(build_id) | |
| 174 | + | .bind(&app) | |
| 175 | + | .bind(&version) | |
| 161 | 176 | .fetch_all(&s.pool) | |
| 162 | 177 | .await?; | |
| 163 | 178 | ||
| @@ -210,6 +225,81 @@ async fn get_status_json(State(s): State<AppState>) -> Result<Json<ops_status::P | |||
| 210 | 225 | Ok(Json(crate::status::payload(&view, chrono::Utc::now()))) | |
| 211 | 226 | } | |
| 212 | 227 | ||
| 228 | + | /// One release, answering "did every target of `{version}` ship?" | |
| 229 | + | #[derive(Serialize)] | |
| 230 | + | struct ReleaseView { | |
| 231 | + | app: String, | |
| 232 | + | version: String, | |
| 233 | + | /// Every target the app's manifest declares for this release. | |
| 234 | + | declared_targets: Vec<String>, | |
| 235 | + | /// The per-`(app, version)` matrix: latest run per target across every build | |
| 236 | + | /// (including retries) of this version. | |
| 237 | + | build: BuildView, | |
| 238 | + | /// Targets with a `releases` row at this version. | |
| 239 | + | published_targets: Vec<String>, | |
| 240 | + | /// Every declared target has a latest run of `ok` — the release built clean. | |
| 241 | + | all_targets_green: bool, | |
| 242 | + | /// `all_targets_green` AND every declared target is published. | |
| 243 | + | complete: bool, | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | /// `GET /release/{app}/{version}` -- the release view for a SPECIFIC version, not | |
| 247 | + | /// just the latest. Unlike `/state`, a retry can't hide it and an unrelated | |
| 248 | + | /// newer build can't mask it; this is the durable "did 0.5.0 fully ship" query. | |
| 249 | + | async fn get_release( | |
| 250 | + | State(s): State<AppState>, | |
| 251 | + | Path((app, version)): Path<(String, String)>, | |
| 252 | + | ) -> Result<Json<ReleaseView>> { | |
| 253 | + | let cfg = s | |
| 254 | + | .topo | |
| 255 | + | .app(&AppId::new(app.clone())) | |
| 256 | + | .ok_or_else(|| Error::BadRequest(format!("unknown app `{app}`")))?; | |
| 257 | + | let declared_targets: Vec<String> = cfg.targets.iter().map(ToString::to_string).collect(); | |
| 258 | + | ||
| 259 | + | // A representative build row for this exact version (newest wins for the | |
| 260 | + | // build-level fields; the target matrix is aggregated across all of them). | |
| 261 | + | let row = sqlx::query( | |
| 262 | + | "SELECT id, app, version, status, created_at FROM builds | |
| 263 | + | WHERE app = ? AND version = ? ORDER BY id DESC LIMIT 1", | |
| 264 | + | ) | |
| 265 | + | .bind(&app) | |
| 266 | + | .bind(&version) | |
| 267 | + | .fetch_optional(&s.pool) | |
| 268 | + | .await? | |
| 269 | + | .ok_or(Error::NotFound)?; | |
| 270 | + | let build = build_view(&s, &row).await?; | |
| 271 | + | ||
| 272 | + | let published_targets = sqlx::query_scalar::<_, String>( | |
| 273 | + | "SELECT DISTINCT target FROM releases WHERE app = ? AND version = ? ORDER BY target", | |
| 274 | + | ) | |
| 275 | + | .bind(&app) | |
| 276 | + | .bind(&version) | |
| 277 | + | .fetch_all(&s.pool) | |
| 278 | + | .await?; | |
| 279 | + | ||
| 280 | + | // Green = every declared target has a latest run that is `ok`. | |
| 281 | + | let all_targets_green = declared_targets.iter().all(|d| { | |
| 282 | + | build | |
| 283 | + | .targets | |
| 284 | + | .iter() | |
| 285 | + | .any(|t| &t.target == d && t.status == "ok") | |
| 286 | + | }); | |
| 287 | + | let complete = all_targets_green | |
| 288 | + | && declared_targets | |
| 289 | + | .iter() | |
| 290 | + | .all(|d| published_targets.contains(d)); | |
| 291 | + | ||
| 292 | + | Ok(Json(ReleaseView { | |
| 293 | + | app, | |
| 294 | + | version, | |
| 295 | + | declared_targets, | |
| 296 | + | build, | |
| 297 | + | published_targets, | |
| 298 | + | all_targets_green, | |
| 299 | + | complete, | |
| 300 | + | })) | |
| 301 | + | } | |
| 302 | + | ||
| 213 | 303 | /// One [`AppStatusView`] per app in the topology, name-ordered. | |
| 214 | 304 | /// | |
| 215 | 305 | /// Every declared app appears whether or not it has ever been built: an app | |
| @@ -456,6 +546,7 @@ repo = "{}" | |||
| 456 | 546 | .unwrap(); | |
| 457 | 547 | let executors = Arc::new(crate::state::build_executors(&topo)); | |
| 458 | 548 | let syncs = Arc::new(crate::state::build_syncs(&topo)); | |
| 549 | + | let host_locks = crate::state::build_host_locks(&topo); | |
| 459 | 550 | AppState { | |
| 460 | 551 | pool, | |
| 461 | 552 | topo: Arc::new(topo), | |
| @@ -467,6 +558,7 @@ repo = "{}" | |||
| 467 | 558 | syncs, | |
| 468 | 559 | active: Arc::new(Mutex::new(HashMap::new())), | |
| 469 | 560 | api_token: None, | |
| 561 | + | host_locks, | |
| 470 | 562 | } | |
| 471 | 563 | } | |
| 472 | 564 | ||
| @@ -713,4 +805,119 @@ repo = "{}" | |||
| 713 | 805 | .unwrap(); | |
| 714 | 806 | assert_eq!(resp.status(), StatusCode::BAD_REQUEST); | |
| 715 | 807 | } | |
| 808 | + | ||
| 809 | + | /// A two-target app + a two-host topology, for the release-view aggregation. | |
| 810 | + | async fn two_target_state(root: &std::path::Path) -> AppState { | |
| 811 | + | let cfg = Config::for_tests(root); | |
| 812 | + | let pool = crate::db::open(&cfg.db_path).await.unwrap(); | |
| 813 | + | let repo = root.join("demo"); | |
| 814 | + | std::fs::create_dir_all(&repo).unwrap(); | |
| 815 | + | std::fs::write( | |
| 816 | + | repo.join("bento.toml"), | |
| 817 | + | "targets = [\"linux/x86_64\", \"macos/aarch64\"]\n", | |
| 818 | + | ) | |
| 819 | + | .unwrap(); | |
| 820 | + | let topo = Topology::from_str_for_tests(&format!( | |
| 821 | + | "[[host]]\nname = \"fw13\"\nssh = \"local\"\ntargets = [\"linux/x86_64\"]\n\ | |
| 822 | + | [[host]]\nname = \"mbp\"\nssh = \"mbp\"\ntargets = [\"macos/aarch64\"]\n\ | |
| 823 | + | [app.demo]\nrepo = \"{}\"\n", | |
| 824 | + | repo.display() | |
| 825 | + | )) | |
| 826 | + | .unwrap(); | |
| 827 | + | let executors = Arc::new(crate::state::build_executors(&topo)); | |
| 828 | + | let syncs = Arc::new(crate::state::build_syncs(&topo)); | |
| 829 | + | let host_locks = crate::state::build_host_locks(&topo); | |
| 830 | + | AppState { | |
| 831 | + | pool, | |
| 832 | + | topo: Arc::new(topo), | |
| 833 | + | cfg: Arc::new(cfg), | |
| 834 | + | prom: crate::metrics::test_handle(), | |
| 835 | + | events: crate::events::channel(), | |
| 836 | + | ota: Arc::new(OtaRegistry::standard("https://makenot.work")), | |
| 837 | + | executors, | |
| 838 | + | syncs, | |
| 839 | + | active: Arc::new(Mutex::new(HashMap::new())), | |
| 840 | + | api_token: None, | |
| 841 | + | host_locks, | |
| 842 | + | } | |
| 843 | + | } | |
| 844 | + | ||
| 845 | + | async fn insert_target_run(pool: &sqlx::SqlitePool, ver: &str, target: &str, status: &str) { | |
| 846 | + | let bid: i64 = sqlx::query_scalar( | |
| 847 | + | "INSERT INTO builds (app, version, status, created_at) VALUES ('demo', ?, ?, '2026-07-23T00:00:00Z') RETURNING id", | |
| 848 | + | ) | |
| 849 | + | .bind(ver) | |
| 850 | + | .bind(status) | |
| 851 | + | .fetch_one(pool) | |
| 852 | + | .await | |
| 853 | + | .unwrap(); | |
| 854 | + | sqlx::query( | |
| 855 | + | "INSERT INTO target_runs (build_id, app, version, target, status, started_at) | |
| 856 | + | VALUES (?, 'demo', ?, ?, ?, '2026-07-23T00:00:00Z')", | |
| 857 | + | ) | |
| 858 | + | .bind(bid) | |
| 859 | + | .bind(ver) | |
| 860 | + | .bind(target) | |
| 861 | + | .bind(status) | |
| 862 | + | .execute(pool) | |
| 863 | + | .await | |
| 864 | + | .unwrap(); | |
| 865 | + | } | |
| 866 | + | ||
| 867 | + | /// The audit's H2: `/retry` inserts a whole new single-target build, so a | |
| 868 | + | /// build-id-keyed view collapses a multi-target release to the retried row. | |
| 869 | + | /// The release view aggregates by (app, version), so a green retry of one | |
| 870 | + | /// target folds into that cell while the other target stays visible. | |
| 871 | + | #[tokio::test] | |
| 872 | + | async fn release_view_survives_a_single_target_retry() { | |
| 873 | + | let tmp = tempfile::tempdir().unwrap(); | |
| 874 | + | let state = two_target_state(tmp.path()).await; | |
| 875 | + | let pool = state.pool.clone(); | |
| 876 | + | ||
| 877 | + | // Original release build: linux ok, macos failed. | |
| 878 | + | insert_target_run(&pool, "0.5.0", "linux/x86_64", "ok").await; | |
| 879 | + | insert_target_run(&pool, "0.5.0", "macos/aarch64", "failed").await; | |
| 880 | + | // Retry macos only — a fresh single-target build that succeeds. | |
| 881 | + | insert_target_run(&pool, "0.5.0", "macos/aarch64", "ok").await; | |
| 882 | + | ||
| 883 | + | let app = router(state); | |
| 884 | + | let resp = app | |
| 885 | + | .oneshot( | |
| 886 | + | Request::builder() | |
| 887 | + | .uri("/release/demo/0.5.0") | |
| 888 | + | .body(Body::empty()) | |
| 889 | + | .unwrap(), | |
| 890 | + | ) | |
| 891 | + | .await | |
| 892 | + | .unwrap(); | |
| 893 | + | assert_eq!(resp.status(), StatusCode::OK); | |
| 894 | + | let v: serde_json::Value = serde_json::from_str(&body_string(resp).await).unwrap(); | |
| 895 | + | ||
| 896 | + | // Both targets are present (the matrix did not collapse to the retry), | |
| 897 | + | // and macos shows the newer `ok`, not the earlier `failed`. | |
| 898 | + | let targets = v["build"]["targets"].as_array().unwrap(); | |
| 899 | + | assert_eq!(targets.len(), 2, "full matrix preserved across the retry"); | |
| 900 | + | let macos = targets | |
| 901 | + | .iter() | |
| 902 | + | .find(|t| t["target"] == "macos/aarch64") | |
| 903 | + | .unwrap(); | |
| 904 | + | assert_eq!(macos["status"], "ok", "retry result wins the cell"); | |
| 905 | + | assert_eq!(v["all_targets_green"], true); | |
| 906 | + | } | |
| 907 | + | ||
| 908 | + | #[tokio::test] | |
| 909 | + | async fn release_view_404s_for_an_unbuilt_version() { | |
| 910 | + | let tmp = tempfile::tempdir().unwrap(); | |
| 911 | + | let app = router(two_target_state(tmp.path()).await); | |
| 912 | + | let resp = app | |
| 913 | + | .oneshot( | |
| 914 | + | Request::builder() | |
| 915 | + | .uri("/release/demo/9.9.9") | |
| 916 | + | .body(Body::empty()) | |
| 917 | + | .unwrap(), | |
| 918 | + | ) | |
| 919 | + | .await | |
| 920 | + | .unwrap(); | |
| 921 | + | assert_eq!(resp.status(), StatusCode::NOT_FOUND); | |
| 922 | + | } | |
| 716 | 923 | } |
| @@ -71,6 +71,15 @@ pub async fn start_build( | |||
| 71 | 71 | version: Version, | |
| 72 | 72 | targets: Vec<Target>, | |
| 73 | 73 | ) -> Result<i64> { | |
| 74 | + | // Preflight: every version source in the repo must agree with the version | |
| 75 | + | // being built, before any host pulls or compiles. `version_from_repo` reads | |
| 76 | + | // one file, so a tauri.conf.json/Cargo.toml (or explicit-version) mismatch | |
| 77 | + | // would otherwise sail through and file artifacts under the wrong version. | |
| 78 | + | if let Some(cfg) = state.topo.app(&app) { | |
| 79 | + | engine::check_version_consistency(&cfg.repo, cfg.version_path.as_deref(), &version) | |
| 80 | + | .context("version preflight")?; | |
| 81 | + | } | |
| 82 | + | ||
| 74 | 83 | let build_id: i64 = sqlx::query_scalar( | |
| 75 | 84 | "INSERT INTO builds (app, version, status, created_at) VALUES (?, ?, 'running', ?) RETURNING id", | |
| 76 | 85 | ) | |
| @@ -254,6 +263,13 @@ async fn run_target( | |||
| 254 | 263 | .app(&app) | |
| 255 | 264 | .map(|a| a.features.clone()) | |
| 256 | 265 | .unwrap_or_default(); | |
| 266 | + | // The opt-in all-targets-green publish gate: pass the declared target set | |
| 267 | + | // (so `publish` can require every sibling green) only when the app turns it | |
| 268 | + | // on; otherwise None leaves independent per-target publishing unchanged. | |
| 269 | + | let all_green_required = state | |
| 270 | + | .topo | |
| 271 | + | .app(&app) | |
| 272 | + | .and_then(|a| a.require_all_targets.then(|| a.targets.clone())); | |
| 257 | 273 | ||
| 258 | 274 | let ctx = Arc::new(RecipeCtx::new( | |
| 259 | 275 | app.clone(), | |
| @@ -271,8 +287,21 @@ async fn run_target( | |||
| 271 | 287 | state.ota.clone(), | |
| 272 | 288 | tokio::runtime::Handle::current(), | |
| 273 | 289 | cancel, | |
| 290 | + | all_green_required, | |
| 274 | 291 | )); | |
| 275 | 292 | ||
| 293 | + | // Serialize per host: hold this host's lock for the whole recipe run so a | |
| 294 | + | // second target on the same box (goingson macos + ios both on mbp) can't | |
| 295 | + | // build concurrently in one checkout and corrupt the shared target/ + | |
| 296 | + | // keychain. Targets on different hosts hold different locks and still fan | |
| 297 | + | // out. Acquired at an await point, so a supersede-abort while queued drops | |
| 298 | + | // the task cleanly before it ever takes the lock. Not held during the | |
| 299 | + | // read-only preflight above. | |
| 300 | + | let _host_guard = match state.host_locks.get(&ctx.build_host).cloned() { | |
| 301 | + | Some(lock) => Some(lock.lock_owned().await), | |
| 302 | + | None => None, | |
| 303 | + | }; | |
| 304 | + | ||
| 276 | 305 | // Rhai is synchronous; run the recipe (and its final step finalization) on | |
| 277 | 306 | // a blocking thread so host functions can `block_on` without sitting on a | |
| 278 | 307 | // runtime worker. | |
| @@ -398,11 +427,18 @@ async fn fail_target( | |||
| 398 | 427 | /// | |
| 399 | 428 | /// Event-driven: it joins the `JoinSet` rather than polling the DB. Each task's | |
| 400 | 429 | /// outcome is supervised — a panicked task is logged (and its still-`running` | |
| 401 | - | /// row is reconciled below), an aborted (superseded) task is expected. A | |
| 402 | - | /// generous overall deadline bounds a wedged step (e.g. a hung ssh) so the build | |
| 403 | - | /// can always finalize; on deadline the remaining tasks are aborted. | |
| 430 | + | /// row is reconciled below), an aborted (superseded) task is expected. | |
| 431 | + | /// | |
| 432 | + | /// Per-step deadlines (see `engine::step_budget`) are the real bound on a wedged | |
| 433 | + | /// step now; this overall deadline is only a generous last-resort backstop for a | |
| 434 | + | /// hang outside a bounded command. When it trips it sets each still-running | |
| 435 | + | /// target's cooperative cancel FIRST — the blocking recipe bodies observe that | |
| 436 | + | /// at their next bounded command or step boundary and stop signing — then aborts | |
| 437 | + | /// the async wrappers. The old code aborted only the wrappers, which could not | |
| 438 | + | /// reach the blocking bodies, so a build was marked failed while codesign kept | |
| 439 | + | /// running on the mac. | |
| 404 | 440 | async fn finalize_build(state: AppState, build_id: i64, mut set: tokio::task::JoinSet<()>) { | |
| 405 | - | const MAX_WAIT: std::time::Duration = std::time::Duration::from_secs(2 * 60 * 60); | |
| 441 | + | const MAX_WAIT: std::time::Duration = std::time::Duration::from_secs(6 * 60 * 60); | |
| 406 | 442 | let deadline = tokio::time::Instant::now() + MAX_WAIT; | |
| 407 | 443 | loop { | |
| 408 | 444 | match tokio::time::timeout_at(deadline, set.join_next()).await { | |
| @@ -418,8 +454,17 @@ async fn finalize_build(state: AppState, build_id: i64, mut set: tokio::task::Jo | |||
| 418 | 454 | Err(_elapsed) => { | |
| 419 | 455 | tracing::error!( | |
| 420 | 456 | build_id, | |
| 421 | - | "finalize_build deadline hit; aborting remaining target tasks" | |
| 457 | + | "finalize_build backstop deadline hit; cancelling then aborting remaining targets" | |
| 422 | 458 | ); | |
| 459 | + | // Set the cooperative cancel on this build's still-running slots | |
| 460 | + | // BEFORE aborting, so the blocking recipe bodies actually stop | |
| 461 | + | // (abort() alone cannot reach a spawn_blocking body). | |
| 462 | + | { | |
| 463 | + | let active = state.active.lock().await; | |
| 464 | + | for slot in active.values().filter(|s| s.build_id == build_id) { | |
| 465 | + | slot.cancel.store(true, std::sync::atomic::Ordering::SeqCst); | |
| 466 | + | } | |
| 467 | + | } | |
| 423 | 468 | set.abort_all(); | |
| 424 | 469 | while set.join_next().await.is_some() {} | |
| 425 | 470 | break; | |
| @@ -602,6 +647,7 @@ mod tests { | |||
| 602 | 647 | fn test_state(pool: SqlitePool, topo: Topology, cfg: Config) -> AppState { | |
| 603 | 648 | let executors = Arc::new(crate::state::build_executors(&topo)); | |
| 604 | 649 | let syncs = Arc::new(crate::state::build_syncs(&topo)); | |
| 650 | + | let host_locks = crate::state::build_host_locks(&topo); | |
| 605 | 651 | AppState { | |
| 606 | 652 | pool, | |
| 607 | 653 | topo: Arc::new(topo), | |
| @@ -613,6 +659,7 @@ mod tests { | |||
| 613 | 659 | syncs, | |
| 614 | 660 | active: Arc::new(Mutex::new(HashMap::new())), | |
| 615 | 661 | api_token: None, | |
| 662 | + | host_locks, | |
| 616 | 663 | } | |
| 617 | 664 | } | |
| 618 | 665 | ||
| @@ -1180,6 +1227,300 @@ repo = "{}" | |||
| 1180 | 1227 | count, 1, | |
| 1181 | 1228 | "only the first (newer) publish should record a release" | |
| 1182 | 1229 | ); | |
| 1230 | + | // The recorded release carries the artifact's sha256 (64 hex chars), | |
| 1231 | + | // not a NULL — the ledger says which bytes shipped. | |
| 1232 | + | let hash: Option<String> = | |
| 1233 | + | sqlx::query_scalar("SELECT artifact_hash FROM releases WHERE version = '0.2.0'") | |
| 1234 | + | .fetch_one(&pool) | |
| 1235 | + | .await | |
| 1236 | + | .unwrap(); | |
| 1237 | + | assert!( | |
| 1238 | + | hash.as_deref().is_some_and(|h| h.len() == 64), | |
| 1239 | + | "publish must record the artifact sha256, got {hash:?}" | |
| 1240 | + | ); | |
| 1241 | + | } | |
| 1242 | + | ||
| 1243 | + | /// The artifact-identity fix at collect: a stale, wrongly-versioned artifact | |
| 1244 | + | /// left in the output dir fails the collect instead of silently winning a | |
| 1245 | + | /// later glob. Here the build is 0.0.1 but the recipe produces a 9.9.9 file. | |
| 1246 | + | #[tokio::test(flavor = "multi_thread", worker_threads = 2)] | |
| 1247 | + | async fn collect_rejects_a_stale_versioned_artifact() { | |
| 1248 | + | let tmp = tempfile::tempdir().unwrap(); | |
| 1249 | + | let root = tmp.path(); | |
| 1250 | + | let repo = root.join("app"); | |
| 1251 | + | std::fs::create_dir_all(repo.join("src-tauri")).unwrap(); | |
| 1252 | + | std::fs::write( | |
| 1253 | + | repo.join("src-tauri/tauri.conf.json"), | |
| 1254 | + | r#"{"version":"0.0.1"}"#, | |
| 1255 | + | ) | |
| 1256 | + | .unwrap(); | |
| 1257 | + | std::fs::create_dir_all(repo.join("dist/recipes")).unwrap(); | |
| 1258 | + | std::fs::write( | |
| 1259 | + | repo.join("dist/recipes/linux.rhai"), | |
| 1260 | + | r#" | |
| 1261 | + | step("build"); | |
| 1262 | + | let v = version_of("demo"); | |
| 1263 | + | sh_ok("fw13", "mkdir -p REPO/out && echo bin > REPO/out/demo-9.9.9.bin"); | |
| 1264 | + | step("collect"); | |
| 1265 | + | collect("fw13", "REPO/out/demo-9.9.9.bin", "demo", v); | |
| 1266 | + | "# | |
| 1267 | + | .replace("REPO", repo.to_str().unwrap()), | |
| 1268 | + | ) | |
| 1269 | + | .unwrap(); | |
| 1270 | + | ||
| 1271 | + | let cfg = Config::for_tests(root); | |
| 1272 | + | let pool = crate::db::open(&cfg.db_path).await.unwrap(); | |
| 1273 | + | std::fs::write(repo.join("bento.toml"), "targets = [\"linux/x86_64\"]\n").unwrap(); | |
| 1274 | + | let topo = Topology::from_str_for_tests(&format!( | |
| 1275 | + | "[[host]]\nname = \"fw13\"\nssh = \"local\"\ntargets = [\"linux/x86_64\"]\n\ | |
| 1276 | + | pull_root = \"{repo}\"\n\n[app.demo]\nrepo = \"{repo}\"\n", | |
| 1277 | + | repo = repo.display() | |
| 1278 | + | )) | |
| 1279 | + | .unwrap(); | |
| 1280 | + | let state = test_state(pool.clone(), topo, cfg); | |
| 1281 | + | let build_id = start_build( | |
| 1282 | + | state.clone(), | |
| 1283 | + | AppId::new("demo"), | |
| 1284 | + | Version::parse("0.0.1").unwrap(), | |
| 1285 | + | vec!["linux/x86_64".parse().unwrap()], | |
| 1286 | + | ) | |
| 1287 | + | .await | |
| 1288 | + | .unwrap(); | |
| 1289 | + | ||
| 1290 | + | let mut status = String::new(); | |
| 1291 | + | let mut error = String::new(); | |
| 1292 | + | for _ in 0..100 { | |
| 1293 | + | let row: Option<(String, Option<String>)> = | |
| 1294 | + | sqlx::query_as("SELECT status, error FROM target_runs WHERE build_id = ?") | |
| 1295 | + | .bind(build_id) | |
| 1296 | + | .fetch_optional(&pool) | |
| 1297 | + | .await | |
| 1298 | + | .unwrap(); | |
| 1299 | + | if let Some((s, e)) = row { | |
| 1300 | + | status = s; | |
| 1301 | + | error = e.unwrap_or_default(); | |
| 1302 | + | if status != "running" { | |
| 1303 | + | break; | |
| 1304 | + | } | |
| 1305 | + | } | |
| 1306 | + | tokio::time::sleep(std::time::Duration::from_millis(50)).await; | |
| 1307 | + | } | |
| 1308 | + | assert_eq!( | |
| 1309 | + | status, "failed", | |
| 1310 | + | "a mismatched-version artifact must fail collect" | |
| 1311 | + | ); | |
| 1312 | + | assert!( | |
| 1313 | + | error.contains("stale artifact"), | |
| 1314 | + | "expected a stale-artifact error, got: {error}" | |
| 1315 | + | ); | |
| 1316 | + | } | |
| 1317 | + | ||
| 1318 | + | /// A command that runs past its step's deadline fails THAT step (and unwinds | |
| 1319 | + | /// the recipe) rather than wedging under the old whole-build guillotine. The | |
| 1320 | + | /// per-step budget is overridden to 1s here; the recipe then sleeps 30s, so | |
| 1321 | + | /// the step deadline — not the sleep — decides the outcome, quickly. | |
| 1322 | + | #[tokio::test(flavor = "multi_thread", worker_threads = 2)] | |
| 1323 | + | async fn a_step_that_exceeds_its_deadline_fails() { | |
| 1324 | + | let tmp = tempfile::tempdir().unwrap(); | |
| 1325 | + | let root = tmp.path(); | |
| 1326 | + | let repo = root.join("app"); | |
| 1327 | + | std::fs::create_dir_all(repo.join("src-tauri")).unwrap(); | |
| 1328 | + | std::fs::write( | |
| 1329 | + | repo.join("src-tauri/tauri.conf.json"), | |
| 1330 | + | r#"{"version":"0.0.1"}"#, | |
| 1331 | + | ) | |
| 1332 | + | .unwrap(); | |
| 1333 | + | std::fs::create_dir_all(repo.join("dist/recipes")).unwrap(); | |
| 1334 | + | std::fs::write( | |
| 1335 | + | repo.join("dist/recipes/linux.rhai"), | |
| 1336 | + | r#" | |
| 1337 | + | step("build"); | |
| 1338 | + | sh_ok("fw13", "sleep 30"); | |
| 1339 | + | "#, | |
| 1340 | + | ) | |
| 1341 | + | .unwrap(); | |
| 1342 | + | ||
| 1343 | + | let mut cfg = Config::for_tests(root); | |
| 1344 | + | cfg.step_timeout_secs = Some(1); // every step's budget -> 1s | |
| 1345 | + | let pool = crate::db::open(&cfg.db_path).await.unwrap(); | |
| 1346 | + | std::fs::write(repo.join("bento.toml"), "targets = [\"linux/x86_64\"]\n").unwrap(); | |
| 1347 | + | let topo = Topology::from_str_for_tests(&format!( | |
| 1348 | + | "[[host]]\nname = \"fw13\"\nssh = \"local\"\ntargets = [\"linux/x86_64\"]\n\ | |
| 1349 | + | [app.demo]\nrepo = \"{}\"\n", | |
| 1350 | + | repo.display() | |
| 1351 | + | )) | |
| 1352 | + | .unwrap(); | |
| 1353 | + | let state = test_state(pool.clone(), topo, cfg); | |
| 1354 | + | let started = std::time::Instant::now(); | |
| 1355 | + | let build_id = start_build( | |
| 1356 | + | state.clone(), | |
| 1357 | + | AppId::new("demo"), | |
| 1358 | + | Version::parse("0.0.1").unwrap(), | |
| 1359 | + | vec!["linux/x86_64".parse().unwrap()], | |
| 1360 | + | ) | |
| 1361 | + | .await | |
| 1362 | + | .unwrap(); | |
| 1363 | + | ||
| 1364 | + | let mut status = String::new(); | |
| 1365 | + | let mut error = String::new(); | |
| 1366 | + | for _ in 0..100 { | |
| 1367 | + | let row: Option<(String, Option<String>)> = | |
| 1368 | + | sqlx::query_as("SELECT status, error FROM target_runs WHERE build_id = ?") | |
| 1369 | + | .bind(build_id) | |
| 1370 | + | .fetch_optional(&pool) | |
| 1371 | + | .await | |
| 1372 | + | .unwrap(); | |
| 1373 | + | if let Some((s, e)) = row { | |
| 1374 | + | status = s; | |
| 1375 | + | error = e.unwrap_or_default(); | |
| 1376 | + | if status != "running" { | |
| 1377 | + | break; | |
| 1378 | + | } | |
| 1379 | + | } | |
| 1380 | + | tokio::time::sleep(std::time::Duration::from_millis(50)).await; | |
| 1381 | + | } | |
| 1382 | + | assert_eq!(status, "failed", "a step past its deadline must fail"); | |
| 1383 | + | assert!( | |
| 1384 | + | error.contains("per-step deadline"), | |
| 1385 | + | "expected a deadline error, got: {error}" | |
| 1386 | + | ); | |
| 1387 | + | // The deadline (1s), not the 30s sleep, decided it — proof the command | |
| 1388 | + | // was actually interrupted rather than run to completion. | |
| 1389 | + | assert!( | |
| 1390 | + | started.elapsed() < std::time::Duration::from_secs(20), | |
| 1391 | + | "the step deadline must fire well before the sleep would finish" | |
| 1392 | + | ); | |
| 1393 | + | } | |
| 1394 | + | ||
| 1395 | + | /// A demo app that publishes linux and has the all-targets-green gate on; it | |
| 1396 | + | /// declares linux + macos, so publishing linux is gated on macos being green. | |
| 1397 | + | async fn gate_state(root: &std::path::Path) -> (AppState, sqlx::SqlitePool) { | |
| 1398 | + | let repo = root.join("app"); | |
| 1399 | + | std::fs::create_dir_all(repo.join("src-tauri")).unwrap(); | |
| 1400 | + | std::fs::write( | |
| 1401 | + | repo.join("src-tauri/tauri.conf.json"), | |
| 1402 | + | r#"{"version":"0.2.0"}"#, | |
| 1403 | + | ) | |
| 1404 | + | .unwrap(); | |
| 1405 | + | std::fs::create_dir_all(repo.join("dist/recipes")).unwrap(); | |
| 1406 | + | let artifact = repo.join("out/app.tar.gz"); | |
| 1407 | + | std::fs::write( | |
| 1408 | + | repo.join("dist/recipes/linux.rhai"), | |
| 1409 | + | r#" | |
| 1410 | + | step("build"); | |
| 1411 | + | sh_ok("fw13", "mkdir -p REPO/out && echo bin > ARTIFACT"); | |
| 1412 | + | step("publish"); | |
| 1413 | + | publish("tauri-mnw", "demo", "linux/x86_64", "0.2.0", "ARTIFACT", #{}); | |
| 1414 | + | "# | |
| 1415 | + | .replace("ARTIFACT", artifact.to_str().unwrap()) | |
| 1416 | + | .replace("REPO", repo.to_str().unwrap()), | |
| 1417 | + | ) | |
| 1418 | + | .unwrap(); | |
| 1419 | + | std::fs::write( | |
| 1420 | + | repo.join("bento.toml"), | |
| 1421 | + | "targets = [\"linux/x86_64\", \"macos/aarch64\"]\nrequire_all_targets = true\n", | |
| 1422 | + | ) | |
| 1423 | + | .unwrap(); | |
| 1424 | + | let cfg = Config::for_tests(root); | |
| 1425 | + | let pool = crate::db::open(&cfg.db_path).await.unwrap(); | |
| 1426 | + | let topo = Topology::from_str_for_tests(&format!( | |
| 1427 | + | "[[host]]\nname = \"fw13\"\nssh = \"local\"\ntargets = [\"linux/x86_64\"]\n\ | |
| 1428 | + | [[host]]\nname = \"mbp\"\nssh = \"mbp\"\ntargets = [\"macos/aarch64\"]\n\ | |
| 1429 | + | [app.demo]\nrepo = \"{}\"\n", | |
| 1430 | + | repo.display() | |
| 1431 | + | )) | |
| 1432 | + | .unwrap(); | |
| 1433 | + | (test_state(pool.clone(), topo, cfg), pool) | |
| 1434 | + | } | |
| 1435 | + | ||
| 1436 | + | async fn await_target(pool: &sqlx::SqlitePool, build_id: i64) -> (String, String) { | |
| 1437 | + | for _ in 0..100 { | |
| 1438 | + | let row: Option<(String, Option<String>)> = | |
| 1439 | + | sqlx::query_as("SELECT status, error FROM target_runs WHERE build_id = ?") | |
| 1440 | + | .bind(build_id) | |
| 1441 | + | .fetch_optional(pool) | |
| 1442 | + | .await | |
| 1443 | + | .unwrap(); | |
| 1444 | + | if let Some((s, e)) = row | |
| 1445 | + | && s != "running" | |
| 1446 | + | { | |
| 1447 | + | return (s, e.unwrap_or_default()); | |
| 1448 | + | } | |
| 1449 | + | tokio::time::sleep(std::time::Duration::from_millis(50)).await; | |
| 1450 | + | } | |
| 1451 | + | panic!("target never settled"); | |
| 1452 | + | } | |
| 1453 | + | ||
| 1454 | + | /// The all-targets-green gate blocks a partial release: linux tries to | |
| 1455 | + | /// publish while macos has no successful run, so publish is refused and the | |
| 1456 | + | /// target fails at its publish step. | |
| 1457 | + | #[tokio::test(flavor = "multi_thread", worker_threads = 2)] | |
| 1458 | + | async fn all_green_gate_blocks_publish_when_a_sibling_is_not_green() { | |
| 1459 | + | let tmp = tempfile::tempdir().unwrap(); | |
| 1460 | + | let (state, pool) = gate_state(tmp.path()).await; | |
| 1461 | + | let build_id = start_build( | |
| 1462 | + | state, | |
| 1463 | + | AppId::new("demo"), | |
| 1464 | + | Version::parse("0.2.0").unwrap(), | |
| 1465 | + | vec!["linux/x86_64".parse().unwrap()], | |
| 1466 | + | ) | |
| 1467 | + | .await | |
| 1468 | + | .unwrap(); | |
| 1469 | + | let (status, error) = await_target(&pool, build_id).await; | |
| 1470 | + | assert_eq!(status, "failed", "a partial release must be blocked"); | |
| 1471 | + | assert!( | |
| 1472 | + | error.contains("all-targets-green gate"), | |
| 1473 | + | "expected the gate to name itself, got: {error}" | |
| 1474 | + | ); | |
| 1475 | + | // Nothing shipped. | |
| 1476 | + | let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM releases") | |
| 1477 | + | .fetch_one(&pool) | |
| 1478 | + | .await | |
| 1479 | + | .unwrap(); | |
| 1480 | + | assert_eq!(count, 0, "a gated-off publish records no release"); | |
| 1481 | + | } | |
| 1482 | + | ||
| 1483 | + | /// With every sibling green, the gate lets the publish through. macos is | |
| 1484 | + | /// pre-recorded `ok` for this version, so linux's publish proceeds. | |
| 1485 | + | #[tokio::test(flavor = "multi_thread", worker_threads = 2)] | |
| 1486 | + | async fn all_green_gate_allows_publish_when_every_sibling_is_green() { | |
| 1487 | + | let tmp = tempfile::tempdir().unwrap(); | |
| 1488 | + | let (state, pool) = gate_state(tmp.path()).await; | |
| 1489 | + | // Pre-record a green macos run at 0.2.0 (as if its target already built). | |
| 1490 | + | let bid: i64 = sqlx::query_scalar( | |
| 1491 | + | "INSERT INTO builds (app, version, status, created_at) VALUES ('demo','0.2.0','ok','2026-07-23T00:00:00Z') RETURNING id", | |
| 1492 | + | ) | |
| 1493 | + | .fetch_one(&pool) | |
| 1494 | + | .await | |
| 1495 | + | .unwrap(); | |
| 1496 | + | sqlx::query( | |
| 1497 | + | "INSERT INTO target_runs (build_id, app, version, target, status, started_at) | |
| 1498 | + | VALUES (?, 'demo', '0.2.0', 'macos/aarch64', 'ok', '2026-07-23T00:00:00Z')", | |
| 1499 | + | ) | |
| 1500 | + | .bind(bid) | |
| 1501 | + | .execute(&pool) | |
| 1502 | + | .await | |
| 1503 | + | .unwrap(); | |
| 1504 | + | ||
| 1505 | + | let build_id = start_build( | |
| 1506 | + | state, | |
| 1507 | + | AppId::new("demo"), | |
| 1508 | + | Version::parse("0.2.0").unwrap(), | |
| 1509 | + | vec!["linux/x86_64".parse().unwrap()], | |
| 1510 | + | ) | |
| 1511 | + | .await | |
| 1512 | + | .unwrap(); | |
| 1513 | + | let (status, error) = await_target(&pool, build_id).await; | |
| 1514 | + | assert_eq!( | |
| 1515 | + | status, "ok", | |
| 1516 | + | "all siblings green -> publish proceeds ({error})" | |
| 1517 | + | ); | |
| 1518 | + | let count: i64 = | |
| 1519 | + | sqlx::query_scalar("SELECT COUNT(*) FROM releases WHERE target = 'linux/x86_64'") | |
| 1520 | + | .fetch_one(&pool) | |
| 1521 | + | .await | |
| 1522 | + | .unwrap(); | |
| 1523 | + | assert_eq!(count, 1, "linux published once the gate was satisfied"); | |
| 1183 | 1524 | } | |
| 1184 | 1525 | ||
| 1185 | 1526 | /// The audit fix: a `build` step dispatched to a host whose executor lacks the |
| @@ -35,6 +35,26 @@ pub struct ActiveSlot { | |||
| 35 | 35 | /// handle); a finishing build reaps only the slots it still owns. | |
| 36 | 36 | pub type ActiveBuilds = Arc<Mutex<HashMap<(AppId, Target), ActiveSlot>>>; | |
| 37 | 37 | ||
| 38 | + | /// One serialization lock per build host. A target holds its host's lock across | |
| 39 | + | /// the whole recipe run, so two targets that land on the same host never build | |
| 40 | + | /// concurrently in one checkout. Without it, goingson's `macos/aarch64` and | |
| 41 | + | /// `ios/universal` — both on mbp — run at once in `~/Code/Apps/goingson`: | |
| 42 | + | /// concurrent `git pull`, a shared `target/`, `release-ios.sh` rewriting | |
| 43 | + | /// `project.yml` mid-build, and (worst) a fixed-path build keychain that the | |
| 44 | + | /// second build deletes out from under the first's codesign. | |
| 45 | + | pub type HostLocks = Arc<HashMap<String, Arc<Mutex<()>>>>; | |
| 46 | + | ||
| 47 | + | /// Build one serialization lock per host in the topology. Hosts are fixed at | |
| 48 | + | /// load, so the map is built once and never mutated. | |
| 49 | + | pub fn build_host_locks(topo: &Topology) -> HostLocks { | |
| 50 | + | Arc::new( | |
| 51 | + | topo.hosts | |
| 52 | + | .iter() | |
| 53 | + | .map(|h| (h.name.clone(), Arc::new(Mutex::new(())))) | |
| 54 | + | .collect(), | |
| 55 | + | ) | |
| 56 | + | } | |
| 57 | + | ||
| 38 | 58 | #[derive(Clone)] | |
| 39 | 59 | pub struct AppState { | |
| 40 | 60 | pub pool: SqlitePool, | |
| @@ -60,6 +80,10 @@ pub struct AppState { | |||
| 60 | 80 | /// The value carries the owning `build_id` so a finished build reaps only | |
| 61 | 81 | /// its own slots and never a superseding build's handle. | |
| 62 | 82 | pub active: ActiveBuilds, | |
| 83 | + | /// One lock per host, held for a target's whole recipe run so two targets on | |
| 84 | + | /// the same host serialize instead of corrupting one shared checkout + | |
| 85 | + | /// keychain. See [`HostLocks`]. | |
| 86 | + | pub host_locks: HostLocks, | |
| 63 | 87 | } | |
| 64 | 88 | ||
| 65 | 89 | /// Build one host's EXEC executor: `LocalExec` for `ssh = "local"`, `AgentRpc` | |
| @@ -266,6 +290,37 @@ mod tests { | |||
| 266 | 290 | ); | |
| 267 | 291 | } | |
| 268 | 292 | ||
| 293 | + | /// One serialization lock per host, and they are independent: holding mbp's | |
| 294 | + | /// lock (a macOS + iOS build serialize behind it) leaves fw13 free to build | |
| 295 | + | /// in parallel. This is the fan-out-across-hosts / serialize-within-a-host | |
| 296 | + | /// property the runner relies on. | |
| 297 | + | #[tokio::test] | |
| 298 | + | async fn host_locks_are_one_per_host_and_independent() { | |
| 299 | + | use crate::topology::Topology; | |
| 300 | + | let dir = tempfile::tempdir().unwrap(); | |
| 301 | + | let repo = dir.path().join("goingson"); | |
| 302 | + | std::fs::create_dir_all(&repo).unwrap(); | |
| 303 | + | std::fs::write(repo.join("bento.toml"), "targets = [\"macos/aarch64\"]\n").unwrap(); | |
| 304 | + | let topo = Topology::from_str_for_tests(&format!( | |
| 305 | + | "[[host]]\nname = \"fw13\"\nssh = \"local\"\ntargets = [\"linux/x86_64\"]\n\ | |
| 306 | + | [[host]]\nname = \"mbp\"\nssh = \"mbp\"\ntargets = [\"macos/aarch64\"]\n\ | |
| 307 | + | [app.goingson]\nrepo = \"{}\"\n", | |
| 308 | + | repo.display() | |
| 309 | + | )) | |
| 310 | + | .unwrap(); | |
| 311 | + | let locks = build_host_locks(&topo); | |
| 312 | + | assert_eq!(locks.len(), 2); | |
| 313 | + | let mbp = locks.get("mbp").expect("mbp lock").clone(); | |
| 314 | + | let _held = mbp.clone().lock_owned().await; | |
| 315 | + | // Same host: a second acquire cannot proceed while the first is held. | |
| 316 | + | assert!(mbp.try_lock().is_err(), "same-host builds must serialize"); | |
| 317 | + | // Different host: unaffected — it builds in parallel. | |
| 318 | + | assert!( | |
| 319 | + | locks.get("fw13").expect("fw13 lock").try_lock().is_ok(), | |
| 320 | + | "a different host must not be blocked" | |
| 321 | + | ); | |
| 322 | + | } | |
| 323 | + | ||
| 269 | 324 | #[test] | |
| 270 | 325 | fn agent_host_executor_permits_sign() { | |
| 271 | 326 | // The mac host: agent transport, widened grant. Proves AgentRpc is |
| @@ -86,6 +86,8 @@ struct AppManifest { | |||
| 86 | 86 | version_path: Option<String>, | |
| 87 | 87 | #[serde(default)] | |
| 88 | 88 | features: Vec<String>, | |
| 89 | + | #[serde(default)] | |
| 90 | + | require_all_targets: bool, | |
| 89 | 91 | targets: Vec<Target>, | |
| 90 | 92 | } | |
| 91 | 93 | ||
| @@ -182,6 +184,12 @@ pub struct AppConfig { | |||
| 182 | 184 | /// fails silently, producing a binary that builds and runs with a feature | |
| 183 | 185 | /// quietly absent. | |
| 184 | 186 | pub features: Vec<String>, | |
| 187 | + | /// Opt-in: `publish` refuses unless every declared target of this | |
| 188 | + | /// `(app, version)` has a successful latest run — the all-targets-green gate | |
| 189 | + | /// that stops a partial release (macOS published while windows is red or | |
| 190 | + | /// still building). Off by default so independent per-target publishing | |
| 191 | + | /// keeps working; a release that must ship as a set turns it on. | |
| 192 | + | pub require_all_targets: bool, | |
| 185 | 193 | /// Targets this app ships. | |
| 186 | 194 | pub targets: Vec<Target>, | |
| 187 | 195 | } | |
| @@ -233,6 +241,7 @@ impl Topology { | |||
| 233 | 241 | recipe_dir: m.recipe_dir, | |
| 234 | 242 | version_path: m.version_path, | |
| 235 | 243 | features: m.features, | |
| 244 | + | require_all_targets: m.require_all_targets, | |
| 236 | 245 | targets: m.targets, | |
| 237 | 246 | }, | |
| 238 | 247 | ); |
| @@ -24,3 +24,9 @@ dist_root = "/home/max/Dist" | |||
| 24 | 24 | ||
| 25 | 25 | # Per-step run logs: <logs_root>/<app>/<version>/<target>/<step>.log | |
| 26 | 26 | logs_root = "/home/max/.local/state/bento/logs" | |
| 27 | + | ||
| 28 | + | # Optional: override the per-step wall-clock budget (seconds) for EVERY step, | |
| 29 | + | # replacing the per-kind defaults (build 90m, notarize 60m, sign 15m, ...). A | |
| 30 | + | # step that runs past its budget fails that step and unwinds the recipe, so a | |
| 31 | + | # hung command can't wedge a build. Leave unset to use the defaults. | |
| 32 | + | # step_timeout_secs = 5400 |
| @@ -13,8 +13,12 @@ | |||
| 13 | 13 | # systemctl --user enable --now bentod | |
| 14 | 14 | # | |
| 15 | 15 | # Watch: journalctl --user -u bentod -f | |
| 16 | - | # Deploy a new bentod: build, copy to ~/.local/bin/bentod, `systemctl --user | |
| 17 | - | # restart bentod` (no sudo — that's the point of a user service). | |
| 16 | + | # Deploy a new bentod: build, copy to ~/.local/bin/bentod, then validate the | |
| 17 | + | # fresh binary against the live config BEFORE restarting: | |
| 18 | + | # ~/.local/bin/bentod --check-config && systemctl --user restart bentod | |
| 19 | + | # (no sudo — that's the point of a user service). The unit also runs the same | |
| 20 | + | # --check-config as ExecStartPre, so a bad config fails the start rather than | |
| 21 | + | # crash-looping. | |
| 18 | 22 | [Unit] | |
| 19 | 23 | Description=Bento app build controller | |
| 20 | 24 | After=network-online.target | |
| @@ -22,6 +26,11 @@ Wants=network-online.target | |||
| 22 | 26 | ||
| 23 | 27 | [Service] | |
| 24 | 28 | Type=simple | |
| 29 | + | # Validate the config (daemon file + every app's in-repo manifest) before each | |
| 30 | + | # start. A binary or schema change that can't parse the live config fails the | |
| 31 | + | # unit here instead of crash-looping — the guard a per-app-config move needed | |
| 32 | + | # when it bricked bentod for 20h. Same check runs at install (see README). | |
| 33 | + | ExecStartPre=%h/.local/bin/bentod --check-config | |
| 25 | 34 | ExecStart=%h/.local/bin/bentod | |
| 26 | 35 | Restart=on-failure | |
| 27 | 36 | RestartSec=5 |