anodizer 0.25.2

A Rust-native release automation tool inspired by GoReleaser
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
//! Integration tests for `anodizer preflight` (the config-derived
//! environment preflight command).
//!
//! Drives the real binary against a synthesized fixture repo whose config
//! demands things the host cannot satisfy, asserting:
//!   - collect-all: failures from independent surfaces (publisher SSH key,
//!     sbom tool, cosign key material) all appear in ONE run;
//!   - the exit code is non-zero when anything is missing;
//!   - secret VALUES never appear in the output — only env-var names;
//!   - `--json` emits a machine-readable report with the same failures.
//!
//! Skips cleanly on hosts without git (fixture bootstrap needs it), same
//! convention as `publish_only.rs`.
//!
//! The reconcile-sweep block at the bottom drives the same binary against a
//! LOCAL package feed, so the whole "does this version's publisher state gate
//! the run?" decision — position resolution, sweep skip, probe, exit code —
//! is asserted end to end without touching a real registry.

use std::process::Command;
use tempfile::TempDir;

mod common;
use common::{bootstrap_minimal_cargo_repo, run_git, tool_on_path};

const FIXTURE_CRATE_NAME: &str = "anodizer-preflight-fixture";

/// Sentinel that must NEVER appear in preflight output: it is the VALUE of
/// an env var the config requires (a malformed cosign key, so the check
/// fails and the failure message is exercised, not just the happy path).
const SECRET_SENTINEL: &str = "SUPERSECRET-PREFLIGHT-SENTINEL-VALUE";

fn write_fixture_config(dir: &std::path::Path) {
    // Four independent failure surfaces:
    //   publish.aur  -> PF_MISSING_AUR_KEY unset        (env-missing)
    //   publish.npm  -> NPM_TOKEN unset                 (env-missing)
    //   sboms.cmd    -> tool that cannot exist on PATH  (tool-missing)
    //   signs env:// -> PF_COSIGN_KEY set but malformed (bad key material)
    //
    // aur and npm are BOTH publishers, so a `--publishers` allowlist can
    // select one and deselect the other in a single run — the allowlist test
    // asserts the selected publisher's requirement survives while the
    // deselected one drops.
    let yaml = format!(
        r#"project_name: {FIXTURE_CRATE_NAME}
crates:
  - name: {FIXTURE_CRATE_NAME}
    path: .
    publish:
      aur:
        private_key: "{{{{ .Env.PF_MISSING_AUR_KEY }}}}"
npms:
  - scope: "@pf"
uploads:
  - name: mirror
    target: "https://uploads.example/{{{{ .ProjectName }}}}/{{{{ .ArtifactName }}}}"
    signature: true
signs:
  - artifacts: checksum
    cmd: cosign
    args: ["sign-blob", "--key", "env://PF_COSIGN_KEY", "{{{{ .Artifact }}}}"]
binary_signs:
  - cmd: cosign
    args: ["sign-blob", "--key", "env://PF_BINARY_COSIGN_KEY", "{{{{ .Artifact }}}}"]
sboms:
  - cmd: pf-definitely-not-a-real-tool-9z
"#
    );
    std::fs::write(dir.join(".anodizer.yaml"), yaml).unwrap();
}

fn run_preflight(dir: &std::path::Path, extra_args: &[&str]) -> std::process::Output {
    Command::new(env!("CARGO_BIN_EXE_anodizer"))
        .current_dir(dir)
        .arg("preflight")
        .args(extra_args)
        // The malformed-but-SET secrets: their NAMEs may appear, VALUEs must not.
        // PF_COSIGN_KEY backs the `signs:` slice; PF_BINARY_COSIGN_KEY backs the
        // `binary_signs:` slice — distinct env vars so each slice's gate is
        // asserted independently.
        .env("PF_COSIGN_KEY", SECRET_SENTINEL)
        .env("PF_BINARY_COSIGN_KEY", SECRET_SENTINEL)
        .env_remove("PF_MISSING_AUR_KEY")
        // Unset on the child only (never the test process) so the npm
        // publisher's token requirement deterministically reads as missing;
        // the allowlist test asserts this surfaces for the SELECTED npm
        // publisher. Per-child env keeps the test-isolation guard satisfied.
        .env_remove("NPM_TOKEN")
        .output()
        .expect("spawn anodizer preflight")
}

#[test]
fn preflight_collects_all_failures_and_exits_nonzero() {
    if !tool_on_path("git") {
        eprintln!("skipping: git not on PATH");
        return;
    }
    let tmp = TempDir::new().unwrap();
    bootstrap_minimal_cargo_repo(tmp.path(), FIXTURE_CRATE_NAME);
    write_fixture_config(tmp.path());

    let out = run_preflight(tmp.path(), &[]);
    let combined = format!(
        "{}{}",
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr)
    );

    assert!(
        !out.status.success(),
        "preflight must exit non-zero on failures; output:\n{combined}"
    );
    // Collect-all: every independent failure surface present in ONE run.
    assert!(
        combined.contains("PF_MISSING_AUR_KEY"),
        "missing publisher SSH key env var not reported:\n{combined}"
    );
    assert!(
        combined.contains("pf-definitely-not-a-real-tool-9z"),
        "missing sbom tool not reported:\n{combined}"
    );
    assert!(
        combined.contains("PF_COSIGN_KEY"),
        "malformed cosign key env var not reported:\n{combined}"
    );
    // Secret hygiene: the VALUE of the set-but-invalid key never leaks.
    assert!(
        !combined.contains(SECRET_SENTINEL),
        "preflight output echoed a secret value:\n{combined}"
    );
}

#[test]
fn preflight_json_reports_same_failures() {
    if !tool_on_path("git") {
        eprintln!("skipping: git not on PATH");
        return;
    }
    let tmp = TempDir::new().unwrap();
    bootstrap_minimal_cargo_repo(tmp.path(), FIXTURE_CRATE_NAME);
    write_fixture_config(tmp.path());

    let out = run_preflight(tmp.path(), &["--json"]);
    assert!(!out.status.success(), "non-zero exit expected");
    let stdout = String::from_utf8_lossy(&out.stdout);
    let json_start = stdout.find('{').expect("JSON object in stdout");
    let report: serde_json::Value =
        serde_json::from_str(stdout[json_start..].trim()).expect("valid JSON report");
    let failures = report["failures"].as_array().expect("failures array");
    assert!(
        failures.len() >= 3,
        "expected at least 3 failures, got: {failures:?}"
    );
    let kinds: Vec<&str> = failures.iter().filter_map(|f| f["kind"].as_str()).collect();
    assert!(kinds.contains(&"missing_env"), "kinds: {kinds:?}");
    assert!(kinds.contains(&"missing_tool"), "kinds: {kinds:?}");
    assert!(kinds.contains(&"bad_key_material"), "kinds: {kinds:?}");
    assert!(
        !stdout.contains(SECRET_SENTINEL),
        "JSON output echoed a secret value"
    );
}

#[test]
fn preflight_skip_drops_stage_requirements() {
    if !tool_on_path("git") {
        eprintln!("skipping: git not on PATH");
        return;
    }
    let tmp = TempDir::new().unwrap();
    bootstrap_minimal_cargo_repo(tmp.path(), FIXTURE_CRATE_NAME);
    write_fixture_config(tmp.path());

    let out = run_preflight(tmp.path(), &["--skip=sign,sbom,publish"]);
    let combined = format!(
        "{}{}",
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr)
    );
    assert!(
        !combined.contains("pf-definitely-not-a-real-tool-9z"),
        "skipped sbom stage still contributed requirements:\n{combined}"
    );
    assert!(
        !combined.contains("PF_COSIGN_KEY"),
        "skipped sign stage still contributed signs requirements:\n{combined}"
    );
    assert!(
        !combined.contains("PF_BINARY_COSIGN_KEY"),
        "skipped sign stage still contributed binary_signs requirements:\n{combined}"
    );
    assert!(
        !combined.contains("PF_MISSING_AUR_KEY"),
        "skipped publish still contributed requirements:\n{combined}"
    );
}

/// `preflight --publishers <name>` mirrors `release --publishers`: a
/// non-empty allowlist SELECTS the named publisher and DESELECTS every other
/// one, in a single pass. The fixture configures two publishers — `npm` and
/// `aur` — so one run proves BOTH directions of the allowlist:
///   - SELECTED (`npm`): its `NPM_TOKEN` requirement SURVIVES the allowlist
///     and surfaces in the report as `[needed by: publish:npm]`;
///   - DESELECTED (`aur`): its `PF_MISSING_AUR_KEY` requirement is DROPPED.
///
/// Crucially, NO `--skip` is passed: the `--publishers npm` allowlist ALONE
/// must auto-deselect the `signs:` surface (its only consumers —
/// github-release / blob / artifactory — are all deselected), so the malformed
/// `PF_COSIGN_KEY` cosign demand vanishes without a hand-skip. This is exactly
/// the surface the npm-provenance job validates with
/// `preflight --publish-only --publishers npm` and zero `--skip`.
#[test]
fn preflight_publishers_allowlist_keeps_selected_drops_deselected_publisher() {
    if !tool_on_path("git") {
        eprintln!("skipping: git not on PATH");
        return;
    }
    let tmp = TempDir::new().unwrap();
    bootstrap_minimal_cargo_repo(tmp.path(), FIXTURE_CRATE_NAME);
    write_fixture_config(tmp.path());

    // Allowlist `npm` with NO `--skip`: npm is SELECTED (its token requirement
    // must survive), the configured-but-unselected `aur` publisher is
    // DESELECTED (its key requirement must vanish), and the `signs:` slice
    // self-deselects because every signature consumer is deselected.
    let out = run_preflight(tmp.path(), &["--publish-only", "--publishers=npm"]);
    let combined = format!(
        "{}{}",
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr)
    );
    // SELECTED publisher: requirement survives the allowlist AND is attributed
    // to the npm publisher source, not merely present by coincidence.
    assert!(
        combined.contains("NPM_TOKEN"),
        "allowlist-selected npm publisher's token requirement was dropped:\n{combined}"
    );
    assert!(
        combined.contains("publish:npm"),
        "npm token requirement not attributed to the selected npm publisher:\n{combined}"
    );
    // DESELECTED publisher: its requirement is gone.
    assert!(
        !combined.contains("PF_MISSING_AUR_KEY"),
        "allowlist-deselected aur publisher still demanded its key:\n{combined}"
    );
    assert!(
        !combined.contains("publish:aur"),
        "deselected aur publisher still attributed a requirement source:\n{combined}"
    );
    // The signs slice self-deselects (no consumer selected) WITHOUT a
    // hand-skip, so its cosign key demand is gone.
    assert!(
        !combined.contains("PF_COSIGN_KEY"),
        "--publishers npm must auto-deselect the signs surface (no --skip):\n{combined}"
    );
    // The binary_signs slice self-skips in --publish-only (its output has no
    // publish-time consumer), so its DISTINCT cosign key demand is ALSO gone
    // WITHOUT a hand-skip — this is the second half of the npm-clean invariant.
    assert!(
        !combined.contains("PF_BINARY_COSIGN_KEY"),
        "--publish-only must auto-skip the binary_signs surface (no --skip):\n{combined}"
    );
    // Neither sign slice contributes anything: with both signs: (deselected
    // consumers) and binary_signs: (publish-only) skipped, no `stage:sign`
    // requirement may appear at all.
    assert!(
        !combined.contains("stage:sign"),
        "the sign slices must contribute nothing under --publish-only --publishers npm:\n{combined}"
    );
    assert!(
        !combined.contains("stage:release") && !combined.contains("publish:github-release"),
        "github-release must auto-deselect under --publishers npm (no --skip):\n{combined}"
    );
}

/// Under `--publish-only` with an EMPTY `--publishers` allowlist the `signs:`
/// surface must SURVIVE (`publisher_deselected` short-circuits to the denylist,
/// which never names a signs consumer) while the `binary_signs:` surface is
/// SKIPPED (publish-only mode — its output has no publish-time consumer).
/// Guards both directions: the signs gate must not over-fire and silently ship
/// an unsigned release; the binary_signs gate must fire on publish-only
/// regardless of allowlist.
#[test]
fn preflight_publish_only_empty_allowlist_keeps_signs_skips_binary_signs() {
    if !tool_on_path("git") {
        eprintln!("skipping: git not on PATH");
        return;
    }
    let tmp = TempDir::new().unwrap();
    bootstrap_minimal_cargo_repo(tmp.path(), FIXTURE_CRATE_NAME);
    write_fixture_config(tmp.path());

    // No allowlist, no skip, publish-only: the signs slice runs (its consumers
    // are not deselected), so its cosign key demand (malformed PF_COSIGN_KEY)
    // still surfaces; the binary_signs slice is publish-only-skipped, so its
    // distinct PF_BINARY_COSIGN_KEY demand is gone.
    let out = run_preflight(tmp.path(), &["--publish-only"]);
    let combined = format!(
        "{}{}",
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr)
    );
    assert!(
        combined.contains("PF_COSIGN_KEY"),
        "publish-only empty allowlist must keep the signs surface:\n{combined}"
    );
    assert!(
        !combined.contains("PF_BINARY_COSIGN_KEY"),
        "publish-only must skip the binary_signs surface regardless of allowlist:\n{combined}"
    );
}

/// The MAIN-job invariant under the REAL binary: the full release pipeline
/// (no `--publish-only`; the main job runs `release --skip=npm`, i.e. the FULL
/// scope with an empty allowlist) must KEEP BOTH sign surfaces — `signs:` AND
/// `binary_signs:` — so the binaries that ship are still signed. Proves the
/// binary_signs publish-only gate does not weaken the main release.
#[test]
fn preflight_full_scope_keeps_both_sign_surfaces() {
    if !tool_on_path("git") {
        eprintln!("skipping: git not on PATH");
        return;
    }
    let tmp = TempDir::new().unwrap();
    bootstrap_minimal_cargo_repo(tmp.path(), FIXTURE_CRATE_NAME);
    write_fixture_config(tmp.path());

    // FULL scope (no --publish-only), empty allowlist: BOTH sign slices run.
    let out = run_preflight(tmp.path(), &[]);
    let combined = format!(
        "{}{}",
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr)
    );
    assert!(
        combined.contains("PF_COSIGN_KEY"),
        "full-scope run must keep the signs surface:\n{combined}"
    );
    assert!(
        combined.contains("PF_BINARY_COSIGN_KEY"),
        "full-scope run must keep the binary_signs surface (main-job binary signing preserved):\n{combined}"
    );
}

/// The `uploads` publisher consumes the `signs:` sidecars when an entry sets
/// `signature: true`, so it is a member of `signs_consumers()`. Selecting it
/// ALONE (every OTHER consumer deselected) must KEEP the `signs:` surface — its
/// cosign key demand must survive — proving preflight stays in lockstep with
/// the fixed runtime. Before the fix, `uploads` was absent from the hard-coded
/// three-consumer conjunction, so `--publishers uploads` falsely dropped
/// `stage:sign` and the selected uploads publisher would mirror an unsigned set.
#[test]
fn preflight_publishers_uploads_keeps_signs_surface() {
    if !tool_on_path("git") {
        eprintln!("skipping: git not on PATH");
        return;
    }
    let tmp = TempDir::new().unwrap();
    bootstrap_minimal_cargo_repo(tmp.path(), FIXTURE_CRATE_NAME);
    write_fixture_config(tmp.path());

    let out = run_preflight(tmp.path(), &["--publish-only", "--publishers=uploads"]);
    let combined = format!(
        "{}{}",
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr)
    );
    // The selected `uploads` publisher reads the signs sidecars, so the signs
    // surface (and its cosign key demand) must NOT be deselected.
    assert!(
        combined.contains("PF_COSIGN_KEY"),
        "--publishers uploads must keep the signs surface (uploads consumes the sidecars):\n{combined}"
    );
    assert!(
        combined.contains("stage:sign"),
        "the signs slice must contribute its requirements under --publishers uploads:\n{combined}"
    );
}

// ---------------------------------------------------------------------------
// Reconcile sweep: position resolution → probe-or-skip → exit code
// ---------------------------------------------------------------------------

const RECONCILE_CRATE_NAME: &str = "anodizer-reconcile-fixture";
const RECONCILE_TAG: &str = "v0.1.0";
const RECONCILE_VERSION: &str = "0.1.0";

/// A fixture whose ONLY publisher is chocolatey, pointed at a local OData feed
/// and marked `required: true` so its verdict reaches the exit gate. `api_key`
/// is inline so the run demands no publisher secret from the environment and
/// the only thing that can drive a non-zero exit is the reconcile verdict.
fn write_reconcile_fixture_config(dir: &std::path::Path, feed: &str) {
    let yaml = format!(
        r#"project_name: {RECONCILE_CRATE_NAME}
crates:
  - name: {RECONCILE_CRATE_NAME}
    path: .
    tag_template: "v{{{{ .Version }}}}"
    publish:
      chocolatey:
        required: true
        api_key: fixture-key
        source_repo: "{feed}"
"#
    );
    std::fs::write(dir.join(".anodizer.yaml"), yaml).unwrap();
}

/// An OData row for [`RECONCILE_VERSION`] in the REJECTED moderation state —
/// the one feed shape chocolatey's `reconcile()` maps to `diverged`, and
/// therefore the one that must reach the exit gate when the sweep applies.
fn rejected_feed_response() -> String {
    let body = format!(
        r#"<?xml version="1.0" encoding="utf-8"?>
<entry>
  <id>http://example.com/api/v2/Packages(Id='{RECONCILE_CRATE_NAME}',Version='{RECONCILE_VERSION}')</id>
  <m:properties>
    <d:PackageHash>deadbeef==</d:PackageHash>
    <d:PackageHashAlgorithm>SHA512</d:PackageHashAlgorithm>
    <d:PackageStatus>Rejected</d:PackageStatus>
    <d:IsApproved>false</d:IsApproved>
  </m:properties>
</entry>"#
    );
    format!(
        "HTTP/1.1 200 OK\r\nContent-Type: application/xml\r\nContent-Length: {}\r\n\r\n{body}",
        body.len()
    )
}

/// Bootstrap a repo whose tag `v0.1.0` sits `commits_after` commits behind
/// HEAD, with the reconcile fixture config pointed at a local feed that answers
/// every request with a REJECTED row. Returns the temp dir and the feed's
/// request counter.
fn reconcile_fixture(
    commits_after: usize,
) -> (TempDir, std::sync::Arc<std::sync::atomic::AtomicU32>) {
    let tmp = TempDir::new().unwrap();
    bootstrap_minimal_cargo_repo(tmp.path(), RECONCILE_CRATE_NAME);
    // Two canned rows rather than one: a single spare keeps a retry from
    // falling through to the drain phase's 503, which would read as "absent"
    // and quietly turn a divergence assertion into a false pass.
    let (addr, calls) =
        anodizer_core::test_helpers::responder::spawn_oneshot_http_responder_with(|_| {
            vec![rejected_feed_response(), rejected_feed_response()]
        });
    write_reconcile_fixture_config(tmp.path(), &format!("http://{addr}"));
    run_git(tmp.path(), &["add", "-A"]);
    run_git(
        tmp.path(),
        &["commit", "-q", "-m", "reconcile fixture config"],
    );
    run_git(tmp.path(), &["tag", RECONCILE_TAG]);
    for i in 0..commits_after {
        run_git(
            tmp.path(),
            &["commit", "-q", "--allow-empty", "-m", &format!("after-{i}")],
        );
    }
    (tmp, calls)
}

/// Run the reconcile fixture's preflight, returning `(output, reconcile rows)`.
fn run_reconcile_preflight(
    dir: &std::path::Path,
    env: &[(&str, &str)],
) -> (std::process::Output, Vec<serde_json::Value>) {
    let mut cmd = Command::new(env!("CARGO_BIN_EXE_anodizer"));
    cmd.current_dir(dir)
        .args(["preflight", "--json", "--publish-only"])
        .arg("--publishers=chocolatey");
    for (k, v) in env {
        cmd.env(k, v);
    }
    let out = cmd.output().expect("spawn anodizer preflight");
    let stdout = String::from_utf8_lossy(&out.stdout).to_string();
    let json_start = stdout
        .find('{')
        .unwrap_or_else(|| panic!("no JSON object in stdout: {stdout}"));
    let report: serde_json::Value =
        serde_json::from_str(stdout[json_start..].trim()).expect("valid JSON report");
    let rows = report["reconcile"]
        .as_array()
        .expect("reconcile array")
        .clone();
    (out, rows)
}

/// End-to-end, HEAD ADVANCED PAST the tag: the resolved version is the last
/// released one, so the sweep must not run at all. The observable proof is
/// threefold — the feed is never contacted, the table reports the whole-sweep
/// skip marker instead of a publisher row, and the command exits ZERO even
/// though that publisher is required and its feed row is a rejection.
#[test]
fn reconcile_sweep_skipped_end_to_end_when_head_advanced_past_the_tag() {
    if !tool_on_path("git") {
        eprintln!("skipping: git not on PATH");
        return;
    }
    if !tool_on_path("xmllint") {
        eprintln!("skipping: xmllint not on PATH (chocolatey's tool requirement)");
        return;
    }
    let (tmp, calls) = reconcile_fixture(1);
    let (out, rows) = run_reconcile_preflight(tmp.path(), &[]);
    let combined = format!(
        "{}{}",
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr)
    );

    assert!(
        out.status.success(),
        "a skipped sweep must not gate the exit code; output:\n{combined}"
    );
    assert_eq!(
        calls.load(std::sync::atomic::Ordering::SeqCst),
        0,
        "a skipped sweep must not probe the registry at all"
    );
    assert_eq!(rows.len(), 1, "expected one marker row, got: {rows:?}");
    assert_eq!(rows[0]["publisher"], "*");
    assert_eq!(rows[0]["state"], "skipped");
    assert_eq!(rows[0]["blocking"], false);
    assert!(
        rows[0]["detail"]
            .as_str()
            .is_some_and(|d| d.contains(RECONCILE_TAG) && d.contains("advanced past it")),
        "the marker must name the version and why it was skipped: {rows:?}"
    );
}

/// End-to-end, HEAD EXACTLY AT the tag: the resolved version IS the version
/// this run would publish, so the sweep runs, the required publisher's
/// `diverged` verdict reaches the gate, and the command exits NON-ZERO with
/// the divergence bail — not the environment bail.
#[test]
fn reconcile_sweep_probes_end_to_end_and_diverged_exits_nonzero_at_the_tag() {
    if !tool_on_path("git") {
        eprintln!("skipping: git not on PATH");
        return;
    }
    if !tool_on_path("xmllint") {
        eprintln!("skipping: xmllint not on PATH (chocolatey's tool requirement)");
        return;
    }
    let (tmp, calls) = reconcile_fixture(0);
    let (out, rows) = run_reconcile_preflight(tmp.path(), &[]);
    let stderr = String::from_utf8_lossy(&out.stderr).to_string();

    assert!(
        !out.status.success(),
        "a required publisher's divergence must exit non-zero; stderr:\n{stderr}"
    );
    assert!(
        stderr.contains("required publisher(s) diverged"),
        "the divergence bail must be what failed the run, not the environment gate: {stderr}"
    );
    assert_eq!(
        calls.load(std::sync::atomic::Ordering::SeqCst),
        1,
        "the sweep must probe the feed exactly once"
    );
    assert_eq!(rows.len(), 1, "expected one publisher row, got: {rows:?}");
    assert_eq!(rows[0]["publisher"], "chocolatey");
    assert_eq!(rows[0]["state"], "diverged");
    assert_eq!(rows[0]["blocking"], true);
}

/// End-to-end backfill canary: HEAD is a commit past `v0.1.0`, but
/// `ANODIZER_CURRENT_TAG` DECLARES `v0.1.0` as the version this run targets.
/// The staleness inference applies only to a tag anodizer picked itself, so the
/// sweep must run and the required divergence must still gate — the same tree
/// that skips in the inferred case.
#[test]
fn reconcile_sweep_probes_end_to_end_for_an_explicitly_declared_tag() {
    if !tool_on_path("git") {
        eprintln!("skipping: git not on PATH");
        return;
    }
    if !tool_on_path("xmllint") {
        eprintln!("skipping: xmllint not on PATH (chocolatey's tool requirement)");
        return;
    }
    let (tmp, calls) = reconcile_fixture(1);
    let (out, rows) =
        run_reconcile_preflight(tmp.path(), &[("ANODIZER_CURRENT_TAG", RECONCILE_TAG)]);
    let stderr = String::from_utf8_lossy(&out.stderr).to_string();

    assert!(
        !out.status.success(),
        "a declared tag must be probed and its divergence must gate; stderr:\n{stderr}"
    );
    assert!(
        stderr.contains("required publisher(s) diverged"),
        "the divergence bail must be what failed the run: {stderr}"
    );
    assert_eq!(
        calls.load(std::sync::atomic::Ordering::SeqCst),
        1,
        "a declared tag must be probed even from a tree that has moved past it"
    );
    assert_eq!(rows.len(), 1, "expected one publisher row, got: {rows:?}");
    assert_eq!(rows[0]["publisher"], "chocolatey");
    assert_eq!(rows[0]["state"], "diverged");
}