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
37/// Publisher contract — one implementer per upstream registry / channel.
38///
39/// Required methods describe the publisher's identity, behavior, and how
40/// it participates in [`PublisherGroup`]-based scheduling:
41///
42/// * [`Publisher::name`] — stable identifier used in logs, evidence, and
43///   review findings (e.g. `"cargo"`, `"homebrew"`, `"winget"`).
44/// * [`Publisher::run`] — perform the actual publish and emit a
45///   [`PublishEvidence`] record describing what was sent upstream.
46/// * [`Publisher::group`] — which [`PublisherGroup`] this publisher belongs
47///   to; used by the publish stage to order and parallelize work.
48/// * [`Publisher::required`] — whether a failure in this publisher should
49///   fail the overall release.
50///
51/// Default-implemented hooks describe optional behavior:
52///
53/// * [`Publisher::rollback`] — best-effort undo of a successful publish.
54///   The default is a no-op so publishers that target irreversible
55///   registries (most of them) do not need to override.
56/// * [`Publisher::preflight`] — fast self-check executed before any
57///   publisher in the pipeline runs. Defaults to [`PreflightCheck::Pass`].
58/// * [`Publisher::rollback_scope_needed`] — declare an opt-in OAuth /
59///   token scope that rollback would require (e.g. `"delete_repo"` for
60///   GitHub-fork-based publishers). Defaults to `None`. Surfaced by
61///   the CLI when explaining why a rollback path is unavailable.
62///
63/// Implementations must be `Send + Sync` so the publish stage can fan out
64/// across publisher groups in parallel. Wrap non-`Send` clients (Rc-based,
65/// thread-local channels) behind an `Arc<Mutex<_>>` or move them inside
66/// `run()`'s scope rather than holding them on `self`.
67pub trait Publisher: Send + Sync {
68    /// Stable, lowercase identifier for this publisher (e.g. `"cargo"`).
69    fn name(&self) -> &str;
70
71    /// Execute the publish and emit evidence describing what was sent.
72    fn run(&self, ctx: &mut Context) -> anyhow::Result<PublishEvidence>;
73
74    /// Scheduling group — controls ordering and parallelism in the publish stage.
75    fn group(&self) -> PublisherGroup;
76
77    /// Whether a failure here should fail the overall release.
78    fn required(&self) -> bool;
79
80    /// Best-effort rollback of a successful publish, given its evidence.
81    ///
82    /// Default is a no-op: most upstream registries are append-only or
83    /// require human moderation to revoke, so the publisher opts in by
84    /// overriding only when it actually has a rollback path.
85    fn rollback(&self, _ctx: &mut Context, _evidence: &PublishEvidence) -> anyhow::Result<()> {
86        Ok(())
87    }
88
89    /// Fast self-check executed before any publisher runs.
90    ///
91    /// Default returns [`PreflightCheck::Pass`]. Override to surface
92    /// publisher-specific blockers (missing tap, missing fork, network
93    /// unreachable) or warnings (duplicate-but-matching upload).
94    fn preflight(&self, _ctx: &Context) -> anyhow::Result<PreflightCheck> {
95        Ok(PreflightCheck::Pass)
96    }
97
98    /// Opt-in OAuth / token scope rollback would require, if any.
99    ///
100    /// Default is `None`. Used by the CLI to explain why a `--rollback`
101    /// invocation cannot recover a given publisher without elevating the
102    /// release token's permissions.
103    fn rollback_scope_needed(&self) -> Option<&'static str> {
104        None
105    }
106
107    /// Whether this publisher opts out of nightly runs (the
108    /// `customization/publish/nightlies.md` skip-list).
109    ///
110    /// Each `Publisher` must declare its nightly behavior explicitly — there
111    /// is no default — so adding a new publisher forces a deliberate decision.
112    /// Return `true` for publishers that push to long-lived registries where a
113    /// nightly clobber is either disruptive (homebrew taps, scoop buckets,
114    /// AUR, krew-index, nix overlays) or outright forbidden by registry policy.
115    fn skips_on_nightly(&self) -> bool;
116
117    /// Whether a *failed* run of this publisher still has a real,
118    /// programmatic rollback to perform against `evidence`.
119    ///
120    /// Default `false`: a publisher's failure leaves nothing to undo (or
121    /// only an informational, human-driven unwind). The orchestration
122    /// rolls back **succeeded** Assets/Manager publishers; a failed
123    /// Submitter is normally inert.
124    ///
125    /// The cargo publisher is the exception. A multi-crate `cargo publish`
126    /// can succeed on crate A, go live on crates.io, then fail on crate B
127    /// — leaving A published under a *failed* Submitter row. cargo records
128    /// the succeeded crates in `evidence` and overrides this to `true`
129    /// when that set is non-empty, so the rollback path yanks A even
130    /// though the publisher's overall outcome is `Failed`. Returning
131    /// `false` for an empty record keeps a clean failure (nothing went
132    /// live) from arming the rollback machinery for no reason.
133    fn programmatic_rollback_on_failure(&self, _evidence: &PublishEvidence) -> bool {
134        false
135    }
136}
137
138/// The exact warn message a publisher emits when `rollback()` is invoked
139/// with no evidence to act on (empty `artifact_paths`, no `primary_ref`).
140/// Each publisher's empty-evidence branch calls this helper; tests can
141/// assert on the returned string without having to intercept stderr
142/// (`eprintln!` cannot be portably captured from the same process).
143///
144/// Lives in `anodizer_core` because the rollback shape is shared across
145/// publishers spread between `stage-publish` and `stage-blob` (and any
146/// future stage crate that implements `Publisher`).
147pub fn rollback_empty_warning_msg(publisher: &str, target_label: &str) -> String {
148    format!(
149        "{}: no {} recorded in evidence; verify {} state manually",
150        publisher, target_label, publisher
151    )
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    struct MinimalPublisher;
159    impl Publisher for MinimalPublisher {
160        fn name(&self) -> &str {
161            "minimal"
162        }
163        fn run(&self, _ctx: &mut Context) -> anyhow::Result<PublishEvidence> {
164            Ok(PublishEvidence::new("minimal"))
165        }
166        fn group(&self) -> PublisherGroup {
167            PublisherGroup::Manager
168        }
169        fn required(&self) -> bool {
170            false
171        }
172        fn skips_on_nightly(&self) -> bool {
173            false
174        }
175    }
176
177    #[test]
178    fn rollback_default_is_noop_ok() {
179        let p = MinimalPublisher;
180        let mut ctx = Context::test_fixture();
181        let evidence = PublishEvidence::new("minimal");
182        assert!(p.rollback(&mut ctx, &evidence).is_ok());
183    }
184
185    #[test]
186    fn preflight_default_is_pass() {
187        let p = MinimalPublisher;
188        let ctx = Context::test_fixture();
189        assert!(matches!(p.preflight(&ctx).unwrap(), PreflightCheck::Pass));
190    }
191
192    #[test]
193    fn rollback_scope_needed_default_is_none() {
194        let p = MinimalPublisher;
195        assert!(p.rollback_scope_needed().is_none());
196    }
197
198    #[test]
199    fn pending_outcome_round_trips_through_context() {
200        // The slot is single-shot: write once, drain once, then empty.
201        // Without single-shot semantics, a chocolatey moderation skip
202        // would bleed into the next publisher's row at dispatch time.
203        let mut ctx = Context::test_fixture();
204        assert!(ctx.take_pending_outcome().is_none());
205
206        ctx.record_publisher_outcome(crate::PublisherOutcome::PendingModeration);
207        assert!(matches!(
208            ctx.take_pending_outcome(),
209            Some(crate::PublisherOutcome::PendingModeration)
210        ));
211        assert!(
212            ctx.take_pending_outcome().is_none(),
213            "slot must be empty after take"
214        );
215
216        // Overwrite semantics: last writer wins (no implicit accumulation).
217        ctx.record_publisher_outcome(crate::PublisherOutcome::PendingModeration);
218        ctx.record_publisher_outcome(crate::PublisherOutcome::PendingValidation);
219        assert!(matches!(
220            ctx.take_pending_outcome(),
221            Some(crate::PublisherOutcome::PendingValidation)
222        ));
223    }
224}