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(
405 long,
406 hide_env_values = true,
407 help = "GitHub token override; when set, GitHub token env-var requirements are treated as satisfied"
408 )]
409 token: Option<String>,
410 },
411 Man,
413 Jsonschema,
415 ResolveTag {
417 #[arg(help = "Tag to resolve (e.g. 'v1.2.3', 'core-v0.2.3')")]
418 tag: String,
419 #[arg(long, help = "Output as JSON")]
420 json: bool,
421 },
422 Targets {
428 #[arg(long, help = "Output as JSON (include-form matrix)")]
429 json: bool,
430 #[arg(long = "crate", action = clap::ArgAction::Append, help = "Restrict to specific crate(s)")]
431 crate_names: Vec<String>,
432 },
433 Tag {
435 #[arg(long, help = "Show what tag would be created without pushing")]
436 dry_run: bool,
437 #[arg(long, help = "Override bump logic with a specific tag value")]
438 custom_tag: Option<String>,
439 #[arg(long = "version", value_name = "VERSION")]
450 version_override: Option<String>,
451 #[arg(long, help = "Override default bump type (patch/minor/major)")]
452 default_bump: Option<String>,
453 #[arg(long = "crate", help = "Tag a specific crate in a workspace")]
454 crate_name: Option<String>,
455 #[arg(
456 long,
457 help = "Push the version-sync bump commit to the release branch atomically with the tag"
458 )]
459 push: bool,
460 #[arg(
461 long,
462 conflicts_with = "push",
463 help = "Push the tag only, leaving the version-sync bump commit local"
464 )]
465 no_push: bool,
466 #[arg(
467 long,
468 value_name = "NAME",
469 help = "Remote to push to (default: origin)"
470 )]
471 push_remote: Option<String>,
472 #[arg(
473 long,
474 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"
475 )]
476 push_dry_run: bool,
477 #[arg(
478 long = "changelog",
479 help = "Refresh CHANGELOG.md as part of this tag (requires a `changelog:` config block)"
480 )]
481 changelog: bool,
482 #[command(subcommand)]
488 sub: Option<TagSub>,
489 },
490 Continue {
510 #[arg(
511 long,
512 help = "Merge artifacts from split build jobs and run post-build stages"
513 )]
514 merge: bool,
515 #[arg(long, help = "Custom dist directory (overrides config)")]
516 dist: Option<PathBuf>,
517 #[arg(long, help = "Run full pipeline without side effects")]
518 dry_run: bool,
519 #[arg(
520 long,
521 value_delimiter = ',',
522 help = "Skip stages or publishers (comma-separated, e.g. docker,announce,npm). \
523 Unified denylist: a stage name skips the stage, a publisher name \
524 (npm, homebrew, chocolatey, …) skips that publisher."
525 )]
526 skip: Vec<String>,
527 #[arg(long = "publishers", value_delimiter = ',', help = PUBLISHERS_HELP_STEM)]
528 publishers: Vec<String>,
529 #[arg(
530 long,
531 help = "GitHub token (overrides ANODIZER_GITHUB_TOKEN / GITHUB_TOKEN env vars)"
532 )]
533 token: Option<String>,
534 },
535 Publish {
548 #[arg(long, help = "Run full pipeline without side effects")]
549 dry_run: bool,
550 #[arg(
551 long,
552 help = "GitHub token (overrides ANODIZER_GITHUB_TOKEN / GITHUB_TOKEN env vars)"
553 )]
554 token: Option<String>,
555 #[arg(long, help = "Custom dist directory (overrides config)")]
556 dist: Option<PathBuf>,
557 #[arg(
558 long,
559 help = "Merge artifacts from `release --split` workers (dist/<subdir>/context.json) before running the publish-only pipeline. Mirrors `goreleaser publish --merge`."
560 )]
561 merge: bool,
562 #[arg(
563 long,
564 help = "Force re-publish even when a prior report.json exists. \
565 WARNING: PR-based publishers will open duplicate pull requests."
566 )]
567 allow_rerun: bool,
568 #[arg(
569 long = "show-skipped",
570 help = "Show per-crate 'no <publisher> config block' skip lines at default verbosity \
571 (normally only visible with --debug). Use to diagnose why a publisher didn't \
572 run for a given crate."
573 )]
574 show_skipped: bool,
575 #[arg(
576 long,
577 value_delimiter = ',',
578 help = "Skip stages or publishers (comma-separated, e.g. npm,blob). \
579 Unified denylist: a stage name skips the stage, a publisher name \
580 (npm, homebrew, chocolatey, …) skips that publisher."
581 )]
582 skip: Vec<String>,
583 #[arg(long = "publishers", value_delimiter = ',', help = PUBLISHERS_HELP_STEM)]
584 publishers: Vec<String>,
585 },
586 Bump {
592 #[arg(help = "patch | minor | major | <version> | release (omit to infer)")]
593 level_or_version: Option<String>,
594 #[arg(
595 long,
596 short = 'p',
597 visible_alias = "crate",
598 action = clap::ArgAction::Append,
599 help = "Bump a specific crate (repeatable)"
600 )]
601 package: Vec<String>,
602 #[arg(
603 long,
604 alias = "all",
605 conflicts_with = "package",
606 help = "Bump every workspace member (excluding publish=false)"
607 )]
608 workspace: bool,
609 #[arg(
610 long,
611 action = clap::ArgAction::Append,
612 help = "Exclude a crate from --workspace (repeatable)"
613 )]
614 exclude: Vec<String>,
615 #[arg(long, help = "Append a prerelease identifier (e.g. rc.1)")]
616 pre: Option<String>,
617 #[arg(long, help = "Do not rewrite dependents' [dependencies] version specs")]
618 exact: bool,
619 #[arg(
620 long,
621 help = "Proceed even if the working tree has uncommitted changes"
622 )]
623 allow_dirty: bool,
624 #[arg(long, short = 'y', help = "Skip confirmation prompt")]
625 yes: bool,
626 #[arg(long, help = "Print the plan without editing any files")]
627 dry_run: bool,
628 #[arg(long, help = "Stage edits and create a single commit")]
629 commit: bool,
630 #[arg(
631 long = "changelog",
632 requires = "commit",
633 help = "Refresh CHANGELOG.md in the bump commit (requires --commit and a `changelog:` config block)"
634 )]
635 changelog: bool,
636 #[arg(
637 long,
638 requires = "commit",
639 help = "GPG-sign the commit (requires --commit)"
640 )]
641 sign: bool,
642 #[arg(long, help = "Override the default commit message template")]
643 commit_message: Option<String>,
644 #[arg(
645 long,
646 default_value = "text",
647 help = "Output format: text | json (json requires --dry-run)"
648 )]
649 output: String,
650 },
651 Announce {
660 #[arg(long, help = "Run full pipeline without side effects")]
661 dry_run: bool,
662 #[arg(long, help = "Custom dist directory (overrides config)")]
663 dist: Option<PathBuf>,
664 #[arg(
665 long,
666 help = "GitHub token (overrides ANODIZER_GITHUB_TOKEN / GITHUB_TOKEN env vars)"
667 )]
668 token: Option<String>,
669 #[arg(long, value_delimiter = ',', help = "Skip stages (comma-separated)")]
670 skip: Vec<String>,
671 #[arg(
672 long,
673 help = "Merge artifact lists from `release --split` workers (dist/<subdir>/context.json) before announcing. Mirrors `goreleaser announce --merge`."
674 )]
675 merge: bool,
676 },
677 Notify {
684 message: String,
687 #[arg(long = "publishers", value_delimiter = ',')]
691 publishers: Vec<String>,
692 #[arg(long = "skip", value_delimiter = ',')]
694 skip: Vec<String>,
695 #[arg(long)]
699 raw: bool,
700 #[arg(long = "allow-secrets")]
704 allow_secrets: bool,
705 #[arg(long)]
707 dry_run: bool,
708 },
709}
710
711#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)]
713pub enum ChangelogFormat {
714 #[default]
718 #[value(name = "keep-a-changelog", alias = "kac")]
719 KeepAChangelog,
720 ReleaseNotes,
723 Json,
726}
727
728#[derive(Subcommand)]
735pub enum TagSub {
736 Rollback {
739 #[arg(
740 value_name = "sha",
741 help = "Commit SHA to roll back from. Defaults to HEAD."
742 )]
743 sha: Option<String>,
744 #[arg(long, help = "Print what would happen without mutating anything")]
745 dry_run: bool,
746 #[arg(
747 long = "no-push",
748 help = "Skip remote tag delete and branch push (local-only)"
749 )]
750 no_push: bool,
751 #[arg(
752 long,
753 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"
754 )]
755 force: bool,
756 #[arg(
757 long,
758 default_value = "all",
759 help = "Tag-shape filter: all | lockstep | per-crate"
760 )]
761 scope: String,
762 #[arg(
763 long,
764 default_value = "revert",
765 help = "Rollback strategy: revert (default; history-preserving) | reset (opt-in; rewrites history, requires --force-with-lease to push)"
766 )]
767 mode: String,
768 #[arg(
769 long,
770 value_name = "name",
771 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)."
772 )]
773 branch: Option<String>,
774 },
775}
776
777#[derive(Subcommand)]
783pub enum CheckCmd {
784 Config {
786 #[arg(long, help = "Validate a specific workspace in a monorepo config")]
787 workspace: Option<String>,
788 #[arg(
789 long,
790 value_delimiter = ',',
791 help = "Validate these skip tokens (stages or publishers) against the known set \
792 without running anything (comma-separated). Unified denylist: a stage name \
793 skips the stage, a publisher name (npm, homebrew, chocolatey, …) skips \
794 that publisher."
795 )]
796 skip: Vec<String>,
797 #[arg(
798 long = "publishers",
799 value_delimiter = ',',
800 help = concat!(
801 "Validate-only: check that each name is a publisher the active config \
802 actually enables (a known but unconfigured publisher is rejected). ",
803 "Comma-separated publishers to run (default: all configured). \
804 --skip always wins over --publishers.",
805 )
806 )]
807 publishers: Vec<String>,
808 },
809 Determinism(CheckDeterminismArgs),
811 VersionFiles,
813}
814
815#[derive(clap::Args)]
816pub struct CheckDeterminismArgs {
817 #[arg(
818 long,
819 default_value = "2",
820 help = "Number of from-clean rebuilds to diff"
821 )]
822 pub runs: u32,
823 #[arg(
824 long,
825 value_name = "stages",
826 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."
827 )]
828 pub stages: Option<String>,
829 #[arg(
830 long,
831 value_name = "csv",
832 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."
833 )]
834 pub targets: Option<String>,
835 #[arg(
836 long,
837 value_name = "path",
838 help = "JSON report path; default dist/run-<id>/determinism.json"
839 )]
840 pub report: Option<PathBuf>,
841 #[arg(
842 long,
843 conflicts_with = "no_snapshot",
844 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."
845 )]
846 pub snapshot: bool,
847 #[arg(
848 long = "no-snapshot",
849 conflicts_with = "snapshot",
850 help = "Force snapshot mode OFF on the child release subprocess (artifacts emit the actual release version). Default: auto — see --snapshot."
851 )]
852 pub no_snapshot: bool,
853 #[arg(
854 long = "inject-drift",
855 value_name = "stage",
856 hide = true,
857 help = "(TEST HARNESS) Append 1 random byte to the first artifact emitted by <stage>. Gated by ANODIZE_TEST_HARNESS=1."
858 )]
859 pub inject_drift: Option<String>,
860 #[arg(
861 long = "preserve-dist",
862 value_name = "path",
863 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."
864 )]
865 pub preserve_dist: Option<PathBuf>,
866 #[arg(
867 long = "crate",
868 value_name = "name",
869 help = "When --preserve-dist is set, write the preserved dist tree to \
870 <dest>/<name>/ instead of directly into <dest>/. Used by the \
871 sharded matrix to produce per-crate subdirectories so a \
872 `release --publish-only` job can merge all crates into a single \
873 dist/ without context.json collision."
874 )]
875 pub crate_name: Option<String>,
876}
877
878fn parse_run_id(s: &str) -> Result<String, String> {
891 anodizer_stage_publish::rollback_only::validate_run_id(s)
892 .map(|()| s.to_string())
893 .map_err(|err| format!("{:#}", err))
894}
895
896pub fn detect_host_target() -> anyhow::Result<String> {
899 anodizer_core::partial::detect_host_target()
900}
901
902pub fn num_cpus() -> usize {
904 std::thread::available_parallelism()
905 .map(|n| n.get())
906 .unwrap_or(4)
907}
908
909pub fn build_cli() -> clap::Command {
911 <Cli as clap::CommandFactory>::command()
912}