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