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/// - `--targets=<csv>` (when `targets` is `Some`) — restricts the
96///   rebuild to a subset of configured triples. The sharded
97///   `release.yml` matrix passes this so each runner only validates
98///   the targets it can natively build (cross-compile to Apple /
99///   Windows from a Linux runner would otherwise fail at link time).
100///
101/// The `extra_skip` slice carries the harness's complement set: stages
102/// the operator did NOT name via `--stages=` (minus the preamble
103/// preserve set). Merged with [`SIDE_EFFECT_STAGES`] via
104/// [`compute_skip_arg`]; the harness in
105/// `crates/cli/src/determinism_harness.rs` is the canonical caller and
106/// computes the set from `anodizer_core::context::VALID_RELEASE_SKIPS`.
107/// Pass `&[]` to keep the legacy "side-effect stages only" behavior.
108///
109/// The child env is fully replaced (`env_clear` then re-populate) so
110/// host env vars cannot leak through and perturb the build. Caller
111/// (the harness) constructs the env map.
112pub fn run_build_pipeline_subprocess(
113    anodize_binary: &Path,
114    worktree_path: &Path,
115    env: &HashMap<String, String>,
116    targets: Option<&[String]>,
117    extra_skip: &[String],
118    snapshot: bool,
119    crate_name: Option<&str>,
120) -> Result<()> {
121    let mut cmd = build_subprocess_command(
122        anodize_binary,
123        worktree_path,
124        env,
125        targets,
126        extra_skip,
127        snapshot,
128        crate_name,
129    );
130    tracing::debug!(
131        args = ?cmd.get_args(),
132        worktree = %worktree_path.display(),
133        "spawning anodize release child for determinism harness",
134    );
135    let status = cmd
136        .status()
137        .context("spawning anodize release for determinism harness")?;
138    anyhow::ensure!(
139        status.success(),
140        "harness build pipeline failed in worktree {} (exit {:?})",
141        worktree_path.display(),
142        status.code()
143    );
144    Ok(())
145}
146
147/// Build the [`Command`] the harness will spawn. Split out from
148/// [`run_build_pipeline_subprocess`] so unit tests can inspect the
149/// constructed argv (`cmd.get_args()`) without shelling out — the
150/// alternative is to ship a real `anodize` binary into the test harness.
151fn build_subprocess_command(
152    anodize_binary: &Path,
153    worktree_path: &Path,
154    env: &HashMap<String, String>,
155    targets: Option<&[String]>,
156    extra_skip: &[String],
157    snapshot: bool,
158    crate_name: Option<&str>,
159) -> Command {
160    let mut cmd = Command::new(anodize_binary);
161    let extra_refs: Vec<&str> = extra_skip.iter().map(String::as_str).collect();
162    cmd.arg("release");
163    if snapshot {
164        cmd.arg("--snapshot");
165    }
166    cmd.arg(compute_skip_arg(&extra_refs));
167    if let Some(list) = targets
168        && !list.is_empty()
169    {
170        cmd.arg(format!("--targets={}", list.join(",")));
171    }
172    // Scope the child build to the same crate the harness is preserving for.
173    // Without it a workspace build defaults to its primary crate, so a
174    // per-crate shard would rebuild (and preserve) the wrong member's
175    // artifacts — e.g. a library's source archive in place of a binary
176    // crate's compiled binaries, leaving publish-only with no binary to
177    // ship or stage into a docker context.
178    if let Some(name) = crate_name {
179        cmd.arg(format!("--crate={name}"));
180    }
181    cmd.current_dir(worktree_path);
182    cmd.env_clear();
183    for (k, v) in env {
184        cmd.env(k, v);
185    }
186    cmd
187}
188
189/// Resolve the path of the currently-running `anodize` binary. Thin
190/// wrapper over [`std::env::current_exe`] kept here so the harness side
191/// doesn't have to touch `std::env` for binary resolution.
192pub fn current_anodize_binary() -> Result<PathBuf> {
193    std::env::current_exe().context("locating the currently-running anodize binary")
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    #[test]
201    fn current_binary_resolves_to_a_real_file() {
202        // In test context, `current_exe` returns the test runner; the
203        // path is just expected to be readable.
204        let p = current_anodize_binary().unwrap();
205        assert!(p.exists(), "current_exe should point at a real file");
206    }
207
208    #[test]
209    fn run_build_pipeline_subprocess_fails_when_binary_missing() {
210        let env = HashMap::new();
211        let worktree = std::env::temp_dir();
212        let bogus = PathBuf::from("/nonexistent/anodize-binary-for-tests");
213        let res = run_build_pipeline_subprocess(&bogus, &worktree, &env, None, &[], true, None);
214        assert!(
215            res.is_err(),
216            "missing binary should surface as an error, not a panic"
217        );
218    }
219
220    /// Argv shape sanity: no `--targets` flag when the harness passes
221    /// `None` (legacy single-runner path validates every configured
222    /// target).
223    #[test]
224    fn subprocess_command_omits_targets_when_none() {
225        let env = HashMap::new();
226        let cmd = build_subprocess_command(
227            &PathBuf::from("/usr/bin/anodize"),
228            &std::env::temp_dir(),
229            &env,
230            None,
231            &[],
232            true,
233            None,
234        );
235        let args: Vec<&str> = cmd.get_args().map(|s| s.to_str().expect("ascii")).collect();
236        assert!(
237            args.iter().all(|a| !a.starts_with("--targets")),
238            "expected no --targets argument; got {args:?}"
239        );
240        // Sanity: --snapshot + --skip=... still present.
241        assert!(
242            args.contains(&"--snapshot"),
243            "argv missing --snapshot: {args:?}"
244        );
245        assert!(
246            args.iter().any(|a| a.starts_with("--skip=")),
247            "argv missing --skip=...: {args:?}"
248        );
249    }
250
251    /// When the harness restricts targets, the child subprocess gets
252    /// the same restriction as a single `--targets=<csv>` argument.
253    /// Sharded release.yml depends on this — each OS shard must only
254    /// rebuild its own native targets.
255    #[test]
256    fn subprocess_command_propagates_targets_csv() {
257        let env = HashMap::new();
258        let triples = vec![
259            "x86_64-apple-darwin".to_string(),
260            "aarch64-apple-darwin".to_string(),
261        ];
262        let cmd = build_subprocess_command(
263            &PathBuf::from("/usr/bin/anodize"),
264            &std::env::temp_dir(),
265            &env,
266            Some(&triples),
267            &[],
268            true,
269            None,
270        );
271        let args: Vec<String> = cmd
272            .get_args()
273            .map(|s| s.to_str().expect("ascii").to_string())
274            .collect();
275        assert!(
276            args.iter()
277                .any(|a| a == "--targets=x86_64-apple-darwin,aarch64-apple-darwin"),
278            "expected joined --targets= argument; got {args:?}"
279        );
280    }
281
282    /// Empty-slice short-circuit: an explicit `Some(&[])` should NOT
283    /// produce `--targets=` (which would parse to "all-empty CSV" and
284    /// fail downstream). The harness is expected to pass `None` when it
285    /// has nothing to filter on; this guards against a future caller
286    /// passing an empty `Vec` by accident.
287    #[test]
288    fn subprocess_command_drops_targets_when_list_is_empty() {
289        let env = HashMap::new();
290        let empty: Vec<String> = Vec::new();
291        let cmd = build_subprocess_command(
292            &PathBuf::from("/usr/bin/anodize"),
293            &std::env::temp_dir(),
294            &env,
295            Some(&empty),
296            &[],
297            true,
298            None,
299        );
300        let args: Vec<String> = cmd
301            .get_args()
302            .map(|s| s.to_str().expect("ascii").to_string())
303            .collect();
304        assert!(
305            args.iter().all(|a| !a.starts_with("--targets")),
306            "empty target slice should omit --targets entirely; got {args:?}"
307        );
308    }
309
310    /// `snapshot=false` MUST drop `--snapshot` from the argv so the
311    /// child release subprocess uses the real release version instead
312    /// of a `-SNAPSHOT-<sha>` suffix. The release workflow relies on
313    /// this for tag-push runs.
314    #[test]
315    fn subprocess_command_drops_snapshot_when_disabled() {
316        let env = HashMap::new();
317        let cmd = build_subprocess_command(
318            &PathBuf::from("/usr/bin/anodize"),
319            &std::env::temp_dir(),
320            &env,
321            None,
322            &[],
323            false,
324            None,
325        );
326        let args: Vec<&str> = cmd.get_args().map(|s| s.to_str().expect("ascii")).collect();
327        assert!(
328            !args.contains(&"--snapshot"),
329            "snapshot=false should drop --snapshot; got {args:?}"
330        );
331        assert!(
332            args.iter().any(|a| a.starts_with("--skip=")),
333            "argv still needs --skip=...: {args:?}"
334        );
335        assert_eq!(args[0], "release", "argv must lead with `release`");
336    }
337
338    /// A per-crate determinism shard MUST scope the child build to its
339    /// crate, else a workspace build defaults to the primary member and
340    /// the shard preserves the wrong crate's artifacts (a library's source
341    /// archive in place of a binary crate's binaries), starving publish-only
342    /// of the binaries docker and the binary publishers need.
343    #[test]
344    fn subprocess_command_scopes_to_crate_when_named() {
345        let env = HashMap::new();
346        let cmd = build_subprocess_command(
347            &PathBuf::from("/usr/bin/anodize"),
348            &std::env::temp_dir(),
349            &env,
350            None,
351            &[],
352            true,
353            Some("cfgd"),
354        );
355        let args: Vec<String> = cmd
356            .get_args()
357            .map(|s| s.to_str().expect("ascii").to_string())
358            .collect();
359        assert!(
360            args.iter().any(|a| a == "--crate=cfgd"),
361            "expected --crate=cfgd to scope the child build; got {args:?}"
362        );
363    }
364
365    /// `None` (a single-crate / non-workspace project) must NOT emit
366    /// `--crate`, so the default whole-project build is preserved.
367    #[test]
368    fn subprocess_command_omits_crate_when_none() {
369        let env = HashMap::new();
370        let cmd = build_subprocess_command(
371            &PathBuf::from("/usr/bin/anodize"),
372            &std::env::temp_dir(),
373            &env,
374            None,
375            &[],
376            true,
377            None,
378        );
379        let args: Vec<String> = cmd
380            .get_args()
381            .map(|s| s.to_str().expect("ascii").to_string())
382            .collect();
383        assert!(
384            args.iter().all(|a| !a.starts_with("--crate")),
385            "no crate named: --crate must be omitted; got {args:?}"
386        );
387    }
388
389    #[test]
390    fn side_effect_stages_covers_every_known_publish_side_effect() {
391        // Regression guard: if a future pipeline edit adds a side-effect
392        // stage and forgets to register it here, this test surfaces the
393        // omission. Add the new stage to SIDE_EFFECT_STAGES (and update
394        // this list) once the new entry is confirmed to belong in the
395        // skip set.
396        let expected = [
397            "release",
398            "docker",
399            "docker-sign",
400            "publish",
401            "blob",
402            "snapcraft-publish",
403            "announce",
404            "verify-release",
405        ];
406        for name in expected {
407            assert!(
408                SIDE_EFFECT_STAGES.contains(&name),
409                "SIDE_EFFECT_STAGES missing known publish-side stage `{name}`"
410            );
411        }
412    }
413
414    #[test]
415    fn compute_skip_arg_starts_with_skip_flag() {
416        // I8 fix shape: harness still uses --skip=<list> (the conservative
417        // path; --only=<list> would require a new CLI flag). Guard against
418        // a future refactor accidentally flipping to a different prefix.
419        let arg = compute_skip_arg(&[]);
420        assert!(
421            arg.starts_with("--skip="),
422            "expected --skip= prefix, got `{arg}`"
423        );
424        // And the joined list is non-empty.
425        assert!(arg.len() > "--skip=".len(), "skip list must not be empty");
426    }
427
428    #[test]
429    fn compute_skip_arg_round_trips_through_comma_join() {
430        let arg = compute_skip_arg(&[]);
431        let list = arg
432            .trim_start_matches("--skip=")
433            .split(',')
434            .collect::<Vec<_>>();
435        assert_eq!(list.len(), SIDE_EFFECT_STAGES.len());
436        for (a, b) in list.iter().zip(SIDE_EFFECT_STAGES.iter()) {
437            assert_eq!(a, b);
438        }
439    }
440
441    /// `compute_skip_arg` MUST merge `SIDE_EFFECT_STAGES` with the
442    /// harness's complement set — otherwise the child release subprocess
443    /// runs produce-stages like `nfpm` / `nsis` / `dmg` on shards that
444    /// have no business running them, and the run dies with `No such
445    /// file or directory`. The harness fix in
446    /// `crates/cli/src/determinism_harness.rs` relies on this merge.
447    #[test]
448    fn compute_skip_arg_includes_side_effects_and_extra() {
449        let extra = ["nfpm".to_string(), "msi".to_string(), "dmg".to_string()];
450        let extra_refs: Vec<&str> = extra.iter().map(String::as_str).collect();
451        let arg = compute_skip_arg(&extra_refs);
452        let list: Vec<&str> = arg.trim_start_matches("--skip=").split(',').collect();
453        for &name in SIDE_EFFECT_STAGES {
454            assert!(
455                list.contains(&name),
456                "merged skip list missing side-effect stage `{name}`: {list:?}"
457            );
458        }
459        for name in ["nfpm", "msi", "dmg"] {
460            assert!(
461                list.contains(&name),
462                "merged skip list missing extra stage `{name}`: {list:?}"
463            );
464        }
465    }
466
467    /// Overlap is a real scenario — the harness's complement set is
468    /// computed against `VALID_RELEASE_SKIPS`, which contains the same
469    /// `release` / `publish` / `announce` names as `SIDE_EFFECT_STAGES`.
470    /// `compute_skip_arg` MUST de-dupe so the final argv isn't bloated
471    /// and CLI validation doesn't choke on a repeated token.
472    #[test]
473    fn compute_skip_arg_dedupes_overlap() {
474        // Pass a SIDE_EFFECT_STAGES member through `extra` and confirm it
475        // appears exactly once in the merged list.
476        let extra = ["release".to_string(), "nfpm".to_string()];
477        let extra_refs: Vec<&str> = extra.iter().map(String::as_str).collect();
478        let arg = compute_skip_arg(&extra_refs);
479        let list: Vec<&str> = arg.trim_start_matches("--skip=").split(',').collect();
480        let release_count = list.iter().filter(|&&s| s == "release").count();
481        assert_eq!(
482            release_count, 1,
483            "expected `release` exactly once in merged skip list, got {release_count} in {list:?}"
484        );
485        // And nfpm did come through.
486        assert!(
487            list.contains(&"nfpm"),
488            "merged list missing extra entry `nfpm`: {list:?}"
489        );
490    }
491
492    /// Every name the harness shovels into `--skip=...` MUST be accepted
493    /// by the release CLI's skip validator. Surfaced by the
494    /// drift-injection integration test when `docker-sign`
495    /// was present in [`SIDE_EFFECT_STAGES`] but missing from
496    /// [`crate::context::VALID_RELEASE_SKIPS`] — the harness's child
497    /// subprocess bombed with `invalid --skip value(s): docker-sign`. This
498    /// pure-cross-check unit test catches the drift in milliseconds so a
499    /// future addition to either list flags the gap immediately.
500    #[test]
501    fn side_effect_stages_are_all_valid_release_skip_values() {
502        use crate::context::VALID_RELEASE_SKIPS;
503        for &name in SIDE_EFFECT_STAGES {
504            assert!(
505                VALID_RELEASE_SKIPS.contains(&name),
506                "SIDE_EFFECT_STAGES contains `{name}` but VALID_RELEASE_SKIPS does not — \
507                 the harness would fail at `anodize release --skip=<list>` invocation. \
508                 Add `{name}` to VALID_RELEASE_SKIPS in crates/core/src/context.rs."
509            );
510        }
511    }
512}