anodizer 0.15.5

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
//! Integration tests for the in-process failure policy
//! (`release.on_failure`).
//!
//! Each test drives the real binary against a throwaway git repo with a
//! local bare `origin`, fails the publish via the env-gated
//! `--simulate-failure` harness, and asserts the git-level outcome:
//!
//! - default `rollback` on a reversible-only failure reverts the bump
//!   commit and deletes the release tag (locally and on origin);
//! - `on_failure: hold` leaves the tag and commit in place;
//! - `rollback` auto-degrades to hold when a one-way-door publisher
//!   already landed (evidence planted as a per-crate run summary,
//!   the shape a prior crate's publish leaves in workspace per-crate
//!   mode), and the output names the burned publisher.
//!
//! Every test also asserts the run summary records the taken path in
//! its `failure_policy` field.

use std::fs;
use std::path::Path;
use std::process::Command;
use tempfile::TempDir;

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

use anodizer_core::test_helpers::{create_config, create_test_project};

/// Stage-skip list leaving only the publish stage live — mirrors the
/// existing required-publisher gate tests in `integration.rs`.
const SKIP_ALL_BUT_PUBLISH: &str = "--skip=build,upx,appbundle,dmg,msi,pkg,nsis,notarize,changelog,archive,source,nfpm,srpm,makeself,snapcraft,flatpak,sbom,templatefiles,checksum,sign,release,docker,docker-sign,blob,snapcraft-publish,announce";

/// Build a repo whose HEAD is a bump-style commit tagged `v0.1.0`, with
/// a local bare `origin` holding the branch and the tag — the state an
/// `anodizer tag --push` run leaves behind right before `release` runs.
fn setup_tagged_repo_with_origin(config: &str) -> (TempDir, TempDir) {
    let repo = TempDir::new().unwrap();
    let origin = TempDir::new().unwrap();
    create_test_project(repo.path());
    create_config(repo.path(), config);

    run_git(repo.path(), &["init", "-q", "-b", "master"]);
    run_git(repo.path(), &["config", "user.email", "test@test.com"]);
    run_git(repo.path(), &["config", "user.name", "Test"]);
    run_git(repo.path(), &["config", "commit.gpgsign", "false"]);
    run_git(repo.path(), &["add", "-A"]);
    run_git(repo.path(), &["commit", "-q", "-m", "initial"]);
    fs::write(repo.path().join("RELEASE.md"), "v0.1.0\n").unwrap();
    run_git(repo.path(), &["add", "-A"]);
    run_git(
        repo.path(),
        &["commit", "-q", "-m", "chore(release): v0.1.0"],
    );
    run_git(repo.path(), &["tag", "v0.1.0"]);

    run_git(origin.path(), &["init", "-q", "--bare", "-b", "master"]);
    let origin_path = origin.path().to_string_lossy().to_string();
    run_git(repo.path(), &["remote", "add", "origin", &origin_path]);
    run_git(repo.path(), &["push", "-q", "origin", "master", "v0.1.0"]);

    (repo, origin)
}

fn git_stdout(dir: &Path, args: &[&str]) -> String {
    let out = anodizer_core::test_helpers::output_with_spawn_retry(
        || {
            let mut cmd = Command::new("git");
            cmd.args(args).current_dir(dir);
            cmd
        },
        "git",
    );
    assert!(out.status.success(), "git {args:?} failed: {out:?}");
    String::from_utf8_lossy(&out.stdout).trim().to_string()
}

fn run_failing_release(repo: &Path, extra_env: &[(&str, &str)]) -> std::process::Output {
    let mut cmd = Command::new(env!("CARGO_BIN_EXE_anodizer"));
    cmd.args([
        "release",
        "--no-preflight",
        "--simulate-failure",
        "cargo",
        SKIP_ALL_BUT_PUBLISH,
    ])
    .env("ANODIZE_TEST_HARNESS", "1")
    .env_remove("CARGO_REGISTRY_TOKEN")
    .env_remove("GITHUB_TOKEN")
    .env_remove("ANODIZER_GITHUB_TOKEN")
    .current_dir(repo);
    for (key, value) in extra_env {
        cmd.env(key, value);
    }
    cmd.output().expect("invoking anodizer release")
}

fn summary_failure_policy(repo: &Path) -> serde_json::Value {
    let path = repo.join("dist").join("run-v0.1.0").join("summary.json");
    let raw = fs::read_to_string(&path)
        .unwrap_or_else(|e| panic!("summary must exist at {}: {e}", path.display()));
    let summary: serde_json::Value = serde_json::from_str(&raw).expect("summary parses");
    summary
        .get("failure_policy")
        .unwrap_or_else(|| panic!("summary must record failure_policy: {raw}"))
        .clone()
}

const CARGO_PUBLISH_CONFIG: &str = r#"
project_name: test-project
crates:
  - name: test-project
    path: "."
    tag_template: "v{{ .Version }}"
    publish:
      cargo: {}
"#;

/// (a) Default policy: a reversible-only failure (required cargo
/// publisher failed — nothing landed) rolls back in-process: revert
/// commit created and pushed, tag deleted locally and on origin.
#[test]
fn release_failure_default_rollback_reverts_bump_and_deletes_tag() {
    if !tool_on_path("git") {
        eprintln!("SKIP release_failure_default_rollback: git missing");
        return;
    }
    let (repo, origin) = setup_tagged_repo_with_origin(CARGO_PUBLISH_CONFIG);
    let bump_sha = git_stdout(repo.path(), &["rev-parse", "HEAD"]);

    // Hermetic crates.io index for the rollback path's GLOBAL published-state
    // probe: `test-project@0.1.0` is a real (unrelated) crate on the live
    // index, and the guard must consult registry state before deleting the
    // tag. A local 404 responder answers "version absent" so the test both
    // stays offline-safe and proves the probe actually ran end-to-end.
    let (index_addr, index_hits) =
        anodizer_core::test_helpers::responder::spawn_oneshot_http_responder(vec![
            "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n",
        ]);
    let index_base = format!("http://{index_addr}");
    let output = run_failing_release(
        repo.path(),
        &[("ANODIZER_TEST_CRATES_IO_INDEX_BASE", index_base.as_str())],
    );
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert_eq!(
        index_hits.load(std::sync::atomic::Ordering::SeqCst),
        1,
        "rollback must consult the crates.io index for test-project@0.1.0 exactly once; \
         stderr: {stderr}"
    );
    assert!(
        !output.status.success(),
        "release must still exit non-zero after rollback; stderr: {stderr}"
    );
    assert!(
        stderr.contains("required publisher") && stderr.contains("cargo"),
        "original failure must not be masked by the policy; got: {stderr}"
    );
    assert!(
        stderr.contains("on_failure=rollback"),
        "policy must announce itself; got: {stderr}"
    );

    // Tag gone locally and on origin.
    assert_eq!(
        git_stdout(repo.path(), &["tag", "-l", "v0.1.0"]),
        "",
        "local tag must be deleted; stderr: {stderr}"
    );
    assert_eq!(
        git_stdout(origin.path(), &["tag", "-l", "v0.1.0"]),
        "",
        "remote tag must be deleted; stderr: {stderr}"
    );

    // HEAD is the revert of the bump commit, and it reached origin.
    let head_subject = git_stdout(repo.path(), &["log", "-1", "--format=%s"]);
    assert!(
        head_subject.contains("rollback v0.1.0"),
        "HEAD must be the rollback revert commit, got: {head_subject}"
    );
    let head_sha = git_stdout(repo.path(), &["rev-parse", "HEAD"]);
    assert_ne!(head_sha, bump_sha, "revert must advance HEAD");
    assert_eq!(
        git_stdout(origin.path(), &["rev-parse", "refs/heads/master"]),
        head_sha,
        "revert commit must be pushed to origin"
    );

    let record = summary_failure_policy(repo.path());
    assert_eq!(record["configured"], "rollback");
    assert_eq!(record["action"], "rolled-back");
    assert_eq!(record["degraded"], false);
}

/// (b) `on_failure: hold` leaves every piece of state in place: tag
/// intact (local + origin), no revert commit, summary records the hold.
#[test]
fn release_failure_hold_leaves_state_in_place() {
    if !tool_on_path("git") {
        eprintln!("SKIP release_failure_hold: git missing");
        return;
    }
    let config = r#"
project_name: test-project
release:
  on_failure: hold
crates:
  - name: test-project
    path: "."
    tag_template: "v{{ .Version }}"
    publish:
      cargo: {}
"#;
    let (repo, origin) = setup_tagged_repo_with_origin(config);
    let bump_sha = git_stdout(repo.path(), &["rev-parse", "HEAD"]);

    let output = run_failing_release(repo.path(), &[]);
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        !output.status.success(),
        "hold must still exit non-zero; stderr: {stderr}"
    );
    assert!(
        stderr.contains("on_failure=hold") && stderr.contains("--rollback-only"),
        "hold must say so and point at the manual recovery path; got: {stderr}"
    );

    assert_eq!(git_stdout(repo.path(), &["tag", "-l", "v0.1.0"]), "v0.1.0");
    assert_eq!(
        git_stdout(origin.path(), &["tag", "-l", "v0.1.0"]),
        "v0.1.0"
    );
    assert_eq!(
        git_stdout(repo.path(), &["rev-parse", "HEAD"]),
        bump_sha,
        "no revert commit may be created under hold"
    );

    let record = summary_failure_policy(repo.path());
    assert_eq!(record["configured"], "hold");
    assert_eq!(record["action"], "held");
    assert_eq!(record["degraded"], false);
}

/// Lay down a preserved dist tree the way `check determinism
/// --preserve-dist` would (archive + artifacts.json + context.json),
/// minimal enough for `--publish-only` to load. Mirrors the fixture in
/// `publish_only.rs`.
fn bootstrap_preserved_dist(repo: &Path, crate_name: &str, version: &str, commit: &str) {
    let dist = repo.join("dist");
    fs::create_dir_all(&dist).unwrap();
    let target = "x86_64-unknown-linux-gnu";
    let archive_name = format!("{crate_name}_{version}_{target}.tar.gz");
    let archive_path = dist.join(&archive_name);
    fs::write(&archive_path, b"ARCHIVE\n").unwrap();

    let artifacts_json = serde_json::json!([
        {
            "kind": "archive",
            "name": archive_name,
            "path": archive_path.to_string_lossy(),
            "target": target,
            "crate_name": crate_name,
            "metadata": { "ID": crate_name, "Format": "tar.gz" },
        }
    ]);
    fs::write(
        dist.join("artifacts.json"),
        serde_json::to_string_pretty(&artifacts_json).unwrap(),
    )
    .unwrap();

    // sha256 of the literal b"ARCHIVE\n" payload above — publish-only
    // hash-verifies every preserved artifact before running.
    let archive_sha256 = "5c1f30e5f037be631bf54f0b521304c77bb439bfe90f7839b885a1b5099c724c";
    let context_json = serde_json::json!({
        "artifacts": [
            {
                "name": archive_name,
                "path": archive_name,
                "sha256": format!("sha256:{archive_sha256}"),
                "size": 8u64,
            }
        ],
        "targets": [target],
        "version": version,
        "commit": commit,
    });
    fs::write(
        dist.join("context.json"),
        serde_json::to_string_pretty(&context_json).unwrap(),
    )
    .unwrap();
}

/// (c) Auto-degrade: with `on_failure: rollback` (default) but a
/// one-way-door publisher already landed — evidence persisted as a
/// per-crate run summary, exactly what a prior crate's publish leaves
/// behind in workspace per-crate mode — the policy must NOT roll back:
/// tag and commit stay, the output says it degraded and names the
/// burned publisher, and the summary records the degrade. Runs in
/// `--publish-only` mode (the preserved-dist consumer), which is also
/// the mode anodizer's own release pipeline uses.
#[test]
fn publish_only_failure_degrades_rollback_to_hold_after_one_way_door() {
    if !tool_on_path("git") {
        eprintln!("SKIP publish_only_failure_degrades: git missing");
        return;
    }
    const CRATE: &str = "anodize-failure-policy-fixture";
    let tmp = TempDir::new().unwrap();
    let repo = tmp.path();
    common::bootstrap_minimal_cargo_repo(repo, CRATE);
    let host = common::host_triple();
    let yaml = format!(
        r#"project_name: {CRATE}
crates:
  - name: {CRATE}
    path: .
    tag_template: "v{{{{ .Version }}}}"
    builds:
      - id: {CRATE}
        binary: {CRATE}
        targets:
          - {host}
    publish:
      cargo: {{}}
"#
    );
    fs::write(repo.join(".anodizer.yaml"), yaml).unwrap();
    fs::write(repo.join(".gitignore"), "dist/\n").unwrap();
    run_git(repo, &["add", "-A"]);
    run_git(repo, &["commit", "-q", "-m", "configure release fixture"]);
    let bump_sha = git_stdout(repo, &["rev-parse", "HEAD"]);
    run_git(repo, &["tag", "-a", "v0.1.0", "-m", "release v0.1.0"]);

    bootstrap_preserved_dist(repo, CRATE, "0.1.0", &bump_sha);

    // A sibling crate's persisted summary: cargo landed for it earlier
    // in this run — the version is burned.
    let burned = serde_json::json!({
        "schema_version": 1,
        "anodize_version": "0.0.0-test",
        "tag": "sibling-v0.1.0",
        "submitter_gated": false,
        "announce_gated": false,
        "publishers_succeeded": 1,
        "publishers_failed": 0,
        "irreversibly_published": true,
        "results": [
            {
                "name": "cargo",
                "group": "Submitter",
                "required": true,
                "status": "succeeded",
                "evidence": null,
            }
        ],
        "determinism_allowlist": { "compile_time": [], "runtime": [] },
    });
    let planted = repo
        .join("dist")
        .join("sibling")
        .join("run-sibling-v0.1.0")
        .join("summary.json");
    fs::create_dir_all(planted.parent().unwrap()).unwrap();
    fs::write(&planted, serde_json::to_string_pretty(&burned).unwrap()).unwrap();

    let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
        .args([
            "release",
            "--publish-only",
            "--no-preflight",
            "--simulate-failure",
            "cargo",
            SKIP_ALL_BUT_PUBLISH,
        ])
        .env("ANODIZE_TEST_HARNESS", "1")
        .env_remove("CARGO_REGISTRY_TOKEN")
        .env_remove("GITHUB_TOKEN")
        .env_remove("ANODIZER_GITHUB_TOKEN")
        .current_dir(repo)
        .output()
        .expect("invoking anodizer release --publish-only");
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        !output.status.success(),
        "degraded run must exit non-zero; stderr: {stderr}"
    );
    assert!(
        stderr.contains("DEGRADED") && stderr.contains("cargo"),
        "degrade must be loud and name the burned publisher; got: {stderr}"
    );

    // Nothing destructive happened.
    assert_eq!(git_stdout(repo, &["tag", "-l", "v0.1.0"]), "v0.1.0");
    assert_eq!(
        git_stdout(repo, &["rev-parse", "HEAD"]),
        bump_sha,
        "degraded rollback must not create a revert commit"
    );

    let record = summary_failure_policy(repo);
    assert_eq!(record["configured"], "rollback");
    assert_eq!(record["action"], "held");
    assert_eq!(record["degraded"], true);
    assert_eq!(record["burned_publishers"], serde_json::json!(["cargo"]));

    // The degrade record reaches the planted per-crate summary too —
    // both layout levels carry the audit trail.
    let sibling: serde_json::Value =
        serde_json::from_str(&fs::read_to_string(&planted).unwrap()).unwrap();
    assert_eq!(sibling["failure_policy"]["action"], "held");
}

/// The publish-only credential gate scopes its production-signing-key
/// requirement to the RESOLVED publisher surface. A signing-free surface
/// — here cargo-only, with `--skip` deselecting the `sign` stage and
/// every `signs:` consumer (github-release / blob / artifactory /
/// uploads) — needs no cosign/GPG material, so a run with a release token
/// but NO signing key must PASS the credential gate and proceed into the
/// publish pipeline. This is the regression behind the github-hosted
/// npm-provenance job (`--publish-only --publishers npm`, which carries
/// no signing keys by design): the old blanket gate aborted it with
/// "missing production signing key" before it could publish.
#[test]
fn publish_only_signing_free_surface_passes_credential_gate() {
    if !tool_on_path("git") {
        eprintln!("SKIP publish_only_signing_free_surface: git missing");
        return;
    }
    let (repo, origin) = setup_tagged_repo_with_origin(CARGO_PUBLISH_CONFIG);
    let bump_sha = git_stdout(repo.path(), &["rev-parse", "HEAD"]);
    // The preserved dist is laid down BEFORE the run here (unlike tests
    // (a)/(b), where the run itself creates it after the dirty-repo
    // check), so it must be ignored or setup aborts on a dirty tree.
    // Self-ignoring keeps the untracked .gitignore out of the way too.
    fs::write(repo.path().join(".gitignore"), "dist/\n.gitignore\n").unwrap();
    bootstrap_preserved_dist(repo.path(), "test-project", "0.1.0", &bump_sha);

    let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
        .args(["release", "--publish-only", SKIP_ALL_BUT_PUBLISH])
        .env("GITHUB_TOKEN", "dummy-token")
        .env("CARGO_REGISTRY_TOKEN", "dummy-token")
        .env_remove("COSIGN_KEY")
        .env_remove("GPG_PRIVATE_KEY")
        .current_dir(repo.path())
        .output()
        .expect("invoking anodizer release --publish-only");
    let stderr = String::from_utf8_lossy(&output.stderr);

    // The credential gate must NOT trip on the missing signing key for a
    // surface that consumes no production signatures.
    assert!(
        !stderr.contains("missing production signing key"),
        "a signing-free surface must not be blocked on the signing key; got: {stderr}"
    );
    // The run reached the publish pipeline (proof it got past the gate):
    // the cargo publisher's own log line appears.
    assert!(
        stderr.contains("cargo publish"),
        "run must proceed past the credential gate into the publish pipeline; got: {stderr}"
    );
    // The original tags stay intact; nothing destructive ran.
    assert_eq!(git_stdout(repo.path(), &["tag", "-l", "v0.1.0"]), "v0.1.0");
    assert_eq!(
        git_stdout(origin.path(), &["tag", "-l", "v0.1.0"]),
        "v0.1.0"
    );
}

/// Counterpart to the signing-free case: a publish-only surface that DOES
/// keep a `signs:` consumer selected (github-release) must STILL abort on the
/// signing requirement — the guard is scoped, not removed. Zero-mutation: no
/// rollback, no revert commit, tags intact on both ends, no `failure_policy`
/// record written. The scoping itself (the sign stage's `signs:` loop
/// self-skips for a signing-free surface so no `KeyEnv` requirement is
/// collected, but stands otherwise) is unit-tested via `collect_requirements`
/// in `preflight.rs`; this case proves the end-to-end abort.
#[test]
fn publish_only_signing_surface_still_requires_sign_key() {
    if !tool_on_path("git") {
        eprintln!("SKIP publish_only_signing_surface_requires_key: git missing");
        return;
    }
    // A surface with a `signs:` slice and a selected signs consumer
    // (github-release). The `--skip` list below deselects everything EXCEPT
    // sign + release, so `signs_fully_deselected` is false and signing is
    // genuinely required.
    let signing_config = r#"
project_name: test-project
signs:
  - artifacts: all
    cmd: cosign
    args: ["sign-blob", "--key=env://COSIGN_KEY", "--output-signature=${signature}", "${artifact}"]
crates:
  - name: test-project
    path: "."
    tag_template: "v{{ .Version }}"
    release:
      github:
        owner: test-owner
        name: test-repo
"#;
    let (repo, origin) = setup_tagged_repo_with_origin(signing_config);
    let bump_sha = git_stdout(repo.path(), &["rev-parse", "HEAD"]);
    fs::write(repo.path().join(".gitignore"), "dist/\n.gitignore\n").unwrap();
    bootstrap_preserved_dist(repo.path(), "test-project", "0.1.0", &bump_sha);

    // Keep sign + release (the signs consumer) selected; skip the rest.
    let skip_all_but_sign_release = "--skip=build,upx,appbundle,dmg,msi,pkg,nsis,notarize,changelog,archive,source,nfpm,\
         srpm,makeself,snapcraft,flatpak,sbom,templatefiles,checksum,docker,docker-sign,blob,\
         snapcraft-publish,announce,cargo";

    let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
        .args(["release", "--publish-only", skip_all_but_sign_release])
        .env("GITHUB_TOKEN", "dummy-token")
        .env_remove("COSIGN_KEY")
        .env_remove("GPG_PRIVATE_KEY")
        .current_dir(repo.path())
        .output()
        .expect("invoking anodizer release --publish-only");
    let stderr = String::from_utf8_lossy(&output.stderr);

    assert!(
        !output.status.success(),
        "missing sign key must abort a signing surface; stderr: {stderr}"
    );
    // The config-derived env preflight is the single front line: the sign
    // stage's `signs:` loop is live (a signs consumer is selected), so it
    // contributes a `KeyEnv` requirement for the missing cosign key, and the
    // report attributes the failure to `stage:sign`. This proves a signing
    // surface cannot silently proceed without signing material.
    assert!(
        stderr.contains("stage:sign"),
        "a signing surface must abort naming the signing requirement; got: {stderr}"
    );
    assert!(
        !stderr.contains("on_failure") && !stderr.contains("rolling back"),
        "a zero-mutation credential miss must never reach the failure policy; got: {stderr}"
    );

    // Nothing was touched: tag intact on both ends, HEAD unchanged.
    assert_eq!(git_stdout(repo.path(), &["tag", "-l", "v0.1.0"]), "v0.1.0");
    assert_eq!(
        git_stdout(origin.path(), &["tag", "-l", "v0.1.0"]),
        "v0.1.0"
    );
    assert_eq!(
        git_stdout(repo.path(), &["rev-parse", "HEAD"]),
        bump_sha,
        "no revert commit may be created; stderr: {stderr}"
    );

    // No run summary / failure_policy record — the run never reached
    // the publish pipeline, let alone the policy.
    assert!(
        !repo
            .path()
            .join("dist")
            .join("run-v0.1.0")
            .join("summary.json")
            .exists(),
        "credential-gate abort must not write a failure-policy summary"
    );
}

/// `release.on_failure` is root-level only: a crate-level setting is a
/// config-load error (never silently ignored), surfaced by every
/// config-loading command.
#[test]
fn crate_level_on_failure_is_rejected_at_config_load() {
    if !tool_on_path("git") {
        eprintln!("SKIP crate_level_on_failure_rejected: git missing");
        return;
    }
    let config = r#"
project_name: test-project
crates:
  - name: test-project
    path: "."
    tag_template: "v{{ .Version }}"
    release:
      on_failure: hold
    publish:
      cargo: {}
"#;
    let (repo, _origin) = setup_tagged_repo_with_origin(config);

    let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
        .args(["check", "config"])
        .current_dir(repo.path())
        .output()
        .expect("invoking anodizer check");
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        !output.status.success(),
        "crate-level on_failure must fail config load; stderr: {stderr}"
    );
    assert!(
        stderr.contains("root-level") && stderr.contains("test-project"),
        "error must explain the rule and name the crate; got: {stderr}"
    );
}