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 = "60m",
83 help = "Pipeline timeout duration (e.g., 60m, 1h, 5s)"
84 )]
85 timeout: String,
86 #[arg(
87 long,
88 short = 'p',
89 help = "Maximum number of parallel build jobs (default: number of CPUs)"
90 )]
91 parallelism: Option<usize>,
92 #[arg(long, help = "Automatically set --snapshot if the git repo is dirty")]
93 auto_snapshot: bool,
94 #[arg(long, help = "Build only for the host target triple")]
95 single_target: bool,
96 #[arg(
97 long,
98 value_name = "csv",
99 conflicts_with = "single_target",
100 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."
101 )]
102 targets: Option<String>,
103 #[arg(
104 long = "host-targets",
105 conflicts_with = "single_target",
106 conflicts_with = "targets",
107 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."
108 )]
109 host_targets: bool,
110 #[arg(
111 long,
112 help = "Path to a custom release notes file (overrides changelog)"
113 )]
114 release_notes: Option<PathBuf>,
115 #[arg(
116 long,
117 conflicts_with = "crate_names",
118 help = "Release a specific workspace in a monorepo config"
119 )]
120 workspace: Option<String>,
121 #[arg(
122 long,
123 conflicts_with = "no_preflight",
124 help = "Run pre-flight publisher-state check and exit (don't start the pipeline)"
125 )]
126 preflight: bool,
127 #[arg(
128 long,
129 conflicts_with = "preflight",
130 help = "Skip the automatic pre-flight publisher-state check"
131 )]
132 no_preflight: bool,
133 #[arg(
134 long = "preflight-secrets",
135 conflicts_with = "no_preflight",
136 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."
137 )]
138 preflight_secrets: bool,
139 #[arg(
140 long,
141 help = "Alias for --strict (also treats Unknown publisher state as a blocker during pre-flight)"
142 )]
143 strict_preflight: bool,
144 #[arg(long, help = "Set the release as a draft")]
145 draft: bool,
146 #[arg(long, help = "Path to a file containing custom release header text")]
147 release_header: Option<PathBuf>,
148 #[arg(
149 long,
150 help = "Path to a template file for release header (rendered with template variables)"
151 )]
152 release_header_tmpl: Option<PathBuf>,
153 #[arg(long, help = "Path to a file containing custom release footer text")]
154 release_footer: Option<PathBuf>,
155 #[arg(
156 long,
157 help = "Path to a template file for release footer (rendered with template variables)"
158 )]
159 release_footer_tmpl: Option<PathBuf>,
160 #[arg(
161 long,
162 help = "Path to a template file for release notes (rendered with template variables, overrides --release-notes)"
163 )]
164 release_notes_tmpl: Option<PathBuf>,
165 #[arg(long, help = "Abort immediately on first error during publishing")]
166 fail_fast: bool,
167 #[arg(
168 long = "no-gate-submitter",
169 help = "Disable the Submitter gate: dispatch Submitter publishers even when required Assets/Manager publishers failed"
170 )]
171 no_gate_submitter: bool,
172 #[arg(
173 long = "rollback",
174 value_name = "none|best-effort",
175 help = "Rollback policy after publish stage. Defaults to best-effort when preflight is clean, none otherwise."
176 )]
177 rollback: Option<String>,
178 #[arg(
179 long = "simulate-failure",
180 value_name = "publisher",
181 action = clap::ArgAction::Append,
182 hide = true,
183 help = "(TEST HARNESS) Force a named publisher to fail. Gated by ANODIZE_TEST_HARNESS=1."
184 )]
185 simulate_failure: Vec<String>,
186 #[arg(
187 long = "rollback-only",
188 requires = "from_run",
189 conflicts_with = "clean",
190 help = "Skip publish; re-attempt rollback from a prior run report. Requires --from-run=<id>."
191 )]
192 rollback_only: bool,
193 #[arg(
194 long = "from-run",
195 value_name = "id",
196 requires = "rollback_only",
197 value_parser = parse_run_id,
198 help = "Prior run id whose state to load when running --rollback-only. \
199 Loads <dist>/run-<id>/rollback.json if present (a prior replay's state), \
200 otherwise <dist>/run-<id>/report.json. Delete rollback.json to force a \
201 full re-roll. Must match the run_id format written by the release pipeline \
202 (alphanumeric, dot, dash, underscore; no path separators)."
203 )]
204 from_run: Option<String>,
205 #[arg(
206 long = "allow-rerun",
207 conflicts_with = "rollback_only",
208 help = "DANGEROUS: force publish to proceed even when a prior \
209 dist/run-<id>/report.json exists for this tag. PR-based publishers \
210 (homebrew, scoop, nix, krew, MCP) will open DUPLICATE pull requests. \
211 Recover from partial failures with --rollback-only --from-run=<id> first. \
212 Cannot be combined with --rollback-only (which has its own idempotency)."
213 )]
214 allow_rerun: bool,
215 #[arg(
216 long = "show-skipped",
217 help = "Show per-crate 'no <publisher> config block' skip lines at default verbosity \
218 (normally only visible with --debug). Use to diagnose why a publisher didn't \
219 run for a given crate."
220 )]
221 show_skipped: bool,
222 #[arg(
223 long = "allow-nondeterministic",
224 value_name = "name=reason",
225 action = clap::ArgAction::Append,
226 help = "Runtime non-determinism opt-out for a specific artifact (repeatable). Mutually exclusive with --strict."
227 )]
228 allow_nondeterministic: Vec<String>,
229 #[arg(
230 long = "summary-json",
231 value_name = "path",
232 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."
233 )]
234 summary_json: Option<PathBuf>,
235 #[arg(
236 long = "allow-ai-failure",
237 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."
238 )]
239 allow_ai_failure: bool,
240 #[arg(
241 long = "allow-snapshot-publish",
242 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."
243 )]
244 allow_snapshot_publish: bool,
245 #[arg(
246 long,
247 conflicts_with = "merge",
248 help = "Run only the build stage for split CI fan-out (outputs artifacts JSON to dist/)"
249 )]
250 split: bool,
251 #[arg(
252 long,
253 conflicts_with = "split",
254 help = "Merge artifacts from split build jobs and resume the pipeline from post-build stages"
255 )]
256 merge: bool,
257 #[arg(
258 long = "publish-only",
259 conflicts_with_all = ["split", "merge", "prepare", "announce_only", "snapshot", "rollback_only", "clean"],
260 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/."
261 )]
262 publish_only: bool,
263 #[arg(
264 long,
265 alias = "prepare-only",
266 conflicts_with_all = ["publish_only", "announce_only", "rollback_only"],
267 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."
268 )]
269 prepare: bool,
270 #[arg(
271 long = "announce-only",
272 conflicts_with_all = ["prepare", "publish_only", "snapshot", "rollback_only", "split", "merge", "clean"],
273 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."
274 )]
275 announce_only: bool,
276 #[arg(
277 long,
278 help = "Resume into an existing release left over from a prior failed attempt; bypasses the safety check that bails on partial assets."
279 )]
280 resume_release: bool,
281 #[arg(
282 long,
283 help = "Force release.replace_existing_artifacts: true regardless of config (overwrite conflicting assets on retry)."
284 )]
285 replace_existing: bool,
286 #[arg(
287 long = "no-post-publish-poll",
288 help = "Skip post-publish polling for chocolatey moderation / winget PR validation; report NotPolled for affected publishers."
289 )]
290 no_post_publish_poll: bool,
291 #[arg(
292 long = "no-failure-policy",
293 hide = true,
294 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."
295 )]
296 no_failure_policy: bool,
297 },
298 Build {
300 #[arg(long = "crate", action = clap::ArgAction::Append, help = "Build a specific crate (repeatable)")]
301 crate_names: Vec<String>,
302 #[arg(
303 long,
304 default_value = "60m",
305 help = "Pipeline timeout duration (e.g., 60m, 1h, 5s)"
306 )]
307 timeout: String,
308 #[arg(
309 long,
310 short = 'p',
311 help = "Maximum number of parallel build jobs (default: number of CPUs)"
312 )]
313 parallelism: Option<usize>,
314 #[arg(long, help = "Build only for the host target triple")]
315 single_target: bool,
316 #[arg(
317 long,
318 conflicts_with = "crate_names",
319 help = "Build a specific workspace in a monorepo config"
320 )]
321 workspace: Option<String>,
322 #[arg(
323 long,
324 short = 'o',
325 help = "Copy the built binary to this path (requires --single-target and single crate)"
326 )]
327 output: Option<PathBuf>,
328 #[arg(
329 long,
330 value_delimiter = ',',
331 help = "Skip stages (comma-separated: pre-hooks, post-hooks, validate, before)"
332 )]
333 skip: Vec<String>,
334 },
335 Check {
337 #[command(subcommand)]
338 cmd: CheckCmd,
339 },
340 Init {
342 #[arg(
343 long,
344 help = "Discover repo files that embed the current version and enroll the selection into version_files in .anodizer.yaml"
345 )]
346 version_files: bool,
347 #[arg(
348 long,
349 value_delimiter = ',',
350 requires = "version_files",
351 help = "Glob(s) to drop from discovered candidates (repeatable or comma-separated); only with --version-files"
352 )]
353 exclude: Vec<String>,
354 #[arg(
355 long,
356 short = 'y',
357 requires = "version_files",
358 help = "Non-interactive: enroll all discovered candidates without prompting"
359 )]
360 yes: bool,
361 },
362 Changelog {
364 #[arg(
365 value_name = "tag|range",
366 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"
367 )]
368 range: Option<String>,
369 #[arg(
370 long,
371 value_enum,
372 default_value = "keep-a-changelog",
373 help = "Output format: keep-a-changelog (refresh the [Unreleased] section), release-notes (grouped-bullet GitHub body to stdout), or json"
374 )]
375 format: ChangelogFormat,
376 #[arg(
377 long,
378 help = "Apply the regenerated [Unreleased] section to the configured CHANGELOG.md file(s) in place (keep-a-changelog only)"
379 )]
380 write: bool,
381 #[arg(long = "crate", help = "Restrict to a specific crate in a workspace")]
382 crate_name: Option<String>,
383 #[arg(
384 long,
385 help = "Preview as a snapshot release (release-notes format only)"
386 )]
387 snapshot: bool,
388 },
389 Completion {
391 #[arg(value_enum, help = "Shell to generate completions for")]
392 shell: Shell,
393 },
394 Healthcheck,
396 Preflight {
403 #[arg(long, help = "Output the report as JSON")]
404 json: bool,
405 #[arg(
406 long,
407 help = "Check only the publish-time surface (the stages `release --publish-only` runs), not artifact-producing stages"
408 )]
409 publish_only: bool,
410 #[arg(
411 long,
412 value_delimiter = ',',
413 help = "Skip requirement collection for these stages (comma-separated, same names as release --skip)"
414 )]
415 skip: Vec<String>,
416 #[arg(long = "publishers", value_delimiter = ',', help = PUBLISHERS_HELP_STEM)]
417 publishers: Vec<String>,
418 #[arg(
419 long,
420 hide_env_values = true,
421 help = "GitHub token override; when set, GitHub token env-var requirements are treated as satisfied"
422 )]
423 token: Option<String>,
424 },
425 Man,
427 Jsonschema,
429 ResolveTag {
431 #[arg(help = "Tag to resolve (e.g. 'v1.2.3', 'core-v0.2.3')")]
432 tag: String,
433 #[arg(long, help = "Output as JSON")]
434 json: bool,
435 },
436 Targets {
442 #[arg(long, help = "Output as JSON (include-form matrix)")]
443 json: bool,
444 #[arg(long = "crate", action = clap::ArgAction::Append, help = "Restrict to specific crate(s)")]
445 crate_names: Vec<String>,
446 },
447 Tag {
449 #[arg(long, help = "Show what tag would be created without pushing")]
450 dry_run: bool,
451 #[arg(long, help = "Override bump logic with a specific tag value")]
452 custom_tag: Option<String>,
453 #[arg(long = "version", value_name = "VERSION")]
464 version_override: Option<String>,
465 #[arg(long, help = "Override default bump type (patch/minor/major)")]
466 default_bump: Option<String>,
467 #[arg(long = "crate", help = "Tag a specific crate in a workspace")]
468 crate_name: Option<String>,
469 #[arg(
470 long,
471 help = "Push the version-sync bump commit to the release branch atomically with the tag"
472 )]
473 push: bool,
474 #[arg(
475 long,
476 conflicts_with = "push",
477 help = "Push the tag only, leaving the version-sync bump commit local"
478 )]
479 no_push: bool,
480 #[arg(
481 long,
482 value_name = "NAME",
483 help = "Remote to push to (default: origin)"
484 )]
485 push_remote: Option<String>,
486 #[arg(
487 long,
488 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"
489 )]
490 push_dry_run: bool,
491 #[arg(
492 long = "changelog",
493 help = "Refresh CHANGELOG.md as part of this tag (requires a `changelog:` config block)"
494 )]
495 changelog: bool,
496 #[command(subcommand)]
502 sub: Option<TagSub>,
503 },
504 Continue {
524 #[arg(
525 long,
526 help = "Merge artifacts from split build jobs and run post-build stages"
527 )]
528 merge: bool,
529 #[arg(long, help = "Custom dist directory (overrides config)")]
530 dist: Option<PathBuf>,
531 #[arg(long, help = "Run full pipeline without side effects")]
532 dry_run: bool,
533 #[arg(
534 long,
535 value_delimiter = ',',
536 help = "Skip stages or publishers (comma-separated, e.g. docker,announce,npm). \
537 Unified denylist: a stage name skips the stage, a publisher name \
538 (npm, homebrew, chocolatey, …) skips that publisher."
539 )]
540 skip: Vec<String>,
541 #[arg(long = "publishers", value_delimiter = ',', help = PUBLISHERS_HELP_STEM)]
542 publishers: Vec<String>,
543 #[arg(
544 long,
545 help = "GitHub token (overrides ANODIZER_GITHUB_TOKEN / GITHUB_TOKEN env vars)"
546 )]
547 token: Option<String>,
548 },
549 Publish {
562 #[arg(long, help = "Run full pipeline without side effects")]
563 dry_run: bool,
564 #[arg(
565 long,
566 help = "GitHub token (overrides ANODIZER_GITHUB_TOKEN / GITHUB_TOKEN env vars)"
567 )]
568 token: Option<String>,
569 #[arg(long, help = "Custom dist directory (overrides config)")]
570 dist: Option<PathBuf>,
571 #[arg(
572 long,
573 help = "Merge artifacts from `release --split` workers (dist/<subdir>/context.json) before running the publish-only pipeline. Mirrors `goreleaser publish --merge`."
574 )]
575 merge: bool,
576 #[arg(
577 long,
578 help = "Force re-publish even when a prior report.json exists. \
579 WARNING: PR-based publishers will open duplicate pull requests."
580 )]
581 allow_rerun: bool,
582 #[arg(
583 long = "show-skipped",
584 help = "Show per-crate 'no <publisher> config block' skip lines at default verbosity \
585 (normally only visible with --debug). Use to diagnose why a publisher didn't \
586 run for a given crate."
587 )]
588 show_skipped: bool,
589 #[arg(
590 long,
591 value_delimiter = ',',
592 help = "Skip stages or publishers (comma-separated, e.g. npm,blob). \
593 Unified denylist: a stage name skips the stage, a publisher name \
594 (npm, homebrew, chocolatey, …) skips that publisher."
595 )]
596 skip: Vec<String>,
597 #[arg(long = "publishers", value_delimiter = ',', help = PUBLISHERS_HELP_STEM)]
598 publishers: Vec<String>,
599 },
600 Bump {
606 #[arg(help = "patch | minor | major | <version> | release (omit to infer)")]
607 level_or_version: Option<String>,
608 #[arg(
609 long,
610 short = 'p',
611 visible_alias = "crate",
612 action = clap::ArgAction::Append,
613 help = "Bump a specific crate (repeatable)"
614 )]
615 package: Vec<String>,
616 #[arg(
617 long,
618 alias = "all",
619 conflicts_with = "package",
620 help = "Bump every workspace member (excluding publish=false)"
621 )]
622 workspace: bool,
623 #[arg(
624 long,
625 action = clap::ArgAction::Append,
626 help = "Exclude a crate from --workspace (repeatable)"
627 )]
628 exclude: Vec<String>,
629 #[arg(long, help = "Append a prerelease identifier (e.g. rc.1)")]
630 pre: Option<String>,
631 #[arg(long, help = "Do not rewrite dependents' [dependencies] version specs")]
632 exact: bool,
633 #[arg(
634 long,
635 help = "Proceed even if the working tree has uncommitted changes"
636 )]
637 allow_dirty: bool,
638 #[arg(long, short = 'y', help = "Skip confirmation prompt")]
639 yes: bool,
640 #[arg(long, help = "Print the plan without editing any files")]
641 dry_run: bool,
642 #[arg(long, help = "Stage edits and create a single commit")]
643 commit: bool,
644 #[arg(
645 long = "changelog",
646 requires = "commit",
647 help = "Refresh CHANGELOG.md in the bump commit (requires --commit and a `changelog:` config block)"
648 )]
649 changelog: bool,
650 #[arg(
651 long,
652 requires = "commit",
653 help = "GPG-sign the commit (requires --commit)"
654 )]
655 sign: bool,
656 #[arg(long, help = "Override the default commit message template")]
657 commit_message: Option<String>,
658 #[arg(
659 long,
660 default_value = "text",
661 help = "Output format: text | json (json requires --dry-run)"
662 )]
663 output: String,
664 },
665 Announce {
674 #[arg(long, help = "Run full pipeline without side effects")]
675 dry_run: bool,
676 #[arg(long, help = "Custom dist directory (overrides config)")]
677 dist: Option<PathBuf>,
678 #[arg(
679 long,
680 help = "GitHub token (overrides ANODIZER_GITHUB_TOKEN / GITHUB_TOKEN env vars)"
681 )]
682 token: Option<String>,
683 #[arg(long, value_delimiter = ',', help = "Skip stages (comma-separated)")]
684 skip: Vec<String>,
685 #[arg(
686 long,
687 help = "Merge artifact lists from `release --split` workers (dist/<subdir>/context.json) before announcing. Mirrors `goreleaser announce --merge`."
688 )]
689 merge: bool,
690 },
691 Notify {
698 message: String,
701 #[arg(long = "publishers", value_delimiter = ',')]
705 publishers: Vec<String>,
706 #[arg(long = "skip", value_delimiter = ',')]
708 skip: Vec<String>,
709 #[arg(long)]
713 raw: bool,
714 #[arg(long = "allow-secrets")]
718 allow_secrets: bool,
719 #[arg(long)]
721 dry_run: bool,
722 },
723}
724
725#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)]
727pub enum ChangelogFormat {
728 #[default]
732 #[value(name = "keep-a-changelog", alias = "kac")]
733 KeepAChangelog,
734 ReleaseNotes,
737 Json,
740}
741
742#[derive(Subcommand)]
749pub enum TagSub {
750 Rollback {
753 #[arg(
754 value_name = "sha",
755 help = "Commit SHA to roll back from. Defaults to HEAD."
756 )]
757 sha: Option<String>,
758 #[arg(long, help = "Print what would happen without mutating anything")]
759 dry_run: bool,
760 #[arg(
761 long = "no-push",
762 help = "Skip remote tag delete and branch push (local-only)"
763 )]
764 no_push: bool,
765 #[arg(
766 long,
767 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"
768 )]
769 force: bool,
770 #[arg(
771 long,
772 default_value = "all",
773 help = "Tag-shape filter: all | lockstep | per-crate"
774 )]
775 scope: String,
776 #[arg(
777 long,
778 default_value = "revert",
779 help = "Rollback strategy: revert (default; history-preserving) | reset (opt-in; rewrites history, requires --force-with-lease to push)"
780 )]
781 mode: String,
782 #[arg(
783 long,
784 value_name = "name",
785 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)."
786 )]
787 branch: Option<String>,
788 },
789}
790
791#[derive(Subcommand)]
797pub enum CheckCmd {
798 Config {
800 #[arg(long, help = "Validate a specific workspace in a monorepo config")]
801 workspace: Option<String>,
802 #[arg(
803 long,
804 value_delimiter = ',',
805 help = "Validate these skip tokens (stages or publishers) against the known set \
806 without running anything (comma-separated). Unified denylist: a stage name \
807 skips the stage, a publisher name (npm, homebrew, chocolatey, …) skips \
808 that publisher."
809 )]
810 skip: Vec<String>,
811 #[arg(
812 long = "publishers",
813 value_delimiter = ',',
814 help = concat!(
815 "Validate-only: check that each name is a publisher the active config \
816 actually enables (a known but unconfigured publisher is rejected). ",
817 "Comma-separated publishers to run (default: all configured). \
818 --skip always wins over --publishers.",
819 )
820 )]
821 publishers: Vec<String>,
822 },
823 Determinism(CheckDeterminismArgs),
825 VersionFiles,
827}
828
829#[derive(clap::Args)]
830pub struct CheckDeterminismArgs {
831 #[arg(
832 long,
833 default_value = "2",
834 help = "Number of from-clean rebuilds to diff"
835 )]
836 pub runs: u32,
837 #[arg(
838 long,
839 value_name = "stages",
840 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."
841 )]
842 pub stages: Option<String>,
843 #[arg(
844 long,
845 value_name = "csv",
846 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."
847 )]
848 pub targets: Option<String>,
849 #[arg(
850 long,
851 value_name = "path",
852 help = "JSON report path; default dist/run-<id>/determinism.json"
853 )]
854 pub report: Option<PathBuf>,
855 #[arg(
856 long,
857 conflicts_with = "no_snapshot",
858 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."
859 )]
860 pub snapshot: bool,
861 #[arg(
862 long = "no-snapshot",
863 conflicts_with = "snapshot",
864 help = "Force snapshot mode OFF on the child release subprocess (artifacts emit the actual release version). Default: auto — see --snapshot."
865 )]
866 pub no_snapshot: bool,
867 #[arg(
868 long = "inject-drift",
869 value_name = "stage",
870 hide = true,
871 help = "(TEST HARNESS) Append 1 random byte to the first artifact emitted by <stage>. Gated by ANODIZE_TEST_HARNESS=1."
872 )]
873 pub inject_drift: Option<String>,
874 #[arg(
875 long = "preserve-dist",
876 value_name = "path",
877 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."
878 )]
879 pub preserve_dist: Option<PathBuf>,
880 #[arg(
881 long = "crate",
882 value_name = "name",
883 help = "When --preserve-dist is set, write the preserved dist tree to \
884 <dest>/<name>/ instead of directly into <dest>/. Used by the \
885 sharded matrix to produce per-crate subdirectories so a \
886 `release --publish-only` job can merge all crates into a single \
887 dist/ without context.json collision."
888 )]
889 pub crate_name: Option<String>,
890}
891
892fn parse_run_id(s: &str) -> Result<String, String> {
905 anodizer_stage_publish::rollback_only::validate_run_id(s)
906 .map(|()| s.to_string())
907 .map_err(|err| format!("{:#}", err))
908}
909
910pub fn detect_host_target() -> anyhow::Result<String> {
913 anodizer_core::partial::detect_host_target()
914}
915
916pub fn num_cpus() -> usize {
918 std::thread::available_parallelism()
919 .map(|n| n.get())
920 .unwrap_or(4)
921}
922
923pub fn build_cli() -> clap::Command {
925 <Cli as clap::CommandFactory>::command()
926}