Skip to main content

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