anodizer_core/context/skip.rs
1use super::*;
2
3/// The root hook lanes as `--skip` tokens, in the order a run passes
4/// through them.
5///
6/// One token per lane, accepted identically by `anodizer release` and
7/// `anodizer build`, and honored at every site that fires a lane — the root
8/// block, the per-crate `crates[].before:` / `crates[].after:` blocks, and
9/// the per-publisher `publish.on_error:` blocks — so one flag suppresses a
10/// lane everywhere it can fire.
11///
12/// Tokens are kebab-case like the rest of the vocabulary, so the `on_error:`
13/// block's token is `on-error`, the same shape `before_publish:` →
14/// `--skip=before-publish` already uses.
15///
16/// `always:` is skippable deliberately. It is the run's `finally`, but
17/// `--skip` is the operator's per-invocation escape hatch and the asymmetry
18/// it removes is the genuinely incoherent state: `--skip=before` already
19/// suppresses the setup lane, so with no `--skip=always` the teardown lane
20/// would still fire against state that was never staged. The cost is stated
21/// where the lane is documented — skipping `always:` means teardown does not
22/// run, and whatever the run staged stays staged.
23///
24/// `anodizer build` has no `on_error:` lane (a failed local build is not a
25/// failed release; it reaches `always:` with `ANODIZER_SUCCESS=false`), so
26/// `--skip=on-error` has nothing to suppress there. It stays in build's
27/// vocabulary anyway: the token set is published to machine consumers via
28/// `anodizer vocabulary` and a caller's one skip list has to work on
29/// whichever command a job runs.
30pub const ROOT_HOOK_LANE_SKIPS: &[&str] = &["before", "after", "always", "on-error"];
31
32/// Non-publisher `--skip` tokens for the `release` command: the pipeline
33/// stage / phase names that are NOT publishers.
34///
35/// The publisher tokens are NOT listed here — they are derived from
36/// [`PublisherKind`] and unioned in by [`VALID_RELEASE_SKIPS`], so the
37/// `--skip` publisher vocabulary cannot drift from the registry. The root
38/// hook lanes are not listed here either — they come from
39/// [`ROOT_HOOK_LANE_SKIPS`], which `release` and `build` share. Keep ONLY
40/// non-publisher, non-lane stage tokens here.
41///
42/// Two pairs look like publishers but are stages and belong here:
43/// `snapcraft` is the snap *build* stage (its publisher sibling is
44/// `snapcraft-publish`), and `release` is the GitHub/GitLab/Gitea release
45/// *stage* (its publisher sibling is `github-release`).
46pub(super) const NON_PUBLISHER_RELEASE_SKIPS: &[&str] = &[
47 "publish",
48 "sign",
49 "validate",
50 "sbom",
51 "attest",
52 "snapcraft",
53 "nfpm",
54 "makeself",
55 "install-script",
56 "appimage",
57 "flatpak",
58 "srpm",
59 "before-publish",
60 "notarize",
61 "archive",
62 "source",
63 "build",
64 "changelog",
65 "release",
66 "checksum",
67 "upx",
68 "templatefiles",
69 "dmg",
70 "msi",
71 "nsis",
72 "pkg",
73 "appbundle",
74 "verify-release",
75];
76
77/// Valid `--skip` values for the `release` command: every root hook lane
78/// token ([`ROOT_HOOK_LANE_SKIPS`]) PLUS every pipeline stage/phase token
79/// (`NON_PUBLISHER_RELEASE_SKIPS`) PLUS every publisher token (derived
80/// from [`PublisherKind`]).
81///
82/// Skip tokens are stage names plus publisher names. Every publisher's skip
83/// token is its canonical [`crate::Publisher::name`] / [`PublisherKind::token`]
84/// (the same token `--publishers` keys on and the same one GoReleaser's
85/// `--skip` uses), so homebrew is `homebrew` and chocolatey is `chocolatey` —
86/// there are no short aliases (`brew`/`choco`). This keeps one denylist
87/// vocabulary across the `--skip` and `--publishers` selectors and matches
88/// GoReleaser's `--skip` keys, so a single name works on both tools.
89///
90/// Deriving the publisher half from [`PublisherKind::iter`] is what makes the
91/// vocabulary drift-proof: a newly added publisher is automatically a valid
92/// `--skip` token. (This closed a real gap — nine publisher tokens
93/// — `npm`, `gemfury`, `cloudsmith`, `artifactory`, `uploads`, `dockerhub`,
94/// `mcp`, `schemastore`, `upstream-aur` — had silently fallen out of the old
95/// hand-maintained literal.)
96pub static VALID_RELEASE_SKIPS: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
97 ROOT_HOOK_LANE_SKIPS
98 .iter()
99 .copied()
100 .chain(NON_PUBLISHER_RELEASE_SKIPS.iter().copied())
101 .chain(PublisherKind::iter().map(PublisherKind::token))
102 .collect()
103});
104
105/// One entry in anodizer's canonical `--skip` / `--publishers` vocabulary,
106/// emitted by `anodizer vocabulary` for machine consumers (the GitHub Action
107/// derives its skip / publisher token sets from this instead of re-deriving
108/// them in shell).
109///
110/// `is_publisher` marks the publisher tokens (the half of the vocabulary that
111/// `--publishers` also accepts); `is_publish_stage` mirrors
112/// [`PublisherKind::is_publish_stage`] for those, and is always `false` for
113/// the non-publisher pipeline-stage tokens.
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
115pub struct ReleaseToken {
116 /// The canonical lowercase token, exactly as `--skip` / `--publishers`
117 /// key on it (e.g. `homebrew`, never `homebrew-cask`; `uploads`, never
118 /// `upload`).
119 pub token: &'static str,
120 /// `true` for the publisher half of the vocabulary — the tokens
121 /// `--publishers` also accepts. `false` for non-publisher stage tokens.
122 pub is_publisher: bool,
123 /// `true` when this is a publisher that fires its publish from a pipeline
124 /// stage rather than the trait-dispatch chokepoint (see
125 /// [`PublisherKind::is_publish_stage`]). Always `false` for non-publisher
126 /// stage tokens.
127 pub is_publish_stage: bool,
128}
129
130/// The full canonical `--skip` / `--publishers` vocabulary as structured
131/// entries, derived entirely from [`ROOT_HOOK_LANE_SKIPS`],
132/// `NON_PUBLISHER_RELEASE_SKIPS` and [`PublisherKind::iter`] — no
133/// hand-maintained list. Adding a publisher variant, a hook lane, or a
134/// non-publisher stage token updates this automatically.
135///
136/// The set of [`ReleaseToken::token`] values equals [`VALID_RELEASE_SKIPS`]
137/// exactly (enforced by a by-construction test), so anodizer and its
138/// consumers can never disagree on the legal token set.
139pub fn release_skip_vocabulary() -> Vec<ReleaseToken> {
140 ROOT_HOOK_LANE_SKIPS
141 .iter()
142 .chain(NON_PUBLISHER_RELEASE_SKIPS.iter())
143 .map(|&token| ReleaseToken {
144 token,
145 is_publisher: false,
146 is_publish_stage: false,
147 })
148 .chain(PublisherKind::iter().map(|k| ReleaseToken {
149 token: k.token(),
150 is_publisher: true,
151 is_publish_stage: k.is_publish_stage(),
152 }))
153 .collect()
154}
155
156/// Non-lane `--skip` tokens for the `build` command: the gates `build`'s own
157/// code consults.
158///
159/// `build` runs a fixed stage list rather than the release pipeline, so its
160/// vocabulary is deliberately narrow — a token here must name something
161/// `anodizer build` actually reads (`validate` gates config / git validation,
162/// `sign` gates the binary-sign stage, `notarize` gates notarization).
163const NON_LANE_BUILD_SKIPS: &[&str] = &["validate", "sign", "notarize"];
164
165/// Valid `--skip` values for the `build` command: every root hook lane token
166/// ([`ROOT_HOOK_LANE_SKIPS`]) PLUS the build-specific gates
167/// (`NON_LANE_BUILD_SKIPS`).
168///
169/// The lane half is shared verbatim with [`VALID_RELEASE_SKIPS`] so a caller
170/// holding one skip list can hand it to either command.
171pub static VALID_BUILD_SKIPS: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
172 ROOT_HOOK_LANE_SKIPS
173 .iter()
174 .copied()
175 .chain(NON_LANE_BUILD_SKIPS.iter().copied())
176 .collect()
177});
178
179/// Validate that all skip values are in the allowed set.
180///
181/// Returns `Ok(())` if all values are valid, or `Err` with a descriptive
182/// message listing the invalid value(s) and the full set of valid options.
183pub fn validate_skip_values(skip: &[String], valid: &[&str]) -> Result<(), String> {
184 let invalid: Vec<&str> = dedup_preserving_order(
185 skip.iter()
186 .map(|s| s.as_str())
187 .filter(|s| !valid.contains(s)),
188 );
189 if invalid.is_empty() {
190 Ok(())
191 } else {
192 // The combined skip vocabulary is `VALID_RELEASE_SKIPS ++ publisher
193 // names`, which overlap (e.g. `homebrew`, `cargo` appear in both), so a
194 // raw join prints each shared token twice. De-dup the hint — a consumer
195 // (or the action's skip-token generator) reading "Valid options" should
196 // see one clean vocabulary, not a confusing list with repeats.
197 Err(format!(
198 "invalid --skip value(s): {}. Valid options: {}",
199 invalid.join(", "),
200 dedup_preserving_order(valid.iter().copied()).join(", "),
201 ))
202 }
203}
204
205/// Collect an iterator of string slices, dropping later duplicates while keeping
206/// first-seen order — used so the `--skip` error hint lists each valid token
207/// once even though its source set unions overlapping vocabularies.
208fn dedup_preserving_order<'a>(items: impl Iterator<Item = &'a str>) -> Vec<&'a str> {
209 let mut seen = std::collections::HashSet::new();
210 items.filter(|s| seen.insert(*s)).collect()
211}
212
213impl Context {
214 /// Whether `stage_name` (or a publisher name — the skip list is unified) is
215 /// in the operator's `--skip` denylist.
216 pub fn should_skip(&self, stage_name: &str) -> bool {
217 self.options.skip_stages.iter().any(|s| s == stage_name)
218 }
219
220 /// Whether the named publisher is excluded from this run by operator
221 /// selection. Combines the two selectors the publish dispatch consults
222 /// before running any publisher:
223 ///
224 /// - `--skip` (`skip_stages`, the UNIFIED denylist holding stage names
225 /// AND publisher names) ALWAYS wins: a publisher named there is
226 /// deselected regardless of any allowlist.
227 /// - `--publishers` (`publisher_allowlist`): an EMPTY allowlist deselects
228 /// nothing (every publisher runs); a NON-EMPTY allowlist deselects every
229 /// publisher not listed in it.
230 ///
231 /// Returns `true` when the publisher should be reported
232 /// [`crate::publish_report::SkipReason::Deselected`] instead of dispatched.
233 pub fn publisher_deselected(&self, name: &str) -> bool {
234 self.should_skip(name)
235 || (!self.options.publisher_allowlist.is_empty()
236 && !self.options.publisher_allowlist.iter().any(|s| s == name))
237 }
238
239 /// Whether ANY of the named publishers survives the operator-selection
240 /// filter — the positive dual of [`Self::publisher_deselected`] over a
241 /// set. One helper for both registers ("is any consumer selected?" and
242 /// its negation "are all consumers deselected?") so callers never
243 /// hand-roll De Morgan twins that can drift apart.
244 pub fn any_publisher_selected(&self, names: &[&str]) -> bool {
245 names.iter().any(|n| !self.publisher_deselected(n))
246 }
247
248 /// A distinguished, operator-facing summary line for a deselected
249 /// publisher, naming WHICH selector excluded it so the operator can fix
250 /// their command. `--skip` always wins, so it is tested first: a publisher
251 /// named in both selectors reports the denylist cause.
252 ///
253 /// Shared by the dispatch chokepoint and the out-of-dispatch publish
254 /// stages (blob / snapcraft-publish / docker / docker-sign / announce) so the
255 /// "skipped X — excluded via --skip" / "… — not in --publishers allowlist"
256 /// wording is identical everywhere a publisher is deselected. Call only
257 /// when [`Self::publisher_deselected`] is `true`.
258 pub fn deselected_reason(&self, name: &str) -> String {
259 let reason = if self.should_skip(name) {
260 "excluded via --skip"
261 } else {
262 "not in --publishers allowlist"
263 };
264 format!("skipped {name} — {reason}")
265 }
266
267 /// Check whether "validate" is in the skip list.
268 pub fn skip_validate(&self) -> bool {
269 self.should_skip("validate")
270 }
271}