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
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 #[command(subcommand)]
60 pub command: Option<Commands>,
61}
62
63#[derive(Subcommand)]
64#[allow(clippy::large_enum_variant)]
69pub enum Commands {
70 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 {
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 Check {
362 #[command(subcommand)]
363 cmd: CheckCmd,
364 },
365 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 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 Completion {
416 #[arg(value_enum, help = "Shell to generate completions for")]
417 shell: Shell,
418 },
419 Healthcheck,
421 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 Man,
451 Jsonschema,
453 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 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 Vocabulary {
479 #[arg(long, help = "Output as JSON")]
480 json: bool,
481 },
482 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 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 #[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 #[command(subcommand)]
577 sub: Option<TagSub>,
578 },
579 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 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 {
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 #[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 value_parser = parse_run_id,
719 help = "Promote what a prior release run recorded (reads dist/run-<id>/report.json)."
720 )]
721 from_run: Option<String>,
722 #[arg(
723 long,
724 help = "Resolve and print the plan without running any external command"
725 )]
726 dry_run: bool,
727 },
728 Bump {
734 #[arg(help = "patch | minor | major | <version> | release (omit to infer)")]
735 level_or_version: Option<String>,
736 #[arg(
737 long,
738 short = 'p',
739 visible_alias = "crate",
740 action = clap::ArgAction::Append,
741 help = "Bump a specific crate (repeatable)"
742 )]
743 package: Vec<String>,
744 #[arg(
745 long,
746 alias = "all",
747 conflicts_with = "package",
748 help = "Bump every workspace member (excluding publish=false)"
749 )]
750 workspace: bool,
751 #[arg(
752 long,
753 action = clap::ArgAction::Append,
754 help = "Exclude a crate from --workspace (repeatable)"
755 )]
756 exclude: Vec<String>,
757 #[arg(long, help = "Append a prerelease identifier (e.g. rc.1)")]
758 pre: Option<String>,
759 #[arg(long, help = "Do not rewrite dependents' [dependencies] version specs")]
760 exact: bool,
761 #[arg(
762 long,
763 help = "Proceed even if the working tree has uncommitted changes"
764 )]
765 allow_dirty: bool,
766 #[arg(long, short = 'y', help = "Skip confirmation prompt")]
767 yes: bool,
768 #[arg(long, help = "Print the plan without editing any files")]
769 dry_run: bool,
770 #[arg(long, help = "Stage edits and create a single commit")]
771 commit: bool,
772 #[arg(
773 long = "changelog",
774 requires = "commit",
775 help = "Refresh CHANGELOG.md in the bump commit (requires --commit and a `changelog:` config block)"
776 )]
777 changelog: bool,
778 #[arg(
779 long,
780 requires = "commit",
781 help = "GPG-sign the commit (requires --commit)"
782 )]
783 sign: bool,
784 #[arg(long, help = "Override the default commit message template")]
785 commit_message: Option<String>,
786 #[arg(
787 long,
788 default_value = "text",
789 help = "Output format: text | json (json requires --dry-run)"
790 )]
791 output: String,
792 },
793 Announce {
802 #[arg(long, help = "Run full pipeline without side effects")]
803 dry_run: bool,
804 #[arg(long, help = "Custom dist directory (overrides config)")]
805 dist: Option<PathBuf>,
806 #[arg(
807 long,
808 help = TOKEN_HELP.as_str()
809 )]
810 token: Option<String>,
811 #[arg(long, value_delimiter = ',', help = "Skip stages (comma-separated)")]
812 skip: Vec<String>,
813 #[arg(
814 long,
815 help = "Merge artifact lists from `release --split` workers (dist/<subdir>/context.json) before announcing. Mirrors `goreleaser announce --merge`."
816 )]
817 merge: bool,
818 },
819 Notify {
826 message: String,
829 #[arg(long = "publishers", value_delimiter = ',')]
833 publishers: Vec<String>,
834 #[arg(long = "skip", value_delimiter = ',')]
836 skip: Vec<String>,
837 #[arg(long)]
841 raw: bool,
842 #[arg(long = "allow-secrets")]
846 allow_secrets: bool,
847 #[arg(long)]
849 dry_run: bool,
850 },
851}
852
853#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)]
855pub enum ChangelogFormat {
856 #[default]
860 #[value(name = "keep-a-changelog", alias = "kac")]
861 KeepAChangelog,
862 ReleaseNotes,
865 Json,
868}
869
870#[derive(Subcommand)]
877pub enum TagSub {
878 Rollback {
881 #[arg(
882 value_name = "sha",
883 help = "Commit SHA to roll back from. Defaults to HEAD."
884 )]
885 sha: Option<String>,
886 #[arg(long, help = "Print what would happen without mutating anything")]
887 dry_run: bool,
888 #[arg(
889 long = "no-push",
890 help = "Skip remote tag delete and branch push (local-only)"
891 )]
892 no_push: bool,
893 #[arg(
894 long,
895 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"
896 )]
897 force: bool,
898 #[arg(
899 long,
900 default_value = "all",
901 help = "Tag-shape filter: all | lockstep | per-crate"
902 )]
903 scope: String,
904 #[arg(
905 long,
906 default_value = "revert",
907 help = "Rollback strategy: revert (default; history-preserving) | reset (opt-in; rewrites history, requires --force-with-lease to push)"
908 )]
909 mode: String,
910 #[arg(
911 long,
912 value_name = "name",
913 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)."
914 )]
915 branch: Option<String>,
916 },
917}
918
919#[derive(Subcommand)]
925pub enum CheckCmd {
926 Config {
928 #[arg(long, help = "Validate a specific workspace in a monorepo config")]
929 workspace: Option<String>,
930 #[arg(
931 long,
932 value_delimiter = ',',
933 help = "Validate these skip tokens (stages or publishers) against the known set \
934 without running anything (comma-separated). Unified denylist: a stage name \
935 skips the stage, a publisher name (npm, homebrew, chocolatey, …) skips \
936 that publisher."
937 )]
938 skip: Vec<String>,
939 #[arg(
940 long = "publishers",
941 value_delimiter = ',',
942 help = concat!(
943 "Validate-only: check that each name is a publisher the active config \
944 actually enables (a known but unconfigured publisher is rejected). ",
945 "Comma-separated publishers to run (default: all configured). \
946 --skip always wins over --publishers.",
947 )
948 )]
949 publishers: Vec<String>,
950 },
951 Determinism(CheckDeterminismArgs),
953 VersionFiles,
955}
956
957#[derive(clap::Args)]
958pub struct CheckDeterminismArgs {
959 #[arg(
960 long,
961 default_value = "2",
962 help = "Number of from-clean rebuilds to diff"
963 )]
964 pub runs: u32,
965 #[arg(
966 long,
967 value_name = "stages",
968 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."
969 )]
970 pub stages: Option<String>,
971 #[arg(
972 long,
973 value_name = "csv",
974 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."
975 )]
976 pub targets: Option<String>,
977 #[arg(
978 long,
979 value_name = "path",
980 help = "JSON report path; default dist/run-<id>/determinism.json"
981 )]
982 pub report: Option<PathBuf>,
983 #[arg(
984 long,
985 conflicts_with = "no_snapshot",
986 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."
987 )]
988 pub snapshot: bool,
989 #[arg(
990 long = "no-snapshot",
991 conflicts_with = "snapshot",
992 help = "Force snapshot mode OFF on the child release subprocess (artifacts emit the actual release version). Default: auto — see --snapshot."
993 )]
994 pub no_snapshot: bool,
995 #[arg(
996 long = "inject-drift",
997 value_name = "stage",
998 hide = true,
999 help = "(TEST HARNESS) Append 1 random byte to the first artifact emitted by <stage>. Gated by ANODIZE_TEST_HARNESS=1."
1000 )]
1001 pub inject_drift: Option<String>,
1002 #[arg(
1003 long = "preserve-dist",
1004 value_name = "path",
1005 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."
1006 )]
1007 pub preserve_dist: Option<PathBuf>,
1008 #[arg(
1009 long = "crate",
1010 value_name = "name",
1011 help = "When --preserve-dist is set, write the preserved dist tree to \
1012 <dest>/<name>/ instead of directly into <dest>/. Used by the \
1013 sharded matrix to produce per-crate subdirectories so a \
1014 `release --publish-only` job can merge all crates into a single \
1015 dist/ without context.json collision."
1016 )]
1017 pub crate_name: Option<String>,
1018 #[arg(
1031 long = "require-tools",
1032 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."
1033 )]
1034 pub require_tools: bool,
1035}
1036
1037fn parse_run_id(s: &str) -> Result<String, String> {
1050 anodizer_stage_publish::rollback_only::validate_run_id(s)
1051 .map(|()| s.to_string())
1052 .map_err(|err| format!("{:#}", err))
1053}
1054
1055pub fn detect_host_target() -> anyhow::Result<String> {
1058 anodizer_core::partial::detect_host_target()
1059}
1060
1061pub fn num_cpus() -> usize {
1063 std::thread::available_parallelism()
1064 .map(|n| n.get())
1065 .unwrap_or(4)
1066}
1067
1068pub fn build_cli() -> clap::Command {
1070 <Cli as clap::CommandFactory>::command()
1071}