Skip to main content

anodizer_cli/
lib.rs

1use clap::{Parser, Subcommand};
2use clap_complete::Shell;
3use std::path::PathBuf;
4
5/// Shared `--publishers` help stem used across `release`, `publish`, and
6/// `check config` so the flag presents one mental model on every command.
7/// `check config` appends its validate-only clause (see its `#[arg]`).
8const PUBLISHERS_HELP_STEM: &str = "Comma-separated publishers to run (default: all configured). \
9     --skip always wins over --publishers.";
10
11#[derive(Parser)]
12#[command(name = "anodizer", version, about = "Release Rust projects with ease")]
13pub struct Cli {
14    #[arg(
15        long,
16        short = 'f',
17        global = true,
18        help = "Path to config file (overrides auto-detection)"
19    )]
20    pub config: Option<PathBuf>,
21    #[arg(long, global = true, help = "Enable verbose output")]
22    pub verbose: bool,
23    #[arg(long, global = true, help = "Enable debug output")]
24    pub debug: bool,
25    #[arg(long, short = 'q', global = true, help = "Suppress non-error output")]
26    pub quiet: bool,
27    #[arg(
28        long,
29        global = true,
30        help = "Strict mode: configured features that silently skip become hard errors"
31    )]
32    pub strict: bool,
33    // Optional so `anodizer` with no args prints help and exits 0. A required
34    // subcommand (non-Option) makes clap emit a "usage" error and exit with
35    // code 2, which package-manager validators (winget's, chocolatey's) treat
36    // as install failure since they smoke-test the installed binary with no
37    // args.
38    #[command(subcommand)]
39    pub command: Option<Commands>,
40}
41
42#[derive(Subcommand)]
43// The `Release` variant carries one field per CLI flag (~40 fields) so its
44// size dwarfs the other subcommands. Boxing every flag bag would just hide
45// the same fields behind an extra allocation per parse with no callsite
46// win; the enum is allocated once per invocation. Local allow only.
47#[allow(clippy::large_enum_variant)]
48pub enum Commands {
49    /// Run the full release pipeline
50    Release {
51        #[arg(long = "crate", visible_alias = "id", action = clap::ArgAction::Append, help = "Release a specific crate (repeatable; --id is accepted as a GoReleaser-compat alias)")]
52        crate_names: Vec<String>,
53        #[arg(long, help = "Release all crates with unreleased changes")]
54        all: bool,
55        #[arg(long, help = "Force release even without unreleased changes")]
56        force: bool,
57        #[arg(long, help = "Build without publishing (snapshot mode)")]
58        snapshot: bool,
59        #[arg(long, help = "Create a nightly release with date-based version")]
60        nightly: bool,
61        #[arg(long, help = "Run full pipeline without side effects")]
62        dry_run: bool,
63        #[arg(long, help = "Remove dist directory before starting")]
64        clean: bool,
65        #[arg(
66            long,
67            value_delimiter = ',',
68            help = "Skip stages or publishers (comma-separated, e.g. docker,announce,npm). \
69                    Unified denylist: a stage name skips the stage, a publisher name \
70                    (npm, homebrew, chocolatey, …) skips that publisher."
71        )]
72        skip: Vec<String>,
73        #[arg(long = "publishers", value_delimiter = ',', help = PUBLISHERS_HELP_STEM)]
74        publishers: Vec<String>,
75        #[arg(
76            long,
77            help = "GitHub token (overrides ANODIZER_GITHUB_TOKEN / GITHUB_TOKEN env vars)"
78        )]
79        token: Option<String>,
80        #[arg(
81            long,
82            default_value = "60m",
83            help = "Pipeline timeout duration (e.g., 60m, 1h, 5s)"
84        )]
85        timeout: String,
86        #[arg(
87            long,
88            short = 'p',
89            help = "Maximum number of parallel build jobs (default: number of CPUs)"
90        )]
91        parallelism: Option<usize>,
92        #[arg(long, help = "Automatically set --snapshot if the git repo is dirty")]
93        auto_snapshot: bool,
94        #[arg(long, help = "Build only for the host target triple")]
95        single_target: bool,
96        #[arg(
97            long,
98            value_name = "csv",
99            conflicts_with = "single_target",
100            help = "Restrict the build to a comma-separated subset of configured target triples (e.g. x86_64-apple-darwin,aarch64-apple-darwin). Used by the Determinism Harness's sharded job matrix; conflicts with --single-target."
101        )]
102        targets: Option<String>,
103        #[arg(
104            long = "host-targets",
105            conflicts_with = "single_target",
106            conflicts_with = "targets",
107            help = "Build every configured target this host can build, skipping cross-compile-only targets (apple targets on a non-macOS host). Only valid with --snapshot or --dry-run. Used by `task prepush` to do a real host-scoped build without aborting on un-buildable targets."
108        )]
109        host_targets: bool,
110        #[arg(
111            long,
112            help = "Path to a custom release notes file (overrides changelog)"
113        )]
114        release_notes: Option<PathBuf>,
115        #[arg(
116            long,
117            conflicts_with = "crate_names",
118            help = "Release a specific workspace in a monorepo config"
119        )]
120        workspace: Option<String>,
121        #[arg(
122            long,
123            conflicts_with = "no_preflight",
124            help = "Run pre-flight publisher-state check and exit (don't start the pipeline)"
125        )]
126        preflight: bool,
127        #[arg(
128            long,
129            conflicts_with = "preflight",
130            help = "Skip the automatic pre-flight publisher-state check"
131        )]
132        no_preflight: bool,
133        #[arg(
134            long = "preflight-secrets",
135            conflicts_with = "no_preflight",
136            help = "Validate that all required publish secrets / credentials are present (and key material is well-formed) without checking host-local tools — for a central pre-release gate across decoupled CI runners. Checks and exits; does not start the pipeline."
137        )]
138        preflight_secrets: bool,
139        #[arg(
140            long,
141            help = "Alias for --strict (also treats Unknown publisher state as a blocker during pre-flight)"
142        )]
143        strict_preflight: bool,
144        #[arg(long, help = "Set the release as a draft")]
145        draft: bool,
146        #[arg(long, help = "Path to a file containing custom release header text")]
147        release_header: Option<PathBuf>,
148        #[arg(
149            long,
150            help = "Path to a template file for release header (rendered with template variables)"
151        )]
152        release_header_tmpl: Option<PathBuf>,
153        #[arg(long, help = "Path to a file containing custom release footer text")]
154        release_footer: Option<PathBuf>,
155        #[arg(
156            long,
157            help = "Path to a template file for release footer (rendered with template variables)"
158        )]
159        release_footer_tmpl: Option<PathBuf>,
160        #[arg(
161            long,
162            help = "Path to a template file for release notes (rendered with template variables, overrides --release-notes)"
163        )]
164        release_notes_tmpl: Option<PathBuf>,
165        #[arg(long, help = "Abort immediately on first error during publishing")]
166        fail_fast: bool,
167        #[arg(
168            long = "no-gate-submitter",
169            help = "Disable the Submitter gate: dispatch Submitter publishers even when required Assets/Manager publishers failed"
170        )]
171        no_gate_submitter: bool,
172        #[arg(
173            long = "rollback",
174            value_name = "none|best-effort",
175            help = "Rollback policy after publish stage. Defaults to best-effort when preflight is clean, none otherwise."
176        )]
177        rollback: Option<String>,
178        #[arg(
179            long = "simulate-failure",
180            value_name = "publisher",
181            action = clap::ArgAction::Append,
182            hide = true,
183            help = "(TEST HARNESS) Force a named publisher to fail. Gated by ANODIZE_TEST_HARNESS=1."
184        )]
185        simulate_failure: Vec<String>,
186        #[arg(
187            long = "rollback-only",
188            requires = "from_run",
189            conflicts_with = "clean",
190            help = "Skip publish; re-attempt rollback from a prior run report. Requires --from-run=<id>."
191        )]
192        rollback_only: bool,
193        #[arg(
194            long = "from-run",
195            value_name = "id",
196            requires = "rollback_only",
197            value_parser = parse_run_id,
198            help = "Prior run id whose state to load when running --rollback-only. \
199                    Loads <dist>/run-<id>/rollback.json if present (a prior replay's state), \
200                    otherwise <dist>/run-<id>/report.json. Delete rollback.json to force a \
201                    full re-roll. Must match the run_id format written by the release pipeline \
202                    (alphanumeric, dot, dash, underscore; no path separators)."
203        )]
204        from_run: Option<String>,
205        #[arg(
206            long = "allow-rerun",
207            conflicts_with = "rollback_only",
208            help = "DANGEROUS: force publish to proceed even when a prior \
209                    dist/run-<id>/report.json exists for this tag. PR-based publishers \
210                    (homebrew, scoop, nix, krew, MCP) will open DUPLICATE pull requests. \
211                    Recover from partial failures with --rollback-only --from-run=<id> first. \
212                    Cannot be combined with --rollback-only (which has its own idempotency)."
213        )]
214        allow_rerun: bool,
215        #[arg(
216            long = "show-skipped",
217            help = "Show per-crate 'no <publisher> config block' skip lines at default verbosity \
218                    (normally only visible with --debug). Use to diagnose why a publisher didn't \
219                    run for a given crate."
220        )]
221        show_skipped: bool,
222        #[arg(
223            long = "allow-nondeterministic",
224            value_name = "name=reason",
225            action = clap::ArgAction::Append,
226            help = "Runtime non-determinism opt-out for a specific artifact (repeatable). Mutually exclusive with --strict."
227        )]
228        allow_nondeterministic: Vec<String>,
229        #[arg(
230            long = "summary-json",
231            value_name = "path",
232            help = "Write the per-publisher run summary JSON to this path. Without it, real (non-snapshot, non-dry-run) releases write <dist>/run-<id>/summary.json — even when a stage fails — so recovery tooling always has machine-readable publish state."
233        )]
234        summary_json: Option<PathBuf>,
235        #[arg(
236            long = "allow-ai-failure",
237            help = "If `changelog.ai` is configured and the AI provider fails, log a warning and keep the pre-AI release notes instead of aborting the release."
238        )]
239        allow_ai_failure: bool,
240        #[arg(
241            long = "allow-snapshot-publish",
242            help = "DANGEROUS: allow publishing a non-release version (snapshot / dirty / 0.0.0-sentinel, e.g. 0.0.0~SNAPSHOT-<sha>) to external publishers. By default the publish, blob, and announce stages refuse such versions — several indexes (crates.io, Cloudsmith, Chocolatey, winget, AUR) are one-way doors. Use ONLY for a private/test channel."
243        )]
244        allow_snapshot_publish: bool,
245        #[arg(
246            long,
247            conflicts_with = "merge",
248            help = "Run only the build stage for split CI fan-out (outputs artifacts JSON to dist/)"
249        )]
250        split: bool,
251        #[arg(
252            long,
253            conflicts_with = "split",
254            help = "Merge artifacts from split build jobs and resume the pipeline from post-build stages"
255        )]
256        merge: bool,
257        #[arg(
258            long = "publish-only",
259            conflicts_with_all = ["split", "merge", "prepare", "announce_only", "snapshot", "rollback_only", "clean"],
260            help = "Load artifacts from dist/ (preserved by `anodize check determinism --preserve-dist`) and run only the sign + publish pipeline. Skips build/archive/nfpm/sbom/checksum — those stages' outputs must already be present in dist/."
261        )]
262        publish_only: bool,
263        #[arg(
264            long,
265            alias = "prepare-only",
266            conflicts_with_all = ["publish_only", "announce_only", "rollback_only"],
267            help = "Run local build + archive + sign + checksum + sbom stages but skip release / publish / announce (GoReleaser Pro parity). Artifacts stay in dist/ for inspection. `--prepare-only` is accepted as an alias for GR-imported scripts."
268        )]
269        prepare: bool,
270        #[arg(
271            long = "announce-only",
272            conflicts_with_all = ["prepare", "publish_only", "snapshot", "rollback_only", "split", "merge", "clean"],
273            help = "Re-fire announcers only. Loads `<dist>/run-<id>/report.json` written by a prior run, skips every pipeline stage except announce (which itself short-circuits on nightly), then runs after-hooks. Use this to retry a transient announcer failure (Slack 502, Discord 5xx) without re-creating the GitHub release or re-publishing to package managers. Fails fast when no `<dist>/run-<id>/report.json` is present."
274        )]
275        announce_only: bool,
276        #[arg(
277            long,
278            help = "Resume into an existing release left over from a prior failed attempt; bypasses the safety check that bails on partial assets."
279        )]
280        resume_release: bool,
281        #[arg(
282            long,
283            help = "Force release.replace_existing_artifacts: true regardless of config (overwrite conflicting assets on retry)."
284        )]
285        replace_existing: bool,
286        #[arg(
287            long = "no-post-publish-poll",
288            help = "Skip post-publish polling for chocolatey moderation / winget PR validation; report NotPolled for affected publishers."
289        )]
290        no_post_publish_poll: bool,
291        #[arg(
292            long = "no-failure-policy",
293            hide = true,
294            help = "(HARNESS) Disable the release.on_failure rollback/hold policy. Set by the determinism harness, whose hermetic replica builds nothing upstream and must surface a stage failure plainly without touching tags or the source repo."
295        )]
296        no_failure_policy: bool,
297    },
298    /// Build binaries only (always runs in snapshot mode)
299    Build {
300        #[arg(long = "crate", action = clap::ArgAction::Append, help = "Build a specific crate (repeatable)")]
301        crate_names: Vec<String>,
302        #[arg(
303            long,
304            default_value = "60m",
305            help = "Pipeline timeout duration (e.g., 60m, 1h, 5s)"
306        )]
307        timeout: String,
308        #[arg(
309            long,
310            short = 'p',
311            help = "Maximum number of parallel build jobs (default: number of CPUs)"
312        )]
313        parallelism: Option<usize>,
314        #[arg(long, help = "Build only for the host target triple")]
315        single_target: bool,
316        #[arg(
317            long,
318            conflicts_with = "crate_names",
319            help = "Build a specific workspace in a monorepo config"
320        )]
321        workspace: Option<String>,
322        #[arg(
323            long,
324            short = 'o',
325            help = "Copy the built binary to this path (requires --single-target and single crate)"
326        )]
327        output: Option<PathBuf>,
328        #[arg(
329            long,
330            value_delimiter = ',',
331            help = "Skip stages (comma-separated: pre-hooks, post-hooks, validate, before)"
332        )]
333        skip: Vec<String>,
334    },
335    /// Validate configuration and run determinism checks
336    Check {
337        #[command(subcommand)]
338        cmd: CheckCmd,
339    },
340    /// Generate starter config, or enroll version-bearing files
341    Init {
342        #[arg(
343            long,
344            help = "Discover repo files that embed the current version and enroll the selection into version_files in .anodizer.yaml"
345        )]
346        version_files: bool,
347        #[arg(
348            long,
349            value_delimiter = ',',
350            requires = "version_files",
351            help = "Glob(s) to drop from discovered candidates (repeatable or comma-separated); only with --version-files"
352        )]
353        exclude: Vec<String>,
354        #[arg(
355            long,
356            short = 'y',
357            requires = "version_files",
358            help = "Non-interactive: enroll all discovered candidates without prompting"
359        )]
360        yes: bool,
361    },
362    /// Manage CHANGELOG.md: refresh the pending section, or render notes/JSON
363    Changelog {
364        #[arg(
365            value_name = "tag|range",
366            help = "Commit range to render: a single tag (predecessor-resolved against its crate), an explicit `from..to` range, or omitted to refresh each crate's pending section against its last tag"
367        )]
368        range: Option<String>,
369        #[arg(
370            long,
371            value_enum,
372            default_value = "keep-a-changelog",
373            help = "Output format: keep-a-changelog (refresh the [Unreleased] section), release-notes (grouped-bullet GitHub body to stdout), or json"
374        )]
375        format: ChangelogFormat,
376        #[arg(
377            long,
378            help = "Apply the regenerated [Unreleased] section to the configured CHANGELOG.md file(s) in place (keep-a-changelog only)"
379        )]
380        write: bool,
381        #[arg(long = "crate", help = "Restrict to a specific crate in a workspace")]
382        crate_name: Option<String>,
383        #[arg(
384            long,
385            help = "Preview as a snapshot release (release-notes format only)"
386        )]
387        snapshot: bool,
388    },
389    /// Generate shell completions
390    Completion {
391        #[arg(value_enum, help = "Shell to generate completions for")]
392        shell: Shell,
393    },
394    /// Check availability of required external tools
395    Healthcheck,
396    /// Verify the environment can run the configured release: required
397    /// tools, env vars/secrets (presence only — values are never printed),
398    /// endpoint reachability, docker daemon, and loadable key material,
399    /// all derived from the resolved config. Every failure is reported in
400    /// one pass and the exit code is non-zero when anything is missing.
401    /// The same checks run automatically at the start of `anodizer release`.
402    Preflight {
403        #[arg(long, help = "Output the report as JSON")]
404        json: bool,
405        #[arg(
406            long,
407            help = "Check only the publish-time surface (the stages `release --publish-only` runs), not artifact-producing stages"
408        )]
409        publish_only: bool,
410        #[arg(
411            long,
412            value_delimiter = ',',
413            help = "Skip requirement collection for these stages (comma-separated, same names as release --skip)"
414        )]
415        skip: Vec<String>,
416        #[arg(long = "publishers", value_delimiter = ',', help = PUBLISHERS_HELP_STEM)]
417        publishers: Vec<String>,
418        #[arg(
419            long,
420            hide_env_values = true,
421            help = "GitHub token override; when set, GitHub token env-var requirements are treated as satisfied"
422        )]
423        token: Option<String>,
424    },
425    /// Generate man pages to stdout
426    Man,
427    /// Output JSON Schema for .anodizer.yaml
428    Jsonschema,
429    /// Resolve a git tag to its matching crate in the config
430    ResolveTag {
431        #[arg(help = "Tag to resolve (e.g. 'v1.2.3', 'core-v0.2.3')")]
432        tag: String,
433        #[arg(long, help = "Output as JSON")]
434        json: bool,
435    },
436    /// Emit the configured build targets as a GitHub Actions matrix.
437    ///
438    /// Derives `{os, target, artifact}` entries from `.anodizer.yaml`.
439    /// Consumed by `anodizer-action`'s `split-matrix` output to feed a
440    /// `strategy.matrix` dynamically (via `fromJson`).
441    Targets {
442        #[arg(long, help = "Output as JSON (include-form matrix)")]
443        json: bool,
444        #[arg(long = "crate", action = clap::ArgAction::Append, help = "Restrict to specific crate(s)")]
445        crate_names: Vec<String>,
446    },
447    /// Auto-tag based on commit message directives
448    Tag {
449        #[arg(long, help = "Show what tag would be created without pushing")]
450        dry_run: bool,
451        #[arg(long, help = "Override bump logic with a specific tag value")]
452        custom_tag: Option<String>,
453        /// Tag exactly this semver version, bypassing autotag derivation and the
454        /// Cargo.toml-ahead guard.
455        ///
456        /// Accepts `1.2.3` or `v1.2.3` (the `v`/configured prefix is normalized).
457        /// The version is applied to the tag AND synced into the relevant
458        /// `Cargo.toml` / `version_files` (single-crate, `--crate`, and lockstep
459        /// modes). In per-crate workspace mode it is rejected unless `--crate
460        /// <name>` selects a single crate — one version across independently
461        /// versioned crates would corrupt their cadences. Intended for release
462        /// recovery where the operator must pin a precise version.
463        #[arg(long = "version", value_name = "VERSION")]
464        version_override: Option<String>,
465        #[arg(long, help = "Override default bump type (patch/minor/major)")]
466        default_bump: Option<String>,
467        #[arg(long = "crate", help = "Tag a specific crate in a workspace")]
468        crate_name: Option<String>,
469        #[arg(
470            long,
471            help = "Push the version-sync bump commit to the release branch atomically with the tag"
472        )]
473        push: bool,
474        #[arg(
475            long,
476            conflicts_with = "push",
477            help = "Push the tag only, leaving the version-sync bump commit local"
478        )]
479        no_push: bool,
480        #[arg(
481            long,
482            value_name = "NAME",
483            help = "Remote to push to (default: origin)"
484        )]
485        push_remote: Option<String>,
486        #[arg(
487            long,
488            help = "Create the tag + bump commit locally but only print (not run) the git push commands --push would use; pass --dry-run to also preview tagging"
489        )]
490        push_dry_run: bool,
491        #[arg(
492            long = "changelog",
493            help = "Refresh CHANGELOG.md as part of this tag (requires a `changelog:` config block)"
494        )]
495        changelog: bool,
496        /// `anodize tag rollback [...]` — failure-recovery counterpart.
497        ///
498        /// Subcommand is optional: bare `anodize tag` keeps its
499        /// existing autotag behavior; only `anodize tag rollback`
500        /// invokes the rollback flow.
501        #[command(subcommand)]
502        sub: Option<TagSub>,
503    },
504    /// Resume a release after a transient failure or after `--prepare`/`--split`
505    ///
506    /// With `--merge`: load every per-target `context.json` under `dist/` (one
507    /// per split-build worker) and run the full post-build pipeline
508    /// (sign / checksum / sbom / release / publish / announce).
509    ///
510    /// Without `--merge`: load existing `dist/` artifacts and run the
511    /// publish-only pipeline (release / publish / blob). Use this to resume
512    /// a single-host release that stalled during publish (e.g. expired
513    /// token, transient 5xx) without rebuilding.
514    ///
515    /// `continue` vs `publish`: both consume a populated `dist/` and run
516    /// the release / publish / blob chain. `continue` is the recommended
517    /// alias for "resume a stalled single-host release" — the
518    /// `continue` command and the in-repo `--prepare` → `continue`
519    /// flow. `publish` is the lower-level entry point that does the same
520    /// thing without the resume framing; prefer `continue` unless you're
521    /// invoking the publish chain on a dist that was never paused. Neither
522    /// is being deprecated.
523    Continue {
524        #[arg(
525            long,
526            help = "Merge artifacts from split build jobs and run post-build stages"
527        )]
528        merge: bool,
529        #[arg(long, help = "Custom dist directory (overrides config)")]
530        dist: Option<PathBuf>,
531        #[arg(long, help = "Run full pipeline without side effects")]
532        dry_run: bool,
533        #[arg(
534            long,
535            value_delimiter = ',',
536            help = "Skip stages or publishers (comma-separated, e.g. docker,announce,npm). \
537                    Unified denylist: a stage name skips the stage, a publisher name \
538                    (npm, homebrew, chocolatey, …) skips that publisher."
539        )]
540        skip: Vec<String>,
541        #[arg(long = "publishers", value_delimiter = ',', help = PUBLISHERS_HELP_STEM)]
542        publishers: Vec<String>,
543        #[arg(
544            long,
545            help = "GitHub token (overrides ANODIZER_GITHUB_TOKEN / GITHUB_TOKEN env vars)"
546        )]
547        token: Option<String>,
548    },
549    /// Run only the publish stages (release, publish, blob) from a completed dist/
550    ///
551    /// `publish` vs `continue`: both consume a populated `dist/` and run
552    /// the same release / publish / blob chain. `publish` is the
553    /// lower-level entry point — no resume framing, no after-hooks /
554    /// milestone closure. `continue` is the recommended alias when
555    /// resuming a stalled single-host release (the
556    /// `continue` command); it additionally invokes the announce
557    /// stage and treats the dist as a paused-release surface. Prefer
558    /// `continue` unless you specifically want the unframed publish
559    /// chain. `--dist` overrides the configured dist directory;
560    /// `release` has no `--dist` because it produces dist.
561    Publish {
562        #[arg(long, help = "Run full pipeline without side effects")]
563        dry_run: bool,
564        #[arg(
565            long,
566            help = "GitHub token (overrides ANODIZER_GITHUB_TOKEN / GITHUB_TOKEN env vars)"
567        )]
568        token: Option<String>,
569        #[arg(long, help = "Custom dist directory (overrides config)")]
570        dist: Option<PathBuf>,
571        #[arg(
572            long,
573            help = "Merge artifacts from `release --split` workers (dist/<subdir>/context.json) before running the publish-only pipeline. Mirrors `goreleaser publish --merge`."
574        )]
575        merge: bool,
576        #[arg(
577            long,
578            help = "Force re-publish even when a prior report.json exists. \
579                    WARNING: PR-based publishers will open duplicate pull requests."
580        )]
581        allow_rerun: bool,
582        #[arg(
583            long = "show-skipped",
584            help = "Show per-crate 'no <publisher> config block' skip lines at default verbosity \
585                    (normally only visible with --debug). Use to diagnose why a publisher didn't \
586                    run for a given crate."
587        )]
588        show_skipped: bool,
589        #[arg(
590            long,
591            value_delimiter = ',',
592            help = "Skip stages or publishers (comma-separated, e.g. npm,blob). \
593                    Unified denylist: a stage name skips the stage, a publisher name \
594                    (npm, homebrew, chocolatey, …) skips that publisher."
595        )]
596        skip: Vec<String>,
597        #[arg(long = "publishers", value_delimiter = ',', help = PUBLISHERS_HELP_STEM)]
598        publishers: Vec<String>,
599    },
600    /// Bump crate versions (Conventional Commits → semver level)
601    ///
602    /// Infers the per-crate level from commits since each crate's last tag
603    /// when no positional argument is given. `patch|minor|major`, an explicit
604    /// version, or `release` (strip prerelease) are also accepted.
605    Bump {
606        #[arg(help = "patch | minor | major | <version> | release (omit to infer)")]
607        level_or_version: Option<String>,
608        #[arg(
609            long,
610            short = 'p',
611            visible_alias = "crate",
612            action = clap::ArgAction::Append,
613            help = "Bump a specific crate (repeatable)"
614        )]
615        package: Vec<String>,
616        #[arg(
617            long,
618            alias = "all",
619            conflicts_with = "package",
620            help = "Bump every workspace member (excluding publish=false)"
621        )]
622        workspace: bool,
623        #[arg(
624            long,
625            action = clap::ArgAction::Append,
626            help = "Exclude a crate from --workspace (repeatable)"
627        )]
628        exclude: Vec<String>,
629        #[arg(long, help = "Append a prerelease identifier (e.g. rc.1)")]
630        pre: Option<String>,
631        #[arg(long, help = "Do not rewrite dependents' [dependencies] version specs")]
632        exact: bool,
633        #[arg(
634            long,
635            help = "Proceed even if the working tree has uncommitted changes"
636        )]
637        allow_dirty: bool,
638        #[arg(long, short = 'y', help = "Skip confirmation prompt")]
639        yes: bool,
640        #[arg(long, help = "Print the plan without editing any files")]
641        dry_run: bool,
642        #[arg(long, help = "Stage edits and create a single commit")]
643        commit: bool,
644        #[arg(
645            long = "changelog",
646            requires = "commit",
647            help = "Refresh CHANGELOG.md in the bump commit (requires --commit and a `changelog:` config block)"
648        )]
649        changelog: bool,
650        #[arg(
651            long,
652            requires = "commit",
653            help = "GPG-sign the commit (requires --commit)"
654        )]
655        sign: bool,
656        #[arg(long, help = "Override the default commit message template")]
657        commit_message: Option<String>,
658        #[arg(
659            long,
660            default_value = "text",
661            help = "Output format: text | json (json requires --dry-run)"
662        )]
663        output: String,
664    },
665    /// Run only the announce stage from a completed dist/
666    ///
667    /// Counterpart to `release --announce-only`: both re-fire announcers
668    /// against a populated dist without re-publishing. The subcommand
669    /// form (`anodizer announce`) accepts `--dist` to point at a
670    /// non-default tree (e.g. preserved by `--preserve-dist`); the flag
671    /// form (`release --announce-only`) operates on the dist configured
672    /// in `.anodizer.yaml`. Both honor nightly short-circuit.
673    Announce {
674        #[arg(long, help = "Run full pipeline without side effects")]
675        dry_run: bool,
676        #[arg(long, help = "Custom dist directory (overrides config)")]
677        dist: Option<PathBuf>,
678        #[arg(
679            long,
680            help = "GitHub token (overrides ANODIZER_GITHUB_TOKEN / GITHUB_TOKEN env vars)"
681        )]
682        token: Option<String>,
683        #[arg(long, value_delimiter = ',', help = "Skip stages (comma-separated)")]
684        skip: Vec<String>,
685        #[arg(
686            long,
687            help = "Merge artifact lists from `release --split` workers (dist/<subdir>/context.json) before announcing. Mirrors `goreleaser announce --merge`."
688        )]
689        merge: bool,
690    },
691    /// Send a notification through configured announce integrations.
692    ///
693    /// Fires configured announce integrations (slack, discord, webhook, …) with
694    /// a Tera-rendered message. Unlike `announce`, this command does not require
695    /// a `dist/` directory — it is intended for ad-hoc notifications outside the
696    /// release pipeline (e.g. CI status alerts, deployment notices).
697    Notify {
698        /// Message template to send. Supports standard Tera template vars
699        /// (e.g. `{{ ProjectName }}`, `{{ Tag }}`, `{{ Version }}`).
700        message: String,
701        /// Comma-separated list of integration names to fire (default: all).
702        /// Valid names: discord, discourse, slack, webhook, telegram, teams,
703        /// mattermost, reddit, twitter, mastodon, bluesky, linkedin.
704        #[arg(long = "publishers", value_delimiter = ',')]
705        publishers: Vec<String>,
706        /// Comma-separated list of integration names to omit.
707        #[arg(long = "skip", value_delimiter = ',')]
708        skip: Vec<String>,
709        /// Send the message literally, without Tera template rendering. Use
710        /// when the message contains untrusted text (e.g. error output in an
711        /// on_error hook).
712        #[arg(long)]
713        raw: bool,
714        /// Send secrets in the message body verbatim (disable outbound
715        /// redaction). For trusted private channels only; log output stays
716        /// redacted.
717        #[arg(long = "allow-secrets")]
718        allow_secrets: bool,
719        /// Run without sending (dry-run mode).
720        #[arg(long)]
721        dry_run: bool,
722    },
723}
724
725/// Output format for `anodizer changelog`.
726#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)]
727pub enum ChangelogFormat {
728    /// Regenerate the `## [Unreleased]` section(s) of the configured
729    /// `CHANGELOG.md` file(s) (the default). Previews to stdout; writes in
730    /// place with `--write`.
731    #[default]
732    #[value(name = "keep-a-changelog", alias = "kac")]
733    KeepAChangelog,
734    /// GitHub-release-body markdown (grouped bullets) for the resolved range,
735    /// to stdout. The historical `anodizer changelog` behavior.
736    ReleaseNotes,
737    /// Machine-readable JSON array of `{ crate, from, to, groups }` objects,
738    /// one per selected crate, sorted by crate name.
739    Json,
740}
741
742/// `anodize tag` parent subcommand.
743///
744/// Bare `anodize tag` keeps its existing autotag behavior (handled
745/// by the `Tag` variant directly). `anodize tag rollback` opts into
746/// the failure-recovery flow described in
747/// [`commands::tag::rollback`].
748#[derive(Subcommand)]
749pub enum TagSub {
750    /// Rollback anodize-managed tags at a SHA, then revert (or reset
751    /// past) the bump commit they point at.
752    Rollback {
753        #[arg(
754            value_name = "sha",
755            help = "Commit SHA to roll back from. Defaults to HEAD."
756        )]
757        sha: Option<String>,
758        #[arg(long, help = "Print what would happen without mutating anything")]
759        dry_run: bool,
760        #[arg(
761            long = "no-push",
762            help = "Skip remote tag delete and branch push (local-only)"
763        )]
764        no_push: bool,
765        #[arg(
766            long,
767            help = "Override the published-state guard: roll back even when the tag's run summary shows a one-way-door publisher (crates.io, chocolatey, winget, snapcraft, ...) accepted the version, or — when no summary exists — when a published (non-draft) GitHub release exists for the tag. Without it, rollback refuses because those registries never accept the same version twice: the version is burned and the only clean recovery is fixing forward"
768        )]
769        force: bool,
770        #[arg(
771            long,
772            default_value = "all",
773            help = "Tag-shape filter: all | lockstep | per-crate"
774        )]
775        scope: String,
776        #[arg(
777            long,
778            default_value = "revert",
779            help = "Rollback strategy: revert (default; history-preserving) | reset (opt-in; rewrites history, requires --force-with-lease to push)"
780        )]
781        mode: String,
782        #[arg(
783            long,
784            value_name = "name",
785            help = "Branch name to push the revert commit to. Required when HEAD is detached and no local branch points at it (typical CI tag-push context, where GITHUB_REF_NAME is the tag — not the bump-commit branch). Pass --branch master (or whichever branch the bump commit was created on)."
786        )]
787        branch: Option<String>,
788    },
789}
790
791/// `anodize check` parent subcommand.
792///
793/// `Config` is the historic `check` body (validate `.anodizer.yaml`); the
794/// determinism harness is plumbed here so the flag set ships with this
795/// commit, but the body lands in a follow-up task.
796#[derive(Subcommand)]
797pub enum CheckCmd {
798    /// Validate the workspace's anodize config.
799    Config {
800        #[arg(long, help = "Validate a specific workspace in a monorepo config")]
801        workspace: Option<String>,
802        #[arg(
803            long,
804            value_delimiter = ',',
805            help = "Validate these skip tokens (stages or publishers) against the known set \
806                    without running anything (comma-separated). Unified denylist: a stage name \
807                    skips the stage, a publisher name (npm, homebrew, chocolatey, …) skips \
808                    that publisher."
809        )]
810        skip: Vec<String>,
811        #[arg(
812            long = "publishers",
813            value_delimiter = ',',
814            help = concat!(
815                "Validate-only: check that each name is a publisher the active config \
816                 actually enables (a known but unconfigured publisher is rejected). ",
817                "Comma-separated publishers to run (default: all configured). \
818                 --skip always wins over --publishers.",
819            )
820        )]
821        publishers: Vec<String>,
822    },
823    /// Run the determinism harness (build pipeline twice, diff artifacts).
824    Determinism(CheckDeterminismArgs),
825    /// Check that enrolled `version_files` still match each crate's current version.
826    VersionFiles,
827}
828
829#[derive(clap::Args)]
830pub struct CheckDeterminismArgs {
831    #[arg(
832        long,
833        default_value = "2",
834        help = "Number of from-clean rebuilds to diff"
835    )]
836    pub runs: u32,
837    #[arg(
838        long,
839        value_name = "stages",
840        help = "Optional stage subset (build,source,upx,archive,nfpm,makeself,snapcraft,sbom,sign,checksum,cargo-package,docker,msi,nsis,dmg,pkg,srpm,appbundle, plus the `installers` family selector expanding to nfpm,makeself,srpm,msi,nsis,dmg,pkg). The list is also the build filter: stages NOT named here are added to the child release's `--skip=` set, so a stage must be requested to be byte-verified. `cargo-package` is harness-only — drives `cargo package --no-verify --allow-dirty` per workspace member to probe `.crate` byte-stability without hitting a registry. `docker` is harness-only — drives `docker buildx build --output=type=oci,rewrite-timestamp=true,dest=…` against `<repo>/Dockerfile` to probe OCI image byte-stability without pushing to a registry; skipped when `docker buildx` is unavailable or no Dockerfile exists. Installer stages (msi/nsis/dmg/pkg/srpm) are skipped at the gate when their backing tool is absent; `appbundle` is pure file assembly and always runs when requested."
841    )]
842    pub stages: Option<String>,
843    #[arg(
844        long,
845        value_name = "csv",
846        help = "Restrict the harness to a comma-separated subset of configured target triples. Used by the sharded release workflow so each runner only validates targets it can natively build (Linux runner skips macOS targets, etc.). Forwarded to the child `anodize release --snapshot` subprocess."
847    )]
848    pub targets: Option<String>,
849    #[arg(
850        long,
851        value_name = "path",
852        help = "JSON report path; default dist/run-<id>/determinism.json"
853    )]
854    pub report: Option<PathBuf>,
855    #[arg(
856        long,
857        conflicts_with = "no_snapshot",
858        help = "Force snapshot mode on the child release subprocess (artifacts get a `-SNAPSHOT-<sha>` suffix). Default: auto — snapshot off when HEAD is at a tag, on otherwise."
859    )]
860    pub snapshot: bool,
861    #[arg(
862        long = "no-snapshot",
863        conflicts_with = "snapshot",
864        help = "Force snapshot mode OFF on the child release subprocess (artifacts emit the actual release version). Default: auto — see --snapshot."
865    )]
866    pub no_snapshot: bool,
867    #[arg(
868        long = "inject-drift",
869        value_name = "stage",
870        hide = true,
871        help = "(TEST HARNESS) Append 1 random byte to the first artifact emitted by <stage>. Gated by ANODIZE_TEST_HARNESS=1."
872    )]
873    pub inject_drift: Option<String>,
874    #[arg(
875        long = "preserve-dist",
876        value_name = "path",
877        help = "When the harness greens, copy run-0's `<worktree>/dist/**` to <path> and emit `<path>/context.json` describing the artifact set. The release workflow's publish-only path consumes this to ship the determinism step's output directly (eliminates the redundant `build:` recompilation). Local operators can pass this too — useful for inspecting a hermetic dist tree without re-running the release pipeline."
878    )]
879    pub preserve_dist: Option<PathBuf>,
880    #[arg(
881        long = "crate",
882        value_name = "name",
883        help = "When --preserve-dist is set, write the preserved dist tree to \
884                <dest>/<name>/ instead of directly into <dest>/. Used by the \
885                sharded matrix to produce per-crate subdirectories so a \
886                `release --publish-only` job can merge all crates into a single \
887                dist/ without context.json collision."
888    )]
889    pub crate_name: Option<String>,
890}
891
892/// Clap `value_parser` for `--from-run=<id>`.
893///
894/// `run_id` is operator-controlled and is joined directly into a
895/// filesystem path (`<dist>/run-<id>/{report,rollback}.json`) by the
896/// `--rollback-only` replay code. Without this validator,
897/// `--from-run=../../etc/passwd` would resolve to a traversed path on
898/// both read (`report.json`) and write (`rollback.json`) — operator
899/// data-loss potential.
900///
901/// Delegates to [`anodizer_stage_publish::rollback_only::validate_run_id`]
902/// so the rule has a single source of truth (the same validator runs at
903/// the `run_with_publishers` entry point as a defense-in-depth guard).
904fn parse_run_id(s: &str) -> Result<String, String> {
905    anodizer_stage_publish::rollback_only::validate_run_id(s)
906        .map(|()| s.to_string())
907        .map_err(|err| format!("{:#}", err))
908}
909
910/// Detect the host target triple by parsing `rustc -vV` output.
911/// Delegates to `anodizer_core::partial::detect_host_target()`.
912pub fn detect_host_target() -> anyhow::Result<String> {
913    anodizer_core::partial::detect_host_target()
914}
915
916/// Return a sensible default parallelism value (number of logical CPUs, minimum 1).
917pub fn num_cpus() -> usize {
918    std::thread::available_parallelism()
919        .map(|n| n.get())
920        .unwrap_or(4)
921}
922
923/// Build the clap `Command` tree for CLI introspection.
924pub fn build_cli() -> clap::Command {
925    <Cli as clap::CommandFactory>::command()
926}