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