Skip to main content

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;
18
19/// Stage names the determinism harness must NOT run.
20///
21/// Single source of truth for the `--skip=...` list passed to the child
22/// `anodize release --snapshot` invocation. Every entry here is a stage
23/// in `crates/cli/src/pipeline/builders.rs::build_release_pipeline` that either:
24///
25/// - touches upstream (uploads, API calls, push, announce), OR
26/// - mutates host state outside `<worktree>/dist` (docker daemon, kms),
27///
28/// i.e. a "side-effect" stage that has no place in a hermetic regression
29/// rebuild. Adding a future side-effect stage to the release pipeline
30/// MUST add its stage name here too — otherwise the harness will fire it
31/// from inside the supposedly-hermetic build.
32///
33/// Order mirrors the position in `build_release_pipeline` so reviewers
34/// scanning both files can pattern-match. Listed exhaustively (no
35/// `starts_with` / glob matching) so a new stage with a similar name
36/// (e.g. `docker-extra`) doesn't accidentally inherit the skip.
37pub const SIDE_EFFECT_STAGES: &[&str] = &[
38    // Publish phase — upstream side effects.
39    "release",
40    "docker",
41    "docker-sign",
42    "publish",
43    "blob",
44    "snapcraft-publish",
45    "announce",
46    // Post-publish verification — issues live GitHub API calls and (when
47    // configured) docker spawns; never run inside a hermetic regression rebuild.
48    "verify-release",
49];
50
51/// Comma-join [`SIDE_EFFECT_STAGES`] plus an `extra` list for use as
52/// the `--skip=<list>` CLI argument value. Order-preserving and
53/// duplicate-free: every entry from [`SIDE_EFFECT_STAGES`] comes first
54/// (in declared order), then each `extra` entry that hasn't already been
55/// seen. Kept as a function (not a const) because Rust can't
56/// const-evaluate `[&str]::join`.
57///
58/// The `extra` argument is the harness's "complement set": every stage
59/// the operator did NOT request via `--stages=` AND that doesn't belong
60/// to the preamble preserve set (`validate` / `before` / `changelog` /
61/// `templatefiles`). Skipping them in the child release subprocess
62/// matches the spec's promise that `anodize check determinism
63/// --stages=<list>` only exercises (and validates) the named stages —
64/// previously the child still ran the full pipeline, attempting nfpm /
65/// nsis / dmg / etc. on shards that have no business running them.
66pub fn compute_skip_arg(extra: &[&str]) -> String {
67    let mut merged: Vec<&str> = Vec::with_capacity(SIDE_EFFECT_STAGES.len() + extra.len());
68    for &name in SIDE_EFFECT_STAGES {
69        if !merged.contains(&name) {
70            merged.push(name);
71        }
72    }
73    for &name in extra {
74        if !merged.contains(&name) {
75            merged.push(name);
76        }
77    }
78    format!("--skip={}", merged.join(","))
79}
80
81/// Invoke the running `anodize` binary against `worktree_path` with the
82/// supplied isolated env.
83///
84/// Pinning args:
85/// - `release` — drives the full build-side pipeline.
86/// - `--snapshot` (when `snapshot` is `true`) — disables tag-cutting and
87///   tells stages to use the pre-resolved SDE. The release workflow
88///   passes `false` on tag-push runs so produce-stages emit artifacts
89///   named with the actual release version (no `-SNAPSHOT-<sha>` suffix)
90///   that the publish-only path can ship directly.
91/// - `--skip=<SIDE_EFFECT_STAGES + extra_skip>` — strips every
92///   side-effect-producing stage AND every non-requested produce-stage
93///   (the harness's complement set). Doubling N is safe in any env
94///   because of this skip list.
95/// - `--no-preflight` — always. The replica runs in a deliberately
96///   credential-less env; see [`build_subprocess_command`].
97/// - `--targets=<csv>` (when `targets` is `Some`) — restricts the
98///   rebuild to a subset of configured triples. The sharded
99///   `release.yml` matrix passes this so each runner only validates
100///   the targets it can natively build (cross-compile to Apple /
101///   Windows from a Linux runner would otherwise fail at link time).
102///
103/// The `extra_skip` slice carries the harness's complement set: stages
104/// the operator did NOT name via `--stages=` (minus the preamble
105/// preserve set). Merged with [`SIDE_EFFECT_STAGES`] via
106/// [`compute_skip_arg`]; the harness in
107/// `crates/cli/src/determinism_harness.rs` is the canonical caller and
108/// computes the set from `anodizer_core::context::VALID_RELEASE_SKIPS`.
109/// Pass `&[]` to keep the legacy "side-effect stages only" behavior.
110///
111/// The child env is fully replaced (`env_clear` then re-populate) so
112/// host env vars cannot leak through and perturb the build. Caller
113/// (the harness) constructs the env map.
114pub fn run_build_pipeline_subprocess(spec: &ChildInvocation<'_>) -> Result<()> {
115    let mut cmd = build_subprocess_command(spec);
116    tracing::debug!(
117        args = ?cmd.get_args(),
118        worktree = %spec.worktree_path.display(),
119        "spawning anodize release child for determinism harness",
120    );
121    let status = cmd
122        .status()
123        .context("spawning anodize release for determinism harness")?;
124    anyhow::ensure!(
125        status.success(),
126        "harness build pipeline failed in worktree {} (exit {:?})",
127        spec.worktree_path.display(),
128        status.code()
129    );
130    Ok(())
131}
132
133/// Invocation knobs for the child `anodize release` subprocess, grouped
134/// so the spawn surface takes one spec instead of a positional argument
135/// list that grows with every new knob.
136pub struct ChildInvocation<'a> {
137    /// Path to the running `anodize` binary (see
138    /// [`current_anodize_binary`]).
139    pub anodize_binary: &'a Path,
140    /// Hermetic worktree the child builds in (`current_dir`).
141    pub worktree_path: &'a Path,
142    /// Fully-replacing child env map (`env_clear` + re-populate);
143    /// constructed by the harness.
144    pub env: &'a HashMap<String, String>,
145    /// `--targets=<csv>` restriction; `None` validates every configured
146    /// target.
147    pub targets: Option<&'a [String]>,
148    /// The harness's complement skip set, merged with
149    /// [`SIDE_EFFECT_STAGES`] via [`compute_skip_arg`]. Pass `&[]` for
150    /// the legacy "side-effect stages only" behavior.
151    pub extra_skip: &'a [String],
152    /// Whether the child gets `--snapshot`. The release workflow passes
153    /// `false` on tag-push runs so artifacts carry the real version.
154    pub snapshot: bool,
155    /// `--crate=<name>` scoping for per-crate shards; `None` builds the
156    /// workspace default.
157    pub crate_name: Option<&'a str>,
158    /// Operator verbosity, forwarded as `--quiet` / `--verbose` /
159    /// `--debug` so the child's inherited stderr honors the same
160    /// contract as the harness's own logger.
161    pub verbosity: crate::log::Verbosity,
162}
163
164/// Build the [`Command`] the harness will spawn. Split out from
165/// [`run_build_pipeline_subprocess`] so unit tests can inspect the
166/// constructed argv (`cmd.get_args()`) without shelling out — the
167/// alternative is to ship a real `anodize` binary into the test harness.
168fn build_subprocess_command(spec: &ChildInvocation<'_>) -> Command {
169    let ChildInvocation {
170        anodize_binary,
171        worktree_path,
172        env,
173        targets,
174        extra_skip,
175        snapshot,
176        crate_name,
177        verbosity,
178    } = *spec;
179    let mut cmd = Command::new(anodize_binary);
180    let extra_refs: Vec<&str> = extra_skip.iter().map(String::as_str).collect();
181    cmd.arg("release");
182    if snapshot {
183        cmd.arg("--snapshot");
184    }
185    // The child's stderr is inherited into the harness's own stream, so
186    // the operator's verbosity choice must extend to the child — a
187    // `check determinism -q` whose children still print every section
188    // would make the flag meaningless.
189    match verbosity {
190        crate::log::Verbosity::Quiet => {
191            cmd.arg("--quiet");
192        }
193        crate::log::Verbosity::Verbose => {
194            cmd.arg("--verbose");
195        }
196        crate::log::Verbosity::Debug => {
197            cmd.arg("--debug");
198        }
199        crate::log::Verbosity::Normal => {}
200    }
201    cmd.arg(compute_skip_arg(&extra_refs));
202    // The replica pipeline runs in a deliberately credential-less hermetic
203    // env (env_clear + identity-only re-population): its run paths skip
204    // gracefully when keys/tools are absent, nothing publishes (see the
205    // skip list above), and signature outputs are excluded from
206    // byte-comparison. The config-derived env preflight would therefore
207    // reject exactly the environment the harness is designed to run in —
208    // disable it for the child by construction. Real release entrypoints
209    // are unaffected; preflight guards them as before.
210    cmd.arg("--no-preflight");
211    if let Some(list) = targets
212        && !list.is_empty()
213    {
214        cmd.arg(format!("--targets={}", list.join(",")));
215    }
216    // Scope the child build to the same crate the harness is preserving for.
217    // Without it a workspace build defaults to its primary crate, so a
218    // per-crate shard would rebuild (and preserve) the wrong member's
219    // artifacts — e.g. a library's source archive in place of a binary
220    // crate's compiled binaries, leaving publish-only with no binary to
221    // ship or stage into a docker context.
222    if let Some(name) = crate_name {
223        cmd.arg(format!("--crate={name}"));
224    }
225    cmd.current_dir(worktree_path);
226    cmd.env_clear();
227    for (k, v) in env {
228        cmd.env(k, v);
229    }
230    // The child's stderr is inherited, so its lines interleave straight
231    // into the harness's stream. Export the parent's nesting depth (+2)
232    // so the child's section headers render beneath the harness's
233    // `• run N of M` bullet instead of flush-left: the bullet itself
234    // sits one section level plus a body indent under the harness
235    // header, and the child's sections belong one further level in.
236    // Set AFTER the hermetic env map so the map cannot clobber it.
237    cmd.env(
238        crate::log::LOG_DEPTH_ENV,
239        (crate::log::current_depth() + 2).to_string(),
240    );
241    cmd
242}
243
244/// Resolve the path of the currently-running `anodize` binary. Thin
245/// wrapper over [`std::env::current_exe`] kept here so the harness side
246/// doesn't have to touch `std::env` for binary resolution.
247pub fn current_anodize_binary() -> Result<PathBuf> {
248    std::env::current_exe().context("locating the currently-running anodize binary")
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    #[test]
256    fn current_binary_resolves_to_a_real_file() {
257        // In test context, `current_exe` returns the test runner; the
258        // path is just expected to be readable.
259        let p = current_anodize_binary().unwrap();
260        assert!(p.exists(), "current_exe should point at a real file");
261    }
262
263    #[test]
264    fn run_build_pipeline_subprocess_fails_when_binary_missing() {
265        let env = HashMap::new();
266        let worktree = std::env::temp_dir();
267        let bogus = PathBuf::from("/nonexistent/anodize-binary-for-tests");
268        let res = run_build_pipeline_subprocess(&ChildInvocation {
269            anodize_binary: &bogus,
270            worktree_path: &worktree,
271            env: &env,
272            targets: None,
273            extra_skip: &[],
274            snapshot: true,
275            crate_name: None,
276            verbosity: crate::log::Verbosity::Normal,
277        });
278        assert!(
279            res.is_err(),
280            "missing binary should surface as an error, not a panic"
281        );
282    }
283
284    /// Argv shape sanity: no `--targets` flag when the harness passes
285    /// `None` (legacy single-runner path validates every configured
286    /// target).
287    #[test]
288    fn subprocess_command_omits_targets_when_none() {
289        let env = HashMap::new();
290        let cmd = build_subprocess_command(&ChildInvocation {
291            anodize_binary: &PathBuf::from("/usr/bin/anodize"),
292            worktree_path: &std::env::temp_dir(),
293            env: &env,
294            targets: None,
295            extra_skip: &[],
296            snapshot: true,
297            crate_name: None,
298            verbosity: crate::log::Verbosity::Normal,
299        });
300        let args: Vec<&str> = cmd.get_args().map(|s| s.to_str().expect("ascii")).collect();
301        assert!(
302            args.iter().all(|a| !a.starts_with("--targets")),
303            "expected no --targets argument; got {args:?}"
304        );
305        // Sanity: --snapshot + --skip=... still present.
306        assert!(
307            args.contains(&"--snapshot"),
308            "argv missing --snapshot: {args:?}"
309        );
310        assert!(
311            args.iter().any(|a| a.starts_with("--skip=")),
312            "argv missing --skip=...: {args:?}"
313        );
314    }
315
316    /// When the harness restricts targets, the child subprocess gets
317    /// the same restriction as a single `--targets=<csv>` argument.
318    /// Sharded release.yml depends on this — each OS shard must only
319    /// rebuild its own native targets.
320    #[test]
321    fn subprocess_command_propagates_targets_csv() {
322        let env = HashMap::new();
323        let triples = vec![
324            "x86_64-apple-darwin".to_string(),
325            "aarch64-apple-darwin".to_string(),
326        ];
327        let cmd = build_subprocess_command(&ChildInvocation {
328            anodize_binary: &PathBuf::from("/usr/bin/anodize"),
329            worktree_path: &std::env::temp_dir(),
330            env: &env,
331            targets: Some(&triples),
332            extra_skip: &[],
333            snapshot: true,
334            crate_name: None,
335            verbosity: crate::log::Verbosity::Normal,
336        });
337        let args: Vec<String> = cmd
338            .get_args()
339            .map(|s| s.to_str().expect("ascii").to_string())
340            .collect();
341        assert!(
342            args.iter()
343                .any(|a| a == "--targets=x86_64-apple-darwin,aarch64-apple-darwin"),
344            "expected joined --targets= argument; got {args:?}"
345        );
346    }
347
348    /// Empty-slice short-circuit: an explicit `Some(&[])` should NOT
349    /// produce `--targets=` (which would parse to "all-empty CSV" and
350    /// fail downstream). The harness is expected to pass `None` when it
351    /// has nothing to filter on; this guards against a future caller
352    /// passing an empty `Vec` by accident.
353    #[test]
354    fn subprocess_command_drops_targets_when_list_is_empty() {
355        let env = HashMap::new();
356        let empty: Vec<String> = Vec::new();
357        let cmd = build_subprocess_command(&ChildInvocation {
358            anodize_binary: &PathBuf::from("/usr/bin/anodize"),
359            worktree_path: &std::env::temp_dir(),
360            env: &env,
361            targets: Some(&empty),
362            extra_skip: &[],
363            snapshot: true,
364            crate_name: None,
365            verbosity: crate::log::Verbosity::Normal,
366        });
367        let args: Vec<String> = cmd
368            .get_args()
369            .map(|s| s.to_str().expect("ascii").to_string())
370            .collect();
371        assert!(
372            args.iter().all(|a| !a.starts_with("--targets")),
373            "empty target slice should omit --targets entirely; got {args:?}"
374        );
375    }
376
377    /// The child release subprocess MUST carry `--no-preflight` in every
378    /// mode — snapshot children skip the env preflight via the snapshot
379    /// gate anyway, but non-snapshot children (tag-push determinism runs,
380    /// where the workflow passes `--no-snapshot` so artifacts carry the
381    /// real version) would otherwise run the config-derived env preflight
382    /// inside the credential-less worktree and abort the replica build on
383    /// missing secrets/tools the run paths handle gracefully.
384    #[test]
385    fn subprocess_command_always_disables_env_preflight() {
386        let env = HashMap::new();
387        for snapshot in [true, false] {
388            let cmd = build_subprocess_command(&ChildInvocation {
389                anodize_binary: &PathBuf::from("/usr/bin/anodize"),
390                worktree_path: &std::env::temp_dir(),
391                env: &env,
392                targets: None,
393                extra_skip: &[],
394                snapshot,
395                crate_name: None,
396                verbosity: crate::log::Verbosity::Normal,
397            });
398            let args: Vec<&str> = cmd.get_args().map(|s| s.to_str().expect("ascii")).collect();
399            assert!(
400                args.contains(&"--no-preflight"),
401                "child argv (snapshot={snapshot}) must always carry --no-preflight; got {args:?}"
402            );
403        }
404    }
405
406    /// The child env must ALWAYS carry the log-depth var so the child's
407    /// interleaved stderr nests beneath the harness's `• run N of M`
408    /// bullet — and it must survive the hermetic `env_clear` +
409    /// re-population (it is set after the map is applied, so the map
410    /// cannot clobber it).
411    #[test]
412    fn subprocess_command_always_exports_log_depth() {
413        // A hermetic map that tries to clobber the var: the explicit
414        // post-map set must win.
415        let mut env = HashMap::new();
416        env.insert(crate::log::LOG_DEPTH_ENV.to_string(), "99".to_string());
417        for snapshot in [true, false] {
418            let cmd = build_subprocess_command(&ChildInvocation {
419                anodize_binary: &PathBuf::from("/usr/bin/anodize"),
420                worktree_path: &std::env::temp_dir(),
421                env: &env,
422                targets: None,
423                extra_skip: &[],
424                snapshot,
425                crate_name: None,
426                verbosity: crate::log::Verbosity::Normal,
427            });
428            let depth = cmd
429                .get_envs()
430                .find(|(k, _)| *k == std::ffi::OsStr::new(crate::log::LOG_DEPTH_ENV))
431                .and_then(|(_, v)| v)
432                .and_then(|v| v.to_str())
433                .map(str::to_string);
434            // Pin presence + the `+2` floor rather than an exact value:
435            // SECTION_DEPTH is process-global and sibling tests open
436            // sections concurrently, so the exact depth at build time is
437            // not stable under a parallel test runner.
438            let parsed: usize = depth
439                .as_deref()
440                .unwrap_or_else(|| {
441                    panic!(
442                        "child env (snapshot={snapshot}) must carry {}",
443                        crate::log::LOG_DEPTH_ENV
444                    )
445                })
446                .parse()
447                .expect("depth var must be numeric");
448            assert!(
449                parsed >= 2,
450                "depth must be parent depth + 2 (>= 2); got {parsed}"
451            );
452        }
453    }
454
455    /// `snapshot=false` MUST drop `--snapshot` from the argv so the
456    /// child release subprocess uses the real release version instead
457    /// of a `-SNAPSHOT-<sha>` suffix. The release workflow relies on
458    /// this for tag-push runs.
459    #[test]
460    fn subprocess_command_drops_snapshot_when_disabled() {
461        let env = HashMap::new();
462        let cmd = build_subprocess_command(&ChildInvocation {
463            anodize_binary: &PathBuf::from("/usr/bin/anodize"),
464            worktree_path: &std::env::temp_dir(),
465            env: &env,
466            targets: None,
467            extra_skip: &[],
468            snapshot: false,
469            crate_name: None,
470            verbosity: crate::log::Verbosity::Normal,
471        });
472        let args: Vec<&str> = cmd.get_args().map(|s| s.to_str().expect("ascii")).collect();
473        assert!(
474            !args.contains(&"--snapshot"),
475            "snapshot=false should drop --snapshot; got {args:?}"
476        );
477        assert!(
478            args.iter().any(|a| a.starts_with("--skip=")),
479            "argv still needs --skip=...: {args:?}"
480        );
481        assert_eq!(args[0], "release", "argv must lead with `release`");
482    }
483
484    /// A per-crate determinism shard MUST scope the child build to its
485    /// crate, else a workspace build defaults to the primary member and
486    /// the shard preserves the wrong crate's artifacts (a library's source
487    /// archive in place of a binary crate's binaries), starving publish-only
488    /// of the binaries docker and the binary publishers need.
489    #[test]
490    fn subprocess_command_scopes_to_crate_when_named() {
491        let env = HashMap::new();
492        let cmd = build_subprocess_command(&ChildInvocation {
493            anodize_binary: &PathBuf::from("/usr/bin/anodize"),
494            worktree_path: &std::env::temp_dir(),
495            env: &env,
496            targets: None,
497            extra_skip: &[],
498            snapshot: true,
499            crate_name: Some("cfgd"),
500            verbosity: crate::log::Verbosity::Normal,
501        });
502        let args: Vec<String> = cmd
503            .get_args()
504            .map(|s| s.to_str().expect("ascii").to_string())
505            .collect();
506        assert!(
507            args.iter().any(|a| a == "--crate=cfgd"),
508            "expected --crate=cfgd to scope the child build; got {args:?}"
509        );
510    }
511
512    /// `None` (a single-crate / non-workspace project) must NOT emit
513    /// `--crate`, so the default whole-project build is preserved.
514    #[test]
515    fn subprocess_command_omits_crate_when_none() {
516        let env = HashMap::new();
517        let cmd = build_subprocess_command(&ChildInvocation {
518            anodize_binary: &PathBuf::from("/usr/bin/anodize"),
519            worktree_path: &std::env::temp_dir(),
520            env: &env,
521            targets: None,
522            extra_skip: &[],
523            snapshot: true,
524            crate_name: None,
525            verbosity: crate::log::Verbosity::Normal,
526        });
527        let args: Vec<String> = cmd
528            .get_args()
529            .map(|s| s.to_str().expect("ascii").to_string())
530            .collect();
531        assert!(
532            args.iter().all(|a| !a.starts_with("--crate")),
533            "no crate named: --crate must be omitted; got {args:?}"
534        );
535    }
536
537    /// Operator verbosity must reach the child argv: each non-Normal
538    /// [`crate::log::Verbosity`] maps to exactly one flag, and Normal
539    /// maps to none (the child's own default). The child's stderr is
540    /// inherited into the parent stream, so a dropped flag would leave
541    /// `-q` runs loud and `--debug` runs mute inside the harness.
542    #[test]
543    fn subprocess_command_forwards_verbosity_flag() {
544        let env = HashMap::new();
545        let argv_for = |verbosity: crate::log::Verbosity| -> Vec<String> {
546            let cmd = build_subprocess_command(&ChildInvocation {
547                anodize_binary: &PathBuf::from("/usr/bin/anodize"),
548                worktree_path: &std::env::temp_dir(),
549                env: &env,
550                targets: None,
551                extra_skip: &[],
552                snapshot: true,
553                crate_name: None,
554                verbosity,
555            });
556            cmd.get_args()
557                .map(|s| s.to_str().expect("ascii").to_string())
558                .collect()
559        };
560        let verbosity_flags = ["--quiet", "--verbose", "--debug"];
561        for (verbosity, expected) in [
562            (crate::log::Verbosity::Quiet, Some("--quiet")),
563            (crate::log::Verbosity::Verbose, Some("--verbose")),
564            (crate::log::Verbosity::Debug, Some("--debug")),
565            (crate::log::Verbosity::Normal, None),
566        ] {
567            let args = argv_for(verbosity);
568            let present: Vec<&String> = args
569                .iter()
570                .filter(|a| verbosity_flags.contains(&a.as_str()))
571                .collect();
572            match expected {
573                Some(flag) => assert_eq!(
574                    present,
575                    vec![flag],
576                    "{verbosity:?} must forward exactly {flag}; got {args:?}"
577                ),
578                None => assert!(
579                    present.is_empty(),
580                    "Normal must forward no verbosity flag; got {args:?}"
581                ),
582            }
583        }
584    }
585
586    #[test]
587    fn side_effect_stages_covers_every_known_publish_side_effect() {
588        // Regression guard: if a future pipeline edit adds a side-effect
589        // stage and forgets to register it here, this test surfaces the
590        // omission. Add the new stage to SIDE_EFFECT_STAGES (and update
591        // this list) once the new entry is confirmed to belong in the
592        // skip set.
593        let expected = [
594            "release",
595            "docker",
596            "docker-sign",
597            "publish",
598            "blob",
599            "snapcraft-publish",
600            "announce",
601            "verify-release",
602        ];
603        for name in expected {
604            assert!(
605                SIDE_EFFECT_STAGES.contains(&name),
606                "SIDE_EFFECT_STAGES missing known publish-side stage `{name}`"
607            );
608        }
609    }
610
611    #[test]
612    fn compute_skip_arg_starts_with_skip_flag() {
613        // I8 fix shape: harness still uses --skip=<list> (the conservative
614        // path; --only=<list> would require a new CLI flag). Guard against
615        // a future refactor accidentally flipping to a different prefix.
616        let arg = compute_skip_arg(&[]);
617        assert!(
618            arg.starts_with("--skip="),
619            "expected --skip= prefix, got `{arg}`"
620        );
621        // And the joined list is non-empty.
622        assert!(arg.len() > "--skip=".len(), "skip list must not be empty");
623    }
624
625    #[test]
626    fn compute_skip_arg_round_trips_through_comma_join() {
627        let arg = compute_skip_arg(&[]);
628        let list = arg
629            .trim_start_matches("--skip=")
630            .split(',')
631            .collect::<Vec<_>>();
632        assert_eq!(list.len(), SIDE_EFFECT_STAGES.len());
633        for (a, b) in list.iter().zip(SIDE_EFFECT_STAGES.iter()) {
634            assert_eq!(a, b);
635        }
636    }
637
638    /// `compute_skip_arg` MUST merge `SIDE_EFFECT_STAGES` with the
639    /// harness's complement set — otherwise the child release subprocess
640    /// runs produce-stages like `nfpm` / `nsis` / `dmg` on shards that
641    /// have no business running them, and the run dies with `No such
642    /// file or directory`. The harness fix in
643    /// `crates/cli/src/determinism_harness.rs` relies on this merge.
644    #[test]
645    fn compute_skip_arg_includes_side_effects_and_extra() {
646        let extra = ["nfpm".to_string(), "msi".to_string(), "dmg".to_string()];
647        let extra_refs: Vec<&str> = extra.iter().map(String::as_str).collect();
648        let arg = compute_skip_arg(&extra_refs);
649        let list: Vec<&str> = arg.trim_start_matches("--skip=").split(',').collect();
650        for &name in SIDE_EFFECT_STAGES {
651            assert!(
652                list.contains(&name),
653                "merged skip list missing side-effect stage `{name}`: {list:?}"
654            );
655        }
656        for name in ["nfpm", "msi", "dmg"] {
657            assert!(
658                list.contains(&name),
659                "merged skip list missing extra stage `{name}`: {list:?}"
660            );
661        }
662    }
663
664    /// Overlap is a real scenario — the harness's complement set is
665    /// computed against `VALID_RELEASE_SKIPS`, which contains the same
666    /// `release` / `publish` / `announce` names as `SIDE_EFFECT_STAGES`.
667    /// `compute_skip_arg` MUST de-dupe so the final argv isn't bloated
668    /// and CLI validation doesn't choke on a repeated token.
669    #[test]
670    fn compute_skip_arg_dedupes_overlap() {
671        // Pass a SIDE_EFFECT_STAGES member through `extra` and confirm it
672        // appears exactly once in the merged list.
673        let extra = ["release".to_string(), "nfpm".to_string()];
674        let extra_refs: Vec<&str> = extra.iter().map(String::as_str).collect();
675        let arg = compute_skip_arg(&extra_refs);
676        let list: Vec<&str> = arg.trim_start_matches("--skip=").split(',').collect();
677        let release_count = list.iter().filter(|&&s| s == "release").count();
678        assert_eq!(
679            release_count, 1,
680            "expected `release` exactly once in merged skip list, got {release_count} in {list:?}"
681        );
682        // And nfpm did come through.
683        assert!(
684            list.contains(&"nfpm"),
685            "merged list missing extra entry `nfpm`: {list:?}"
686        );
687    }
688
689    /// Every name the harness shovels into `--skip=...` MUST be accepted
690    /// by the release CLI's skip validator. Surfaced by the
691    /// drift-injection integration test when `docker-sign`
692    /// was present in [`SIDE_EFFECT_STAGES`] but missing from
693    /// [`crate::context::VALID_RELEASE_SKIPS`] — the harness's child
694    /// subprocess bombed with `invalid --skip value(s): docker-sign`. This
695    /// pure-cross-check unit test catches the drift in milliseconds so a
696    /// future addition to either list flags the gap immediately.
697    #[test]
698    fn side_effect_stages_are_all_valid_release_skip_values() {
699        use crate::context::VALID_RELEASE_SKIPS;
700        for &name in SIDE_EFFECT_STAGES {
701            assert!(
702                VALID_RELEASE_SKIPS.contains(&name),
703                "SIDE_EFFECT_STAGES contains `{name}` but VALID_RELEASE_SKIPS does not — \
704                 the harness would fail at `anodize release --skip=<list>` invocation. \
705                 Add `{name}` to VALID_RELEASE_SKIPS in crates/core/src/context.rs."
706            );
707        }
708    }
709}