Skip to main content

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    /// True when this publisher was registered (a config block exists) but
154    /// every configured entry evaluates skip-inactive under the CURRENT
155    /// config/env — `skip:`/`skip_upload:` truthy or `if:` falsy on all of
156    /// them. Checked at the dispatch chokepoint BEFORE [`Publisher::run`]
157    /// runs, so a `run()` that unconditionally returns `Ok(evidence)` even
158    /// with zero active entries is never recorded as `Succeeded`.
159    ///
160    /// Default `false`: publishers with no skip/enable knob need no
161    /// override. Publishers that do have one implement this by reusing the
162    /// exact active-entries predicate their [`Publisher::requirements`]
163    /// already applies — never a second, independently-derived skip check —
164    /// so the two cannot drift.
165    fn config_fully_inactive(&self, _ctx: &Context) -> bool {
166        false
167    }
168
169    /// Whether this publisher opts out of nightly runs (the
170    /// `customization/publish/nightlies.md` skip-list).
171    ///
172    /// Each `Publisher` must declare its nightly behavior explicitly — there
173    /// is no default — so adding a new publisher forces a deliberate decision.
174    /// Return `true` for publishers that push to long-lived registries where a
175    /// nightly clobber is either disruptive (homebrew taps, scoop buckets,
176    /// AUR, krew-index, nix overlays) or outright forbidden by registry policy.
177    fn skips_on_nightly(&self) -> bool;
178
179    /// Whether a *failed* run of this publisher still has a real,
180    /// programmatic rollback to perform against `evidence`.
181    ///
182    /// Default `false`: a publisher's failure leaves nothing to undo (or
183    /// only an informational, human-driven unwind). The orchestration
184    /// rolls back **succeeded** Assets/Manager publishers; a failed
185    /// Submitter is normally inert.
186    ///
187    /// The cargo publisher is the exception. A multi-crate `cargo publish`
188    /// can succeed on crate A, go live on crates.io, then fail on crate B
189    /// — leaving A published under a *failed* Submitter row. cargo records
190    /// the succeeded crates in `evidence` and overrides this to `true`
191    /// when that set is non-empty, so the rollback path yanks A even
192    /// though the publisher's overall outcome is `Failed`. Returning
193    /// `false` for an empty record keeps a clean failure (nothing went
194    /// live) from arming the rollback machinery for no reason.
195    fn programmatic_rollback_on_failure(&self, _evidence: &PublishEvidence) -> bool {
196        false
197    }
198
199    /// When `true`, this publisher's successful work is left in place even
200    /// when a rollback is triggered — it is never passed to `rollback()`.
201    /// Default `false` (rollback runs if the publisher implements it).
202    fn retain_on_rollback(&self) -> bool {
203        false
204    }
205}
206
207/// The exact warn message a publisher emits when `rollback()` is invoked
208/// with no evidence to act on (empty `artifact_paths`, no `primary_ref`).
209/// Each publisher's empty-evidence branch calls this helper; tests can
210/// assert on the returned string without having to intercept stderr
211/// (`eprintln!` cannot be portably captured from the same process).
212///
213/// Lives in `anodizer_core` because the rollback shape is shared across
214/// publishers spread between `stage-publish` and `stage-blob` (and any
215/// future stage crate that implements `Publisher`).
216pub fn rollback_empty_warning_msg(publisher: &str, target_label: &str) -> String {
217    format!(
218        "no {} recorded in {} evidence — verify {} state manually",
219        target_label, publisher, publisher
220    )
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    struct MinimalPublisher;
228    impl Publisher for MinimalPublisher {
229        fn name(&self) -> &str {
230            "minimal"
231        }
232        fn run(&self, _ctx: &mut Context) -> anyhow::Result<PublishEvidence> {
233            Ok(PublishEvidence::new("minimal"))
234        }
235        fn group(&self) -> PublisherGroup {
236            PublisherGroup::Manager
237        }
238        fn required(&self) -> bool {
239            false
240        }
241        fn skips_on_nightly(&self) -> bool {
242            false
243        }
244    }
245
246    #[test]
247    fn rollback_default_is_noop_ok() {
248        let p = MinimalPublisher;
249        let mut ctx = Context::test_fixture();
250        let evidence = PublishEvidence::new("minimal");
251        assert!(p.rollback(&mut ctx, &evidence).is_ok());
252    }
253
254    #[test]
255    fn preflight_default_is_pass() {
256        let p = MinimalPublisher;
257        let ctx = Context::test_fixture();
258        assert!(matches!(p.preflight(&ctx).unwrap(), PreflightCheck::Pass));
259    }
260
261    #[test]
262    fn rollback_scope_needed_default_is_none() {
263        let p = MinimalPublisher;
264        assert!(p.rollback_scope_needed().is_none());
265    }
266
267    #[test]
268    fn pending_outcome_round_trips_through_context() {
269        // The slot is single-shot: write once, drain once, then empty.
270        // Without single-shot semantics, a chocolatey moderation skip
271        // would bleed into the next publisher's row at dispatch time.
272        let mut ctx = Context::test_fixture();
273        assert!(ctx.take_pending_outcome().is_none());
274
275        ctx.record_publisher_outcome(crate::PublisherOutcome::PendingModeration);
276        assert!(matches!(
277            ctx.take_pending_outcome(),
278            Some(crate::PublisherOutcome::PendingModeration)
279        ));
280        assert!(
281            ctx.take_pending_outcome().is_none(),
282            "slot must be empty after take"
283        );
284
285        // Overwrite semantics: last writer wins (no implicit accumulation).
286        ctx.record_publisher_outcome(crate::PublisherOutcome::PendingModeration);
287        ctx.record_publisher_outcome(crate::PublisherOutcome::PendingValidation);
288        assert!(matches!(
289            ctx.take_pending_outcome(),
290            Some(crate::PublisherOutcome::PendingValidation)
291        ));
292    }
293
294    #[test]
295    fn rollback_empty_warning_msg_interpolates_all_three_slots() {
296        let msg = rollback_empty_warning_msg("homebrew", "tap commit");
297        assert_eq!(
298            msg,
299            "no tap commit recorded in homebrew evidence — verify homebrew state manually"
300        );
301    }
302
303    #[test]
304    fn rollback_empty_warning_msg_distinct_per_publisher() {
305        let a = rollback_empty_warning_msg("cargo", "crate");
306        let b = rollback_empty_warning_msg("aur", "commit");
307        assert_ne!(a, b);
308        assert!(a.contains("cargo") && a.contains("crate"));
309        assert!(b.contains("aur") && b.contains("commit"));
310    }
311
312    #[test]
313    fn programmatic_rollback_on_failure_defaults_false() {
314        let p = MinimalPublisher;
315        let evidence = PublishEvidence::new("minimal");
316        assert!(!p.programmatic_rollback_on_failure(&evidence));
317    }
318
319    #[test]
320    fn retain_on_rollback_defaults_false() {
321        assert!(!MinimalPublisher.retain_on_rollback());
322    }
323
324    #[test]
325    fn requirements_default_is_empty() {
326        let p = MinimalPublisher;
327        let ctx = Context::test_fixture();
328        assert!(p.requirements(&ctx).is_empty());
329    }
330
331    #[test]
332    fn config_fully_inactive_defaults_false() {
333        let p = MinimalPublisher;
334        let ctx = Context::test_fixture();
335        assert!(!p.config_fully_inactive(&ctx));
336    }
337
338    #[test]
339    fn preflight_check_variants_compare_by_value() {
340        assert_eq!(PreflightCheck::Pass, PreflightCheck::Pass);
341        assert_eq!(
342            PreflightCheck::Warning("dup".into()),
343            PreflightCheck::Warning("dup".into())
344        );
345        // same variant, different payload, must not be equal
346        assert_ne!(
347            PreflightCheck::Blocker("a".into()),
348            PreflightCheck::Blocker("b".into())
349        );
350        // different variants with same string must not be equal
351        assert_ne!(
352            PreflightCheck::Warning("x".into()),
353            PreflightCheck::Blocker("x".into())
354        );
355    }
356
357    #[test]
358    fn minimal_publisher_carries_its_declared_identity() {
359        let p = MinimalPublisher;
360        assert_eq!(p.name(), "minimal");
361        assert_eq!(p.group(), PublisherGroup::Manager);
362        assert!(!p.required());
363        assert!(!p.skips_on_nightly());
364    }
365
366    /// A publisher that overrides every default-implemented hook, so the
367    /// trait dispatch is proven to reach the override (not silently shadowed
368    /// by the default body).
369    struct OverridingPublisher;
370    impl Publisher for OverridingPublisher {
371        fn name(&self) -> &str {
372            "overriding"
373        }
374        fn run(&self, _ctx: &mut Context) -> anyhow::Result<PublishEvidence> {
375            Ok(PublishEvidence::new("overriding"))
376        }
377        fn group(&self) -> PublisherGroup {
378            PublisherGroup::Assets
379        }
380        fn required(&self) -> bool {
381            true
382        }
383        fn skips_on_nightly(&self) -> bool {
384            true
385        }
386        fn preflight(&self, _ctx: &Context) -> anyhow::Result<PreflightCheck> {
387            Ok(PreflightCheck::Blocker("fork missing".into()))
388        }
389        fn rollback_scope_needed(&self) -> Option<&'static str> {
390            Some("delete_repo")
391        }
392        fn programmatic_rollback_on_failure(&self, _evidence: &PublishEvidence) -> bool {
393            true
394        }
395        fn retain_on_rollback(&self) -> bool {
396            true
397        }
398    }
399
400    #[test]
401    fn override_publisher_preflight_returns_blocker() {
402        let p = OverridingPublisher;
403        let ctx = Context::test_fixture();
404        assert_eq!(
405            p.preflight(&ctx).unwrap(),
406            PreflightCheck::Blocker("fork missing".into())
407        );
408    }
409
410    #[test]
411    fn override_publisher_exposes_rollback_scope_and_flags() {
412        let p = OverridingPublisher;
413        let evidence = PublishEvidence::new("overriding");
414        assert_eq!(p.rollback_scope_needed(), Some("delete_repo"));
415        assert!(p.programmatic_rollback_on_failure(&evidence));
416        assert!(p.retain_on_rollback());
417        assert!(p.required());
418        assert!(p.skips_on_nightly());
419        assert_eq!(p.group(), PublisherGroup::Assets);
420    }
421
422    #[test]
423    fn preflight_check_clone_preserves_payload() {
424        let warn = PreflightCheck::Warning("dup upload".into());
425        assert_eq!(warn.clone(), warn);
426        let blocker = PreflightCheck::Blocker("no tap".into());
427        let cloned = blocker.clone();
428        assert_eq!(cloned, PreflightCheck::Blocker("no tap".into()));
429        // Clone must not collapse a Warning into the same value as a Blocker.
430        assert_ne!(warn, blocker);
431    }
432
433    #[test]
434    fn merge_escalates_to_worst_severity_keeping_first_message() {
435        use PreflightCheck::{Blocker, Pass, Warning};
436
437        // Blocker dominates regardless of position, keeping its own message.
438        assert_eq!(
439            Blocker("b".into()).merge(Warning("w".into())),
440            Blocker("b".into())
441        );
442        assert_eq!(
443            Warning("w".into()).merge(Blocker("b".into())),
444            Blocker("b".into())
445        );
446        assert_eq!(Pass.merge(Blocker("b".into())), Blocker("b".into()));
447        assert_eq!(Blocker("b".into()).merge(Pass), Blocker("b".into()));
448
449        // Warning dominates Pass.
450        assert_eq!(Warning("w".into()).merge(Pass), Warning("w".into()));
451        assert_eq!(Pass.merge(Warning("w".into())), Warning("w".into()));
452
453        // Pass + Pass stays Pass.
454        assert_eq!(Pass.merge(Pass), Pass);
455
456        // Within a severity the first-seen (left) message wins.
457        assert_eq!(
458            Blocker("first".into()).merge(Blocker("second".into())),
459            Blocker("first".into())
460        );
461        assert_eq!(
462            Warning("first".into()).merge(Warning("second".into())),
463            Warning("first".into())
464        );
465    }
466}