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}