use clap::{Parser, Subcommand};
use clap_complete::Shell;
use std::path::PathBuf;
pub mod mcp;
pub mod subcommands;
pub use subcommands::{ChangelogFormat, CheckCmd, CheckDeterminismArgs, TagSub};
const PUBLISHERS_HELP_STEM: &str = "Comma-separated publishers to run (default: all configured). \
--skip always wins over --publishers.";
static TOKEN_HELP: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
format!(
"GitHub token (overrides {} env vars)",
anodizer_core::git::GITHUB_TOKEN_ENV_LADDER.join(" / ")
)
});
static PREPARE_HELP: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
format!(
"Run local build + archive + sign + checksum + sbom stages but skip every \
upstream-reaching stage ({}) — GoReleaser Pro parity. Artifacts stay in dist/ \
for inspection. `--prepare-only` is accepted as an alias for GR-imported scripts.",
anodizer_core::stages::UPSTREAM_STAGES.join(", ")
)
});
const REMOVED_ROLLBACK_FLAG_MIGRATION: &str = "removed in favor of convergent re-run: re-running `anodizer release` with the identical \
arguments reconciles against what already published and skips it, so a failed release is \
recovered by re-running it. To withdraw a release deliberately, use `anodizer tag rollback`. \
Drop this flag from the invocation.";
fn removed_rollback_flag(_: &str) -> Result<String, String> {
Err(REMOVED_ROLLBACK_FLAG_MIGRATION.to_string())
}
#[derive(Parser)]
#[command(name = "anodizer", version, about = "Release Rust projects with ease")]
pub struct Cli {
#[arg(
long,
short = 'f',
global = true,
help = "Path to config file (overrides auto-detection)"
)]
pub config: Option<PathBuf>,
#[arg(long, global = true, help = "Enable verbose output")]
pub verbose: bool,
#[arg(long, global = true, help = "Enable debug output")]
pub debug: bool,
#[arg(long, short = 'q', global = true, help = "Suppress non-error output")]
pub quiet: bool,
#[arg(
long,
global = true,
help = "Strict mode: configured features that silently skip become hard errors"
)]
pub strict: bool,
#[command(subcommand)]
pub command: Option<Commands>,
}
#[derive(Subcommand)]
#[allow(clippy::large_enum_variant)]
pub enum Commands {
Release {
#[arg(long = "crate", visible_alias = "id", action = clap::ArgAction::Append, help = "Release a specific crate (repeatable; --id is accepted as a GoReleaser-compat alias)")]
crate_names: Vec<String>,
#[arg(long, help = "Release all crates with unreleased changes")]
all: bool,
#[arg(long, help = "Force release even without unreleased changes")]
force: bool,
#[arg(long, help = "Build without publishing (snapshot mode)")]
snapshot: bool,
#[arg(long, help = "Create a nightly release with date-based version")]
nightly: bool,
#[arg(long, help = "Run full pipeline without side effects")]
dry_run: bool,
#[arg(long, help = "Remove dist directory before starting")]
clean: bool,
#[arg(
long,
value_delimiter = ',',
help = "Skip stages or publishers (comma-separated, e.g. docker,announce,npm). \
Unified denylist: a stage name skips the stage, a publisher name \
(npm, homebrew, chocolatey, …) skips that publisher."
)]
skip: Vec<String>,
#[arg(long = "publishers", value_delimiter = ',', help = PUBLISHERS_HELP_STEM)]
publishers: Vec<String>,
#[arg(
long,
help = TOKEN_HELP.as_str()
)]
token: Option<String>,
#[arg(
long,
default_value = "3h",
help = "Pipeline timeout duration (e.g., 90m, 3h, 5s) — a generous \
safety backstop, not the primary bound; per-stage bounds \
(e.g. announce.deadline) catch a hung stage in seconds"
)]
timeout: String,
#[arg(
long,
short = 'p',
help = "Maximum number of parallel build jobs (default: number of CPUs)"
)]
parallelism: Option<usize>,
#[arg(long, help = "Automatically set --snapshot if the git repo is dirty")]
auto_snapshot: bool,
#[arg(long, help = "Build only for the host target triple")]
single_target: bool,
#[arg(
long,
value_name = "csv",
conflicts_with = "single_target",
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."
)]
targets: Option<String>,
#[arg(
long = "host-targets",
conflicts_with = "single_target",
conflicts_with = "targets",
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."
)]
host_targets: bool,
#[arg(
long,
help = "Path to a custom release notes file (overrides changelog)"
)]
release_notes: Option<PathBuf>,
#[arg(
long,
conflicts_with = "crate_names",
help = "Release a specific workspace in a monorepo config"
)]
workspace: Option<String>,
#[arg(long, help = "Set the release as a draft")]
draft: bool,
#[arg(long, help = "Path to a file containing custom release header text")]
release_header: Option<PathBuf>,
#[arg(
long,
help = "Path to a template file for release header (rendered with template variables)"
)]
release_header_tmpl: Option<PathBuf>,
#[arg(long, help = "Path to a file containing custom release footer text")]
release_footer: Option<PathBuf>,
#[arg(
long,
help = "Path to a template file for release footer (rendered with template variables)"
)]
release_footer_tmpl: Option<PathBuf>,
#[arg(
long,
help = "Path to a template file for release notes (rendered with template variables, overrides --release-notes)"
)]
release_notes_tmpl: Option<PathBuf>,
#[arg(long, help = "Abort immediately on first error during publishing")]
fail_fast: bool,
#[arg(
long = "no-gate-submitter",
help = "Disable the Submitter gate: dispatch Submitter publishers even when required Assets/Manager publishers failed, or when the pre-submitter verify-release check did not pass"
)]
no_gate_submitter: bool,
#[arg(
long = "simulate-failure",
value_name = "publisher",
action = clap::ArgAction::Append,
hide = true,
help = "(TEST HARNESS) Force a named publisher to fail. Gated by ANODIZE_TEST_HARNESS=1."
)]
simulate_failure: Vec<String>,
#[arg(
long = "show-skipped",
help = "Show per-crate 'no <publisher> config block' skip lines at default verbosity \
(normally only visible with --debug). Use to diagnose why a publisher didn't \
run for a given crate."
)]
show_skipped: bool,
#[arg(
long = "allow-nondeterministic",
value_name = "name=reason",
action = clap::ArgAction::Append,
help = "Runtime non-determinism opt-out for a specific artifact (repeatable). Mutually exclusive with --strict."
)]
allow_nondeterministic: Vec<String>,
#[arg(
long = "summary-json",
value_name = "path",
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."
)]
summary_json: Option<PathBuf>,
#[arg(
long = "allow-ai-failure",
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."
)]
allow_ai_failure: bool,
#[arg(
long = "allow-snapshot-publish",
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."
)]
allow_snapshot_publish: bool,
#[arg(
long,
conflicts_with = "merge",
help = "Run only the build stage for split CI fan-out (outputs artifacts JSON to dist/)"
)]
split: bool,
#[arg(
long,
conflicts_with = "split",
help = "Merge artifacts from split build jobs and resume the pipeline from post-build stages"
)]
merge: bool,
#[arg(
long = "publish-only",
conflicts_with_all = ["split", "merge", "prepare", "announce_only", "snapshot", "clean"],
help = "Load artifacts from dist/ (preserved by `anodizer 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/."
)]
publish_only: bool,
#[arg(
long,
alias = "prepare-only",
conflicts_with_all = ["publish_only", "announce_only"],
help = PREPARE_HELP.as_str()
)]
prepare: bool,
#[arg(
long = "announce-only",
conflicts_with_all = ["prepare", "publish_only", "snapshot", "split", "merge", "clean"],
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."
)]
announce_only: bool,
#[arg(
long,
help = "Resume into an existing release left over from a prior failed attempt; bypasses the safety check that bails on partial assets."
)]
resume_release: bool,
#[arg(
long,
help = "Force release.replace_existing_artifacts: true regardless of config (overwrite conflicting assets on retry)."
)]
replace_existing: bool,
#[arg(
long = "no-post-publish-poll",
help = "Skip post-publish polling for chocolatey moderation / winget PR validation; report NotPolled for affected publishers."
)]
no_post_publish_poll: bool,
#[arg(
long = "rollback",
value_name = "policy",
hide = true,
value_parser = removed_rollback_flag,
help = "(REMOVED) Automatic rollback policy."
)]
removed_rollback: Option<String>,
#[arg(
long = "rollback-only",
value_name = "removed",
num_args = 0..=1,
default_missing_value = "rollback-only",
hide = true,
value_parser = removed_rollback_flag,
help = "(REMOVED) Run only the rollback of a prior run."
)]
removed_rollback_only: Option<String>,
#[arg(
long = "from-run",
value_name = "run-id",
hide = true,
value_parser = removed_rollback_flag,
help = "(REMOVED) Prior run id to roll back."
)]
removed_from_run: Option<String>,
#[arg(
long = "no-failure-policy",
value_name = "removed",
num_args = 0..=1,
default_missing_value = "no-failure-policy",
hide = true,
value_parser = removed_rollback_flag,
help = "(REMOVED) Ignore release.on_failure for this run."
)]
removed_no_failure_policy: Option<String>,
},
Build {
#[arg(long = "crate", action = clap::ArgAction::Append, help = "Build a specific crate (repeatable)")]
crate_names: Vec<String>,
#[arg(
long,
default_value = "3h",
help = "Pipeline timeout duration (e.g., 90m, 3h, 5s) — a generous \
safety backstop, not the primary bound; per-stage bounds \
(e.g. announce.deadline) catch a hung stage in seconds"
)]
timeout: String,
#[arg(
long,
short = 'p',
help = "Maximum number of parallel build jobs (default: number of CPUs)"
)]
parallelism: Option<usize>,
#[arg(long, help = "Build only for the host target triple")]
single_target: bool,
#[arg(
long,
conflicts_with = "crate_names",
help = "Build a specific workspace in a monorepo config"
)]
workspace: Option<String>,
#[arg(
long,
short = 'o',
help = "Copy the built binary to this path (requires --single-target and single crate)"
)]
output: Option<PathBuf>,
#[arg(
long,
value_delimiter = ',',
help = "Skip hook lanes or stages (comma-separated: before, after, always, on-error, validate, sign, notarize)"
)]
skip: Vec<String>,
},
Check {
#[command(subcommand)]
cmd: CheckCmd,
},
Init {
#[arg(
long,
help = "Discover repo files that embed the current version and enroll the selection into version_files in .anodizer.yaml"
)]
version_files: bool,
#[arg(
long,
value_delimiter = ',',
requires = "version_files",
help = "Glob(s) to drop from discovered candidates (repeatable or comma-separated); only with --version-files"
)]
exclude: Vec<String>,
#[arg(
long,
short = 'y',
requires = "version_files",
help = "Non-interactive: enroll all discovered candidates without prompting"
)]
yes: bool,
},
Changelog {
#[arg(
value_name = "tag|range",
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"
)]
range: Option<String>,
#[arg(
long,
value_enum,
default_value = "keep-a-changelog",
help = "Output format: keep-a-changelog (refresh the [Unreleased] section), release-notes (grouped-bullet GitHub body to stdout), or json"
)]
format: ChangelogFormat,
#[arg(
long,
help = "Apply the regenerated [Unreleased] section to the configured CHANGELOG.md file(s) in place (keep-a-changelog only)"
)]
write: bool,
#[arg(long = "crate", help = "Restrict to a specific crate in a workspace")]
crate_name: Option<String>,
#[arg(
long,
help = "Preview as a snapshot release (release-notes format only)"
)]
snapshot: bool,
},
Completion {
#[arg(value_enum, help = "Shell to generate completions for")]
shell: Shell,
},
Healthcheck,
Preflight {
#[arg(long, help = "Output the report as JSON")]
json: bool,
#[arg(
long,
help = "Check only the publish-time stages (what `release --publish-only` runs), not artifact-producing stages"
)]
publish_only: bool,
#[arg(
long,
value_delimiter = ',',
help = "Skip requirement collection for these stages (comma-separated, same names as release --skip)"
)]
skip: Vec<String>,
#[arg(long = "publishers", value_delimiter = ',', help = PUBLISHERS_HELP_STEM)]
publishers: Vec<String>,
#[arg(
long,
help = "GitHub token override; when set, GitHub token env-var requirements are treated as satisfied"
)]
token: Option<String>,
},
Man,
Jsonschema,
ResolveTag {
#[arg(help = "Tag to resolve (e.g. 'v1.2.3', 'core-v0.2.3')")]
tag: String,
#[arg(long, help = "Output as JSON")]
json: bool,
},
Targets {
#[arg(long, help = "Output as JSON (include-form matrix)")]
json: bool,
#[arg(long = "crate", action = clap::ArgAction::Append, help = "Restrict to specific crate(s)")]
crate_names: Vec<String>,
},
Vocabulary {
#[arg(long, help = "Output as JSON")]
json: bool,
},
Tools {
#[arg(long, help = "Output as JSON")]
json: bool,
#[arg(
long,
help = "Only the tools the publish-time stages need (what `release --publish-only` runs), not artifact-producing stages"
)]
publish_only: bool,
#[arg(
long,
value_delimiter = ',',
help = "Drop tools contributed by these skipped stages (comma-separated, same names as release --skip)"
)]
skip: Vec<String>,
#[arg(long = "publishers", value_delimiter = ',', help = PUBLISHERS_HELP_STEM)]
publishers: Vec<String>,
},
Tag {
#[arg(long, help = "Show what tag would be created, without creating it")]
dry_run: bool,
#[arg(long, help = "Override bump logic with a specific tag value")]
custom_tag: Option<String>,
#[arg(long = "version", value_name = "VERSION")]
version_override: Option<String>,
#[arg(long, help = "Override default bump type (patch/minor/major)")]
default_bump: Option<String>,
#[arg(long = "crate", help = "Tag a specific crate in a workspace")]
crate_name: Option<String>,
#[arg(
long,
help = "Push the version-sync bump commit to the release branch atomically with the tag"
)]
push: bool,
#[arg(
long,
conflicts_with = "push",
help = "Do not push anything; the tag(s) and version-sync bump commit stay local"
)]
no_push: bool,
#[arg(
long,
conflicts_with_all = ["push", "no_push"],
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)"
)]
push_tags_only: bool,
#[arg(
long,
help = "Create a signed annotated tag (git tag -s), using the signing key/method from git config (user.signingkey, gpg.format)"
)]
sign: bool,
#[arg(
long,
conflicts_with = "sign",
help = "Create an unsigned annotated tag (git tag -a), overriding tag.sign = true in config"
)]
no_sign: bool,
#[arg(
long,
value_name = "NAME",
help = "Remote to push to (default: origin)"
)]
push_remote: Option<String>,
#[arg(
long,
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"
)]
push_dry_run: bool,
#[arg(
long = "changelog",
help = "Refresh CHANGELOG.md as part of this tag (requires a `changelog:` config block)"
)]
changelog: bool,
#[command(subcommand)]
sub: Option<TagSub>,
},
Continue {
#[arg(
long,
help = "Merge artifacts from split build jobs and run post-build stages"
)]
merge: bool,
#[arg(long, help = "Custom dist directory (overrides config)")]
dist: Option<PathBuf>,
#[arg(long, help = "Run full pipeline without side effects")]
dry_run: bool,
#[arg(
long,
value_delimiter = ',',
help = "Skip stages or publishers (comma-separated, e.g. docker,announce,npm). \
Unified denylist: a stage name skips the stage, a publisher name \
(npm, homebrew, chocolatey, …) skips that publisher."
)]
skip: Vec<String>,
#[arg(long = "publishers", value_delimiter = ',', help = PUBLISHERS_HELP_STEM)]
publishers: Vec<String>,
#[arg(
long,
help = TOKEN_HELP.as_str()
)]
token: Option<String>,
},
Publish {
#[arg(long, help = "Run full pipeline without side effects")]
dry_run: bool,
#[arg(
long,
help = TOKEN_HELP.as_str()
)]
token: Option<String>,
#[arg(long, help = "Custom dist directory (overrides config)")]
dist: Option<PathBuf>,
#[arg(
long,
help = "Merge artifacts from `release --split` workers (dist/<subdir>/context.json) before running the publish-only pipeline. Mirrors `goreleaser publish --merge`."
)]
merge: bool,
#[arg(
long = "show-skipped",
help = "Show per-crate 'no <publisher> config block' skip lines at default verbosity \
(normally only visible with --debug). Use to diagnose why a publisher didn't \
run for a given crate."
)]
show_skipped: bool,
#[arg(
long,
value_delimiter = ',',
help = "Skip stages or publishers (comma-separated, e.g. npm,blob). \
Unified denylist: a stage name skips the stage, a publisher name \
(npm, homebrew, chocolatey, …) skips that publisher."
)]
skip: Vec<String>,
#[arg(long = "publishers", value_delimiter = ',', help = PUBLISHERS_HELP_STEM)]
publishers: Vec<String>,
},
Promote {
#[arg(
long,
help = "Destination track: stable | prerelease | candidate | beta | edge, \
or a publisher-native track name (passed through verbatim)."
)]
to: String,
#[arg(
long,
help = "Source track (default: prerelease — each publisher's pre-stable track). \
Canonical or publisher-native."
)]
from: Option<String>,
#[arg(
long = "publishers",
value_delimiter = ',',
help = "Comma-separated promotion-capable publishers to run (default: all \
configured). Naming a configured-but-not-promotable publisher is an error."
)]
publishers: Vec<String>,
#[arg(
long = "version",
value_name = "VERSION",
conflicts_with = "from_run",
help = "Promote this explicit version/tag (default: the newest artifact in the \
--from track)."
)]
version_selector: Option<String>,
#[arg(
long = "from-run",
value_parser = parse_run_id,
help = "Promote what a prior release run recorded (reads dist/run-<id>/report.json)."
)]
from_run: Option<String>,
#[arg(
long,
help = "Resolve and print the plan without running any external command"
)]
dry_run: bool,
},
Bump {
#[arg(help = "patch | minor | major | <version> | release (omit to infer)")]
level_or_version: Option<String>,
#[arg(
long,
short = 'p',
visible_alias = "crate",
action = clap::ArgAction::Append,
help = "Bump a specific crate (repeatable)"
)]
package: Vec<String>,
#[arg(
long,
alias = "all",
conflicts_with = "package",
help = "Bump every workspace member (excluding publish=false)"
)]
workspace: bool,
#[arg(
long,
action = clap::ArgAction::Append,
help = "Exclude a crate from --workspace (repeatable)"
)]
exclude: Vec<String>,
#[arg(long, help = "Append a prerelease identifier (e.g. rc.1)")]
pre: Option<String>,
#[arg(long, help = "Do not rewrite dependents' [dependencies] version specs")]
exact: bool,
#[arg(
long,
help = "Proceed even if the working tree has uncommitted changes"
)]
allow_dirty: bool,
#[arg(long, short = 'y', help = "Skip confirmation prompt")]
yes: bool,
#[arg(long, help = "Print the plan without editing any files")]
dry_run: bool,
#[arg(long, help = "Stage edits and create a single commit")]
commit: bool,
#[arg(
long = "changelog",
requires = "commit",
help = "Refresh CHANGELOG.md in the bump commit (requires --commit and a `changelog:` config block)"
)]
changelog: bool,
#[arg(
long,
requires = "commit",
help = "GPG-sign the commit (requires --commit)"
)]
sign: bool,
#[arg(long, help = "Override the default commit message template")]
commit_message: Option<String>,
#[arg(
long,
default_value = "text",
help = "Output format: text | json (json requires --dry-run)"
)]
output: String,
},
Announce {
#[arg(long, help = "Run full pipeline without side effects")]
dry_run: bool,
#[arg(long, help = "Custom dist directory (overrides config)")]
dist: Option<PathBuf>,
#[arg(
long,
help = TOKEN_HELP.as_str()
)]
token: Option<String>,
#[arg(long, value_delimiter = ',', help = "Skip stages (comma-separated)")]
skip: Vec<String>,
#[arg(
long,
help = "Merge artifact lists from `release --split` workers (dist/<subdir>/context.json) before announcing. Mirrors `goreleaser announce --merge`."
)]
merge: bool,
},
Notify {
message: String,
#[arg(long = "publishers", value_delimiter = ',')]
publishers: Vec<String>,
#[arg(long = "skip", value_delimiter = ',')]
skip: Vec<String>,
#[arg(long)]
raw: bool,
#[arg(long = "allow-secrets")]
allow_secrets: bool,
#[arg(long)]
dry_run: bool,
},
}
fn parse_run_id(s: &str) -> Result<String, String> {
anodizer_stage_publish::rollback::validate_run_id(s)
.map(|()| s.to_string())
.map_err(|err| format!("{:#}", err))
}
pub fn detect_host_target() -> anyhow::Result<String> {
anodizer_core::partial::detect_host_target()
}
pub fn num_cpus() -> usize {
std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(4)
}
pub fn build_cli() -> clap::Command {
<Cli as clap::CommandFactory>::command()
}