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