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"
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 Bump {
681 #[arg(help = "patch | minor | major | <version> | release (omit to infer)")]
682 level_or_version: Option<String>,
683 #[arg(
684 long,
685 short = 'p',
686 visible_alias = "crate",
687 action = clap::ArgAction::Append,
688 help = "Bump a specific crate (repeatable)"
689 )]
690 package: Vec<String>,
691 #[arg(
692 long,
693 alias = "all",
694 conflicts_with = "package",
695 help = "Bump every workspace member (excluding publish=false)"
696 )]
697 workspace: bool,
698 #[arg(
699 long,
700 action = clap::ArgAction::Append,
701 help = "Exclude a crate from --workspace (repeatable)"
702 )]
703 exclude: Vec<String>,
704 #[arg(long, help = "Append a prerelease identifier (e.g. rc.1)")]
705 pre: Option<String>,
706 #[arg(long, help = "Do not rewrite dependents' [dependencies] version specs")]
707 exact: bool,
708 #[arg(
709 long,
710 help = "Proceed even if the working tree has uncommitted changes"
711 )]
712 allow_dirty: bool,
713 #[arg(long, short = 'y', help = "Skip confirmation prompt")]
714 yes: bool,
715 #[arg(long, help = "Print the plan without editing any files")]
716 dry_run: bool,
717 #[arg(long, help = "Stage edits and create a single commit")]
718 commit: bool,
719 #[arg(
720 long = "changelog",
721 requires = "commit",
722 help = "Refresh CHANGELOG.md in the bump commit (requires --commit and a `changelog:` config block)"
723 )]
724 changelog: bool,
725 #[arg(
726 long,
727 requires = "commit",
728 help = "GPG-sign the commit (requires --commit)"
729 )]
730 sign: bool,
731 #[arg(long, help = "Override the default commit message template")]
732 commit_message: Option<String>,
733 #[arg(
734 long,
735 default_value = "text",
736 help = "Output format: text | json (json requires --dry-run)"
737 )]
738 output: String,
739 },
740 Announce {
749 #[arg(long, help = "Run full pipeline without side effects")]
750 dry_run: bool,
751 #[arg(long, help = "Custom dist directory (overrides config)")]
752 dist: Option<PathBuf>,
753 #[arg(
754 long,
755 help = TOKEN_HELP.as_str()
756 )]
757 token: Option<String>,
758 #[arg(long, value_delimiter = ',', help = "Skip stages (comma-separated)")]
759 skip: Vec<String>,
760 #[arg(
761 long,
762 help = "Merge artifact lists from `release --split` workers (dist/<subdir>/context.json) before announcing. Mirrors `goreleaser announce --merge`."
763 )]
764 merge: bool,
765 },
766 Notify {
773 message: String,
776 #[arg(long = "publishers", value_delimiter = ',')]
780 publishers: Vec<String>,
781 #[arg(long = "skip", value_delimiter = ',')]
783 skip: Vec<String>,
784 #[arg(long)]
788 raw: bool,
789 #[arg(long = "allow-secrets")]
793 allow_secrets: bool,
794 #[arg(long)]
796 dry_run: bool,
797 },
798}
799
800#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)]
802pub enum ChangelogFormat {
803 #[default]
807 #[value(name = "keep-a-changelog", alias = "kac")]
808 KeepAChangelog,
809 ReleaseNotes,
812 Json,
815}
816
817#[derive(Subcommand)]
824pub enum TagSub {
825 Rollback {
828 #[arg(
829 value_name = "sha",
830 help = "Commit SHA to roll back from. Defaults to HEAD."
831 )]
832 sha: Option<String>,
833 #[arg(long, help = "Print what would happen without mutating anything")]
834 dry_run: bool,
835 #[arg(
836 long = "no-push",
837 help = "Skip remote tag delete and branch push (local-only)"
838 )]
839 no_push: bool,
840 #[arg(
841 long,
842 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"
843 )]
844 force: bool,
845 #[arg(
846 long,
847 default_value = "all",
848 help = "Tag-shape filter: all | lockstep | per-crate"
849 )]
850 scope: String,
851 #[arg(
852 long,
853 default_value = "revert",
854 help = "Rollback strategy: revert (default; history-preserving) | reset (opt-in; rewrites history, requires --force-with-lease to push)"
855 )]
856 mode: String,
857 #[arg(
858 long,
859 value_name = "name",
860 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)."
861 )]
862 branch: Option<String>,
863 },
864}
865
866#[derive(Subcommand)]
872pub enum CheckCmd {
873 Config {
875 #[arg(long, help = "Validate a specific workspace in a monorepo config")]
876 workspace: Option<String>,
877 #[arg(
878 long,
879 value_delimiter = ',',
880 help = "Validate these skip tokens (stages or publishers) against the known set \
881 without running anything (comma-separated). Unified denylist: a stage name \
882 skips the stage, a publisher name (npm, homebrew, chocolatey, …) skips \
883 that publisher."
884 )]
885 skip: Vec<String>,
886 #[arg(
887 long = "publishers",
888 value_delimiter = ',',
889 help = concat!(
890 "Validate-only: check that each name is a publisher the active config \
891 actually enables (a known but unconfigured publisher is rejected). ",
892 "Comma-separated publishers to run (default: all configured). \
893 --skip always wins over --publishers.",
894 )
895 )]
896 publishers: Vec<String>,
897 },
898 Determinism(CheckDeterminismArgs),
900 VersionFiles,
902}
903
904#[derive(clap::Args)]
905pub struct CheckDeterminismArgs {
906 #[arg(
907 long,
908 default_value = "2",
909 help = "Number of from-clean rebuilds to diff"
910 )]
911 pub runs: u32,
912 #[arg(
913 long,
914 value_name = "stages",
915 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."
916 )]
917 pub stages: Option<String>,
918 #[arg(
919 long,
920 value_name = "csv",
921 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."
922 )]
923 pub targets: Option<String>,
924 #[arg(
925 long,
926 value_name = "path",
927 help = "JSON report path; default dist/run-<id>/determinism.json"
928 )]
929 pub report: Option<PathBuf>,
930 #[arg(
931 long,
932 conflicts_with = "no_snapshot",
933 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."
934 )]
935 pub snapshot: bool,
936 #[arg(
937 long = "no-snapshot",
938 conflicts_with = "snapshot",
939 help = "Force snapshot mode OFF on the child release subprocess (artifacts emit the actual release version). Default: auto — see --snapshot."
940 )]
941 pub no_snapshot: bool,
942 #[arg(
943 long = "inject-drift",
944 value_name = "stage",
945 hide = true,
946 help = "(TEST HARNESS) Append 1 random byte to the first artifact emitted by <stage>. Gated by ANODIZE_TEST_HARNESS=1."
947 )]
948 pub inject_drift: Option<String>,
949 #[arg(
950 long = "preserve-dist",
951 value_name = "path",
952 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."
953 )]
954 pub preserve_dist: Option<PathBuf>,
955 #[arg(
956 long = "crate",
957 value_name = "name",
958 help = "When --preserve-dist is set, write the preserved dist tree to \
959 <dest>/<name>/ instead of directly into <dest>/. Used by the \
960 sharded matrix to produce per-crate subdirectories so a \
961 `release --publish-only` job can merge all crates into a single \
962 dist/ without context.json collision."
963 )]
964 pub crate_name: Option<String>,
965 #[arg(
978 long = "require-tools",
979 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."
980 )]
981 pub require_tools: bool,
982}
983
984fn parse_run_id(s: &str) -> Result<String, String> {
997 anodizer_stage_publish::rollback_only::validate_run_id(s)
998 .map(|()| s.to_string())
999 .map_err(|err| format!("{:#}", err))
1000}
1001
1002pub fn detect_host_target() -> anyhow::Result<String> {
1005 anodizer_core::partial::detect_host_target()
1006}
1007
1008pub fn num_cpus() -> usize {
1010 std::thread::available_parallelism()
1011 .map(|n| n.get())
1012 .unwrap_or(4)
1013}
1014
1015pub fn build_cli() -> clap::Command {
1017 <Cli as clap::CommandFactory>::command()
1018}