max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
9 files changed,
+370 insertions,
-32 deletions
| @@ -117,9 +117,17 @@ | |||
| 117 | 117 | [[test_target]] | |
| 118 | 118 | dir = "wam" | |
| 119 | 119 | ||
| 120 | - | # shared/* — every app and service in the repo links these. | |
| 120 | + | # docengine left the repo for Libraries/docengine on 2026-07-30 and is consumed | |
| 121 | + | # through the [[aux_repo]] checkout, which sits BESIDE the worktree rather than | |
| 122 | + | # under it. `dir = "shared/docengine"` therefore stopped resolving that day and | |
| 123 | + | # quietly became a warn-and-skip, indistinguishable from a bisect skip: the gate | |
| 124 | + | # stayed green having run one crate fewer than it claims. `aux_repo` names the | |
| 125 | + | # checkout, and `dir` is empty because the crate is at that repo's root. | |
| 126 | + | # | |
| 127 | + | # Still worth gating despite living in another repo: the checkout is at branch | |
| 128 | + | # HEAD and is compiled into these binaries, so a break there breaks this build. | |
| 121 | 129 | [[test_target]] | |
| 122 | - | dir = "shared/docengine" | |
| 130 | + | aux_repo = "docengine" | |
| 123 | 131 | all_features = true | |
| 124 | 132 | ||
| 125 | 133 | # No tests of its own yet; listed so a compile break still fails the gate. |
| @@ -131,13 +131,16 @@ | |||
| 131 | 131 | [[test_target]] | |
| 132 | 132 | dir = "wam" | |
| 133 | 133 | ||
| 134 | + | # docengine lives in its own repo since 2026-07-30 and reaches the build through | |
| 135 | + | # the [[aux_repo]] checkout, which sits beside the worktree rather than under it. | |
| 136 | + | # `aux_repo` resolves `dir` against that checkout instead; `dir` is empty because | |
| 137 | + | # the crate is at the repo root. Gated because the checkout is at branch HEAD and | |
| 138 | + | # is compiled into these binaries — a break there breaks this build. | |
| 139 | + | [[test_target]] | |
| 140 | + | aux_repo = "docengine" | |
| 141 | + | all_features = true | |
| 142 | + | ||
| 134 | 143 | # shared/* — every app and service in the repo links these. | |
| 135 | - | # | |
| 136 | - | # No `shared/docengine` entry: docengine left the repo for Libraries/docengine on | |
| 137 | - | # 2026-07-30 and is consumed as a path dep resolved through the [[aux_repo]] | |
| 138 | - | # checkout, which is beside the worktree rather than under it. test_target dirs | |
| 139 | - | # are worktree-relative, so it is not addressable from here. (The dev config at | |
| 140 | - | # daemon/sando-daemon.toml still lists it; that entry is a warn-and-skip no-op.) | |
| 141 | 144 | [[test_target]] | |
| 142 | 145 | dir = "shared/egui-updater" | |
| 143 | 146 |
| @@ -240,6 +240,24 @@ | |||
| 240 | 240 | /// (the branch may already be present from a prior build); an unresolvable branch | |
| 241 | 241 | /// after that is fatal, as is a failed worktree — a half-assembled source tree | |
| 242 | 242 | /// must fail the build here, loudly, not as a downstream compile error. | |
| 243 | + | /// Where an aux repo's checkout lands. The single derivation: `checkout_aux_repos` | |
| 244 | + | /// creates it here and `GateCtx::aux_dirs` resolves `test_target`s against it, so | |
| 245 | + | /// the two cannot drift into looking in different places. | |
| 246 | + | pub fn aux_checkout_dir(cfg: &Config, aux: &crate::topology::AuxRepo) -> PathBuf { | |
| 247 | + | cfg.workdir.join(&aux.checkout_dir) | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | /// Every aux repo's checkout dir, keyed by name — what `GateCtx::aux_dirs` holds. | |
| 251 | + | pub fn aux_checkout_dirs( | |
| 252 | + | cfg: &Config, | |
| 253 | + | topo: &Topology, | |
| 254 | + | ) -> std::collections::HashMap<String, PathBuf> { | |
| 255 | + | topo.aux_repos | |
| 256 | + | .iter() | |
| 257 | + | .map(|a| (a.name.clone(), aux_checkout_dir(cfg, a))) | |
| 258 | + | .collect() | |
| 259 | + | } | |
| 260 | + | ||
| 243 | 261 | pub async fn checkout_aux_repos(cfg: &Config, topo: &Topology) -> Result<()> { | |
| 244 | 262 | for aux in &topo.aux_repos { | |
| 245 | 263 | let bare = PathBuf::from(&aux.bare_path); | |
| @@ -258,7 +276,7 @@ | |||
| 258 | 276 | aux.name, aux.branch, aux.upstream, | |
| 259 | 277 | ) | |
| 260 | 278 | })?; | |
| 261 | - | let dest = cfg.workdir.join(&aux.checkout_dir); | |
| 279 | + | let dest = aux_checkout_dir(cfg, aux); | |
| 262 | 280 | git::checkout_worktree(&bare, &sha, &dest) | |
| 263 | 281 | .await | |
| 264 | 282 | .with_context(|| { | |
| @@ -471,6 +489,9 @@ | |||
| 471 | 489 | // These gates vouch for this build; record its id so promote can resolve | |
| 472 | 490 | // the artifact through the evidence rather than a version string. | |
| 473 | 491 | build_id: Some(run_id.0), | |
| 492 | + | // Where checkout_aux_repos put each aux repo, so a test_target naming | |
| 493 | + | // one resolves. Shared derivation, so the two cannot disagree. | |
| 494 | + | aux_dirs: aux_checkout_dirs(&cfg, &topo), | |
| 474 | 495 | }; | |
| 475 | 496 | let failed = gates::run_all(&ctx, &host.gates).await?; | |
| 476 | 497 | ||
| @@ -691,6 +712,7 @@ | |||
| 691 | 712 | companions: Vec::new(), | |
| 692 | 713 | test_targets: vec![TestTarget { | |
| 693 | 714 | dir: PathBuf::from("server"), | |
| 715 | + | aux_repo: None, | |
| 694 | 716 | features: vec!["fast-tests".into()], | |
| 695 | 717 | all_features: false, | |
| 696 | 718 | scratch_db: true, | |
| @@ -800,6 +822,70 @@ | |||
| 800 | 822 | } | |
| 801 | 823 | } | |
| 802 | 824 | ||
| 825 | + | #[tokio::test] | |
| 826 | + | async fn a_gate_looks_where_the_aux_checkout_actually_landed() { | |
| 827 | + | // The two halves of the aux-repo test_target path: checkout_aux_repos | |
| 828 | + | // writes the tree, and GateCtx::target_dir reads it. Nothing but this | |
| 829 | + | // stops one from being changed without the other, and the failure would | |
| 830 | + | // be a warn-and-skip — a green gate that ran one crate fewer. | |
| 831 | + | let tmp = tempfile::tempdir().unwrap(); | |
| 832 | + | let src = tmp.path().join("docengine-src"); | |
| 833 | + | tokio::fs::create_dir_all(&src).await.unwrap(); | |
| 834 | + | git_in(&src, &["init", "-q", "-b", "main"]).await; | |
| 835 | + | tokio::fs::write(src.join("Cargo.toml"), b"[package]\nname = \"docengine\"\n") | |
| 836 | + | .await | |
| 837 | + | .unwrap(); | |
| 838 | + | git_in(&src, &["add", "."]).await; | |
| 839 | + | git_in(&src, &["commit", "-q", "-m", "one"]).await; | |
| 840 | + | ||
| 841 | + | let workdir = tmp.path().join("work"); | |
| 842 | + | tokio::fs::create_dir_all(&workdir).await.unwrap(); | |
| 843 | + | let cfg = cfg_with_workdir(workdir.clone()); | |
| 844 | + | let topo = topo_with_aux(vec![AuxRepo { | |
| 845 | + | name: "docengine".into(), | |
| 846 | + | bare_path: tmp | |
| 847 | + | .path() | |
| 848 | + | .join("docengine.git") | |
| 849 | + | .to_string_lossy() | |
| 850 | + | .into_owned(), | |
| 851 | + | upstream: src.to_string_lossy().into_owned(), | |
| 852 | + | branch: "main".into(), | |
| 853 | + | // Nested, as the real one is: it must not be mistaken for a path | |
| 854 | + | // under the per-sha worktree. | |
| 855 | + | checkout_dir: "Libraries/docengine".into(), | |
| 856 | + | }]); | |
| 857 | + | checkout_aux_repos(&cfg, &topo).await.unwrap(); | |
| 858 | + | ||
| 859 | + | let ctx = crate::gates::GateCtx { | |
| 860 | + | pool: sqlx::SqlitePool::connect_lazy("sqlite::memory:").unwrap(), | |
| 861 | + | cfg: Arc::new(cfg.clone()), | |
| 862 | + | tier: crate::domain::TierId::new("host"), | |
| 863 | + | version: "0.1.0".parse().unwrap(), | |
| 864 | + | worktree: workdir.join("abc123"), | |
| 865 | + | events: crate::events::channel(), | |
| 866 | + | nodes: Vec::new(), | |
| 867 | + | build_id: None, | |
| 868 | + | aux_dirs: super::aux_checkout_dirs(&cfg, &topo), | |
| 869 | + | }; | |
| 870 | + | let target = crate::config::TestTarget { | |
| 871 | + | dir: PathBuf::new(), | |
| 872 | + | aux_repo: Some("docengine".into()), | |
| 873 | + | features: Vec::new(), | |
| 874 | + | all_features: true, | |
| 875 | + | scratch_db: false, | |
| 876 | + | }; | |
| 877 | + | let resolved = ctx.target_dir(&target).expect("aux repo is checked out"); | |
| 878 | + | assert!( | |
| 879 | + | resolved.join("Cargo.toml").is_file(), | |
| 880 | + | "gate would skip the aux target as absent; resolved {}", | |
| 881 | + | resolved.display(), | |
| 882 | + | ); | |
| 883 | + | assert!( | |
| 884 | + | !resolved.starts_with(&ctx.worktree), | |
| 885 | + | "an aux checkout is a sibling of the worktree, not under it", | |
| 886 | + | ); | |
| 887 | + | } | |
| 888 | + | ||
| 803 | 889 | #[tokio::test] | |
| 804 | 890 | async fn checkout_aux_repos_places_repo_beside_worktree_and_refreshes_to_branch_head() { | |
| 805 | 891 | let tmp = tempfile::tempdir().unwrap(); |
| @@ -158,9 +158,26 @@ | |||
| 158 | 158 | /// One crate's test suite, as run by the `cargo_test` gate. | |
| 159 | 159 | #[derive(Debug, Clone, Deserialize)] | |
| 160 | 160 | pub struct TestTarget { | |
| 161 | - | /// Directory under the worktree holding the crate's `Cargo.toml` | |
| 162 | - | /// (e.g. `server`, `shared/tagtree`). | |
| 161 | + | /// Directory holding the crate's `Cargo.toml`, relative to the worktree | |
| 162 | + | /// (e.g. `server`, `shared/tagtree`) — or, with `aux_repo` set, relative to | |
| 163 | + | /// that repo's checkout. Leave it empty for the root of an aux repo. | |
| 164 | + | #[serde(default)] | |
| 163 | 165 | pub dir: PathBuf, | |
| 166 | + | /// Resolve `dir` inside this `[[aux_repo]]`'s checkout instead of inside the | |
| 167 | + | /// worktree, naming the repo by its topology `name`. | |
| 168 | + | /// | |
| 169 | + | /// An aux repo is checked out *beside* the per-sha worktree, not under it | |
| 170 | + | /// (`<workdir>/<checkout_dir>` vs `<workdir>/<sha>`), so a worktree-relative | |
| 171 | + | /// path cannot reach one. Without this, a crate that leaves the repo but | |
| 172 | + | /// stays a path dependency silently stops being gated: `shared/docengine` | |
| 173 | + | /// became `Libraries/docengine` on 2026-07-30 and its `[[test_target]]` | |
| 174 | + | /// turned into a warn-and-skip no-op that read exactly like a bisect skip. | |
| 175 | + | /// | |
| 176 | + | /// Worth gating even though the code is not in this repo: an aux repo is | |
| 177 | + | /// checked out at its branch HEAD and compiled into these binaries, so a | |
| 178 | + | /// break there breaks this build, and nothing else stands between the two. | |
| 179 | + | #[serde(default)] | |
| 180 | + | pub aux_repo: Option<String>, | |
| 164 | 181 | /// Cargo features to enable. MNW's server needs `fast-tests`; most crates | |
| 165 | 182 | /// need none. | |
| 166 | 183 | #[serde(default)] | |
| @@ -177,6 +194,20 @@ | |||
| 177 | 194 | pub scratch_db: bool, | |
| 178 | 195 | } | |
| 179 | 196 | ||
| 197 | + | impl TestTarget { | |
| 198 | + | /// How the target is named in logs, warnings and config errors. `dir` alone | |
| 199 | + | /// is ambiguous once two repos are in play, and an empty `dir` (an aux | |
| 200 | + | /// repo's root) would otherwise print as nothing at all. | |
| 201 | + | pub fn label(&self) -> String { | |
| 202 | + | let dir = self.dir.display().to_string(); | |
| 203 | + | match (self.aux_repo.as_deref(), dir.is_empty()) { | |
| 204 | + | (None, _) => dir, | |
| 205 | + | (Some(repo), true) => format!("{repo} (aux)"), | |
| 206 | + | (Some(repo), false) => format!("{repo}/{dir} (aux)"), | |
| 207 | + | } | |
| 208 | + | } | |
| 209 | + | } | |
| 210 | + | ||
| 180 | 211 | /// One database's migrations, as dry-run by the `migration_dry_run` gate: | |
| 181 | 212 | /// restore that database's prod dump into a scratch DB, then run the worktree's | |
| 182 | 213 | /// migrations on top. | |
| @@ -237,6 +268,7 @@ | |||
| 237 | 268 | fn default_test_targets() -> Vec<TestTarget> { | |
| 238 | 269 | vec![TestTarget { | |
| 239 | 270 | dir: PathBuf::from("server"), | |
| 271 | + | aux_repo: None, | |
| 240 | 272 | features: vec!["fast-tests".into()], | |
| 241 | 273 | all_features: false, | |
| 242 | 274 | scratch_db: true, |
| @@ -14,6 +14,7 @@ | |||
| 14 | 14 | use ops_core::live_log::LiveLog; | |
| 15 | 15 | use ops_core::remote::LogSink; // brings `LiveLog::write_chunk` (the sink trait) into scope | |
| 16 | 16 | use sqlx::SqlitePool; | |
| 17 | + | use std::collections::HashMap; | |
| 17 | 18 | use std::path::PathBuf; | |
| 18 | 19 | use std::sync::Arc; | |
| 19 | 20 | use tokio::io::AsyncReadExt; | |
| @@ -54,6 +55,29 @@ | |||
| 54 | 55 | /// not through a version string that a later rebuild can silently reuse. | |
| 55 | 56 | /// `None` for legacy/pre-identity runs and gate unit tests. | |
| 56 | 57 | pub build_id: Option<i64>, | |
| 58 | + | /// Where each `[[aux_repo]]` is checked out, by topology name. Aux repos sit | |
| 59 | + | /// beside the per-sha worktree rather than under it, so a `test_target` that | |
| 60 | + | /// names one cannot be resolved against `worktree` alone. Filled from the | |
| 61 | + | /// topology at build time; empty at promote time, where the only gate that | |
| 62 | + | /// runs is `node_health` and there is no checkout at all. | |
| 63 | + | pub aux_dirs: HashMap<String, PathBuf>, | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | impl GateCtx { | |
| 67 | + | /// Absolute directory a `test_target` runs in: under the worktree, or under | |
| 68 | + | /// the named aux repo's checkout. | |
| 69 | + | /// | |
| 70 | + | /// An `aux_repo` naming nothing this run knows about resolves to `None` | |
| 71 | + | /// rather than to a wrong path. Callers treat that as "not present in this | |
| 72 | + | /// run" and skip, the same as a target missing from an older sha — | |
| 73 | + | /// `--check-config` is what stops a genuine typo from reaching here | |
| 74 | + | /// (`Topology::ensure_test_target_aux_repos_exist`). | |
| 75 | + | pub fn target_dir(&self, target: &crate::config::TestTarget) -> Option<PathBuf> { | |
| 76 | + | match target.aux_repo.as_deref() { | |
| 77 | + | None => Some(self.worktree.join(&target.dir)), | |
| 78 | + | Some(name) => Some(self.aux_dirs.get(name)?.join(&target.dir)), | |
| 79 | + | } | |
| 80 | + | } | |
| 57 | 81 | } | |
| 58 | 82 | ||
| 59 | 83 | /// One node the `node_health` gate verifies: its id, the systemd unit to | |
| @@ -242,18 +266,21 @@ | |||
| 242 | 266 | let mut ran = 0usize; | |
| 243 | 267 | ||
| 244 | 268 | for target in &ctx.cfg.test_targets { | |
| 245 | - | let dir = ctx.worktree.join(&target.dir); | |
| 269 | + | let label = target.label(); | |
| 246 | 270 | // A target absent from this sha is skipped, not fatal: sando has to be | |
| 247 | 271 | // able to build older shas (bisect, rollback rebuild) from a config that | |
| 248 | 272 | // describes the tip. The zero-targets-ran check below is what stops this | |
| 249 | 273 | // from quietly turning the gate into a no-op. | |
| 250 | - | if !dir.join("Cargo.toml").is_file() { | |
| 274 | + | let Some(dir) = ctx | |
| 275 | + | .target_dir(target) | |
| 276 | + | .filter(|d| d.join("Cargo.toml").is_file()) | |
| 277 | + | else { | |
| 251 | 278 | tracing::warn!( | |
| 252 | - | target = %target.dir.display(), version = %ctx.version, | |
| 253 | - | "test_target has no Cargo.toml in this worktree; skipping", | |
| 279 | + | target = %label, version = %ctx.version, | |
| 280 | + | "test_target has no Cargo.toml in this run; skipping", | |
| 254 | 281 | ); | |
| 255 | 282 | continue; | |
| 256 | - | } | |
| 283 | + | }; | |
| 257 | 284 | let features: Vec<&str> = target.features.iter().map(String::as_str).collect(); | |
| 258 | 285 | ||
| 259 | 286 | // Fast pre-gate: compile the test targets WITHOUT running them. This | |
| @@ -263,14 +290,14 @@ | |||
| 263 | 290 | // class (a field missing in a `#[cfg(test)]`-only binary like `load`) | |
| 264 | 291 | // otherwise compiles fine under the build step and only blows up here, | |
| 265 | 292 | // after a full build, as an opaque mass test failure. | |
| 266 | - | let banner = format!("\n==== test_target: {} ====\n", target.dir.display()); | |
| 293 | + | let banner = format!("\n==== test_target: {label} ====\n"); | |
| 267 | 294 | append_to_log(&log_path, banner.as_bytes()).await; | |
| 268 | 295 | let mut pre = match cargo_test_command(ctx, &dir, target, &features, &["--no-run"]).spawn() | |
| 269 | 296 | { | |
| 270 | 297 | Ok(c) => c, | |
| 271 | 298 | Err(e) => { | |
| 272 | 299 | return Ok(GateOutcome::failed(GateFailure::SpawnFailed { | |
| 273 | - | message: format!("{}: {e}", target.dir.display()), | |
| 300 | + | message: format!("{label}: {e}"), | |
| 274 | 301 | }) | |
| 275 | 302 | .with_log_ref(log_ref)); | |
| 276 | 303 | } | |
| @@ -300,7 +327,7 @@ | |||
| 300 | 327 | Ok(c) => c, | |
| 301 | 328 | Err(e) => { | |
| 302 | 329 | return Ok(GateOutcome::failed(GateFailure::SpawnFailed { | |
| 303 | - | message: format!("{}: {e}", target.dir.display()), | |
| 330 | + | message: format!("{label}: {e}"), | |
| 304 | 331 | }) | |
| 305 | 332 | .with_log_ref(log_ref)); | |
| 306 | 333 | } | |
| @@ -473,7 +500,7 @@ | |||
| 473 | 500 | GateKind::CargoDeny => ("deny.toml", vec!["deny".into(), "check".into()]), | |
| 474 | 501 | other => unreachable!("supply_chain called for {other:?}"), | |
| 475 | 502 | }; | |
| 476 | - | run_over_targets(ctx, run_id, kind, |target_dir| { | |
| 503 | + | run_over_targets(ctx, run_id, kind, |_target, target_dir| { | |
| 477 | 504 | target_dir.join(config_rel).is_file().then(|| args.clone()) | |
| 478 | 505 | }) | |
| 479 | 506 | .await | |
| @@ -487,9 +514,10 @@ | |||
| 487 | 514 | kind: GateKind, | |
| 488 | 515 | build_args: impl Fn(&crate::config::TestTarget, &[String]) -> Vec<String>, | |
| 489 | 516 | ) -> Result<GateOutcome> { | |
| 490 | - | let targets = ctx.cfg.test_targets.clone(); | |
| 491 | - | run_over_targets(ctx, run_id, kind, move |dir| { | |
| 492 | - | let t = targets.iter().find(|t| ctx.worktree.join(&t.dir) == dir)?; | |
| 517 | + | // The target is handed in directly. This used to reverse-look-it-up by | |
| 518 | + | // comparing `worktree.join(dir)` against the resolved path, which silently | |
| 519 | + | // stopped matching for anything resolved anywhere else — an aux repo, say. | |
| 520 | + | run_over_targets(ctx, run_id, kind, move |t, _dir| { | |
| 493 | 521 | Some(build_args(t, &t.features)) | |
| 494 | 522 | }) | |
| 495 | 523 | .await | |
| @@ -506,7 +534,7 @@ | |||
| 506 | 534 | ctx: &GateCtx, | |
| 507 | 535 | run_id: GateRunId, | |
| 508 | 536 | kind: GateKind, | |
| 509 | - | args_for: impl Fn(&std::path::Path) -> Option<Vec<String>>, | |
| 537 | + | args_for: impl Fn(&crate::config::TestTarget, &std::path::Path) -> Option<Vec<String>>, | |
| 510 | 538 | ) -> Result<GateOutcome> { | |
| 511 | 539 | let log_path = gate_log_path(ctx, kind); | |
| 512 | 540 | let log_ref = LogRef::new(&ctx.version, kind); | |
| @@ -515,19 +543,24 @@ | |||
| 515 | 543 | let mut ran = 0usize; | |
| 516 | 544 | ||
| 517 | 545 | for target in &ctx.cfg.test_targets { | |
| 518 | - | let dir = ctx.worktree.join(&target.dir); | |
| 519 | - | if !dir.join("Cargo.toml").is_file() { | |
| 546 | + | let label = target.label(); | |
| 547 | + | let Some(dir) = ctx | |
| 548 | + | .target_dir(target) | |
| 549 | + | .filter(|d| d.join("Cargo.toml").is_file()) | |
| 550 | + | else { | |
| 520 | 551 | tracing::warn!( | |
| 521 | - | gate = kind.as_str(), target = %target.dir.display(), | |
| 522 | - | "target has no Cargo.toml in this worktree; skipping", | |
| 552 | + | gate = kind.as_str(), target = %label, | |
| 553 | + | "target has no Cargo.toml in this run; skipping", | |
| 523 | 554 | ); | |
| 524 | 555 | continue; | |
| 525 | - | } | |
| 526 | - | let Some(args) = args_for(&dir) else { continue }; | |
| 556 | + | }; | |
| 557 | + | let Some(args) = args_for(target, &dir) else { | |
| 558 | + | continue; | |
| 559 | + | }; | |
| 527 | 560 | ||
| 528 | 561 | append_to_log( | |
| 529 | 562 | &log_path, | |
| 530 | - | format!("\n==== {}: {} ====\n", kind.as_str(), target.dir.display()).as_bytes(), | |
| 563 | + | format!("\n==== {}: {label} ====\n", kind.as_str()).as_bytes(), | |
| 531 | 564 | ) | |
| 532 | 565 | .await; | |
| 533 | 566 | ||
| @@ -560,7 +593,7 @@ | |||
| 560 | 593 | Ok(c) => c, | |
| 561 | 594 | Err(e) => { | |
| 562 | 595 | return Ok(GateOutcome::failed(GateFailure::SpawnFailed { | |
| 563 | - | message: format!("{}: {e}", target.dir.display()), | |
| 596 | + | message: format!("{label}: {e}"), | |
| 564 | 597 | }) | |
| 565 | 598 | .with_log_ref(log_ref)); | |
| 566 | 599 | } | |
| @@ -652,6 +685,7 @@ | |||
| 652 | 685 | // suite in one specific crate, not "the repo's tests". | |
| 653 | 686 | let target = crate::config::TestTarget { | |
| 654 | 687 | dir: std::path::PathBuf::from("server"), | |
| 688 | + | aux_repo: None, | |
| 655 | 689 | features: Vec::new(), | |
| 656 | 690 | all_features: false, | |
| 657 | 691 | scratch_db: true, | |
| @@ -2262,6 +2296,7 @@ | |||
| 2262 | 2296 | fn target(dir: &str) -> crate::config::TestTarget { | |
| 2263 | 2297 | crate::config::TestTarget { | |
| 2264 | 2298 | dir: std::path::PathBuf::from(dir), | |
| 2299 | + | aux_repo: None, | |
| 2265 | 2300 | features: Vec::new(), | |
| 2266 | 2301 | all_features: false, | |
| 2267 | 2302 | scratch_db: false, | |
| @@ -2349,6 +2384,76 @@ | |||
| 2349 | 2384 | assert!(matches!(f, GateFailure::SpawnFailed { .. })); | |
| 2350 | 2385 | } | |
| 2351 | 2386 | ||
| 2387 | + | /// `target()` above, but resolved against an aux repo's checkout. | |
| 2388 | + | fn aux_target(dir: &str, repo: &str) -> crate::config::TestTarget { | |
| 2389 | + | crate::config::TestTarget { | |
| 2390 | + | aux_repo: Some(repo.to_string()), | |
| 2391 | + | ..target(dir) | |
| 2392 | + | } | |
| 2393 | + | } | |
| 2394 | + | ||
| 2395 | + | fn resolving_ctx(worktree: &str, aux: &[(&str, &str)]) -> GateCtx { | |
| 2396 | + | GateCtx { | |
| 2397 | + | pool: SqlitePool::connect_lazy("sqlite::memory:").unwrap(), | |
| 2398 | + | cfg: std::sync::Arc::new(crate::config::Config::for_tests()), | |
| 2399 | + | tier: TierId::new("host"), | |
| 2400 | + | version: "0.1.0".parse().unwrap(), | |
| 2401 | + | worktree: PathBuf::from(worktree), | |
| 2402 | + | events: events::channel(), | |
| 2403 | + | nodes: Vec::new(), | |
| 2404 | + | build_id: None, | |
| 2405 | + | aux_dirs: aux | |
| 2406 | + | .iter() | |
| 2407 | + | .map(|(n, d)| ((*n).to_string(), PathBuf::from(d))) | |
| 2408 | + | .collect(), | |
| 2409 | + | } | |
| 2410 | + | } | |
| 2411 | + | ||
| 2412 | + | #[tokio::test] | |
| 2413 | + | async fn a_plain_target_resolves_under_the_worktree() { | |
| 2414 | + | let ctx = resolving_ctx("/w/abc123", &[]); | |
| 2415 | + | assert_eq!( | |
| 2416 | + | ctx.target_dir(&target("shared/tagtree")), | |
| 2417 | + | Some(PathBuf::from("/w/abc123/shared/tagtree")), | |
| 2418 | + | ); | |
| 2419 | + | } | |
| 2420 | + | ||
| 2421 | + | #[tokio::test] | |
| 2422 | + | async fn an_aux_target_resolves_beside_the_worktree_not_under_it() { | |
| 2423 | + | // The whole point: `Libraries/docengine` is a sibling of the per-sha | |
| 2424 | + | // worktree, so a worktree-relative path can never reach it. | |
| 2425 | + | let ctx = resolving_ctx("/w/abc123", &[("docengine", "/w/Libraries/docengine")]); | |
| 2426 | + | assert_eq!( | |
| 2427 | + | ctx.target_dir(&aux_target("", "docengine")), | |
| 2428 | + | Some(PathBuf::from("/w/Libraries/docengine")), | |
| 2429 | + | ); | |
| 2430 | + | // A subdirectory of an aux repo resolves under its checkout. | |
| 2431 | + | assert_eq!( | |
| 2432 | + | ctx.target_dir(&aux_target("crates/inner", "docengine")), | |
| 2433 | + | Some(PathBuf::from("/w/Libraries/docengine/crates/inner")), | |
| 2434 | + | ); | |
| 2435 | + | } | |
| 2436 | + | ||
| 2437 | + | #[tokio::test] | |
| 2438 | + | async fn an_aux_target_with_no_checkout_this_run_resolves_to_nothing() { | |
| 2439 | + | // Promote-time gates carry no aux dirs. Resolving to a wrong path (say, | |
| 2440 | + | // the worktree) would run the gate against whatever happened to sit | |
| 2441 | + | // there; `None` makes the caller skip, and --check-config is what | |
| 2442 | + | // catches a real typo. | |
| 2443 | + | let ctx = resolving_ctx("/w/abc123", &[]); | |
| 2444 | + | assert_eq!(ctx.target_dir(&aux_target("", "docengine")), None); | |
| 2445 | + | } | |
| 2446 | + | ||
| 2447 | + | #[test] | |
| 2448 | + | fn labels_name_the_repo_an_aux_target_lives_in() { | |
| 2449 | + | assert_eq!(target("server").label(), "server"); | |
| 2450 | + | assert_eq!(aux_target("", "docengine").label(), "docengine (aux)"); | |
| 2451 | + | assert_eq!( | |
| 2452 | + | aux_target("crates/inner", "docengine").label(), | |
| 2453 | + | "docengine/crates/inner (aux)", | |
| 2454 | + | ); | |
| 2455 | + | } | |
| 2456 | + | ||
| 2352 | 2457 | /// A `GateCtx` over `worktree` with the given frontend projects configured. | |
| 2353 | 2458 | /// No DB, no artifact — `code_smoke_frontends` touches neither. | |
| 2354 | 2459 | async fn frontend_ctx(worktree: &std::path::Path, dirs: &[&str]) -> GateCtx { | |
| @@ -2374,6 +2479,7 @@ | |||
| 2374 | 2479 | events: events::channel(), | |
| 2375 | 2480 | nodes: Vec::new(), | |
| 2376 | 2481 | build_id: None, | |
| 2482 | + | aux_dirs: HashMap::new(), | |
| 2377 | 2483 | } | |
| 2378 | 2484 | } | |
| 2379 | 2485 | ||
| @@ -2401,6 +2507,7 @@ | |||
| 2401 | 2507 | events: events::channel(), | |
| 2402 | 2508 | nodes: Vec::new(), | |
| 2403 | 2509 | build_id: None, | |
| 2510 | + | aux_dirs: HashMap::new(), | |
| 2404 | 2511 | } | |
| 2405 | 2512 | } | |
| 2406 | 2513 | ||
| @@ -2726,6 +2833,7 @@ | |||
| 2726 | 2833 | events: events::channel(), | |
| 2727 | 2834 | nodes: Vec::new(), | |
| 2728 | 2835 | build_id: None, | |
| 2836 | + | aux_dirs: HashMap::new(), | |
| 2729 | 2837 | }; | |
| 2730 | 2838 | let out = cargo_test(&ctx, GateRunId(1)).await.unwrap(); | |
| 2731 | 2839 | assert_eq!( | |
| @@ -2761,6 +2869,7 @@ | |||
| 2761 | 2869 | events: events::channel(), | |
| 2762 | 2870 | nodes: Vec::new(), | |
| 2763 | 2871 | build_id: None, | |
| 2872 | + | aux_dirs: HashMap::new(), | |
| 2764 | 2873 | }; | |
| 2765 | 2874 | let dir = std::path::Path::new("/tmp/wt/x"); | |
| 2766 | 2875 | ||
| @@ -2794,6 +2903,7 @@ | |||
| 2794 | 2903 | events: events::channel(), | |
| 2795 | 2904 | nodes: Vec::new(), | |
| 2796 | 2905 | build_id: None, | |
| 2906 | + | aux_dirs: HashMap::new(), | |
| 2797 | 2907 | }; | |
| 2798 | 2908 | let mut t = target("shared/ops-exec"); | |
| 2799 | 2909 | t.all_features = true; | |
| @@ -2882,6 +2992,7 @@ | |||
| 2882 | 2992 | events: events::channel(), | |
| 2883 | 2993 | nodes: Vec::new(), | |
| 2884 | 2994 | build_id: None, | |
| 2995 | + | aux_dirs: HashMap::new(), | |
| 2885 | 2996 | }; | |
| 2886 | 2997 | let out = supply_chain(&ctx, GateRunId(1), GateKind::CargoAudit) | |
| 2887 | 2998 | .await | |
| @@ -2957,9 +3068,11 @@ | |||
| 2957 | 3068 | events: events::channel(), | |
| 2958 | 3069 | nodes: Vec::new(), | |
| 2959 | 3070 | build_id: None, | |
| 3071 | + | aux_dirs: HashMap::new(), | |
| 2960 | 3072 | }; | |
| 2961 | 3073 | let plain = crate::config::TestTarget { | |
| 2962 | 3074 | dir: std::path::PathBuf::from("server"), | |
| 3075 | + | aux_repo: None, | |
| 2963 | 3076 | features: Vec::new(), | |
| 2964 | 3077 | all_features: false, | |
| 2965 | 3078 | scratch_db: true, | |
| @@ -2987,6 +3100,7 @@ | |||
| 2987 | 3100 | ||
| 2988 | 3101 | let fast_target = crate::config::TestTarget { | |
| 2989 | 3102 | dir: std::path::PathBuf::from("server"), | |
| 3103 | + | aux_repo: None, | |
| 2990 | 3104 | features: vec!["fast-tests".into()], | |
| 2991 | 3105 | all_features: false, | |
| 2992 | 3106 | scratch_db: true, | |
| @@ -3089,6 +3203,7 @@ | |||
| 3089 | 3203 | events: events::channel(), | |
| 3090 | 3204 | nodes: Vec::new(), | |
| 3091 | 3205 | build_id: None, | |
| 3206 | + | aux_dirs: HashMap::new(), | |
| 3092 | 3207 | }; | |
| 3093 | 3208 | let out = run(&ctx, &Gate::BurnIn { hours: 24 }).await.unwrap(); | |
| 3094 | 3209 | assert_eq!(out.status_str(), "blocked"); | |
| @@ -3143,6 +3258,7 @@ | |||
| 3143 | 3258 | events: events::channel(), | |
| 3144 | 3259 | nodes: Vec::new(), // no nodes -> fail closed | |
| 3145 | 3260 | build_id: None, | |
| 3261 | + | aux_dirs: HashMap::new(), | |
| 3146 | 3262 | }; | |
| 3147 | 3263 | let out = run(&ctx, &Gate::NodeHealth).await.unwrap(); | |
| 3148 | 3264 | assert_eq!(out.status_str(), "blocked"); | |
| @@ -3464,6 +3580,7 @@ | |||
| 3464 | 3580 | events: events::channel(), | |
| 3465 | 3581 | nodes: Vec::new(), | |
| 3466 | 3582 | build_id: None, | |
| 3583 | + | aux_dirs: HashMap::new(), | |
| 3467 | 3584 | }; | |
| 3468 | 3585 | let out = run(&ctx, &Gate::CodeSmoke).await.unwrap(); | |
| 3469 | 3586 | assert_eq!(out.status_str(), "blocked"); |
| @@ -51,6 +51,7 @@ | |||
| 51 | 51 | topo.ensure_build_host_not_serving(&cfg.build_host)?; | |
| 52 | 52 | topo.ensure_migration_checks_have_backups(&cfg.migration_checks)?; | |
| 53 | 53 | topo.ensure_node_companions_are_built(&cfg.companions)?; | |
| 54 | + | topo.ensure_test_target_aux_repos_exist(&cfg.test_targets)?; | |
| 54 | 55 | Ok(topo) | |
| 55 | 56 | } | |
| 56 | 57 | ||
| @@ -75,6 +76,7 @@ | |||
| 75 | 76 | topo.ensure_build_host_not_serving(&cfg.build_host)?; | |
| 76 | 77 | topo.ensure_migration_checks_have_backups(&cfg.migration_checks)?; | |
| 77 | 78 | topo.ensure_node_companions_are_built(&cfg.companions)?; | |
| 79 | + | topo.ensure_test_target_aux_repos_exist(&cfg.test_targets)?; | |
| 78 | 80 | tokio::fs::create_dir_all(&cfg.workdir).await?; | |
| 79 | 81 | tokio::fs::create_dir_all(&cfg.release_root).await?; | |
| 80 | 82 | git::ensure_bare_repo(Path::new(&topo.repo.bare_path)).await?; |
| @@ -349,6 +349,40 @@ | |||
| 349 | 349 | Ok(()) | |
| 350 | 350 | } | |
| 351 | 351 | ||
| 352 | + | /// Every `[[test_target]]` with an `aux_repo` must name a repo this topology | |
| 353 | + | /// checks out. Third of the cross-file checks, and the one whose absence has | |
| 354 | + | /// already cost coverage once: an unresolvable target is a warn-and-skip, by | |
| 355 | + | /// design, so that a config describing the tip can still build an older sha. | |
| 356 | + | /// That makes a typo here indistinguishable from a legitimate bisect skip — | |
| 357 | + | /// a green gate that ran one crate fewer than it says it does. | |
| 358 | + | pub fn ensure_test_target_aux_repos_exist( | |
| 359 | + | &self, | |
| 360 | + | targets: &[crate::config::TestTarget], | |
| 361 | + | ) -> Result<()> { | |
| 362 | + | for t in targets { | |
| 363 | + | let Some(name) = t.aux_repo.as_deref() else { | |
| 364 | + | continue; | |
| 365 | + | }; | |
| 366 | + | anyhow::ensure!( | |
| 367 | + | self.aux_repos.iter().any(|a| a.name == name), | |
| 368 | + | "test_target {} names aux_repo {:?}, which no [[aux_repo]] in the topology \ | |
| 369 | + | checks out, so the gate would skip it as absent (have: {})", | |
| 370 | + | t.label(), | |
| 371 | + | name, | |
| 372 | + | if self.aux_repos.is_empty() { | |
| 373 | + | "none".to_string() | |
| 374 | + | } else { | |
| 375 | + | self.aux_repos | |
| 376 | + | .iter() | |
| 377 | + | .map(|a| a.name.as_str()) | |
| 378 | + | .collect::<Vec<_>>() | |
| 379 | + | .join(", ") | |
| 380 | + | }, | |
| 381 | + | ); | |
| 382 | + | } | |
| 383 | + | Ok(()) | |
| 384 | + | } | |
| 385 | + | ||
| 352 | 386 | /// Every `[[tier.node.companion]]` must name a companion the daemon config | |
| 353 | 387 | /// actually builds. Same two-file split as the migration checks above, and | |
| 354 | 388 | /// the same class of typo, but a worse landing: companions are installed | |
| @@ -665,6 +699,59 @@ | |||
| 665 | 699 | .collect() | |
| 666 | 700 | } | |
| 667 | 701 | ||
| 702 | + | fn test_target(dir: &str, aux_repo: Option<&str>) -> crate::config::TestTarget { | |
| 703 | + | crate::config::TestTarget { | |
| 704 | + | dir: dir.into(), | |
| 705 | + | aux_repo: aux_repo.map(str::to_string), | |
| 706 | + | features: Vec::new(), | |
| 707 | + | all_features: false, | |
| 708 | + | scratch_db: false, | |
| 709 | + | } | |
| 710 | + | } | |
| 711 | + | ||
| 712 | + | #[test] | |
| 713 | + | fn a_test_target_naming_a_checked_out_aux_repo_is_accepted() { | |
| 714 | + | let topo = topo_with_aux( | |
| 715 | + | "[[aux_repo]]\nname = \"docengine\"\nbare_path = \"/tmp/d.git\"\n\ | |
| 716 | + | upstream = \"git@h:max/d.git\"\nbranch = \"main\"\ncheckout_dir = \"Libraries/docengine\"\n", | |
| 717 | + | ) | |
| 718 | + | .expect("parse"); | |
| 719 | + | assert!( | |
| 720 | + | topo.ensure_test_target_aux_repos_exist(&[ | |
| 721 | + | test_target("server", None), | |
| 722 | + | test_target("", Some("docengine")), | |
| 723 | + | ]) | |
| 724 | + | .is_ok() | |
| 725 | + | ); | |
| 726 | + | } | |
| 727 | + | ||
| 728 | + | #[test] | |
| 729 | + | fn a_test_target_naming_an_unknown_aux_repo_is_rejected_at_load() { | |
| 730 | + | // The failure this exists to prevent is silent: an unresolvable target | |
| 731 | + | // is a warn-and-skip (bisect), so the gate stays green having run one | |
| 732 | + | // crate fewer than the config claims. | |
| 733 | + | let topo = topo_with_aux( | |
| 734 | + | "[[aux_repo]]\nname = \"synckit\"\nbare_path = \"/tmp/s.git\"\n\ | |
| 735 | + | upstream = \"git@h:max/s.git\"\nbranch = \"main\"\ncheckout_dir = \"synckit\"\n", | |
| 736 | + | ) | |
| 737 | + | .expect("parse"); | |
| 738 | + | let err = topo | |
| 739 | + | .ensure_test_target_aux_repos_exist(&[test_target("", Some("docengine"))]) | |
| 740 | + | .unwrap_err() | |
| 741 | + | .to_string(); | |
| 742 | + | assert!(err.contains("docengine"), "{err}"); | |
| 743 | + | assert!(err.contains("have: synckit"), "{err}"); | |
| 744 | + | } | |
| 745 | + | ||
| 746 | + | #[test] | |
| 747 | + | fn test_targets_without_an_aux_repo_need_no_aux_repos_declared() { | |
| 748 | + | let topo = topo_with_serving_gates(true, r#"{ kind = "node_health" }"#); | |
| 749 | + | assert!( | |
| 750 | + | topo.ensure_test_target_aux_repos_exist(&[test_target("server", None)]) | |
| 751 | + | .is_ok() | |
| 752 | + | ); | |
| 753 | + | } | |
| 754 | + | ||
| 668 | 755 | #[test] | |
| 669 | 756 | fn a_node_companion_the_daemon_builds_is_accepted() { | |
| 670 | 757 | let topo = topo_installing(&["mnw-cli", "multithreaded"]); |
| @@ -962,6 +962,7 @@ | |||
| 962 | 962 | companions: Vec::new(), | |
| 963 | 963 | test_targets: vec![crate::config::TestTarget { | |
| 964 | 964 | dir: PathBuf::from("server"), | |
| 965 | + | aux_repo: None, | |
| 965 | 966 | features: vec!["fast-tests".into()], | |
| 966 | 967 | all_features: false, | |
| 967 | 968 | scratch_db: true, |
| @@ -417,6 +417,8 @@ | |||
| 417 | 417 | // tier -> the following one) resolves through, so they must carry the | |
| 418 | 418 | // build they vouch for. | |
| 419 | 419 | build_id, | |
| 420 | + | // No checkout at promote time, so nothing to resolve against. | |
| 421 | + | aux_dirs: std::collections::HashMap::new(), | |
| 420 | 422 | }; | |
| 421 | 423 | post_deploy_failure = match crate::gates::run_all(&ctx, &post_deploy).await { | |
| 422 | 424 | Ok(failed) if failed.is_empty() => None, |