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