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.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];
47
48/// Comma-join [`SIDE_EFFECT_STAGES`] plus an `extra` list for use as
49/// the `--skip=<list>` CLI argument value. Order-preserving and
50/// duplicate-free: every entry from [`SIDE_EFFECT_STAGES`] comes first
51/// (in declared order), then each `extra` entry that hasn't already been
52/// seen. Kept as a function (not a const) because Rust can't
53/// const-evaluate `[&str]::join`.
54///
55/// The `extra` argument is the harness's "complement set": every stage
56/// the operator did NOT request via `--stages=` AND that doesn't belong
57/// to the preamble preserve set (`validate` / `before` / `changelog` /
58/// `templatefiles`). Skipping them in the child release subprocess
59/// matches the spec's promise that `anodize check determinism
60/// --stages=<list>` only exercises (and validates) the named stages —
61/// previously the child still ran the full pipeline, attempting nfpm /
62/// nsis / dmg / etc. on shards that have no business running them.
63pub fn compute_skip_arg(extra: &[&str]) -> String {
64 let mut merged: Vec<&str> = Vec::with_capacity(SIDE_EFFECT_STAGES.len() + extra.len());
65 for &name in SIDE_EFFECT_STAGES {
66 if !merged.contains(&name) {
67 merged.push(name);
68 }
69 }
70 for &name in extra {
71 if !merged.contains(&name) {
72 merged.push(name);
73 }
74 }
75 format!("--skip={}", merged.join(","))
76}
77
78/// Invoke the running `anodize` binary against `worktree_path` with the
79/// supplied isolated env.
80///
81/// Pinning args:
82/// - `release` — drives the full build-side pipeline.
83/// - `--snapshot` (when `snapshot` is `true`) — disables tag-cutting and
84/// tells stages to use the pre-resolved SDE. The release workflow
85/// passes `false` on tag-push runs so produce-stages emit artifacts
86/// named with the actual release version (no `-SNAPSHOT-<sha>` suffix)
87/// that the publish-only path can ship directly.
88/// - `--skip=<SIDE_EFFECT_STAGES + extra_skip>` — strips every
89/// side-effect-producing stage AND every non-requested produce-stage
90/// (the harness's complement set). Doubling N is safe in any env
91/// because of this skip list.
92/// - `--targets=<csv>` (when `targets` is `Some`) — restricts the
93/// rebuild to a subset of configured triples. The sharded
94/// `release.yml` matrix passes this so each runner only validates
95/// the targets it can natively build (cross-compile to Apple /
96/// Windows from a Linux runner would otherwise fail at link time).
97///
98/// The `extra_skip` slice carries the harness's complement set: stages
99/// the operator did NOT name via `--stages=` (minus the preamble
100/// preserve set). Merged with [`SIDE_EFFECT_STAGES`] via
101/// [`compute_skip_arg`]; the harness in
102/// `crates/cli/src/determinism_harness.rs` is the canonical caller and
103/// computes the set from `anodizer_core::context::VALID_RELEASE_SKIPS`.
104/// Pass `&[]` to keep the legacy "side-effect stages only" behavior.
105///
106/// The child env is fully replaced (`env_clear` then re-populate) so
107/// host env vars cannot leak through and perturb the build. Caller
108/// (the harness) constructs the env map.
109pub fn run_build_pipeline_subprocess(
110 anodize_binary: &Path,
111 worktree_path: &Path,
112 env: &HashMap<String, String>,
113 targets: Option<&[String]>,
114 extra_skip: &[String],
115 snapshot: bool,
116) -> Result<()> {
117 let mut cmd = build_subprocess_command(
118 anodize_binary,
119 worktree_path,
120 env,
121 targets,
122 extra_skip,
123 snapshot,
124 );
125 tracing::debug!(
126 args = ?cmd.get_args(),
127 worktree = %worktree_path.display(),
128 "spawning anodize release child for determinism harness",
129 );
130 let status = cmd
131 .status()
132 .context("spawning anodize release for determinism harness")?;
133 anyhow::ensure!(
134 status.success(),
135 "harness build pipeline failed in worktree {} (exit {:?})",
136 worktree_path.display(),
137 status.code()
138 );
139 Ok(())
140}
141
142/// Build the [`Command`] the harness will spawn. Split out from
143/// [`run_build_pipeline_subprocess`] so unit tests can inspect the
144/// constructed argv (`cmd.get_args()`) without shelling out — the
145/// alternative is to ship a real `anodize` binary into the test harness.
146fn build_subprocess_command(
147 anodize_binary: &Path,
148 worktree_path: &Path,
149 env: &HashMap<String, String>,
150 targets: Option<&[String]>,
151 extra_skip: &[String],
152 snapshot: bool,
153) -> Command {
154 let mut cmd = Command::new(anodize_binary);
155 let extra_refs: Vec<&str> = extra_skip.iter().map(String::as_str).collect();
156 cmd.arg("release");
157 if snapshot {
158 cmd.arg("--snapshot");
159 }
160 cmd.arg(compute_skip_arg(&extra_refs));
161 if let Some(list) = targets
162 && !list.is_empty()
163 {
164 cmd.arg(format!("--targets={}", list.join(",")));
165 }
166 cmd.current_dir(worktree_path);
167 cmd.env_clear();
168 for (k, v) in env {
169 cmd.env(k, v);
170 }
171 cmd
172}
173
174/// Resolve the path of the currently-running `anodize` binary. Thin
175/// wrapper over [`std::env::current_exe`] kept here so the harness side
176/// doesn't have to touch `std::env` for binary resolution.
177pub fn current_anodize_binary() -> Result<PathBuf> {
178 std::env::current_exe().context("locating the currently-running anodize binary")
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184
185 #[test]
186 fn current_binary_resolves_to_a_real_file() {
187 // In test context, `current_exe` returns the test runner; the
188 // path is just expected to be readable.
189 let p = current_anodize_binary().unwrap();
190 assert!(p.exists(), "current_exe should point at a real file");
191 }
192
193 #[test]
194 fn run_build_pipeline_subprocess_fails_when_binary_missing() {
195 let env = HashMap::new();
196 let worktree = std::env::temp_dir();
197 let bogus = PathBuf::from("/nonexistent/anodize-binary-for-tests");
198 let res = run_build_pipeline_subprocess(&bogus, &worktree, &env, None, &[], true);
199 assert!(
200 res.is_err(),
201 "missing binary should surface as an error, not a panic"
202 );
203 }
204
205 /// Argv shape sanity: no `--targets` flag when the harness passes
206 /// `None` (legacy single-runner path validates every configured
207 /// target).
208 #[test]
209 fn subprocess_command_omits_targets_when_none() {
210 let env = HashMap::new();
211 let cmd = build_subprocess_command(
212 &PathBuf::from("/usr/bin/anodize"),
213 &std::env::temp_dir(),
214 &env,
215 None,
216 &[],
217 true,
218 );
219 let args: Vec<&str> = cmd.get_args().map(|s| s.to_str().expect("ascii")).collect();
220 assert!(
221 args.iter().all(|a| !a.starts_with("--targets")),
222 "expected no --targets argument; got {args:?}"
223 );
224 // Sanity: --snapshot + --skip=... still present.
225 assert!(
226 args.contains(&"--snapshot"),
227 "argv missing --snapshot: {args:?}"
228 );
229 assert!(
230 args.iter().any(|a| a.starts_with("--skip=")),
231 "argv missing --skip=...: {args:?}"
232 );
233 }
234
235 /// When the harness restricts targets, the child subprocess gets
236 /// the same restriction as a single `--targets=<csv>` argument.
237 /// Sharded release.yml depends on this — each OS shard must only
238 /// rebuild its own native targets.
239 #[test]
240 fn subprocess_command_propagates_targets_csv() {
241 let env = HashMap::new();
242 let triples = vec![
243 "x86_64-apple-darwin".to_string(),
244 "aarch64-apple-darwin".to_string(),
245 ];
246 let cmd = build_subprocess_command(
247 &PathBuf::from("/usr/bin/anodize"),
248 &std::env::temp_dir(),
249 &env,
250 Some(&triples),
251 &[],
252 true,
253 );
254 let args: Vec<String> = cmd
255 .get_args()
256 .map(|s| s.to_str().expect("ascii").to_string())
257 .collect();
258 assert!(
259 args.iter()
260 .any(|a| a == "--targets=x86_64-apple-darwin,aarch64-apple-darwin"),
261 "expected joined --targets= argument; got {args:?}"
262 );
263 }
264
265 /// Empty-slice short-circuit: an explicit `Some(&[])` should NOT
266 /// produce `--targets=` (which would parse to "all-empty CSV" and
267 /// fail downstream). The harness is expected to pass `None` when it
268 /// has nothing to filter on; this guards against a future caller
269 /// passing an empty `Vec` by accident.
270 #[test]
271 fn subprocess_command_drops_targets_when_list_is_empty() {
272 let env = HashMap::new();
273 let empty: Vec<String> = Vec::new();
274 let cmd = build_subprocess_command(
275 &PathBuf::from("/usr/bin/anodize"),
276 &std::env::temp_dir(),
277 &env,
278 Some(&empty),
279 &[],
280 true,
281 );
282 let args: Vec<String> = cmd
283 .get_args()
284 .map(|s| s.to_str().expect("ascii").to_string())
285 .collect();
286 assert!(
287 args.iter().all(|a| !a.starts_with("--targets")),
288 "empty target slice should omit --targets entirely; got {args:?}"
289 );
290 }
291
292 /// `snapshot=false` MUST drop `--snapshot` from the argv so the
293 /// child release subprocess uses the real release version instead
294 /// of a `-SNAPSHOT-<sha>` suffix. The release workflow relies on
295 /// this for tag-push runs.
296 #[test]
297 fn subprocess_command_drops_snapshot_when_disabled() {
298 let env = HashMap::new();
299 let cmd = build_subprocess_command(
300 &PathBuf::from("/usr/bin/anodize"),
301 &std::env::temp_dir(),
302 &env,
303 None,
304 &[],
305 false,
306 );
307 let args: Vec<&str> = cmd.get_args().map(|s| s.to_str().expect("ascii")).collect();
308 assert!(
309 !args.contains(&"--snapshot"),
310 "snapshot=false should drop --snapshot; got {args:?}"
311 );
312 assert!(
313 args.iter().any(|a| a.starts_with("--skip=")),
314 "argv still needs --skip=...: {args:?}"
315 );
316 assert_eq!(args[0], "release", "argv must lead with `release`");
317 }
318
319 #[test]
320 fn side_effect_stages_covers_every_known_publish_side_effect() {
321 // Regression guard: if a future pipeline edit adds a side-effect
322 // stage and forgets to register it here, this test surfaces the
323 // omission. Add the new stage to SIDE_EFFECT_STAGES (and update
324 // this list) once the new entry is confirmed to belong in the
325 // skip set.
326 let expected = [
327 "release",
328 "docker",
329 "docker-sign",
330 "publish",
331 "blob",
332 "snapcraft-publish",
333 "announce",
334 ];
335 for name in expected {
336 assert!(
337 SIDE_EFFECT_STAGES.contains(&name),
338 "SIDE_EFFECT_STAGES missing known publish-side stage `{name}`"
339 );
340 }
341 }
342
343 #[test]
344 fn compute_skip_arg_starts_with_skip_flag() {
345 // I8 fix shape: harness still uses --skip=<list> (the conservative
346 // path; --only=<list> would require a new CLI flag). Guard against
347 // a future refactor accidentally flipping to a different prefix.
348 let arg = compute_skip_arg(&[]);
349 assert!(
350 arg.starts_with("--skip="),
351 "expected --skip= prefix, got `{arg}`"
352 );
353 // And the joined list is non-empty.
354 assert!(arg.len() > "--skip=".len(), "skip list must not be empty");
355 }
356
357 #[test]
358 fn compute_skip_arg_round_trips_through_comma_join() {
359 let arg = compute_skip_arg(&[]);
360 let list = arg
361 .trim_start_matches("--skip=")
362 .split(',')
363 .collect::<Vec<_>>();
364 assert_eq!(list.len(), SIDE_EFFECT_STAGES.len());
365 for (a, b) in list.iter().zip(SIDE_EFFECT_STAGES.iter()) {
366 assert_eq!(a, b);
367 }
368 }
369
370 /// `compute_skip_arg` MUST merge `SIDE_EFFECT_STAGES` with the
371 /// harness's complement set — otherwise the child release subprocess
372 /// runs produce-stages like `nfpm` / `nsis` / `dmg` on shards that
373 /// have no business running them, and the run dies with `No such
374 /// file or directory`. The harness fix in
375 /// `crates/cli/src/determinism_harness.rs` relies on this merge.
376 #[test]
377 fn compute_skip_arg_includes_side_effects_and_extra() {
378 let extra = ["nfpm".to_string(), "msi".to_string(), "dmg".to_string()];
379 let extra_refs: Vec<&str> = extra.iter().map(String::as_str).collect();
380 let arg = compute_skip_arg(&extra_refs);
381 let list: Vec<&str> = arg.trim_start_matches("--skip=").split(',').collect();
382 for &name in SIDE_EFFECT_STAGES {
383 assert!(
384 list.contains(&name),
385 "merged skip list missing side-effect stage `{name}`: {list:?}"
386 );
387 }
388 for name in ["nfpm", "msi", "dmg"] {
389 assert!(
390 list.contains(&name),
391 "merged skip list missing extra stage `{name}`: {list:?}"
392 );
393 }
394 }
395
396 /// Overlap is a real scenario — the harness's complement set is
397 /// computed against `VALID_RELEASE_SKIPS`, which contains the same
398 /// `release` / `publish` / `announce` names as `SIDE_EFFECT_STAGES`.
399 /// `compute_skip_arg` MUST de-dupe so the final argv isn't bloated
400 /// and CLI validation doesn't choke on a repeated token.
401 #[test]
402 fn compute_skip_arg_dedupes_overlap() {
403 // Pass a SIDE_EFFECT_STAGES member through `extra` and confirm it
404 // appears exactly once in the merged list.
405 let extra = ["release".to_string(), "nfpm".to_string()];
406 let extra_refs: Vec<&str> = extra.iter().map(String::as_str).collect();
407 let arg = compute_skip_arg(&extra_refs);
408 let list: Vec<&str> = arg.trim_start_matches("--skip=").split(',').collect();
409 let release_count = list.iter().filter(|&&s| s == "release").count();
410 assert_eq!(
411 release_count, 1,
412 "expected `release` exactly once in merged skip list, got {release_count} in {list:?}"
413 );
414 // And nfpm did come through.
415 assert!(
416 list.contains(&"nfpm"),
417 "merged list missing extra entry `nfpm`: {list:?}"
418 );
419 }
420
421 /// Every name the harness shovels into `--skip=...` MUST be accepted
422 /// by the release CLI's skip validator. Surfaced by the
423 /// drift-injection integration test when `docker-sign`
424 /// was present in [`SIDE_EFFECT_STAGES`] but missing from
425 /// [`crate::context::VALID_RELEASE_SKIPS`] — the harness's child
426 /// subprocess bombed with `invalid --skip value(s): docker-sign`. This
427 /// pure-cross-check unit test catches the drift in milliseconds so a
428 /// future addition to either list flags the gap immediately.
429 #[test]
430 fn side_effect_stages_are_all_valid_release_skip_values() {
431 use crate::context::VALID_RELEASE_SKIPS;
432 for &name in SIDE_EFFECT_STAGES {
433 assert!(
434 VALID_RELEASE_SKIPS.contains(&name),
435 "SIDE_EFFECT_STAGES contains `{name}` but VALID_RELEASE_SKIPS does not — \
436 the harness would fail at `anodize release --skip=<list>` invocation. \
437 Add `{name}` to VALID_RELEASE_SKIPS in crates/core/src/context.rs."
438 );
439 }
440 }
441}