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}