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
11static TOKEN_HELP: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
15 format!(
16 "GitHub token (overrides {} env vars)",
17 anodizer_core::git::GITHUB_TOKEN_ENV_LADDER.join(" / ")
18 )
19});
20
21static PREPARE_HELP: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
24 format!(
25 "Run local build + archive + sign + checksum + sbom stages but skip every \
26 upstream-reaching stage ({}) — GoReleaser Pro parity. Artifacts stay in dist/ \
27 for inspection. `--prepare-only` is accepted as an alias for GR-imported scripts.",
28 anodizer_core::stages::UPSTREAM_STAGES.join(", ")
29 )
30});
31
32#[derive(Parser)]
33#[command(name = "anodizer", version, about = "Release Rust projects with ease")]
34pub struct Cli {
35 #[arg(
36 long,
37 short = 'f',
38 global = true,
39 help = "Path to config file (overrides auto-detection)"
40 )]
41 pub config: Option<PathBuf>,
42 #[arg(long, global = true, help = "Enable verbose output")]
43 pub verbose: bool,
44 #[arg(long, global = true, help = "Enable debug output")]
45 pub debug: bool,
46 #[arg(long, short = 'q', global = true, help = "Suppress non-error output")]
47 pub quiet: bool,
48 #[arg(
49 long,
50 global = true,
51 help = "Strict mode: configured features that silently skip become hard errors"
52 )]
53 pub strict: bool,
54 #[command(subcommand)]
60 pub command: Option<Commands>,
61}
62
63#[derive(Subcommand)]
64#[allow(clippy::large_enum_variant)]
69pub enum Commands {
70 Release {
72 #[arg(long = "crate", visible_alias = "id", action = clap::ArgAction::Append, help = "Release a specific crate (repeatable; --id is accepted as a GoReleaser-compat alias)")]
73 crate_names: Vec<String>,
74 #[arg(long, help = "Release all crates with unreleased changes")]
75 all: bool,
76 #[arg(long, help = "Force release even without unreleased changes")]
77 force: bool,
78 #[arg(long, help = "Build without publishing (snapshot mode)")]
79 snapshot: bool,
80 #[arg(long, help = "Create a nightly release with date-based version")]
81 nightly: bool,
82 #[arg(long, help = "Run full pipeline without side effects")]
83 dry_run: bool,
84 #[arg(long, help = "Remove dist directory before starting")]
85 clean: bool,
86 #[arg(
87 long,
88 value_delimiter = ',',
89 help = "Skip stages or publishers (comma-separated, e.g. docker,announce,npm). \
90 Unified denylist: a stage name skips the stage, a publisher name \
91 (npm, homebrew, chocolatey, …) skips that publisher."
92 )]
93 skip: Vec<String>,
94 #[arg(long = "publishers", value_delimiter = ',', help = PUBLISHERS_HELP_STEM)]
95 publishers: Vec<String>,
96 #[arg(
97 long,
98 help = TOKEN_HELP.as_str()
99 )]
100 token: Option<String>,
101 #[arg(
102 long,
103 default_value = "3h",
104 help = "Pipeline timeout duration (e.g., 90m, 3h, 5s) — a generous \
105 safety backstop, not the primary bound; per-stage bounds \
106 (e.g. announce.deadline) catch a hung stage in seconds"
107 )]
108 timeout: String,
109 #[arg(
110 long,
111 short = 'p',
112 help = "Maximum number of parallel build jobs (default: number of CPUs)"
113 )]
114 parallelism: Option<usize>,
115 #[arg(long, help = "Automatically set --snapshot if the git repo is dirty")]
116 auto_snapshot: bool,
117 #[arg(long, help = "Build only for the host target triple")]
118 single_target: bool,
119 #[arg(
120 long,
121 value_name = "csv",
122 conflicts_with = "single_target",
123 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."
124 )]
125 targets: Option<String>,
126 #[arg(
127 long = "host-targets",
128 conflicts_with = "single_target",
129 conflicts_with = "targets",
130 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."
131 )]
132 host_targets: bool,
133 #[arg(
134 long,
135 help = "Path to a custom release notes file (overrides changelog)"
136 )]
137 release_notes: Option<PathBuf>,
138 #[arg(
139 long,
140 conflicts_with = "crate_names",
141 help = "Release a specific workspace in a monorepo config"
142 )]
143 workspace: Option<String>,
144 #[arg(
145 long,
146 conflicts_with = "no_preflight",
147 help = "Run pre-flight publisher-state check and exit (don't start the pipeline)"
148 )]
149 preflight: bool,
150 #[arg(
151 long,
152 conflicts_with = "preflight",
153 help = "Skip the automatic pre-flight publisher-state check"
154 )]
155 no_preflight: bool,
156 #[arg(
157 long = "preflight-secrets",
158 conflicts_with = "no_preflight",
159 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."
160 )]
161 preflight_secrets: bool,
162 #[arg(
163 long,
164 help = "Strict pre-flight: treat Unknown publisher state and indeterminate probe results (5xx / rate-limit / network failure / undeterminable permissions) as blockers. Implied by --strict; configurable per-project via preflight.strict"
165 )]
166 strict_preflight: bool,
167 #[arg(long, help = "Set the release as a draft")]
168 draft: bool,
169 #[arg(long, help = "Path to a file containing custom release header text")]
170 release_header: Option<PathBuf>,
171 #[arg(
172 long,
173 help = "Path to a template file for release header (rendered with template variables)"
174 )]
175 release_header_tmpl: Option<PathBuf>,
176 #[arg(long, help = "Path to a file containing custom release footer text")]
177 release_footer: Option<PathBuf>,
178 #[arg(
179 long,
180 help = "Path to a template file for release footer (rendered with template variables)"
181 )]
182 release_footer_tmpl: Option<PathBuf>,
183 #[arg(
184 long,
185 help = "Path to a template file for release notes (rendered with template variables, overrides --release-notes)"
186 )]
187 release_notes_tmpl: Option<PathBuf>,
188 #[arg(long, help = "Abort immediately on first error during publishing")]
189 fail_fast: bool,
190 #[arg(
191 long = "no-gate-submitter",
192 help = "Disable the Submitter gate: dispatch Submitter publishers even when required Assets/Manager publishers failed"
193 )]
194 no_gate_submitter: bool,
195 #[arg(
196 long = "rollback",
197 value_name = "none|best-effort",
198 help = "Rollback policy after publish stage. Defaults to best-effort when preflight is clean, none otherwise."
199 )]
200 rollback: Option<String>,
201 #[arg(
202 long = "simulate-failure",
203 value_name = "publisher",
204 action = clap::ArgAction::Append,
205 hide = true,
206 help = "(TEST HARNESS) Force a named publisher to fail. Gated by ANODIZE_TEST_HARNESS=1."
207 )]
208 simulate_failure: Vec<String>,
209 #[arg(
210 long = "rollback-only",
211 requires = "from_run",
212 conflicts_with = "clean",
213 help = "Skip publish; re-attempt rollback from a prior run report. Requires --from-run=<id>."
214 )]
215 rollback_only: bool,
216 #[arg(
217 long = "from-run",
218 value_name = "id",
219 requires = "rollback_only",
220 value_parser = parse_run_id,
221 help = "Prior run id whose state to load when running --rollback-only. \
222 Loads <dist>/run-<id>/rollback.json if present (a prior replay's state), \
223 otherwise <dist>/run-<id>/report.json. Delete rollback.json to force a \
224 full re-roll. Must match the run_id format written by the release pipeline \
225 (alphanumeric, dot, dash, underscore; no path separators)."
226 )]
227 from_run: Option<String>,
228 #[arg(
229 long = "allow-rerun",
230 conflicts_with = "rollback_only",
231 help = "DANGEROUS: force publish to proceed even when a prior \
232 dist/run-<id>/report.json exists for this tag. PR-based publishers \
233 (homebrew, scoop, nix, krew, MCP) will open DUPLICATE pull requests. \
234 Recover from partial failures with --rollback-only --from-run=<id> first. \
235 Cannot be combined with --rollback-only (which has its own idempotency)."
236 )]
237 allow_rerun: bool,
238 #[arg(
239 long = "show-skipped",
240 help = "Show per-crate 'no <publisher> config block' skip lines at default verbosity \
241 (normally only visible with --debug). Use to diagnose why a publisher didn't \
242 run for a given crate."
243 )]
244 show_skipped: bool,
245 #[arg(
246 long = "allow-nondeterministic",
247 value_name = "name=reason",
248 action = clap::ArgAction::Append,
249 help = "Runtime non-determinism opt-out for a specific artifact (repeatable). Mutually exclusive with --strict."
250 )]
251 allow_nondeterministic: Vec<String>,
252 #[arg(
253 long = "summary-json",
254 value_name = "path",
255 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."
256 )]
257 summary_json: Option<PathBuf>,
258 #[arg(
259 long = "allow-ai-failure",
260 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."
261 )]
262 allow_ai_failure: bool,
263 #[arg(
264 long = "allow-snapshot-publish",
265 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."
266 )]
267 allow_snapshot_publish: bool,
268 #[arg(
269 long,
270 conflicts_with = "merge",
271 help = "Run only the build stage for split CI fan-out (outputs artifacts JSON to dist/)"
272 )]
273 split: bool,
274 #[arg(
275 long,
276 conflicts_with = "split",
277 help = "Merge artifacts from split build jobs and resume the pipeline from post-build stages"
278 )]
279 merge: bool,
280 #[arg(
281 long = "publish-only",
282 conflicts_with_all = ["split", "merge", "prepare", "announce_only", "snapshot", "rollback_only", "clean"],
283 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/."
284 )]
285 publish_only: bool,
286 #[arg(
287 long,
288 alias = "prepare-only",
289 conflicts_with_all = ["publish_only", "announce_only", "rollback_only"],
290 help = PREPARE_HELP.as_str()
291 )]
292 prepare: bool,
293 #[arg(
294 long = "announce-only",
295 conflicts_with_all = ["prepare", "publish_only", "snapshot", "rollback_only", "split", "merge", "clean"],
296 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."
297 )]
298 announce_only: bool,
299 #[arg(
300 long,
301 help = "Resume into an existing release left over from a prior failed attempt; bypasses the safety check that bails on partial assets."
302 )]
303 resume_release: bool,
304 #[arg(
305 long,
306 help = "Force release.replace_existing_artifacts: true regardless of config (overwrite conflicting assets on retry)."
307 )]
308 replace_existing: bool,
309 #[arg(
310 long = "no-post-publish-poll",
311 help = "Skip post-publish polling for chocolatey moderation / winget PR validation; report NotPolled for affected publishers."
312 )]
313 no_post_publish_poll: bool,
314 #[arg(
315 long = "no-failure-policy",
316 hide = true,
317 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."
318 )]
319 no_failure_policy: bool,
320 },
321 Build {
323 #[arg(long = "crate", action = clap::ArgAction::Append, help = "Build a specific crate (repeatable)")]
324 crate_names: Vec<String>,
325 #[arg(
326 long,
327 default_value = "3h",
328 help = "Pipeline timeout duration (e.g., 90m, 3h, 5s) — a generous \
329 safety backstop, not the primary bound; per-stage bounds \
330 (e.g. announce.deadline) catch a hung stage in seconds"
331 )]
332 timeout: String,
333 #[arg(
334 long,
335 short = 'p',
336 help = "Maximum number of parallel build jobs (default: number of CPUs)"
337 )]
338 parallelism: Option<usize>,
339 #[arg(long, help = "Build only for the host target triple")]
340 single_target: bool,
341 #[arg(
342 long,
343 conflicts_with = "crate_names",
344 help = "Build a specific workspace in a monorepo config"
345 )]
346 workspace: Option<String>,
347 #[arg(
348 long,
349 short = 'o',
350 help = "Copy the built binary to this path (requires --single-target and single crate)"
351 )]
352 output: Option<PathBuf>,
353 #[arg(
354 long,
355 value_delimiter = ',',
356 help = "Skip stages (comma-separated: pre-hooks, post-hooks, validate, before)"
357 )]
358 skip: Vec<String>,
359 },
360 Check {
362 #[command(subcommand)]
363 cmd: CheckCmd,
364 },
365 Init {
367 #[arg(
368 long,
369 help = "Discover repo files that embed the current version and enroll the selection into version_files in .anodizer.yaml"
370 )]
371 version_files: bool,
372 #[arg(
373 long,
374 value_delimiter = ',',
375 requires = "version_files",
376 help = "Glob(s) to drop from discovered candidates (repeatable or comma-separated); only with --version-files"
377 )]
378 exclude: Vec<String>,
379 #[arg(
380 long,
381 short = 'y',
382 requires = "version_files",
383 help = "Non-interactive: enroll all discovered candidates without prompting"
384 )]
385 yes: bool,
386 },
387 Changelog {
389 #[arg(
390 value_name = "tag|range",
391 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"
392 )]
393 range: Option<String>,
394 #[arg(
395 long,
396 value_enum,
397 default_value = "keep-a-changelog",
398 help = "Output format: keep-a-changelog (refresh the [Unreleased] section), release-notes (grouped-bullet GitHub body to stdout), or json"
399 )]
400 format: ChangelogFormat,
401 #[arg(
402 long,
403 help = "Apply the regenerated [Unreleased] section to the configured CHANGELOG.md file(s) in place (keep-a-changelog only)"
404 )]
405 write: bool,
406 #[arg(long = "crate", help = "Restrict to a specific crate in a workspace")]
407 crate_name: Option<String>,
408 #[arg(
409 long,
410 help = "Preview as a snapshot release (release-notes format only)"
411 )]
412 snapshot: bool,
413 },
414 Completion {
416 #[arg(value_enum, help = "Shell to generate completions for")]
417 shell: Shell,
418 },
419 Healthcheck,
421 Preflight {
428 #[arg(long, help = "Output the report as JSON")]
429 json: bool,
430 #[arg(
431 long,
432 help = "Check only the publish-time surface (the stages `release --publish-only` runs), not artifact-producing stages"
433 )]
434 publish_only: bool,
435 #[arg(
436 long,
437 value_delimiter = ',',
438 help = "Skip requirement collection for these stages (comma-separated, same names as release --skip)"
439 )]
440 skip: Vec<String>,
441 #[arg(long = "publishers", value_delimiter = ',', help = PUBLISHERS_HELP_STEM)]
442 publishers: Vec<String>,
443 #[arg(
444 long,
445 help = "GitHub token override; when set, GitHub token env-var requirements are treated as satisfied"
446 )]
447 token: Option<String>,
448 },
449 Man,
451 Jsonschema,
453 ResolveTag {
455 #[arg(help = "Tag to resolve (e.g. 'v1.2.3', 'core-v0.2.3')")]
456 tag: String,
457 #[arg(long, help = "Output as JSON")]
458 json: bool,
459 },
460 Targets {
466 #[arg(long, help = "Output as JSON (include-form matrix)")]
467 json: bool,
468 #[arg(long = "crate", action = clap::ArgAction::Append, help = "Restrict to specific crate(s)")]
469 crate_names: Vec<String>,
470 },
471 Vocabulary {
479 #[arg(long, help = "Output as JSON")]
480 json: bool,
481 },
482 Tools {
489 #[arg(long, help = "Output as JSON")]
490 json: bool,
491 #[arg(
492 long,
493 help = "Only the tools the publish-time surface needs (the stages `release --publish-only` runs), not artifact-producing stages"
494 )]
495 publish_only: bool,
496 #[arg(
497 long,
498 value_delimiter = ',',
499 help = "Drop tools contributed by these skipped stages (comma-separated, same names as release --skip)"
500 )]
501 skip: Vec<String>,
502 #[arg(long = "publishers", value_delimiter = ',', help = PUBLISHERS_HELP_STEM)]
503 publishers: Vec<String>,
504 },
505 Tag {
507 #[arg(long, help = "Show what tag would be created, without creating it")]
508 dry_run: bool,
509 #[arg(long, help = "Override bump logic with a specific tag value")]
510 custom_tag: Option<String>,
511 #[arg(long = "version", value_name = "VERSION")]
522 version_override: Option<String>,
523 #[arg(long, help = "Override default bump type (patch/minor/major)")]
524 default_bump: Option<String>,
525 #[arg(long = "crate", help = "Tag a specific crate in a workspace")]
526 crate_name: Option<String>,
527 #[arg(
528 long,
529 help = "Push the version-sync bump commit to the release branch atomically with the tag"
530 )]
531 push: bool,
532 #[arg(
533 long,
534 conflicts_with = "push",
535 help = "Do not push anything; the tag(s) and version-sync bump commit stay local"
536 )]
537 no_push: bool,
538 #[arg(
539 long,
540 conflicts_with_all = ["push", "no_push"],
541 help = "Push the tag(s) but not the version-sync bump commit (deferred-branch CI pattern; the branch must be advanced to the bump commit separately)"
542 )]
543 push_tags_only: bool,
544 #[arg(
545 long,
546 value_name = "NAME",
547 help = "Remote to push to (default: origin)"
548 )]
549 push_remote: Option<String>,
550 #[arg(
551 long,
552 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"
553 )]
554 push_dry_run: bool,
555 #[arg(
556 long = "changelog",
557 help = "Refresh CHANGELOG.md as part of this tag (requires a `changelog:` config block)"
558 )]
559 changelog: bool,
560 #[command(subcommand)]
566 sub: Option<TagSub>,
567 },
568 Continue {
588 #[arg(
589 long,
590 help = "Merge artifacts from split build jobs and run post-build stages"
591 )]
592 merge: bool,
593 #[arg(long, help = "Custom dist directory (overrides config)")]
594 dist: Option<PathBuf>,
595 #[arg(long, help = "Run full pipeline without side effects")]
596 dry_run: bool,
597 #[arg(
598 long,
599 value_delimiter = ',',
600 help = "Skip stages or publishers (comma-separated, e.g. docker,announce,npm). \
601 Unified denylist: a stage name skips the stage, a publisher name \
602 (npm, homebrew, chocolatey, …) skips that publisher."
603 )]
604 skip: Vec<String>,
605 #[arg(long = "publishers", value_delimiter = ',', help = PUBLISHERS_HELP_STEM)]
606 publishers: Vec<String>,
607 #[arg(
608 long,
609 help = TOKEN_HELP.as_str()
610 )]
611 token: Option<String>,
612 },
613 Publish {
626 #[arg(long, help = "Run full pipeline without side effects")]
627 dry_run: bool,
628 #[arg(
629 long,
630 help = TOKEN_HELP.as_str()
631 )]
632 token: Option<String>,
633 #[arg(long, help = "Custom dist directory (overrides config)")]
634 dist: Option<PathBuf>,
635 #[arg(
636 long,
637 help = "Merge artifacts from `release --split` workers (dist/<subdir>/context.json) before running the publish-only pipeline. Mirrors `goreleaser publish --merge`."
638 )]
639 merge: bool,
640 #[arg(
641 long,
642 help = "Force re-publish even when a prior report.json exists. \
643 WARNING: PR-based publishers will open duplicate pull requests."
644 )]
645 allow_rerun: bool,
646 #[arg(
647 long = "show-skipped",
648 help = "Show per-crate 'no <publisher> config block' skip lines at default verbosity \
649 (normally only visible with --debug). Use to diagnose why a publisher didn't \
650 run for a given crate."
651 )]
652 show_skipped: bool,
653 #[arg(
654 long,
655 value_delimiter = ',',
656 help = "Skip stages or publishers (comma-separated, e.g. npm,blob). \
657 Unified denylist: a stage name skips the stage, a publisher name \
658 (npm, homebrew, chocolatey, …) skips that publisher."
659 )]
660 skip: Vec<String>,
661 #[arg(long = "publishers", value_delimiter = ',', help = PUBLISHERS_HELP_STEM)]
662 publishers: Vec<String>,
663 },
664 Bump {
670 #[arg(help = "patch | minor | major | <version> | release (omit to infer)")]
671 level_or_version: Option<String>,
672 #[arg(
673 long,
674 short = 'p',
675 visible_alias = "crate",
676 action = clap::ArgAction::Append,
677 help = "Bump a specific crate (repeatable)"
678 )]
679 package: Vec<String>,
680 #[arg(
681 long,
682 alias = "all",
683 conflicts_with = "package",
684 help = "Bump every workspace member (excluding publish=false)"
685 )]
686 workspace: bool,
687 #[arg(
688 long,
689 action = clap::ArgAction::Append,
690 help = "Exclude a crate from --workspace (repeatable)"
691 )]
692 exclude: Vec<String>,
693 #[arg(long, help = "Append a prerelease identifier (e.g. rc.1)")]
694 pre: Option<String>,
695 #[arg(long, help = "Do not rewrite dependents' [dependencies] version specs")]
696 exact: bool,
697 #[arg(
698 long,
699 help = "Proceed even if the working tree has uncommitted changes"
700 )]
701 allow_dirty: bool,
702 #[arg(long, short = 'y', help = "Skip confirmation prompt")]
703 yes: bool,
704 #[arg(long, help = "Print the plan without editing any files")]
705 dry_run: bool,
706 #[arg(long, help = "Stage edits and create a single commit")]
707 commit: bool,
708 #[arg(
709 long = "changelog",
710 requires = "commit",
711 help = "Refresh CHANGELOG.md in the bump commit (requires --commit and a `changelog:` config block)"
712 )]
713 changelog: bool,
714 #[arg(
715 long,
716 requires = "commit",
717 help = "GPG-sign the commit (requires --commit)"
718 )]
719 sign: bool,
720 #[arg(long, help = "Override the default commit message template")]
721 commit_message: Option<String>,
722 #[arg(
723 long,
724 default_value = "text",
725 help = "Output format: text | json (json requires --dry-run)"
726 )]
727 output: String,
728 },
729 Announce {
738 #[arg(long, help = "Run full pipeline without side effects")]
739 dry_run: bool,
740 #[arg(long, help = "Custom dist directory (overrides config)")]
741 dist: Option<PathBuf>,
742 #[arg(
743 long,
744 help = TOKEN_HELP.as_str()
745 )]
746 token: Option<String>,
747 #[arg(long, value_delimiter = ',', help = "Skip stages (comma-separated)")]
748 skip: Vec<String>,
749 #[arg(
750 long,
751 help = "Merge artifact lists from `release --split` workers (dist/<subdir>/context.json) before announcing. Mirrors `goreleaser announce --merge`."
752 )]
753 merge: bool,
754 },
755 Notify {
762 message: String,
765 #[arg(long = "publishers", value_delimiter = ',')]
769 publishers: Vec<String>,
770 #[arg(long = "skip", value_delimiter = ',')]
772 skip: Vec<String>,
773 #[arg(long)]
777 raw: bool,
778 #[arg(long = "allow-secrets")]
782 allow_secrets: bool,
783 #[arg(long)]
785 dry_run: bool,
786 },
787}
788
789#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)]
791pub enum ChangelogFormat {
792 #[default]
796 #[value(name = "keep-a-changelog", alias = "kac")]
797 KeepAChangelog,
798 ReleaseNotes,
801 Json,
804}
805
806#[derive(Subcommand)]
813pub enum TagSub {
814 Rollback {
817 #[arg(
818 value_name = "sha",
819 help = "Commit SHA to roll back from. Defaults to HEAD."
820 )]
821 sha: Option<String>,
822 #[arg(long, help = "Print what would happen without mutating anything")]
823 dry_run: bool,
824 #[arg(
825 long = "no-push",
826 help = "Skip remote tag delete and branch push (local-only)"
827 )]
828 no_push: bool,
829 #[arg(
830 long,
831 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, when the crates.io index shows the tag's crate@version live (GLOBAL state — published by any prior run, not just this one; an unreachable index also refuses), 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"
832 )]
833 force: bool,
834 #[arg(
835 long,
836 default_value = "all",
837 help = "Tag-shape filter: all | lockstep | per-crate"
838 )]
839 scope: String,
840 #[arg(
841 long,
842 default_value = "revert",
843 help = "Rollback strategy: revert (default; history-preserving) | reset (opt-in; rewrites history, requires --force-with-lease to push)"
844 )]
845 mode: String,
846 #[arg(
847 long,
848 value_name = "name",
849 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)."
850 )]
851 branch: Option<String>,
852 },
853}
854
855#[derive(Subcommand)]
861pub enum CheckCmd {
862 Config {
864 #[arg(long, help = "Validate a specific workspace in a monorepo config")]
865 workspace: Option<String>,
866 #[arg(
867 long,
868 value_delimiter = ',',
869 help = "Validate these skip tokens (stages or publishers) against the known set \
870 without running anything (comma-separated). Unified denylist: a stage name \
871 skips the stage, a publisher name (npm, homebrew, chocolatey, …) skips \
872 that publisher."
873 )]
874 skip: Vec<String>,
875 #[arg(
876 long = "publishers",
877 value_delimiter = ',',
878 help = concat!(
879 "Validate-only: check that each name is a publisher the active config \
880 actually enables (a known but unconfigured publisher is rejected). ",
881 "Comma-separated publishers to run (default: all configured). \
882 --skip always wins over --publishers.",
883 )
884 )]
885 publishers: Vec<String>,
886 },
887 Determinism(CheckDeterminismArgs),
889 VersionFiles,
891}
892
893#[derive(clap::Args)]
894pub struct CheckDeterminismArgs {
895 #[arg(
896 long,
897 default_value = "2",
898 help = "Number of from-clean rebuilds to diff"
899 )]
900 pub runs: u32,
901 #[arg(
902 long,
903 value_name = "stages",
904 help = "Optional stage subset (build,source,upx,archive,nfpm,makeself,snapcraft,sbom,sign,checksum,cargo-package,docker,msi,nsis,dmg,pkg,srpm,appbundle,appimage,flatpak, plus the `installers` family selector expanding to nfpm,makeself,srpm,msi,nsis,dmg,pkg). Omit the flag to byte-verify the full OS-native partition for this host (Linux adds nfpm/makeself/snapcraft/srpm/docker/appimage/flatpak; macOS adds appbundle/dmg/pkg; Windows adds msi/nsis). 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 (or in the host default) 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; it is NOT in the host default and stays opt-in. `docker` is harness-only — drives `docker buildx build --output=type=oci,rewrite-timestamp=true,dest=…` against each configured `dockers_v2` entry's rendered dockerfile (with its `extra_files` and `build_args`, mirroring the production `docker` stage) to probe OCI image byte-stability without pushing to a registry; skipped when `docker buildx` is unavailable or the crate configures no `dockers_v2`. Installer stages (msi/nsis/dmg/pkg/srpm) plus appimage (needs `linuxdeploy`) and flatpak (needs `flatpak-builder`) are skipped at the gate when their backing tool is absent — a host-default stage warn-skips, an explicitly typed one hard-fails; `appbundle` is pure file assembly and always runs when requested."
905 )]
906 pub stages: Option<String>,
907 #[arg(
908 long,
909 value_name = "csv",
910 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."
911 )]
912 pub targets: Option<String>,
913 #[arg(
914 long,
915 value_name = "path",
916 help = "JSON report path; default dist/run-<id>/determinism.json"
917 )]
918 pub report: Option<PathBuf>,
919 #[arg(
920 long,
921 conflicts_with = "no_snapshot",
922 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."
923 )]
924 pub snapshot: bool,
925 #[arg(
926 long = "no-snapshot",
927 conflicts_with = "snapshot",
928 help = "Force snapshot mode OFF on the child release subprocess (artifacts emit the actual release version). Default: auto — see --snapshot."
929 )]
930 pub no_snapshot: bool,
931 #[arg(
932 long = "inject-drift",
933 value_name = "stage",
934 hide = true,
935 help = "(TEST HARNESS) Append 1 random byte to the first artifact emitted by <stage>. Gated by ANODIZE_TEST_HARNESS=1."
936 )]
937 pub inject_drift: Option<String>,
938 #[arg(
939 long = "preserve-dist",
940 value_name = "path",
941 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."
942 )]
943 pub preserve_dist: Option<PathBuf>,
944 #[arg(
945 long = "crate",
946 value_name = "name",
947 help = "When --preserve-dist is set, write the preserved dist tree to \
948 <dest>/<name>/ instead of directly into <dest>/. Used by the \
949 sharded matrix to produce per-crate subdirectories so a \
950 `release --publish-only` job can merge all crates into a single \
951 dist/ without context.json collision."
952 )]
953 pub crate_name: Option<String>,
954 #[arg(
967 long = "require-tools",
968 help = "Fail (not warn-skip) if any selected stage's backing tool is missing — used by CI so a default host-OS run cannot silently skip an OS-native producer."
969 )]
970 pub require_tools: bool,
971}
972
973fn parse_run_id(s: &str) -> Result<String, String> {
986 anodizer_stage_publish::rollback_only::validate_run_id(s)
987 .map(|()| s.to_string())
988 .map_err(|err| format!("{:#}", err))
989}
990
991pub fn detect_host_target() -> anyhow::Result<String> {
994 anodizer_core::partial::detect_host_target()
995}
996
997pub fn num_cpus() -> usize {
999 std::thread::available_parallelism()
1000 .map(|n| n.get())
1001 .unwrap_or(4)
1002}
1003
1004pub fn build_cli() -> clap::Command {
1006 <Cli as clap::CommandFactory>::command()
1007}