Skip to main content

anodizer_core/
promote.rs

1//! Cross-publisher artifact promotion.
2//!
3//! Promotion moves an already-published artifact from a pre-release track to a
4//! more stable track **without rebuilding** — a snapcraft channel release, an
5//! npm dist-tag move, an OCI floating-tag re-point, or a GitHub prerelease
6//! flip. It is a cross-publisher capability, not snap-specific: four publishers
7//! own the mechanic and each speaks its own track vocabulary.
8//!
9//! This module defines the publisher-agnostic surface the `anodizer promote`
10//! verb fans out over:
11//!
12//! * [`Promotable`] — the capability a promotion-capable publisher implements.
13//! * [`PromoteRequest`] — the resolved (native) `from`/`to` tracks, the
14//!   [`PromoteSelector`], the dry-run flag, and the [`Context`] handle a
15//!   publisher reads its config, version, and logger from.
16//! * [`PromoteOutcome`] / [`PromoteReport`] — the per-publisher result the verb
17//!   renders a summary from, parallel to
18//!   [`crate::publish_report::PublisherResult`] /
19//!   [`crate::publish_report::PublishReport`] so a promotion run reports the
20//!   same shape a publish run does.
21//!
22//! ## Why a trait (and not the stage/report dispatch the publish path uses)
23//!
24//! The publish path splits its dispatch: trait-based [`Publisher`]s run through
25//! a central registry, while snapcraft runs as its own [`crate::stage::Stage`]
26//! (a trait registration would double-publish). Promotion has no such hazard —
27//! there is exactly one promotion action per publisher and no gate ordering — so
28//! a single [`Promotable`] trait object per capable publisher is the cleanest
29//! fit. The trait lives in `anodizer-core` (like [`Publisher`]) so each
30//! publisher's own stage crate can implement it without a circular dependency,
31//! and so the subprocess spawn a promotion needs (e.g. `snapcraft release`)
32//! stays inside the module-boundary allow-list of that stage crate rather than
33//! leaking into `core` or the CLI. The `anodizer promote` verb assembles the
34//! `Vec<Box<dyn Promotable>>` (it is the one crate that depends on every stage)
35//! and hands it to [`dispatch_promotions`]; adding npm / docker / github
36//! promotion is a new `impl Promotable` plus one line in that assembly.
37//!
38//! [`Publisher`]: crate::publisher::Publisher
39
40use serde::{Deserialize, Serialize};
41
42use crate::context::Context;
43use crate::publish_report::PublishReport;
44
45/// Canonical, publisher-neutral track names the CLI accepts for `--from` /
46/// `--to`. `stable` is the promotion target; the rest are pre-stable aliases
47/// each publisher's [`Promotable::resolve_track`] maps into its native
48/// vocabulary. A raw native name (e.g. a snapcraft `edge` or an npm `next`
49/// dist-tag) that is not in this set passes through `resolve_track` verbatim,
50/// so operators are never boxed into the canonical words.
51pub const CANONICAL_TRACKS: &[&str] = &["stable", "prerelease", "candidate", "beta", "edge"];
52
53/// The canonical pre-stable aliases (every CANONICAL_TRACKS entry except
54/// `stable`). A publisher with a single native pre-track maps all of these to
55/// that track; snapcraft (real edge/beta/candidate channels) maps them
56/// individually and is the deliberate exception.
57pub const CANONICAL_PRETRACKS: &[&str] = &["prerelease", "candidate", "beta", "edge"];
58
59/// Whether `name` is a canonical pre-stable alias (see [`CANONICAL_PRETRACKS`]).
60pub fn is_canonical_pretrack(name: &str) -> bool {
61    CANONICAL_PRETRACKS.contains(&name)
62}
63
64/// Publisher-neutral default for `--from` when the operator omits it: the
65/// pre-stable track, whatever the selected publisher calls it. Each publisher's
66/// [`Promotable::resolve_track`] maps `"prerelease"` into its native pre-stable
67/// track (snapcraft → `candidate`).
68pub const DEFAULT_FROM_TRACK: &str = "prerelease";
69
70/// Identifiers of the publishers that currently implement [`Promotable`]. The
71/// `anodizer promote` verb consults this to distinguish "named a publisher that
72/// cannot be promoted" (a clear error) from "named a promotable publisher that
73/// is not configured". It lists only publishers with a real implementation so
74/// the error messages never claim a capability that does not exist yet.
75pub const PROMOTABLE_PUBLISHERS: &[&str] = &["snapcraft", "npm", "docker", "github"];
76
77/// Whether `name` identifies a promotion-capable publisher (see
78/// [`PROMOTABLE_PUBLISHERS`]).
79pub fn is_promotion_capable(name: &str) -> bool {
80    PROMOTABLE_PUBLISHERS.contains(&name)
81}
82
83/// Which already-published artifact a promotion should move.
84///
85/// Carried by [`PromoteRequest`] and interpreted by each publisher against its
86/// own native coordinates (a snapcraft revision, an npm version, an OCI digest,
87/// a GitHub tag).
88#[derive(Debug, Clone)]
89pub enum PromoteSelector {
90    /// Promote the newest artifact currently landed in the `from` track
91    /// (publisher-resolved — e.g. the highest snapcraft revision in the
92    /// from-channel).
93    Newest,
94    /// Promote this explicit version / tag.
95    Version(String),
96    /// Promote what a prior release run recorded. Carries the loaded
97    /// [`PublishReport`] so publishers read the recorded coordinates
98    /// (snapcraft revision, npm version, …) straight from the evidence instead
99    /// of re-parsing run-summary files. The `run_id` is retained for
100    /// diagnostics and dry-run rendering.
101    FromRun {
102        /// The run id the report was loaded from (`--from-run <id>`).
103        run_id: String,
104        /// The prior run's `report.json`, already parsed.
105        report: PublishReport,
106    },
107}
108
109impl PromoteSelector {
110    /// A short operator-facing description of what this selector targets, used
111    /// in dry-run and summary lines (e.g. `version 1.2.3`, `newest`,
112    /// `run abc123`).
113    pub fn describe(&self) -> String {
114        match self {
115            PromoteSelector::Newest => "newest".to_string(),
116            PromoteSelector::Version(v) => format!("version {v}"),
117            PromoteSelector::FromRun { run_id, .. } => format!("run {run_id}"),
118        }
119    }
120
121    /// The `from` label a folded promotion summary/outcome should show. An
122    /// explicit selector names the source it actually targets (a concrete
123    /// version, a recorded run), so the summary reads `1.4.0→latest` rather
124    /// than the canonical track direction `edge→latest` that would mislead when
125    /// the operator passed `--version`/`--from-run`. `Newest` keeps the native
126    /// `from_track` (it genuinely promotes whatever sits on that track).
127    pub fn source_label(&self, from_track: &str) -> String {
128        match self {
129            PromoteSelector::Newest => from_track.to_string(),
130            PromoteSelector::Version(v) => v.clone(),
131            PromoteSelector::FromRun { run_id, .. } => format!("run {run_id}"),
132        }
133    }
134}
135
136/// A single publisher's promotion request: the resolved **native** `from`/`to`
137/// tracks (already mapped through [`Promotable::resolve_track`] by
138/// [`dispatch_promotions`]), the [`PromoteSelector`], the dry-run flag, and the
139/// [`Context`] handle the publisher reads config / version / logger from.
140pub struct PromoteRequest<'a> {
141    /// Source track, in this publisher's native vocabulary.
142    pub from: String,
143    /// Destination track, in this publisher's native vocabulary.
144    pub to: String,
145    /// What to promote.
146    pub selector: &'a PromoteSelector,
147    /// When true, resolve and print the plan but run no external command.
148    pub dry_run: bool,
149    /// Shared context: config, resolved version, logger.
150    pub ctx: &'a Context,
151}
152
153/// Why a publisher's promotion did not run.
154#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
155#[serde(rename_all = "kebab-case")]
156pub enum PromoteSkipReason {
157    /// The publisher does not support promotion (defensive — the verb filters
158    /// these out before dispatch; recorded if a caller dispatches one anyway).
159    Unsupported,
160    /// The publisher is promotion-capable but had nothing to promote in the
161    /// `from` track (e.g. no matching snapcraft revision).
162    NothingToPromote,
163}
164
165/// Terminal state of a single publisher's promotion, parallel to
166/// [`crate::publish_report::PublisherOutcome`].
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
168pub enum PromoteStatus {
169    /// The artifact was moved from `from` to `to`.
170    Promoted,
171    /// `--dry-run`: the plan was printed; nothing was moved.
172    DryRun,
173    /// The promotion did not run; see [`PromoteSkipReason`].
174    Skipped(PromoteSkipReason),
175    /// The promotion was attempted and failed; the `String` is the rendered
176    /// error (`{:#}`).
177    Failed(String),
178}
179
180/// Per-publisher promotion result, parallel to
181/// [`crate::publish_report::PublisherResult`]. Rendered by the verb into a
182/// summary line and folded into the process exit code.
183#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
184pub struct PromoteOutcome {
185    /// Publisher identifier (e.g. `"snapcraft"`).
186    pub publisher: String,
187    /// Source track, native vocabulary.
188    pub from: String,
189    /// Destination track, native vocabulary.
190    pub to: String,
191    /// The coordinate that was promoted (revision / version / tag / digest),
192    /// when known. `None` for dry-run and skip outcomes.
193    pub what: Option<String>,
194    /// Terminal state.
195    pub status: PromoteStatus,
196}
197
198impl PromoteOutcome {
199    /// Build a `Promoted` outcome naming the coordinate that moved.
200    pub fn promoted(
201        publisher: impl Into<String>,
202        from: impl Into<String>,
203        to: impl Into<String>,
204        what: impl Into<String>,
205    ) -> Self {
206        Self {
207            publisher: publisher.into(),
208            from: from.into(),
209            to: to.into(),
210            what: Some(what.into()),
211            status: PromoteStatus::Promoted,
212        }
213    }
214
215    /// Build a `DryRun` outcome; `what` is the resolved coordinate when the
216    /// publisher could name it without spawning, else `None`.
217    pub fn dry_run(
218        publisher: impl Into<String>,
219        from: impl Into<String>,
220        to: impl Into<String>,
221        what: Option<String>,
222    ) -> Self {
223        Self {
224            publisher: publisher.into(),
225            from: from.into(),
226            to: to.into(),
227            what,
228            status: PromoteStatus::DryRun,
229        }
230    }
231
232    /// Build a `Skipped` outcome.
233    pub fn skipped(
234        publisher: impl Into<String>,
235        from: impl Into<String>,
236        to: impl Into<String>,
237        reason: PromoteSkipReason,
238    ) -> Self {
239        Self {
240            publisher: publisher.into(),
241            from: from.into(),
242            to: to.into(),
243            what: None,
244            status: PromoteStatus::Skipped(reason),
245        }
246    }
247
248    /// Build a `Failed` outcome carrying the rendered error.
249    pub fn failed(
250        publisher: impl Into<String>,
251        from: impl Into<String>,
252        to: impl Into<String>,
253        error: impl Into<String>,
254    ) -> Self {
255        Self {
256            publisher: publisher.into(),
257            from: from.into(),
258            to: to.into(),
259            what: None,
260            status: PromoteStatus::Failed(error.into()),
261        }
262    }
263
264    /// Whether this outcome is a terminal failure that must fail the verb.
265    ///
266    /// Exhaustive `match` (not `matches!`) so a future terminal-failure variant
267    /// forces a conscious classification decision here, mirroring
268    /// [`crate::publish_report::PublisherOutcome::is_required_release_failure`].
269    pub fn is_failure(&self) -> bool {
270        match self.status {
271            PromoteStatus::Failed(_) => true,
272            PromoteStatus::Promoted | PromoteStatus::DryRun | PromoteStatus::Skipped(_) => false,
273        }
274    }
275
276    /// One-line operator-facing summary, e.g.
277    /// `snapcraft: revision 42 candidate→stable (promoted)`.
278    pub fn summary_line(&self) -> String {
279        let coord = self
280            .what
281            .as_deref()
282            .map(|w| format!("{w} "))
283            .unwrap_or_default();
284        let state = match &self.status {
285            PromoteStatus::Promoted => "promoted".to_string(),
286            PromoteStatus::DryRun => "dry-run".to_string(),
287            PromoteStatus::Skipped(PromoteSkipReason::Unsupported) => {
288                "skipped (unsupported)".into()
289            }
290            PromoteStatus::Skipped(PromoteSkipReason::NothingToPromote) => {
291                "skipped (nothing to promote)".into()
292            }
293            PromoteStatus::Failed(msg) => format!("failed: {msg}"),
294        };
295        format!(
296            "{}: {}{}→{} ({})",
297            self.publisher, coord, self.from, self.to, state
298        )
299    }
300}
301
302/// Aggregate result of a promotion run, parallel to
303/// [`crate::publish_report::PublishReport`].
304#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
305pub struct PromoteReport {
306    /// One entry per dispatched publisher, in dispatch order.
307    pub results: Vec<PromoteOutcome>,
308}
309
310impl PromoteReport {
311    /// Whether any dispatched publisher's promotion failed.
312    pub fn any_failure(&self) -> bool {
313        self.results.iter().any(PromoteOutcome::is_failure)
314    }
315
316    /// Names of the publishers whose promotion failed.
317    pub fn failure_names(&self) -> Vec<&str> {
318        self.results
319            .iter()
320            .filter(|r| r.is_failure())
321            .map(|r| r.publisher.as_str())
322            .collect()
323    }
324}
325
326/// Build the `bail!` message for a best-effort multi-target promotion in which
327/// at least one sub-target failed. Names both what was already applied (those
328/// successes stay in place — promotion is idempotent on re-run) and every
329/// sub-target that failed, each with its rendered cause, so an operator sees the
330/// partial state at a glance:
331/// `promoted 2/3 (acme/app, acme/lib); failed on acme/tool: <cause>`.
332pub fn partial_promotion_error(applied: &[String], failed: &[(String, String)]) -> String {
333    let total = applied.len() + failed.len();
334    let applied_list = if applied.is_empty() {
335        "none".to_string()
336    } else {
337        applied.join(", ")
338    };
339    let failed_detail = failed
340        .iter()
341        .map(|(target, cause)| format!("{target}: {cause}"))
342        .collect::<Vec<_>>()
343        .join("; ");
344    format!(
345        "promoted {}/{total} ({applied_list}); failed on {failed_detail}",
346        applied.len()
347    )
348}
349
350/// The capability a promotion-capable publisher implements.
351///
352/// Each implementer maps canonical track names into its own vocabulary
353/// ([`resolve_track`](Promotable::resolve_track)) and moves an
354/// already-published artifact between tracks ([`promote`](Promotable::promote)).
355/// Implementations must be `Send + Sync` (matching [`Publisher`]) so the verb
356/// can hold them behind trait objects.
357///
358/// [`Publisher`]: crate::publisher::Publisher
359pub trait Promotable: Send + Sync {
360    /// Stable, lowercase identifier (e.g. `"snapcraft"`) — matches the
361    /// publisher's [`crate::publisher::Publisher::name`] so `--publishers`
362    /// selection and summaries use one vocabulary.
363    fn name(&self) -> &str;
364
365    /// Map a canonical track name (see [`CANONICAL_TRACKS`]) into this
366    /// publisher's native track vocabulary. An unrecognized name — including a
367    /// raw native track the operator typed directly — passes through verbatim,
368    /// so promotion is never restricted to the canonical words.
369    fn resolve_track(&self, canonical: &str) -> String;
370
371    /// Move the selected artifact from `req.from` to `req.to` (both already in
372    /// this publisher's native vocabulary). Honors `req.dry_run` by resolving
373    /// and printing the plan without running any external command.
374    fn promote(&self, req: &PromoteRequest) -> anyhow::Result<PromoteOutcome>;
375}
376
377/// Fan out a promotion across `publishers`, resolving the canonical `from`/`to`
378/// into each publisher's native vocabulary and collecting one
379/// [`PromoteOutcome`] each. A publisher that returns `Err` is recorded as
380/// [`PromoteStatus::Failed`] (never aborting the fan-out) so the report
381/// enumerates every publisher's result, mirroring the publish dispatch's
382/// per-publisher-failure discipline. `ctx.is_dry_run()` drives `req.dry_run`.
383pub fn dispatch_promotions(
384    publishers: &[Box<dyn Promotable>],
385    canonical_from: &str,
386    canonical_to: &str,
387    selector: &PromoteSelector,
388    ctx: &Context,
389) -> PromoteReport {
390    let dry_run = ctx.is_dry_run();
391    let mut report = PromoteReport::default();
392    for p in publishers {
393        let from = p.resolve_track(canonical_from);
394        let to = p.resolve_track(canonical_to);
395        let req = PromoteRequest {
396            from: from.clone(),
397            to: to.clone(),
398            selector,
399            dry_run,
400            ctx,
401        };
402        let outcome = match p.promote(&req) {
403            Ok(o) => o,
404            Err(err) => PromoteOutcome::failed(p.name(), from, to, format!("{err:#}")),
405        };
406        report.results.push(outcome);
407    }
408    report
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414
415    /// A fake promotable that maps `prerelease`→`beta` and records requests,
416    /// so the dispatcher's resolve-then-promote wiring can be asserted without
417    /// a real publisher.
418    struct FakePromotable {
419        name: &'static str,
420        behavior: FakeBehavior,
421    }
422
423    enum FakeBehavior {
424        Echo,
425        Fail(&'static str),
426    }
427
428    impl Promotable for FakePromotable {
429        fn name(&self) -> &str {
430            self.name
431        }
432        fn resolve_track(&self, canonical: &str) -> String {
433            match canonical {
434                "prerelease" => "beta".to_string(),
435                other => other.to_string(),
436            }
437        }
438        fn promote(&self, req: &PromoteRequest) -> anyhow::Result<PromoteOutcome> {
439            match self.behavior {
440                FakeBehavior::Echo => Ok(PromoteOutcome::promoted(
441                    self.name,
442                    &req.from,
443                    &req.to,
444                    req.selector.describe(),
445                )),
446                FakeBehavior::Fail(msg) => anyhow::bail!("{msg}"),
447            }
448        }
449    }
450
451    fn fake(name: &'static str, behavior: FakeBehavior) -> Box<dyn Promotable> {
452        Box::new(FakePromotable { name, behavior })
453    }
454
455    #[test]
456    fn is_canonical_pretrack_membership() {
457        assert!(is_canonical_pretrack("prerelease"));
458        assert!(is_canonical_pretrack("candidate"));
459        assert!(is_canonical_pretrack("beta"));
460        assert!(is_canonical_pretrack("edge"));
461        assert!(!is_canonical_pretrack("stable"));
462        assert!(!is_canonical_pretrack("canary"));
463    }
464
465    #[test]
466    fn is_promotion_capable_tracks_the_registry() {
467        assert!(is_promotion_capable("snapcraft"));
468        assert!(is_promotion_capable("npm"));
469        assert!(is_promotion_capable("docker"));
470        assert!(is_promotion_capable("github"));
471        assert!(!is_promotion_capable("cargo"));
472        assert!(!is_promotion_capable("pypi"));
473    }
474
475    #[test]
476    fn selector_describe_is_stable() {
477        assert_eq!(PromoteSelector::Newest.describe(), "newest");
478        assert_eq!(
479            PromoteSelector::Version("1.2.3".into()).describe(),
480            "version 1.2.3"
481        );
482        assert_eq!(
483            PromoteSelector::FromRun {
484                run_id: "abc".into(),
485                report: PublishReport::default(),
486            }
487            .describe(),
488            "run abc"
489        );
490    }
491
492    #[test]
493    fn dispatch_resolves_tracks_into_native_vocabulary() {
494        let ctx = Context::test_fixture();
495        let publishers = vec![fake("fake", FakeBehavior::Echo)];
496        let report = dispatch_promotions(
497            &publishers,
498            "prerelease",
499            "stable",
500            &PromoteSelector::Newest,
501            &ctx,
502        );
503        assert_eq!(report.results.len(), 1);
504        let o = &report.results[0];
505        // `prerelease` resolved to the publisher-native `beta`; `stable` passed through.
506        assert_eq!(o.from, "beta");
507        assert_eq!(o.to, "stable");
508        assert!(matches!(o.status, PromoteStatus::Promoted));
509        assert!(!report.any_failure());
510    }
511
512    #[test]
513    fn dispatch_records_err_as_failed_without_aborting() {
514        let ctx = Context::test_fixture();
515        let publishers = vec![
516            fake("boom", FakeBehavior::Fail("kaboom")),
517            fake("ok", FakeBehavior::Echo),
518        ];
519        let report = dispatch_promotions(
520            &publishers,
521            "candidate",
522            "stable",
523            &PromoteSelector::Version("9.9.9".into()),
524            &ctx,
525        );
526        assert_eq!(report.results.len(), 2, "fan-out continues past a failure");
527        assert!(report.any_failure());
528        assert_eq!(report.failure_names(), vec!["boom"]);
529        let boom = &report.results[0];
530        assert!(matches!(boom.status, PromoteStatus::Failed(ref m) if m.contains("kaboom")));
531    }
532
533    #[test]
534    fn source_label_reflects_selector() {
535        assert_eq!(PromoteSelector::Newest.source_label("edge"), "edge");
536        assert_eq!(
537            PromoteSelector::Version("1.4.0".into()).source_label("edge"),
538            "1.4.0"
539        );
540        assert_eq!(
541            PromoteSelector::FromRun {
542                run_id: "abc".into(),
543                report: PublishReport::default(),
544            }
545            .source_label("edge"),
546            "run abc"
547        );
548    }
549
550    #[test]
551    fn partial_promotion_error_names_applied_and_failed() {
552        let msg = partial_promotion_error(
553            &["acme/app".to_string(), "acme/lib".to_string()],
554            &[("acme/tool".to_string(), "boom".to_string())],
555        );
556        assert_eq!(
557            msg,
558            "promoted 2/3 (acme/app, acme/lib); failed on acme/tool: boom"
559        );
560        let all_failed =
561            partial_promotion_error(&[], &[("acme/only".to_string(), "nope".to_string())]);
562        assert_eq!(all_failed, "promoted 0/1 (none); failed on acme/only: nope");
563    }
564
565    #[test]
566    fn summary_line_renders_each_state() {
567        let promoted = PromoteOutcome::promoted("snapcraft", "candidate", "stable", "revision 42");
568        assert_eq!(
569            promoted.summary_line(),
570            "snapcraft: revision 42 candidate→stable (promoted)"
571        );
572        let dry = PromoteOutcome::dry_run("snapcraft", "candidate", "stable", None);
573        assert_eq!(dry.summary_line(), "snapcraft: candidate→stable (dry-run)");
574        let skipped = PromoteOutcome::skipped(
575            "snapcraft",
576            "candidate",
577            "stable",
578            PromoteSkipReason::NothingToPromote,
579        );
580        assert_eq!(
581            skipped.summary_line(),
582            "snapcraft: candidate→stable (skipped (nothing to promote))"
583        );
584        let failed = PromoteOutcome::failed("snapcraft", "candidate", "stable", "boom");
585        assert_eq!(
586            failed.summary_line(),
587            "snapcraft: candidate→stable (failed: boom)"
588        );
589    }
590
591    #[test]
592    fn is_failure_only_true_for_failed() {
593        assert!(PromoteOutcome::failed("p", "a", "b", "e").is_failure());
594        assert!(!PromoteOutcome::promoted("p", "a", "b", "w").is_failure());
595        assert!(!PromoteOutcome::dry_run("p", "a", "b", None).is_failure());
596        assert!(
597            !PromoteOutcome::skipped("p", "a", "b", PromoteSkipReason::Unsupported).is_failure()
598        );
599    }
600}