anodizer_core/publisher.rs
1//! Publisher trait + preflight result type.
2//!
3//! Defines the polymorphic interface that every publisher (cargo, homebrew,
4//! scoop, chocolatey, nix, AUR, krew, winget, snapcraft, blob, release, ...)
5//! implements. Lives in `anodizer-core` rather than `stage-publish` so that
6//! `stage-blob`, `stage-release`, and `stage-snapcraft` can implement
7//! `Publisher` without taking a circular dependency on `stage-publish`.
8
9use crate::context::Context;
10use crate::{PublishEvidence, PublisherGroup};
11
12/// Outcome of a publisher's pre-flight self-check.
13///
14/// Each variant signals a different release-pipeline reaction:
15///
16/// * `Pass` — no concern detected; publishing may proceed.
17/// * `Warning(msg)` — surface the message to the operator (and review log)
18/// but do not block the publish. Use for soft signals like "remote
19/// already has a tag at this version but contents match".
20/// * `Blocker(msg)` — abort before the publish stage runs. Use for hard
21/// prerequisites the publisher knows it cannot satisfy at runtime, e.g.
22/// "homebrew tap repo not reachable", "winget-pkgs fork not configured".
23///
24/// Named `Pass` (not `Clean`) to avoid nominal collision with
25/// [`crate::preflight::PublisherState::Clean`], which describes the
26/// already-published state of a publisher rather than a self-check result.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum PreflightCheck {
29 /// Publisher's pre-flight checks completed with no concerns.
30 Pass,
31 /// Publisher detected a non-blocking concern; surface it but continue.
32 Warning(String),
33 /// Publisher detected a blocking concern; abort before the publish stage.
34 Blocker(String),
35}
36
37impl PreflightCheck {
38 /// Fold two pre-flight outcomes into the most severe, escalating
39 /// `Blocker` > `Warning` > `Pass`. Within a severity the first-seen
40 /// message (`self`'s) wins, so a left-fold over many targets yields a
41 /// stable, deterministic line rather than whichever target iterated last.
42 pub fn merge(self, next: Self) -> Self {
43 use PreflightCheck::{Blocker, Pass, Warning};
44 match (self, next) {
45 (Blocker(m), _) => Blocker(m),
46 (_, Blocker(m)) => Blocker(m),
47 (Warning(m), _) => Warning(m),
48 (_, Warning(m)) => Warning(m),
49 (Pass, Pass) => Pass,
50 }
51 }
52}
53
54/// Publisher contract — one implementer per upstream registry / channel.
55///
56/// Required methods describe the publisher's identity, behavior, and how
57/// it participates in [`PublisherGroup`]-based scheduling:
58///
59/// * [`Publisher::name`] — stable identifier used in logs, evidence, and
60/// review findings (e.g. `"cargo"`, `"homebrew"`, `"winget"`).
61/// * [`Publisher::run`] — perform the actual publish and emit a
62/// [`PublishEvidence`] record describing what was sent upstream.
63/// * [`Publisher::group`] — which [`PublisherGroup`] this publisher belongs
64/// to; used by the publish stage to order and parallelize work.
65/// * [`Publisher::required`] — whether a failure in this publisher should
66/// fail the overall release.
67///
68/// Default-implemented hooks describe optional behavior:
69///
70/// * [`Publisher::rollback`] — best-effort undo of a successful publish.
71/// The default is a no-op so publishers that target irreversible
72/// registries (most of them) do not need to override.
73/// * [`Publisher::preflight`] — fast self-check executed before any
74/// publisher in the pipeline runs. Defaults to [`PreflightCheck::Pass`].
75/// * [`Publisher::rollback_scope_needed`] — declare an opt-in OAuth /
76/// token scope that rollback would require (e.g. `"delete_repo"` for
77/// GitHub-fork-based publishers). Defaults to `None`. Surfaced by
78/// the CLI when explaining why a rollback path is unavailable.
79///
80/// Implementations must be `Send + Sync` so the publish stage can fan out
81/// across publisher groups in parallel. Wrap non-`Send` clients (Rc-based,
82/// thread-local channels) behind an `Arc<Mutex<_>>` or move them inside
83/// `run()`'s scope rather than holding them on `self`.
84pub trait Publisher: Send + Sync {
85 /// Stable, lowercase identifier for this publisher (e.g. `"cargo"`).
86 fn name(&self) -> &str;
87
88 /// Execute the publish and emit evidence describing what was sent.
89 fn run(&self, ctx: &mut Context) -> anyhow::Result<PublishEvidence>;
90
91 /// Scheduling group — controls ordering and parallelism in the publish stage.
92 fn group(&self) -> PublisherGroup;
93
94 /// Whether a failure here should fail the overall release.
95 fn required(&self) -> bool;
96
97 /// Best-effort rollback of a successful publish, given its evidence.
98 ///
99 /// Default is a no-op: most upstream registries are append-only or
100 /// require human moderation to revoke, so the publisher opts in by
101 /// overriding only when it actually has a rollback path.
102 fn rollback(&self, _ctx: &mut Context, _evidence: &PublishEvidence) -> anyhow::Result<()> {
103 Ok(())
104 }
105
106 /// Fast self-check executed before any publisher runs.
107 ///
108 /// Default returns [`PreflightCheck::Pass`]. Override to surface
109 /// publisher-specific blockers (missing tap, missing fork, network
110 /// unreachable) or warnings (duplicate-but-matching upload).
111 fn preflight(&self, _ctx: &Context) -> anyhow::Result<PreflightCheck> {
112 Ok(PreflightCheck::Pass)
113 }
114
115 /// Opt-in OAuth / token scope rollback would require, if any.
116 ///
117 /// Default is `None`. Used by the CLI to explain why a `--rollback`
118 /// invocation cannot recover a given publisher without elevating the
119 /// release token's permissions.
120 fn rollback_scope_needed(&self) -> Option<&'static str> {
121 None
122 }
123
124 /// Environment requirements this publisher derives from the resolved
125 /// config: CLI tools it spawns, env vars/secrets it reads, endpoints
126 /// it talks to, key material it loads.
127 ///
128 /// Consumed by the config-aware preflight (`anodizer preflight` and the
129 /// in-process phase at the head of `anodizer release`). Declared next
130 /// to each publisher's implementation — derived from the same config
131 /// fields `run()` reads — so the preflight cannot drift from the
132 /// publish path. Default is empty for publishers with no external
133 /// prerequisites beyond what their stage already declares.
134 fn requirements(&self, _ctx: &Context) -> Vec<crate::env_preflight::EnvRequirement> {
135 Vec::new()
136 }
137
138 /// Environment requirements whose absence DEGRADES this publisher's run
139 /// rather than failing it: optional validators (`ruby -c`, `bash -n`,
140 /// `nix-instantiate --parse`) that warn+skip when missing, or a preferred
141 /// transport with a full fallback (`gh` vs the GitHub REST API).
142 ///
143 /// Collected alongside [`Publisher::requirements`] but surfaced as
144 /// ADVISORY: preflight warns instead of blocking, and `anodizer tools`
145 /// reports them as recommended so an auto-provisioned runner installs
146 /// them and gets the stronger validation/transport. Hard needs (the run
147 /// path errors without the tool) belong in `requirements()` instead.
148 /// Default is empty.
149 fn advisory_requirements(&self, _ctx: &Context) -> Vec<crate::env_preflight::EnvRequirement> {
150 Vec::new()
151 }
152
153 /// Whether this publisher opts out of nightly runs (the
154 /// `customization/publish/nightlies.md` skip-list).
155 ///
156 /// Each `Publisher` must declare its nightly behavior explicitly — there
157 /// is no default — so adding a new publisher forces a deliberate decision.
158 /// Return `true` for publishers that push to long-lived registries where a
159 /// nightly clobber is either disruptive (homebrew taps, scoop buckets,
160 /// AUR, krew-index, nix overlays) or outright forbidden by registry policy.
161 fn skips_on_nightly(&self) -> bool;
162
163 /// Whether a *failed* run of this publisher still has a real,
164 /// programmatic rollback to perform against `evidence`.
165 ///
166 /// Default `false`: a publisher's failure leaves nothing to undo (or
167 /// only an informational, human-driven unwind). The orchestration
168 /// rolls back **succeeded** Assets/Manager publishers; a failed
169 /// Submitter is normally inert.
170 ///
171 /// The cargo publisher is the exception. A multi-crate `cargo publish`
172 /// can succeed on crate A, go live on crates.io, then fail on crate B
173 /// — leaving A published under a *failed* Submitter row. cargo records
174 /// the succeeded crates in `evidence` and overrides this to `true`
175 /// when that set is non-empty, so the rollback path yanks A even
176 /// though the publisher's overall outcome is `Failed`. Returning
177 /// `false` for an empty record keeps a clean failure (nothing went
178 /// live) from arming the rollback machinery for no reason.
179 fn programmatic_rollback_on_failure(&self, _evidence: &PublishEvidence) -> bool {
180 false
181 }
182
183 /// When `true`, this publisher's successful work is left in place even
184 /// when a rollback is triggered — it is never passed to `rollback()`.
185 /// Default `false` (rollback runs if the publisher implements it).
186 fn retain_on_rollback(&self) -> bool {
187 false
188 }
189}
190
191/// The exact warn message a publisher emits when `rollback()` is invoked
192/// with no evidence to act on (empty `artifact_paths`, no `primary_ref`).
193/// Each publisher's empty-evidence branch calls this helper; tests can
194/// assert on the returned string without having to intercept stderr
195/// (`eprintln!` cannot be portably captured from the same process).
196///
197/// Lives in `anodizer_core` because the rollback shape is shared across
198/// publishers spread between `stage-publish` and `stage-blob` (and any
199/// future stage crate that implements `Publisher`).
200pub fn rollback_empty_warning_msg(publisher: &str, target_label: &str) -> String {
201 format!(
202 "no {} recorded in {} evidence — verify {} state manually",
203 target_label, publisher, publisher
204 )
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 struct MinimalPublisher;
212 impl Publisher for MinimalPublisher {
213 fn name(&self) -> &str {
214 "minimal"
215 }
216 fn run(&self, _ctx: &mut Context) -> anyhow::Result<PublishEvidence> {
217 Ok(PublishEvidence::new("minimal"))
218 }
219 fn group(&self) -> PublisherGroup {
220 PublisherGroup::Manager
221 }
222 fn required(&self) -> bool {
223 false
224 }
225 fn skips_on_nightly(&self) -> bool {
226 false
227 }
228 }
229
230 #[test]
231 fn rollback_default_is_noop_ok() {
232 let p = MinimalPublisher;
233 let mut ctx = Context::test_fixture();
234 let evidence = PublishEvidence::new("minimal");
235 assert!(p.rollback(&mut ctx, &evidence).is_ok());
236 }
237
238 #[test]
239 fn preflight_default_is_pass() {
240 let p = MinimalPublisher;
241 let ctx = Context::test_fixture();
242 assert!(matches!(p.preflight(&ctx).unwrap(), PreflightCheck::Pass));
243 }
244
245 #[test]
246 fn rollback_scope_needed_default_is_none() {
247 let p = MinimalPublisher;
248 assert!(p.rollback_scope_needed().is_none());
249 }
250
251 #[test]
252 fn pending_outcome_round_trips_through_context() {
253 // The slot is single-shot: write once, drain once, then empty.
254 // Without single-shot semantics, a chocolatey moderation skip
255 // would bleed into the next publisher's row at dispatch time.
256 let mut ctx = Context::test_fixture();
257 assert!(ctx.take_pending_outcome().is_none());
258
259 ctx.record_publisher_outcome(crate::PublisherOutcome::PendingModeration);
260 assert!(matches!(
261 ctx.take_pending_outcome(),
262 Some(crate::PublisherOutcome::PendingModeration)
263 ));
264 assert!(
265 ctx.take_pending_outcome().is_none(),
266 "slot must be empty after take"
267 );
268
269 // Overwrite semantics: last writer wins (no implicit accumulation).
270 ctx.record_publisher_outcome(crate::PublisherOutcome::PendingModeration);
271 ctx.record_publisher_outcome(crate::PublisherOutcome::PendingValidation);
272 assert!(matches!(
273 ctx.take_pending_outcome(),
274 Some(crate::PublisherOutcome::PendingValidation)
275 ));
276 }
277
278 #[test]
279 fn rollback_empty_warning_msg_interpolates_all_three_slots() {
280 let msg = rollback_empty_warning_msg("homebrew", "tap commit");
281 assert_eq!(
282 msg,
283 "no tap commit recorded in homebrew evidence — verify homebrew state manually"
284 );
285 }
286
287 #[test]
288 fn rollback_empty_warning_msg_distinct_per_publisher() {
289 let a = rollback_empty_warning_msg("cargo", "crate");
290 let b = rollback_empty_warning_msg("aur", "commit");
291 assert_ne!(a, b);
292 assert!(a.contains("cargo") && a.contains("crate"));
293 assert!(b.contains("aur") && b.contains("commit"));
294 }
295
296 #[test]
297 fn programmatic_rollback_on_failure_defaults_false() {
298 let p = MinimalPublisher;
299 let evidence = PublishEvidence::new("minimal");
300 assert!(!p.programmatic_rollback_on_failure(&evidence));
301 }
302
303 #[test]
304 fn retain_on_rollback_defaults_false() {
305 assert!(!MinimalPublisher.retain_on_rollback());
306 }
307
308 #[test]
309 fn requirements_default_is_empty() {
310 let p = MinimalPublisher;
311 let ctx = Context::test_fixture();
312 assert!(p.requirements(&ctx).is_empty());
313 }
314
315 #[test]
316 fn preflight_check_variants_compare_by_value() {
317 assert_eq!(PreflightCheck::Pass, PreflightCheck::Pass);
318 assert_eq!(
319 PreflightCheck::Warning("dup".into()),
320 PreflightCheck::Warning("dup".into())
321 );
322 // same variant, different payload, must not be equal
323 assert_ne!(
324 PreflightCheck::Blocker("a".into()),
325 PreflightCheck::Blocker("b".into())
326 );
327 // different variants with same string must not be equal
328 assert_ne!(
329 PreflightCheck::Warning("x".into()),
330 PreflightCheck::Blocker("x".into())
331 );
332 }
333
334 #[test]
335 fn minimal_publisher_carries_its_declared_identity() {
336 let p = MinimalPublisher;
337 assert_eq!(p.name(), "minimal");
338 assert_eq!(p.group(), PublisherGroup::Manager);
339 assert!(!p.required());
340 assert!(!p.skips_on_nightly());
341 }
342
343 /// A publisher that overrides every default-implemented hook, so the
344 /// trait dispatch is proven to reach the override (not silently shadowed
345 /// by the default body).
346 struct OverridingPublisher;
347 impl Publisher for OverridingPublisher {
348 fn name(&self) -> &str {
349 "overriding"
350 }
351 fn run(&self, _ctx: &mut Context) -> anyhow::Result<PublishEvidence> {
352 Ok(PublishEvidence::new("overriding"))
353 }
354 fn group(&self) -> PublisherGroup {
355 PublisherGroup::Assets
356 }
357 fn required(&self) -> bool {
358 true
359 }
360 fn skips_on_nightly(&self) -> bool {
361 true
362 }
363 fn preflight(&self, _ctx: &Context) -> anyhow::Result<PreflightCheck> {
364 Ok(PreflightCheck::Blocker("fork missing".into()))
365 }
366 fn rollback_scope_needed(&self) -> Option<&'static str> {
367 Some("delete_repo")
368 }
369 fn programmatic_rollback_on_failure(&self, _evidence: &PublishEvidence) -> bool {
370 true
371 }
372 fn retain_on_rollback(&self) -> bool {
373 true
374 }
375 }
376
377 #[test]
378 fn override_publisher_preflight_returns_blocker() {
379 let p = OverridingPublisher;
380 let ctx = Context::test_fixture();
381 assert_eq!(
382 p.preflight(&ctx).unwrap(),
383 PreflightCheck::Blocker("fork missing".into())
384 );
385 }
386
387 #[test]
388 fn override_publisher_exposes_rollback_scope_and_flags() {
389 let p = OverridingPublisher;
390 let evidence = PublishEvidence::new("overriding");
391 assert_eq!(p.rollback_scope_needed(), Some("delete_repo"));
392 assert!(p.programmatic_rollback_on_failure(&evidence));
393 assert!(p.retain_on_rollback());
394 assert!(p.required());
395 assert!(p.skips_on_nightly());
396 assert_eq!(p.group(), PublisherGroup::Assets);
397 }
398
399 #[test]
400 fn preflight_check_clone_preserves_payload() {
401 let warn = PreflightCheck::Warning("dup upload".into());
402 assert_eq!(warn.clone(), warn);
403 let blocker = PreflightCheck::Blocker("no tap".into());
404 let cloned = blocker.clone();
405 assert_eq!(cloned, PreflightCheck::Blocker("no tap".into()));
406 // Clone must not collapse a Warning into the same value as a Blocker.
407 assert_ne!(warn, blocker);
408 }
409
410 #[test]
411 fn merge_escalates_to_worst_severity_keeping_first_message() {
412 use PreflightCheck::{Blocker, Pass, Warning};
413
414 // Blocker dominates regardless of position, keeping its own message.
415 assert_eq!(
416 Blocker("b".into()).merge(Warning("w".into())),
417 Blocker("b".into())
418 );
419 assert_eq!(
420 Warning("w".into()).merge(Blocker("b".into())),
421 Blocker("b".into())
422 );
423 assert_eq!(Pass.merge(Blocker("b".into())), Blocker("b".into()));
424 assert_eq!(Blocker("b".into()).merge(Pass), Blocker("b".into()));
425
426 // Warning dominates Pass.
427 assert_eq!(Warning("w".into()).merge(Pass), Warning("w".into()));
428 assert_eq!(Pass.merge(Warning("w".into())), Warning("w".into()));
429
430 // Pass + Pass stays Pass.
431 assert_eq!(Pass.merge(Pass), Pass);
432
433 // Within a severity the first-seen (left) message wins.
434 assert_eq!(
435 Blocker("first".into()).merge(Blocker("second".into())),
436 Blocker("first".into())
437 );
438 assert_eq!(
439 Warning("first".into()).merge(Warning("second".into())),
440 Warning("first".into())
441 );
442 }
443}