1use clap::{Parser, Subcommand};
2use clap_complete::Shell;
3use std::path::PathBuf;
4
5#[derive(Parser)]
6#[command(name = "anodizer", version, about = "Release Rust projects with ease")]
7pub struct Cli {
8 #[arg(
9 long,
10 short = 'f',
11 global = true,
12 help = "Path to config file (overrides auto-detection)"
13 )]
14 pub config: Option<PathBuf>,
15 #[arg(long, global = true, help = "Enable verbose output")]
16 pub verbose: bool,
17 #[arg(long, global = true, help = "Enable debug output")]
18 pub debug: bool,
19 #[arg(long, short = 'q', global = true, help = "Suppress non-error output")]
20 pub quiet: bool,
21 #[arg(
22 long,
23 global = true,
24 help = "Strict mode: configured features that silently skip become hard errors"
25 )]
26 pub strict: bool,
27 #[command(subcommand)]
33 pub command: Option<Commands>,
34}
35
36#[derive(Subcommand)]
37#[allow(clippy::large_enum_variant)]
42pub enum Commands {
43 Release {
45 #[arg(long = "crate", visible_alias = "id", action = clap::ArgAction::Append, help = "Release a specific crate (repeatable; --id is accepted as a GoReleaser-compat alias)")]
46 crate_names: Vec<String>,
47 #[arg(long, help = "Release all crates with unreleased changes")]
48 all: bool,
49 #[arg(long, help = "Force release even without unreleased changes")]
50 force: bool,
51 #[arg(long, help = "Build without publishing (snapshot mode)")]
52 snapshot: bool,
53 #[arg(long, help = "Create a nightly release with date-based version")]
54 nightly: bool,
55 #[arg(long, help = "Run full pipeline without side effects")]
56 dry_run: bool,
57 #[arg(long, help = "Remove dist directory before starting")]
58 clean: bool,
59 #[arg(
60 long,
61 value_delimiter = ',',
62 help = "Skip stages (comma-separated, e.g. docker,announce)"
63 )]
64 skip: Vec<String>,
65 #[arg(
66 long,
67 help = "GitHub token (overrides ANODIZER_GITHUB_TOKEN / GITHUB_TOKEN env vars)"
68 )]
69 token: Option<String>,
70 #[arg(
71 long,
72 default_value = "60m",
73 help = "Pipeline timeout duration (e.g., 60m, 1h, 5s)"
74 )]
75 timeout: String,
76 #[arg(
77 long,
78 short = 'p',
79 help = "Maximum number of parallel build jobs (default: number of CPUs)"
80 )]
81 parallelism: Option<usize>,
82 #[arg(long, help = "Automatically set --snapshot if the git repo is dirty")]
83 auto_snapshot: bool,
84 #[arg(long, help = "Build only for the host target triple")]
85 single_target: bool,
86 #[arg(
87 long,
88 value_name = "csv",
89 conflicts_with = "single_target",
90 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."
91 )]
92 targets: Option<String>,
93 #[arg(
94 long = "host-targets",
95 conflicts_with = "single_target",
96 conflicts_with = "targets",
97 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."
98 )]
99 host_targets: bool,
100 #[arg(
101 long,
102 help = "Path to a custom release notes file (overrides changelog)"
103 )]
104 release_notes: Option<PathBuf>,
105 #[arg(
106 long,
107 conflicts_with = "crate_names",
108 help = "Release a specific workspace in a monorepo config"
109 )]
110 workspace: Option<String>,
111 #[arg(
112 long,
113 conflicts_with = "no_preflight",
114 help = "Run pre-flight publisher-state check and exit (don't start the pipeline)"
115 )]
116 preflight: bool,
117 #[arg(
118 long,
119 conflicts_with = "preflight",
120 help = "Skip the automatic pre-flight publisher-state check"
121 )]
122 no_preflight: bool,
123 #[arg(
124 long,
125 help = "Alias for --strict (also treats Unknown publisher state as a blocker during pre-flight)"
126 )]
127 strict_preflight: bool,
128 #[arg(long, help = "Set the release as a draft")]
129 draft: bool,
130 #[arg(long, help = "Path to a file containing custom release header text")]
131 release_header: Option<PathBuf>,
132 #[arg(
133 long,
134 help = "Path to a template file for release header (rendered with template variables)"
135 )]
136 release_header_tmpl: Option<PathBuf>,
137 #[arg(long, help = "Path to a file containing custom release footer text")]
138 release_footer: Option<PathBuf>,
139 #[arg(
140 long,
141 help = "Path to a template file for release footer (rendered with template variables)"
142 )]
143 release_footer_tmpl: Option<PathBuf>,
144 #[arg(
145 long,
146 help = "Path to a template file for release notes (rendered with template variables, overrides --release-notes)"
147 )]
148 release_notes_tmpl: Option<PathBuf>,
149 #[arg(long, help = "Abort immediately on first error during publishing")]
150 fail_fast: bool,
151 #[arg(
152 long = "no-gate-submitter",
153 help = "Disable the Submitter gate: dispatch Submitter publishers even when required Assets/Manager publishers failed"
154 )]
155 no_gate_submitter: bool,
156 #[arg(
157 long = "rollback",
158 value_name = "none|best-effort",
159 help = "Rollback policy after publish stage. Defaults to best-effort when preflight is clean, none otherwise."
160 )]
161 rollback: Option<String>,
162 #[arg(
163 long = "simulate-failure",
164 value_name = "publisher",
165 action = clap::ArgAction::Append,
166 hide = true,
167 help = "(TEST HARNESS) Force a named publisher to fail. Gated by ANODIZE_TEST_HARNESS=1."
168 )]
169 simulate_failure: Vec<String>,
170 #[arg(
171 long = "rollback-only",
172 requires = "from_run",
173 conflicts_with = "clean",
174 help = "Skip publish; re-attempt rollback from a prior run report. Requires --from-run=<id>."
175 )]
176 rollback_only: bool,
177 #[arg(
178 long = "from-run",
179 value_name = "id",
180 requires = "rollback_only",
181 value_parser = parse_run_id,
182 help = "Prior run id whose state to load when running --rollback-only. \
183 Loads <dist>/run-<id>/rollback.json if present (a prior replay's state), \
184 otherwise <dist>/run-<id>/report.json. Delete rollback.json to force a \
185 full re-roll. Must match the run_id format written by the release pipeline \
186 (alphanumeric, dot, dash, underscore; no path separators)."
187 )]
188 from_run: Option<String>,
189 #[arg(
190 long = "allow-rerun",
191 conflicts_with = "rollback_only",
192 help = "DANGEROUS: force publish to proceed even when a prior \
193 dist/run-<id>/report.json exists for this tag. PR-based publishers \
194 (homebrew, scoop, nix, krew, MCP) will open DUPLICATE pull requests. \
195 Recover from partial failures with --rollback-only --from-run=<id> first. \
196 Cannot be combined with --rollback-only (which has its own idempotency)."
197 )]
198 allow_rerun: bool,
199 #[arg(
200 long = "show-skipped",
201 help = "Show per-crate 'no <publisher> config block' skip lines at default verbosity \
202 (normally only visible with --debug). Use to diagnose why a publisher didn't \
203 run for a given crate."
204 )]
205 show_skipped: bool,
206 #[arg(
207 long = "allow-nondeterministic",
208 value_name = "name=reason",
209 action = clap::ArgAction::Append,
210 help = "Runtime non-determinism opt-out for a specific artifact (repeatable). Mutually exclusive with --strict."
211 )]
212 allow_nondeterministic: Vec<String>,
213 #[arg(
214 long = "summary-json",
215 value_name = "path",
216 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."
217 )]
218 summary_json: Option<PathBuf>,
219 #[arg(
220 long = "allow-ai-failure",
221 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."
222 )]
223 allow_ai_failure: bool,
224 #[arg(
225 long = "allow-snapshot-publish",
226 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."
227 )]
228 allow_snapshot_publish: bool,
229 #[arg(
230 long,
231 conflicts_with = "merge",
232 help = "Run only the build stage for split CI fan-out (outputs artifacts JSON to dist/)"
233 )]
234 split: bool,
235 #[arg(
236 long,
237 conflicts_with = "split",
238 help = "Merge artifacts from split build jobs and resume the pipeline from post-build stages"
239 )]
240 merge: bool,
241 #[arg(
242 long = "publish-only",
243 conflicts_with_all = ["split", "merge", "prepare", "announce_only", "snapshot", "rollback_only", "clean"],
244 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/."
245 )]
246 publish_only: bool,
247 #[arg(
248 long,
249 alias = "prepare-only",
250 conflicts_with_all = ["publish_only", "announce_only", "rollback_only"],
251 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."
252 )]
253 prepare: bool,
254 #[arg(
255 long = "announce-only",
256 conflicts_with_all = ["prepare", "publish_only", "snapshot", "rollback_only", "split", "merge", "clean"],
257 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."
258 )]
259 announce_only: bool,
260 #[arg(
261 long,
262 help = "Resume into an existing release left over from a prior failed attempt; bypasses the safety check that bails on partial assets."
263 )]
264 resume_release: bool,
265 #[arg(
266 long,
267 help = "Force release.replace_existing_artifacts: true regardless of config (overwrite conflicting assets on retry)."
268 )]
269 replace_existing: bool,
270 #[arg(
271 long = "no-post-publish-poll",
272 help = "Skip post-publish polling for chocolatey moderation / winget PR validation; report NotPolled for affected publishers."
273 )]
274 no_post_publish_poll: bool,
275 },
276 Build {
278 #[arg(long = "crate", action = clap::ArgAction::Append, help = "Build a specific crate (repeatable)")]
279 crate_names: Vec<String>,
280 #[arg(
281 long,
282 default_value = "60m",
283 help = "Pipeline timeout duration (e.g., 60m, 1h, 5s)"
284 )]
285 timeout: String,
286 #[arg(
287 long,
288 short = 'p',
289 help = "Maximum number of parallel build jobs (default: number of CPUs)"
290 )]
291 parallelism: Option<usize>,
292 #[arg(long, help = "Build only for the host target triple")]
293 single_target: bool,
294 #[arg(
295 long,
296 conflicts_with = "crate_names",
297 help = "Build a specific workspace in a monorepo config"
298 )]
299 workspace: Option<String>,
300 #[arg(
301 long,
302 short = 'o',
303 help = "Copy the built binary to this path (requires --single-target and single crate)"
304 )]
305 output: Option<PathBuf>,
306 #[arg(
307 long,
308 value_delimiter = ',',
309 help = "Skip stages (comma-separated: pre-hooks, post-hooks, validate, before)"
310 )]
311 skip: Vec<String>,
312 },
313 Check {
315 #[command(subcommand)]
316 cmd: CheckCmd,
317 },
318 Init {
320 #[arg(
321 long,
322 help = "Discover repo files that embed the current version and enroll the selection into version_files in .anodizer.yaml"
323 )]
324 version_files: bool,
325 #[arg(
326 long,
327 value_delimiter = ',',
328 requires = "version_files",
329 help = "Glob(s) to drop from discovered candidates (repeatable or comma-separated); only with --version-files"
330 )]
331 exclude: Vec<String>,
332 #[arg(
333 long,
334 short = 'y',
335 requires = "version_files",
336 help = "Non-interactive: enroll all discovered candidates without prompting"
337 )]
338 yes: bool,
339 },
340 Changelog {
342 #[arg(
343 value_name = "tag|range",
344 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"
345 )]
346 range: Option<String>,
347 #[arg(
348 long,
349 value_enum,
350 default_value = "keep-a-changelog",
351 help = "Output format: keep-a-changelog (refresh the [Unreleased] section), release-notes (grouped-bullet GitHub body to stdout), or json"
352 )]
353 format: ChangelogFormat,
354 #[arg(
355 long,
356 help = "Apply the regenerated [Unreleased] section to the configured CHANGELOG.md file(s) in place (keep-a-changelog only)"
357 )]
358 write: bool,
359 #[arg(long = "crate", help = "Restrict to a specific crate in a workspace")]
360 crate_name: Option<String>,
361 #[arg(
362 long,
363 help = "Preview as a snapshot release (release-notes format only)"
364 )]
365 snapshot: bool,
366 },
367 Completion {
369 #[arg(value_enum, help = "Shell to generate completions for")]
370 shell: Shell,
371 },
372 Healthcheck,
374 Preflight {
381 #[arg(long, help = "Output the report as JSON")]
382 json: bool,
383 #[arg(
384 long,
385 help = "Check only the publish-time surface (the stages `release --publish-only` runs), not artifact-producing stages"
386 )]
387 publish_only: bool,
388 #[arg(
389 long,
390 value_delimiter = ',',
391 help = "Skip requirement collection for these stages (comma-separated, same names as release --skip)"
392 )]
393 skip: Vec<String>,
394 #[arg(
395 long,
396 hide_env_values = true,
397 help = "GitHub token override; when set, GitHub token env-var requirements are treated as satisfied"
398 )]
399 token: Option<String>,
400 },
401 Man,
403 Jsonschema,
405 ResolveTag {
407 #[arg(help = "Tag to resolve (e.g. 'v1.2.3', 'core-v0.2.3')")]
408 tag: String,
409 #[arg(long, help = "Output as JSON")]
410 json: bool,
411 },
412 Targets {
418 #[arg(long, help = "Output as JSON (include-form matrix)")]
419 json: bool,
420 #[arg(long = "crate", action = clap::ArgAction::Append, help = "Restrict to specific crate(s)")]
421 crate_names: Vec<String>,
422 },
423 Tag {
425 #[arg(long, help = "Show what tag would be created without pushing")]
426 dry_run: bool,
427 #[arg(long, help = "Override bump logic with a specific tag value")]
428 custom_tag: Option<String>,
429 #[arg(long = "version", value_name = "VERSION")]
440 version_override: Option<String>,
441 #[arg(long, help = "Override default bump type (patch/minor/major)")]
442 default_bump: Option<String>,
443 #[arg(long = "crate", help = "Tag a specific crate in a workspace")]
444 crate_name: Option<String>,
445 #[arg(
446 long,
447 help = "Push the version-sync bump commit to the release branch atomically with the tag"
448 )]
449 push: bool,
450 #[arg(
451 long,
452 conflicts_with = "push",
453 help = "Push the tag only, leaving the version-sync bump commit local"
454 )]
455 no_push: bool,
456 #[arg(
457 long,
458 value_name = "NAME",
459 help = "Remote to push to (default: origin)"
460 )]
461 push_remote: Option<String>,
462 #[arg(
463 long,
464 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"
465 )]
466 push_dry_run: bool,
467 #[arg(
468 long = "changelog",
469 help = "Refresh CHANGELOG.md as part of this tag (requires a `changelog:` config block)"
470 )]
471 changelog: bool,
472 #[command(subcommand)]
478 sub: Option<TagSub>,
479 },
480 Continue {
500 #[arg(
501 long,
502 help = "Merge artifacts from split build jobs and run post-build stages"
503 )]
504 merge: bool,
505 #[arg(long, help = "Custom dist directory (overrides config)")]
506 dist: Option<PathBuf>,
507 #[arg(long, help = "Run full pipeline without side effects")]
508 dry_run: bool,
509 #[arg(
510 long,
511 value_delimiter = ',',
512 help = "Skip stages (comma-separated, e.g. docker,announce)"
513 )]
514 skip: Vec<String>,
515 #[arg(
516 long,
517 help = "GitHub token (overrides ANODIZER_GITHUB_TOKEN / GITHUB_TOKEN env vars)"
518 )]
519 token: Option<String>,
520 },
521 Publish {
534 #[arg(long, help = "Run full pipeline without side effects")]
535 dry_run: bool,
536 #[arg(
537 long,
538 help = "GitHub token (overrides ANODIZER_GITHUB_TOKEN / GITHUB_TOKEN env vars)"
539 )]
540 token: Option<String>,
541 #[arg(long, help = "Custom dist directory (overrides config)")]
542 dist: Option<PathBuf>,
543 #[arg(
544 long,
545 help = "Merge artifacts from `release --split` workers (dist/<subdir>/context.json) before running the publish-only pipeline. Mirrors `goreleaser publish --merge`."
546 )]
547 merge: bool,
548 #[arg(
549 long,
550 help = "Force re-publish even when a prior report.json exists. \
551 WARNING: PR-based publishers will open duplicate pull requests."
552 )]
553 allow_rerun: bool,
554 #[arg(
555 long = "show-skipped",
556 help = "Show per-crate 'no <publisher> config block' skip lines at default verbosity \
557 (normally only visible with --debug). Use to diagnose why a publisher didn't \
558 run for a given crate."
559 )]
560 show_skipped: bool,
561 },
562 Bump {
568 #[arg(help = "patch | minor | major | <version> | release (omit to infer)")]
569 level_or_version: Option<String>,
570 #[arg(
571 long,
572 short = 'p',
573 visible_alias = "crate",
574 action = clap::ArgAction::Append,
575 help = "Bump a specific crate (repeatable)"
576 )]
577 package: Vec<String>,
578 #[arg(
579 long,
580 alias = "all",
581 conflicts_with = "package",
582 help = "Bump every workspace member (excluding publish=false)"
583 )]
584 workspace: bool,
585 #[arg(
586 long,
587 action = clap::ArgAction::Append,
588 help = "Exclude a crate from --workspace (repeatable)"
589 )]
590 exclude: Vec<String>,
591 #[arg(long, help = "Append a prerelease identifier (e.g. rc.1)")]
592 pre: Option<String>,
593 #[arg(long, help = "Do not rewrite dependents' [dependencies] version specs")]
594 exact: bool,
595 #[arg(
596 long,
597 help = "Proceed even if the working tree has uncommitted changes"
598 )]
599 allow_dirty: bool,
600 #[arg(long, short = 'y', help = "Skip confirmation prompt")]
601 yes: bool,
602 #[arg(long, help = "Print the plan without editing any files")]
603 dry_run: bool,
604 #[arg(long, help = "Stage edits and create a single commit")]
605 commit: bool,
606 #[arg(
607 long = "changelog",
608 requires = "commit",
609 help = "Refresh CHANGELOG.md in the bump commit (requires --commit and a `changelog:` config block)"
610 )]
611 changelog: bool,
612 #[arg(
613 long,
614 requires = "commit",
615 help = "GPG-sign the commit (requires --commit)"
616 )]
617 sign: bool,
618 #[arg(long, help = "Override the default commit message template")]
619 commit_message: Option<String>,
620 #[arg(
621 long,
622 default_value = "text",
623 help = "Output format: text | json (json requires --dry-run)"
624 )]
625 output: String,
626 },
627 Announce {
636 #[arg(long, help = "Run full pipeline without side effects")]
637 dry_run: bool,
638 #[arg(long, help = "Custom dist directory (overrides config)")]
639 dist: Option<PathBuf>,
640 #[arg(
641 long,
642 help = "GitHub token (overrides ANODIZER_GITHUB_TOKEN / GITHUB_TOKEN env vars)"
643 )]
644 token: Option<String>,
645 #[arg(long, value_delimiter = ',', help = "Skip stages (comma-separated)")]
646 skip: Vec<String>,
647 #[arg(
648 long,
649 help = "Merge artifact lists from `release --split` workers (dist/<subdir>/context.json) before announcing. Mirrors `goreleaser announce --merge`."
650 )]
651 merge: bool,
652 },
653 Notify {
660 message: String,
663 #[arg(long = "publishers", value_delimiter = ',')]
667 publishers: Vec<String>,
668 #[arg(long = "skip", value_delimiter = ',')]
670 skip: Vec<String>,
671 #[arg(long)]
675 raw: bool,
676 #[arg(long = "allow-secrets")]
680 allow_secrets: bool,
681 #[arg(long)]
683 dry_run: bool,
684 },
685}
686
687#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)]
689pub enum ChangelogFormat {
690 #[default]
694 #[value(name = "keep-a-changelog", alias = "kac")]
695 KeepAChangelog,
696 ReleaseNotes,
699 Json,
702}
703
704#[derive(Subcommand)]
711pub enum TagSub {
712 Rollback {
715 #[arg(
716 value_name = "sha",
717 help = "Commit SHA to roll back from. Defaults to HEAD."
718 )]
719 sha: Option<String>,
720 #[arg(long, help = "Print what would happen without mutating anything")]
721 dry_run: bool,
722 #[arg(
723 long = "no-push",
724 help = "Skip remote tag delete and branch push (local-only)"
725 )]
726 no_push: bool,
727 #[arg(
728 long,
729 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"
730 )]
731 force: bool,
732 #[arg(
733 long,
734 default_value = "all",
735 help = "Tag-shape filter: all | lockstep | per-crate"
736 )]
737 scope: String,
738 #[arg(
739 long,
740 default_value = "revert",
741 help = "Rollback strategy: revert (default; history-preserving) | reset (opt-in; rewrites history, requires --force-with-lease to push)"
742 )]
743 mode: String,
744 #[arg(
745 long,
746 value_name = "name",
747 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)."
748 )]
749 branch: Option<String>,
750 },
751}
752
753#[derive(Subcommand)]
759pub enum CheckCmd {
760 Config {
762 #[arg(long, help = "Validate a specific workspace in a monorepo config")]
763 workspace: Option<String>,
764 },
765 Determinism(CheckDeterminismArgs),
767 VersionFiles,
769}
770
771#[derive(clap::Args)]
772pub struct CheckDeterminismArgs {
773 #[arg(
774 long,
775 default_value = "2",
776 help = "Number of from-clean rebuilds to diff"
777 )]
778 pub runs: u32,
779 #[arg(
780 long,
781 value_name = "stages",
782 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."
783 )]
784 pub stages: Option<String>,
785 #[arg(
786 long,
787 value_name = "csv",
788 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."
789 )]
790 pub targets: Option<String>,
791 #[arg(
792 long,
793 value_name = "path",
794 help = "JSON report path; default dist/run-<id>/determinism.json"
795 )]
796 pub report: Option<PathBuf>,
797 #[arg(
798 long,
799 conflicts_with = "no_snapshot",
800 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."
801 )]
802 pub snapshot: bool,
803 #[arg(
804 long = "no-snapshot",
805 conflicts_with = "snapshot",
806 help = "Force snapshot mode OFF on the child release subprocess (artifacts emit the actual release version). Default: auto — see --snapshot."
807 )]
808 pub no_snapshot: bool,
809 #[arg(
810 long = "inject-drift",
811 value_name = "stage",
812 hide = true,
813 help = "(TEST HARNESS) Append 1 random byte to the first artifact emitted by <stage>. Gated by ANODIZE_TEST_HARNESS=1."
814 )]
815 pub inject_drift: Option<String>,
816 #[arg(
817 long = "preserve-dist",
818 value_name = "path",
819 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."
820 )]
821 pub preserve_dist: Option<PathBuf>,
822 #[arg(
823 long = "crate",
824 value_name = "name",
825 help = "When --preserve-dist is set, write the preserved dist tree to \
826 <dest>/<name>/ instead of directly into <dest>/. Used by the \
827 sharded matrix to produce per-crate subdirectories so a \
828 `release --publish-only` job can merge all crates into a single \
829 dist/ without context.json collision."
830 )]
831 pub crate_name: Option<String>,
832}
833
834fn parse_run_id(s: &str) -> Result<String, String> {
847 anodizer_stage_publish::rollback_only::validate_run_id(s)
848 .map(|()| s.to_string())
849 .map_err(|err| format!("{:#}", err))
850}
851
852pub fn detect_host_target() -> anyhow::Result<String> {
855 anodizer_core::partial::detect_host_target()
856}
857
858pub fn num_cpus() -> usize {
860 std::thread::available_parallelism()
861 .map(|n| n.get())
862 .unwrap_or(4)
863}
864
865pub fn build_cli() -> clap::Command {
867 <Cli as clap::CommandFactory>::command()
868}