Skip to main content

max / makenotwork

Gate docengine again, by letting a test_target name an aux repo docengine left the repo for Libraries/docengine on 2026-07-30 and reaches the build through the aux_repo checkout, which lands beside the per-sha worktree rather than under it. test_target dirs are worktree-relative, so dir = "shared/docengine" stopped resolving that day. An unresolvable target is warn-and-skip, deliberately, so a config describing the tip can still build an old sha for a bisect. That is what made this invisible: the skip is indistinguishable from a legitimate one, and the gate stayed green having run one crate fewer than it claims. docengine is checked out at branch HEAD and compiled into these binaries, so deleting the entry would have made the config honest and left a real gap. test_target now takes aux_repo, resolving dir against that checkout instead of the worktree, with dir optional for the repo root. Derive the checkout path once. checkout_aux_repos writes the tree and GateCtx::aux_dirs reads it; they were about to be two copies of one expression, and a divergence would present as another silent skip. Cross-check aux_repo names at load, so a typo fails --check-config on the build host. Third of these checks, after backups and companions. Label a target by the repo it lives in. dir alone is ambiguous once two repos are in play, and prints as nothing for an aux repo's root. Hand lint_over_targets its target instead of reverse-looking it up by comparing worktree.join(dir) against the resolved path — a match that silently fails for anything resolved anywhere else.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-03 02:51 UTC
Signed with PGP, not checked
Commit: e449bfaf9902fc3585c9974e6a66ca0763760682
Parent: be3070c
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,