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(
405            long,
406            hide_env_values = true,
407            help = "GitHub token override; when set, GitHub token env-var requirements are treated as satisfied"
408        )]
409        token: Option<String>,
410    },
411    /// Generate man pages to stdout
412    Man,
413    /// Output JSON Schema for .anodizer.yaml
414    Jsonschema,
415    /// Resolve a git tag to its matching crate in the config
416    ResolveTag {
417        #[arg(help = "Tag to resolve (e.g. 'v1.2.3', 'core-v0.2.3')")]
418        tag: String,
419        #[arg(long, help = "Output as JSON")]
420        json: bool,
421    },
422    /// Emit the configured build targets as a GitHub Actions matrix.
423    ///
424    /// Derives `{os, target, artifact}` entries from `.anodizer.yaml`.
425    /// Consumed by `anodizer-action`'s `split-matrix` output to feed a
426    /// `strategy.matrix` dynamically (via `fromJson`).
427    Targets {
428        #[arg(long, help = "Output as JSON (include-form matrix)")]
429        json: bool,
430        #[arg(long = "crate", action = clap::ArgAction::Append, help = "Restrict to specific crate(s)")]
431        crate_names: Vec<String>,
432    },
433    /// Auto-tag based on commit message directives
434    Tag {
435        #[arg(long, help = "Show what tag would be created without pushing")]
436        dry_run: bool,
437        #[arg(long, help = "Override bump logic with a specific tag value")]
438        custom_tag: Option<String>,
439        /// Tag exactly this semver version, bypassing autotag derivation and the
440        /// Cargo.toml-ahead guard.
441        ///
442        /// Accepts `1.2.3` or `v1.2.3` (the `v`/configured prefix is normalized).
443        /// The version is applied to the tag AND synced into the relevant
444        /// `Cargo.toml` / `version_files` (single-crate, `--crate`, and lockstep
445        /// modes). In per-crate workspace mode it is rejected unless `--crate
446        /// <name>` selects a single crate — one version across independently
447        /// versioned crates would corrupt their cadences. Intended for release
448        /// recovery where the operator must pin a precise version.
449        #[arg(long = "version", value_name = "VERSION")]
450        version_override: Option<String>,
451        #[arg(long, help = "Override default bump type (patch/minor/major)")]
452        default_bump: Option<String>,
453        #[arg(long = "crate", help = "Tag a specific crate in a workspace")]
454        crate_name: Option<String>,
455        #[arg(
456            long,
457            help = "Push the version-sync bump commit to the release branch atomically with the tag"
458        )]
459        push: bool,
460        #[arg(
461            long,
462            conflicts_with = "push",
463            help = "Push the tag only, leaving the version-sync bump commit local"
464        )]
465        no_push: bool,
466        #[arg(
467            long,
468            value_name = "NAME",
469            help = "Remote to push to (default: origin)"
470        )]
471        push_remote: Option<String>,
472        #[arg(
473            long,
474            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"
475        )]
476        push_dry_run: bool,
477        #[arg(
478            long = "changelog",
479            help = "Refresh CHANGELOG.md as part of this tag (requires a `changelog:` config block)"
480        )]
481        changelog: bool,
482        /// `anodize tag rollback [...]` — failure-recovery counterpart.
483        ///
484        /// Subcommand is optional: bare `anodize tag` keeps its
485        /// existing autotag behavior; only `anodize tag rollback`
486        /// invokes the rollback flow.
487        #[command(subcommand)]
488        sub: Option<TagSub>,
489    },
490    /// Resume a release after a transient failure or after `--prepare`/`--split`
491    ///
492    /// With `--merge`: load every per-target `context.json` under `dist/` (one
493    /// per split-build worker) and run the full post-build pipeline
494    /// (sign / checksum / sbom / release / publish / announce).
495    ///
496    /// Without `--merge`: load existing `dist/` artifacts and run the
497    /// publish-only pipeline (release / publish / blob). Use this to resume
498    /// a single-host release that stalled during publish (e.g. expired
499    /// token, transient 5xx) without rebuilding.
500    ///
501    /// `continue` vs `publish`: both consume a populated `dist/` and run
502    /// the release / publish / blob chain. `continue` is the recommended
503    /// alias for "resume a stalled single-host release" — the
504    /// `continue` command and the in-repo `--prepare` → `continue`
505    /// flow. `publish` is the lower-level entry point that does the same
506    /// thing without the resume framing; prefer `continue` unless you're
507    /// invoking the publish chain on a dist that was never paused. Neither
508    /// is being deprecated.
509    Continue {
510        #[arg(
511            long,
512            help = "Merge artifacts from split build jobs and run post-build stages"
513        )]
514        merge: bool,
515        #[arg(long, help = "Custom dist directory (overrides config)")]
516        dist: Option<PathBuf>,
517        #[arg(long, help = "Run full pipeline without side effects")]
518        dry_run: bool,
519        #[arg(
520            long,
521            value_delimiter = ',',
522            help = "Skip stages or publishers (comma-separated, e.g. docker,announce,npm). \
523                    Unified denylist: a stage name skips the stage, a publisher name \
524                    (npm, homebrew, chocolatey, …) skips that publisher."
525        )]
526        skip: Vec<String>,
527        #[arg(long = "publishers", value_delimiter = ',', help = PUBLISHERS_HELP_STEM)]
528        publishers: Vec<String>,
529        #[arg(
530            long,
531            help = "GitHub token (overrides ANODIZER_GITHUB_TOKEN / GITHUB_TOKEN env vars)"
532        )]
533        token: Option<String>,
534    },
535    /// Run only the publish stages (release, publish, blob) from a completed dist/
536    ///
537    /// `publish` vs `continue`: both consume a populated `dist/` and run
538    /// the same release / publish / blob chain. `publish` is the
539    /// lower-level entry point — no resume framing, no after-hooks /
540    /// milestone closure. `continue` is the recommended alias when
541    /// resuming a stalled single-host release (the
542    /// `continue` command); it additionally invokes the announce
543    /// stage and treats the dist as a paused-release surface. Prefer
544    /// `continue` unless you specifically want the unframed publish
545    /// chain. `--dist` overrides the configured dist directory;
546    /// `release` has no `--dist` because it produces dist.
547    Publish {
548        #[arg(long, help = "Run full pipeline without side effects")]
549        dry_run: bool,
550        #[arg(
551            long,
552            help = "GitHub token (overrides ANODIZER_GITHUB_TOKEN / GITHUB_TOKEN env vars)"
553        )]
554        token: Option<String>,
555        #[arg(long, help = "Custom dist directory (overrides config)")]
556        dist: Option<PathBuf>,
557        #[arg(
558            long,
559            help = "Merge artifacts from `release --split` workers (dist/<subdir>/context.json) before running the publish-only pipeline. Mirrors `goreleaser publish --merge`."
560        )]
561        merge: bool,
562        #[arg(
563            long,
564            help = "Force re-publish even when a prior report.json exists. \
565                    WARNING: PR-based publishers will open duplicate pull requests."
566        )]
567        allow_rerun: bool,
568        #[arg(
569            long = "show-skipped",
570            help = "Show per-crate 'no <publisher> config block' skip lines at default verbosity \
571                    (normally only visible with --debug). Use to diagnose why a publisher didn't \
572                    run for a given crate."
573        )]
574        show_skipped: bool,
575        #[arg(
576            long,
577            value_delimiter = ',',
578            help = "Skip stages or publishers (comma-separated, e.g. npm,blob). \
579                    Unified denylist: a stage name skips the stage, a publisher name \
580                    (npm, homebrew, chocolatey, …) skips that publisher."
581        )]
582        skip: Vec<String>,
583        #[arg(long = "publishers", value_delimiter = ',', help = PUBLISHERS_HELP_STEM)]
584        publishers: Vec<String>,
585    },
586    /// Bump crate versions (Conventional Commits → semver level)
587    ///
588    /// Infers the per-crate level from commits since each crate's last tag
589    /// when no positional argument is given. `patch|minor|major`, an explicit
590    /// version, or `release` (strip prerelease) are also accepted.
591    Bump {
592        #[arg(help = "patch | minor | major | <version> | release (omit to infer)")]
593        level_or_version: Option<String>,
594        #[arg(
595            long,
596            short = 'p',
597            visible_alias = "crate",
598            action = clap::ArgAction::Append,
599            help = "Bump a specific crate (repeatable)"
600        )]
601        package: Vec<String>,
602        #[arg(
603            long,
604            alias = "all",
605            conflicts_with = "package",
606            help = "Bump every workspace member (excluding publish=false)"
607        )]
608        workspace: bool,
609        #[arg(
610            long,
611            action = clap::ArgAction::Append,
612            help = "Exclude a crate from --workspace (repeatable)"
613        )]
614        exclude: Vec<String>,
615        #[arg(long, help = "Append a prerelease identifier (e.g. rc.1)")]
616        pre: Option<String>,
617        #[arg(long, help = "Do not rewrite dependents' [dependencies] version specs")]
618        exact: bool,
619        #[arg(
620            long,
621            help = "Proceed even if the working tree has uncommitted changes"
622        )]
623        allow_dirty: bool,
624        #[arg(long, short = 'y', help = "Skip confirmation prompt")]
625        yes: bool,
626        #[arg(long, help = "Print the plan without editing any files")]
627        dry_run: bool,
628        #[arg(long, help = "Stage edits and create a single commit")]
629        commit: bool,
630        #[arg(
631            long = "changelog",
632            requires = "commit",
633            help = "Refresh CHANGELOG.md in the bump commit (requires --commit and a `changelog:` config block)"
634        )]
635        changelog: bool,
636        #[arg(
637            long,
638            requires = "commit",
639            help = "GPG-sign the commit (requires --commit)"
640        )]
641        sign: bool,
642        #[arg(long, help = "Override the default commit message template")]
643        commit_message: Option<String>,
644        #[arg(
645            long,
646            default_value = "text",
647            help = "Output format: text | json (json requires --dry-run)"
648        )]
649        output: String,
650    },
651    /// Run only the announce stage from a completed dist/
652    ///
653    /// Counterpart to `release --announce-only`: both re-fire announcers
654    /// against a populated dist without re-publishing. The subcommand
655    /// form (`anodizer announce`) accepts `--dist` to point at a
656    /// non-default tree (e.g. preserved by `--preserve-dist`); the flag
657    /// form (`release --announce-only`) operates on the dist configured
658    /// in `.anodizer.yaml`. Both honor nightly short-circuit.
659    Announce {
660        #[arg(long, help = "Run full pipeline without side effects")]
661        dry_run: bool,
662        #[arg(long, help = "Custom dist directory (overrides config)")]
663        dist: Option<PathBuf>,
664        #[arg(
665            long,
666            help = "GitHub token (overrides ANODIZER_GITHUB_TOKEN / GITHUB_TOKEN env vars)"
667        )]
668        token: Option<String>,
669        #[arg(long, value_delimiter = ',', help = "Skip stages (comma-separated)")]
670        skip: Vec<String>,
671        #[arg(
672            long,
673            help = "Merge artifact lists from `release --split` workers (dist/<subdir>/context.json) before announcing. Mirrors `goreleaser announce --merge`."
674        )]
675        merge: bool,
676    },
677    /// Send a notification through configured announce integrations.
678    ///
679    /// Fires configured announce integrations (slack, discord, webhook, …) with
680    /// a Tera-rendered message. Unlike `announce`, this command does not require
681    /// a `dist/` directory — it is intended for ad-hoc notifications outside the
682    /// release pipeline (e.g. CI status alerts, deployment notices).
683    Notify {
684        /// Message template to send. Supports standard Tera template vars
685        /// (e.g. `{{ ProjectName }}`, `{{ Tag }}`, `{{ Version }}`).
686        message: String,
687        /// Comma-separated list of integration names to fire (default: all).
688        /// Valid names: discord, discourse, slack, webhook, telegram, teams,
689        /// mattermost, reddit, twitter, mastodon, bluesky, linkedin.
690        #[arg(long = "publishers", value_delimiter = ',')]
691        publishers: Vec<String>,
692        /// Comma-separated list of integration names to omit.
693        #[arg(long = "skip", value_delimiter = ',')]
694        skip: Vec<String>,
695        /// Send the message literally, without Tera template rendering. Use
696        /// when the message contains untrusted text (e.g. error output in an
697        /// on_error hook).
698        #[arg(long)]
699        raw: bool,
700        /// Send secrets in the message body verbatim (disable outbound
701        /// redaction). For trusted private channels only; log output stays
702        /// redacted.
703        #[arg(long = "allow-secrets")]
704        allow_secrets: bool,
705        /// Run without sending (dry-run mode).
706        #[arg(long)]
707        dry_run: bool,
708    },
709}
710
711/// Output format for `anodizer changelog`.
712#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)]
713pub enum ChangelogFormat {
714    /// Regenerate the `## [Unreleased]` section(s) of the configured
715    /// `CHANGELOG.md` file(s) (the default). Previews to stdout; writes in
716    /// place with `--write`.
717    #[default]
718    #[value(name = "keep-a-changelog", alias = "kac")]
719    KeepAChangelog,
720    /// GitHub-release-body markdown (grouped bullets) for the resolved range,
721    /// to stdout. The historical `anodizer changelog` behavior.
722    ReleaseNotes,
723    /// Machine-readable JSON array of `{ crate, from, to, groups }` objects,
724    /// one per selected crate, sorted by crate name.
725    Json,
726}
727
728/// `anodize tag` parent subcommand.
729///
730/// Bare `anodize tag` keeps its existing autotag behavior (handled
731/// by the `Tag` variant directly). `anodize tag rollback` opts into
732/// the failure-recovery flow described in
733/// [`commands::tag::rollback`].
734#[derive(Subcommand)]
735pub enum TagSub {
736    /// Rollback anodize-managed tags at a SHA, then revert (or reset
737    /// past) the bump commit they point at.
738    Rollback {
739        #[arg(
740            value_name = "sha",
741            help = "Commit SHA to roll back from. Defaults to HEAD."
742        )]
743        sha: Option<String>,
744        #[arg(long, help = "Print what would happen without mutating anything")]
745        dry_run: bool,
746        #[arg(
747            long = "no-push",
748            help = "Skip remote tag delete and branch push (local-only)"
749        )]
750        no_push: bool,
751        #[arg(
752            long,
753            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"
754        )]
755        force: bool,
756        #[arg(
757            long,
758            default_value = "all",
759            help = "Tag-shape filter: all | lockstep | per-crate"
760        )]
761        scope: String,
762        #[arg(
763            long,
764            default_value = "revert",
765            help = "Rollback strategy: revert (default; history-preserving) | reset (opt-in; rewrites history, requires --force-with-lease to push)"
766        )]
767        mode: String,
768        #[arg(
769            long,
770            value_name = "name",
771            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)."
772        )]
773        branch: Option<String>,
774    },
775}
776
777/// `anodize check` parent subcommand.
778///
779/// `Config` is the historic `check` body (validate `.anodizer.yaml`); the
780/// determinism harness is plumbed here so the flag set ships with this
781/// commit, but the body lands in a follow-up task.
782#[derive(Subcommand)]
783pub enum CheckCmd {
784    /// Validate the workspace's anodize config.
785    Config {
786        #[arg(long, help = "Validate a specific workspace in a monorepo config")]
787        workspace: Option<String>,
788        #[arg(
789            long,
790            value_delimiter = ',',
791            help = "Validate these skip tokens (stages or publishers) against the known set \
792                    without running anything (comma-separated). Unified denylist: a stage name \
793                    skips the stage, a publisher name (npm, homebrew, chocolatey, …) skips \
794                    that publisher."
795        )]
796        skip: Vec<String>,
797        #[arg(
798            long = "publishers",
799            value_delimiter = ',',
800            help = concat!(
801                "Validate-only: check that each name is a publisher the active config \
802                 actually enables (a known but unconfigured publisher is rejected). ",
803                "Comma-separated publishers to run (default: all configured). \
804                 --skip always wins over --publishers.",
805            )
806        )]
807        publishers: Vec<String>,
808    },
809    /// Run the determinism harness (build pipeline twice, diff artifacts).
810    Determinism(CheckDeterminismArgs),
811    /// Check that enrolled `version_files` still match each crate's current version.
812    VersionFiles,
813}
814
815#[derive(clap::Args)]
816pub struct CheckDeterminismArgs {
817    #[arg(
818        long,
819        default_value = "2",
820        help = "Number of from-clean rebuilds to diff"
821    )]
822    pub runs: u32,
823    #[arg(
824        long,
825        value_name = "stages",
826        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."
827    )]
828    pub stages: Option<String>,
829    #[arg(
830        long,
831        value_name = "csv",
832        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."
833    )]
834    pub targets: Option<String>,
835    #[arg(
836        long,
837        value_name = "path",
838        help = "JSON report path; default dist/run-<id>/determinism.json"
839    )]
840    pub report: Option<PathBuf>,
841    #[arg(
842        long,
843        conflicts_with = "no_snapshot",
844        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."
845    )]
846    pub snapshot: bool,
847    #[arg(
848        long = "no-snapshot",
849        conflicts_with = "snapshot",
850        help = "Force snapshot mode OFF on the child release subprocess (artifacts emit the actual release version). Default: auto — see --snapshot."
851    )]
852    pub no_snapshot: bool,
853    #[arg(
854        long = "inject-drift",
855        value_name = "stage",
856        hide = true,
857        help = "(TEST HARNESS) Append 1 random byte to the first artifact emitted by <stage>. Gated by ANODIZE_TEST_HARNESS=1."
858    )]
859    pub inject_drift: Option<String>,
860    #[arg(
861        long = "preserve-dist",
862        value_name = "path",
863        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."
864    )]
865    pub preserve_dist: Option<PathBuf>,
866    #[arg(
867        long = "crate",
868        value_name = "name",
869        help = "When --preserve-dist is set, write the preserved dist tree to \
870                <dest>/<name>/ instead of directly into <dest>/. Used by the \
871                sharded matrix to produce per-crate subdirectories so a \
872                `release --publish-only` job can merge all crates into a single \
873                dist/ without context.json collision."
874    )]
875    pub crate_name: Option<String>,
876}
877
878/// Clap `value_parser` for `--from-run=<id>`.
879///
880/// `run_id` is operator-controlled and is joined directly into a
881/// filesystem path (`<dist>/run-<id>/{report,rollback}.json`) by the
882/// `--rollback-only` replay code. Without this validator,
883/// `--from-run=../../etc/passwd` would resolve to a traversed path on
884/// both read (`report.json`) and write (`rollback.json`) — operator
885/// data-loss potential.
886///
887/// Delegates to [`anodizer_stage_publish::rollback_only::validate_run_id`]
888/// so the rule has a single source of truth (the same validator runs at
889/// the `run_with_publishers` entry point as a defense-in-depth guard).
890fn parse_run_id(s: &str) -> Result<String, String> {
891    anodizer_stage_publish::rollback_only::validate_run_id(s)
892        .map(|()| s.to_string())
893        .map_err(|err| format!("{:#}", err))
894}
895
896/// Detect the host target triple by parsing `rustc -vV` output.
897/// Delegates to `anodizer_core::partial::detect_host_target()`.
898pub fn detect_host_target() -> anyhow::Result<String> {
899    anodizer_core::partial::detect_host_target()
900}
901
902/// Return a sensible default parallelism value (number of logical CPUs, minimum 1).
903pub fn num_cpus() -> usize {
904    std::thread::available_parallelism()
905        .map(|n| n.get())
906        .unwrap_or(4)
907}
908
909/// Build the clap `Command` tree for CLI introspection.
910pub fn build_cli() -> clap::Command {
911    <Cli as clap::CommandFactory>::command()
912}