use anodizer_core::context::Context;
use anodizer_core::log::StageLogger;
use anodizer_core::stage::Stage;
use anyhow::Result;
use colored::Colorize;
mod builders;
mod config_loader;
mod monorepo;
pub use anodizer_core::hooks::run_hooks;
pub(crate) use builders::build_publish_only_pipeline;
pub use builders::{
build_announce_pipeline, build_merge_pipeline, build_publish_pipeline, build_release_pipeline,
build_split_pipeline,
};
pub(crate) use config_loader::CARGO_TOML_FALLBACK_WARNING;
pub use config_loader::{
emit_config_advisories, emit_config_advisories_filtered, find_config, find_config_in,
find_config_with_logger, load_config, load_config_logged, load_repo_config,
};
pub struct Pipeline {
stages: Vec<Box<dyn Stage>>,
expects_binaries: bool,
}
impl Pipeline {
pub fn new() -> Self {
Self {
stages: vec![],
expects_binaries: false,
}
}
pub fn add(&mut self, stage: Box<dyn Stage>) {
self.stages.push(stage);
}
pub(crate) fn expect_binaries(&mut self) {
self.expects_binaries = true;
}
pub fn stage_names(&self) -> Vec<&str> {
self.stages.iter().map(|s| s.name()).collect()
}
pub fn run(&self, ctx: &mut Context, log: &StageLogger) -> Result<()> {
let outcome = self.run_inner(ctx, log);
anodizer_stage_announce::emit_summary(ctx);
outcome
}
fn run_inner(&self, ctx: &mut Context, log: &StageLogger) -> Result<()> {
const BINARY_DEPENDENT_STAGES: &[&str] = &[
"upx",
"archive",
"makeself",
"appimage",
"nfpm",
"snapcraft",
"appbundle",
"dmg",
"msi",
"pkg",
"nsis",
"flatpak",
"notarize",
"srpm",
];
let mut has_binaries = ctx.artifacts.all().iter().any(|a| {
matches!(
a.kind,
anodizer_core::artifact::ArtifactKind::Binary
| anodizer_core::artifact::ArtifactKind::UploadableBinary
| anodizer_core::artifact::ArtifactKind::UniversalBinary
)
});
let build_in_pipeline =
self.stages.iter().any(|s| s.name() == "build") && !ctx.should_skip("build");
if self.expects_binaries && !build_in_pipeline {
anodizer_core::binary_artifact_guard::check(
&ctx.config,
&ctx.artifacts,
&ctx.options.selected_crates,
None,
)?;
}
let mut pending_skips: Vec<(&str, bool)> = Vec::new();
let show_skipped = ctx.options.show_skipped;
for stage in &self.stages {
let name = stage.name();
if ctx.should_skip(name) {
pending_skips.push((name, false));
continue;
}
if BINARY_DEPENDENT_STAGES.contains(&name) && !has_binaries {
pending_skips.push((name, true));
continue;
}
flush_skipped(log, &mut pending_skips, show_skipped);
if name == "release"
&& let Err(e) = write_pre_release_metadata(ctx)
{
log.warn(&format!("failed to write pre-release metadata: {}", e));
}
let _section = log.group(name);
match stage.run(ctx) {
Ok(()) => {
if name == "build" {
has_binaries = ctx.artifacts.all().iter().any(|a| {
matches!(
a.kind,
anodizer_core::artifact::ArtifactKind::Binary
| anodizer_core::artifact::ArtifactKind::UploadableBinary
| anodizer_core::artifact::ArtifactKind::UniversalBinary
)
});
if self.expects_binaries {
anodizer_core::binary_artifact_guard::check(
&ctx.config,
&ctx.artifacts,
&ctx.options.selected_crates,
ctx.built_crate_names(),
)?;
}
}
if name == "changelog" {
ctx.populate_release_notes_var();
}
}
Err(e) => {
log.error(&format!("{name} failed: {e}"));
return Err(e);
}
}
}
flush_skipped(log, &mut pending_skips, show_skipped);
let skips = ctx.skip_memento.drain();
if !skips.is_empty() {
let noun = if skips.len() == 1 {
"intentional skip"
} else {
"intentional skips"
};
log.status(&format!("{} {}:", skips.len(), noun.yellow()));
for ev in &skips {
log.status(&format!(
" {} [{}] {} — {}",
"\u{21b3}".yellow(),
ev.stage.bold(),
ev.label,
ev.reason
));
}
}
Ok(())
}
}
fn flush_skipped(log: &StageLogger, pending: &mut Vec<(&str, bool)>, show: bool) {
if pending.is_empty() {
return;
}
if !show {
pending.clear();
return;
}
let join = |no_binaries: bool| -> Option<String> {
let names: Vec<&str> = pending
.iter()
.filter(|(_, nb)| *nb == no_binaries)
.map(|(n, _)| *n)
.collect();
(!names.is_empty()).then(|| names.join(", "))
};
let _indent = anodizer_core::log::indent_one_level();
let key_width = "skipped".len();
if let Some(names) = join(false) {
log.kv("skipped", &names, key_width);
}
if let Some(names) = join(true) {
log.kv("skipped", &format!("{names} (no binaries)"), key_width);
}
pending.clear();
}
fn write_pre_release_metadata(ctx: &mut anodizer_core::context::Context) -> anyhow::Result<()> {
let dist = &ctx.config.dist;
std::fs::create_dir_all(dist)?;
let tag = ctx.template_vars().get("Tag").cloned().unwrap_or_default();
let version = ctx.version();
let commit = ctx
.template_vars()
.get("FullCommit")
.cloned()
.unwrap_or_default();
let metadata = serde_json::json!({
"project_name": ctx.config.project_name,
"tag": tag,
"version": version,
"commit": commit,
});
std::fs::write(
dist.join(anodizer_core::dist::METADATA_JSON),
serde_json::to_string_pretty(&metadata)?,
)?;
let artifacts_json = ctx.artifacts.to_artifacts_json()?;
std::fs::write(
dist.join(anodizer_core::dist::ARTIFACTS_JSON),
serde_json::to_string_pretty(&artifacts_json)?,
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use anodizer_core::artifact::{Artifact, ArtifactKind};
use anodizer_core::config::{Config, CrateConfig, DockerV2Config};
use anodizer_core::context::{Context, ContextOptions};
use std::collections::HashMap;
use std::path::PathBuf;
fn isolate_dist(ctx: &mut Context) -> tempfile::TempDir {
let tmp = tempfile::TempDir::new().expect("tempdir");
ctx.config.dist = tmp.path().to_path_buf();
tmp
}
struct NoopBuildStage;
impl Stage for NoopBuildStage {
fn name(&self) -> &str {
"build"
}
fn run(&self, _ctx: &mut Context) -> Result<()> {
Ok(())
}
}
fn binary_surface_config() -> Config {
Config {
crates: vec![CrateConfig {
name: "svc".to_string(),
dockers_v2: Some(vec![DockerV2Config::default()]),
..CrateConfig::default()
}],
..Config::default()
}
}
fn source_artifact() -> Artifact {
Artifact {
kind: ArtifactKind::SourceArchive,
path: PathBuf::from("dist/svc.tar.gz"),
name: "svc.tar.gz".to_string(),
target: None,
crate_name: "svc".to_string(),
metadata: HashMap::new(),
size: None,
}
}
#[test]
fn skip_build_still_runs_binary_presence_guard() {
let mut p = Pipeline::new();
p.add(Box::new(NoopBuildStage));
p.expect_binaries();
let opts = ContextOptions {
skip_stages: vec!["build".to_string()],
..Default::default()
};
let mut ctx = Context::new(binary_surface_config(), opts);
ctx.artifacts.add(source_artifact());
let _dist_guard = isolate_dist(&mut ctx);
let log = ctx.logger("pipeline-test");
let err = p
.run(&mut ctx, &log)
.expect_err("guard must fire with --skip=build and no binary");
let msg = err.to_string();
assert!(msg.contains("crate 'svc'"), "{msg}");
assert!(msg.contains("no binary artifacts"), "{msg}");
}
#[test]
fn skip_build_passes_guard_when_prebuilt_binary_present() {
let mut p = Pipeline::new();
p.add(Box::new(NoopBuildStage));
p.expect_binaries();
let opts = ContextOptions {
skip_stages: vec!["build".to_string()],
..Default::default()
};
let mut ctx = Context::new(binary_surface_config(), opts);
ctx.artifacts.add(Artifact {
kind: ArtifactKind::Binary,
path: PathBuf::from("dist/svc"),
name: "svc".to_string(),
target: Some("x86_64-unknown-linux-gnu".to_string()),
crate_name: "svc".to_string(),
metadata: HashMap::new(),
size: None,
});
let _dist_guard = isolate_dist(&mut ctx);
let log = ctx.logger("pipeline-test");
p.run(&mut ctx, &log)
.expect("prebuilt binary satisfies the guard under --skip=build");
}
struct FailingGuardStage;
impl Stage for FailingGuardStage {
fn name(&self) -> &str {
"prepublish-guard"
}
fn run(&self, _ctx: &mut Context) -> Result<()> {
anyhow::bail!("prepublish-guard: broken template")
}
}
struct SpyPublishStage(std::sync::Arc<std::sync::atomic::AtomicBool>);
impl Stage for SpyPublishStage {
fn name(&self) -> &str {
"publish"
}
fn run(&self, _ctx: &mut Context) -> Result<()> {
self.0.store(true, std::sync::atomic::Ordering::SeqCst);
Ok(())
}
}
#[test]
fn failing_prepublish_guard_aborts_before_publish_runs() {
let published = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let mut p = Pipeline::new();
p.add(Box::new(FailingGuardStage));
p.add(Box::new(SpyPublishStage(published.clone())));
let mut ctx = Context::new(Config::default(), ContextOptions::default());
let _dist_guard = isolate_dist(&mut ctx);
let log = ctx.logger("pipeline-test");
let err = p
.run(&mut ctx, &log)
.expect_err("a failing prepublish-guard must abort the pipeline");
assert!(
err.to_string().contains("broken template"),
"abort surfaces the guard's error: {err}"
);
assert!(
!published.load(std::sync::atomic::Ordering::SeqCst),
"PublishStage must NOT run after the guard fails"
);
}
struct NoopNamedStage(&'static str);
impl Stage for NoopNamedStage {
fn name(&self) -> &str {
self.0
}
fn run(&self, _ctx: &mut Context) -> Result<()> {
Ok(())
}
}
struct ChattyStage;
impl Stage for ChattyStage {
fn name(&self) -> &str {
"beta"
}
fn run(&self, ctx: &mut Context) -> Result<()> {
ctx.logger("beta").status("beta body line");
Ok(())
}
}
#[test]
fn buffered_skips_flush_before_next_stage_and_survive_its_failure() {
use anodizer_core::log::LogCapture;
let mut p = Pipeline::new();
p.add(Box::new(NoopNamedStage("alpha")));
p.add(Box::new(ChattyStage));
let opts = ContextOptions {
skip_stages: vec!["alpha".to_string()],
show_skipped: true,
..Default::default()
};
let mut ctx = Context::new(Config::default(), opts);
let capture = LogCapture::new();
ctx.with_log_capture(capture.clone());
let _dist_guard = isolate_dist(&mut ctx);
let log = ctx.logger("pipeline-test");
p.run(&mut ctx, &log).expect("chatty stage succeeds");
let msgs: Vec<String> = capture.all_messages().into_iter().map(|(_, m)| m).collect();
let skip_idx = msgs
.iter()
.position(|m| m == "skipped = alpha")
.unwrap_or_else(|| panic!("consolidated skip row missing: {msgs:?}"));
let body_idx = msgs
.iter()
.position(|m| m == "beta body line")
.unwrap_or_else(|| panic!("running stage body line missing: {msgs:?}"));
assert!(
skip_idx < body_idx,
"skip row must flush before the next stage's output: {msgs:?}"
);
let mut p = Pipeline::new();
p.add(Box::new(NoopNamedStage("alpha")));
p.add(Box::new(FailingGuardStage));
let opts = ContextOptions {
skip_stages: vec!["alpha".to_string()],
show_skipped: true,
..Default::default()
};
let mut ctx = Context::new(Config::default(), opts);
let capture = LogCapture::new();
ctx.with_log_capture(capture.clone());
let _dist_guard = isolate_dist(&mut ctx);
let log = ctx.logger("pipeline-test");
p.run(&mut ctx, &log)
.expect_err("failing stage must abort the pipeline");
let msgs: Vec<String> = capture.all_messages().into_iter().map(|(_, m)| m).collect();
let skip_idx = msgs
.iter()
.position(|m| m == "skipped = alpha")
.unwrap_or_else(|| panic!("consolidated skip row missing: {msgs:?}"));
let fail_idx = msgs
.iter()
.position(|m| m.starts_with("prepublish-guard failed:"))
.unwrap_or_else(|| panic!("failure line missing: {msgs:?}"));
assert!(
skip_idx < fail_idx,
"skip row must flush before the failing stage's error line: {msgs:?}"
);
}
}