1use clap::{Parser, Subcommand};
2use clap_complete::Shell;
3use std::path::PathBuf;
4
5const PUBLISHERS_HELP_STEM: &str = "Comma-separated publishers to run (default: all configured). \
9 --skip always wins over --publishers.";
10
11static 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
21static 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
32const REMOVED_ROLLBACK_FLAG_MIGRATION: &str = "removed in favor of convergent re-run: re-running `anodizer release` with the identical \
38 arguments reconciles against what already published and skips it, so a failed release is \
39 recovered by re-running it. To withdraw a release deliberately, use `anodizer tag rollback`. \
40 Drop this flag from the invocation.";
41
42fn removed_rollback_flag(_: &str) -> Result<String, String> {
46 Err(REMOVED_ROLLBACK_FLAG_MIGRATION.to_string())
47}
48
49#[derive(Parser)]
50#[command(name = "anodizer", version, about = "Release Rust projects with ease")]
51pub struct Cli {
52 #[arg(
53 long,
54 short = 'f',
55 global = true,
56 help = "Path to config file (overrides auto-detection)"
57 )]
58 pub config: Option<PathBuf>,
59 #[arg(long, global = true, help = "Enable verbose output")]
60 pub verbose: bool,
61 #[arg(long, global = true, help = "Enable debug output")]
62 pub debug: bool,
63 #[arg(long, short = 'q', global = true, help = "Suppress non-error output")]
64 pub quiet: bool,
65 #[arg(
66 long,
67 global = true,
68 help = "Strict mode: configured features that silently skip become hard errors"
69 )]
70 pub strict: bool,
71 #[command(subcommand)]
77 pub command: Option<Commands>,
78}
79
80#[derive(Subcommand)]
81#[allow(clippy::large_enum_variant)]
86pub enum Commands {
87 Release {
97 #[arg(long = "crate", visible_alias = "id", action = clap::ArgAction::Append, help = "Release a specific crate (repeatable; --id is accepted as a GoReleaser-compat alias)")]
98 crate_names: Vec<String>,
99 #[arg(long, help = "Release all crates with unreleased changes")]
100 all: bool,
101 #[arg(long, help = "Force release even without unreleased changes")]
102 force: bool,
103 #[arg(long, help = "Build without publishing (snapshot mode)")]
104 snapshot: bool,
105 #[arg(long, help = "Create a nightly release with date-based version")]
106 nightly: bool,
107 #[arg(long, help = "Run full pipeline without side effects")]
108 dry_run: bool,
109 #[arg(long, help = "Remove dist directory before starting")]
110 clean: bool,
111 #[arg(
112 long,
113 value_delimiter = ',',
114 help = "Skip stages or publishers (comma-separated, e.g. docker,announce,npm). \
115 Unified denylist: a stage name skips the stage, a publisher name \
116 (npm, homebrew, chocolatey, …) skips that publisher."
117 )]
118 skip: Vec<String>,
119 #[arg(long = "publishers", value_delimiter = ',', help = PUBLISHERS_HELP_STEM)]
120 publishers: Vec<String>,
121 #[arg(
122 long,
123 help = TOKEN_HELP.as_str()
124 )]
125 token: Option<String>,
126 #[arg(
127 long,
128 default_value = "3h",
129 help = "Pipeline timeout duration (e.g., 90m, 3h, 5s) — a generous \
130 safety backstop, not the primary bound; per-stage bounds \
131 (e.g. announce.deadline) catch a hung stage in seconds"
132 )]
133 timeout: String,
134 #[arg(
135 long,
136 short = 'p',
137 help = "Maximum number of parallel build jobs (default: number of CPUs)"
138 )]
139 parallelism: Option<usize>,
140 #[arg(long, help = "Automatically set --snapshot if the git repo is dirty")]
141 auto_snapshot: bool,
142 #[arg(long, help = "Build only for the host target triple")]
143 single_target: bool,
144 #[arg(
145 long,
146 value_name = "csv",
147 conflicts_with = "single_target",
148 help = "Restrict the build to a comma-separated subset of configured target triples (e.g. x86_64-apple-darwin,aarch64-apple-darwin). Used by the Determinism Harness's sharded job matrix; conflicts with --single-target."
149 )]
150 targets: Option<String>,
151 #[arg(
152 long = "host-targets",
153 conflicts_with = "single_target",
154 conflicts_with = "targets",
155 help = "Build every configured target this host can build, skipping cross-compile-only targets (apple targets on a non-macOS host). Only valid with --snapshot or --dry-run. Used by `task prepush` to do a real host-scoped build without aborting on un-buildable targets."
156 )]
157 host_targets: bool,
158 #[arg(
159 long,
160 help = "Path to a custom release notes file (overrides changelog)"
161 )]
162 release_notes: Option<PathBuf>,
163 #[arg(
164 long,
165 conflicts_with = "crate_names",
166 help = "Release a specific workspace in a monorepo config"
167 )]
168 workspace: Option<String>,
169 #[arg(
170 long,
171 help = "Run pre-flight publisher-state check and exit (don't start the pipeline)"
172 )]
173 preflight: bool,
174 #[arg(
175 long = "preflight-secrets",
176 help = "Validate that all required publish secrets / credentials are present (and key material is well-formed) without checking host-local tools — for a central pre-release gate across decoupled CI runners. Checks and exits; does not start the pipeline."
177 )]
178 preflight_secrets: bool,
179 #[arg(long, help = "Set the release as a draft")]
180 draft: bool,
181 #[arg(long, help = "Path to a file containing custom release header text")]
182 release_header: Option<PathBuf>,
183 #[arg(
184 long,
185 help = "Path to a template file for release header (rendered with template variables)"
186 )]
187 release_header_tmpl: Option<PathBuf>,
188 #[arg(long, help = "Path to a file containing custom release footer text")]
189 release_footer: Option<PathBuf>,
190 #[arg(
191 long,
192 help = "Path to a template file for release footer (rendered with template variables)"
193 )]
194 release_footer_tmpl: Option<PathBuf>,
195 #[arg(
196 long,
197 help = "Path to a template file for release notes (rendered with template variables, overrides --release-notes)"
198 )]
199 release_notes_tmpl: Option<PathBuf>,
200 #[arg(long, help = "Abort immediately on first error during publishing")]
201 fail_fast: bool,
202 #[arg(
203 long = "no-gate-submitter",
204 help = "Disable the Submitter gate: dispatch Submitter publishers even when required Assets/Manager publishers failed, or when the pre-submitter verify-release check did not pass"
205 )]
206 no_gate_submitter: bool,
207 #[arg(
208 long = "simulate-failure",
209 value_name = "publisher",
210 action = clap::ArgAction::Append,
211 hide = true,
212 help = "(TEST HARNESS) Force a named publisher to fail. Gated by ANODIZE_TEST_HARNESS=1."
213 )]
214 simulate_failure: Vec<String>,
215 #[arg(
216 long = "show-skipped",
217 help = "Show per-crate 'no <publisher> config block' skip lines at default verbosity \
218 (normally only visible with --debug). Use to diagnose why a publisher didn't \
219 run for a given crate."
220 )]
221 show_skipped: bool,
222 #[arg(
223 long = "allow-nondeterministic",
224 value_name = "name=reason",
225 action = clap::ArgAction::Append,
226 help = "Runtime non-determinism opt-out for a specific artifact (repeatable). Mutually exclusive with --strict."
227 )]
228 allow_nondeterministic: Vec<String>,
229 #[arg(
230 long = "summary-json",
231 value_name = "path",
232 help = "Write the per-publisher run summary JSON to this path. Without it, real (non-snapshot, non-dry-run) releases write <dist>/run-<id>/summary.json — even when a stage fails — so recovery tooling always has machine-readable publish state."
233 )]
234 summary_json: Option<PathBuf>,
235 #[arg(
236 long = "allow-ai-failure",
237 help = "If `changelog.ai` is configured and the AI provider fails, log a warning and keep the pre-AI release notes instead of aborting the release."
238 )]
239 allow_ai_failure: bool,
240 #[arg(
241 long = "allow-snapshot-publish",
242 help = "DANGEROUS: allow publishing a non-release version (snapshot / dirty / 0.0.0-sentinel, e.g. 0.0.0~SNAPSHOT-<sha>) to external publishers. By default the publish, blob, and announce stages refuse such versions — several indexes (crates.io, Cloudsmith, Chocolatey, winget, AUR) are one-way doors. Use ONLY for a private/test channel."
243 )]
244 allow_snapshot_publish: bool,
245 #[arg(
246 long,
247 conflicts_with = "merge",
248 help = "Run only the build stage for split CI fan-out (outputs artifacts JSON to dist/)"
249 )]
250 split: bool,
251 #[arg(
252 long,
253 conflicts_with = "split",
254 help = "Merge artifacts from split build jobs and resume the pipeline from post-build stages"
255 )]
256 merge: bool,
257 #[arg(
258 long = "publish-only",
259 conflicts_with_all = ["split", "merge", "prepare", "announce_only", "snapshot", "clean"],
260 help = "Load artifacts from dist/ (preserved by `anodize check determinism --preserve-dist`) and run only the sign + publish pipeline. Skips build/archive/nfpm/sbom/checksum — those stages' outputs must already be present in dist/."
261 )]
262 publish_only: bool,
263 #[arg(
264 long,
265 alias = "prepare-only",
266 conflicts_with_all = ["publish_only", "announce_only"],
267 help = PREPARE_HELP.as_str()
268 )]
269 prepare: bool,
270 #[arg(
271 long = "announce-only",
272 conflicts_with_all = ["prepare", "publish_only", "snapshot", "split", "merge", "clean"],
273 help = "Re-fire announcers only. Loads `<dist>/run-<id>/report.json` written by a prior run, skips every pipeline stage except announce (which itself short-circuits on nightly), then runs after-hooks. Use this to retry a transient announcer failure (Slack 502, Discord 5xx) without re-creating the GitHub release or re-publishing to package managers. Fails fast when no `<dist>/run-<id>/report.json` is present."
274 )]
275 announce_only: bool,
276 #[arg(
277 long,
278 help = "Resume into an existing release left over from a prior failed attempt; bypasses the safety check that bails on partial assets."
279 )]
280 resume_release: bool,
281 #[arg(
282 long,
283 help = "Force release.replace_existing_artifacts: true regardless of config (overwrite conflicting assets on retry)."
284 )]
285 replace_existing: bool,
286 #[arg(
287 long = "no-post-publish-poll",
288 help = "Skip post-publish polling for chocolatey moderation / winget PR validation; report NotPolled for affected publishers."
289 )]
290 no_post_publish_poll: bool,
291 #[arg(
292 long = "no-env-preflight",
293 hide = true,
294 help = "(HARNESS) Skip the environment preflight (tools / secrets / key material). Set by the determinism harness, whose hermetic replica runs in a deliberately credential-less env that the config-derived preflight would correctly reject."
295 )]
296 no_env_preflight: bool,
297 #[arg(
304 long = "rollback",
305 value_name = "policy",
306 hide = true,
307 value_parser = removed_rollback_flag,
308 help = "(REMOVED) Automatic rollback policy."
309 )]
310 removed_rollback: Option<String>,
311 #[arg(
312 long = "rollback-only",
313 value_name = "removed",
314 num_args = 0..=1,
315 default_missing_value = "rollback-only",
316 hide = true,
317 value_parser = removed_rollback_flag,
318 help = "(REMOVED) Run only the rollback of a prior run."
319 )]
320 removed_rollback_only: Option<String>,
321 #[arg(
322 long = "from-run",
323 value_name = "run-id",
324 hide = true,
325 value_parser = removed_rollback_flag,
326 help = "(REMOVED) Prior run id to roll back."
327 )]
328 removed_from_run: Option<String>,
329 #[arg(
330 long = "no-failure-policy",
331 value_name = "removed",
332 num_args = 0..=1,
333 default_missing_value = "no-failure-policy",
334 hide = true,
335 value_parser = removed_rollback_flag,
336 help = "(REMOVED) Ignore release.on_failure for this run."
337 )]
338 removed_no_failure_policy: Option<String>,
339 },
340 Build {
342 #[arg(long = "crate", action = clap::ArgAction::Append, help = "Build a specific crate (repeatable)")]
343 crate_names: Vec<String>,
344 #[arg(
345 long,
346 default_value = "3h",
347 help = "Pipeline timeout duration (e.g., 90m, 3h, 5s) — a generous \
348 safety backstop, not the primary bound; per-stage bounds \
349 (e.g. announce.deadline) catch a hung stage in seconds"
350 )]
351 timeout: String,
352 #[arg(
353 long,
354 short = 'p',
355 help = "Maximum number of parallel build jobs (default: number of CPUs)"
356 )]
357 parallelism: Option<usize>,
358 #[arg(long, help = "Build only for the host target triple")]
359 single_target: bool,
360 #[arg(
361 long,
362 conflicts_with = "crate_names",
363 help = "Build a specific workspace in a monorepo config"
364 )]
365 workspace: Option<String>,
366 #[arg(
367 long,
368 short = 'o',
369 help = "Copy the built binary to this path (requires --single-target and single crate)"
370 )]
371 output: Option<PathBuf>,
372 #[arg(
373 long,
374 value_delimiter = ',',
375 help = "Skip hook lanes or stages (comma-separated: before, after, always, on-error, validate, sign, notarize)"
376 )]
377 skip: Vec<String>,
378 },
379 Check {
381 #[command(subcommand)]
382 cmd: CheckCmd,
383 },
384 Init {
386 #[arg(
387 long,
388 help = "Discover repo files that embed the current version and enroll the selection into version_files in .anodizer.yaml"
389 )]
390 version_files: bool,
391 #[arg(
392 long,
393 value_delimiter = ',',
394 requires = "version_files",
395 help = "Glob(s) to drop from discovered candidates (repeatable or comma-separated); only with --version-files"
396 )]
397 exclude: Vec<String>,
398 #[arg(
399 long,
400 short = 'y',
401 requires = "version_files",
402 help = "Non-interactive: enroll all discovered candidates without prompting"
403 )]
404 yes: bool,
405 },
406 Changelog {
408 #[arg(
409 value_name = "tag|range",
410 help = "Commit range to render: a single tag (predecessor-resolved against its crate), an explicit `from..to` range, or omitted to refresh each crate's pending section against its last tag"
411 )]
412 range: Option<String>,
413 #[arg(
414 long,
415 value_enum,
416 default_value = "keep-a-changelog",
417 help = "Output format: keep-a-changelog (refresh the [Unreleased] section), release-notes (grouped-bullet GitHub body to stdout), or json"
418 )]
419 format: ChangelogFormat,
420 #[arg(
421 long,
422 help = "Apply the regenerated [Unreleased] section to the configured CHANGELOG.md file(s) in place (keep-a-changelog only)"
423 )]
424 write: bool,
425 #[arg(long = "crate", help = "Restrict to a specific crate in a workspace")]
426 crate_name: Option<String>,
427 #[arg(
428 long,
429 help = "Preview as a snapshot release (release-notes format only)"
430 )]
431 snapshot: bool,
432 },
433 Completion {
435 #[arg(value_enum, help = "Shell to generate completions for")]
436 shell: Shell,
437 },
438 Healthcheck,
440 Preflight {
450 #[arg(long, help = "Output the report as JSON")]
451 json: bool,
452 #[arg(
453 long,
454 help = "Check only the publish-time surface (the stages `release --publish-only` runs), not artifact-producing stages"
455 )]
456 publish_only: bool,
457 #[arg(
458 long,
459 value_delimiter = ',',
460 help = "Skip requirement collection for these stages (comma-separated, same names as release --skip)"
461 )]
462 skip: Vec<String>,
463 #[arg(long = "publishers", value_delimiter = ',', help = PUBLISHERS_HELP_STEM)]
464 publishers: Vec<String>,
465 #[arg(
466 long,
467 help = "GitHub token override; when set, GitHub token env-var requirements are treated as satisfied"
468 )]
469 token: Option<String>,
470 },
471 Man,
473 Jsonschema,
475 ResolveTag {
477 #[arg(help = "Tag to resolve (e.g. 'v1.2.3', 'core-v0.2.3')")]
478 tag: String,
479 #[arg(long, help = "Output as JSON")]
480 json: bool,
481 },
482 Targets {
488 #[arg(long, help = "Output as JSON (include-form matrix)")]
489 json: bool,
490 #[arg(long = "crate", action = clap::ArgAction::Append, help = "Restrict to specific crate(s)")]
491 crate_names: Vec<String>,
492 },
493 Vocabulary {
501 #[arg(long, help = "Output as JSON")]
502 json: bool,
503 },
504 Tools {
511 #[arg(long, help = "Output as JSON")]
512 json: bool,
513 #[arg(
514 long,
515 help = "Only the tools the publish-time surface needs (the stages `release --publish-only` runs), not artifact-producing stages"
516 )]
517 publish_only: bool,
518 #[arg(
519 long,
520 value_delimiter = ',',
521 help = "Drop tools contributed by these skipped stages (comma-separated, same names as release --skip)"
522 )]
523 skip: Vec<String>,
524 #[arg(long = "publishers", value_delimiter = ',', help = PUBLISHERS_HELP_STEM)]
525 publishers: Vec<String>,
526 },
527 Tag {
529 #[arg(long, help = "Show what tag would be created, without creating it")]
530 dry_run: bool,
531 #[arg(long, help = "Override bump logic with a specific tag value")]
532 custom_tag: Option<String>,
533 #[arg(long = "version", value_name = "VERSION")]
544 version_override: Option<String>,
545 #[arg(long, help = "Override default bump type (patch/minor/major)")]
546 default_bump: Option<String>,
547 #[arg(long = "crate", help = "Tag a specific crate in a workspace")]
548 crate_name: Option<String>,
549 #[arg(
550 long,
551 help = "Push the version-sync bump commit to the release branch atomically with the tag"
552 )]
553 push: bool,
554 #[arg(
555 long,
556 conflicts_with = "push",
557 help = "Do not push anything; the tag(s) and version-sync bump commit stay local"
558 )]
559 no_push: bool,
560 #[arg(
561 long,
562 conflicts_with_all = ["push", "no_push"],
563 help = "Push the tag(s) but not the version-sync bump commit (deferred-branch CI pattern; the branch must be advanced to the bump commit separately)"
564 )]
565 push_tags_only: bool,
566 #[arg(
567 long,
568 help = "Create a signed annotated tag (git tag -s), using the signing key/method from git config (user.signingkey, gpg.format)"
569 )]
570 sign: bool,
571 #[arg(
572 long,
573 conflicts_with = "sign",
574 help = "Create an unsigned annotated tag (git tag -a), overriding tag.sign = true in config"
575 )]
576 no_sign: bool,
577 #[arg(
578 long,
579 value_name = "NAME",
580 help = "Remote to push to (default: origin)"
581 )]
582 push_remote: Option<String>,
583 #[arg(
584 long,
585 help = "Create the tag + bump commit locally but only print (not run) the git push commands --push would use; pass --dry-run to also preview tagging"
586 )]
587 push_dry_run: bool,
588 #[arg(
589 long = "changelog",
590 help = "Refresh CHANGELOG.md as part of this tag (requires a `changelog:` config block)"
591 )]
592 changelog: bool,
593 #[command(subcommand)]
599 sub: Option<TagSub>,
600 },
601 Continue {
621 #[arg(
622 long,
623 help = "Merge artifacts from split build jobs and run post-build stages"
624 )]
625 merge: bool,
626 #[arg(long, help = "Custom dist directory (overrides config)")]
627 dist: Option<PathBuf>,
628 #[arg(long, help = "Run full pipeline without side effects")]
629 dry_run: bool,
630 #[arg(
631 long,
632 value_delimiter = ',',
633 help = "Skip stages or publishers (comma-separated, e.g. docker,announce,npm). \
634 Unified denylist: a stage name skips the stage, a publisher name \
635 (npm, homebrew, chocolatey, …) skips that publisher."
636 )]
637 skip: Vec<String>,
638 #[arg(long = "publishers", value_delimiter = ',', help = PUBLISHERS_HELP_STEM)]
639 publishers: Vec<String>,
640 #[arg(
641 long,
642 help = TOKEN_HELP.as_str()
643 )]
644 token: Option<String>,
645 },
646 Publish {
659 #[arg(long, help = "Run full pipeline without side effects")]
660 dry_run: bool,
661 #[arg(
662 long,
663 help = TOKEN_HELP.as_str()
664 )]
665 token: Option<String>,
666 #[arg(long, help = "Custom dist directory (overrides config)")]
667 dist: Option<PathBuf>,
668 #[arg(
669 long,
670 help = "Merge artifacts from `release --split` workers (dist/<subdir>/context.json) before running the publish-only pipeline. Mirrors `goreleaser publish --merge`."
671 )]
672 merge: bool,
673 #[arg(
674 long = "show-skipped",
675 help = "Show per-crate 'no <publisher> config block' skip lines at default verbosity \
676 (normally only visible with --debug). Use to diagnose why a publisher didn't \
677 run for a given crate."
678 )]
679 show_skipped: bool,
680 #[arg(
681 long,
682 value_delimiter = ',',
683 help = "Skip stages or publishers (comma-separated, e.g. npm,blob). \
684 Unified denylist: a stage name skips the stage, a publisher name \
685 (npm, homebrew, chocolatey, …) skips that publisher."
686 )]
687 skip: Vec<String>,
688 #[arg(long = "publishers", value_delimiter = ',', help = PUBLISHERS_HELP_STEM)]
689 publishers: Vec<String>,
690 },
691 Promote {
700 #[arg(
701 long,
702 help = "Destination track: stable | prerelease | candidate | beta | edge, \
703 or a publisher-native track name (passed through verbatim)."
704 )]
705 to: String,
706 #[arg(
707 long,
708 help = "Source track (default: prerelease — each publisher's pre-stable track). \
709 Canonical or publisher-native."
710 )]
711 from: Option<String>,
712 #[arg(
713 long = "publishers",
714 value_delimiter = ',',
715 help = "Comma-separated promotion-capable publishers to run (default: all \
716 configured). Naming a configured-but-not-promotable publisher is an error."
717 )]
718 publishers: Vec<String>,
719 #[arg(
725 long = "version",
726 value_name = "VERSION",
727 conflicts_with = "from_run",
728 help = "Promote this explicit version/tag (default: the newest artifact in the \
729 --from track)."
730 )]
731 version_selector: Option<String>,
732 #[arg(
733 long = "from-run",
734 value_parser = parse_run_id,
735 help = "Promote what a prior release run recorded (reads dist/run-<id>/report.json)."
736 )]
737 from_run: Option<String>,
738 #[arg(
739 long,
740 help = "Resolve and print the plan without running any external command"
741 )]
742 dry_run: bool,
743 },
744 Bump {
750 #[arg(help = "patch | minor | major | <version> | release (omit to infer)")]
751 level_or_version: Option<String>,
752 #[arg(
753 long,
754 short = 'p',
755 visible_alias = "crate",
756 action = clap::ArgAction::Append,
757 help = "Bump a specific crate (repeatable)"
758 )]
759 package: Vec<String>,
760 #[arg(
761 long,
762 alias = "all",
763 conflicts_with = "package",
764 help = "Bump every workspace member (excluding publish=false)"
765 )]
766 workspace: bool,
767 #[arg(
768 long,
769 action = clap::ArgAction::Append,
770 help = "Exclude a crate from --workspace (repeatable)"
771 )]
772 exclude: Vec<String>,
773 #[arg(long, help = "Append a prerelease identifier (e.g. rc.1)")]
774 pre: Option<String>,
775 #[arg(long, help = "Do not rewrite dependents' [dependencies] version specs")]
776 exact: bool,
777 #[arg(
778 long,
779 help = "Proceed even if the working tree has uncommitted changes"
780 )]
781 allow_dirty: bool,
782 #[arg(long, short = 'y', help = "Skip confirmation prompt")]
783 yes: bool,
784 #[arg(long, help = "Print the plan without editing any files")]
785 dry_run: bool,
786 #[arg(long, help = "Stage edits and create a single commit")]
787 commit: bool,
788 #[arg(
789 long = "changelog",
790 requires = "commit",
791 help = "Refresh CHANGELOG.md in the bump commit (requires --commit and a `changelog:` config block)"
792 )]
793 changelog: bool,
794 #[arg(
795 long,
796 requires = "commit",
797 help = "GPG-sign the commit (requires --commit)"
798 )]
799 sign: bool,
800 #[arg(long, help = "Override the default commit message template")]
801 commit_message: Option<String>,
802 #[arg(
803 long,
804 default_value = "text",
805 help = "Output format: text | json (json requires --dry-run)"
806 )]
807 output: String,
808 },
809 Announce {
818 #[arg(long, help = "Run full pipeline without side effects")]
819 dry_run: bool,
820 #[arg(long, help = "Custom dist directory (overrides config)")]
821 dist: Option<PathBuf>,
822 #[arg(
823 long,
824 help = TOKEN_HELP.as_str()
825 )]
826 token: Option<String>,
827 #[arg(long, value_delimiter = ',', help = "Skip stages (comma-separated)")]
828 skip: Vec<String>,
829 #[arg(
830 long,
831 help = "Merge artifact lists from `release --split` workers (dist/<subdir>/context.json) before announcing. Mirrors `goreleaser announce --merge`."
832 )]
833 merge: bool,
834 },
835 Notify {
842 message: String,
845 #[arg(long = "publishers", value_delimiter = ',')]
849 publishers: Vec<String>,
850 #[arg(long = "skip", value_delimiter = ',')]
852 skip: Vec<String>,
853 #[arg(long)]
857 raw: bool,
858 #[arg(long = "allow-secrets")]
862 allow_secrets: bool,
863 #[arg(long)]
865 dry_run: bool,
866 },
867}
868
869#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)]
871pub enum ChangelogFormat {
872 #[default]
876 #[value(name = "keep-a-changelog", alias = "kac")]
877 KeepAChangelog,
878 ReleaseNotes,
881 Json,
884}
885
886#[derive(Subcommand)]
893pub enum TagSub {
894 Rollback {
903 #[arg(
904 value_name = "sha",
905 help = "Commit SHA to roll back from. Defaults to HEAD."
906 )]
907 sha: Option<String>,
908 #[arg(long, help = "Print what would happen without mutating anything")]
909 dry_run: bool,
910 #[arg(
911 long = "no-push",
912 help = "Skip remote tag delete and branch push (local-only)"
913 )]
914 no_push: bool,
915 #[arg(
916 long,
917 help = "Override the published-state guard: roll back even when the tag's run summary shows a one-way-door publisher (crates.io, chocolatey, winget, snapcraft, ...) accepted the version, when the crates.io index shows the tag's crate@version live (GLOBAL state — published by any prior run, not just this one; an unreachable index also refuses), or — when no summary exists — when a published (non-draft) GitHub release exists for the tag. Without it, rollback refuses because those registries never accept the same version twice: the version is burned and the only clean recovery is fixing forward"
918 )]
919 force: bool,
920 #[arg(
921 long,
922 default_value = "all",
923 help = "Tag-shape filter: all | lockstep | per-crate"
924 )]
925 scope: String,
926 #[arg(
927 long,
928 default_value = "revert",
929 help = "Rollback strategy: revert (default; history-preserving) | reset (opt-in; rewrites history, requires --force-with-lease to push)"
930 )]
931 mode: String,
932 #[arg(
933 long,
934 value_name = "name",
935 help = "Branch name to push the revert commit to. Usually unnecessary: the branch is auto-resolved from the bump commit via `git branch -r --contains <sha>`, which covers the ordinary CI tag-push case (detached HEAD, GITHUB_REF_NAME set to the tag). Needed only when that resolution is ambiguous or empty — the bump commit is on two or more remote branches, or on none and HEAD cannot be resolved either. Both cases fail with an error naming this flag. Pass --branch master (or whichever branch the bump commit was created on)."
936 )]
937 branch: Option<String>,
938 },
939}
940
941#[derive(Subcommand)]
947pub enum CheckCmd {
948 Config {
950 #[arg(long, help = "Validate a specific workspace in a monorepo config")]
951 workspace: Option<String>,
952 #[arg(
953 long,
954 value_delimiter = ',',
955 help = "Validate these skip tokens (stages or publishers) against the known set \
956 without running anything (comma-separated). Unified denylist: a stage name \
957 skips the stage, a publisher name (npm, homebrew, chocolatey, …) skips \
958 that publisher."
959 )]
960 skip: Vec<String>,
961 #[arg(
962 long = "publishers",
963 value_delimiter = ',',
964 help = concat!(
965 "Validate-only: check that each name is a publisher the active config \
966 actually enables (a known but unconfigured publisher is rejected). ",
967 "Comma-separated publishers to run (default: all configured). \
968 --skip always wins over --publishers.",
969 )
970 )]
971 publishers: Vec<String>,
972 },
973 Determinism(CheckDeterminismArgs),
975 VersionFiles,
977}
978
979#[derive(clap::Args)]
980pub struct CheckDeterminismArgs {
981 #[arg(
982 long,
983 default_value = "2",
984 help = "Number of from-clean rebuilds to diff"
985 )]
986 pub runs: u32,
987 #[arg(
988 long,
989 value_name = "stages",
990 help = "Optional stage subset (build,source,upx,archive,nfpm,makeself,snapcraft,sbom,sign,checksum,cargo-package,docker,msi,nsis,dmg,pkg,srpm,appbundle,appimage,flatpak, plus the `installers` family selector expanding to nfpm,makeself,srpm,msi,nsis,dmg,pkg). Omit the flag to byte-verify the full OS-native partition for this host (Linux adds nfpm/makeself/snapcraft/srpm/docker/appimage/flatpak; macOS adds appbundle/dmg/pkg; Windows adds msi/nsis). The list is also the build filter: stages NOT named here are added to the child release's `--skip=` set, so a stage must be requested (or in the host default) to be byte-verified. `cargo-package` is harness-only — drives `cargo package --no-verify --allow-dirty` per workspace member to probe `.crate` byte-stability without hitting a registry; it is NOT in the host default and stays opt-in. `docker` is harness-only — drives `docker buildx build --output=type=oci,rewrite-timestamp=true,dest=…` against each configured `dockers_v2` entry's rendered dockerfile (with its `extra_files` and `build_args`, mirroring the production `docker` stage) to probe OCI image byte-stability without pushing to a registry; skipped when `docker buildx` is unavailable or the crate configures no `dockers_v2`. Installer stages (msi/nsis/dmg/pkg/srpm) plus appimage (needs `linuxdeploy`) and flatpak (needs `flatpak-builder`) are skipped at the gate when their backing tool is absent — a host-default stage warn-skips, an explicitly typed one hard-fails; `appbundle` is pure file assembly and always runs when requested."
991 )]
992 pub stages: Option<String>,
993 #[arg(
994 long,
995 value_name = "csv",
996 help = "Restrict the harness to a comma-separated subset of configured target triples. Used by the sharded release workflow so each runner only validates targets it can natively build (Linux runner skips macOS targets, etc.). Forwarded to the child `anodize release --snapshot` subprocess."
997 )]
998 pub targets: Option<String>,
999 #[arg(
1000 long,
1001 value_name = "path",
1002 help = "JSON report path; default dist/run-<id>/determinism.json"
1003 )]
1004 pub report: Option<PathBuf>,
1005 #[arg(
1006 long,
1007 conflicts_with = "no_snapshot",
1008 help = "Force snapshot mode on the child release subprocess (artifacts get a `-SNAPSHOT-<sha>` suffix). Default: auto — snapshot off when HEAD is at a tag, on otherwise."
1009 )]
1010 pub snapshot: bool,
1011 #[arg(
1012 long = "no-snapshot",
1013 conflicts_with = "snapshot",
1014 help = "Force snapshot mode OFF on the child release subprocess (artifacts emit the actual release version). Default: auto — see --snapshot."
1015 )]
1016 pub no_snapshot: bool,
1017 #[arg(
1018 long = "inject-drift",
1019 value_name = "stage",
1020 hide = true,
1021 help = "(TEST HARNESS) Append 1 random byte to the first artifact emitted by <stage>. Gated by ANODIZE_TEST_HARNESS=1."
1022 )]
1023 pub inject_drift: Option<String>,
1024 #[arg(
1025 long = "preserve-dist",
1026 value_name = "path",
1027 help = "When the harness greens, copy run-0's `<worktree>/dist/**` to <path> and emit `<path>/context.json` describing the artifact set. The release workflow's publish-only path consumes this to ship the determinism step's output directly (eliminates the redundant `build:` recompilation). Local operators can pass this too — useful for inspecting a hermetic dist tree without re-running the release pipeline."
1028 )]
1029 pub preserve_dist: Option<PathBuf>,
1030 #[arg(
1031 long = "crate",
1032 value_name = "name",
1033 help = "When --preserve-dist is set, write the preserved dist tree to \
1034 <dest>/<name>/ instead of directly into <dest>/. Used by the \
1035 sharded matrix to produce per-crate subdirectories so a \
1036 `release --publish-only` job can merge all crates into a single \
1037 dist/ without context.json collision."
1038 )]
1039 pub crate_name: Option<String>,
1040 #[arg(
1053 long = "require-tools",
1054 help = "Fail (not warn-skip) if any selected stage's backing tool is missing — used by CI so a default host-OS run cannot silently skip an OS-native producer."
1055 )]
1056 pub require_tools: bool,
1057}
1058
1059fn parse_run_id(s: &str) -> Result<String, String> {
1072 anodizer_stage_publish::rollback::validate_run_id(s)
1073 .map(|()| s.to_string())
1074 .map_err(|err| format!("{:#}", err))
1075}
1076
1077pub fn detect_host_target() -> anyhow::Result<String> {
1080 anodizer_core::partial::detect_host_target()
1081}
1082
1083pub fn num_cpus() -> usize {
1085 std::thread::available_parallelism()
1086 .map(|n| n.get())
1087 .unwrap_or(4)
1088}
1089
1090pub fn build_cli() -> clap::Command {
1092 <Cli as clap::CommandFactory>::command()
1093}