Skip to main content

anodizer_cli/
subcommands.rs

1//! Nested subcommand trees and their arg structs.
2//!
3//! The root `Cli` and the top-level `Commands` enum live in the crate root;
4//! these are the trees that hang off individual commands.
5
6use clap::Subcommand;
7use std::path::PathBuf;
8
9/// Output format for `anodizer changelog`.
10#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)]
11pub enum ChangelogFormat {
12    /// Regenerate the `## [Unreleased]` section(s) of the configured
13    /// `CHANGELOG.md` file(s) (the default). Previews to stdout; writes in
14    /// place with `--write`.
15    #[default]
16    #[value(name = "keep-a-changelog", alias = "kac")]
17    KeepAChangelog,
18    /// GitHub-release-body markdown (grouped bullets) for the resolved range,
19    /// to stdout. The historical `anodizer changelog` behavior.
20    ReleaseNotes,
21    /// Machine-readable JSON array of `{ crate, from, to, groups }` objects,
22    /// one per selected crate, sorted by crate name.
23    Json,
24}
25
26/// `anodizer tag` parent subcommand.
27///
28/// Bare `anodizer tag` keeps its existing autotag behavior (handled
29/// by the `Tag` variant directly). `anodizer tag rollback` opts into
30/// the failure-recovery flow described in
31/// `commands::tag::rollback` (binary-only module, so not linkable from here).
32#[derive(Subcommand)]
33pub enum TagSub {
34    /// Withdraw a release: unwind the publishers the run recorded, delete
35    /// the anodizer-managed tags at a SHA, then revert (or reset past) the
36    /// bump commit they point at.
37    ///
38    /// The publisher unwind reads the run state the release left under
39    /// `dist/run-<tag>/`, so the tags being rolled back name the run — there
40    /// is no run id to pass. A tag with no recorded state is a tag-only
41    /// rollback.
42    Rollback {
43        #[arg(
44            value_name = "sha",
45            help = "Commit SHA to roll back from. Defaults to HEAD."
46        )]
47        sha: Option<String>,
48        #[arg(long, help = "Print what would happen without mutating anything")]
49        dry_run: bool,
50        #[arg(
51            long = "no-push",
52            help = "Skip remote tag delete and branch push (local-only)"
53        )]
54        no_push: bool,
55        #[arg(
56            long,
57            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"
58        )]
59        force: bool,
60        #[arg(
61            long,
62            default_value = "all",
63            help = "Tag-shape filter: all | lockstep | per-crate"
64        )]
65        scope: String,
66        #[arg(
67            long,
68            default_value = "revert",
69            help = "Rollback strategy: revert (default; history-preserving) | reset (opt-in; rewrites history, requires --force-with-lease to push)"
70        )]
71        mode: String,
72        #[arg(
73            long,
74            value_name = "name",
75            help = "Branch name to push the revert commit to. Usually unnecessary: the branch is auto-resolved from the bump commit via `git branch -r --contains <sha>`, which covers the ordinary CI tag-push case (detached HEAD, GITHUB_REF_NAME set to the tag). Needed only when that resolution is ambiguous or empty — the bump commit is on two or more remote branches, or on none and HEAD cannot be resolved either. Both cases fail with an error naming this flag. Pass --branch master (or whichever branch the bump commit was created on)."
76        )]
77        branch: Option<String>,
78    },
79}
80
81/// The checks `anodizer check` can run: the config validator, the determinism
82/// harness, and the `version_files` drift guard.
83#[derive(Subcommand)]
84pub enum CheckCmd {
85    /// Validate the workspace's anodizer config.
86    Config {
87        #[arg(long, help = "Validate a specific workspace in a monorepo config")]
88        workspace: Option<String>,
89        #[arg(
90            long,
91            value_delimiter = ',',
92            help = "Validate these skip tokens (stages or publishers) against the known set \
93                    without running anything (comma-separated). Unified denylist: a stage name \
94                    skips the stage, a publisher name (npm, homebrew, chocolatey, …) skips \
95                    that publisher."
96        )]
97        skip: Vec<String>,
98        #[arg(
99            long = "publishers",
100            value_delimiter = ',',
101            help = concat!(
102                "Validate-only: check that each name is a publisher the active config \
103                 actually enables (a known but unconfigured publisher is rejected). ",
104                "Comma-separated publishers to run (default: all configured). \
105                 --skip always wins over --publishers.",
106            )
107        )]
108        publishers: Vec<String>,
109    },
110    /// Run the determinism harness (build pipeline twice, diff artifacts).
111    Determinism(CheckDeterminismArgs),
112    /// Check that enrolled `version_files` still match each crate's current version.
113    VersionFiles,
114}
115
116/// Arguments of `anodizer check determinism`: the run count, the stage and
117/// target filters, preserved-dist reuse, and where the report is written.
118#[derive(clap::Args)]
119pub struct CheckDeterminismArgs {
120    #[arg(
121        long,
122        default_value = "2",
123        help = "Number of from-clean rebuilds to diff"
124    )]
125    pub runs: u32,
126    #[arg(
127        long,
128        value_name = "stages",
129        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."
130    )]
131    pub stages: Option<String>,
132    #[arg(
133        long,
134        value_name = "csv",
135        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 `anodizer release --snapshot` subprocess."
136    )]
137    pub targets: Option<String>,
138    #[arg(
139        long,
140        value_name = "path",
141        help = "JSON report path; default dist/run-<id>/determinism.json"
142    )]
143    pub report: Option<PathBuf>,
144    #[arg(
145        long,
146        conflicts_with = "no_snapshot",
147        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."
148    )]
149    pub snapshot: bool,
150    #[arg(
151        long = "no-snapshot",
152        conflicts_with = "snapshot",
153        help = "Force snapshot mode OFF on the child release subprocess (artifacts emit the actual release version). Default: auto — see --snapshot."
154    )]
155    pub no_snapshot: bool,
156    #[arg(
157        long = "inject-drift",
158        value_name = "stage",
159        hide = true,
160        help = "(TEST HARNESS) Append 1 random byte to the first artifact emitted by <stage>. Gated by ANODIZE_TEST_HARNESS=1."
161    )]
162    pub inject_drift: Option<String>,
163    #[arg(
164        long = "preserve-dist",
165        value_name = "path",
166        help = "On a passing run, 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."
167    )]
168    pub preserve_dist: Option<PathBuf>,
169    #[arg(
170        long = "crate",
171        value_name = "name",
172        help = "When --preserve-dist is set, write the preserved dist tree to \
173                <dest>/<name>/ instead of directly into <dest>/. Used by the \
174                sharded matrix to produce per-crate subdirectories so a \
175                `release --publish-only` job can merge all crates into a single \
176                dist/ without context.json collision."
177    )]
178    pub crate_name: Option<String>,
179    /// Fail (not warn-skip) if any selected stage's backing tool is missing —
180    /// used by CI so a default host-OS run cannot silently skip an OS-native
181    /// producer.
182    ///
183    /// Without `--stages`, the harness builds the full host-OS partition
184    /// (`commands::check::determinism`'s `default_stages_for_host`),
185    /// and a host-default stage whose tool is absent normally warn-skips so dev
186    /// boxes stay usable. CI provisions every OS-native tool and must treat a
187    /// missing one as a hard failure: a silent skip is the exact false coverage
188    /// that once hid the installer formats from every release. This flag
189    /// promotes the WHOLE resolved stage set to the hard-fail contract that
190    /// explicitly typed stages already get.
191    #[arg(
192        long = "require-tools",
193        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."
194    )]
195    pub require_tools: bool,
196}