Skip to main content

anodizer_core/context/
skip.rs

1use super::*;
2
3/// Rollback policy after the publish stage. `BestEffort` is the default when
4/// pre-flight ran clean; `None` is the implicit default otherwise (callers
5/// should warn that rollback is disabled). The CLI flag `--rollback=<v>`
6/// sets `ContextOptions::rollback_mode` to `Some(v)` to override the
7/// default-resolution at the dispatch site.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
9#[serde(rename_all = "kebab-case")]
10pub enum RollbackMode {
11    /// Do not attempt rollback. Useful when the operator wants to inspect
12    /// half-published state before deciding.
13    None,
14    /// Run best-effort rollback for every reversible publisher whose
15    /// evidence is present in the report. Most irreversible publishers
16    /// (chocolatey moderation, winget PRs, AUR) are never rolled back —
17    /// the Submitter gate is their only protection. The exception is
18    /// cargo: a partial multi-crate publish that left live crates records
19    /// them and gets those crates yanked even on a failed run.
20    #[default]
21    BestEffort,
22}
23
24/// Non-publisher `--skip` tokens for the `release` command: the pipeline
25/// stage / phase names that are NOT publishers.
26///
27/// The publisher tokens are NOT listed here — they are derived from
28/// [`PublisherKind`] and unioned in by [`VALID_RELEASE_SKIPS`], so the
29/// `--skip` publisher vocabulary cannot drift from the registry. Keep ONLY
30/// non-publisher stage tokens here.
31///
32/// Two pairs look like publishers but are stages and belong here:
33/// `snapcraft` is the snap *build* stage (its publisher sibling is
34/// `snapcraft-publish`), and `release` is the GitHub/GitLab/Gitea release
35/// *stage* (its publisher sibling is `github-release`).
36pub(super) const NON_PUBLISHER_RELEASE_SKIPS: &[&str] = &[
37    "publish",
38    "sign",
39    "validate",
40    "sbom",
41    "attest",
42    "snapcraft",
43    "nfpm",
44    "makeself",
45    "install-script",
46    "appimage",
47    "flatpak",
48    "srpm",
49    "before",
50    "before-publish",
51    "notarize",
52    "archive",
53    "source",
54    "build",
55    "changelog",
56    "release",
57    "checksum",
58    "upx",
59    "templatefiles",
60    "dmg",
61    "msi",
62    "nsis",
63    "pkg",
64    "appbundle",
65    "verify-release",
66];
67
68/// Valid `--skip` values for the `release` command: every pipeline
69/// stage/phase token ([`NON_PUBLISHER_RELEASE_SKIPS`]) PLUS every publisher
70/// token (derived from [`PublisherKind`]).
71///
72/// Skip tokens are stage names plus publisher names. Every publisher's skip
73/// token is its canonical [`crate::Publisher::name`] / [`PublisherKind::token`]
74/// (the same token `--publishers` keys on and the same one GoReleaser's
75/// `--skip` uses), so homebrew is `homebrew` and chocolatey is `chocolatey` —
76/// there are no short aliases (`brew`/`choco`). This keeps one denylist
77/// vocabulary across the `--skip` and `--publishers` selectors and matches
78/// GoReleaser's `--skip` keys, so a single name works on both tools.
79///
80/// Deriving the publisher half from [`PublisherKind::iter`] is what makes the
81/// vocabulary drift-proof: a newly added publisher is automatically a valid
82/// `--skip` token. (This closed a real gap — nine publisher tokens
83/// — `npm`, `gemfury`, `cloudsmith`, `artifactory`, `uploads`, `dockerhub`,
84/// `mcp`, `schemastore`, `upstream-aur` — had silently fallen out of the old
85/// hand-maintained literal.)
86pub static VALID_RELEASE_SKIPS: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
87    NON_PUBLISHER_RELEASE_SKIPS
88        .iter()
89        .copied()
90        .chain(PublisherKind::iter().map(PublisherKind::token))
91        .collect()
92});
93
94/// One entry in anodizer's canonical `--skip` / `--publishers` vocabulary,
95/// emitted by `anodizer vocabulary` for machine consumers (the GitHub Action
96/// derives its skip / publisher token sets from this instead of re-deriving
97/// them in shell).
98///
99/// `is_publisher` marks the publisher tokens (the half of the vocabulary that
100/// `--publishers` also accepts); `is_publish_stage` mirrors
101/// [`PublisherKind::is_publish_stage`] for those, and is always `false` for
102/// the non-publisher pipeline-stage tokens.
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
104pub struct ReleaseToken {
105    /// The canonical lowercase token, exactly as `--skip` / `--publishers`
106    /// key on it (e.g. `homebrew`, never `homebrew-cask`; `uploads`, never
107    /// `upload`).
108    pub token: &'static str,
109    /// `true` for the publisher half of the vocabulary — the tokens
110    /// `--publishers` also accepts. `false` for non-publisher stage tokens.
111    pub is_publisher: bool,
112    /// `true` when this is a publisher that fires its publish from a pipeline
113    /// stage rather than the trait-dispatch chokepoint (see
114    /// [`PublisherKind::is_publish_stage`]). Always `false` for non-publisher
115    /// stage tokens.
116    pub is_publish_stage: bool,
117}
118
119/// The full canonical `--skip` / `--publishers` vocabulary as structured
120/// entries, derived entirely from [`NON_PUBLISHER_RELEASE_SKIPS`] and
121/// [`PublisherKind::iter`] — no hand-maintained list. Adding a publisher
122/// variant or a non-publisher stage token updates this automatically.
123///
124/// The set of [`ReleaseToken::token`] values equals [`VALID_RELEASE_SKIPS`]
125/// exactly (enforced by a by-construction test), so anodizer and its
126/// consumers can never disagree on the legal token set.
127pub fn release_skip_vocabulary() -> Vec<ReleaseToken> {
128    NON_PUBLISHER_RELEASE_SKIPS
129        .iter()
130        .map(|&token| ReleaseToken {
131            token,
132            is_publisher: false,
133            is_publish_stage: false,
134        })
135        .chain(PublisherKind::iter().map(|k| ReleaseToken {
136            token: k.token(),
137            is_publisher: true,
138            is_publish_stage: k.is_publish_stage(),
139        }))
140        .collect()
141}
142
143/// Valid --skip values for the `build` command.
144pub const VALID_BUILD_SKIPS: &[&str] = &["pre-hooks", "post-hooks", "validate", "before"];
145
146/// Validate that all skip values are in the allowed set.
147///
148/// Returns `Ok(())` if all values are valid, or `Err` with a descriptive
149/// message listing the invalid value(s) and the full set of valid options.
150pub fn validate_skip_values(skip: &[String], valid: &[&str]) -> Result<(), String> {
151    let invalid: Vec<&str> = dedup_preserving_order(
152        skip.iter()
153            .map(|s| s.as_str())
154            .filter(|s| !valid.contains(s)),
155    );
156    if invalid.is_empty() {
157        Ok(())
158    } else {
159        // The combined skip vocabulary is `VALID_RELEASE_SKIPS ++ publisher
160        // names`, which overlap (e.g. `homebrew`, `cargo` appear in both), so a
161        // raw join prints each shared token twice. De-dup the hint — a consumer
162        // (or the action's skip-token generator) reading "Valid options" should
163        // see one clean vocabulary, not a confusing list with repeats.
164        Err(format!(
165            "invalid --skip value(s): {}. Valid options: {}",
166            invalid.join(", "),
167            dedup_preserving_order(valid.iter().copied()).join(", "),
168        ))
169    }
170}
171
172/// Collect an iterator of string slices, dropping later duplicates while keeping
173/// first-seen order — used so the `--skip` error hint lists each valid token
174/// once even though its source set unions overlapping vocabularies.
175fn dedup_preserving_order<'a>(items: impl Iterator<Item = &'a str>) -> Vec<&'a str> {
176    let mut seen = std::collections::HashSet::new();
177    items.filter(|s| seen.insert(*s)).collect()
178}
179
180impl Context {
181    /// Whether `stage_name` (or a publisher name — the skip list is unified) is
182    /// in the operator's `--skip` denylist.
183    pub fn should_skip(&self, stage_name: &str) -> bool {
184        self.options.skip_stages.iter().any(|s| s == stage_name)
185    }
186
187    /// Whether the named publisher is excluded from this run by operator
188    /// selection. Combines the two selectors the publish dispatch consults
189    /// before running any publisher:
190    ///
191    /// - `--skip` (`skip_stages`, the UNIFIED denylist holding stage names
192    ///   AND publisher names) ALWAYS wins: a publisher named there is
193    ///   deselected regardless of any allowlist.
194    /// - `--publishers` (`publisher_allowlist`): an EMPTY allowlist deselects
195    ///   nothing (every publisher runs); a NON-EMPTY allowlist deselects every
196    ///   publisher not listed in it.
197    ///
198    /// Returns `true` when the publisher should be reported
199    /// [`crate::publish_report::SkipReason::Deselected`] instead of dispatched.
200    pub fn publisher_deselected(&self, name: &str) -> bool {
201        self.should_skip(name)
202            || (!self.options.publisher_allowlist.is_empty()
203                && !self.options.publisher_allowlist.iter().any(|s| s == name))
204    }
205
206    /// Whether ANY of the named publishers survives the operator-selection
207    /// filter — the positive dual of [`Self::publisher_deselected`] over a
208    /// set. One helper for both registers ("is any consumer selected?" and
209    /// its negation "are all consumers deselected?") so callers never
210    /// hand-roll De Morgan twins that can drift apart.
211    pub fn any_publisher_selected(&self, names: &[&str]) -> bool {
212        names.iter().any(|n| !self.publisher_deselected(n))
213    }
214
215    /// A distinguished, operator-facing summary line for a deselected
216    /// publisher, naming WHICH selector excluded it so the operator can fix
217    /// their command. `--skip` always wins, so it is tested first: a publisher
218    /// named in both selectors reports the denylist cause.
219    ///
220    /// Shared by the dispatch chokepoint and the out-of-dispatch publish
221    /// stages (blob / snapcraft-publish / docker / docker-sign / announce) so the
222    /// "skipped X — excluded via --skip" / "… — not in --publishers allowlist"
223    /// wording is identical everywhere a publisher is deselected. Call only
224    /// when [`Self::publisher_deselected`] is `true`.
225    pub fn deselected_reason(&self, name: &str) -> String {
226        let reason = if self.should_skip(name) {
227            "excluded via --skip"
228        } else {
229            "not in --publishers allowlist"
230        };
231        format!("skipped {name} — {reason}")
232    }
233
234    /// Check whether "validate" is in the skip list.
235    pub fn skip_validate(&self) -> bool {
236        self.should_skip("validate")
237    }
238}