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/// A publisher's upstream state for the exact version+content this run would
55/// publish — the answer to "am I already done?", owned by ONE trait method
56/// ([`Publisher::reconcile`]) instead of being re-derived ad hoc by per-`run()`
57/// self-skips, the global preflight gate, and the burn-guard probes.
58///
59/// Consumed by two surfaces with one contract:
60/// * the publish dispatch loop — `Complete` skips `run()`
61/// (`SkipReason::AlreadyPublished`), `Diverged` records a `Failed` result
62/// (for a required publisher that closes the Submitter gate and the run
63/// exits nonzero — bump the version; for an optional one it is a tolerated
64/// failure), and `Absent`/`Unknown` fall through to `run()`. A divergence
65/// never short-circuits the loop: dispatch continues to the remaining
66/// publishers unless `--fail-fast` was passed;
67/// * `anodizer preflight` — prints the per-publisher reconcile table.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub enum ReconcileState {
70 /// Not present upstream. `run()` must publish.
71 Absent,
72 /// This exact version+content is already published/submitted for this
73 /// target (cargo version live with matching cksum, chocolatey submission
74 /// in moderation with matching hash, winget open PR for this exact
75 /// version, …). `run()` must be skipped — recording it is a no-op
76 /// success/pending, and a re-run of a partially-failed release converges
77 /// instead of wedging on the publisher's own prior success.
78 ///
79 /// `Complete` requires a POSITIVE upstream match (version present with
80 /// matching content, or an open submission for this exact version) —
81 /// never an inference from absence, which would silently under-publish.
82 Complete {
83 /// Operator-facing context ("in moderation since …", "open PR #123").
84 note: String,
85 },
86 /// The version exists upstream but the LOCAL artifact bytes differ from
87 /// what was published. The one true blocker at any time: the operator must bump
88 /// the version. Honors [`Publisher::required`] at the dispatch arm — a
89 /// required publisher's divergence fails the run via the Submitter gate
90 /// (the result is recorded as `Failed` and the run exits nonzero) while
91 /// dispatch continues unless `--fail-fast`; an optional publisher's
92 /// divergence records a gate-neutral tolerated failure.
93 Diverged {
94 /// What diverged (hash mismatch details, comparison evidence).
95 detail: String,
96 },
97 /// Could not determine (network failure, unparseable feed). NEVER blocks
98 /// a release: `run()` proceeds and the registry's own conflict handling
99 /// is the backstop — fail-safe toward publishing, not skipping.
100 Unknown {
101 /// Why the probe was inconclusive.
102 reason: String,
103 },
104}
105
106/// Publisher contract — one implementer per upstream registry / channel.
107///
108/// Required methods describe the publisher's identity, behavior, and how
109/// it participates in [`PublisherGroup`]-based scheduling:
110///
111/// * [`Publisher::name`] — stable identifier used in logs, evidence, and
112/// review findings (e.g. `"cargo"`, `"homebrew"`, `"winget"`).
113/// * [`Publisher::run`] — perform the actual publish and emit a
114/// [`PublishEvidence`] record describing what was sent upstream.
115/// * [`Publisher::group`] — which [`PublisherGroup`] this publisher belongs
116/// to; used by the publish stage to order and parallelize work.
117/// * [`Publisher::required`] — whether a failure in this publisher should
118/// fail the overall release.
119///
120/// Default-implemented hooks describe optional behavior:
121///
122/// * [`Publisher::rollback`] — best-effort undo of a successful publish.
123/// The default is a no-op so publishers that target irreversible
124/// registries (most of them) do not need to override.
125/// * [`Publisher::preflight`] — fast self-check executed before any
126/// publisher in the pipeline runs. Defaults to [`PreflightCheck::Pass`].
127/// * [`Publisher::rollback_scope_needed`] — declare an opt-in OAuth /
128/// token scope that rollback would require (e.g. `"delete_repo"` for
129/// GitHub-fork-based publishers). Defaults to `None`. Surfaced by
130/// the CLI when explaining why a rollback path is unavailable.
131///
132/// Implementations must be `Send + Sync` so the publish stage can fan out
133/// across publisher groups in parallel. Wrap non-`Send` clients (Rc-based,
134/// thread-local channels) behind an `Arc<Mutex<_>>` or move them inside
135/// `run()`'s scope rather than holding them on `self`.
136pub trait Publisher: Send + Sync {
137 /// Stable, lowercase identifier for this publisher (e.g. `"cargo"`).
138 fn name(&self) -> &str;
139
140 /// Execute the publish and emit evidence describing what was sent.
141 fn run(&self, ctx: &mut Context) -> anyhow::Result<PublishEvidence>;
142
143 /// Scheduling group — controls ordering and parallelism in the publish stage.
144 fn group(&self) -> PublisherGroup;
145
146 /// Whether a failure here should fail the overall release.
147 fn required(&self) -> bool;
148
149 /// Best-effort rollback of a successful publish, given its evidence.
150 ///
151 /// Default is a no-op: most upstream registries are append-only or
152 /// require human moderation to revoke, so the publisher opts in by
153 /// overriding only when it actually has a rollback path.
154 fn rollback(&self, _ctx: &mut Context, _evidence: &PublishEvidence) -> anyhow::Result<()> {
155 Ok(())
156 }
157
158 /// Fast self-check executed before any publisher runs.
159 ///
160 /// Default returns [`PreflightCheck::Pass`]. Override to surface
161 /// publisher-specific blockers (missing tap, missing fork, network
162 /// unreachable) or warnings (duplicate-but-matching upload).
163 fn preflight(&self, _ctx: &Context) -> anyhow::Result<PreflightCheck> {
164 Ok(PreflightCheck::Pass)
165 }
166
167 /// Cheap, read-only "am I already done for this exact version+content?"
168 ///
169 /// The single owner of the reconcile question (see [`ReconcileState`]).
170 /// Default probes nothing (`Absent`) so a publisher with no idempotency
171 /// concern — every same-version-overwrite Manager, the idempotent Assets
172 /// re-uploads — needs no override. Publishers that self-skip (cargo's
173 /// already-published index check, chocolatey's in-moderation hash match,
174 /// winget's open-PR probe) implement this with that exact logic so the
175 /// forward dispatch and the `anodizer preflight` report consume ONE
176 /// probe instead of three drifting ones.
177 ///
178 /// Must be read-only toward upstream (probes, never writes): dispatch
179 /// calls it before `run()` on every non-skipped publisher of a real
180 /// (non-dry-run) release. `&mut Context` matches `run()` — probes may
181 /// need template-var crate scoping or local staging (cargo's local
182 /// `.crate` checksum), but never a registry write.
183 fn reconcile(&self, _ctx: &mut Context) -> anyhow::Result<ReconcileState> {
184 Ok(ReconcileState::Absent)
185 }
186
187 /// Opt-in OAuth / token scope rollback would require, if any.
188 ///
189 /// Default is `None`. Used by `anodizer tag rollback` to explain why
190 /// it cannot withdraw a given publisher without elevating the release
191 /// token's permissions.
192 fn rollback_scope_needed(&self) -> Option<&'static str> {
193 None
194 }
195
196 /// Environment requirements this publisher derives from the resolved
197 /// config: CLI tools it spawns, env vars/secrets it reads, endpoints
198 /// it talks to, key material it loads.
199 ///
200 /// Consumed by the config-aware preflight (`anodizer preflight` and the
201 /// in-process phase at the head of `anodizer release`). Declared next
202 /// to each publisher's implementation — derived from the same config
203 /// fields `run()` reads — so the preflight cannot drift from the
204 /// publish path. Default is empty for publishers with no external
205 /// prerequisites beyond what their stage already declares.
206 fn requirements(&self, _ctx: &Context) -> Vec<crate::env_preflight::EnvRequirement> {
207 Vec::new()
208 }
209
210 /// Environment requirements whose absence DEGRADES this publisher's run
211 /// rather than failing it: optional validators (`ruby -c`, `bash -n`,
212 /// `nix-instantiate --parse`) that warn+skip when missing, or a preferred
213 /// transport with a full fallback (`gh` vs the GitHub REST API).
214 ///
215 /// Collected alongside [`Publisher::requirements`] but surfaced as
216 /// ADVISORY: preflight warns instead of blocking, and `anodizer tools`
217 /// reports them as recommended so an auto-provisioned runner installs
218 /// them and gets the stronger validation/transport. Hard needs (the run
219 /// path errors without the tool) belong in `requirements()` instead.
220 /// Default is empty.
221 fn advisory_requirements(&self, _ctx: &Context) -> Vec<crate::env_preflight::EnvRequirement> {
222 Vec::new()
223 }
224
225 /// True when this publisher was registered (a config block exists) but
226 /// every configured entry evaluates skip-inactive under the CURRENT
227 /// config/env — `skip:`/`skip_upload:` truthy or `if:` falsy on all of
228 /// them. Checked at the dispatch chokepoint BEFORE [`Publisher::run`]
229 /// runs, so a `run()` that unconditionally returns `Ok(evidence)` even
230 /// with zero active entries is never recorded as `Succeeded`.
231 ///
232 /// Default `false`: publishers with no skip/enable knob need no
233 /// override. Publishers that do have one implement this by reusing the
234 /// exact active-entries predicate their [`Publisher::requirements`]
235 /// already applies — never a second, independently-derived skip check —
236 /// so the two cannot drift.
237 fn config_fully_inactive(&self, _ctx: &Context) -> bool {
238 false
239 }
240
241 /// Whether this publisher opts out of nightly runs (the
242 /// `customization/publish/nightlies.md` skip-list).
243 ///
244 /// Each `Publisher` must declare its nightly behavior explicitly — there
245 /// is no default — so adding a new publisher forces a deliberate decision.
246 /// Return `true` for publishers that push to long-lived registries where a
247 /// nightly clobber is either disruptive (homebrew taps, scoop buckets,
248 /// AUR, krew-index, nix overlays) or outright forbidden by registry policy.
249 fn skips_on_nightly(&self) -> bool;
250
251 /// When `true`, this publisher's successful work is left in place even
252 /// when a rollback is triggered — it is never passed to `rollback()`.
253 /// Default `false` (rollback runs if the publisher implements it).
254 fn retain_on_rollback(&self) -> bool {
255 false
256 }
257}
258
259/// The exact warn message a publisher emits when `rollback()` is invoked
260/// with no evidence to act on (empty `artifact_paths`, no `primary_ref`).
261/// Each publisher's empty-evidence branch calls this helper; tests can
262/// assert on the returned string without having to intercept stderr
263/// (`eprintln!` cannot be portably captured from the same process).
264///
265/// A run that publishes nothing — dry-run or snapshot — reaches the same empty
266/// branch, and there the manual-verification wording sends the operator to
267/// inspect remote state the run never created. The distinction is made here,
268/// on the evidence, so every publisher says the right thing rather than one
269/// of them carrying a special case.
270///
271/// Lives in `anodizer_core` because the rollback shape is shared across
272/// publishers spread between `stage-publish` and `stage-blob` (and any
273/// future stage crate that implements `Publisher`).
274pub fn rollback_empty_warning_msg(ctx: &Context, publisher: &str, target_label: &str) -> String {
275 if ctx.is_dry_run() || ctx.is_snapshot() {
276 return format!(
277 "no {target_label} recorded in {publisher} evidence — this run published nothing to \
278 {publisher}, so there is no state to undo"
279 );
280 }
281 format!(
282 "no {target_label} recorded in {publisher} evidence — verify {publisher} state manually"
283 )
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289
290 struct MinimalPublisher;
291 impl Publisher for MinimalPublisher {
292 fn name(&self) -> &str {
293 "minimal"
294 }
295 fn run(&self, _ctx: &mut Context) -> anyhow::Result<PublishEvidence> {
296 Ok(PublishEvidence::new("minimal"))
297 }
298 fn group(&self) -> PublisherGroup {
299 PublisherGroup::Manager
300 }
301 fn required(&self) -> bool {
302 false
303 }
304 fn skips_on_nightly(&self) -> bool {
305 false
306 }
307 }
308
309 #[test]
310 fn rollback_default_is_noop_ok() {
311 let p = MinimalPublisher;
312 let mut ctx = Context::test_fixture();
313 let evidence = PublishEvidence::new("minimal");
314 assert!(p.rollback(&mut ctx, &evidence).is_ok());
315 }
316
317 #[test]
318 fn preflight_default_is_pass() {
319 let p = MinimalPublisher;
320 let ctx = Context::test_fixture();
321 assert!(matches!(p.preflight(&ctx).unwrap(), PreflightCheck::Pass));
322 }
323
324 #[test]
325 fn rollback_scope_needed_default_is_none() {
326 let p = MinimalPublisher;
327 assert!(p.rollback_scope_needed().is_none());
328 }
329
330 #[test]
331 fn pending_outcome_round_trips_through_context() {
332 // The slot is single-shot: write once, take once, then empty.
333 // Without single-shot semantics, a chocolatey moderation skip
334 // would bleed into the next publisher's row at dispatch time.
335 let mut ctx = Context::test_fixture();
336 assert!(ctx.take_pending_outcome().is_none());
337
338 ctx.record_publisher_outcome(crate::PublisherOutcome::PendingModeration);
339 assert!(matches!(
340 ctx.take_pending_outcome(),
341 Some(crate::PublisherOutcome::PendingModeration)
342 ));
343 assert!(
344 ctx.take_pending_outcome().is_none(),
345 "slot must be empty after take"
346 );
347
348 // Overwrite semantics: last writer wins (no implicit accumulation).
349 ctx.record_publisher_outcome(crate::PublisherOutcome::PendingModeration);
350 ctx.record_publisher_outcome(crate::PublisherOutcome::PendingValidation);
351 assert!(matches!(
352 ctx.take_pending_outcome(),
353 Some(crate::PublisherOutcome::PendingValidation)
354 ));
355 }
356
357 #[test]
358 fn rollback_empty_warning_msg_interpolates_all_three_slots() {
359 let ctx = Context::test_fixture();
360 let msg = rollback_empty_warning_msg(&ctx, "homebrew", "tap commit");
361 assert_eq!(
362 msg,
363 "no tap commit recorded in homebrew evidence — verify homebrew state manually"
364 );
365 }
366
367 #[test]
368 fn rollback_empty_warning_msg_distinct_per_publisher() {
369 let ctx = Context::test_fixture();
370 let a = rollback_empty_warning_msg(&ctx, "cargo", "crate");
371 let b = rollback_empty_warning_msg(&ctx, "aur", "commit");
372 assert_ne!(a, b);
373 assert!(a.contains("cargo") && a.contains("crate"));
374 assert!(b.contains("aur") && b.contains("commit"));
375 }
376
377 /// A run that published nothing has no remote state to inspect, so the
378 /// empty-evidence line must not send the operator to verify any. Decided
379 /// on the evidence, once, rather than in the publisher that happened to
380 /// surface it.
381 #[test]
382 fn a_run_that_published_nothing_is_not_asked_to_verify_remote_state() {
383 for mode in ["dry_run", "snapshot"] {
384 let mut ctx = Context::test_fixture();
385 match mode {
386 "dry_run" => ctx.options.dry_run = true,
387 _ => ctx.options.snapshot = true,
388 }
389 let msg = rollback_empty_warning_msg(&ctx, "winget", "submitted PR targets");
390 assert!(
391 msg.starts_with("no submitted PR targets recorded in winget evidence"),
392 "{mode}: {msg}"
393 );
394 assert!(
395 !msg.contains("verify") && !msg.contains("manually"),
396 "{mode}: a run that published nothing has no state to verify: {msg}"
397 );
398 assert!(msg.contains("no state to undo"), "{mode}: {msg}");
399 }
400 }
401
402 #[test]
403 fn retain_on_rollback_defaults_false() {
404 assert!(!MinimalPublisher.retain_on_rollback());
405 }
406
407 #[test]
408 fn requirements_default_is_empty() {
409 let p = MinimalPublisher;
410 let ctx = Context::test_fixture();
411 assert!(p.requirements(&ctx).is_empty());
412 }
413
414 #[test]
415 fn config_fully_inactive_defaults_false() {
416 let p = MinimalPublisher;
417 let ctx = Context::test_fixture();
418 assert!(!p.config_fully_inactive(&ctx));
419 }
420
421 #[test]
422 fn preflight_check_variants_compare_by_value() {
423 assert_eq!(PreflightCheck::Pass, PreflightCheck::Pass);
424 assert_eq!(
425 PreflightCheck::Warning("dup".into()),
426 PreflightCheck::Warning("dup".into())
427 );
428 // same variant, different payload, must not be equal
429 assert_ne!(
430 PreflightCheck::Blocker("a".into()),
431 PreflightCheck::Blocker("b".into())
432 );
433 // different variants with same string must not be equal
434 assert_ne!(
435 PreflightCheck::Warning("x".into()),
436 PreflightCheck::Blocker("x".into())
437 );
438 }
439
440 #[test]
441 fn minimal_publisher_carries_its_declared_identity() {
442 let p = MinimalPublisher;
443 assert_eq!(p.name(), "minimal");
444 assert_eq!(p.group(), PublisherGroup::Manager);
445 assert!(!p.required());
446 assert!(!p.skips_on_nightly());
447 }
448
449 /// A publisher that overrides every default-implemented hook, so the
450 /// trait dispatch is proven to reach the override (not silently shadowed
451 /// by the default body).
452 struct OverridingPublisher;
453 impl Publisher for OverridingPublisher {
454 fn name(&self) -> &str {
455 "overriding"
456 }
457 fn run(&self, _ctx: &mut Context) -> anyhow::Result<PublishEvidence> {
458 Ok(PublishEvidence::new("overriding"))
459 }
460 fn group(&self) -> PublisherGroup {
461 PublisherGroup::Assets
462 }
463 fn required(&self) -> bool {
464 true
465 }
466 fn skips_on_nightly(&self) -> bool {
467 true
468 }
469 fn preflight(&self, _ctx: &Context) -> anyhow::Result<PreflightCheck> {
470 Ok(PreflightCheck::Blocker("fork missing".into()))
471 }
472 fn rollback_scope_needed(&self) -> Option<&'static str> {
473 Some("delete_repo")
474 }
475 fn retain_on_rollback(&self) -> bool {
476 true
477 }
478 }
479
480 #[test]
481 fn override_publisher_preflight_returns_blocker() {
482 let p = OverridingPublisher;
483 let ctx = Context::test_fixture();
484 assert_eq!(
485 p.preflight(&ctx).unwrap(),
486 PreflightCheck::Blocker("fork missing".into())
487 );
488 }
489
490 #[test]
491 fn override_publisher_exposes_rollback_scope_and_flags() {
492 let p = OverridingPublisher;
493 assert_eq!(p.rollback_scope_needed(), Some("delete_repo"));
494 assert!(p.retain_on_rollback());
495 assert!(p.required());
496 assert!(p.skips_on_nightly());
497 assert_eq!(p.group(), PublisherGroup::Assets);
498 }
499
500 #[test]
501 fn preflight_check_clone_preserves_payload() {
502 let warn = PreflightCheck::Warning("dup upload".into());
503 assert_eq!(warn.clone(), warn);
504 let blocker = PreflightCheck::Blocker("no tap".into());
505 let cloned = blocker.clone();
506 assert_eq!(cloned, PreflightCheck::Blocker("no tap".into()));
507 // Clone must not collapse a Warning into the same value as a Blocker.
508 assert_ne!(warn, blocker);
509 }
510
511 #[test]
512 fn merge_escalates_to_worst_severity_keeping_first_message() {
513 use PreflightCheck::{Blocker, Pass, Warning};
514
515 // Blocker dominates regardless of position, keeping its own message.
516 assert_eq!(
517 Blocker("b".into()).merge(Warning("w".into())),
518 Blocker("b".into())
519 );
520 assert_eq!(
521 Warning("w".into()).merge(Blocker("b".into())),
522 Blocker("b".into())
523 );
524 assert_eq!(Pass.merge(Blocker("b".into())), Blocker("b".into()));
525 assert_eq!(Blocker("b".into()).merge(Pass), Blocker("b".into()));
526
527 // Warning dominates Pass.
528 assert_eq!(Warning("w".into()).merge(Pass), Warning("w".into()));
529 assert_eq!(Pass.merge(Warning("w".into())), Warning("w".into()));
530
531 // Pass + Pass stays Pass.
532 assert_eq!(Pass.merge(Pass), Pass);
533
534 // Within a severity the first-seen (left) message wins.
535 assert_eq!(
536 Blocker("first".into()).merge(Blocker("second".into())),
537 Blocker("first".into())
538 );
539 assert_eq!(
540 Warning("first".into()).merge(Warning("second".into())),
541 Warning("first".into())
542 );
543 }
544}