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