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