use anodizer_core::context::{Context, ContextOptions};
use anodizer_core::log::{StageLogger, Verbosity};
use anodizer_core::promote::{
DEFAULT_FROM_TRACK, PROMOTABLE_PUBLISHERS, Promotable, PromoteSelector, dispatch_promotions,
is_promotion_capable,
};
use anyhow::{Result, bail};
use std::collections::HashSet;
use std::path::PathBuf;
pub struct PromoteOpts {
pub to: String,
pub from: Option<String>,
pub publishers: Vec<String>,
pub version: Option<String>,
pub from_run: Option<String>,
pub dry_run: bool,
pub config_override: Option<PathBuf>,
pub verbose: bool,
pub debug: bool,
pub quiet: bool,
}
pub fn run(opts: PromoteOpts) -> Result<()> {
let log = StageLogger::new(
"promote",
Verbosity::from_flags(opts.quiet, opts.verbose, opts.debug),
);
let config_path =
crate::pipeline::find_config_with_logger(opts.config_override.as_deref(), Some(&log))?;
let config = crate::pipeline::load_config(&config_path)?;
let ctx_opts = ContextOptions {
dry_run: opts.dry_run,
quiet: opts.quiet,
verbose: opts.verbose,
debug: opts.debug,
..Default::default()
};
let ctx = Context::new(config, ctx_opts);
let selector = match (&opts.version, &opts.from_run) {
(Some(v), _) => PromoteSelector::Version(v.clone()),
(_, Some(run_id)) => {
let report = anodizer_stage_publish::load_prior_report(&ctx, run_id)?;
PromoteSelector::FromRun {
run_id: run_id.clone(),
report,
}
}
_ => PromoteSelector::Newest,
};
let canonical_from = opts.from.as_deref().unwrap_or(DEFAULT_FROM_TRACK);
let canonical_to = opts.to.as_str();
let selected = select_publishers(&ctx, &opts.publishers)?;
if selected.is_empty() {
log.status("no promotion-capable publishers configured; nothing to promote");
return Ok(());
}
if !opts.dry_run {
for p in &selected {
match p.name() {
"snapcraft" => anodizer_stage_snapcraft::snapcraft_promote_preflight()?,
"npm" => anodizer_stage_publish::npm_promote_preflight(&ctx)?,
"docker" => anodizer_stage_docker::docker_promote_preflight()?,
"github" => anodizer_stage_release::github_promote_preflight(&ctx)?,
_ => {}
}
}
}
let report = dispatch_promotions(&selected, canonical_from, canonical_to, &selector, &ctx);
for result in &report.results {
log.status(&result.summary_line());
}
if report.any_failure() {
bail!(
"{} publisher(s) failed to promote: {}",
report.failure_names().len(),
report.failure_names().join(", ")
);
}
Ok(())
}
fn configured_promotable(ctx: &Context) -> Vec<Box<dyn Promotable>> {
let mut out: Vec<Box<dyn Promotable>> = Vec::new();
let crates = ctx.config.crate_universe();
let has_snap = crates
.iter()
.any(|c| c.snapcrafts.as_ref().is_some_and(|s| !s.is_empty()));
if has_snap {
out.push(Box::new(anodizer_stage_snapcraft::SnapcraftPromoter));
}
if ctx.config.npms.as_ref().is_some_and(|n| !n.is_empty()) {
out.push(Box::new(anodizer_stage_publish::NpmPromoter::new(
npm_pre_dist_tag(ctx),
)));
}
let has_docker = crates
.iter()
.any(|c| c.dockers_v2.as_ref().is_some_and(|d| !d.is_empty()));
if has_docker {
out.push(Box::new(anodizer_stage_docker::DockerPromoter));
}
let has_github_release = crates
.iter()
.any(|c| c.release.as_ref().is_some_and(|r| r.github.is_some()));
if has_github_release {
out.push(Box::new(anodizer_stage_release::GithubReleasePromoter));
}
out
}
fn npm_pre_dist_tag(ctx: &Context) -> String {
ctx.config
.npms
.iter()
.flatten()
.find_map(|cfg| {
cfg.tag
.as_deref()
.map(str::trim)
.filter(|t| !t.is_empty() && !t.eq_ignore_ascii_case("latest"))
.map(str::to_string)
})
.unwrap_or_else(|| "next".to_string())
}
fn select_publishers(ctx: &Context, requested: &[String]) -> Result<Vec<Box<dyn Promotable>>> {
let all = configured_promotable(ctx);
if requested.is_empty() {
return Ok(all);
}
let configured: HashSet<&str> = all.iter().map(|p| p.name()).collect();
for name in requested {
if !is_promotion_capable(name) {
bail!(
"publisher '{}' does not support promotion (promotable: {})",
name,
PROMOTABLE_PUBLISHERS.join(", ")
);
}
if !configured.contains(name.as_str()) {
bail!(
"publisher '{}' supports promotion but is not configured in this project",
name
);
}
}
let keep: HashSet<&str> = requested.iter().map(String::as_str).collect();
Ok(all
.into_iter()
.filter(|p| keep.contains(p.name()))
.collect())
}
#[cfg(test)]
mod tests;