max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
6 files changed,
+285 insertions,
-75 deletions
| @@ -3508,6 +3508,18 @@ | |||
| 3508 | 3508 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3509 | 3509 | checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" | |
| 3510 | 3510 | ||
| 3511 | + | [[patch.unused]] | |
| 3512 | + | name = "synckit-client" | |
| 3513 | + | version = "0.7.0" | |
| 3514 | + | ||
| 3515 | + | [[patch.unused]] | |
| 3516 | + | name = "synckit-config" | |
| 3517 | + | version = "0.1.2" | |
| 3518 | + | ||
| 3519 | + | [[patch.unused]] | |
| 3520 | + | name = "docengine" | |
| 3521 | + | version = "0.4.0" | |
| 3522 | + | ||
| 3511 | 3523 | [[patch.unused]] | |
| 3512 | 3524 | name = "kberg" | |
| 3513 | 3525 | version = "0.1.0" | |
| @@ -3519,15 +3531,3 @@ | |||
| 3519 | 3531 | [[patch.unused]] | |
| 3520 | 3532 | name = "tagtree" | |
| 3521 | 3533 | version = "0.4.0" | |
| 3522 | - | ||
| 3523 | - | [[patch.unused]] | |
| 3524 | - | name = "docengine" | |
| 3525 | - | version = "0.4.0" | |
| 3526 | - | ||
| 3527 | - | [[patch.unused]] | |
| 3528 | - | name = "synckit-client" | |
| 3529 | - | version = "0.7.0" | |
| 3530 | - | ||
| 3531 | - | [[patch.unused]] | |
| 3532 | - | name = "synckit-config" | |
| 3533 | - | version = "0.1.2" |
| @@ -15,7 +15,7 @@ | |||
| 15 | 15 | |------|---------------|---------| | |
| 16 | 16 | | `bentod.service` | `~/.config/systemd/user/` | The user service unit. | | |
| 17 | 17 | | `bento-daemon.toml.example` | `~/.config/bento/bento-daemon.toml` | Daemon-local config (paths + listen). Absolute paths only — no shell expansion. | | |
| 18 | - | | `bento.toml.example` | `~/.config/bento/bento.toml` | Build topology (hosts + apps). `repo` paths are tilde-expanded. | | |
| 18 | + | | `bento.toml.example` | `~/.config/bento/bento.toml` | Build topology (hosts + apps). `repo` paths are tilde-expanded; `repo_by_host` overrides the path for one host. | | |
| 19 | 19 | | (none, hand-written 0600) | `~/.config/bento/bento.env` | `BENTO_API_TOKEN=...` for a tailnet bind. Read by the unit's `EnvironmentFile=-`, so a loopback install can leave it absent. No example file ships: it holds a secret. | | |
| 20 | 20 | ||
| 21 | 21 | ## Stand-up (no root except enable-linger) | |
| @@ -189,7 +189,7 @@ | |||
| 189 | 189 | |----------|---------| | |
| 190 | 190 | | `version()` | the version being built (e.g. `"0.4.2"`) | | |
| 191 | 191 | | `build_host()` | the host this target builds on (e.g. `"fw13"`) | | |
| 192 | - | | `repo()` | the app's checkout path (`~`-prefixed; `cd` into it — commands don't auto-cd) | | |
| 192 | + | | `repo()` | the app's checkout path on this target's build host (`~`-prefixed on unix; `cd` into it, commands don't auto-cd) | | |
| 193 | 193 | | `target()` / `platform()` / `arch()` | `"linux/x86_64"` / `"linux"` / `"x86_64"` | | |
| 194 | 194 | ||
| 195 | 195 | Non-Tauri apps (audiofiles) set `version_path` in the topology to the crate |
| @@ -32,12 +32,20 @@ | |||
| 32 | 32 | targets = ["windows/x86_64"] | |
| 33 | 33 | # transport defaults to "ssh"; actuate defaults to ["build", "package"] | |
| 34 | 34 | ||
| 35 | + | # `repo` is where the checkout lives on every host that does not say otherwise. | |
| 36 | + | # `repo_by_host` overrides it for one host, which is how a Windows checkout at a | |
| 37 | + | # Windows path stays addressable: without it a recipe has to hard-code the path, | |
| 38 | + | # and a hard-coded path cannot use repo() or checkout_sha(). Keys must name a | |
| 39 | + | # declared [[host]]. | |
| 35 | 40 | [app.goingson] | |
| 36 | 41 | repo = "~/Code/Apps/goingson" | |
| 42 | + | repo_by_host = { windows-x86 = "C:/Users/me/Code/Apps/goingson" } | |
| 37 | 43 | [app.balanced_breakfast] | |
| 38 | 44 | repo = "~/Code/Apps/balanced_breakfast" | |
| 45 | + | repo_by_host = { windows-x86 = "C:/Users/me/Code/Apps/balanced_breakfast" } | |
| 39 | 46 | [app.audiofiles] | |
| 40 | 47 | repo = "~/Code/Apps/audiofiles" | |
| 48 | + | repo_by_host = { windows-x86 = "C:/Users/me/Code/Apps/audiofiles" } | |
| 41 | 49 | ||
| 42 | 50 | # Library crates published to crates.io. Same two-file split as the apps: the | |
| 43 | 51 | # pointer lives here, `kind = "library"` and the rest in each repo's bento.toml. |
| @@ -159,10 +159,14 @@ | |||
| 159 | 159 | /// products spells it per product (`pom-v0.4.1`), and the recipe, the | |
| 160 | 160 | /// preflight barrier and the failure message all have to agree on it. | |
| 161 | 161 | pub tag: String, | |
| 162 | - | /// The app's checkout path (topology `repo`, `~`-prefixed). Recipes read it | |
| 163 | - | /// via `repo()` to `cd` into the checkout — commands don't auto-cd, and each | |
| 164 | - | /// `sh` is a fresh shell. | |
| 162 | + | /// The app's default checkout path (topology `repo`, `~`-prefixed). Read it | |
| 163 | + | /// through [`RecipeCtx::repo_for`] for anything that runs on a build host; | |
| 164 | + | /// this field alone is the daemon-local answer. | |
| 165 | 165 | pub repo: String, | |
| 166 | + | /// Per-host checkout overrides (topology `repo_by_host`). Empty for every app | |
| 167 | + | /// that has not declared one, which is all of them but the Windows-shipping | |
| 168 | + | /// ones. | |
| 169 | + | pub repo_by_host: HashMap<String, String>, | |
| 166 | 170 | /// Cargo features this app's release builds enable (topology `features`). | |
| 167 | 171 | /// Recipes read it via `feature_flags()`. | |
| 168 | 172 | pub features: Vec<String>, | |
| @@ -282,6 +286,7 @@ | |||
| 282 | 286 | build_host_ssh, | |
| 283 | 287 | tag, | |
| 284 | 288 | repo, | |
| 289 | + | repo_by_host: HashMap::new(), | |
| 285 | 290 | features, | |
| 286 | 291 | kind, | |
| 287 | 292 | target_run_id, | |
| @@ -302,6 +307,27 @@ | |||
| 302 | 307 | } | |
| 303 | 308 | } | |
| 304 | 309 | ||
| 310 | + | /// Declare the app's per-host checkout overrides (topology `repo_by_host`). | |
| 311 | + | /// | |
| 312 | + | /// Separate from `new` because it is empty for every app that has not opted | |
| 313 | + | /// in, and `new` already carries twenty arguments no test wants a | |
| 314 | + | /// twenty-first of. | |
| 315 | + | #[must_use] | |
| 316 | + | pub fn with_repo_by_host(mut self, repo_by_host: HashMap<String, String>) -> Self { | |
| 317 | + | self.repo_by_host = repo_by_host; | |
| 318 | + | self | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | /// Where this app is checked out on `host` (topology `AppConfig::repo_for`). | |
| 322 | + | /// | |
| 323 | + | /// Every git command a recipe or the engine runs on a build host goes | |
| 324 | + | /// through this. The bare `repo` field is the daemon-local path. | |
| 325 | + | pub fn repo_for(&self, host: &str) -> &str { | |
| 326 | + | self.repo_by_host | |
| 327 | + | .get(host) | |
| 328 | + | .map_or(self.repo.as_str(), String::as_str) | |
| 329 | + | } | |
| 330 | + | ||
| 305 | 331 | /// Whether a newer build has superseded this run. | |
| 306 | 332 | fn is_cancelled(&self) -> bool { | |
| 307 | 333 | self.cancel.load(Ordering::SeqCst) | |
| @@ -637,20 +663,23 @@ | |||
| 637 | 663 | /// has checked out. Fetch + checkout stream into the current step's log; | |
| 638 | 664 | /// the sha comes from a separate `rev-parse` so its stdout is only the sha. | |
| 639 | 665 | fn checkout_sha(self: &Arc<Self>, host: &str) -> Result<String> { | |
| 666 | + | // Every command below runs ON `host`, so the path is that host's, not the | |
| 667 | + | // daemon's. Windows is why: its checkout is at `C:/Users/me/Code/...`. | |
| 668 | + | let repo = self.repo_for(host).to_string(); | |
| 640 | 669 | // A failing mirror is not a failing release: fetch is advisory, and only | |
| 641 | 670 | // the checkout decides. Its output still streams into the step log, so an | |
| 642 | 671 | // unreachable remote stays visible without being fatal. | |
| 643 | - | let _ = self.run(host, &git_fetch_cmd(&self.repo))?; | |
| 644 | - | let (code, _) = self.run(host, &git_checkout_tag_cmd(&self.repo, &self.tag))?; | |
| 672 | + | let _ = self.run(host, &git_fetch_cmd(&repo))?; | |
| 673 | + | let (code, _) = self.run(host, &git_checkout_tag_cmd(&repo, &self.tag))?; | |
| 645 | 674 | if code != 0 { | |
| 646 | - | let (probe, _) = self.run(host, &git_tag_exists_cmd(&self.repo, &self.tag))?; | |
| 675 | + | let (probe, _) = self.run(host, &git_tag_exists_cmd(&repo, &self.tag))?; | |
| 647 | 676 | anyhow::bail!( | |
| 648 | 677 | "checkout of {} failed on `{host}`: {}", | |
| 649 | 678 | self.tag, | |
| 650 | 679 | checkout_failure_reason(&self.tag, probe == 0) | |
| 651 | 680 | ); | |
| 652 | 681 | } | |
| 653 | - | let (code, tail) = self.run(host, &git_rev_parse_cmd(&self.repo))?; | |
| 682 | + | let (code, tail) = self.run(host, &git_rev_parse_cmd(&repo))?; | |
| 654 | 683 | anyhow::ensure!(code == 0, "rev-parse failed on `{host}`"); | |
| 655 | 684 | Ok(tail.trim().to_string()) | |
| 656 | 685 | } | |
| @@ -1316,10 +1345,16 @@ | |||
| 1316 | 1345 | engine.register_fn("build_host", move || -> String { ctx.build_host.clone() }); | |
| 1317 | 1346 | } | |
| 1318 | 1347 | ||
| 1319 | - | // --- repo() -> string: the app's checkout path (`~`-prefixed) --- | |
| 1348 | + | // --- repo() -> string: the app's checkout path on this target's build host | |
| 1349 | + | // (`~`-prefixed on a unix host). Host-correct rather than one path per | |
| 1350 | + | // app, so a recipe for a host whose checkout is elsewhere still calls | |
| 1351 | + | // this instead of hard-coding the path — which is what kept the Windows | |
| 1352 | + | // recipes off `checkout_sha`. --- | |
| 1320 | 1353 | { | |
| 1321 | 1354 | let ctx = ctx.clone(); | |
| 1322 | - | engine.register_fn("repo", move || -> String { ctx.repo.clone() }); | |
| 1355 | + | engine.register_fn("repo", move || -> String { | |
| 1356 | + | ctx.repo_for(&ctx.build_host).to_string() | |
| 1357 | + | }); | |
| 1323 | 1358 | } | |
| 1324 | 1359 | ||
| 1325 | 1360 | // --- checkout_sha(host) -> sha: pin this host to the release tag and report | |
| @@ -1345,6 +1380,9 @@ | |||
| 1345 | 1380 | engine.register_fn( | |
| 1346 | 1381 | "crate_preflight", | |
| 1347 | 1382 | move || -> Result<String, Box<EvalAltResult>> { | |
| 1383 | + | // `repo`, not `repo_for(...)`: `cargo metadata` runs on the | |
| 1384 | + | // daemon's own box, so this is the one checkout that is always | |
| 1385 | + | // the local one. It is not a missed call site. | |
| 1348 | 1386 | let repo = expand_tilde(&ctx.repo); | |
| 1349 | 1387 | ||
| 1350 | 1388 | let out = std::process::Command::new("cargo") | |
| @@ -2510,6 +2548,53 @@ | |||
| 2510 | 2548 | ); | |
| 2511 | 2549 | } | |
| 2512 | 2550 | ||
| 2551 | + | /// `repo()` answers for the host this target builds on, not for the daemon. | |
| 2552 | + | /// | |
| 2553 | + | /// This is what lets a Windows recipe call `repo()` and `checkout_sha(h)` | |
| 2554 | + | /// instead of hard-coding `C:/Users/me/...` — and hard-coding it is what | |
| 2555 | + | /// kept those recipes off the release-tag pin, since `checkout_sha` builds | |
| 2556 | + | /// its git commands from the app's path and takes no override. | |
| 2557 | + | #[tokio::test] | |
| 2558 | + | async fn repo_resolves_per_build_host() { | |
| 2559 | + | async fn repo_on(build_host: &str) -> String { | |
| 2560 | + | let dir = tempfile::tempdir().unwrap(); | |
| 2561 | + | let cfg = Arc::new(Config::for_tests(dir.path())); | |
| 2562 | + | let pool = crate::db::open(&cfg.db_path).await.unwrap(); | |
| 2563 | + | let ctx = Arc::new( | |
| 2564 | + | RecipeCtx::new( | |
| 2565 | + | AppId::new("demo"), | |
| 2566 | + | Version::parse("0.1.0").unwrap(), | |
| 2567 | + | "linux/x86_64".parse().unwrap(), | |
| 2568 | + | build_host.into(), | |
| 2569 | + | "local".into(), | |
| 2570 | + | "v0.1.0".into(), | |
| 2571 | + | "~/Code/Apps/demo".into(), | |
| 2572 | + | vec![], | |
| 2573 | + | Kind::App, | |
| 2574 | + | 1, | |
| 2575 | + | Arc::new(std::collections::HashMap::new()), | |
| 2576 | + | Arc::new(std::collections::HashMap::new()), | |
| 2577 | + | None, | |
| 2578 | + | pool, | |
| 2579 | + | crate::events::channel(), | |
| 2580 | + | cfg, | |
| 2581 | + | Arc::new(OtaRegistry::standard("https://makenot.work")), | |
| 2582 | + | tokio::runtime::Handle::current(), | |
| 2583 | + | Arc::new(AtomicBool::new(false)), | |
| 2584 | + | None, | |
| 2585 | + | ) | |
| 2586 | + | .with_repo_by_host(HashMap::from([( | |
| 2587 | + | "windows-x86".to_string(), | |
| 2588 | + | "C:/Users/me/Code/Apps/demo".to_string(), | |
| 2589 | + | )])), | |
| 2590 | + | ); | |
| 2591 | + | build_engine(&ctx).eval::<String>("repo()").unwrap() | |
| 2592 | + | } | |
| 2593 | + | ||
| 2594 | + | assert_eq!(repo_on("windows-x86").await, "C:/Users/me/Code/Apps/demo"); | |
| 2595 | + | assert_eq!(repo_on("fw13").await, "~/Code/Apps/demo"); | |
| 2596 | + | } | |
| 2597 | + | ||
| 2513 | 2598 | /// `secret(key)` reads a file under `secrets_root`, trims its trailing | |
| 2514 | 2599 | /// newline (the shape of a here-doc'd token file), and refuses any key that | |
| 2515 | 2600 | /// could escape the root. Covers the host-fn registered in `build_engine`. |
| @@ -56,7 +56,6 @@ | |||
| 56 | 56 | .topo | |
| 57 | 57 | .app(app) | |
| 58 | 58 | .ok_or_else(|| anyhow::anyhow!("unknown app `{app}`"))?; | |
| 59 | - | let repo = cfg.repo.clone(); | |
| 60 | 59 | // Spelled per app: a repo holding one product tags `v0.4.1`, a repo holding | |
| 61 | 60 | // several tags `pom-v0.4.1`. See `AppConfig::tag_format`. | |
| 62 | 61 | let tag = cfg.tag_for(version); | |
| @@ -93,9 +92,13 @@ | |||
| 93 | 92 | .get(host) | |
| 94 | 93 | .ok_or_else(|| anyhow::anyhow!("no executor for host `{host}`"))?; | |
| 95 | 94 | let mut sink = DiscardSink; | |
| 95 | + | // Resolved per host, not once for the set: the checkouts need not be at | |
| 96 | + | // the same path on every machine, and pinning windows-x86 with the unix | |
| 97 | + | // path failed on the first git command of the release. | |
| 98 | + | let repo = cfg.repo_for(host); | |
| 96 | 99 | ||
| 97 | 100 | // Read the branch BEFORE pinning, while there is still one to read. | |
| 98 | - | let branch_cmd = OpStep::shell(Action::Build, engine::git_current_branch_cmd(&repo)); | |
| 101 | + | let branch_cmd = OpStep::shell(Action::Build, engine::git_current_branch_cmd(repo)); | |
| 99 | 102 | let out = exec | |
| 100 | 103 | .run_streaming(&branch_cmd, &mut sink) | |
| 101 | 104 | .await | |
| @@ -128,7 +131,7 @@ | |||
| 128 | 131 | // builds something else. The rev-parse barrier below cannot see it: | |
| 129 | 132 | // both hosts genuinely are on the same commit. Two architectures, two | |
| 130 | 133 | // different binaries, one tag containing neither. | |
| 131 | - | let dirty_cmd = OpStep::shell(Action::Build, engine::git_dirty_cmd(&repo)); | |
| 134 | + | let dirty_cmd = OpStep::shell(Action::Build, engine::git_dirty_cmd(repo)); | |
| 132 | 135 | let out = exec | |
| 133 | 136 | .run_streaming(&dirty_cmd, &mut sink) | |
| 134 | 137 | .await | |
| @@ -150,10 +153,10 @@ | |||
| 150 | 153 | // Advisory: `fetch --all` is non-zero if any one of a repo's remotes is | |
| 151 | 154 | // unreachable, and blaming the tag for a dead mirror is what made this | |
| 152 | 155 | // preflight misreport. Only the checkout below is allowed to fail. | |
| 153 | - | let fetch = OpStep::shell(Action::Build, engine::git_fetch_cmd(&repo)); | |
| 156 | + | let fetch = OpStep::shell(Action::Build, engine::git_fetch_cmd(repo)); | |
| 154 | 157 | let _ = exec.run_streaming(&fetch, &mut sink).await; | |
| 155 | 158 | ||
| 156 | - | let checkout = OpStep::shell(Action::Build, engine::git_checkout_tag_cmd(&repo, &tag)); | |
| 159 | + | let checkout = OpStep::shell(Action::Build, engine::git_checkout_tag_cmd(repo, &tag)); | |
| 157 | 160 | let out = exec | |
| 158 | 161 | .run_streaming(&checkout, &mut sink) | |
| 159 | 162 | .await | |
| @@ -161,7 +164,7 @@ | |||
| 161 | 164 | if !out.status.success() { | |
| 162 | 165 | // Ask git why before telling the operator. The two causes need | |
| 163 | 166 | // opposite responses: tag-and-push, versus clean the working tree. | |
| 164 | - | let probe = OpStep::shell(Action::Build, engine::git_tag_exists_cmd(&repo, &tag)); | |
| 167 | + | let probe = OpStep::shell(Action::Build, engine::git_tag_exists_cmd(repo, &tag)); | |
| 165 | 168 | let tag_exists = exec | |
| 166 | 169 | .run_streaming(&probe, &mut sink) | |
| 167 | 170 | .await | |
| @@ -172,7 +175,7 @@ | |||
| 172 | 175 | ); | |
| 173 | 176 | } | |
| 174 | 177 | ||
| 175 | - | let rev = OpStep::shell(Action::Build, engine::git_rev_parse_cmd(&repo)); | |
| 178 | + | let rev = OpStep::shell(Action::Build, engine::git_rev_parse_cmd(repo)); | |
| 176 | 179 | let out = exec | |
| 177 | 180 | .run_streaming(&rev, &mut sink) | |
| 178 | 181 | .await | |
| @@ -262,21 +265,22 @@ | |||
| 262 | 265 | None => None, | |
| 263 | 266 | }; | |
| 264 | 267 | let mut sink = DiscardSink; | |
| 265 | - | let step = OpStep::shell( | |
| 266 | - | Action::Build, | |
| 267 | - | engine::git_restore_branch_cmd(&cfg.repo, branch), | |
| 268 | - | ); | |
| 268 | + | // The same per-host path `pin_release` detached; restoring the daemon's | |
| 269 | + | // path on a host that keeps its checkout elsewhere would leave the real | |
| 270 | + | // one detached and report success. | |
| 271 | + | let repo = cfg.repo_for(host); | |
| 272 | + | let step = OpStep::shell(Action::Build, engine::git_restore_branch_cmd(repo, branch)); | |
| 269 | 273 | match exec.run_streaming(&step, &mut sink).await { | |
| 270 | 274 | Ok(out) if out.status.success() => { | |
| 271 | 275 | tracing::debug!(%host, %branch, "restored checkout to its branch"); | |
| 272 | 276 | } | |
| 273 | 277 | Ok(_) => tracing::error!( | |
| 274 | - | %host, %branch, repo = %cfg.repo, | |
| 278 | + | %host, %branch, %repo, | |
| 275 | 279 | "could not restore the checkout to its branch; it is left DETACHED at the \ | |
| 276 | 280 | release tag, and commits made there will belong to no branch" | |
| 277 | 281 | ), | |
| 278 | 282 | Err(e) => tracing::error!( | |
| 279 | - | %host, %branch, repo = %cfg.repo, error = %e, | |
| 283 | + | %host, %branch, %repo, error = %e, | |
| 280 | 284 | "could not restore the checkout to its branch; it is left DETACHED at the \ | |
| 281 | 285 | release tag, and commits made there will belong to no branch" | |
| 282 | 286 | ), | |
| @@ -553,6 +557,11 @@ | |||
| 553 | 557 | .app(&app) | |
| 554 | 558 | .map(|a| a.repo.clone()) | |
| 555 | 559 | .unwrap_or_default(); | |
| 560 | + | let repo_by_host = state | |
| 561 | + | .topo | |
| 562 | + | .app(&app) | |
| 563 | + | .map(|a| a.repo_by_host.clone()) | |
| 564 | + | .unwrap_or_default(); | |
| 556 | 565 | let features = state | |
| 557 | 566 | .topo | |
| 558 | 567 | .app(&app) | |
| @@ -591,30 +600,33 @@ | |||
| 591 | 600 | None => state.executors.clone(), | |
| 592 | 601 | }; | |
| 593 | 602 | ||
| 594 | - | let ctx = Arc::new(RecipeCtx::new( | |
| 595 | - | app.clone(), | |
| 596 | - | version.clone(), | |
| 597 | - | target, | |
| 598 | - | build_host, | |
| 599 | - | build_host_ssh, | |
| 600 | - | tag.clone(), | |
| 601 | - | repo, | |
| 602 | - | features, | |
| 603 | - | // Gates the `verify` step's capability: a library's crate preflight is | |
| 604 | - | // not an app's Gatekeeper check. See engine::action_for. | |
| 605 | - | state.topo.app(&app).map(|a| a.kind).unwrap_or_default(), | |
| 606 | - | target_run_id, | |
| 607 | - | execs, | |
| 608 | - | state.syncs.clone(), | |
| 609 | - | deploy, | |
| 610 | - | state.pool.clone(), | |
| 611 | - | state.events.clone(), | |
| 612 | - | state.cfg.clone(), | |
| 613 | - | state.ota.clone(), | |
| 614 | - | tokio::runtime::Handle::current(), | |
| 615 | - | cancel, | |
| 616 | - | all_green_required, | |
| 617 | - | )); | |
| 603 | + | let ctx = Arc::new( | |
| 604 | + | RecipeCtx::new( | |
| 605 | + | app.clone(), | |
| 606 | + | version.clone(), | |
| 607 | + | target, | |
| 608 | + | build_host, | |
| 609 | + | build_host_ssh, | |
| 610 | + | tag.clone(), | |
| 611 | + | repo, | |
| 612 | + | features, | |
| 613 | + | // Gates the `verify` step's capability: a library's crate preflight is | |
| 614 | + | // not an app's Gatekeeper check. See engine::action_for. | |
| 615 | + | state.topo.app(&app).map(|a| a.kind).unwrap_or_default(), | |
| 616 | + | target_run_id, | |
| 617 | + | execs, | |
| 618 | + | state.syncs.clone(), | |
| 619 | + | deploy, | |
| 620 | + | state.pool.clone(), | |
| 621 | + | state.events.clone(), | |
| 622 | + | state.cfg.clone(), | |
| 623 | + | state.ota.clone(), | |
| 624 | + | tokio::runtime::Handle::current(), | |
| 625 | + | cancel, | |
| 626 | + | all_green_required, | |
| 627 | + | ) | |
| 628 | + | .with_repo_by_host(repo_by_host), | |
| 629 | + | ); | |
| 618 | 630 | ||
| 619 | 631 | // Serialize per host: hold this host's lock for the whole recipe run so a | |
| 620 | 632 | // second target on the same box (goingson macos + ios both on mbp) can't | |
| @@ -3596,8 +3608,8 @@ | |||
| 3596 | 3608 | /// like [`crate::topology`]'s live-config smoke test. | |
| 3597 | 3609 | #[cfg(test)] | |
| 3598 | 3610 | mod live_recipe_smoke { | |
| 3599 | - | use crate::topology::{Kind, Topology}; | |
| 3600 | - | use std::path::Path; | |
| 3611 | + | use crate::topology::Topology; | |
| 3612 | + | use std::path::{Path, PathBuf}; | |
| 3601 | 3613 | ||
| 3602 | 3614 | #[test] | |
| 3603 | 3615 | fn live_recipes_compile_if_present() { | |
| @@ -3615,18 +3627,22 @@ | |||
| 3615 | 3627 | let mut checked = 0; | |
| 3616 | 3628 | for (name, cfg) in &topo.app { | |
| 3617 | 3629 | let dir = crate::engine::expand_tilde(&cfg.repo).join(&cfg.recipe_dir); | |
| 3618 | - | let files: Vec<String> = match cfg.kind { | |
| 3619 | - | Kind::Library => vec!["publish.rhai".into()], | |
| 3620 | - | Kind::App | Kind::Service => cfg | |
| 3621 | - | .targets | |
| 3622 | - | .iter() | |
| 3623 | - | .map(|t| format!("{}.rhai", t.platform.as_str())) | |
| 3624 | - | .collect(), | |
| 3630 | + | // Every recipe in the directory, not just the ones the app's current | |
| 3631 | + | // targets name. A recipe for a target that is temporarily not | |
| 3632 | + | // shipped (windows, dropped from the manifests until its host is | |
| 3633 | + | // real) still has to parse, and it is the one nothing else is | |
| 3634 | + | // watching. | |
| 3635 | + | let Ok(entries) = std::fs::read_dir(&dir) else { | |
| 3636 | + | continue; | |
| 3625 | 3637 | }; | |
| 3626 | - | for file in files { | |
| 3627 | - | let p = dir.join(&file); | |
| 3628 | - | // An app can ship a target whose recipe is still pending (BB's | |
| 3629 | - | // macOS/iOS); a missing file is that, not a parse failure. | |
| 3638 | + | let mut files: Vec<PathBuf> = entries | |
| 3639 | + | .filter_map(Result::ok) | |
| 3640 | + | .map(|e| e.path()) | |
| 3641 | + | .filter(|p| p.extension().is_some_and(|x| x == "rhai")) | |
| 3642 | + | .collect(); | |
| 3643 | + | files.sort(); | |
| 3644 | + | for p in files { | |
| 3645 | + | let file = p.file_name().unwrap_or_default().to_string_lossy(); | |
| 3630 | 3646 | let Ok(src) = std::fs::read_to_string(&p) else { | |
| 3631 | 3647 | continue; | |
| 3632 | 3648 | }; |
| @@ -54,8 +54,11 @@ | |||
| 54 | 54 | /// be able to rename the thing the daemon has history for by editing a file. | |
| 55 | 55 | #[derive(Debug, Clone, Deserialize)] | |
| 56 | 56 | struct AppPointer { | |
| 57 | - | /// Checkout path on each build host (apps are cloned on every host). | |
| 57 | + | /// Default checkout path, used by every host that does not override it. | |
| 58 | 58 | repo: String, | |
| 59 | + | /// Per-host overrides of `repo`, keyed by host name. | |
| 60 | + | #[serde(default)] | |
| 61 | + | repo_by_host: HashMap<String, String>, | |
| 59 | 62 | } | |
| 60 | 63 | ||
| 61 | 64 | /// What a repo produces, which decides how Bento releases it. | |
| @@ -227,8 +230,25 @@ | |||
| 227 | 230 | /// An app as the runner sees it: its pointer merged with its in-repo manifest. | |
| 228 | 231 | #[derive(Debug, Clone)] | |
| 229 | 232 | pub struct AppConfig { | |
| 230 | - | /// Checkout path on each build host (apps are cloned on every host). | |
| 233 | + | /// Default checkout path: where this app is cloned on a host that does not | |
| 234 | + | /// override it. Read through [`AppConfig::repo_for`], never directly, on any | |
| 235 | + | /// path that names a build host. | |
| 231 | 236 | pub repo: String, | |
| 237 | + | /// Per-host overrides of [`AppConfig::repo`], keyed by host name. | |
| 238 | + | /// | |
| 239 | + | /// One path for every host is a unix assumption. The Windows checkout is at | |
| 240 | + | /// `C:/Users/me/Code/Apps/goingson`, not at `~/Code/Apps/goingson`, and until | |
| 241 | + | /// this existed the Windows recipes worked around it by hard-coding the path | |
| 242 | + | /// and never calling `repo()` — which meant they could not use | |
| 243 | + | /// `checkout_sha(h)` either, since that builds its git commands from the | |
| 244 | + | /// app's one path. `pin_release` had the same bug and ran | |
| 245 | + | /// `git -C ~/Code/Apps/goingson` on windows-x86. | |
| 246 | + | /// | |
| 247 | + | /// Declared rather than derived from the host's `pull_root`: deriving would | |
| 248 | + | /// need the checkout to sit under the pull root, which is false for the | |
| 249 | + | /// library crates (`~/Code/Libraries/...` against a `pull_root` of | |
| 250 | + | /// `~/Code/Apps`). | |
| 251 | + | pub repo_by_host: HashMap<String, String>, | |
| 232 | 252 | /// What this repo produces (see [`Kind`]). | |
| 233 | 253 | pub kind: Kind, | |
| 234 | 254 | pub branch: String, | |
| @@ -279,6 +299,18 @@ | |||
| 279 | 299 | } | |
| 280 | 300 | ||
| 281 | 301 | impl AppConfig { | |
| 302 | + | /// Where this app is checked out on `host`. | |
| 303 | + | /// | |
| 304 | + | /// The single reader of the path for anything that runs on a build host, so | |
| 305 | + | /// a new call site cannot quietly reintroduce the one-path-per-app | |
| 306 | + | /// assumption. Daemon-local reads (the version, the recipe directory, the | |
| 307 | + | /// crate preflight) are a different question and keep using `repo`. | |
| 308 | + | pub fn repo_for(&self, host: &str) -> &str { | |
| 309 | + | self.repo_by_host | |
| 310 | + | .get(host) | |
| 311 | + | .map_or(self.repo.as_str(), String::as_str) | |
| 312 | + | } | |
| 313 | + | ||
| 282 | 314 | /// The install destination for `target`, if this app declares one. | |
| 283 | 315 | pub fn deploy_for(&self, target: Target) -> Option<&DeployTarget> { | |
| 284 | 316 | self.deploy.iter().find(|d| d.target == target) | |
| @@ -409,6 +441,7 @@ | |||
| 409 | 441 | name, | |
| 410 | 442 | AppConfig { | |
| 411 | 443 | repo: ptr.repo, | |
| 444 | + | repo_by_host: ptr.repo_by_host, | |
| 412 | 445 | kind: m.kind, | |
| 413 | 446 | branch: m.branch, | |
| 414 | 447 | recipe_dir: m.recipe_dir, | |
| @@ -509,6 +542,15 @@ | |||
| 509 | 542 | "app `{name}`: tag_format `{}` contains shell metacharacters", | |
| 510 | 543 | app.tag_format | |
| 511 | 544 | ); | |
| 545 | + | // A typo'd host name here is invisible: the lookup misses and every | |
| 546 | + | // host silently gets the default path, which is the exact bug the | |
| 547 | + | // override exists to fix. | |
| 548 | + | for host in app.repo_by_host.keys() { | |
| 549 | + | anyhow::ensure!( | |
| 550 | + | self.hosts.iter().any(|h| &h.name == host), | |
| 551 | + | "app `{name}`: repo_by_host names host `{host}`, which no [[host]] declares" | |
| 552 | + | ); | |
| 553 | + | } | |
| 512 | 554 | validate_deploy(name, app)?; | |
| 513 | 555 | } | |
| 514 | 556 | // Capability/transport coherence: a host that declares buildable targets | |
| @@ -572,6 +614,24 @@ | |||
| 572 | 614 | Topology::from_str_for_tests(&daemon).map(|t| (t, dir)) | |
| 573 | 615 | } | |
| 574 | 616 | ||
| 617 | + | /// As [`load_with`], but the app pointer carries extra lines (a | |
| 618 | + | /// `repo_by_host` table) beneath its `repo`. | |
| 619 | + | fn load_with_pointer( | |
| 620 | + | hosts: &str, | |
| 621 | + | manifest: &str, | |
| 622 | + | pointer_extra: &str, | |
| 623 | + | ) -> Result<(Topology, tempfile::TempDir)> { | |
| 624 | + | let dir = tempfile::tempdir().unwrap(); | |
| 625 | + | let repo = dir.path().join("goingson"); | |
| 626 | + | std::fs::create_dir_all(&repo).unwrap(); | |
| 627 | + | std::fs::write(repo.join(APP_MANIFEST), manifest).unwrap(); | |
| 628 | + | let daemon = format!( | |
| 629 | + | "{hosts}\n[app.goingson]\nrepo = \"{}\"\n{pointer_extra}", | |
| 630 | + | repo.display() | |
| 631 | + | ); | |
| 632 | + | Topology::from_str_for_tests(&daemon).map(|t| (t, dir)) | |
| 633 | + | } | |
| 634 | + | ||
| 575 | 635 | fn load(hosts: &str) -> Result<Topology> { | |
| 576 | 636 | load_with(hosts, MANIFEST).map(|(t, dir)| { | |
| 577 | 637 | std::mem::forget(dir); | |
| @@ -607,6 +667,47 @@ | |||
| 607 | 667 | ); | |
| 608 | 668 | } | |
| 609 | 669 | ||
| 670 | + | /// No `repo_by_host` is the ordinary case and every host resolves to the one | |
| 671 | + | /// declared path — including a host that does not exist, since the resolver | |
| 672 | + | /// is a lookup with a default and not a validation. | |
| 673 | + | #[test] | |
| 674 | + | fn repo_for_defaults_to_the_single_path_for_every_host() { | |
| 675 | + | let t = load(HOSTS).unwrap(); | |
| 676 | + | let app = t.app(&"goingson".into()).unwrap(); | |
| 677 | + | assert!(app.repo_by_host.is_empty()); | |
| 678 | + | for host in ["fw13", "mbp", "nobody"] { | |
| 679 | + | assert_eq!(app.repo_for(host), app.repo); | |
| 680 | + | } | |
| 681 | + | } | |
| 682 | + | ||
| 683 | + | /// The Windows shape: one host's checkout is somewhere else entirely, and | |
| 684 | + | /// the override wins for that host and only that host. | |
| 685 | + | #[test] | |
| 686 | + | fn repo_by_host_overrides_one_host_only() { | |
| 687 | + | let (t, _dir) = load_with_pointer( | |
| 688 | + | HOSTS, | |
| 689 | + | MANIFEST, | |
| 690 | + | "[app.goingson.repo_by_host]\nmbp = \"/Users/max/Code/Apps/goingson\"\n", | |
| 691 | + | ) | |
| 692 | + | .unwrap(); | |
| 693 | + | let app = t.app(&"goingson".into()).unwrap(); | |
| 694 | + | assert_eq!(app.repo_for("mbp"), "/Users/max/Code/Apps/goingson"); | |
| 695 | + | assert_eq!(app.repo_for("fw13"), app.repo); | |
| 696 | + | } | |
| 697 | + | ||
| 698 | + | /// A misspelled host name would resolve to nothing and hand every host the | |
| 699 | + | /// default path, which looks exactly like a working config. | |
| 700 | + | #[test] | |
| 701 | + | fn repo_by_host_naming_an_unknown_host_is_rejected() { | |
| 702 | + | let err = load_with_pointer( | |
| 703 | + | HOSTS, | |
| 704 | + | MANIFEST, | |
| 705 | + | "[app.goingson.repo_by_host]\nwindows-x86 = \"C:/Users/me/Code/Apps/goingson\"\n", | |
| 706 | + | ) | |
| 707 | + | .unwrap_err(); | |
| 708 | + | assert!(format!("{err:#}").contains("windows-x86"), "{err:#}"); | |
| 709 | + | } | |
| 710 | + | ||
| 610 | 711 | #[test] | |
| 611 | 712 | fn rejects_target_without_a_host() { | |
| 612 | 713 | let only_linux = r#" |