1use clap::{Parser, Subcommand};
2use clap_complete::Shell;
3use std::path::PathBuf;
4
5#[derive(Parser)]
6#[command(name = "anodizer", version, about = "Release Rust projects with ease")]
7pub struct Cli {
8 #[arg(
9 long,
10 short = 'f',
11 global = true,
12 help = "Path to config file (overrides auto-detection)"
13 )]
14 pub config: Option<PathBuf>,
15 #[arg(long, global = true, help = "Enable verbose output")]
16 pub verbose: bool,
17 #[arg(long, global = true, help = "Enable debug output")]
18 pub debug: bool,
19 #[arg(long, short = 'q', global = true, help = "Suppress non-error output")]
20 pub quiet: bool,
21 #[arg(
22 long,
23 global = true,
24 help = "Strict mode: configured features that silently skip become hard errors"
25 )]
26 pub strict: bool,
27 #[command(subcommand)]
33 pub command: Option<Commands>,
34}
35
36#[derive(Subcommand)]
37#[allow(clippy::large_enum_variant)]
42pub enum Commands {
43 Release {
45 #[arg(long = "crate", visible_alias = "id", action = clap::ArgAction::Append, help = "Release a specific crate (repeatable; --id is accepted as a GoReleaser-compat alias)")]
46 crate_names: Vec<String>,
47 #[arg(long, help = "Release all crates with unreleased changes")]
48 all: bool,
49 #[arg(long, help = "Force release even without unreleased changes")]
50 force: bool,
51 #[arg(long, help = "Build without publishing (snapshot mode)")]
52 snapshot: bool,
53 #[arg(long, help = "Create a nightly release with date-based version")]
54 nightly: bool,
55 #[arg(long, help = "Run full pipeline without side effects")]
56 dry_run: bool,
57 #[arg(long, help = "Remove dist directory before starting")]
58 clean: bool,
59 #[arg(
60 long,
61 value_delimiter = ',',
62 help = "Skip stages (comma-separated, e.g. docker,announce)"
63 )]
64 skip: Vec<String>,
65 #[arg(
66 long,
67 help = "GitHub token (overrides ANODIZER_GITHUB_TOKEN / GITHUB_TOKEN env vars)"
68 )]
69 token: Option<String>,
70 #[arg(
71 long,
72 default_value = "60m",
73 help = "Pipeline timeout duration (e.g., 60m, 1h, 5s)"
74 )]
75 timeout: String,
76 #[arg(
77 long,
78 short = 'p',
79 help = "Maximum number of parallel build jobs (default: number of CPUs)"
80 )]
81 parallelism: Option<usize>,
82 #[arg(long, help = "Automatically set --snapshot if the git repo is dirty")]
83 auto_snapshot: bool,
84 #[arg(long, help = "Build only for the host target triple")]
85 single_target: bool,
86 #[arg(
87 long,
88 value_name = "csv",
89 conflicts_with = "single_target",
90 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."
91 )]
92 targets: Option<String>,
93 #[arg(
94 long = "host-targets",
95 conflicts_with = "single_target",
96 conflicts_with = "targets",
97 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."
98 )]
99 host_targets: bool,
100 #[arg(
101 long,
102 help = "Path to a custom release notes file (overrides changelog)"
103 )]
104 release_notes: Option<PathBuf>,
105 #[arg(
106 long,
107 conflicts_with = "crate_names",
108 help = "Release a specific workspace in a monorepo config"
109 )]
110 workspace: Option<String>,
111 #[arg(
112 long,
113 conflicts_with = "no_preflight",
114 help = "Run pre-flight publisher-state check and exit (don't start the pipeline)"
115 )]
116 preflight: bool,
117 #[arg(
118 long,
119 conflicts_with = "preflight",
120 help = "Skip the automatic pre-flight publisher-state check"
121 )]
122 no_preflight: bool,
123 #[arg(
124 long,
125 help = "Alias for --strict (also treats Unknown publisher state as a blocker during pre-flight)"
126 )]
127 strict_preflight: bool,
128 #[arg(long, help = "Set the release as a draft")]
129 draft: bool,
130 #[arg(long, help = "Path to a file containing custom release header text")]
131 release_header: Option<PathBuf>,
132 #[arg(
133 long,
134 help = "Path to a template file for release header (rendered with template variables)"
135 )]
136 release_header_tmpl: Option<PathBuf>,
137 #[arg(long, help = "Path to a file containing custom release footer text")]
138 release_footer: Option<PathBuf>,
139 #[arg(
140 long,
141 help = "Path to a template file for release footer (rendered with template variables)"
142 )]
143 release_footer_tmpl: Option<PathBuf>,
144 #[arg(
145 long,
146 help = "Path to a template file for release notes (rendered with template variables, overrides --release-notes)"
147 )]
148 release_notes_tmpl: Option<PathBuf>,
149 #[arg(long, help = "Abort immediately on first error during publishing")]
150 fail_fast: bool,
151 #[arg(
152 long = "no-gate-submitter",
153 help = "Disable the Submitter gate: dispatch Submitter publishers even when required Assets/Manager publishers failed"
154 )]
155 no_gate_submitter: bool,
156 #[arg(
157 long = "rollback",
158 value_name = "none|best-effort",
159 help = "Rollback policy after publish stage. Defaults to best-effort when preflight is clean, none otherwise."
160 )]
161 rollback: Option<String>,
162 #[arg(
163 long = "simulate-failure",
164 value_name = "publisher",
165 action = clap::ArgAction::Append,
166 hide = true,
167 help = "(TEST HARNESS) Force a named publisher to fail. Gated by ANODIZE_TEST_HARNESS=1."
168 )]
169 simulate_failure: Vec<String>,
170 #[arg(
171 long = "rollback-only",
172 requires = "from_run",
173 conflicts_with = "clean",
174 help = "Skip publish; re-attempt rollback from a prior run report. Requires --from-run=<id>."
175 )]
176 rollback_only: bool,
177 #[arg(
178 long = "from-run",
179 value_name = "id",
180 requires = "rollback_only",
181 value_parser = parse_run_id,
182 help = "Prior run id whose state to load when running --rollback-only. \
183 Loads <dist>/run-<id>/rollback.json if present (a prior replay's state), \
184 otherwise <dist>/run-<id>/report.json. Delete rollback.json to force a \
185 full re-roll. Must match the run_id format written by the release pipeline \
186 (alphanumeric, dot, dash, underscore; no path separators)."
187 )]
188 from_run: Option<String>,
189 #[arg(
190 long = "allow-rerun",
191 conflicts_with = "rollback_only",
192 help = "DANGEROUS: force publish to proceed even when a prior \
193 dist/run-<id>/report.json exists for this tag. PR-based publishers \
194 (homebrew, scoop, nix, krew, MCP) will open DUPLICATE pull requests. \
195 Recover from partial failures with --rollback-only --from-run=<id> first. \
196 Cannot be combined with --rollback-only (which has its own idempotency)."
197 )]
198 allow_rerun: bool,
199 #[arg(
200 long = "allow-nondeterministic",
201 value_name = "name=reason",
202 action = clap::ArgAction::Append,
203 help = "Runtime non-determinism opt-out for a specific artifact (repeatable). Mutually exclusive with --strict."
204 )]
205 allow_nondeterministic: Vec<String>,
206 #[arg(
207 long = "summary-json",
208 value_name = "path",
209 help = "Write the per-publisher run summary JSON to this path."
210 )]
211 summary_json: Option<PathBuf>,
212 #[arg(
213 long = "allow-ai-failure",
214 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."
215 )]
216 allow_ai_failure: bool,
217 #[arg(
218 long,
219 conflicts_with = "merge",
220 help = "Run only the build stage for split CI fan-out (outputs artifacts JSON to dist/)"
221 )]
222 split: bool,
223 #[arg(
224 long,
225 conflicts_with = "split",
226 help = "Merge artifacts from split build jobs and resume the pipeline from post-build stages"
227 )]
228 merge: bool,
229 #[arg(
230 long = "publish-only",
231 conflicts_with_all = ["split", "merge", "prepare", "announce_only", "snapshot", "rollback_only", "clean"],
232 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/."
233 )]
234 publish_only: bool,
235 #[arg(
236 long,
237 alias = "prepare-only",
238 conflicts_with_all = ["publish_only", "announce_only", "rollback_only"],
239 help = "Run local build + archive + sign + checksum + sbom stages but skip release / publish / announce (GoReleaser Pro parity). Artifacts stay in dist/ for inspection. `--prepare-only` is accepted as an alias for GR-imported scripts."
240 )]
241 prepare: bool,
242 #[arg(
243 long = "announce-only",
244 conflicts_with_all = ["prepare", "publish_only", "snapshot", "rollback_only", "split", "merge", "clean"],
245 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."
246 )]
247 announce_only: bool,
248 #[arg(
249 long,
250 help = "Resume into an existing release left over from a prior failed attempt; bypasses the safety check that bails on partial assets."
251 )]
252 resume_release: bool,
253 #[arg(
254 long,
255 help = "Force release.replace_existing_artifacts: true regardless of config (overwrite conflicting assets on retry)."
256 )]
257 replace_existing: bool,
258 #[arg(
259 long = "no-post-publish-poll",
260 help = "Skip post-publish polling for chocolatey moderation / winget PR validation; report NotPolled for affected publishers."
261 )]
262 no_post_publish_poll: bool,
263 },
264 Build {
266 #[arg(long = "crate", action = clap::ArgAction::Append, help = "Build a specific crate (repeatable)")]
267 crate_names: Vec<String>,
268 #[arg(
269 long,
270 default_value = "60m",
271 help = "Pipeline timeout duration (e.g., 60m, 1h, 5s)"
272 )]
273 timeout: String,
274 #[arg(
275 long,
276 short = 'p',
277 help = "Maximum number of parallel build jobs (default: number of CPUs)"
278 )]
279 parallelism: Option<usize>,
280 #[arg(long, help = "Build only for the host target triple")]
281 single_target: bool,
282 #[arg(
283 long,
284 conflicts_with = "crate_names",
285 help = "Build a specific workspace in a monorepo config"
286 )]
287 workspace: Option<String>,
288 #[arg(
289 long,
290 short = 'o',
291 help = "Copy the built binary to this path (requires --single-target and single crate)"
292 )]
293 output: Option<PathBuf>,
294 #[arg(
295 long,
296 value_delimiter = ',',
297 help = "Skip stages (comma-separated: pre-hooks, post-hooks, validate, before)"
298 )]
299 skip: Vec<String>,
300 },
301 Check {
303 #[command(subcommand)]
304 cmd: CheckCmd,
305 },
306 Init {
308 #[arg(
309 long,
310 help = "Discover repo files that embed the current version and enroll the selection into version_files in .anodizer.yaml"
311 )]
312 version_files: bool,
313 #[arg(
314 long,
315 value_delimiter = ',',
316 requires = "version_files",
317 help = "Glob(s) to drop from discovered candidates (repeatable or comma-separated); only with --version-files"
318 )]
319 exclude: Vec<String>,
320 #[arg(
321 long,
322 short = 'y',
323 requires = "version_files",
324 help = "Non-interactive: enroll all discovered candidates without prompting"
325 )]
326 yes: bool,
327 },
328 Changelog {
330 #[arg(
331 value_name = "tag|range",
332 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"
333 )]
334 range: Option<String>,
335 #[arg(
336 long,
337 value_enum,
338 default_value = "keep-a-changelog",
339 help = "Output format: keep-a-changelog (refresh the [Unreleased] section), release-notes (grouped-bullet GitHub body to stdout), or json"
340 )]
341 format: ChangelogFormat,
342 #[arg(
343 long,
344 help = "Apply the regenerated [Unreleased] section to the configured CHANGELOG.md file(s) in place (keep-a-changelog only)"
345 )]
346 write: bool,
347 #[arg(long = "crate", help = "Restrict to a specific crate in a workspace")]
348 crate_name: Option<String>,
349 #[arg(
350 long,
351 help = "Preview as a snapshot release (release-notes format only)"
352 )]
353 snapshot: bool,
354 },
355 Completion {
357 #[arg(value_enum, help = "Shell to generate completions for")]
358 shell: Shell,
359 },
360 Healthcheck,
362 Man,
364 Jsonschema,
366 ResolveTag {
368 #[arg(help = "Tag to resolve (e.g. 'v1.2.3', 'core-v0.2.3')")]
369 tag: String,
370 #[arg(long, help = "Output as JSON")]
371 json: bool,
372 },
373 Targets {
379 #[arg(long, help = "Output as JSON (include-form matrix)")]
380 json: bool,
381 #[arg(long = "crate", action = clap::ArgAction::Append, help = "Restrict to specific crate(s)")]
382 crate_names: Vec<String>,
383 },
384 Tag {
386 #[arg(long, help = "Show what tag would be created without pushing")]
387 dry_run: bool,
388 #[arg(long, help = "Override bump logic with a specific tag value")]
389 custom_tag: Option<String>,
390 #[arg(long, help = "Override default bump type (patch/minor/major)")]
391 default_bump: Option<String>,
392 #[arg(long = "crate", help = "Tag a specific crate in a workspace")]
393 crate_name: Option<String>,
394 #[arg(
395 long,
396 help = "Push the version-sync bump commit to the release branch atomically with the tag"
397 )]
398 push: bool,
399 #[arg(
400 long,
401 conflicts_with = "push",
402 help = "Push the tag only, leaving the version-sync bump commit local"
403 )]
404 no_push: bool,
405 #[arg(
406 long,
407 value_name = "NAME",
408 help = "Remote to push to (default: origin)"
409 )]
410 push_remote: Option<String>,
411 #[arg(
412 long,
413 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"
414 )]
415 push_dry_run: bool,
416 #[arg(
417 long = "changelog",
418 help = "Refresh CHANGELOG.md as part of this tag (requires a `changelog:` config block)"
419 )]
420 changelog: bool,
421 #[command(subcommand)]
427 sub: Option<TagSub>,
428 },
429 Continue {
449 #[arg(
450 long,
451 help = "Merge artifacts from split build jobs and run post-build stages"
452 )]
453 merge: bool,
454 #[arg(long, help = "Custom dist directory (overrides config)")]
455 dist: Option<PathBuf>,
456 #[arg(long, help = "Run full pipeline without side effects")]
457 dry_run: bool,
458 #[arg(
459 long,
460 value_delimiter = ',',
461 help = "Skip stages (comma-separated, e.g. docker,announce)"
462 )]
463 skip: Vec<String>,
464 #[arg(
465 long,
466 help = "GitHub token (overrides ANODIZER_GITHUB_TOKEN / GITHUB_TOKEN env vars)"
467 )]
468 token: Option<String>,
469 },
470 Publish {
483 #[arg(long, help = "Run full pipeline without side effects")]
484 dry_run: bool,
485 #[arg(
486 long,
487 help = "GitHub token (overrides ANODIZER_GITHUB_TOKEN / GITHUB_TOKEN env vars)"
488 )]
489 token: Option<String>,
490 #[arg(long, help = "Custom dist directory (overrides config)")]
491 dist: Option<PathBuf>,
492 #[arg(
493 long,
494 help = "Merge artifacts from `release --split` workers (dist/<subdir>/context.json) before running the publish-only pipeline. Mirrors `goreleaser publish --merge`."
495 )]
496 merge: bool,
497 },
498 Bump {
504 #[arg(help = "patch | minor | major | <version> | release (omit to infer)")]
505 level_or_version: Option<String>,
506 #[arg(
507 long,
508 short = 'p',
509 visible_alias = "crate",
510 action = clap::ArgAction::Append,
511 help = "Bump a specific crate (repeatable)"
512 )]
513 package: Vec<String>,
514 #[arg(
515 long,
516 alias = "all",
517 conflicts_with = "package",
518 help = "Bump every workspace member (excluding publish=false)"
519 )]
520 workspace: bool,
521 #[arg(
522 long,
523 action = clap::ArgAction::Append,
524 help = "Exclude a crate from --workspace (repeatable)"
525 )]
526 exclude: Vec<String>,
527 #[arg(long, help = "Append a prerelease identifier (e.g. rc.1)")]
528 pre: Option<String>,
529 #[arg(long, help = "Do not rewrite dependents' [dependencies] version specs")]
530 exact: bool,
531 #[arg(
532 long,
533 help = "Proceed even if the working tree has uncommitted changes"
534 )]
535 allow_dirty: bool,
536 #[arg(long, short = 'y', help = "Skip confirmation prompt")]
537 yes: bool,
538 #[arg(long, help = "Print the plan without editing any files")]
539 dry_run: bool,
540 #[arg(long, help = "Stage edits and create a single commit")]
541 commit: bool,
542 #[arg(
543 long = "changelog",
544 requires = "commit",
545 help = "Refresh CHANGELOG.md in the bump commit (requires --commit and a `changelog:` config block)"
546 )]
547 changelog: bool,
548 #[arg(
549 long,
550 requires = "commit",
551 help = "GPG-sign the commit (requires --commit)"
552 )]
553 sign: bool,
554 #[arg(long, help = "Override the default commit message template")]
555 commit_message: Option<String>,
556 #[arg(
557 long,
558 default_value = "text",
559 help = "Output format: text | json (json requires --dry-run)"
560 )]
561 output: String,
562 },
563 Announce {
572 #[arg(long, help = "Run full pipeline without side effects")]
573 dry_run: bool,
574 #[arg(long, help = "Custom dist directory (overrides config)")]
575 dist: Option<PathBuf>,
576 #[arg(
577 long,
578 help = "GitHub token (overrides ANODIZER_GITHUB_TOKEN / GITHUB_TOKEN env vars)"
579 )]
580 token: Option<String>,
581 #[arg(long, value_delimiter = ',', help = "Skip stages (comma-separated)")]
582 skip: Vec<String>,
583 #[arg(
584 long,
585 help = "Merge artifact lists from `release --split` workers (dist/<subdir>/context.json) before announcing. Mirrors `goreleaser announce --merge`."
586 )]
587 merge: bool,
588 },
589}
590
591#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)]
593pub enum ChangelogFormat {
594 #[default]
598 #[value(name = "keep-a-changelog", alias = "kac")]
599 KeepAChangelog,
600 ReleaseNotes,
603 Json,
606}
607
608#[derive(Subcommand)]
615pub enum TagSub {
616 Rollback {
619 #[arg(
620 value_name = "sha",
621 help = "Commit SHA to roll back from. Defaults to HEAD."
622 )]
623 sha: Option<String>,
624 #[arg(long, help = "Print what would happen without mutating anything")]
625 dry_run: bool,
626 #[arg(
627 long = "no-push",
628 help = "Skip remote tag delete and branch push (local-only)"
629 )]
630 no_push: bool,
631 #[arg(
632 long,
633 default_value = "all",
634 help = "Tag-shape filter: all | lockstep | per-crate"
635 )]
636 scope: String,
637 #[arg(
638 long,
639 default_value = "revert",
640 help = "Rollback strategy: revert (default; history-preserving) | reset (opt-in; rewrites history, requires --force-with-lease to push)"
641 )]
642 mode: String,
643 #[arg(
644 long,
645 value_name = "name",
646 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)."
647 )]
648 branch: Option<String>,
649 },
650}
651
652#[derive(Subcommand)]
658pub enum CheckCmd {
659 Config {
661 #[arg(long, help = "Validate a specific workspace in a monorepo config")]
662 workspace: Option<String>,
663 },
664 Determinism(CheckDeterminismArgs),
666 VersionFiles,
668}
669
670#[derive(clap::Args)]
671pub struct CheckDeterminismArgs {
672 #[arg(
673 long,
674 default_value = "2",
675 help = "Number of from-clean rebuilds to diff"
676 )]
677 pub runs: u32,
678 #[arg(
679 long,
680 value_name = "stages",
681 help = "Optional stage subset (build,archive,sbom,sign,checksum,cargo-package,docker). `cargo-package` is harness-only — drives `cargo package --no-verify --allow-dirty` per workspace member to probe `.crate` byte-stability without hitting a registry. `docker` is harness-only — drives `docker buildx build --output=type=oci,rewrite-timestamp=true,dest=…` against `<repo>/Dockerfile` to probe OCI image byte-stability without pushing to a registry; skipped when `docker buildx` is unavailable or no Dockerfile exists."
682 )]
683 pub stages: Option<String>,
684 #[arg(
685 long,
686 value_name = "csv",
687 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."
688 )]
689 pub targets: Option<String>,
690 #[arg(
691 long,
692 value_name = "path",
693 help = "JSON report path; default dist/run-<id>/determinism.json"
694 )]
695 pub report: Option<PathBuf>,
696 #[arg(
697 long,
698 conflicts_with = "no_snapshot",
699 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."
700 )]
701 pub snapshot: bool,
702 #[arg(
703 long = "no-snapshot",
704 conflicts_with = "snapshot",
705 help = "Force snapshot mode OFF on the child release subprocess (artifacts emit the actual release version). Default: auto — see --snapshot."
706 )]
707 pub no_snapshot: bool,
708 #[arg(
709 long = "inject-drift",
710 value_name = "stage",
711 hide = true,
712 help = "(TEST HARNESS) Append 1 random byte to the first artifact emitted by <stage>. Gated by ANODIZE_TEST_HARNESS=1."
713 )]
714 pub inject_drift: Option<String>,
715 #[arg(
716 long = "preserve-dist",
717 value_name = "path",
718 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."
719 )]
720 pub preserve_dist: Option<PathBuf>,
721 #[arg(
722 long = "crate",
723 value_name = "name",
724 help = "When --preserve-dist is set, write the preserved dist tree to \
725 <dest>/<name>/ instead of directly into <dest>/. Used by the \
726 sharded matrix to produce per-crate subdirectories so a \
727 `release --publish-only` job can merge all crates into a single \
728 dist/ without context.json collision."
729 )]
730 pub crate_name: Option<String>,
731}
732
733fn parse_run_id(s: &str) -> Result<String, String> {
746 anodizer_stage_publish::rollback_only::validate_run_id(s)
747 .map(|()| s.to_string())
748 .map_err(|err| format!("{:#}", err))
749}
750
751pub fn detect_host_target() -> anyhow::Result<String> {
754 anodizer_core::partial::detect_host_target()
755}
756
757pub fn num_cpus() -> usize {
759 std::thread::available_parallelism()
760 .map(|n| n.get())
761 .unwrap_or(4)
762}
763
764pub fn build_cli() -> clap::Command {
766 <Cli as clap::CommandFactory>::command()
767}