anodizer_core/determinism_runner.rs
1//! Subprocess runner for the determinism harness.
2//!
3//! Allow-listed entry-point for `Command::new` in core. The determinism
4//! harness in `crates/cli/src/determinism_harness.rs` is forbidden
5//! from spawning processes directly per the module-boundary rule, so
6//! this module owns the `anodize release --snapshot --skip=...`
7//! invocation that drives each from-clean rebuild.
8//!
9//! Why a separate module: `Command::new` is an authorization boundary
10//! (write-to-disk, network, env exfiltration); concentrating the
11//! harness's one call site here keeps the security-relevant surface
12//! reviewable.
13
14use anyhow::{Context, Result};
15use std::collections::HashMap;
16use std::path::{Path, PathBuf};
17use std::process::{Command, Stdio};
18use std::time::Duration;
19
20/// Stage names the determinism harness must NOT run.
21///
22/// Single source of truth for the `--skip=...` list passed to the child
23/// `anodize release --snapshot` invocation: every stage in
24/// [`crate::stages::UPSTREAM_STAGES`] (uploads, API calls, push, announce)
25/// plus the harness-only extras below — stages that don't reach upstream
26/// but still have no place in a hermetic byte-reproducibility rebuild.
27/// Deriving from the shared classification means a future upstream stage
28/// added there is skipped here automatically, instead of the two lists
29/// drifting apart by hand.
30pub static SIDE_EFFECT_STAGES: std::sync::LazyLock<Vec<&'static str>> =
31 std::sync::LazyLock::new(|| {
32 // Harness-only extras: notarization is an Apple-server round-trip
33 // (`notarytool` submit + an Apple-issued ticket stapled to the
34 // artifact) — the ticket is server-generated so the output can never
35 // be byte-reproduced in a hermetic rebuild, and the submit is
36 // credential-gated (App Store Connect API key). `--prepare`
37 // legitimately produces notarized artifacts, so it lives here rather
38 // than in the shared upstream classification.
39 let mut stages = vec!["notarize"];
40 stages.extend_from_slice(crate::stages::UPSTREAM_STAGES);
41 stages
42 });
43
44/// Comma-join [`SIDE_EFFECT_STAGES`] plus an `extra` list for use as
45/// the `--skip=<list>` CLI argument value. Order-preserving and
46/// duplicate-free: every entry from [`SIDE_EFFECT_STAGES`] comes first
47/// (in declared order), then each `extra` entry that hasn't already been
48/// seen. Kept as a function (not a const) because Rust can't
49/// const-evaluate `[&str]::join`.
50///
51/// The `extra` argument is the harness's "complement set": every stage
52/// the operator did NOT request via `--stages=` AND that doesn't belong
53/// to the preamble preserve set (`validate` / `before` / `changelog` /
54/// `templatefiles`). Skipping them in the child release subprocess
55/// matches the spec's promise that `anodize check determinism
56/// --stages=<list>` only exercises (and validates) the named stages —
57/// previously the child still ran the full pipeline, attempting nfpm /
58/// nsis / dmg / etc. on shards that have no business running them.
59pub fn compute_skip_arg(extra: &[&str]) -> String {
60 let mut merged: Vec<&str> = Vec::with_capacity(SIDE_EFFECT_STAGES.len() + extra.len());
61 for &name in SIDE_EFFECT_STAGES.iter() {
62 if !merged.contains(&name) {
63 merged.push(name);
64 }
65 }
66 for &name in extra {
67 if !merged.contains(&name) {
68 merged.push(name);
69 }
70 }
71 format!("--skip={}", merged.join(","))
72}
73
74/// Invoke the running `anodize` binary against `worktree_path` with the
75/// supplied isolated env.
76///
77/// Pinning args:
78/// - `release` — drives the full build-side pipeline.
79/// - `--snapshot` (when `snapshot` is `true`) — disables tag-cutting and
80/// tells stages to use the pre-resolved SDE. The release workflow
81/// passes `false` on tag-push runs so produce-stages emit artifacts
82/// named with the actual release version (no `-SNAPSHOT-<sha>` suffix)
83/// that the publish-only path can ship directly.
84/// - `--skip=<SIDE_EFFECT_STAGES + extra_skip>` — strips every
85/// side-effect-producing stage AND every non-requested produce-stage
86/// (the harness's complement set). Doubling N is safe in any env
87/// because of this skip list.
88/// - `--no-env-preflight` — always. The replica runs in a deliberately
89/// credential-less env; see `build_subprocess_command`.
90/// - `--targets=<csv>` (when `targets` is `Some`) — restricts the
91/// rebuild to a subset of configured triples. The sharded
92/// `release.yml` matrix passes this so each runner only validates
93/// the targets it can natively build (cross-compile to Apple /
94/// Windows from a Linux runner would otherwise fail at link time).
95///
96/// The `extra_skip` slice carries the harness's complement set: stages
97/// the operator did NOT name via `--stages=` (minus the preamble
98/// preserve set). Merged with [`SIDE_EFFECT_STAGES`] via
99/// [`compute_skip_arg`]; the harness in
100/// `crates/cli/src/determinism_harness.rs` is the canonical caller and
101/// computes the set from `anodizer_core::context::VALID_RELEASE_SKIPS`.
102/// Pass `&[]` to keep the legacy "side-effect stages only" behavior.
103///
104/// The child env is fully replaced (`env_clear` then re-populate) so
105/// host env vars cannot leak through and perturb the build. Caller
106/// (the harness) constructs the env map.
107pub fn run_build_pipeline_subprocess(spec: &ChildInvocation<'_>) -> Result<()> {
108 let mut cmd = build_subprocess_command(spec);
109 tracing::debug!(
110 args = ?cmd.get_args(),
111 worktree = %spec.worktree_path.display(),
112 "spawning anodize release child for determinism harness",
113 );
114 let status = cmd
115 .status()
116 .context("spawning anodize release for determinism harness")?;
117 anyhow::ensure!(
118 status.success(),
119 "harness build pipeline failed in worktree {} (exit {:?})",
120 spec.worktree_path.display(),
121 status.code()
122 );
123 Ok(())
124}
125
126/// Invocation knobs for the child `anodize release` subprocess, grouped
127/// so the spawn surface takes one spec instead of a positional argument
128/// list that grows with every new knob.
129pub struct ChildInvocation<'a> {
130 /// Path to the running `anodize` binary (see
131 /// [`current_anodize_binary`]).
132 pub anodize_binary: &'a Path,
133 /// Hermetic worktree the child builds in (`current_dir`).
134 pub worktree_path: &'a Path,
135 /// Fully-replacing child env map (`env_clear` + re-populate);
136 /// constructed by the harness.
137 pub env: &'a HashMap<String, String>,
138 /// `--targets=<csv>` restriction; `None` validates every configured
139 /// target.
140 pub targets: Option<&'a [String]>,
141 /// The harness's complement skip set, merged with
142 /// [`SIDE_EFFECT_STAGES`] via [`compute_skip_arg`]. Pass `&[]` for
143 /// the legacy "side-effect stages only" behavior.
144 pub extra_skip: &'a [String],
145 /// Whether the child gets `--snapshot`. The release workflow passes
146 /// `false` on tag-push runs so artifacts carry the real version.
147 pub snapshot: bool,
148 /// `--crate=<name>` scoping for per-crate shards; `None` builds the
149 /// workspace default.
150 pub crate_name: Option<&'a str>,
151 /// Operator verbosity, forwarded as `--quiet` / `--verbose` /
152 /// `--debug` so the child's inherited stderr honors the same
153 /// contract as the harness's own logger.
154 pub verbosity: crate::log::Verbosity,
155}
156
157/// Build the [`Command`] the harness will spawn. Split out from
158/// [`run_build_pipeline_subprocess`] so unit tests can inspect the
159/// constructed argv (`cmd.get_args()`) without shelling out — the
160/// alternative is to ship a real `anodize` binary into the test harness.
161fn build_subprocess_command(spec: &ChildInvocation<'_>) -> Command {
162 let ChildInvocation {
163 anodize_binary,
164 worktree_path,
165 env,
166 targets,
167 extra_skip,
168 snapshot,
169 crate_name,
170 verbosity,
171 } = *spec;
172 let mut cmd = Command::new(anodize_binary);
173 let extra_refs: Vec<&str> = extra_skip.iter().map(String::as_str).collect();
174 cmd.arg("release");
175 if snapshot {
176 cmd.arg("--snapshot");
177 }
178 // The child's stderr is inherited into the harness's own stream, so
179 // the operator's verbosity choice must extend to the child — a
180 // `check determinism -q` whose children still print every section
181 // would make the flag meaningless.
182 match verbosity {
183 crate::log::Verbosity::Quiet => {
184 cmd.arg("--quiet");
185 }
186 crate::log::Verbosity::Verbose => {
187 cmd.arg("--verbose");
188 }
189 crate::log::Verbosity::Debug => {
190 cmd.arg("--debug");
191 }
192 crate::log::Verbosity::Normal => {}
193 }
194 cmd.arg(compute_skip_arg(&extra_refs));
195 // The replica pipeline runs in a deliberately credential-less hermetic
196 // env (env_clear + identity-only re-population): its run paths skip
197 // gracefully when keys/tools are absent, nothing publishes (see the
198 // skip list above), and signature outputs are excluded from
199 // byte-comparison. The config-derived env preflight would therefore
200 // reject exactly the environment the harness is designed to run in —
201 // disable it for the child by construction. Real release entrypoints
202 // are unaffected; preflight guards them as before.
203 cmd.arg("--no-env-preflight");
204 if let Some(list) = targets
205 && !list.is_empty()
206 {
207 cmd.arg(format!("--targets={}", list.join(",")));
208 }
209 // Scope the child build to the same crate the harness is preserving for.
210 // Without it a workspace build defaults to its primary crate, so a
211 // per-crate shard would rebuild (and preserve) the wrong member's
212 // artifacts — e.g. a library's source archive in place of a binary
213 // crate's compiled binaries, leaving publish-only with no binary to
214 // ship or stage into a docker context.
215 if let Some(name) = crate_name {
216 cmd.arg(format!("--crate={name}"));
217 }
218 cmd.current_dir(worktree_path);
219 cmd.env_clear();
220 for (k, v) in env {
221 cmd.env(k, v);
222 }
223 // anodizer's stdout is a machine-readable data channel (GHA step
224 // outputs, JSON payloads); the harness consumes none of the child's —
225 // it reads artifacts from disk and gates on the exit status alone. Null
226 // the child's stdout so its data channel never pollutes the harness's
227 // own stdout. The child's *stderr* (its logger's status/verbose lines)
228 // stays inherited so the operator's verbosity choice, forwarded above,
229 // still surfaces the inner run's progress.
230 cmd.stdout(Stdio::null());
231 // The child's stderr is inherited, so its lines interleave straight
232 // into the harness's stream. Export the parent's nesting depth (+2)
233 // so the child's section headers render beneath the harness's
234 // `• run N of M` bullet instead of flush-left: the bullet itself
235 // sits one section level plus a body indent under the harness
236 // header, and the child's sections belong one further level in.
237 // Set AFTER the hermetic env map so the map cannot clobber it.
238 cmd.env(
239 crate::log::LOG_DEPTH_ENV,
240 (crate::log::current_depth() + 2).to_string(),
241 );
242 cmd
243}
244
245/// Resolve the path of the currently-running `anodize` binary. Thin
246/// wrapper over [`std::env::current_exe`] kept here so the harness side
247/// doesn't have to touch `std::env` for binary resolution.
248pub fn current_anodize_binary() -> Result<PathBuf> {
249 std::env::current_exe().context("locating the currently-running anodize binary")
250}
251
252/// Number of `cargo fetch` attempts [`prefetch_deps`] makes before failing.
253/// The prefetch is the harness's single network operation; transient
254/// registry/DNS failures (`Could not resolve host: index.crates.io`) are
255/// precisely what a retry kills — so the retry lives here, on the one online
256/// op, and NOT on the offline-sealed rebuilds (which cannot reach the network
257/// to flake on it in the first place).
258const PREFETCH_ATTEMPTS: u32 = 3;
259
260/// Fixed backoff between [`prefetch_deps`] attempts.
261const PREFETCH_BACKOFF: Duration = Duration::from_secs(3);
262
263/// Warm `cargo_home`'s registry cache by fetching every `Cargo.lock`-pinned
264/// dependency of the workspace at `manifest_dir` exactly ONCE, before the
265/// harness's rebuild loop seals all child builds offline
266/// (`CARGO_NET_OFFLINE=true`, set in the harness's child env).
267///
268/// This makes the reproducibility gate network-independent on every rebuild:
269/// the shared, lock-pinned cache is determinism-safe to reuse across runs
270/// (`.crate` tarballs + extracted sources are content-addressed → byte-
271/// identical regardless of which run downloaded them), and the offline seal
272/// then guarantees no rebuild can reach the network at all. The prefetch is
273/// therefore the only place a transient registry hiccup is survivable, which
274/// is why it — and only it — retries.
275///
276/// The prefetch passes NO `--target`: a plain `cargo fetch` resolves and
277/// downloads the dependency superset for EVERY platform in the manifest's cfg
278/// graph (host + all cross targets at once — empirically `windows-sys`,
279/// `core-foundation-sys`, `cpufeatures`, … all land in the cache even from a
280/// Linux host). That all-platform superset is exactly what a hermetic offline
281/// rebuild needs, on any shard: the explicit-target Windows shards, the
282/// multi-arch `targets:''` macOS/Ubuntu shards, and the host-side man-page
283/// `before:` hook (`cargo run --bin anodize`) alike. See
284/// `build_fetch_command` for why an explicit `--target` is the bug this
285/// guards against.
286///
287/// The child inherits the host env (operator cargo/proxy/registry config,
288/// `RUSTUP_HOME`, …) and only overrides `CARGO_HOME` + forces
289/// `CARGO_NET_OFFLINE=false` — the prefetch MUST stay online even if the host
290/// opted offline, unlike the sealed rebuild children.
291pub fn prefetch_deps(manifest_dir: &Path, cargo_home: &Path) -> Result<()> {
292 // A project that doesn't commit a `Cargo.lock` (library crate / minimal
293 // fixture) gets one WRITTEN into the worktree by `cargo fetch` as a side
294 // effect of resolution. Left in place it dirties the worktree and trips the
295 // release pipeline's clean-state guard on non-snapshot rebuilds (`git is in
296 // a dirty state; … Use --snapshot to force`). The thing we actually wanted —
297 // a warm registry cache — lives in CARGO_HOME, not the worktree, so drop the
298 // stray lock afterward to leave the checkout byte-for-byte as `git worktree
299 // add` produced it. The offline rebuild regenerates an identical lock from
300 // the warm cache during its OWN build (after the clean-state check passes),
301 // so determinism is unaffected. Projects that DO commit a lock take the
302 // `--locked` path, which never mutates the lock, so nothing is removed.
303 let lock = manifest_dir.join("Cargo.lock");
304 let lock_committed = lock.is_file();
305 let res = prefetch_deps_with(
306 manifest_dir,
307 cargo_home,
308 PREFETCH_ATTEMPTS,
309 PREFETCH_BACKOFF,
310 );
311 if !lock_committed && lock.is_file() {
312 let _ = std::fs::remove_file(&lock);
313 }
314 res
315}
316
317/// Build the `cargo fetch` [`Command`] that warms the shared cache. Split out
318/// so unit tests can inspect the argv/env (`cmd.get_args()` / `cmd.get_envs()`)
319/// without a live network fetch.
320///
321/// Passes NO `--target` deliberately: a plain `cargo fetch` resolves and
322/// downloads the dependency superset for EVERY platform in the manifest's cfg
323/// graph (host + all cross targets in one shot). Passing an explicit
324/// `--target X` is the bug this guards against — it NARROWS the fetch to X's
325/// resolve graph, dropping host-target deps the offline rebuild still needs
326/// for host-side work (the man-page `before:` hook's `cargo run --bin anodize`
327/// host build, proc-macros, build scripts). On a cross shard (host x86_64 ≠
328/// target aarch64) that surfaces as `failed to download <crate>: --offline was
329/// specified` the instant the offline seal bites.
330fn build_fetch_command(manifest_dir: &Path, cargo_home: &Path) -> Command {
331 let mut cmd = Command::new("cargo");
332 cmd.arg("fetch");
333 // `--locked` ONLY when the project actually commits a `Cargo.lock` — the
334 // determinism contract for binary releases. cargo's `--locked` hard-errors
335 // when the lock is absent or stale, which would break two legitimate cases:
336 // library crates (Cargo.lock is conventionally gitignored) and the minimal
337 // test-fixture workspaces. When no lock is committed, plain `cargo fetch`
338 // resolves the manifest and writes a lock the offline rebuild then reuses;
339 // determinism still holds because every rebuild resolves the SAME manifest
340 // against the SAME warm, content-addressed registry cache.
341 if manifest_dir.join("Cargo.lock").is_file() {
342 cmd.arg("--locked");
343 }
344 cmd.arg("--manifest-path")
345 .arg(manifest_dir.join("Cargo.toml"));
346 cmd.current_dir(manifest_dir);
347 cmd.env("CARGO_HOME", cargo_home);
348 // The harness seals the *rebuilds* offline; the prefetch is the one
349 // op that must reach crates.io, so force it online even if the host
350 // exported CARGO_NET_OFFLINE=true (otherwise the warm-the-cache step
351 // would itself fail offline and defeat the whole prefetch).
352 cmd.env("CARGO_NET_OFFLINE", "false");
353 // cargo writes progress to stderr (inherited so the operator sees the
354 // one-time download); stdout carries nothing the harness consumes.
355 cmd.stdout(Stdio::null());
356 cmd
357}
358
359/// Retry-wrapped core of [`prefetch_deps`], parameterized on attempt count +
360/// backoff so tests can drive the retry path without real sleeps. One
361/// all-platform `cargo fetch`, retried, so a transient registry/DNS hiccup is
362/// survivable on the harness's single network operation.
363fn prefetch_deps_with(
364 manifest_dir: &Path,
365 cargo_home: &Path,
366 attempts: u32,
367 backoff: Duration,
368) -> Result<()> {
369 let attempts = attempts.max(1);
370 let mut last: Option<String> = None;
371 for attempt in 1..=attempts {
372 let mut cmd = build_fetch_command(manifest_dir, cargo_home);
373 match cmd.status() {
374 Ok(status) if status.success() => return Ok(()),
375 Ok(status) => last = Some(format!("cargo fetch exited {:?}", status.code())),
376 Err(e) => last = Some(format!("spawning cargo fetch: {e}")),
377 }
378 if attempt < attempts {
379 tracing::warn!(
380 attempt,
381 attempts,
382 "determinism prefetch `cargo fetch` failed; retrying"
383 );
384 std::thread::sleep(backoff);
385 }
386 }
387 anyhow::bail!(
388 "determinism prefetch `cargo fetch` failed after {attempts} attempt(s) in {}: {}",
389 manifest_dir.display(),
390 last.unwrap_or_default()
391 )
392}
393
394#[cfg(test)]
395mod tests {
396 use super::*;
397
398 #[test]
399 fn current_binary_resolves_to_a_real_file() {
400 // In test context, `current_exe` returns the test runner; the
401 // path is just expected to be readable.
402 let p = current_anodize_binary().unwrap();
403 assert!(p.exists(), "current_exe should point at a real file");
404 }
405
406 #[test]
407 fn run_build_pipeline_subprocess_fails_when_binary_missing() {
408 let env = HashMap::new();
409 let worktree = std::env::temp_dir();
410 let bogus = PathBuf::from("/nonexistent/anodize-binary-for-tests");
411 let res = run_build_pipeline_subprocess(&ChildInvocation {
412 anodize_binary: &bogus,
413 worktree_path: &worktree,
414 env: &env,
415 targets: None,
416 extra_skip: &[],
417 snapshot: true,
418 crate_name: None,
419 verbosity: crate::log::Verbosity::Normal,
420 });
421 assert!(
422 res.is_err(),
423 "missing binary should surface as an error, not a panic"
424 );
425 }
426
427 /// Argv shape sanity: no `--targets` flag when the harness passes
428 /// `None` (legacy single-runner path validates every configured
429 /// target).
430 #[test]
431 fn subprocess_command_omits_targets_when_none() {
432 let env = HashMap::new();
433 let cmd = build_subprocess_command(&ChildInvocation {
434 anodize_binary: &PathBuf::from("/usr/bin/anodize"),
435 worktree_path: &std::env::temp_dir(),
436 env: &env,
437 targets: None,
438 extra_skip: &[],
439 snapshot: true,
440 crate_name: None,
441 verbosity: crate::log::Verbosity::Normal,
442 });
443 let args: Vec<&str> = cmd.get_args().map(|s| s.to_str().expect("ascii")).collect();
444 assert!(
445 args.iter().all(|a| !a.starts_with("--targets")),
446 "expected no --targets argument; got {args:?}"
447 );
448 // Sanity: --snapshot + --skip=... still present.
449 assert!(
450 args.contains(&"--snapshot"),
451 "argv missing --snapshot: {args:?}"
452 );
453 assert!(
454 args.iter().any(|a| a.starts_with("--skip=")),
455 "argv missing --skip=...: {args:?}"
456 );
457 }
458
459 /// When the harness restricts targets, the child subprocess gets
460 /// the same restriction as a single `--targets=<csv>` argument.
461 /// Sharded release.yml depends on this — each OS shard must only
462 /// rebuild its own native targets.
463 #[test]
464 fn subprocess_command_propagates_targets_csv() {
465 let env = HashMap::new();
466 let triples = vec![
467 "x86_64-apple-darwin".to_string(),
468 "aarch64-apple-darwin".to_string(),
469 ];
470 let cmd = build_subprocess_command(&ChildInvocation {
471 anodize_binary: &PathBuf::from("/usr/bin/anodize"),
472 worktree_path: &std::env::temp_dir(),
473 env: &env,
474 targets: Some(&triples),
475 extra_skip: &[],
476 snapshot: true,
477 crate_name: None,
478 verbosity: crate::log::Verbosity::Normal,
479 });
480 let args: Vec<String> = cmd
481 .get_args()
482 .map(|s| s.to_str().expect("ascii").to_string())
483 .collect();
484 assert!(
485 args.iter()
486 .any(|a| a == "--targets=x86_64-apple-darwin,aarch64-apple-darwin"),
487 "expected joined --targets= argument; got {args:?}"
488 );
489 }
490
491 /// Empty-slice short-circuit: an explicit `Some(&[])` should NOT
492 /// produce `--targets=` (which would parse to "all-empty CSV" and
493 /// fail downstream). The harness is expected to pass `None` when it
494 /// has nothing to filter on; this guards against a future caller
495 /// passing an empty `Vec` by accident.
496 #[test]
497 fn subprocess_command_drops_targets_when_list_is_empty() {
498 let env = HashMap::new();
499 let empty: Vec<String> = Vec::new();
500 let cmd = build_subprocess_command(&ChildInvocation {
501 anodize_binary: &PathBuf::from("/usr/bin/anodize"),
502 worktree_path: &std::env::temp_dir(),
503 env: &env,
504 targets: Some(&empty),
505 extra_skip: &[],
506 snapshot: true,
507 crate_name: None,
508 verbosity: crate::log::Verbosity::Normal,
509 });
510 let args: Vec<String> = cmd
511 .get_args()
512 .map(|s| s.to_str().expect("ascii").to_string())
513 .collect();
514 assert!(
515 args.iter().all(|a| !a.starts_with("--targets")),
516 "empty target slice should omit --targets entirely; got {args:?}"
517 );
518 }
519
520 /// The child release subprocess MUST carry `--no-env-preflight` in every
521 /// mode — snapshot children skip the env preflight via the snapshot
522 /// gate anyway, but non-snapshot children (tag-push determinism runs,
523 /// where the workflow passes `--no-snapshot` so artifacts carry the
524 /// real version) would otherwise run the config-derived env preflight
525 /// inside the credential-less worktree and abort the replica build on
526 /// missing secrets/tools the run paths handle gracefully.
527 #[test]
528 fn subprocess_command_always_disables_env_preflight() {
529 let env = HashMap::new();
530 for snapshot in [true, false] {
531 let cmd = build_subprocess_command(&ChildInvocation {
532 anodize_binary: &PathBuf::from("/usr/bin/anodize"),
533 worktree_path: &std::env::temp_dir(),
534 env: &env,
535 targets: None,
536 extra_skip: &[],
537 snapshot,
538 crate_name: None,
539 verbosity: crate::log::Verbosity::Normal,
540 });
541 let args: Vec<&str> = cmd.get_args().map(|s| s.to_str().expect("ascii")).collect();
542 assert!(
543 args.contains(&"--no-env-preflight"),
544 "child argv (snapshot={snapshot}) must always carry --no-env-preflight; got {args:?}"
545 );
546 }
547 }
548
549 /// The child argv must not carry a flag `anodizer release` no longer
550 /// accepts. Automatic rollback is gone, and with it `--rollback` and
551 /// `--no-failure-policy`: a harness that still passed either would die
552 /// on an unknown-flag clap error before building anything, turning
553 /// every determinism check into a parse failure.
554 #[test]
555 fn subprocess_command_passes_no_removed_rollback_flags() {
556 let env = HashMap::new();
557 for snapshot in [true, false] {
558 let cmd = build_subprocess_command(&ChildInvocation {
559 anodize_binary: &PathBuf::from("/usr/bin/anodize"),
560 worktree_path: &std::env::temp_dir(),
561 env: &env,
562 targets: None,
563 extra_skip: &[],
564 snapshot,
565 crate_name: None,
566 verbosity: crate::log::Verbosity::Normal,
567 });
568 let args: Vec<&str> = cmd.get_args().map(|s| s.to_str().expect("ascii")).collect();
569 for removed in ["--rollback", "--rollback-only", "--no-failure-policy"] {
570 assert!(
571 !args.contains(&removed),
572 "child argv (snapshot={snapshot}) must not carry the removed \
573 {removed} flag; got {args:?}"
574 );
575 }
576 }
577 }
578
579 /// The child env must ALWAYS carry the log-depth var so the child's
580 /// interleaved stderr nests beneath the harness's `• run N of M`
581 /// bullet — and it must survive the hermetic `env_clear` +
582 /// re-population (it is set after the map is applied, so the map
583 /// cannot clobber it).
584 #[test]
585 fn subprocess_command_always_exports_log_depth() {
586 // A hermetic map that tries to clobber the var: the explicit
587 // post-map set must win.
588 let mut env = HashMap::new();
589 env.insert(crate::log::LOG_DEPTH_ENV.to_string(), "99".to_string());
590 for snapshot in [true, false] {
591 let cmd = build_subprocess_command(&ChildInvocation {
592 anodize_binary: &PathBuf::from("/usr/bin/anodize"),
593 worktree_path: &std::env::temp_dir(),
594 env: &env,
595 targets: None,
596 extra_skip: &[],
597 snapshot,
598 crate_name: None,
599 verbosity: crate::log::Verbosity::Normal,
600 });
601 let depth = cmd
602 .get_envs()
603 .find(|(k, _)| *k == std::ffi::OsStr::new(crate::log::LOG_DEPTH_ENV))
604 .and_then(|(_, v)| v)
605 .and_then(|v| v.to_str())
606 .map(str::to_string);
607 // Pin presence + the `+2` floor rather than an exact value:
608 // SECTION_DEPTH is process-global and sibling tests open
609 // sections concurrently, so the exact depth at build time is
610 // not stable under a parallel test runner.
611 let parsed: usize = depth
612 .as_deref()
613 .unwrap_or_else(|| {
614 panic!(
615 "child env (snapshot={snapshot}) must carry {}",
616 crate::log::LOG_DEPTH_ENV
617 )
618 })
619 .parse()
620 .expect("depth var must be numeric");
621 assert!(
622 parsed >= 2,
623 "depth must be parent depth + 2 (>= 2); got {parsed}"
624 );
625 }
626 }
627
628 /// `snapshot=false` MUST drop `--snapshot` from the argv so the
629 /// child release subprocess uses the real release version instead
630 /// of a `-SNAPSHOT-<sha>` suffix. The release workflow relies on
631 /// this for tag-push runs.
632 #[test]
633 fn subprocess_command_drops_snapshot_when_disabled() {
634 let env = HashMap::new();
635 let cmd = build_subprocess_command(&ChildInvocation {
636 anodize_binary: &PathBuf::from("/usr/bin/anodize"),
637 worktree_path: &std::env::temp_dir(),
638 env: &env,
639 targets: None,
640 extra_skip: &[],
641 snapshot: false,
642 crate_name: None,
643 verbosity: crate::log::Verbosity::Normal,
644 });
645 let args: Vec<&str> = cmd.get_args().map(|s| s.to_str().expect("ascii")).collect();
646 assert!(
647 !args.contains(&"--snapshot"),
648 "snapshot=false should drop --snapshot; got {args:?}"
649 );
650 assert!(
651 args.iter().any(|a| a.starts_with("--skip=")),
652 "argv still needs --skip=...: {args:?}"
653 );
654 assert_eq!(args[0], "release", "argv must lead with `release`");
655 }
656
657 /// A per-crate determinism shard MUST scope the child build to its
658 /// crate, else a workspace build defaults to the primary member and
659 /// the shard preserves the wrong crate's artifacts (a library's source
660 /// archive in place of a binary crate's binaries), starving publish-only
661 /// of the binaries docker and the binary publishers need.
662 #[test]
663 fn subprocess_command_scopes_to_crate_when_named() {
664 let env = HashMap::new();
665 let cmd = build_subprocess_command(&ChildInvocation {
666 anodize_binary: &PathBuf::from("/usr/bin/anodize"),
667 worktree_path: &std::env::temp_dir(),
668 env: &env,
669 targets: None,
670 extra_skip: &[],
671 snapshot: true,
672 crate_name: Some("cfgd"),
673 verbosity: crate::log::Verbosity::Normal,
674 });
675 let args: Vec<String> = cmd
676 .get_args()
677 .map(|s| s.to_str().expect("ascii").to_string())
678 .collect();
679 assert!(
680 args.iter().any(|a| a == "--crate=cfgd"),
681 "expected --crate=cfgd to scope the child build; got {args:?}"
682 );
683 }
684
685 /// `None` (a single-crate / non-workspace project) must NOT emit
686 /// `--crate`, so the default whole-project build is preserved.
687 #[test]
688 fn subprocess_command_omits_crate_when_none() {
689 let env = HashMap::new();
690 let cmd = build_subprocess_command(&ChildInvocation {
691 anodize_binary: &PathBuf::from("/usr/bin/anodize"),
692 worktree_path: &std::env::temp_dir(),
693 env: &env,
694 targets: None,
695 extra_skip: &[],
696 snapshot: true,
697 crate_name: None,
698 verbosity: crate::log::Verbosity::Normal,
699 });
700 let args: Vec<String> = cmd
701 .get_args()
702 .map(|s| s.to_str().expect("ascii").to_string())
703 .collect();
704 assert!(
705 args.iter().all(|a| !a.starts_with("--crate")),
706 "no crate named: --crate must be omitted; got {args:?}"
707 );
708 }
709
710 /// Operator verbosity must reach the child argv: each non-Normal
711 /// [`crate::log::Verbosity`] maps to exactly one flag, and Normal
712 /// maps to none (the child's own default). The child's stderr is
713 /// inherited into the parent stream, so a dropped flag would leave
714 /// `-q` runs loud and `--debug` runs mute inside the harness.
715 #[test]
716 fn subprocess_command_forwards_verbosity_flag() {
717 let env = HashMap::new();
718 let argv_for = |verbosity: crate::log::Verbosity| -> Vec<String> {
719 let cmd = build_subprocess_command(&ChildInvocation {
720 anodize_binary: &PathBuf::from("/usr/bin/anodize"),
721 worktree_path: &std::env::temp_dir(),
722 env: &env,
723 targets: None,
724 extra_skip: &[],
725 snapshot: true,
726 crate_name: None,
727 verbosity,
728 });
729 cmd.get_args()
730 .map(|s| s.to_str().expect("ascii").to_string())
731 .collect()
732 };
733 let verbosity_flags = ["--quiet", "--verbose", "--debug"];
734 for (verbosity, expected) in [
735 (crate::log::Verbosity::Quiet, Some("--quiet")),
736 (crate::log::Verbosity::Verbose, Some("--verbose")),
737 (crate::log::Verbosity::Debug, Some("--debug")),
738 (crate::log::Verbosity::Normal, None),
739 ] {
740 let args = argv_for(verbosity);
741 let present: Vec<&String> = args
742 .iter()
743 .filter(|a| verbosity_flags.contains(&a.as_str()))
744 .collect();
745 match expected {
746 Some(flag) => assert_eq!(
747 present,
748 vec![flag],
749 "{verbosity:?} must forward exactly {flag}; got {args:?}"
750 ),
751 None => assert!(
752 present.is_empty(),
753 "Normal must forward no verbosity flag; got {args:?}"
754 ),
755 }
756 }
757 }
758
759 /// The prefetch argv must carry `fetch --manifest-path <dir>/Cargo.toml`,
760 /// set `CARGO_HOME` to the shared cache, force `CARGO_NET_OFFLINE=false`
761 /// (the one online op), and — because a `Cargo.lock` is committed here —
762 /// the strict `--locked`.
763 #[test]
764 fn fetch_command_pins_locked_home_and_manifest() {
765 let dir = tempfile::tempdir().unwrap();
766 // A committed lock → `--locked` (the binary-release determinism path).
767 std::fs::write(dir.path().join("Cargo.lock"), "# lock").unwrap();
768 let home = PathBuf::from("/cache/cargo");
769 let cmd = build_fetch_command(dir.path(), &home);
770
771 let args: Vec<String> = cmd
772 .get_args()
773 .map(|s| s.to_str().expect("ascii").to_string())
774 .collect();
775 assert_eq!(
776 args[0], "fetch",
777 "argv must lead with `fetch`; got {args:?}"
778 );
779 assert!(
780 args.contains(&"--locked".to_string()),
781 "a committed Cargo.lock must yield --locked: {args:?}"
782 );
783 let mp = args
784 .iter()
785 .position(|a| a == "--manifest-path")
786 .expect("--manifest-path present");
787 assert_eq!(
788 args.get(mp + 1).map(String::as_str),
789 Some(dir.path().join("Cargo.toml").to_str().unwrap()),
790 "manifest path must point at the worktree's Cargo.toml; got {args:?}"
791 );
792
793 let env: HashMap<String, String> = cmd
794 .get_envs()
795 .filter_map(|(k, v)| Some((k.to_str()?.to_string(), v?.to_str()?.to_string())))
796 .collect();
797 assert_eq!(
798 env.get("CARGO_HOME").map(String::as_str),
799 Some("/cache/cargo")
800 );
801 assert_eq!(
802 env.get("CARGO_NET_OFFLINE").map(String::as_str),
803 Some("false"),
804 "prefetch must stay online; the rebuilds are what get sealed offline"
805 );
806 }
807
808 /// No `Cargo.lock` committed (library crate / minimal fixture) → omit
809 /// `--locked`, which would otherwise hard-error before cargo can resolve
810 /// + write a lock. Plain `cargo fetch` is correct there.
811 #[test]
812 fn fetch_command_omits_locked_without_committed_lock() {
813 let dir = tempfile::tempdir().unwrap();
814 // No Cargo.lock written.
815 let cmd = build_fetch_command(dir.path(), &PathBuf::from("/c"));
816 let args: Vec<String> = cmd
817 .get_args()
818 .map(|s| s.to_str().expect("ascii").to_string())
819 .collect();
820 assert!(
821 args.iter().all(|a| a != "--locked"),
822 "no committed lock → --locked must be omitted; got {args:?}"
823 );
824 }
825
826 /// Regression guard for the cross-shard offline failure: the prefetch must
827 /// NEVER pass `--target`. A plain `cargo fetch` downloads the all-platform
828 /// dependency superset (host + every cross target); `cargo fetch --target X`
829 /// NARROWS to X's `CompileKind` and drops the host-target deps the offline
830 /// rebuild still needs for host-side work (the man-page `before:` hook's
831 /// `cargo run --bin anodize` host build) — which then dies with `failed to
832 /// download <crate>: --offline was specified` on a cross shard.
833 #[test]
834 fn fetch_command_never_narrows_to_a_target() {
835 let dir = tempfile::tempdir().unwrap();
836 std::fs::write(dir.path().join("Cargo.lock"), "# lock").unwrap();
837 let cmd = build_fetch_command(dir.path(), &PathBuf::from("/c"));
838 let args: Vec<String> = cmd
839 .get_args()
840 .map(|s| s.to_str().expect("ascii").to_string())
841 .collect();
842 assert!(
843 args.iter().all(|a| a != "--target"),
844 "prefetch must fetch the all-platform superset (no --target); got {args:?}"
845 );
846 }
847
848 /// The prefetch retries then bails with a context-bearing error when
849 /// the fetch can't succeed — driven here with a bogus manifest dir so
850 /// every attempt fails fast (attempts=1, zero backoff keeps the test
851 /// instant).
852 #[test]
853 fn prefetch_fails_after_exhausting_attempts() {
854 let res = prefetch_deps_with(
855 &PathBuf::from("/nonexistent/anodize-prefetch-test-dir"),
856 &PathBuf::from("/nonexistent/cargo-home"),
857 1,
858 Duration::ZERO,
859 );
860 let err = res.expect_err("fetch against a missing manifest must error");
861 assert!(
862 err.to_string().contains("determinism prefetch"),
863 "error must identify the prefetch step; got {err:#}"
864 );
865 }
866
867 #[test]
868 fn side_effect_stages_covers_every_known_publish_side_effect() {
869 // Regression guard: if a future pipeline edit adds a side-effect
870 // stage and forgets to register it here, this test surfaces the
871 // omission. Add the new stage to SIDE_EFFECT_STAGES (and update
872 // this list) once the new entry is confirmed to belong in the
873 // skip set.
874 let expected = [
875 "release",
876 "docker",
877 "docker-sign",
878 "publish",
879 "blob",
880 "snapcraft-publish",
881 "announce",
882 "verify-release",
883 ];
884 for name in expected {
885 assert!(
886 SIDE_EFFECT_STAGES.contains(&name),
887 "SIDE_EFFECT_STAGES missing known publish-side stage `{name}`"
888 );
889 }
890 }
891
892 /// The harness's skip set must be a SUPERSET of the upstream-touching
893 /// classification `release --prepare` skips: everything that reaches
894 /// upstream is also a hermetic-rebuild side effect (the harness merely
895 /// adds host-state extras like notarize on top). Holds by construction
896 /// since the derivation, but pins the relation against a future rewrite
897 /// of either set.
898 #[test]
899 fn side_effect_stages_superset_of_upstream_stages() {
900 for &name in crate::stages::UPSTREAM_STAGES {
901 assert!(
902 SIDE_EFFECT_STAGES.contains(&name),
903 "SIDE_EFFECT_STAGES must contain every UPSTREAM_STAGES entry; missing `{name}`"
904 );
905 }
906 assert!(
907 SIDE_EFFECT_STAGES.contains(&"notarize"),
908 "notarize is a harness-only extra and must stay in the skip set"
909 );
910 }
911
912 #[test]
913 fn compute_skip_arg_starts_with_skip_flag() {
914 // I8 fix shape: harness still uses --skip=<list> (the conservative
915 // path; --only=<list> would require a new CLI flag). Guard against
916 // a future refactor accidentally flipping to a different prefix.
917 let arg = compute_skip_arg(&[]);
918 assert!(
919 arg.starts_with("--skip="),
920 "expected --skip= prefix, got `{arg}`"
921 );
922 // And the joined list is non-empty.
923 assert!(arg.len() > "--skip=".len(), "skip list must not be empty");
924 }
925
926 #[test]
927 fn compute_skip_arg_round_trips_through_comma_join() {
928 let arg = compute_skip_arg(&[]);
929 let list = arg
930 .trim_start_matches("--skip=")
931 .split(',')
932 .collect::<Vec<_>>();
933 assert_eq!(list.len(), SIDE_EFFECT_STAGES.len());
934 for (a, b) in list.iter().zip(SIDE_EFFECT_STAGES.iter()) {
935 assert_eq!(a, b);
936 }
937 }
938
939 /// `compute_skip_arg` MUST merge `SIDE_EFFECT_STAGES` with the
940 /// harness's complement set — otherwise the child release subprocess
941 /// runs produce-stages like `nfpm` / `nsis` / `dmg` on shards that
942 /// have no business running them, and the run dies with `No such
943 /// file or directory`. The harness fix in
944 /// `crates/cli/src/determinism_harness.rs` relies on this merge.
945 #[test]
946 fn compute_skip_arg_includes_side_effects_and_extra() {
947 let extra = ["nfpm".to_string(), "msi".to_string(), "dmg".to_string()];
948 let extra_refs: Vec<&str> = extra.iter().map(String::as_str).collect();
949 let arg = compute_skip_arg(&extra_refs);
950 let list: Vec<&str> = arg.trim_start_matches("--skip=").split(',').collect();
951 for &name in SIDE_EFFECT_STAGES.iter() {
952 assert!(
953 list.contains(&name),
954 "merged skip list missing side-effect stage `{name}`: {list:?}"
955 );
956 }
957 for name in ["nfpm", "msi", "dmg"] {
958 assert!(
959 list.contains(&name),
960 "merged skip list missing extra stage `{name}`: {list:?}"
961 );
962 }
963 }
964
965 /// Overlap is a real scenario — the harness's complement set is
966 /// computed against `VALID_RELEASE_SKIPS`, which contains the same
967 /// `release` / `publish` / `announce` names as `SIDE_EFFECT_STAGES`.
968 /// `compute_skip_arg` MUST de-dupe so the final argv isn't bloated
969 /// and CLI validation doesn't choke on a repeated token.
970 #[test]
971 fn compute_skip_arg_dedupes_overlap() {
972 // Pass a SIDE_EFFECT_STAGES member through `extra` and confirm it
973 // appears exactly once in the merged list.
974 let extra = ["release".to_string(), "nfpm".to_string()];
975 let extra_refs: Vec<&str> = extra.iter().map(String::as_str).collect();
976 let arg = compute_skip_arg(&extra_refs);
977 let list: Vec<&str> = arg.trim_start_matches("--skip=").split(',').collect();
978 let release_count = list.iter().filter(|&&s| s == "release").count();
979 assert_eq!(
980 release_count, 1,
981 "expected `release` exactly once in merged skip list, got {release_count} in {list:?}"
982 );
983 // And nfpm did come through.
984 assert!(
985 list.contains(&"nfpm"),
986 "merged list missing extra entry `nfpm`: {list:?}"
987 );
988 }
989
990 /// Every name the harness shovels into `--skip=...` MUST be accepted
991 /// by the release CLI's skip validator. Surfaced by the
992 /// drift-injection integration test when `docker-sign`
993 /// was present in [`SIDE_EFFECT_STAGES`] but missing from
994 /// [`crate::context::VALID_RELEASE_SKIPS`] — the harness's child
995 /// subprocess bombed with `invalid --skip value(s): docker-sign`. This
996 /// pure-cross-check unit test catches the drift in milliseconds so a
997 /// future addition to either list flags the gap immediately.
998 #[test]
999 fn side_effect_stages_are_all_valid_release_skip_values() {
1000 use crate::context::VALID_RELEASE_SKIPS;
1001 for &name in SIDE_EFFECT_STAGES.iter() {
1002 assert!(
1003 VALID_RELEASE_SKIPS.contains(&name),
1004 "SIDE_EFFECT_STAGES contains `{name}` but VALID_RELEASE_SKIPS does not — \
1005 the harness would fail at `anodize release --skip=<list>` invocation. \
1006 Add `{name}` to VALID_RELEASE_SKIPS in crates/core/src/context.rs."
1007 );
1008 }
1009 }
1010}