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 = "Alias for --strict (also treats Unknown publisher state as a blocker during pre-flight)"
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 pushing")]
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 = "Push the tag only, leaving the version-sync bump commit local"
536 )]
537 no_push: bool,
538 #[arg(
539 long,
540 value_name = "NAME",
541 help = "Remote to push to (default: origin)"
542 )]
543 push_remote: Option<String>,
544 #[arg(
545 long,
546 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"
547 )]
548 push_dry_run: bool,
549 #[arg(
550 long = "changelog",
551 help = "Refresh CHANGELOG.md as part of this tag (requires a `changelog:` config block)"
552 )]
553 changelog: bool,
554 #[command(subcommand)]
560 sub: Option<TagSub>,
561 },
562 Continue {
582 #[arg(
583 long,
584 help = "Merge artifacts from split build jobs and run post-build stages"
585 )]
586 merge: bool,
587 #[arg(long, help = "Custom dist directory (overrides config)")]
588 dist: Option<PathBuf>,
589 #[arg(long, help = "Run full pipeline without side effects")]
590 dry_run: bool,
591 #[arg(
592 long,
593 value_delimiter = ',',
594 help = "Skip stages or publishers (comma-separated, e.g. docker,announce,npm). \
595 Unified denylist: a stage name skips the stage, a publisher name \
596 (npm, homebrew, chocolatey, …) skips that publisher."
597 )]
598 skip: Vec<String>,
599 #[arg(long = "publishers", value_delimiter = ',', help = PUBLISHERS_HELP_STEM)]
600 publishers: Vec<String>,
601 #[arg(
602 long,
603 help = TOKEN_HELP.as_str()
604 )]
605 token: Option<String>,
606 },
607 Publish {
620 #[arg(long, help = "Run full pipeline without side effects")]
621 dry_run: bool,
622 #[arg(
623 long,
624 help = TOKEN_HELP.as_str()
625 )]
626 token: Option<String>,
627 #[arg(long, help = "Custom dist directory (overrides config)")]
628 dist: Option<PathBuf>,
629 #[arg(
630 long,
631 help = "Merge artifacts from `release --split` workers (dist/<subdir>/context.json) before running the publish-only pipeline. Mirrors `goreleaser publish --merge`."
632 )]
633 merge: bool,
634 #[arg(
635 long,
636 help = "Force re-publish even when a prior report.json exists. \
637 WARNING: PR-based publishers will open duplicate pull requests."
638 )]
639 allow_rerun: bool,
640 #[arg(
641 long = "show-skipped",
642 help = "Show per-crate 'no <publisher> config block' skip lines at default verbosity \
643 (normally only visible with --debug). Use to diagnose why a publisher didn't \
644 run for a given crate."
645 )]
646 show_skipped: bool,
647 #[arg(
648 long,
649 value_delimiter = ',',
650 help = "Skip stages or publishers (comma-separated, e.g. npm,blob). \
651 Unified denylist: a stage name skips the stage, a publisher name \
652 (npm, homebrew, chocolatey, …) skips that publisher."
653 )]
654 skip: Vec<String>,
655 #[arg(long = "publishers", value_delimiter = ',', help = PUBLISHERS_HELP_STEM)]
656 publishers: Vec<String>,
657 },
658 Bump {
664 #[arg(help = "patch | minor | major | <version> | release (omit to infer)")]
665 level_or_version: Option<String>,
666 #[arg(
667 long,
668 short = 'p',
669 visible_alias = "crate",
670 action = clap::ArgAction::Append,
671 help = "Bump a specific crate (repeatable)"
672 )]
673 package: Vec<String>,
674 #[arg(
675 long,
676 alias = "all",
677 conflicts_with = "package",
678 help = "Bump every workspace member (excluding publish=false)"
679 )]
680 workspace: bool,
681 #[arg(
682 long,
683 action = clap::ArgAction::Append,
684 help = "Exclude a crate from --workspace (repeatable)"
685 )]
686 exclude: Vec<String>,
687 #[arg(long, help = "Append a prerelease identifier (e.g. rc.1)")]
688 pre: Option<String>,
689 #[arg(long, help = "Do not rewrite dependents' [dependencies] version specs")]
690 exact: bool,
691 #[arg(
692 long,
693 help = "Proceed even if the working tree has uncommitted changes"
694 )]
695 allow_dirty: bool,
696 #[arg(long, short = 'y', help = "Skip confirmation prompt")]
697 yes: bool,
698 #[arg(long, help = "Print the plan without editing any files")]
699 dry_run: bool,
700 #[arg(long, help = "Stage edits and create a single commit")]
701 commit: bool,
702 #[arg(
703 long = "changelog",
704 requires = "commit",
705 help = "Refresh CHANGELOG.md in the bump commit (requires --commit and a `changelog:` config block)"
706 )]
707 changelog: bool,
708 #[arg(
709 long,
710 requires = "commit",
711 help = "GPG-sign the commit (requires --commit)"
712 )]
713 sign: bool,
714 #[arg(long, help = "Override the default commit message template")]
715 commit_message: Option<String>,
716 #[arg(
717 long,
718 default_value = "text",
719 help = "Output format: text | json (json requires --dry-run)"
720 )]
721 output: String,
722 },
723 Announce {
732 #[arg(long, help = "Run full pipeline without side effects")]
733 dry_run: bool,
734 #[arg(long, help = "Custom dist directory (overrides config)")]
735 dist: Option<PathBuf>,
736 #[arg(
737 long,
738 help = TOKEN_HELP.as_str()
739 )]
740 token: Option<String>,
741 #[arg(long, value_delimiter = ',', help = "Skip stages (comma-separated)")]
742 skip: Vec<String>,
743 #[arg(
744 long,
745 help = "Merge artifact lists from `release --split` workers (dist/<subdir>/context.json) before announcing. Mirrors `goreleaser announce --merge`."
746 )]
747 merge: bool,
748 },
749 Notify {
756 message: String,
759 #[arg(long = "publishers", value_delimiter = ',')]
763 publishers: Vec<String>,
764 #[arg(long = "skip", value_delimiter = ',')]
766 skip: Vec<String>,
767 #[arg(long)]
771 raw: bool,
772 #[arg(long = "allow-secrets")]
776 allow_secrets: bool,
777 #[arg(long)]
779 dry_run: bool,
780 },
781}
782
783#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)]
785pub enum ChangelogFormat {
786 #[default]
790 #[value(name = "keep-a-changelog", alias = "kac")]
791 KeepAChangelog,
792 ReleaseNotes,
795 Json,
798}
799
800#[derive(Subcommand)]
807pub enum TagSub {
808 Rollback {
811 #[arg(
812 value_name = "sha",
813 help = "Commit SHA to roll back from. Defaults to HEAD."
814 )]
815 sha: Option<String>,
816 #[arg(long, help = "Print what would happen without mutating anything")]
817 dry_run: bool,
818 #[arg(
819 long = "no-push",
820 help = "Skip remote tag delete and branch push (local-only)"
821 )]
822 no_push: bool,
823 #[arg(
824 long,
825 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"
826 )]
827 force: bool,
828 #[arg(
829 long,
830 default_value = "all",
831 help = "Tag-shape filter: all | lockstep | per-crate"
832 )]
833 scope: String,
834 #[arg(
835 long,
836 default_value = "revert",
837 help = "Rollback strategy: revert (default; history-preserving) | reset (opt-in; rewrites history, requires --force-with-lease to push)"
838 )]
839 mode: String,
840 #[arg(
841 long,
842 value_name = "name",
843 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)."
844 )]
845 branch: Option<String>,
846 },
847}
848
849#[derive(Subcommand)]
855pub enum CheckCmd {
856 Config {
858 #[arg(long, help = "Validate a specific workspace in a monorepo config")]
859 workspace: Option<String>,
860 #[arg(
861 long,
862 value_delimiter = ',',
863 help = "Validate these skip tokens (stages or publishers) against the known set \
864 without running anything (comma-separated). Unified denylist: a stage name \
865 skips the stage, a publisher name (npm, homebrew, chocolatey, …) skips \
866 that publisher."
867 )]
868 skip: Vec<String>,
869 #[arg(
870 long = "publishers",
871 value_delimiter = ',',
872 help = concat!(
873 "Validate-only: check that each name is a publisher the active config \
874 actually enables (a known but unconfigured publisher is rejected). ",
875 "Comma-separated publishers to run (default: all configured). \
876 --skip always wins over --publishers.",
877 )
878 )]
879 publishers: Vec<String>,
880 },
881 Determinism(CheckDeterminismArgs),
883 VersionFiles,
885}
886
887#[derive(clap::Args)]
888pub struct CheckDeterminismArgs {
889 #[arg(
890 long,
891 default_value = "2",
892 help = "Number of from-clean rebuilds to diff"
893 )]
894 pub runs: u32,
895 #[arg(
896 long,
897 value_name = "stages",
898 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."
899 )]
900 pub stages: Option<String>,
901 #[arg(
902 long,
903 value_name = "csv",
904 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."
905 )]
906 pub targets: Option<String>,
907 #[arg(
908 long,
909 value_name = "path",
910 help = "JSON report path; default dist/run-<id>/determinism.json"
911 )]
912 pub report: Option<PathBuf>,
913 #[arg(
914 long,
915 conflicts_with = "no_snapshot",
916 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."
917 )]
918 pub snapshot: bool,
919 #[arg(
920 long = "no-snapshot",
921 conflicts_with = "snapshot",
922 help = "Force snapshot mode OFF on the child release subprocess (artifacts emit the actual release version). Default: auto — see --snapshot."
923 )]
924 pub no_snapshot: bool,
925 #[arg(
926 long = "inject-drift",
927 value_name = "stage",
928 hide = true,
929 help = "(TEST HARNESS) Append 1 random byte to the first artifact emitted by <stage>. Gated by ANODIZE_TEST_HARNESS=1."
930 )]
931 pub inject_drift: Option<String>,
932 #[arg(
933 long = "preserve-dist",
934 value_name = "path",
935 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."
936 )]
937 pub preserve_dist: Option<PathBuf>,
938 #[arg(
939 long = "crate",
940 value_name = "name",
941 help = "When --preserve-dist is set, write the preserved dist tree to \
942 <dest>/<name>/ instead of directly into <dest>/. Used by the \
943 sharded matrix to produce per-crate subdirectories so a \
944 `release --publish-only` job can merge all crates into a single \
945 dist/ without context.json collision."
946 )]
947 pub crate_name: Option<String>,
948 #[arg(
961 long = "require-tools",
962 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."
963 )]
964 pub require_tools: bool,
965}
966
967fn parse_run_id(s: &str) -> Result<String, String> {
980 anodizer_stage_publish::rollback_only::validate_run_id(s)
981 .map(|()| s.to_string())
982 .map_err(|err| format!("{:#}", err))
983}
984
985pub fn detect_host_target() -> anyhow::Result<String> {
988 anodizer_core::partial::detect_host_target()
989}
990
991pub fn num_cpus() -> usize {
993 std::thread::available_parallelism()
994 .map(|n| n.get())
995 .unwrap_or(4)
996}
997
998pub fn build_cli() -> clap::Command {
1000 <Cli as clap::CommandFactory>::command()
1001}