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