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 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 {
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 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 Notify {
825 message: String,
828 #[arg(long = "publishers", value_delimiter = ',')]
832 publishers: Vec<String>,
833 #[arg(long = "skip", value_delimiter = ',')]
835 skip: Vec<String>,
836 #[arg(long)]
840 raw: bool,
841 #[arg(long = "allow-secrets")]
845 allow_secrets: bool,
846 #[arg(long)]
848 dry_run: bool,
849 },
850}
851
852#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)]
854pub enum ChangelogFormat {
855 #[default]
859 #[value(name = "keep-a-changelog", alias = "kac")]
860 KeepAChangelog,
861 ReleaseNotes,
864 Json,
867}
868
869#[derive(Subcommand)]
876pub enum TagSub {
877 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#[derive(Subcommand)]
924pub enum CheckCmd {
925 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 Determinism(CheckDeterminismArgs),
952 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 #[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
1036fn 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
1054pub fn detect_host_target() -> anyhow::Result<String> {
1057 anodizer_core::partial::detect_host_target()
1058}
1059
1060pub fn num_cpus() -> usize {
1062 std::thread::available_parallelism()
1063 .map(|n| n.get())
1064 .unwrap_or(4)
1065}
1066
1067pub fn build_cli() -> clap::Command {
1069 <Cli as clap::CommandFactory>::command()
1070}