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
11#[derive(Parser)]
12#[command(name = "anodizer", version, about = "Release Rust projects with ease")]
13pub struct Cli {
14 #[arg(
15 long,
16 short = 'f',
17 global = true,
18 help = "Path to config file (overrides auto-detection)"
19 )]
20 pub config: Option<PathBuf>,
21 #[arg(long, global = true, help = "Enable verbose output")]
22 pub verbose: bool,
23 #[arg(long, global = true, help = "Enable debug output")]
24 pub debug: bool,
25 #[arg(long, short = 'q', global = true, help = "Suppress non-error output")]
26 pub quiet: bool,
27 #[arg(
28 long,
29 global = true,
30 help = "Strict mode: configured features that silently skip become hard errors"
31 )]
32 pub strict: bool,
33 #[command(subcommand)]
39 pub command: Option<Commands>,
40}
41
42#[derive(Subcommand)]
43#[allow(clippy::large_enum_variant)]
48pub enum Commands {
49 Release {
51 #[arg(long = "crate", visible_alias = "id", action = clap::ArgAction::Append, help = "Release a specific crate (repeatable; --id is accepted as a GoReleaser-compat alias)")]
52 crate_names: Vec<String>,
53 #[arg(long, help = "Release all crates with unreleased changes")]
54 all: bool,
55 #[arg(long, help = "Force release even without unreleased changes")]
56 force: bool,
57 #[arg(long, help = "Build without publishing (snapshot mode)")]
58 snapshot: bool,
59 #[arg(long, help = "Create a nightly release with date-based version")]
60 nightly: bool,
61 #[arg(long, help = "Run full pipeline without side effects")]
62 dry_run: bool,
63 #[arg(long, help = "Remove dist directory before starting")]
64 clean: bool,
65 #[arg(
66 long,
67 value_delimiter = ',',
68 help = "Skip stages or publishers (comma-separated, e.g. docker,announce,npm). \
69 Unified denylist: a stage name skips the stage, a publisher name \
70 (npm, homebrew, chocolatey, …) skips that publisher."
71 )]
72 skip: Vec<String>,
73 #[arg(long = "publishers", value_delimiter = ',', help = PUBLISHERS_HELP_STEM)]
74 publishers: Vec<String>,
75 #[arg(
76 long,
77 help = "GitHub token (overrides ANODIZER_GITHUB_TOKEN / GITHUB_TOKEN env vars)"
78 )]
79 token: Option<String>,
80 #[arg(
81 long,
82 default_value = "3h",
83 help = "Pipeline timeout duration (e.g., 90m, 3h, 5s) — a generous \
84 safety backstop, not the primary bound; per-stage bounds \
85 (e.g. announce.deadline) catch a hung stage in seconds"
86 )]
87 timeout: String,
88 #[arg(
89 long,
90 short = 'p',
91 help = "Maximum number of parallel build jobs (default: number of CPUs)"
92 )]
93 parallelism: Option<usize>,
94 #[arg(long, help = "Automatically set --snapshot if the git repo is dirty")]
95 auto_snapshot: bool,
96 #[arg(long, help = "Build only for the host target triple")]
97 single_target: bool,
98 #[arg(
99 long,
100 value_name = "csv",
101 conflicts_with = "single_target",
102 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."
103 )]
104 targets: Option<String>,
105 #[arg(
106 long = "host-targets",
107 conflicts_with = "single_target",
108 conflicts_with = "targets",
109 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."
110 )]
111 host_targets: bool,
112 #[arg(
113 long,
114 help = "Path to a custom release notes file (overrides changelog)"
115 )]
116 release_notes: Option<PathBuf>,
117 #[arg(
118 long,
119 conflicts_with = "crate_names",
120 help = "Release a specific workspace in a monorepo config"
121 )]
122 workspace: Option<String>,
123 #[arg(
124 long,
125 conflicts_with = "no_preflight",
126 help = "Run pre-flight publisher-state check and exit (don't start the pipeline)"
127 )]
128 preflight: bool,
129 #[arg(
130 long,
131 conflicts_with = "preflight",
132 help = "Skip the automatic pre-flight publisher-state check"
133 )]
134 no_preflight: bool,
135 #[arg(
136 long = "preflight-secrets",
137 conflicts_with = "no_preflight",
138 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."
139 )]
140 preflight_secrets: bool,
141 #[arg(
142 long,
143 help = "Alias for --strict (also treats Unknown publisher state as a blocker during pre-flight)"
144 )]
145 strict_preflight: bool,
146 #[arg(long, help = "Set the release as a draft")]
147 draft: bool,
148 #[arg(long, help = "Path to a file containing custom release header text")]
149 release_header: Option<PathBuf>,
150 #[arg(
151 long,
152 help = "Path to a template file for release header (rendered with template variables)"
153 )]
154 release_header_tmpl: Option<PathBuf>,
155 #[arg(long, help = "Path to a file containing custom release footer text")]
156 release_footer: Option<PathBuf>,
157 #[arg(
158 long,
159 help = "Path to a template file for release footer (rendered with template variables)"
160 )]
161 release_footer_tmpl: Option<PathBuf>,
162 #[arg(
163 long,
164 help = "Path to a template file for release notes (rendered with template variables, overrides --release-notes)"
165 )]
166 release_notes_tmpl: Option<PathBuf>,
167 #[arg(long, help = "Abort immediately on first error during publishing")]
168 fail_fast: bool,
169 #[arg(
170 long = "no-gate-submitter",
171 help = "Disable the Submitter gate: dispatch Submitter publishers even when required Assets/Manager publishers failed"
172 )]
173 no_gate_submitter: bool,
174 #[arg(
175 long = "rollback",
176 value_name = "none|best-effort",
177 help = "Rollback policy after publish stage. Defaults to best-effort when preflight is clean, none otherwise."
178 )]
179 rollback: Option<String>,
180 #[arg(
181 long = "simulate-failure",
182 value_name = "publisher",
183 action = clap::ArgAction::Append,
184 hide = true,
185 help = "(TEST HARNESS) Force a named publisher to fail. Gated by ANODIZE_TEST_HARNESS=1."
186 )]
187 simulate_failure: Vec<String>,
188 #[arg(
189 long = "rollback-only",
190 requires = "from_run",
191 conflicts_with = "clean",
192 help = "Skip publish; re-attempt rollback from a prior run report. Requires --from-run=<id>."
193 )]
194 rollback_only: bool,
195 #[arg(
196 long = "from-run",
197 value_name = "id",
198 requires = "rollback_only",
199 value_parser = parse_run_id,
200 help = "Prior run id whose state to load when running --rollback-only. \
201 Loads <dist>/run-<id>/rollback.json if present (a prior replay's state), \
202 otherwise <dist>/run-<id>/report.json. Delete rollback.json to force a \
203 full re-roll. Must match the run_id format written by the release pipeline \
204 (alphanumeric, dot, dash, underscore; no path separators)."
205 )]
206 from_run: Option<String>,
207 #[arg(
208 long = "allow-rerun",
209 conflicts_with = "rollback_only",
210 help = "DANGEROUS: force publish to proceed even when a prior \
211 dist/run-<id>/report.json exists for this tag. PR-based publishers \
212 (homebrew, scoop, nix, krew, MCP) will open DUPLICATE pull requests. \
213 Recover from partial failures with --rollback-only --from-run=<id> first. \
214 Cannot be combined with --rollback-only (which has its own idempotency)."
215 )]
216 allow_rerun: bool,
217 #[arg(
218 long = "show-skipped",
219 help = "Show per-crate 'no <publisher> config block' skip lines at default verbosity \
220 (normally only visible with --debug). Use to diagnose why a publisher didn't \
221 run for a given crate."
222 )]
223 show_skipped: bool,
224 #[arg(
225 long = "allow-nondeterministic",
226 value_name = "name=reason",
227 action = clap::ArgAction::Append,
228 help = "Runtime non-determinism opt-out for a specific artifact (repeatable). Mutually exclusive with --strict."
229 )]
230 allow_nondeterministic: Vec<String>,
231 #[arg(
232 long = "summary-json",
233 value_name = "path",
234 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."
235 )]
236 summary_json: Option<PathBuf>,
237 #[arg(
238 long = "allow-ai-failure",
239 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."
240 )]
241 allow_ai_failure: bool,
242 #[arg(
243 long = "allow-snapshot-publish",
244 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."
245 )]
246 allow_snapshot_publish: bool,
247 #[arg(
248 long,
249 conflicts_with = "merge",
250 help = "Run only the build stage for split CI fan-out (outputs artifacts JSON to dist/)"
251 )]
252 split: bool,
253 #[arg(
254 long,
255 conflicts_with = "split",
256 help = "Merge artifacts from split build jobs and resume the pipeline from post-build stages"
257 )]
258 merge: bool,
259 #[arg(
260 long = "publish-only",
261 conflicts_with_all = ["split", "merge", "prepare", "announce_only", "snapshot", "rollback_only", "clean"],
262 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/."
263 )]
264 publish_only: bool,
265 #[arg(
266 long,
267 alias = "prepare-only",
268 conflicts_with_all = ["publish_only", "announce_only", "rollback_only"],
269 help = "Run local build + archive + sign + checksum + sbom stages but skip release / publish / announce (GoReleaser Pro parity). Artifacts stay in dist/ for inspection. `--prepare-only` is accepted as an alias for GR-imported scripts."
270 )]
271 prepare: bool,
272 #[arg(
273 long = "announce-only",
274 conflicts_with_all = ["prepare", "publish_only", "snapshot", "rollback_only", "split", "merge", "clean"],
275 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."
276 )]
277 announce_only: bool,
278 #[arg(
279 long,
280 help = "Resume into an existing release left over from a prior failed attempt; bypasses the safety check that bails on partial assets."
281 )]
282 resume_release: bool,
283 #[arg(
284 long,
285 help = "Force release.replace_existing_artifacts: true regardless of config (overwrite conflicting assets on retry)."
286 )]
287 replace_existing: bool,
288 #[arg(
289 long = "no-post-publish-poll",
290 help = "Skip post-publish polling for chocolatey moderation / winget PR validation; report NotPolled for affected publishers."
291 )]
292 no_post_publish_poll: bool,
293 #[arg(
294 long = "no-failure-policy",
295 hide = true,
296 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."
297 )]
298 no_failure_policy: bool,
299 },
300 Build {
302 #[arg(long = "crate", action = clap::ArgAction::Append, help = "Build a specific crate (repeatable)")]
303 crate_names: Vec<String>,
304 #[arg(
305 long,
306 default_value = "3h",
307 help = "Pipeline timeout duration (e.g., 90m, 3h, 5s) — a generous \
308 safety backstop, not the primary bound; per-stage bounds \
309 (e.g. announce.deadline) catch a hung stage in seconds"
310 )]
311 timeout: String,
312 #[arg(
313 long,
314 short = 'p',
315 help = "Maximum number of parallel build jobs (default: number of CPUs)"
316 )]
317 parallelism: Option<usize>,
318 #[arg(long, help = "Build only for the host target triple")]
319 single_target: bool,
320 #[arg(
321 long,
322 conflicts_with = "crate_names",
323 help = "Build a specific workspace in a monorepo config"
324 )]
325 workspace: Option<String>,
326 #[arg(
327 long,
328 short = 'o',
329 help = "Copy the built binary to this path (requires --single-target and single crate)"
330 )]
331 output: Option<PathBuf>,
332 #[arg(
333 long,
334 value_delimiter = ',',
335 help = "Skip stages (comma-separated: pre-hooks, post-hooks, validate, before)"
336 )]
337 skip: Vec<String>,
338 },
339 Check {
341 #[command(subcommand)]
342 cmd: CheckCmd,
343 },
344 Init {
346 #[arg(
347 long,
348 help = "Discover repo files that embed the current version and enroll the selection into version_files in .anodizer.yaml"
349 )]
350 version_files: bool,
351 #[arg(
352 long,
353 value_delimiter = ',',
354 requires = "version_files",
355 help = "Glob(s) to drop from discovered candidates (repeatable or comma-separated); only with --version-files"
356 )]
357 exclude: Vec<String>,
358 #[arg(
359 long,
360 short = 'y',
361 requires = "version_files",
362 help = "Non-interactive: enroll all discovered candidates without prompting"
363 )]
364 yes: bool,
365 },
366 Changelog {
368 #[arg(
369 value_name = "tag|range",
370 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"
371 )]
372 range: Option<String>,
373 #[arg(
374 long,
375 value_enum,
376 default_value = "keep-a-changelog",
377 help = "Output format: keep-a-changelog (refresh the [Unreleased] section), release-notes (grouped-bullet GitHub body to stdout), or json"
378 )]
379 format: ChangelogFormat,
380 #[arg(
381 long,
382 help = "Apply the regenerated [Unreleased] section to the configured CHANGELOG.md file(s) in place (keep-a-changelog only)"
383 )]
384 write: bool,
385 #[arg(long = "crate", help = "Restrict to a specific crate in a workspace")]
386 crate_name: Option<String>,
387 #[arg(
388 long,
389 help = "Preview as a snapshot release (release-notes format only)"
390 )]
391 snapshot: bool,
392 },
393 Completion {
395 #[arg(value_enum, help = "Shell to generate completions for")]
396 shell: Shell,
397 },
398 Healthcheck,
400 Preflight {
407 #[arg(long, help = "Output the report as JSON")]
408 json: bool,
409 #[arg(
410 long,
411 help = "Check only the publish-time surface (the stages `release --publish-only` runs), not artifact-producing stages"
412 )]
413 publish_only: bool,
414 #[arg(
415 long,
416 value_delimiter = ',',
417 help = "Skip requirement collection for these stages (comma-separated, same names as release --skip)"
418 )]
419 skip: Vec<String>,
420 #[arg(long = "publishers", value_delimiter = ',', help = PUBLISHERS_HELP_STEM)]
421 publishers: Vec<String>,
422 #[arg(
423 long,
424 help = "GitHub token override; when set, GitHub token env-var requirements are treated as satisfied"
425 )]
426 token: Option<String>,
427 },
428 Man,
430 Jsonschema,
432 ResolveTag {
434 #[arg(help = "Tag to resolve (e.g. 'v1.2.3', 'core-v0.2.3')")]
435 tag: String,
436 #[arg(long, help = "Output as JSON")]
437 json: bool,
438 },
439 Targets {
445 #[arg(long, help = "Output as JSON (include-form matrix)")]
446 json: bool,
447 #[arg(long = "crate", action = clap::ArgAction::Append, help = "Restrict to specific crate(s)")]
448 crate_names: Vec<String>,
449 },
450 Vocabulary {
458 #[arg(long, help = "Output as JSON")]
459 json: bool,
460 },
461 Tools {
468 #[arg(long, help = "Output as JSON")]
469 json: bool,
470 #[arg(
471 long,
472 help = "Only the tools the publish-time surface needs (the stages `release --publish-only` runs), not artifact-producing stages"
473 )]
474 publish_only: bool,
475 #[arg(
476 long,
477 value_delimiter = ',',
478 help = "Drop tools contributed by these skipped stages (comma-separated, same names as release --skip)"
479 )]
480 skip: Vec<String>,
481 #[arg(long = "publishers", value_delimiter = ',', help = PUBLISHERS_HELP_STEM)]
482 publishers: Vec<String>,
483 },
484 Tag {
486 #[arg(long, help = "Show what tag would be created without pushing")]
487 dry_run: bool,
488 #[arg(long, help = "Override bump logic with a specific tag value")]
489 custom_tag: Option<String>,
490 #[arg(long = "version", value_name = "VERSION")]
501 version_override: Option<String>,
502 #[arg(long, help = "Override default bump type (patch/minor/major)")]
503 default_bump: Option<String>,
504 #[arg(long = "crate", help = "Tag a specific crate in a workspace")]
505 crate_name: Option<String>,
506 #[arg(
507 long,
508 help = "Push the version-sync bump commit to the release branch atomically with the tag"
509 )]
510 push: bool,
511 #[arg(
512 long,
513 conflicts_with = "push",
514 help = "Push the tag only, leaving the version-sync bump commit local"
515 )]
516 no_push: bool,
517 #[arg(
518 long,
519 value_name = "NAME",
520 help = "Remote to push to (default: origin)"
521 )]
522 push_remote: Option<String>,
523 #[arg(
524 long,
525 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"
526 )]
527 push_dry_run: bool,
528 #[arg(
529 long = "changelog",
530 help = "Refresh CHANGELOG.md as part of this tag (requires a `changelog:` config block)"
531 )]
532 changelog: bool,
533 #[command(subcommand)]
539 sub: Option<TagSub>,
540 },
541 Continue {
561 #[arg(
562 long,
563 help = "Merge artifacts from split build jobs and run post-build stages"
564 )]
565 merge: bool,
566 #[arg(long, help = "Custom dist directory (overrides config)")]
567 dist: Option<PathBuf>,
568 #[arg(long, help = "Run full pipeline without side effects")]
569 dry_run: bool,
570 #[arg(
571 long,
572 value_delimiter = ',',
573 help = "Skip stages or publishers (comma-separated, e.g. docker,announce,npm). \
574 Unified denylist: a stage name skips the stage, a publisher name \
575 (npm, homebrew, chocolatey, …) skips that publisher."
576 )]
577 skip: Vec<String>,
578 #[arg(long = "publishers", value_delimiter = ',', help = PUBLISHERS_HELP_STEM)]
579 publishers: Vec<String>,
580 #[arg(
581 long,
582 help = "GitHub token (overrides ANODIZER_GITHUB_TOKEN / GITHUB_TOKEN env vars)"
583 )]
584 token: Option<String>,
585 },
586 Publish {
599 #[arg(long, help = "Run full pipeline without side effects")]
600 dry_run: bool,
601 #[arg(
602 long,
603 help = "GitHub token (overrides ANODIZER_GITHUB_TOKEN / GITHUB_TOKEN env vars)"
604 )]
605 token: Option<String>,
606 #[arg(long, help = "Custom dist directory (overrides config)")]
607 dist: Option<PathBuf>,
608 #[arg(
609 long,
610 help = "Merge artifacts from `release --split` workers (dist/<subdir>/context.json) before running the publish-only pipeline. Mirrors `goreleaser publish --merge`."
611 )]
612 merge: bool,
613 #[arg(
614 long,
615 help = "Force re-publish even when a prior report.json exists. \
616 WARNING: PR-based publishers will open duplicate pull requests."
617 )]
618 allow_rerun: bool,
619 #[arg(
620 long = "show-skipped",
621 help = "Show per-crate 'no <publisher> config block' skip lines at default verbosity \
622 (normally only visible with --debug). Use to diagnose why a publisher didn't \
623 run for a given crate."
624 )]
625 show_skipped: bool,
626 #[arg(
627 long,
628 value_delimiter = ',',
629 help = "Skip stages or publishers (comma-separated, e.g. npm,blob). \
630 Unified denylist: a stage name skips the stage, a publisher name \
631 (npm, homebrew, chocolatey, …) skips that publisher."
632 )]
633 skip: Vec<String>,
634 #[arg(long = "publishers", value_delimiter = ',', help = PUBLISHERS_HELP_STEM)]
635 publishers: Vec<String>,
636 },
637 Bump {
643 #[arg(help = "patch | minor | major | <version> | release (omit to infer)")]
644 level_or_version: Option<String>,
645 #[arg(
646 long,
647 short = 'p',
648 visible_alias = "crate",
649 action = clap::ArgAction::Append,
650 help = "Bump a specific crate (repeatable)"
651 )]
652 package: Vec<String>,
653 #[arg(
654 long,
655 alias = "all",
656 conflicts_with = "package",
657 help = "Bump every workspace member (excluding publish=false)"
658 )]
659 workspace: bool,
660 #[arg(
661 long,
662 action = clap::ArgAction::Append,
663 help = "Exclude a crate from --workspace (repeatable)"
664 )]
665 exclude: Vec<String>,
666 #[arg(long, help = "Append a prerelease identifier (e.g. rc.1)")]
667 pre: Option<String>,
668 #[arg(long, help = "Do not rewrite dependents' [dependencies] version specs")]
669 exact: bool,
670 #[arg(
671 long,
672 help = "Proceed even if the working tree has uncommitted changes"
673 )]
674 allow_dirty: bool,
675 #[arg(long, short = 'y', help = "Skip confirmation prompt")]
676 yes: bool,
677 #[arg(long, help = "Print the plan without editing any files")]
678 dry_run: bool,
679 #[arg(long, help = "Stage edits and create a single commit")]
680 commit: bool,
681 #[arg(
682 long = "changelog",
683 requires = "commit",
684 help = "Refresh CHANGELOG.md in the bump commit (requires --commit and a `changelog:` config block)"
685 )]
686 changelog: bool,
687 #[arg(
688 long,
689 requires = "commit",
690 help = "GPG-sign the commit (requires --commit)"
691 )]
692 sign: bool,
693 #[arg(long, help = "Override the default commit message template")]
694 commit_message: Option<String>,
695 #[arg(
696 long,
697 default_value = "text",
698 help = "Output format: text | json (json requires --dry-run)"
699 )]
700 output: String,
701 },
702 Announce {
711 #[arg(long, help = "Run full pipeline without side effects")]
712 dry_run: bool,
713 #[arg(long, help = "Custom dist directory (overrides config)")]
714 dist: Option<PathBuf>,
715 #[arg(
716 long,
717 help = "GitHub token (overrides ANODIZER_GITHUB_TOKEN / GITHUB_TOKEN env vars)"
718 )]
719 token: Option<String>,
720 #[arg(long, value_delimiter = ',', help = "Skip stages (comma-separated)")]
721 skip: Vec<String>,
722 #[arg(
723 long,
724 help = "Merge artifact lists from `release --split` workers (dist/<subdir>/context.json) before announcing. Mirrors `goreleaser announce --merge`."
725 )]
726 merge: bool,
727 },
728 Notify {
735 message: String,
738 #[arg(long = "publishers", value_delimiter = ',')]
742 publishers: Vec<String>,
743 #[arg(long = "skip", value_delimiter = ',')]
745 skip: Vec<String>,
746 #[arg(long)]
750 raw: bool,
751 #[arg(long = "allow-secrets")]
755 allow_secrets: bool,
756 #[arg(long)]
758 dry_run: bool,
759 },
760}
761
762#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)]
764pub enum ChangelogFormat {
765 #[default]
769 #[value(name = "keep-a-changelog", alias = "kac")]
770 KeepAChangelog,
771 ReleaseNotes,
774 Json,
777}
778
779#[derive(Subcommand)]
786pub enum TagSub {
787 Rollback {
790 #[arg(
791 value_name = "sha",
792 help = "Commit SHA to roll back from. Defaults to HEAD."
793 )]
794 sha: Option<String>,
795 #[arg(long, help = "Print what would happen without mutating anything")]
796 dry_run: bool,
797 #[arg(
798 long = "no-push",
799 help = "Skip remote tag delete and branch push (local-only)"
800 )]
801 no_push: bool,
802 #[arg(
803 long,
804 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, 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"
805 )]
806 force: bool,
807 #[arg(
808 long,
809 default_value = "all",
810 help = "Tag-shape filter: all | lockstep | per-crate"
811 )]
812 scope: String,
813 #[arg(
814 long,
815 default_value = "revert",
816 help = "Rollback strategy: revert (default; history-preserving) | reset (opt-in; rewrites history, requires --force-with-lease to push)"
817 )]
818 mode: String,
819 #[arg(
820 long,
821 value_name = "name",
822 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)."
823 )]
824 branch: Option<String>,
825 },
826}
827
828#[derive(Subcommand)]
834pub enum CheckCmd {
835 Config {
837 #[arg(long, help = "Validate a specific workspace in a monorepo config")]
838 workspace: Option<String>,
839 #[arg(
840 long,
841 value_delimiter = ',',
842 help = "Validate these skip tokens (stages or publishers) against the known set \
843 without running anything (comma-separated). Unified denylist: a stage name \
844 skips the stage, a publisher name (npm, homebrew, chocolatey, …) skips \
845 that publisher."
846 )]
847 skip: Vec<String>,
848 #[arg(
849 long = "publishers",
850 value_delimiter = ',',
851 help = concat!(
852 "Validate-only: check that each name is a publisher the active config \
853 actually enables (a known but unconfigured publisher is rejected). ",
854 "Comma-separated publishers to run (default: all configured). \
855 --skip always wins over --publishers.",
856 )
857 )]
858 publishers: Vec<String>,
859 },
860 Determinism(CheckDeterminismArgs),
862 VersionFiles,
864}
865
866#[derive(clap::Args)]
867pub struct CheckDeterminismArgs {
868 #[arg(
869 long,
870 default_value = "2",
871 help = "Number of from-clean rebuilds to diff"
872 )]
873 pub runs: u32,
874 #[arg(
875 long,
876 value_name = "stages",
877 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 `<repo>/Dockerfile` to probe OCI image byte-stability without pushing to a registry; skipped when `docker buildx` is unavailable or no Dockerfile exists. 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."
878 )]
879 pub stages: Option<String>,
880 #[arg(
881 long,
882 value_name = "csv",
883 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."
884 )]
885 pub targets: Option<String>,
886 #[arg(
887 long,
888 value_name = "path",
889 help = "JSON report path; default dist/run-<id>/determinism.json"
890 )]
891 pub report: Option<PathBuf>,
892 #[arg(
893 long,
894 conflicts_with = "no_snapshot",
895 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."
896 )]
897 pub snapshot: bool,
898 #[arg(
899 long = "no-snapshot",
900 conflicts_with = "snapshot",
901 help = "Force snapshot mode OFF on the child release subprocess (artifacts emit the actual release version). Default: auto — see --snapshot."
902 )]
903 pub no_snapshot: bool,
904 #[arg(
905 long = "inject-drift",
906 value_name = "stage",
907 hide = true,
908 help = "(TEST HARNESS) Append 1 random byte to the first artifact emitted by <stage>. Gated by ANODIZE_TEST_HARNESS=1."
909 )]
910 pub inject_drift: Option<String>,
911 #[arg(
912 long = "preserve-dist",
913 value_name = "path",
914 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."
915 )]
916 pub preserve_dist: Option<PathBuf>,
917 #[arg(
918 long = "crate",
919 value_name = "name",
920 help = "When --preserve-dist is set, write the preserved dist tree to \
921 <dest>/<name>/ instead of directly into <dest>/. Used by the \
922 sharded matrix to produce per-crate subdirectories so a \
923 `release --publish-only` job can merge all crates into a single \
924 dist/ without context.json collision."
925 )]
926 pub crate_name: Option<String>,
927 #[arg(
940 long = "require-tools",
941 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."
942 )]
943 pub require_tools: bool,
944}
945
946fn parse_run_id(s: &str) -> Result<String, String> {
959 anodizer_stage_publish::rollback_only::validate_run_id(s)
960 .map(|()| s.to_string())
961 .map_err(|err| format!("{:#}", err))
962}
963
964pub fn detect_host_target() -> anyhow::Result<String> {
967 anodizer_core::partial::detect_host_target()
968}
969
970pub fn num_cpus() -> usize {
972 std::thread::available_parallelism()
973 .map(|n| n.get())
974 .unwrap_or(4)
975}
976
977pub fn build_cli() -> clap::Command {
979 <Cli as clap::CommandFactory>::command()
980}