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