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}