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}