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 Tag {
452 #[arg(long, help = "Show what tag would be created without pushing")]
453 dry_run: bool,
454 #[arg(long, help = "Override bump logic with a specific tag value")]
455 custom_tag: Option<String>,
456 #[arg(long = "version", value_name = "VERSION")]
467 version_override: Option<String>,
468 #[arg(long, help = "Override default bump type (patch/minor/major)")]
469 default_bump: Option<String>,
470 #[arg(long = "crate", help = "Tag a specific crate in a workspace")]
471 crate_name: Option<String>,
472 #[arg(
473 long,
474 help = "Push the version-sync bump commit to the release branch atomically with the tag"
475 )]
476 push: bool,
477 #[arg(
478 long,
479 conflicts_with = "push",
480 help = "Push the tag only, leaving the version-sync bump commit local"
481 )]
482 no_push: bool,
483 #[arg(
484 long,
485 value_name = "NAME",
486 help = "Remote to push to (default: origin)"
487 )]
488 push_remote: Option<String>,
489 #[arg(
490 long,
491 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"
492 )]
493 push_dry_run: bool,
494 #[arg(
495 long = "changelog",
496 help = "Refresh CHANGELOG.md as part of this tag (requires a `changelog:` config block)"
497 )]
498 changelog: bool,
499 #[command(subcommand)]
505 sub: Option<TagSub>,
506 },
507 Continue {
527 #[arg(
528 long,
529 help = "Merge artifacts from split build jobs and run post-build stages"
530 )]
531 merge: bool,
532 #[arg(long, help = "Custom dist directory (overrides config)")]
533 dist: Option<PathBuf>,
534 #[arg(long, help = "Run full pipeline without side effects")]
535 dry_run: bool,
536 #[arg(
537 long,
538 value_delimiter = ',',
539 help = "Skip stages or publishers (comma-separated, e.g. docker,announce,npm). \
540 Unified denylist: a stage name skips the stage, a publisher name \
541 (npm, homebrew, chocolatey, …) skips that publisher."
542 )]
543 skip: Vec<String>,
544 #[arg(long = "publishers", value_delimiter = ',', help = PUBLISHERS_HELP_STEM)]
545 publishers: Vec<String>,
546 #[arg(
547 long,
548 help = "GitHub token (overrides ANODIZER_GITHUB_TOKEN / GITHUB_TOKEN env vars)"
549 )]
550 token: Option<String>,
551 },
552 Publish {
565 #[arg(long, help = "Run full pipeline without side effects")]
566 dry_run: bool,
567 #[arg(
568 long,
569 help = "GitHub token (overrides ANODIZER_GITHUB_TOKEN / GITHUB_TOKEN env vars)"
570 )]
571 token: Option<String>,
572 #[arg(long, help = "Custom dist directory (overrides config)")]
573 dist: Option<PathBuf>,
574 #[arg(
575 long,
576 help = "Merge artifacts from `release --split` workers (dist/<subdir>/context.json) before running the publish-only pipeline. Mirrors `goreleaser publish --merge`."
577 )]
578 merge: bool,
579 #[arg(
580 long,
581 help = "Force re-publish even when a prior report.json exists. \
582 WARNING: PR-based publishers will open duplicate pull requests."
583 )]
584 allow_rerun: bool,
585 #[arg(
586 long = "show-skipped",
587 help = "Show per-crate 'no <publisher> config block' skip lines at default verbosity \
588 (normally only visible with --debug). Use to diagnose why a publisher didn't \
589 run for a given crate."
590 )]
591 show_skipped: bool,
592 #[arg(
593 long,
594 value_delimiter = ',',
595 help = "Skip stages or publishers (comma-separated, e.g. npm,blob). \
596 Unified denylist: a stage name skips the stage, a publisher name \
597 (npm, homebrew, chocolatey, …) skips that publisher."
598 )]
599 skip: Vec<String>,
600 #[arg(long = "publishers", value_delimiter = ',', help = PUBLISHERS_HELP_STEM)]
601 publishers: Vec<String>,
602 },
603 Bump {
609 #[arg(help = "patch | minor | major | <version> | release (omit to infer)")]
610 level_or_version: Option<String>,
611 #[arg(
612 long,
613 short = 'p',
614 visible_alias = "crate",
615 action = clap::ArgAction::Append,
616 help = "Bump a specific crate (repeatable)"
617 )]
618 package: Vec<String>,
619 #[arg(
620 long,
621 alias = "all",
622 conflicts_with = "package",
623 help = "Bump every workspace member (excluding publish=false)"
624 )]
625 workspace: bool,
626 #[arg(
627 long,
628 action = clap::ArgAction::Append,
629 help = "Exclude a crate from --workspace (repeatable)"
630 )]
631 exclude: Vec<String>,
632 #[arg(long, help = "Append a prerelease identifier (e.g. rc.1)")]
633 pre: Option<String>,
634 #[arg(long, help = "Do not rewrite dependents' [dependencies] version specs")]
635 exact: bool,
636 #[arg(
637 long,
638 help = "Proceed even if the working tree has uncommitted changes"
639 )]
640 allow_dirty: bool,
641 #[arg(long, short = 'y', help = "Skip confirmation prompt")]
642 yes: bool,
643 #[arg(long, help = "Print the plan without editing any files")]
644 dry_run: bool,
645 #[arg(long, help = "Stage edits and create a single commit")]
646 commit: bool,
647 #[arg(
648 long = "changelog",
649 requires = "commit",
650 help = "Refresh CHANGELOG.md in the bump commit (requires --commit and a `changelog:` config block)"
651 )]
652 changelog: bool,
653 #[arg(
654 long,
655 requires = "commit",
656 help = "GPG-sign the commit (requires --commit)"
657 )]
658 sign: bool,
659 #[arg(long, help = "Override the default commit message template")]
660 commit_message: Option<String>,
661 #[arg(
662 long,
663 default_value = "text",
664 help = "Output format: text | json (json requires --dry-run)"
665 )]
666 output: String,
667 },
668 Announce {
677 #[arg(long, help = "Run full pipeline without side effects")]
678 dry_run: bool,
679 #[arg(long, help = "Custom dist directory (overrides config)")]
680 dist: Option<PathBuf>,
681 #[arg(
682 long,
683 help = "GitHub token (overrides ANODIZER_GITHUB_TOKEN / GITHUB_TOKEN env vars)"
684 )]
685 token: Option<String>,
686 #[arg(long, value_delimiter = ',', help = "Skip stages (comma-separated)")]
687 skip: Vec<String>,
688 #[arg(
689 long,
690 help = "Merge artifact lists from `release --split` workers (dist/<subdir>/context.json) before announcing. Mirrors `goreleaser announce --merge`."
691 )]
692 merge: bool,
693 },
694 Notify {
701 message: String,
704 #[arg(long = "publishers", value_delimiter = ',')]
708 publishers: Vec<String>,
709 #[arg(long = "skip", value_delimiter = ',')]
711 skip: Vec<String>,
712 #[arg(long)]
716 raw: bool,
717 #[arg(long = "allow-secrets")]
721 allow_secrets: bool,
722 #[arg(long)]
724 dry_run: bool,
725 },
726}
727
728#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)]
730pub enum ChangelogFormat {
731 #[default]
735 #[value(name = "keep-a-changelog", alias = "kac")]
736 KeepAChangelog,
737 ReleaseNotes,
740 Json,
743}
744
745#[derive(Subcommand)]
752pub enum TagSub {
753 Rollback {
756 #[arg(
757 value_name = "sha",
758 help = "Commit SHA to roll back from. Defaults to HEAD."
759 )]
760 sha: Option<String>,
761 #[arg(long, help = "Print what would happen without mutating anything")]
762 dry_run: bool,
763 #[arg(
764 long = "no-push",
765 help = "Skip remote tag delete and branch push (local-only)"
766 )]
767 no_push: bool,
768 #[arg(
769 long,
770 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"
771 )]
772 force: bool,
773 #[arg(
774 long,
775 default_value = "all",
776 help = "Tag-shape filter: all | lockstep | per-crate"
777 )]
778 scope: String,
779 #[arg(
780 long,
781 default_value = "revert",
782 help = "Rollback strategy: revert (default; history-preserving) | reset (opt-in; rewrites history, requires --force-with-lease to push)"
783 )]
784 mode: String,
785 #[arg(
786 long,
787 value_name = "name",
788 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)."
789 )]
790 branch: Option<String>,
791 },
792}
793
794#[derive(Subcommand)]
800pub enum CheckCmd {
801 Config {
803 #[arg(long, help = "Validate a specific workspace in a monorepo config")]
804 workspace: Option<String>,
805 #[arg(
806 long,
807 value_delimiter = ',',
808 help = "Validate these skip tokens (stages or publishers) against the known set \
809 without running anything (comma-separated). Unified denylist: a stage name \
810 skips the stage, a publisher name (npm, homebrew, chocolatey, …) skips \
811 that publisher."
812 )]
813 skip: Vec<String>,
814 #[arg(
815 long = "publishers",
816 value_delimiter = ',',
817 help = concat!(
818 "Validate-only: check that each name is a publisher the active config \
819 actually enables (a known but unconfigured publisher is rejected). ",
820 "Comma-separated publishers to run (default: all configured). \
821 --skip always wins over --publishers.",
822 )
823 )]
824 publishers: Vec<String>,
825 },
826 Determinism(CheckDeterminismArgs),
828 VersionFiles,
830}
831
832#[derive(clap::Args)]
833pub struct CheckDeterminismArgs {
834 #[arg(
835 long,
836 default_value = "2",
837 help = "Number of from-clean rebuilds to diff"
838 )]
839 pub runs: u32,
840 #[arg(
841 long,
842 value_name = "stages",
843 help = "Optional stage subset (build,source,upx,archive,nfpm,makeself,snapcraft,sbom,sign,checksum,cargo-package,docker,msi,nsis,dmg,pkg,srpm,appbundle, plus the `installers` family selector expanding to nfpm,makeself,srpm,msi,nsis,dmg,pkg). 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 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. `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) are skipped at the gate when their backing tool is absent; `appbundle` is pure file assembly and always runs when requested."
844 )]
845 pub stages: Option<String>,
846 #[arg(
847 long,
848 value_name = "csv",
849 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."
850 )]
851 pub targets: Option<String>,
852 #[arg(
853 long,
854 value_name = "path",
855 help = "JSON report path; default dist/run-<id>/determinism.json"
856 )]
857 pub report: Option<PathBuf>,
858 #[arg(
859 long,
860 conflicts_with = "no_snapshot",
861 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."
862 )]
863 pub snapshot: bool,
864 #[arg(
865 long = "no-snapshot",
866 conflicts_with = "snapshot",
867 help = "Force snapshot mode OFF on the child release subprocess (artifacts emit the actual release version). Default: auto — see --snapshot."
868 )]
869 pub no_snapshot: bool,
870 #[arg(
871 long = "inject-drift",
872 value_name = "stage",
873 hide = true,
874 help = "(TEST HARNESS) Append 1 random byte to the first artifact emitted by <stage>. Gated by ANODIZE_TEST_HARNESS=1."
875 )]
876 pub inject_drift: Option<String>,
877 #[arg(
878 long = "preserve-dist",
879 value_name = "path",
880 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."
881 )]
882 pub preserve_dist: Option<PathBuf>,
883 #[arg(
884 long = "crate",
885 value_name = "name",
886 help = "When --preserve-dist is set, write the preserved dist tree to \
887 <dest>/<name>/ instead of directly into <dest>/. Used by the \
888 sharded matrix to produce per-crate subdirectories so a \
889 `release --publish-only` job can merge all crates into a single \
890 dist/ without context.json collision."
891 )]
892 pub crate_name: Option<String>,
893}
894
895fn parse_run_id(s: &str) -> Result<String, String> {
908 anodizer_stage_publish::rollback_only::validate_run_id(s)
909 .map(|()| s.to_string())
910 .map_err(|err| format!("{:#}", err))
911}
912
913pub fn detect_host_target() -> anyhow::Result<String> {
916 anodizer_core::partial::detect_host_target()
917}
918
919pub fn num_cpus() -> usize {
921 std::thread::available_parallelism()
922 .map(|n| n.get())
923 .unwrap_or(4)
924}
925
926pub fn build_cli() -> clap::Command {
928 <Cli as clap::CommandFactory>::command()
929}