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(long, help = "Set the release as a draft")]
179        draft: bool,
180        #[arg(long, help = "Path to a file containing custom release header text")]
181        release_header: Option<PathBuf>,
182        #[arg(
183            long,
184            help = "Path to a template file for release header (rendered with template variables)"
185        )]
186        release_header_tmpl: Option<PathBuf>,
187        #[arg(long, help = "Path to a file containing custom release footer text")]
188        release_footer: Option<PathBuf>,
189        #[arg(
190            long,
191            help = "Path to a template file for release footer (rendered with template variables)"
192        )]
193        release_footer_tmpl: Option<PathBuf>,
194        #[arg(
195            long,
196            help = "Path to a template file for release notes (rendered with template variables, overrides --release-notes)"
197        )]
198        release_notes_tmpl: Option<PathBuf>,
199        #[arg(long, help = "Abort immediately on first error during publishing")]
200        fail_fast: bool,
201        #[arg(
202            long = "no-gate-submitter",
203            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"
204        )]
205        no_gate_submitter: bool,
206        #[arg(
207            long = "simulate-failure",
208            value_name = "publisher",
209            action = clap::ArgAction::Append,
210            hide = true,
211            help = "(TEST HARNESS) Force a named publisher to fail. Gated by ANODIZE_TEST_HARNESS=1."
212        )]
213        simulate_failure: Vec<String>,
214        #[arg(
215            long = "show-skipped",
216            help = "Show per-crate 'no <publisher> config block' skip lines at default verbosity \
217                    (normally only visible with --debug). Use to diagnose why a publisher didn't \
218                    run for a given crate."
219        )]
220        show_skipped: bool,
221        #[arg(
222            long = "allow-nondeterministic",
223            value_name = "name=reason",
224            action = clap::ArgAction::Append,
225            help = "Runtime non-determinism opt-out for a specific artifact (repeatable). Mutually exclusive with --strict."
226        )]
227        allow_nondeterministic: Vec<String>,
228        #[arg(
229            long = "summary-json",
230            value_name = "path",
231            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."
232        )]
233        summary_json: Option<PathBuf>,
234        #[arg(
235            long = "allow-ai-failure",
236            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."
237        )]
238        allow_ai_failure: bool,
239        #[arg(
240            long = "allow-snapshot-publish",
241            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."
242        )]
243        allow_snapshot_publish: bool,
244        #[arg(
245            long,
246            conflicts_with = "merge",
247            help = "Run only the build stage for split CI fan-out (outputs artifacts JSON to dist/)"
248        )]
249        split: bool,
250        #[arg(
251            long,
252            conflicts_with = "split",
253            help = "Merge artifacts from split build jobs and resume the pipeline from post-build stages"
254        )]
255        merge: bool,
256        #[arg(
257            long = "publish-only",
258            conflicts_with_all = ["split", "merge", "prepare", "announce_only", "snapshot", "clean"],
259            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/."
260        )]
261        publish_only: bool,
262        #[arg(
263            long,
264            alias = "prepare-only",
265            conflicts_with_all = ["publish_only", "announce_only"],
266            help = PREPARE_HELP.as_str()
267        )]
268        prepare: bool,
269        #[arg(
270            long = "announce-only",
271            conflicts_with_all = ["prepare", "publish_only", "snapshot", "split", "merge", "clean"],
272            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."
273        )]
274        announce_only: bool,
275        #[arg(
276            long,
277            help = "Resume into an existing release left over from a prior failed attempt; bypasses the safety check that bails on partial assets."
278        )]
279        resume_release: bool,
280        #[arg(
281            long,
282            help = "Force release.replace_existing_artifacts: true regardless of config (overwrite conflicting assets on retry)."
283        )]
284        replace_existing: bool,
285        #[arg(
286            long = "no-post-publish-poll",
287            help = "Skip post-publish polling for chocolatey moderation / winget PR validation; report NotPolled for affected publishers."
288        )]
289        no_post_publish_poll: bool,
290        // The four automatic-rollback flags removed with the policy they
291        // drove. Declared (hidden) rather than simply deleted so a stale CI
292        // invocation gets the migration instead of clap's unknown-argument
293        // suggestion, which for `--rollback-only` proposed the unrelated
294        // `--prepare-only`. The value parser always rejects, so these never
295        // carry a value the pipeline could read.
296        #[arg(
297            long = "rollback",
298            value_name = "policy",
299            hide = true,
300            value_parser = removed_rollback_flag,
301            help = "(REMOVED) Automatic rollback policy."
302        )]
303        removed_rollback: Option<String>,
304        #[arg(
305            long = "rollback-only",
306            value_name = "removed",
307            num_args = 0..=1,
308            default_missing_value = "rollback-only",
309            hide = true,
310            value_parser = removed_rollback_flag,
311            help = "(REMOVED) Run only the rollback of a prior run."
312        )]
313        removed_rollback_only: Option<String>,
314        #[arg(
315            long = "from-run",
316            value_name = "run-id",
317            hide = true,
318            value_parser = removed_rollback_flag,
319            help = "(REMOVED) Prior run id to roll back."
320        )]
321        removed_from_run: Option<String>,
322        #[arg(
323            long = "no-failure-policy",
324            value_name = "removed",
325            num_args = 0..=1,
326            default_missing_value = "no-failure-policy",
327            hide = true,
328            value_parser = removed_rollback_flag,
329            help = "(REMOVED) Ignore release.on_failure for this run."
330        )]
331        removed_no_failure_policy: Option<String>,
332    },
333    /// Build binaries only (always runs in snapshot mode)
334    Build {
335        #[arg(long = "crate", action = clap::ArgAction::Append, help = "Build a specific crate (repeatable)")]
336        crate_names: Vec<String>,
337        #[arg(
338            long,
339            default_value = "3h",
340            help = "Pipeline timeout duration (e.g., 90m, 3h, 5s) — a generous \
341                    safety backstop, not the primary bound; per-stage bounds \
342                    (e.g. announce.deadline) catch a hung stage in seconds"
343        )]
344        timeout: String,
345        #[arg(
346            long,
347            short = 'p',
348            help = "Maximum number of parallel build jobs (default: number of CPUs)"
349        )]
350        parallelism: Option<usize>,
351        #[arg(long, help = "Build only for the host target triple")]
352        single_target: bool,
353        #[arg(
354            long,
355            conflicts_with = "crate_names",
356            help = "Build a specific workspace in a monorepo config"
357        )]
358        workspace: Option<String>,
359        #[arg(
360            long,
361            short = 'o',
362            help = "Copy the built binary to this path (requires --single-target and single crate)"
363        )]
364        output: Option<PathBuf>,
365        #[arg(
366            long,
367            value_delimiter = ',',
368            help = "Skip hook lanes or stages (comma-separated: before, after, always, on-error, validate, sign, notarize)"
369        )]
370        skip: Vec<String>,
371    },
372    /// Validate configuration and run determinism checks
373    Check {
374        #[command(subcommand)]
375        cmd: CheckCmd,
376    },
377    /// Generate starter config, or enroll version-bearing files
378    Init {
379        #[arg(
380            long,
381            help = "Discover repo files that embed the current version and enroll the selection into version_files in .anodizer.yaml"
382        )]
383        version_files: bool,
384        #[arg(
385            long,
386            value_delimiter = ',',
387            requires = "version_files",
388            help = "Glob(s) to drop from discovered candidates (repeatable or comma-separated); only with --version-files"
389        )]
390        exclude: Vec<String>,
391        #[arg(
392            long,
393            short = 'y',
394            requires = "version_files",
395            help = "Non-interactive: enroll all discovered candidates without prompting"
396        )]
397        yes: bool,
398    },
399    /// Manage CHANGELOG.md: refresh the pending section, or render notes/JSON
400    Changelog {
401        #[arg(
402            value_name = "tag|range",
403            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"
404        )]
405        range: Option<String>,
406        #[arg(
407            long,
408            value_enum,
409            default_value = "keep-a-changelog",
410            help = "Output format: keep-a-changelog (refresh the [Unreleased] section), release-notes (grouped-bullet GitHub body to stdout), or json"
411        )]
412        format: ChangelogFormat,
413        #[arg(
414            long,
415            help = "Apply the regenerated [Unreleased] section to the configured CHANGELOG.md file(s) in place (keep-a-changelog only)"
416        )]
417        write: bool,
418        #[arg(long = "crate", help = "Restrict to a specific crate in a workspace")]
419        crate_name: Option<String>,
420        #[arg(
421            long,
422            help = "Preview as a snapshot release (release-notes format only)"
423        )]
424        snapshot: bool,
425    },
426    /// Generate shell completions
427    Completion {
428        #[arg(value_enum, help = "Shell to generate completions for")]
429        shell: Shell,
430    },
431    /// Check availability of required external tools
432    Healthcheck,
433    /// Run the release preflight without releasing: the environment check
434    /// (required tools, env vars/secrets by presence only — values are never
435    /// printed — endpoint reachability, docker daemon, loadable key
436    /// material), the one-way-door publisher state and credential probes,
437    /// and the per-publisher reconcile table (is the target version already
438    /// published?), all derived from the resolved config. The target version
439    /// is the one this tree would release: the tag at HEAD, or the next
440    /// version `anodizer tag` would cut. Every failure is reported in one
441    /// pass; the exit code is non-zero on a missing requirement, a publisher
442    /// blocker, or a required publisher's content divergence. The same engine
443    /// runs at the start of `anodizer release`, which `--skip=preflight`
444    /// leaves out when a pre-tag job already ran it.
445    Preflight {
446        #[arg(long, help = "Output the report as JSON")]
447        json: bool,
448        #[arg(
449            long,
450            help = "Check only the publish-time stages (what `release --publish-only` runs), not artifact-producing stages"
451        )]
452        publish_only: bool,
453        #[arg(
454            long,
455            value_delimiter = ',',
456            help = "Skip requirement collection for these stages (comma-separated, same names as release --skip)"
457        )]
458        skip: Vec<String>,
459        #[arg(long = "publishers", value_delimiter = ',', help = PUBLISHERS_HELP_STEM)]
460        publishers: Vec<String>,
461        #[arg(
462            long,
463            help = "GitHub token override; when set, GitHub token env-var requirements are treated as satisfied"
464        )]
465        token: Option<String>,
466    },
467    /// Generate man pages to stdout
468    Man,
469    /// Output JSON Schema for .anodizer.yaml
470    Jsonschema,
471    /// Resolve a git tag to its matching crate in the config
472    ResolveTag {
473        #[arg(help = "Tag to resolve (e.g. 'v1.2.3', 'core-v0.2.3')")]
474        tag: String,
475        #[arg(long, help = "Output as JSON")]
476        json: bool,
477    },
478    /// Emit the configured build targets as a GitHub Actions matrix.
479    ///
480    /// Derives `{os, target, artifact}` entries from `.anodizer.yaml`.
481    /// Consumed by `anodizer-action`'s `split-matrix` output to feed a
482    /// `strategy.matrix` dynamically (via `fromJson`).
483    Targets {
484        #[arg(long, help = "Output as JSON (include-form matrix)")]
485        json: bool,
486        #[arg(long = "crate", action = clap::ArgAction::Append, help = "Restrict to specific crate(s)")]
487        crate_names: Vec<String>,
488    },
489    /// Emit the canonical `--skip` / `--publishers` token vocabulary.
490    ///
491    /// Lists every legal `--skip` / `--publishers` token, each tagged with
492    /// `is_publisher` / `is_publish_stage`, derived from anodizer's publisher
493    /// registry (no hand-maintained list). Consumed by `anodizer-action` so it
494    /// emits only canonical tokens (e.g. `homebrew`, not `homebrew-cask`)
495    /// instead of re-deriving them in shell.
496    Vocabulary {
497        #[arg(long, help = "Output as JSON")]
498        json: bool,
499    },
500    /// Emit the external CLI tools the resolved config's pipeline will invoke.
501    ///
502    /// Derives the tool set from the same per-stage / per-publisher
503    /// requirements the preflight engine checks, so it tracks the config
504    /// exactly. Consumed by `anodizer-action` to decide what to install on a
505    /// runner instead of grepping the config in shell.
506    Tools {
507        #[arg(long, help = "Output as JSON")]
508        json: bool,
509        #[arg(
510            long,
511            help = "Only the tools the publish-time stages need (what `release --publish-only` runs), not artifact-producing stages"
512        )]
513        publish_only: bool,
514        #[arg(
515            long,
516            value_delimiter = ',',
517            help = "Drop tools contributed by these skipped stages (comma-separated, same names as release --skip)"
518        )]
519        skip: Vec<String>,
520        #[arg(long = "publishers", value_delimiter = ',', help = PUBLISHERS_HELP_STEM)]
521        publishers: Vec<String>,
522    },
523    /// Auto-tag based on commit message directives
524    Tag {
525        #[arg(long, help = "Show what tag would be created, without creating it")]
526        dry_run: bool,
527        #[arg(long, help = "Override bump logic with a specific tag value")]
528        custom_tag: Option<String>,
529        /// Tag exactly this semver version, bypassing autotag derivation and the
530        /// Cargo.toml-ahead guard.
531        ///
532        /// Accepts `1.2.3` or `v1.2.3` (the `v`/configured prefix is normalized).
533        /// The version is applied to the tag AND synced into the relevant
534        /// `Cargo.toml` / `version_files` (single-crate, `--crate`, and lockstep
535        /// modes). In per-crate workspace mode it is rejected unless `--crate
536        /// <name>` selects a single crate — one version across independently
537        /// versioned crates would corrupt their cadences. Intended for release
538        /// recovery where the operator must pin a precise version.
539        #[arg(long = "version", value_name = "VERSION")]
540        version_override: Option<String>,
541        #[arg(long, help = "Override default bump type (patch/minor/major)")]
542        default_bump: Option<String>,
543        #[arg(long = "crate", help = "Tag a specific crate in a workspace")]
544        crate_name: Option<String>,
545        #[arg(
546            long,
547            help = "Push the version-sync bump commit to the release branch atomically with the tag"
548        )]
549        push: bool,
550        #[arg(
551            long,
552            conflicts_with = "push",
553            help = "Do not push anything; the tag(s) and version-sync bump commit stay local"
554        )]
555        no_push: bool,
556        #[arg(
557            long,
558            conflicts_with_all = ["push", "no_push"],
559            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)"
560        )]
561        push_tags_only: bool,
562        #[arg(
563            long,
564            help = "Create a signed annotated tag (git tag -s), using the signing key/method from git config (user.signingkey, gpg.format)"
565        )]
566        sign: bool,
567        #[arg(
568            long,
569            conflicts_with = "sign",
570            help = "Create an unsigned annotated tag (git tag -a), overriding tag.sign = true in config"
571        )]
572        no_sign: bool,
573        #[arg(
574            long,
575            value_name = "NAME",
576            help = "Remote to push to (default: origin)"
577        )]
578        push_remote: Option<String>,
579        #[arg(
580            long,
581            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"
582        )]
583        push_dry_run: bool,
584        #[arg(
585            long = "changelog",
586            help = "Refresh CHANGELOG.md as part of this tag (requires a `changelog:` config block)"
587        )]
588        changelog: bool,
589        /// `anodizer tag rollback [...]` — failure-recovery counterpart.
590        ///
591        /// Subcommand is optional: bare `anodizer tag` keeps its
592        /// existing autotag behavior; only `anodizer tag rollback`
593        /// invokes the rollback flow.
594        #[command(subcommand)]
595        sub: Option<TagSub>,
596    },
597    /// Resume a release after a transient failure or after `--prepare`/`--split`
598    ///
599    /// With `--merge`: load every per-target `context.json` under `dist/` (one
600    /// per split-build worker) and run the full post-build pipeline
601    /// (sign / checksum / sbom / release / publish / announce).
602    ///
603    /// Without `--merge`: load existing `dist/` artifacts and run the
604    /// publish-only pipeline (release / blob / publish). Use this to resume
605    /// a single-host release that stalled during publish (e.g. expired
606    /// token, transient 5xx) without rebuilding.
607    ///
608    /// `continue` vs `publish`: both consume a populated `dist/` and run
609    /// the release / blob / publish chain. `continue` is the recommended
610    /// alias for "resume a stalled single-host release" — the
611    /// `continue` command and the in-repo `--prepare` → `continue`
612    /// flow. `publish` is the lower-level entry point that does the same
613    /// thing without the resume framing; prefer `continue` unless you're
614    /// invoking the publish chain on a dist that was never paused. Neither
615    /// is being deprecated.
616    Continue {
617        #[arg(
618            long,
619            help = "Merge artifacts from split build jobs and run post-build stages"
620        )]
621        merge: bool,
622        #[arg(long, help = "Custom dist directory (overrides config)")]
623        dist: Option<PathBuf>,
624        #[arg(long, help = "Run full pipeline without side effects")]
625        dry_run: bool,
626        #[arg(
627            long,
628            value_delimiter = ',',
629            help = "Skip stages or publishers (comma-separated, e.g. docker,announce,npm). \
630                    Unified denylist: a stage name skips the stage, a publisher name \
631                    (npm, homebrew, chocolatey, …) skips that publisher."
632        )]
633        skip: Vec<String>,
634        #[arg(long = "publishers", value_delimiter = ',', help = PUBLISHERS_HELP_STEM)]
635        publishers: Vec<String>,
636        #[arg(
637            long,
638            help = TOKEN_HELP.as_str()
639        )]
640        token: Option<String>,
641    },
642    /// Run only the publish stages (release, blob, publish) from a completed dist/
643    ///
644    /// `publish` vs `continue`: both consume a populated `dist/` and run
645    /// the same release / blob / publish chain. `publish` is the
646    /// lower-level entry point — no resume framing, no after-hooks /
647    /// milestone closure. `continue` is the recommended alias when
648    /// resuming a stalled single-host release (the
649    /// `continue` command); it additionally invokes the announce
650    /// stage and treats the dist as a paused-release surface. Prefer
651    /// `continue` unless you specifically want the unframed publish
652    /// chain. `--dist` overrides the configured dist directory;
653    /// `release` has no `--dist` because it produces dist.
654    Publish {
655        #[arg(long, help = "Run full pipeline without side effects")]
656        dry_run: bool,
657        #[arg(
658            long,
659            help = TOKEN_HELP.as_str()
660        )]
661        token: Option<String>,
662        #[arg(long, help = "Custom dist directory (overrides config)")]
663        dist: Option<PathBuf>,
664        #[arg(
665            long,
666            help = "Merge artifacts from `release --split` workers (dist/<subdir>/context.json) before running the publish-only pipeline. Mirrors `goreleaser publish --merge`."
667        )]
668        merge: bool,
669        #[arg(
670            long = "show-skipped",
671            help = "Show per-crate 'no <publisher> config block' skip lines at default verbosity \
672                    (normally only visible with --debug). Use to diagnose why a publisher didn't \
673                    run for a given crate."
674        )]
675        show_skipped: bool,
676        #[arg(
677            long,
678            value_delimiter = ',',
679            help = "Skip stages or publishers (comma-separated, e.g. npm,blob). \
680                    Unified denylist: a stage name skips the stage, a publisher name \
681                    (npm, homebrew, chocolatey, …) skips that publisher."
682        )]
683        skip: Vec<String>,
684        #[arg(long = "publishers", value_delimiter = ',', help = PUBLISHERS_HELP_STEM)]
685        publishers: Vec<String>,
686    },
687    /// Promote an already-published artifact from a pre-release track to a
688    /// stable track, without rebuilding.
689    ///
690    /// A cross-publisher capability (snapcraft channels, npm dist-tags, OCI
691    /// floating tags, GitHub prerelease flips). Reads existing publisher config
692    /// only to learn each publisher's native track vocabulary — there is no
693    /// `promote:` config block (a static one would auto-promote the just-
694    /// uploaded revision every release, defeating the candidate gate).
695    Promote {
696        #[arg(
697            long,
698            help = "Destination track: stable | prerelease | candidate | beta | edge, \
699                    or a publisher-native track name (passed through verbatim)."
700        )]
701        to: String,
702        #[arg(
703            long,
704            help = "Source track (default: prerelease — each publisher's pre-stable track). \
705                    Canonical or publisher-native."
706        )]
707        from: Option<String>,
708        #[arg(
709            long = "publishers",
710            value_delimiter = ',',
711            help = "Comma-separated promotion-capable publishers to run (default: all \
712                    configured). Naming a configured-but-not-promotable publisher is an error."
713        )]
714        publishers: Vec<String>,
715        // `long = "version"` with a distinct field id (not `version`): the doc
716        // generator skips any arg whose clap id is literally `version` (the
717        // global version flag), so the field is named `version_selector` to
718        // keep the user-facing `--version` flag documented, matching `tag`'s
719        // `version_override`.
720        #[arg(
721            long = "version",
722            value_name = "VERSION",
723            conflicts_with = "from_run",
724            help = "Promote this explicit version/tag (default: the newest artifact in the \
725                    --from track)."
726        )]
727        version_selector: Option<String>,
728        #[arg(
729            long = "from-run",
730            value_parser = parse_run_id,
731            help = "Promote what a prior release run recorded (reads dist/run-<id>/report.json)."
732        )]
733        from_run: Option<String>,
734        #[arg(
735            long,
736            help = "Resolve and print the plan without running any external command"
737        )]
738        dry_run: bool,
739    },
740    /// Bump crate versions (Conventional Commits → semver level)
741    ///
742    /// Infers the per-crate level from commits since each crate's last tag
743    /// when no positional argument is given. `patch|minor|major`, an explicit
744    /// version, or `release` (strip prerelease) are also accepted.
745    Bump {
746        #[arg(help = "patch | minor | major | <version> | release (omit to infer)")]
747        level_or_version: Option<String>,
748        #[arg(
749            long,
750            short = 'p',
751            visible_alias = "crate",
752            action = clap::ArgAction::Append,
753            help = "Bump a specific crate (repeatable)"
754        )]
755        package: Vec<String>,
756        #[arg(
757            long,
758            alias = "all",
759            conflicts_with = "package",
760            help = "Bump every workspace member (excluding publish=false)"
761        )]
762        workspace: bool,
763        #[arg(
764            long,
765            action = clap::ArgAction::Append,
766            help = "Exclude a crate from --workspace (repeatable)"
767        )]
768        exclude: Vec<String>,
769        #[arg(long, help = "Append a prerelease identifier (e.g. rc.1)")]
770        pre: Option<String>,
771        #[arg(long, help = "Do not rewrite dependents' [dependencies] version specs")]
772        exact: bool,
773        #[arg(
774            long,
775            help = "Proceed even if the working tree has uncommitted changes"
776        )]
777        allow_dirty: bool,
778        #[arg(long, short = 'y', help = "Skip confirmation prompt")]
779        yes: bool,
780        #[arg(long, help = "Print the plan without editing any files")]
781        dry_run: bool,
782        #[arg(long, help = "Stage edits and create a single commit")]
783        commit: bool,
784        #[arg(
785            long = "changelog",
786            requires = "commit",
787            help = "Refresh CHANGELOG.md in the bump commit (requires --commit and a `changelog:` config block)"
788        )]
789        changelog: bool,
790        #[arg(
791            long,
792            requires = "commit",
793            help = "GPG-sign the commit (requires --commit)"
794        )]
795        sign: bool,
796        #[arg(long, help = "Override the default commit message template")]
797        commit_message: Option<String>,
798        #[arg(
799            long,
800            default_value = "text",
801            help = "Output format: text | json (json requires --dry-run)"
802        )]
803        output: String,
804    },
805    /// Run only the announce stage from a completed dist/
806    ///
807    /// Counterpart to `release --announce-only`: both re-fire announcers
808    /// against a populated dist without re-publishing. The subcommand
809    /// form (`anodizer announce`) accepts `--dist` to point at a
810    /// non-default tree (e.g. preserved by `--preserve-dist`); the flag
811    /// form (`release --announce-only`) operates on the dist configured
812    /// in `.anodizer.yaml`. Both honor nightly short-circuit.
813    Announce {
814        #[arg(long, help = "Run full pipeline without side effects")]
815        dry_run: bool,
816        #[arg(long, help = "Custom dist directory (overrides config)")]
817        dist: Option<PathBuf>,
818        #[arg(
819            long,
820            help = TOKEN_HELP.as_str()
821        )]
822        token: Option<String>,
823        #[arg(long, value_delimiter = ',', help = "Skip stages (comma-separated)")]
824        skip: Vec<String>,
825        #[arg(
826            long,
827            help = "Merge artifact lists from `release --split` workers (dist/<subdir>/context.json) before announcing. Mirrors `goreleaser announce --merge`."
828        )]
829        merge: bool,
830    },
831    /// Send a notification through configured announce integrations.
832    ///
833    /// Fires configured announce integrations (slack, discord, webhook, …) with
834    /// a Tera-rendered message. Unlike `announce`, this command does not require
835    /// a `dist/` directory — it is intended for ad-hoc notifications outside the
836    /// release pipeline (e.g. CI status alerts, deployment notices).
837    Notify {
838        /// Message template to send. Supports standard Tera template vars
839        /// (e.g. `{{ ProjectName }}`, `{{ Tag }}`, `{{ Version }}`).
840        message: String,
841        /// Comma-separated list of integration names to fire (default: all).
842        /// Valid names: discord, discourse, slack, webhook, telegram, teams,
843        /// mattermost, reddit, twitter, mastodon, bluesky, linkedin.
844        #[arg(long = "publishers", value_delimiter = ',')]
845        publishers: Vec<String>,
846        /// Comma-separated list of integration names to omit.
847        #[arg(long = "skip", value_delimiter = ',')]
848        skip: Vec<String>,
849        /// Send the message literally, without Tera template rendering. Use
850        /// when the message contains untrusted text (e.g. error output in an
851        /// on_error hook).
852        #[arg(long)]
853        raw: bool,
854        /// Send secrets in the message body verbatim (disable outbound
855        /// redaction). For trusted private channels only; log output stays
856        /// redacted.
857        #[arg(long = "allow-secrets")]
858        allow_secrets: bool,
859        /// Run without sending (dry-run mode).
860        #[arg(long)]
861        dry_run: bool,
862    },
863}
864
865/// Clap `value_parser` for `promote --from-run=<id>`.
866///
867/// `run_id` is operator-controlled and is joined directly into a
868/// filesystem path (`<dist>/run-<id>/report.json`). Without this
869/// validator, `--from-run=../../etc/passwd` would resolve to a traversed
870/// path on read — and the same id shape names the write path
871/// (`rollback.json`) inside the publisher-unwind engine, so the rule is
872/// enforced once for both.
873///
874/// Delegates to [`anodizer_stage_publish::rollback::validate_run_id`]
875/// so the rule has a single source of truth (the same validator runs at
876/// the `run_with_publishers` entry point as a defense-in-depth guard).
877fn parse_run_id(s: &str) -> Result<String, String> {
878    anodizer_stage_publish::rollback::validate_run_id(s)
879        .map(|()| s.to_string())
880        .map_err(|err| format!("{:#}", err))
881}
882
883/// Detect the host target triple by parsing `rustc -vV` output.
884/// Delegates to `anodizer_core::partial::detect_host_target()`.
885pub fn detect_host_target() -> anyhow::Result<String> {
886    anodizer_core::partial::detect_host_target()
887}
888
889/// Return a sensible default parallelism value (number of logical CPUs, minimum 1).
890pub fn num_cpus() -> usize {
891    std::thread::available_parallelism()
892        .map(|n| n.get())
893        .unwrap_or(4)
894}
895
896/// Build the clap `Command` tree for CLI introspection.
897pub fn build_cli() -> clap::Command {
898    <Cli as clap::CommandFactory>::command()
899}