Skip to main content

anodizer_cli/
lib.rs

1use clap::{Parser, Subcommand};
2use clap_complete::Shell;
3use std::path::PathBuf;
4
5pub mod mcp;
6pub mod subcommands;
7
8pub use subcommands::{ChangelogFormat, CheckCmd, CheckDeterminismArgs, TagSub};
9
10/// Shared `--publishers` help stem used across `release`, `publish`, and
11/// `check config` so the flag presents one mental model on every command.
12/// `check config` appends its validate-only clause (see its `#[arg]`).
13const PUBLISHERS_HELP_STEM: &str = "Comma-separated publishers to run (default: all configured). \
14     --skip always wins over --publishers.";
15
16/// Shared `--token` help used by every token-taking subcommand, rendered
17/// from the canonical env ladder so the documented override order can never
18/// drift from the order the resolver actually applies.
19static TOKEN_HELP: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
20    format!(
21        "GitHub token (overrides {} env vars)",
22        anodizer_core::git::GITHUB_TOKEN_ENV_LADDER.join(" / ")
23    )
24});
25
26/// `--prepare` help, rendered from `UPSTREAM_STAGES` so the documented skip
27/// set can never drift from the set the flag actually skips.
28static PREPARE_HELP: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
29    format!(
30        "Run local build + archive + sign + checksum + sbom stages but skip every \
31         upstream-reaching stage ({}) — GoReleaser Pro parity. Artifacts stay in dist/ \
32         for inspection. `--prepare-only` is accepted as an alias for GR-imported scripts.",
33        anodizer_core::stages::UPSTREAM_STAGES.join(", ")
34    )
35});
36
37/// The migration every removed automatic-rollback flag on `release` errors
38/// with. Automatic rollback no longer exists, so a CI script still passing
39/// one of these needs to know which of the two replacements it wanted —
40/// stating that in the parse error is the only place the script's owner is
41/// guaranteed to read.
42const REMOVED_ROLLBACK_FLAG_MIGRATION: &str = "removed in favor of convergent re-run: re-running `anodizer release` with the identical \
43     arguments reconciles against what already published and skips it, so a failed release is \
44     recovered by re-running it. To withdraw a release deliberately, use `anodizer tag rollback`. \
45     Drop this flag from the invocation.";
46
47/// `value_parser` for the removed automatic-rollback flags. Always rejects,
48/// turning a stale flag into a parse error that carries the migration
49/// instead of clap's nearest-neighbour flag suggestion.
50fn removed_rollback_flag(_: &str) -> Result<String, String> {
51    Err(REMOVED_ROLLBACK_FLAG_MIGRATION.to_string())
52}
53
54/// The parsed `anodizer` command line: the global flags, and the subcommand
55/// to run.
56#[derive(Parser)]
57#[command(name = "anodizer", version, about = "Release Rust projects with ease")]
58pub struct Cli {
59    #[arg(
60        long,
61        short = 'f',
62        global = true,
63        help = "Path to config file (overrides auto-detection)"
64    )]
65    pub config: Option<PathBuf>,
66    #[arg(long, global = true, help = "Enable verbose output")]
67    pub verbose: bool,
68    #[arg(long, global = true, help = "Enable debug output")]
69    pub debug: bool,
70    #[arg(long, short = 'q', global = true, help = "Suppress non-error output")]
71    pub quiet: bool,
72    #[arg(
73        long,
74        global = true,
75        help = "Strict mode: configured features that silently skip become hard errors"
76    )]
77    pub strict: bool,
78    // Optional so `anodizer` with no args prints help and exits 0. A required
79    // subcommand (non-Option) makes clap emit a "usage" error and exit with
80    // code 2, which package-manager validators (winget's, chocolatey's) treat
81    // as install failure since they smoke-test the installed binary with no
82    // args.
83    #[command(subcommand)]
84    pub command: Option<Commands>,
85}
86
87/// Every `anodizer` subcommand, each variant carrying that command's own
88/// flags.
89#[derive(Subcommand)]
90// The `Release` variant carries one field per CLI flag (~40 fields) so its
91// size dwarfs the other subcommands. Boxing every flag bag would just hide
92// the same fields behind an extra allocation per parse with no callsite
93// win; the enum is allocated once per invocation. Local allow only.
94#[allow(clippy::large_enum_variant)]
95pub enum Commands {
96    /// Run the full release pipeline. Re-running the identical command
97    /// converges on already-published state instead of double-publishing,
98    /// so a re-run is how a failed release is recovered.
99    ///
100    /// Every publisher reconciles against upstream before it acts and skips
101    /// itself when this exact version+content is already there. A publisher
102    /// reporting DIVERGED (the version is live upstream with different
103    /// bytes) is the one case a re-run cannot fix — bump the version. To
104    /// withdraw a release deliberately, use `anodizer tag rollback`.
105    Release {
106        #[arg(long = "crate", visible_alias = "id", action = clap::ArgAction::Append, help = "Release a specific crate (repeatable; --id is accepted as a GoReleaser-compat alias)")]
107        crate_names: Vec<String>,
108        #[arg(long, help = "Release all crates with unreleased changes")]
109        all: bool,
110        #[arg(long, help = "Force release even without unreleased changes")]
111        force: bool,
112        #[arg(long, help = "Build without publishing (snapshot mode)")]
113        snapshot: bool,
114        #[arg(long, help = "Create a nightly release with date-based version")]
115        nightly: bool,
116        #[arg(long, help = "Run full pipeline without side effects")]
117        dry_run: bool,
118        #[arg(long, help = "Remove dist directory before starting")]
119        clean: bool,
120        #[arg(
121            long,
122            value_delimiter = ',',
123            help = "Skip stages or publishers (comma-separated, e.g. docker,announce,npm). \
124                    Unified denylist: a stage name skips the stage, a publisher name \
125                    (npm, homebrew, chocolatey, …) skips that publisher."
126        )]
127        skip: Vec<String>,
128        #[arg(long = "publishers", value_delimiter = ',', help = PUBLISHERS_HELP_STEM)]
129        publishers: Vec<String>,
130        #[arg(
131            long,
132            help = TOKEN_HELP.as_str()
133        )]
134        token: Option<String>,
135        #[arg(
136            long,
137            default_value = "3h",
138            help = "Pipeline timeout duration (e.g., 90m, 3h, 5s) — a generous \
139                    safety backstop, not the primary bound; per-stage bounds \
140                    (e.g. announce.deadline) catch a hung stage in seconds"
141        )]
142        timeout: String,
143        #[arg(
144            long,
145            short = 'p',
146            help = "Maximum number of parallel build jobs (default: number of CPUs)"
147        )]
148        parallelism: Option<usize>,
149        #[arg(long, help = "Automatically set --snapshot if the git repo is dirty")]
150        auto_snapshot: bool,
151        #[arg(long, help = "Build only for the host target triple")]
152        single_target: bool,
153        #[arg(
154            long,
155            value_name = "csv",
156            conflicts_with = "single_target",
157            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."
158        )]
159        targets: Option<String>,
160        #[arg(
161            long = "host-targets",
162            conflicts_with = "single_target",
163            conflicts_with = "targets",
164            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."
165        )]
166        host_targets: bool,
167        #[arg(
168            long,
169            help = "Path to a custom release notes file (overrides changelog)"
170        )]
171        release_notes: Option<PathBuf>,
172        #[arg(
173            long,
174            conflicts_with = "crate_names",
175            help = "Release a specific workspace in a monorepo config"
176        )]
177        workspace: Option<String>,
178        #[arg(
179            long,
180            help = "Run pre-flight publisher-state check and exit (don't start the pipeline)"
181        )]
182        preflight: bool,
183        #[arg(
184            long = "preflight-secrets",
185            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."
186        )]
187        preflight_secrets: bool,
188        #[arg(long, help = "Set the release as a draft")]
189        draft: bool,
190        #[arg(long, help = "Path to a file containing custom release header text")]
191        release_header: Option<PathBuf>,
192        #[arg(
193            long,
194            help = "Path to a template file for release header (rendered with template variables)"
195        )]
196        release_header_tmpl: Option<PathBuf>,
197        #[arg(long, help = "Path to a file containing custom release footer text")]
198        release_footer: Option<PathBuf>,
199        #[arg(
200            long,
201            help = "Path to a template file for release footer (rendered with template variables)"
202        )]
203        release_footer_tmpl: Option<PathBuf>,
204        #[arg(
205            long,
206            help = "Path to a template file for release notes (rendered with template variables, overrides --release-notes)"
207        )]
208        release_notes_tmpl: Option<PathBuf>,
209        #[arg(long, help = "Abort immediately on first error during publishing")]
210        fail_fast: bool,
211        #[arg(
212            long = "no-gate-submitter",
213            help = "Disable the Submitter gate: dispatch Submitter publishers even when required Assets/Manager publishers failed, or when the pre-submitter verify-release check did not pass"
214        )]
215        no_gate_submitter: bool,
216        #[arg(
217            long = "simulate-failure",
218            value_name = "publisher",
219            action = clap::ArgAction::Append,
220            hide = true,
221            help = "(TEST HARNESS) Force a named publisher to fail. Gated by ANODIZE_TEST_HARNESS=1."
222        )]
223        simulate_failure: Vec<String>,
224        #[arg(
225            long = "show-skipped",
226            help = "Show per-crate 'no <publisher> config block' skip lines at default verbosity \
227                    (normally only visible with --debug). Use to diagnose why a publisher didn't \
228                    run for a given crate."
229        )]
230        show_skipped: bool,
231        #[arg(
232            long = "allow-nondeterministic",
233            value_name = "name=reason",
234            action = clap::ArgAction::Append,
235            help = "Runtime non-determinism opt-out for a specific artifact (repeatable). Mutually exclusive with --strict."
236        )]
237        allow_nondeterministic: Vec<String>,
238        #[arg(
239            long = "summary-json",
240            value_name = "path",
241            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."
242        )]
243        summary_json: Option<PathBuf>,
244        #[arg(
245            long = "allow-ai-failure",
246            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."
247        )]
248        allow_ai_failure: bool,
249        #[arg(
250            long = "allow-snapshot-publish",
251            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."
252        )]
253        allow_snapshot_publish: bool,
254        #[arg(
255            long,
256            conflicts_with = "merge",
257            help = "Run only the build stage for split CI fan-out (outputs artifacts JSON to dist/)"
258        )]
259        split: bool,
260        #[arg(
261            long,
262            conflicts_with = "split",
263            help = "Merge artifacts from split build jobs and resume the pipeline from post-build stages"
264        )]
265        merge: bool,
266        #[arg(
267            long = "publish-only",
268            conflicts_with_all = ["split", "merge", "prepare", "announce_only", "snapshot", "clean"],
269            help = "Load artifacts from dist/ (preserved by `anodizer 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/."
270        )]
271        publish_only: bool,
272        #[arg(
273            long,
274            alias = "prepare-only",
275            conflicts_with_all = ["publish_only", "announce_only"],
276            help = PREPARE_HELP.as_str()
277        )]
278        prepare: bool,
279        #[arg(
280            long = "announce-only",
281            conflicts_with_all = ["prepare", "publish_only", "snapshot", "split", "merge", "clean"],
282            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."
283        )]
284        announce_only: bool,
285        #[arg(
286            long,
287            help = "Resume into an existing release left over from a prior failed attempt; bypasses the safety check that bails on partial assets."
288        )]
289        resume_release: bool,
290        #[arg(
291            long,
292            help = "Force release.replace_existing_artifacts: true regardless of config (overwrite conflicting assets on retry)."
293        )]
294        replace_existing: bool,
295        #[arg(
296            long = "no-post-publish-poll",
297            help = "Skip post-publish polling for chocolatey moderation / winget PR validation; report NotPolled for affected publishers."
298        )]
299        no_post_publish_poll: bool,
300        #[arg(
301            long = "no-env-preflight",
302            hide = true,
303            help = "(HARNESS) Skip the environment preflight (tools / secrets / key material). Set by the determinism harness, whose hermetic replica runs in a deliberately credential-less env that the config-derived preflight would correctly reject."
304        )]
305        no_env_preflight: bool,
306        // The four automatic-rollback flags removed with the policy they
307        // drove. Declared (hidden) rather than simply deleted so a stale CI
308        // invocation gets the migration instead of clap's unknown-argument
309        // suggestion, which for `--rollback-only` proposed the unrelated
310        // `--prepare-only`. The value parser always rejects, so these never
311        // carry a value the pipeline could read.
312        #[arg(
313            long = "rollback",
314            value_name = "policy",
315            hide = true,
316            value_parser = removed_rollback_flag,
317            help = "(REMOVED) Automatic rollback policy."
318        )]
319        removed_rollback: Option<String>,
320        #[arg(
321            long = "rollback-only",
322            value_name = "removed",
323            num_args = 0..=1,
324            default_missing_value = "rollback-only",
325            hide = true,
326            value_parser = removed_rollback_flag,
327            help = "(REMOVED) Run only the rollback of a prior run."
328        )]
329        removed_rollback_only: Option<String>,
330        #[arg(
331            long = "from-run",
332            value_name = "run-id",
333            hide = true,
334            value_parser = removed_rollback_flag,
335            help = "(REMOVED) Prior run id to roll back."
336        )]
337        removed_from_run: Option<String>,
338        #[arg(
339            long = "no-failure-policy",
340            value_name = "removed",
341            num_args = 0..=1,
342            default_missing_value = "no-failure-policy",
343            hide = true,
344            value_parser = removed_rollback_flag,
345            help = "(REMOVED) Ignore release.on_failure for this run."
346        )]
347        removed_no_failure_policy: Option<String>,
348    },
349    /// Build binaries only (always runs in snapshot mode)
350    Build {
351        #[arg(long = "crate", action = clap::ArgAction::Append, help = "Build a specific crate (repeatable)")]
352        crate_names: Vec<String>,
353        #[arg(
354            long,
355            default_value = "3h",
356            help = "Pipeline timeout duration (e.g., 90m, 3h, 5s) — a generous \
357                    safety backstop, not the primary bound; per-stage bounds \
358                    (e.g. announce.deadline) catch a hung stage in seconds"
359        )]
360        timeout: String,
361        #[arg(
362            long,
363            short = 'p',
364            help = "Maximum number of parallel build jobs (default: number of CPUs)"
365        )]
366        parallelism: Option<usize>,
367        #[arg(long, help = "Build only for the host target triple")]
368        single_target: bool,
369        #[arg(
370            long,
371            conflicts_with = "crate_names",
372            help = "Build a specific workspace in a monorepo config"
373        )]
374        workspace: Option<String>,
375        #[arg(
376            long,
377            short = 'o',
378            help = "Copy the built binary to this path (requires --single-target and single crate)"
379        )]
380        output: Option<PathBuf>,
381        #[arg(
382            long,
383            value_delimiter = ',',
384            help = "Skip hook lanes or stages (comma-separated: before, after, always, on-error, validate, sign, notarize)"
385        )]
386        skip: Vec<String>,
387    },
388    /// Validate configuration and run determinism checks
389    Check {
390        #[command(subcommand)]
391        cmd: CheckCmd,
392    },
393    /// Generate starter config, or enroll version-bearing files
394    Init {
395        #[arg(
396            long,
397            help = "Discover repo files that embed the current version and enroll the selection into version_files in .anodizer.yaml"
398        )]
399        version_files: bool,
400        #[arg(
401            long,
402            value_delimiter = ',',
403            requires = "version_files",
404            help = "Glob(s) to drop from discovered candidates (repeatable or comma-separated); only with --version-files"
405        )]
406        exclude: Vec<String>,
407        #[arg(
408            long,
409            short = 'y',
410            requires = "version_files",
411            help = "Non-interactive: enroll all discovered candidates without prompting"
412        )]
413        yes: bool,
414    },
415    /// Manage CHANGELOG.md: refresh the pending section, or render notes/JSON
416    Changelog {
417        #[arg(
418            value_name = "tag|range",
419            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"
420        )]
421        range: Option<String>,
422        #[arg(
423            long,
424            value_enum,
425            default_value = "keep-a-changelog",
426            help = "Output format: keep-a-changelog (refresh the [Unreleased] section), release-notes (grouped-bullet GitHub body to stdout), or json"
427        )]
428        format: ChangelogFormat,
429        #[arg(
430            long,
431            help = "Apply the regenerated [Unreleased] section to the configured CHANGELOG.md file(s) in place (keep-a-changelog only)"
432        )]
433        write: bool,
434        #[arg(long = "crate", help = "Restrict to a specific crate in a workspace")]
435        crate_name: Option<String>,
436        #[arg(
437            long,
438            help = "Preview as a snapshot release (release-notes format only)"
439        )]
440        snapshot: bool,
441    },
442    /// Generate shell completions
443    Completion {
444        #[arg(value_enum, help = "Shell to generate completions for")]
445        shell: Shell,
446    },
447    /// Check availability of required external tools
448    Healthcheck,
449    /// Verify the environment can run the configured release: required
450    /// tools, env vars/secrets (presence only — values are never printed),
451    /// endpoint reachability, docker daemon, and loadable key material,
452    /// all derived from the resolved config. Every failure is reported in
453    /// one pass and the exit code is non-zero when anything is missing.
454    /// The same checks run automatically at the start of `anodizer release`.
455    /// Also prints the per-publisher reconcile table (is the target version
456    /// already published?); only a required publisher's content divergence
457    /// exits non-zero — an already-complete or unreachable publisher does not.
458    Preflight {
459        #[arg(long, help = "Output the report as JSON")]
460        json: bool,
461        #[arg(
462            long,
463            help = "Check only the publish-time stages (what `release --publish-only` runs), not artifact-producing stages"
464        )]
465        publish_only: bool,
466        #[arg(
467            long,
468            value_delimiter = ',',
469            help = "Skip requirement collection for these stages (comma-separated, same names as release --skip)"
470        )]
471        skip: Vec<String>,
472        #[arg(long = "publishers", value_delimiter = ',', help = PUBLISHERS_HELP_STEM)]
473        publishers: Vec<String>,
474        #[arg(
475            long,
476            help = "GitHub token override; when set, GitHub token env-var requirements are treated as satisfied"
477        )]
478        token: Option<String>,
479    },
480    /// Generate man pages to stdout
481    Man,
482    /// Output JSON Schema for .anodizer.yaml
483    Jsonschema,
484    /// Resolve a git tag to its matching crate in the config
485    ResolveTag {
486        #[arg(help = "Tag to resolve (e.g. 'v1.2.3', 'core-v0.2.3')")]
487        tag: String,
488        #[arg(long, help = "Output as JSON")]
489        json: bool,
490    },
491    /// Emit the configured build targets as a GitHub Actions matrix.
492    ///
493    /// Derives `{os, target, artifact}` entries from `.anodizer.yaml`.
494    /// Consumed by `anodizer-action`'s `split-matrix` output to feed a
495    /// `strategy.matrix` dynamically (via `fromJson`).
496    Targets {
497        #[arg(long, help = "Output as JSON (include-form matrix)")]
498        json: bool,
499        #[arg(long = "crate", action = clap::ArgAction::Append, help = "Restrict to specific crate(s)")]
500        crate_names: Vec<String>,
501    },
502    /// Emit the canonical `--skip` / `--publishers` token vocabulary.
503    ///
504    /// Lists every legal `--skip` / `--publishers` token, each tagged with
505    /// `is_publisher` / `is_publish_stage`, derived from anodizer's publisher
506    /// registry (no hand-maintained list). Consumed by `anodizer-action` so it
507    /// emits only canonical tokens (e.g. `homebrew`, not `homebrew-cask`)
508    /// instead of re-deriving them in shell.
509    Vocabulary {
510        #[arg(long, help = "Output as JSON")]
511        json: bool,
512    },
513    /// Emit the external CLI tools the resolved config's pipeline will invoke.
514    ///
515    /// Derives the tool set from the same per-stage / per-publisher
516    /// requirements the preflight engine checks, so it tracks the config
517    /// exactly. Consumed by `anodizer-action` to decide what to install on a
518    /// runner instead of grepping the config in shell.
519    Tools {
520        #[arg(long, help = "Output as JSON")]
521        json: bool,
522        #[arg(
523            long,
524            help = "Only the tools the publish-time stages need (what `release --publish-only` runs), not artifact-producing stages"
525        )]
526        publish_only: bool,
527        #[arg(
528            long,
529            value_delimiter = ',',
530            help = "Drop tools contributed by these skipped stages (comma-separated, same names as release --skip)"
531        )]
532        skip: Vec<String>,
533        #[arg(long = "publishers", value_delimiter = ',', help = PUBLISHERS_HELP_STEM)]
534        publishers: Vec<String>,
535    },
536    /// Auto-tag based on commit message directives
537    Tag {
538        #[arg(long, help = "Show what tag would be created, without creating it")]
539        dry_run: bool,
540        #[arg(long, help = "Override bump logic with a specific tag value")]
541        custom_tag: Option<String>,
542        /// Tag exactly this semver version, bypassing autotag derivation and the
543        /// Cargo.toml-ahead guard.
544        ///
545        /// Accepts `1.2.3` or `v1.2.3` (the `v`/configured prefix is normalized).
546        /// The version is applied to the tag AND synced into the relevant
547        /// `Cargo.toml` / `version_files` (single-crate, `--crate`, and lockstep
548        /// modes). In per-crate workspace mode it is rejected unless `--crate
549        /// <name>` selects a single crate — one version across independently
550        /// versioned crates would corrupt their cadences. Intended for release
551        /// recovery where the operator must pin a precise version.
552        #[arg(long = "version", value_name = "VERSION")]
553        version_override: Option<String>,
554        #[arg(long, help = "Override default bump type (patch/minor/major)")]
555        default_bump: Option<String>,
556        #[arg(long = "crate", help = "Tag a specific crate in a workspace")]
557        crate_name: Option<String>,
558        #[arg(
559            long,
560            help = "Push the version-sync bump commit to the release branch atomically with the tag"
561        )]
562        push: bool,
563        #[arg(
564            long,
565            conflicts_with = "push",
566            help = "Do not push anything; the tag(s) and version-sync bump commit stay local"
567        )]
568        no_push: bool,
569        #[arg(
570            long,
571            conflicts_with_all = ["push", "no_push"],
572            help = "Push the tag(s) but not the version-sync bump commit (deferred-branch CI pattern; the branch must be advanced to the bump commit separately)"
573        )]
574        push_tags_only: bool,
575        #[arg(
576            long,
577            help = "Create a signed annotated tag (git tag -s), using the signing key/method from git config (user.signingkey, gpg.format)"
578        )]
579        sign: bool,
580        #[arg(
581            long,
582            conflicts_with = "sign",
583            help = "Create an unsigned annotated tag (git tag -a), overriding tag.sign = true in config"
584        )]
585        no_sign: bool,
586        #[arg(
587            long,
588            value_name = "NAME",
589            help = "Remote to push to (default: origin)"
590        )]
591        push_remote: Option<String>,
592        #[arg(
593            long,
594            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"
595        )]
596        push_dry_run: bool,
597        #[arg(
598            long = "changelog",
599            help = "Refresh CHANGELOG.md as part of this tag (requires a `changelog:` config block)"
600        )]
601        changelog: bool,
602        /// `anodizer tag rollback [...]` — failure-recovery counterpart.
603        ///
604        /// Subcommand is optional: bare `anodizer tag` keeps its
605        /// existing autotag behavior; only `anodizer tag rollback`
606        /// invokes the rollback flow.
607        #[command(subcommand)]
608        sub: Option<TagSub>,
609    },
610    /// Resume a release after a transient failure or after `--prepare`/`--split`
611    ///
612    /// With `--merge`: load every per-target `context.json` under `dist/` (one
613    /// per split-build worker) and run the full post-build pipeline
614    /// (sign / checksum / sbom / release / publish / announce).
615    ///
616    /// Without `--merge`: load existing `dist/` artifacts and run the
617    /// publish-only pipeline (release / blob / publish). Use this to resume
618    /// a single-host release that stalled during publish (e.g. expired
619    /// token, transient 5xx) without rebuilding.
620    ///
621    /// `continue` vs `publish`: both consume a populated `dist/` and run
622    /// the release / blob / publish chain. `continue` is the recommended
623    /// alias for "resume a stalled single-host release" — the
624    /// `continue` command and the in-repo `--prepare` → `continue`
625    /// flow. `publish` is the lower-level entry point that does the same
626    /// thing without the resume framing; prefer `continue` unless you're
627    /// invoking the publish chain on a dist that was never paused. Neither
628    /// is being deprecated.
629    Continue {
630        #[arg(
631            long,
632            help = "Merge artifacts from split build jobs and run post-build stages"
633        )]
634        merge: bool,
635        #[arg(long, help = "Custom dist directory (overrides config)")]
636        dist: Option<PathBuf>,
637        #[arg(long, help = "Run full pipeline without side effects")]
638        dry_run: bool,
639        #[arg(
640            long,
641            value_delimiter = ',',
642            help = "Skip stages or publishers (comma-separated, e.g. docker,announce,npm). \
643                    Unified denylist: a stage name skips the stage, a publisher name \
644                    (npm, homebrew, chocolatey, …) skips that publisher."
645        )]
646        skip: Vec<String>,
647        #[arg(long = "publishers", value_delimiter = ',', help = PUBLISHERS_HELP_STEM)]
648        publishers: Vec<String>,
649        #[arg(
650            long,
651            help = TOKEN_HELP.as_str()
652        )]
653        token: Option<String>,
654    },
655    /// Run only the publish stages (release, blob, publish) from a completed dist/
656    ///
657    /// `publish` vs `continue`: both consume a populated `dist/` and run
658    /// the same release / blob / publish chain. `publish` is the
659    /// lower-level entry point — no resume framing, no after-hooks /
660    /// milestone closure. `continue` is the recommended alias when
661    /// resuming a stalled single-host release (the
662    /// `continue` command); it additionally invokes the announce
663    /// stage and treats the dist as a paused-release surface. Prefer
664    /// `continue` unless you specifically want the unframed publish
665    /// chain. `--dist` overrides the configured dist directory;
666    /// `release` has no `--dist` because it produces dist.
667    Publish {
668        #[arg(long, help = "Run full pipeline without side effects")]
669        dry_run: bool,
670        #[arg(
671            long,
672            help = TOKEN_HELP.as_str()
673        )]
674        token: Option<String>,
675        #[arg(long, help = "Custom dist directory (overrides config)")]
676        dist: Option<PathBuf>,
677        #[arg(
678            long,
679            help = "Merge artifacts from `release --split` workers (dist/<subdir>/context.json) before running the publish-only pipeline. Mirrors `goreleaser publish --merge`."
680        )]
681        merge: bool,
682        #[arg(
683            long = "show-skipped",
684            help = "Show per-crate 'no <publisher> config block' skip lines at default verbosity \
685                    (normally only visible with --debug). Use to diagnose why a publisher didn't \
686                    run for a given crate."
687        )]
688        show_skipped: bool,
689        #[arg(
690            long,
691            value_delimiter = ',',
692            help = "Skip stages or publishers (comma-separated, e.g. npm,blob). \
693                    Unified denylist: a stage name skips the stage, a publisher name \
694                    (npm, homebrew, chocolatey, …) skips that publisher."
695        )]
696        skip: Vec<String>,
697        #[arg(long = "publishers", value_delimiter = ',', help = PUBLISHERS_HELP_STEM)]
698        publishers: Vec<String>,
699    },
700    /// Promote an already-published artifact from a pre-release track to a
701    /// stable track, without rebuilding.
702    ///
703    /// A cross-publisher capability (snapcraft channels, npm dist-tags, OCI
704    /// floating tags, GitHub prerelease flips). Reads existing publisher config
705    /// only to learn each publisher's native track vocabulary — there is no
706    /// `promote:` config block (a static one would auto-promote the just-
707    /// uploaded revision every release, defeating the candidate gate).
708    Promote {
709        #[arg(
710            long,
711            help = "Destination track: stable | prerelease | candidate | beta | edge, \
712                    or a publisher-native track name (passed through verbatim)."
713        )]
714        to: String,
715        #[arg(
716            long,
717            help = "Source track (default: prerelease — each publisher's pre-stable track). \
718                    Canonical or publisher-native."
719        )]
720        from: Option<String>,
721        #[arg(
722            long = "publishers",
723            value_delimiter = ',',
724            help = "Comma-separated promotion-capable publishers to run (default: all \
725                    configured). Naming a configured-but-not-promotable publisher is an error."
726        )]
727        publishers: Vec<String>,
728        // `long = "version"` with a distinct field id (not `version`): the doc
729        // generator skips any arg whose clap id is literally `version` (the
730        // global version flag), so the field is named `version_selector` to
731        // keep the user-facing `--version` flag documented, matching `tag`'s
732        // `version_override`.
733        #[arg(
734            long = "version",
735            value_name = "VERSION",
736            conflicts_with = "from_run",
737            help = "Promote this explicit version/tag (default: the newest artifact in the \
738                    --from track)."
739        )]
740        version_selector: Option<String>,
741        #[arg(
742            long = "from-run",
743            value_parser = parse_run_id,
744            help = "Promote what a prior release run recorded (reads dist/run-<id>/report.json)."
745        )]
746        from_run: Option<String>,
747        #[arg(
748            long,
749            help = "Resolve and print the plan without running any external command"
750        )]
751        dry_run: bool,
752    },
753    /// Bump crate versions (Conventional Commits → semver level)
754    ///
755    /// Infers the per-crate level from commits since each crate's last tag
756    /// when no positional argument is given. `patch|minor|major`, an explicit
757    /// version, or `release` (strip prerelease) are also accepted.
758    Bump {
759        #[arg(help = "patch | minor | major | <version> | release (omit to infer)")]
760        level_or_version: Option<String>,
761        #[arg(
762            long,
763            short = 'p',
764            visible_alias = "crate",
765            action = clap::ArgAction::Append,
766            help = "Bump a specific crate (repeatable)"
767        )]
768        package: Vec<String>,
769        #[arg(
770            long,
771            alias = "all",
772            conflicts_with = "package",
773            help = "Bump every workspace member (excluding publish=false)"
774        )]
775        workspace: bool,
776        #[arg(
777            long,
778            action = clap::ArgAction::Append,
779            help = "Exclude a crate from --workspace (repeatable)"
780        )]
781        exclude: Vec<String>,
782        #[arg(long, help = "Append a prerelease identifier (e.g. rc.1)")]
783        pre: Option<String>,
784        #[arg(long, help = "Do not rewrite dependents' [dependencies] version specs")]
785        exact: bool,
786        #[arg(
787            long,
788            help = "Proceed even if the working tree has uncommitted changes"
789        )]
790        allow_dirty: bool,
791        #[arg(long, short = 'y', help = "Skip confirmation prompt")]
792        yes: bool,
793        #[arg(long, help = "Print the plan without editing any files")]
794        dry_run: bool,
795        #[arg(long, help = "Stage edits and create a single commit")]
796        commit: bool,
797        #[arg(
798            long = "changelog",
799            requires = "commit",
800            help = "Refresh CHANGELOG.md in the bump commit (requires --commit and a `changelog:` config block)"
801        )]
802        changelog: bool,
803        #[arg(
804            long,
805            requires = "commit",
806            help = "GPG-sign the commit (requires --commit)"
807        )]
808        sign: bool,
809        #[arg(long, help = "Override the default commit message template")]
810        commit_message: Option<String>,
811        #[arg(
812            long,
813            default_value = "text",
814            help = "Output format: text | json (json requires --dry-run)"
815        )]
816        output: String,
817    },
818    /// Run only the announce stage from a completed dist/
819    ///
820    /// Counterpart to `release --announce-only`: both re-fire announcers
821    /// against a populated dist without re-publishing. The subcommand
822    /// form (`anodizer announce`) accepts `--dist` to point at a
823    /// non-default tree (e.g. preserved by `--preserve-dist`); the flag
824    /// form (`release --announce-only`) operates on the dist configured
825    /// in `.anodizer.yaml`. Both honor nightly short-circuit.
826    Announce {
827        #[arg(long, help = "Run full pipeline without side effects")]
828        dry_run: bool,
829        #[arg(long, help = "Custom dist directory (overrides config)")]
830        dist: Option<PathBuf>,
831        #[arg(
832            long,
833            help = TOKEN_HELP.as_str()
834        )]
835        token: Option<String>,
836        #[arg(long, value_delimiter = ',', help = "Skip stages (comma-separated)")]
837        skip: Vec<String>,
838        #[arg(
839            long,
840            help = "Merge artifact lists from `release --split` workers (dist/<subdir>/context.json) before announcing. Mirrors `goreleaser announce --merge`."
841        )]
842        merge: bool,
843    },
844    /// Send a notification through configured announce integrations.
845    ///
846    /// Fires configured announce integrations (slack, discord, webhook, …) with
847    /// a Tera-rendered message. Unlike `announce`, this command does not require
848    /// a `dist/` directory — it is intended for ad-hoc notifications outside the
849    /// release pipeline (e.g. CI status alerts, deployment notices).
850    Notify {
851        /// Message template to send. Supports standard Tera template vars
852        /// (e.g. `{{ ProjectName }}`, `{{ Tag }}`, `{{ Version }}`).
853        message: String,
854        /// Comma-separated list of integration names to fire (default: all).
855        /// Valid names: discord, discourse, slack, webhook, telegram, teams,
856        /// mattermost, reddit, twitter, mastodon, bluesky, linkedin.
857        #[arg(long = "publishers", value_delimiter = ',')]
858        publishers: Vec<String>,
859        /// Comma-separated list of integration names to omit.
860        #[arg(long = "skip", value_delimiter = ',')]
861        skip: Vec<String>,
862        /// Send the message literally, without Tera template rendering. Use
863        /// when the message contains untrusted text (e.g. error output in an
864        /// on_error hook).
865        #[arg(long)]
866        raw: bool,
867        /// Send secrets in the message body verbatim (disable outbound
868        /// redaction). For trusted private channels only; log output stays
869        /// redacted.
870        #[arg(long = "allow-secrets")]
871        allow_secrets: bool,
872        /// Run without sending (dry-run mode).
873        #[arg(long)]
874        dry_run: bool,
875    },
876}
877
878/// Clap `value_parser` for `promote --from-run=<id>`.
879///
880/// `run_id` is operator-controlled and is joined directly into a
881/// filesystem path (`<dist>/run-<id>/report.json`). Without this
882/// validator, `--from-run=../../etc/passwd` would resolve to a traversed
883/// path on read — and the same id shape names the write path
884/// (`rollback.json`) inside the publisher-unwind engine, so the rule is
885/// enforced once for both.
886///
887/// Delegates to [`anodizer_stage_publish::rollback::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::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}