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