use anodizer_core::artifact::{Artifact, ArtifactKind};
use anodizer_core::config::PublisherConfig;
use anodizer_core::log::StageLogger;
use anodizer_core::pipe_skip::SkipMemento;
use anodizer_core::template::{self, TemplateVars};
use anyhow::{Context as _, Result};
fn split_shellwords(s: &str) -> Vec<String> {
let mut args = Vec::new();
let mut current = String::new();
let mut in_single = false;
let mut in_double = false;
let mut escape_next = false;
for ch in s.chars() {
if escape_next {
current.push(ch);
escape_next = false;
continue;
}
match ch {
'\\' if !in_single => escape_next = true,
'\'' if !in_double => in_single = !in_single,
'"' if !in_single => in_double = !in_double,
' ' | '\t' if !in_single && !in_double => {
if !current.is_empty() {
args.push(std::mem::take(&mut current));
}
}
_ => current.push(ch),
}
}
if !current.is_empty() {
args.push(current);
}
args
}
pub fn run_publishers(
publishers: &[PublisherConfig],
artifacts: &[Artifact],
base_vars: &TemplateVars,
dry_run: bool,
log: &StageLogger,
parallelism: usize,
skip_memento: Option<&SkipMemento>,
) -> Result<()> {
let parallelism = parallelism.max(1);
for (i, publisher) in publishers.iter().enumerate() {
let default_label = format!("publisher[{}]", i);
let label = publisher.name.as_deref().unwrap_or(&default_label);
if let Some(ref d) = publisher.skip {
let off = d
.try_evaluates_to_true(|tmpl| template::render(tmpl, base_vars))
.with_context(|| format!("[publisher] render skip template for {}", label))?;
if off {
log.verbose(&format!(
"[publisher] skipping {} -- skip=true (template)",
label
));
if let Some(sm) = skip_memento {
sm.remember("publisher", label, "skipped by template");
}
continue;
}
}
if publisher.cmd.is_empty() {
log.verbose(&format!("[publisher] skipping {} -- empty cmd", label));
if let Some(sm) = skip_memento {
sm.remember("publisher", label, "empty cmd");
}
continue;
}
let mut extra_artifacts: Vec<Artifact> = Vec::new();
if let Some(ref extra_files) = publisher.extra_files {
let rendered_specs: Vec<anodizer_core::config::ExtraFileSpec> = extra_files
.iter()
.map(|ef| {
let raw_glob = ef.glob();
let glob = template::render(raw_glob, base_vars)
.unwrap_or_else(|_| raw_glob.to_string());
let allow_empty = ef.allow_empty();
match (ef.name_template(), allow_empty) {
(None, false) => anodizer_core::config::ExtraFileSpec::Glob(glob),
(name_template, allow_empty) => {
anodizer_core::config::ExtraFileSpec::Detailed {
glob,
name_template: name_template.map(str::to_owned),
allow_empty,
}
}
}
})
.collect();
let resolved = anodizer_core::extrafiles::resolve(&rendered_specs, log)?;
for r in resolved {
let name = if let Some(name_tmpl) = r.name_template.as_deref() {
template::render(name_tmpl, base_vars).unwrap_or_else(|_| name_tmpl.to_string())
} else {
r.path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default()
};
extra_artifacts.push(Artifact {
kind: ArtifactKind::UploadableFile,
name,
path: r.path,
target: None,
crate_name: String::new(),
metadata: std::collections::HashMap::new(),
size: None,
});
}
}
let _tpl_dir_guard;
if let Some(ref tpl_specs) = publisher.templated_extra_files {
if !tpl_specs.is_empty() {
let tpl_dir = tempfile::TempDir::new()
.context("publisher: create temp dir for templated files")?;
let rendered =
anodizer_core::templated_files::process_templated_extra_files_with_vars(
tpl_specs,
base_vars,
tpl_dir.path(),
&format!("publisher[{}]", label),
)?;
for (path, name) in rendered {
extra_artifacts.push(Artifact {
kind: ArtifactKind::UploadableFile,
name,
path,
target: None,
crate_name: String::new(),
metadata: std::collections::HashMap::new(),
size: None,
});
}
_tpl_dir_guard = Some(tpl_dir);
} else {
_tpl_dir_guard = None;
}
} else {
_tpl_dir_guard = None;
}
let matching: Vec<&Artifact> = artifacts
.iter()
.filter(|a| matches_publisher_filter(a, publisher))
.chain(extra_artifacts.iter())
.collect();
if matching.is_empty() {
log.verbose(&format!("[publisher] {} -- no matching artifacts", label));
if let Some(sm) = skip_memento {
sm.remember("publisher", label, "no matching artifacts");
}
continue;
}
let run_for_artifact = |artifact: &&Artifact| -> Result<()> {
let (rendered_cmd, rendered_args) = build_publisher_command(
&publisher.cmd,
publisher.args.as_deref(),
artifact,
base_vars,
)
.with_context(|| format!("failed to render publisher command for {}", label))?;
if dry_run {
let full_cmd = format_command_line(&rendered_cmd, &rendered_args);
log.status(&format!("[dry-run] [publisher] {} -- {}", label, full_cmd));
} else {
log.status(&format!(
"[publisher] {} -- running for {}",
label,
artifact.path.display()
));
let full_cmd = format_command_line(&rendered_cmd, &rendered_args);
let shell_args = split_shellwords(&full_cmd);
if shell_args.is_empty() {
anyhow::bail!("publisher: empty command after parsing: {}", full_cmd);
}
let mut cmd = anodizer_core::user_command::whitelisted(&shell_args)?;
if let Some(ref dir) = publisher.dir {
let rendered_dir =
template::render(dir, base_vars).unwrap_or_else(|_| dir.clone());
cmd.current_dir(rendered_dir);
}
if let Some(ref env_list) = publisher.env {
let rendered = anodizer_core::config::render_env_entries(env_list, |v| {
template::render(v, base_vars)
})
.with_context(|| "publisher env: parse and render entries")?;
for (k, v) in &rendered {
cmd.env(k, v);
}
}
let output = cmd
.output()
.with_context(|| format!("failed to spawn publisher command: {}", full_cmd))?;
log.check_output(output, &format!("publisher {}", label))?;
}
Ok(())
};
if parallelism <= 1 || dry_run {
for artifact in &matching {
run_for_artifact(artifact)?;
}
} else {
use std::sync::Mutex;
let errors: Mutex<Vec<anyhow::Error>> = Mutex::new(Vec::new());
for chunk in matching.chunks(parallelism) {
std::thread::scope(|s| {
for artifact in chunk {
s.spawn(|| {
if let Err(e) = run_for_artifact(artifact) {
errors
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(e);
}
});
}
});
let errs = errors
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if !errs.is_empty() {
break;
}
}
let mut errs = errors
.into_inner()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if !errs.is_empty() {
for e in errs.iter().skip(1) {
log.status(&format!("[publisher] {} -- additional error: {}", label, e));
}
return Err(errs.remove(0));
}
}
}
Ok(())
}
pub fn matches_publisher_filter(artifact: &Artifact, publisher: &PublisherConfig) -> bool {
if let Some(ref ids) = publisher.ids {
let artifact_id = artifact.metadata.get("id");
match artifact_id {
Some(id) if ids.iter().any(|allowed| allowed == id) => {}
_ => return false,
}
}
if let Some(ref types) = publisher.artifact_types {
return types.iter().any(|t| t == artifact.kind.as_str());
}
let in_default_set = matches!(
artifact.kind,
ArtifactKind::Archive
| ArtifactKind::UploadableFile
| ArtifactKind::LinuxPackage
| ArtifactKind::UploadableBinary
| ArtifactKind::DockerImage
| ArtifactKind::DockerManifest
| ArtifactKind::DockerImageV2
| ArtifactKind::SourceArchive
| ArtifactKind::Sbom
);
if in_default_set {
return true;
}
if artifact.kind == ArtifactKind::Checksum {
return publisher.checksum.unwrap_or(false);
}
if artifact.kind == ArtifactKind::Metadata {
return publisher.meta.unwrap_or(false);
}
if matches!(
artifact.kind,
ArtifactKind::Signature | ArtifactKind::Certificate
) {
if anodizer_core::artifact::is_binary_sign_output(artifact) {
return false;
}
return publisher.signature.unwrap_or(false);
}
false
}
pub fn build_publisher_command(
cmd: &str,
args: Option<&[String]>,
artifact: &Artifact,
base_vars: &TemplateVars,
) -> Result<(String, Vec<String>)> {
let mut vars = base_vars.clone();
vars.set("ArtifactPath", &artifact.path.to_string_lossy());
let artifact_name = artifact
.path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
vars.set("ArtifactName", &artifact_name);
vars.set("ArtifactExt", &artifact.ext());
vars.set("ArtifactKind", artifact.kind.as_str());
vars.set(
"ArtifactID",
artifact
.metadata
.get("id")
.map(|s| s.as_str())
.unwrap_or(""),
);
if let Some(ref target) = artifact.target {
let (os, arch) = anodizer_core::target::map_target(target);
vars.set("Os", &os);
vars.set("Arch", &arch);
vars.set("Target", target);
} else {
vars.set("Os", "");
vars.set("Arch", "");
vars.set("Target", "");
}
for (k, v) in &artifact.metadata {
vars.set(k, v);
}
let rendered_cmd = template::render(cmd, &vars)
.with_context(|| format!("failed to render publisher cmd: {}", cmd))?;
let rendered_args = match args {
Some(args) => {
let mut out = Vec::with_capacity(args.len());
for arg in args {
let rendered = template::render(arg, &vars)
.with_context(|| format!("failed to render publisher arg: {}", arg))?;
out.push(rendered);
}
out
}
None => Vec::new(),
};
Ok((rendered_cmd, rendered_args))
}
fn format_command_line(cmd: &str, args: &[String]) -> String {
if args.is_empty() {
cmd.to_string()
} else {
format!("{} {}", cmd, args.join(" "))
}
}
#[cfg(test)]
mod tests {
use super::*;
use anodizer_core::config::StringOrBool;
use std::collections::HashMap;
use std::path::PathBuf;
fn make_artifact(kind: ArtifactKind, path: &str, id: Option<&str>) -> Artifact {
let mut metadata = HashMap::new();
if let Some(id_val) = id {
metadata.insert("id".to_string(), id_val.to_string());
}
Artifact {
kind,
name: String::new(),
path: PathBuf::from(path),
target: Some("x86_64-unknown-linux-gnu".to_string()),
crate_name: "myapp".to_string(),
metadata,
size: None,
}
}
fn make_publisher(
cmd: &str,
ids: Option<Vec<&str>>,
artifact_types: Option<Vec<&str>>,
) -> PublisherConfig {
PublisherConfig {
name: Some("test-publisher".to_string()),
cmd: cmd.to_string(),
args: None,
ids: ids.map(|v| v.into_iter().map(|s| s.to_string()).collect()),
artifact_types: artifact_types.map(|v| v.into_iter().map(|s| s.to_string()).collect()),
env: None,
dir: None,
skip: None,
checksum: None,
signature: None,
meta: None,
extra_files: None,
templated_extra_files: None,
}
}
fn base_vars() -> TemplateVars {
let mut vars = TemplateVars::new();
vars.set("ProjectName", "myapp");
vars.set("Version", "1.0.0");
vars
}
fn test_logger() -> StageLogger {
use anodizer_core::log::Verbosity;
StageLogger::new("test", Verbosity::Normal)
}
#[test]
fn test_filter_matches_default_curated_list() {
let publisher = make_publisher("echo", None, None);
let binary = make_artifact(ArtifactKind::Binary, "dist/myapp", None);
let uploadable_binary = make_artifact(
ArtifactKind::UploadableBinary,
"dist/myapp-linux-amd64",
None,
);
let archive = make_artifact(ArtifactKind::Archive, "dist/myapp.tar.gz", None);
let checksum = make_artifact(ArtifactKind::Checksum, "dist/checksums.sha256", None);
let metadata = make_artifact(ArtifactKind::Metadata, "dist/metadata.json", None);
let signature = make_artifact(ArtifactKind::Signature, "dist/myapp.tar.gz.sig", None);
assert!(matches_publisher_filter(&archive, &publisher));
assert!(matches_publisher_filter(&uploadable_binary, &publisher));
assert!(
!matches_publisher_filter(&binary, &publisher),
"raw Binary kind is not in the default curated list"
);
assert!(
!matches_publisher_filter(&checksum, &publisher),
"checksums excluded by default"
);
assert!(
!matches_publisher_filter(&metadata, &publisher),
"metadata artifacts excluded by default"
);
assert!(
!matches_publisher_filter(&signature, &publisher),
"signatures excluded by default"
);
let mut pub_with_checksums = make_publisher("echo", None, None);
pub_with_checksums.checksum = Some(true);
assert!(matches_publisher_filter(&checksum, &pub_with_checksums));
let mut pub_with_meta = make_publisher("echo", None, None);
pub_with_meta.meta = Some(true);
assert!(matches_publisher_filter(&metadata, &pub_with_meta));
let mut pub_with_sig = make_publisher("echo", None, None);
pub_with_sig.signature = Some(true);
assert!(matches_publisher_filter(&signature, &pub_with_sig));
}
#[test]
fn test_filter_excludes_binary_sign_outputs_even_with_signature_opt_in() {
let mut binary_sign_sig =
make_artifact(ArtifactKind::Signature, "dist/myapp_linux_amd64", None);
binary_sign_sig
.metadata
.insert("binary_sign".to_string(), "true".to_string());
let mut binary_sign_cert = make_artifact(
ArtifactKind::Certificate,
"dist/myapp_linux_amd64.pem",
None,
);
binary_sign_cert
.metadata
.insert("binary_sign".to_string(), "true".to_string());
let archive_sig = make_artifact(ArtifactKind::Signature, "dist/myapp.tar.gz.sig", None);
let mut pub_with_sig = make_publisher("echo", None, None);
pub_with_sig.signature = Some(true);
assert!(
!matches_publisher_filter(&binary_sign_sig, &pub_with_sig),
"binary-sign Signature must be excluded even with signature opt-in"
);
assert!(
!matches_publisher_filter(&binary_sign_cert, &pub_with_sig),
"binary-sign Certificate must be excluded even with signature opt-in"
);
assert!(
matches_publisher_filter(&archive_sig, &pub_with_sig),
"archive-sign Signature must still pass when signature opt-in is set"
);
}
#[test]
fn test_filter_by_ids() {
let publisher = make_publisher("echo", Some(vec!["linux-amd64"]), None);
let matching = make_artifact(ArtifactKind::Archive, "dist/a.tar.gz", Some("linux-amd64"));
let non_matching =
make_artifact(ArtifactKind::Archive, "dist/b.tar.gz", Some("darwin-arm64"));
let no_id = make_artifact(ArtifactKind::Archive, "dist/c.tar.gz", None);
assert!(matches_publisher_filter(&matching, &publisher));
assert!(!matches_publisher_filter(&non_matching, &publisher));
assert!(
!matches_publisher_filter(&no_id, &publisher),
"artifact without id should not match when ids filter is set"
);
}
#[test]
fn test_filter_by_ids_uses_artifact_meta_default() {
let mut metadata = HashMap::new();
metadata.insert("binary".to_string(), "myapp".to_string());
metadata.insert("id".to_string(), "myapp".to_string()); let artifact = Artifact {
kind: ArtifactKind::UploadableBinary,
name: "myapp".to_string(),
path: PathBuf::from("dist/myapp"),
target: Some("x86_64-unknown-linux-gnu".to_string()),
crate_name: "myapp".to_string(),
metadata,
size: None,
};
let publisher = make_publisher("echo", Some(vec!["myapp"]), None);
assert!(
matches_publisher_filter(&artifact, &publisher),
"default-populated id (= binary name) must pass an ids: [<binary>] filter"
);
}
#[test]
fn test_filter_by_artifact_types() {
let publisher = make_publisher("echo", None, Some(vec!["archive", "checksum"]));
let archive = make_artifact(ArtifactKind::Archive, "dist/a.tar.gz", None);
let checksum = make_artifact(ArtifactKind::Checksum, "dist/checksums.sha256", None);
let binary = make_artifact(ArtifactKind::Binary, "dist/myapp", None);
let docker = make_artifact(ArtifactKind::DockerImage, "myapp:latest", None);
assert!(matches_publisher_filter(&archive, &publisher));
assert!(matches_publisher_filter(&checksum, &publisher));
assert!(!matches_publisher_filter(&binary, &publisher));
assert!(!matches_publisher_filter(&docker, &publisher));
}
#[test]
fn test_filter_by_ids_and_artifact_types_combined() {
let publisher = make_publisher("echo", Some(vec!["linux-amd64"]), Some(vec!["archive"]));
let good = make_artifact(ArtifactKind::Archive, "dist/a.tar.gz", Some("linux-amd64"));
assert!(matches_publisher_filter(&good, &publisher));
let wrong_id = make_artifact(ArtifactKind::Archive, "dist/b.tar.gz", Some("darwin-arm64"));
assert!(!matches_publisher_filter(&wrong_id, &publisher));
let wrong_type = make_artifact(ArtifactKind::Binary, "dist/myapp", Some("linux-amd64"));
assert!(!matches_publisher_filter(&wrong_type, &publisher));
}
#[test]
fn test_build_command_renders_artifact_vars() {
let vars = base_vars();
let artifact = make_artifact(ArtifactKind::Archive, "/dist/myapp-1.0.0.tar.gz", None);
let (cmd, args) = build_publisher_command(
"curl -F 'file=@{{ ArtifactPath }}'",
Some(&[
"--header".to_string(),
"X-Name: {{ ArtifactName }}".to_string(),
]),
&artifact,
&vars,
)
.unwrap();
assert_eq!(cmd, "curl -F 'file=@/dist/myapp-1.0.0.tar.gz'");
assert_eq!(args.len(), 2);
assert_eq!(args[0], "--header");
assert_eq!(args[1], "X-Name: myapp-1.0.0.tar.gz");
}
#[test]
fn test_build_command_renders_project_vars() {
let vars = base_vars();
let artifact = make_artifact(ArtifactKind::Binary, "/dist/myapp", None);
let (cmd, _) = build_publisher_command(
"upload --project {{ ProjectName }} --version {{ Version }} {{ ArtifactPath }}",
None,
&artifact,
&vars,
)
.unwrap();
assert_eq!(cmd, "upload --project myapp --version 1.0.0 /dist/myapp");
}
#[test]
fn test_build_command_renders_os_arch_target_vars() {
let vars = base_vars();
let artifact = make_artifact(ArtifactKind::Archive, "/dist/myapp.tar.gz", None);
let (cmd, _) = build_publisher_command(
"deploy --os {{ Os }} --arch {{ Arch }} --target {{ Target }}",
None,
&artifact,
&vars,
)
.unwrap();
assert_eq!(
cmd,
"deploy --os linux --arch amd64 --target x86_64-unknown-linux-gnu"
);
}
#[test]
fn test_build_command_renders_artifact_kind() {
let vars = base_vars();
let artifact = make_artifact(ArtifactKind::LinuxPackage, "/dist/myapp.deb", None);
let (cmd, _) =
build_publisher_command("echo {{ ArtifactKind }}", None, &artifact, &vars).unwrap();
assert_eq!(cmd, "echo linux_package");
}
#[test]
fn test_dry_run_does_not_execute() {
let vars = base_vars();
let artifacts = vec![make_artifact(
ArtifactKind::Archive,
"/dist/myapp.tar.gz",
None,
)];
let publishers = vec![PublisherConfig {
name: Some("test".to_string()),
cmd: "this-command-does-not-exist --should-not-run".to_string(),
args: None,
ids: None,
artifact_types: None,
env: None,
dir: None,
skip: None,
checksum: None,
signature: None,
meta: None,
extra_files: None,
templated_extra_files: None,
}];
let result = run_publishers(
&publishers,
&artifacts,
&vars,
true,
&test_logger(),
1,
None,
);
assert!(
result.is_ok(),
"dry-run should not execute commands: {:?}",
result.err()
);
}
#[test]
fn test_empty_publishers_is_noop() {
let vars = base_vars();
let artifacts = vec![make_artifact(ArtifactKind::Binary, "/dist/myapp", None)];
let result = run_publishers(&[], &artifacts, &vars, false, &test_logger(), 1, None);
assert!(result.is_ok());
}
#[test]
fn test_empty_cmd_is_skipped() {
let vars = base_vars();
let artifacts = vec![make_artifact(ArtifactKind::Binary, "/dist/myapp", None)];
let publishers = vec![PublisherConfig {
name: Some("empty".to_string()),
cmd: String::new(),
args: None,
ids: None,
artifact_types: None,
env: None,
dir: None,
skip: None,
checksum: None,
signature: None,
meta: None,
extra_files: None,
templated_extra_files: None,
}];
let result = run_publishers(
&publishers,
&artifacts,
&vars,
false,
&test_logger(),
1,
None,
);
assert!(result.is_ok());
}
#[test]
fn test_publisher_empty_cmd_records_skip_memento() {
let vars = base_vars();
let artifacts = vec![make_artifact(ArtifactKind::Binary, "/dist/myapp", None)];
let publishers = vec![PublisherConfig {
name: Some("noisy".to_string()),
cmd: String::new(),
args: None,
ids: None,
artifact_types: None,
env: None,
dir: None,
skip: None,
checksum: None,
signature: None,
meta: None,
extra_files: None,
templated_extra_files: None,
}];
let memento = anodizer_core::pipe_skip::SkipMemento::new();
let result = run_publishers(
&publishers,
&artifacts,
&vars,
false,
&test_logger(),
1,
Some(&memento),
);
assert!(result.is_ok());
let events = memento.snapshot();
assert_eq!(events.len(), 1);
assert_eq!(events[0].stage, "publisher");
assert_eq!(events[0].label, "noisy");
assert_eq!(events[0].reason, "empty cmd");
}
#[test]
fn test_publisher_no_matching_artifacts_records_skip_memento() {
let vars = base_vars();
let artifacts = vec![make_artifact(ArtifactKind::Binary, "/dist/myapp", None)];
let publishers = vec![PublisherConfig {
name: Some("filtered".to_string()),
cmd: "true".to_string(),
args: None,
ids: Some(vec!["does-not-exist".to_string()]),
artifact_types: None,
env: None,
dir: None,
skip: None,
checksum: None,
signature: None,
meta: None,
extra_files: None,
templated_extra_files: None,
}];
let memento = anodizer_core::pipe_skip::SkipMemento::new();
let result = run_publishers(
&publishers,
&artifacts,
&vars,
true, &test_logger(),
1,
Some(&memento),
);
assert!(result.is_ok());
let events = memento.snapshot();
assert_eq!(events.len(), 1);
assert_eq!(events[0].reason, "no matching artifacts");
}
#[test]
fn test_format_command_line_no_args() {
assert_eq!(format_command_line("echo hello", &[]), "echo hello");
}
#[test]
fn test_format_command_line_with_args() {
let args = vec!["--flag".to_string(), "value".to_string()];
assert_eq!(format_command_line("cmd", &args), "cmd --flag value");
}
#[test]
fn test_publisher_config_yaml_parsing() {
use anodizer_core::config::Config;
let yaml = r#"
project_name: test
publishers:
- name: upload-s3
cmd: "aws s3 cp {{ ArtifactPath }} s3://my-bucket/"
artifact_types:
- archive
- checksum
env:
- AWS_REGION=us-east-1
- name: notify
cmd: "curl -X POST https://hooks.example.com/release"
ids:
- linux-amd64
- darwin-arm64
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let publishers = config.publishers.as_ref().unwrap();
assert_eq!(publishers.len(), 2);
let p0 = &publishers[0];
assert_eq!(p0.name, Some("upload-s3".to_string()));
assert!(p0.cmd.contains("aws s3 cp"));
assert_eq!(
p0.artifact_types.as_ref().unwrap(),
&["archive", "checksum"]
);
assert!(
p0.env
.as_ref()
.unwrap()
.contains(&"AWS_REGION=us-east-1".to_string())
);
assert!(p0.ids.is_none());
let p1 = &publishers[1];
assert_eq!(p1.name, Some("notify".to_string()));
assert_eq!(p1.ids.as_ref().unwrap(), &["linux-amd64", "darwin-arm64"]);
assert!(p1.artifact_types.is_none());
}
#[test]
fn test_publisher_config_toml_parsing() {
use anodizer_core::config::Config;
let toml_str = r#"
project_name = "test"
[[publishers]]
name = "upload"
cmd = "upload {{ ArtifactPath }}"
artifact_types = ["archive"]
[[publishers]]
name = "notify"
cmd = "notify"
ids = ["linux-amd64"]
[[crates]]
name = "a"
path = "."
tag_template = "v{{ .Version }}"
"#;
let config: Config = toml::from_str(toml_str).unwrap();
let publishers = config.publishers.as_ref().unwrap();
assert_eq!(publishers.len(), 2);
assert_eq!(publishers[0].name, Some("upload".to_string()));
assert_eq!(publishers[1].ids.as_ref().unwrap(), &["linux-amd64"]);
}
#[test]
fn test_publishers_omitted_is_none() {
use anodizer_core::config::Config;
let yaml = r#"
project_name: test
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
assert!(config.publishers.is_none());
}
#[test]
fn test_publisher_config_parses_dir_and_disable() {
use anodizer_core::config::Config;
let yaml = r#"
project_name: test
publishers:
- name: deploy
cmd: "deploy.sh"
dir: "/opt/deploy"
skip: "{{ IsSnapshot }}"
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let publishers = config.publishers.as_ref().unwrap();
assert_eq!(publishers.len(), 1);
assert_eq!(publishers[0].dir.as_deref(), Some("/opt/deploy"));
assert_eq!(
publishers[0].skip,
Some(StringOrBool::String("{{ IsSnapshot }}".to_string()))
);
}
#[test]
fn test_publisher_dir_sets_working_directory() {
let publisher = PublisherConfig {
name: Some("test".to_string()),
cmd: "echo hello".to_string(),
args: None,
ids: None,
artifact_types: None,
env: None,
dir: Some("/tmp/work".to_string()),
skip: None,
checksum: None,
signature: None,
meta: None,
extra_files: None,
templated_extra_files: None,
};
assert_eq!(publisher.dir.as_deref(), Some("/tmp/work"));
}
#[test]
fn test_publisher_disable_skips_when_true() {
let vars = base_vars();
let artifacts = vec![make_artifact(
ArtifactKind::Archive,
"/dist/myapp.tar.gz",
None,
)];
let publishers = vec![PublisherConfig {
name: Some("disabled".to_string()),
cmd: "this-should-not-run".to_string(),
args: None,
ids: None,
artifact_types: None,
env: None,
dir: None,
skip: Some(StringOrBool::String("true".to_string())),
checksum: None,
signature: None,
meta: None,
extra_files: None,
templated_extra_files: None,
}];
let result = run_publishers(
&publishers,
&artifacts,
&vars,
false,
&test_logger(),
1,
None,
);
assert!(
result.is_ok(),
"disabled publisher should be skipped without error: {:?}",
result.err()
);
}
#[test]
fn test_publisher_disable_template_conditional() {
let mut vars = base_vars();
vars.set("IsSnapshot", "true");
let artifacts = vec![make_artifact(
ArtifactKind::Archive,
"/dist/myapp.tar.gz",
None,
)];
let publishers = vec![PublisherConfig {
name: Some("conditional".to_string()),
cmd: "this-should-not-run".to_string(),
args: None,
ids: None,
artifact_types: None,
env: None,
dir: None,
skip: Some(StringOrBool::String("{{ IsSnapshot }}".to_string())),
checksum: None,
signature: None,
meta: None,
extra_files: None,
templated_extra_files: None,
}];
let result = run_publishers(
&publishers,
&artifacts,
&vars,
false,
&test_logger(),
1,
None,
);
assert!(
result.is_ok(),
"conditionally disabled publisher should be skipped: {:?}",
result.err()
);
}
#[test]
fn test_meta_false_excludes_metadata() {
let publisher = make_publisher("echo", None, None);
let metadata = make_artifact(ArtifactKind::Metadata, "dist/metadata.json", None);
assert!(!matches_publisher_filter(&metadata, &publisher));
}
#[test]
fn test_meta_true_includes_metadata() {
let mut publisher = make_publisher("echo", None, None);
publisher.meta = Some(true);
let metadata = make_artifact(ArtifactKind::Metadata, "dist/metadata.json", None);
assert!(matches_publisher_filter(&metadata, &publisher));
}
#[test]
fn test_publisher_config_parses_meta_and_extra_files() {
use anodizer_core::config::Config;
let yaml = r#"
project_name: test
publishers:
- name: deploy
cmd: "deploy.sh"
meta: true
extra_files:
- glob: "docs/*.md"
- glob: "LICENSE"
name_template: "LICENSE.txt"
crates:
- name: a
path: "."
tag_template: "v{{ .Version }}"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
let publishers = config.publishers.as_ref().unwrap();
assert_eq!(publishers.len(), 1);
let p = &publishers[0];
assert_eq!(p.meta, Some(true));
let extra = p.extra_files.as_ref().unwrap();
assert_eq!(extra.len(), 2);
assert_eq!(extra[0].glob(), "docs/*.md");
assert!(extra[0].name_template().is_none());
assert_eq!(extra[1].glob(), "LICENSE");
assert_eq!(extra[1].name_template(), Some("LICENSE.txt"));
}
#[test]
fn test_parallel_dry_run_is_sequential() {
let vars = base_vars();
let artifacts = vec![
make_artifact(ArtifactKind::Archive, "/dist/a.tar.gz", None),
make_artifact(ArtifactKind::Archive, "/dist/b.tar.gz", None),
make_artifact(ArtifactKind::Archive, "/dist/c.tar.gz", None),
];
let publishers = vec![make_publisher("echo {{ ArtifactPath }}", None, None)];
let result = run_publishers(
&publishers,
&artifacts,
&vars,
true,
&test_logger(),
4,
None,
);
assert!(result.is_ok());
}
}