use super::Pipeline;
pub fn build_release_pipeline() -> Pipeline {
use anodizer_stage_announce::AnnounceStage;
use anodizer_stage_appbundle::AppBundleStage;
use anodizer_stage_appimage::AppImageStage;
use anodizer_stage_archive::ArchiveStage;
use anodizer_stage_attest::AttestStage;
use anodizer_stage_blob::BlobStage;
use anodizer_stage_build::BuildStage;
use anodizer_stage_changelog::ChangelogStage;
use anodizer_stage_checksum::ChecksumStage;
use anodizer_stage_dmg::DmgStage;
use anodizer_stage_docker::DockerStage;
use anodizer_stage_flatpak::FlatpakStage;
use anodizer_stage_makeself::MakeselfStage;
use anodizer_stage_msi::MsiStage;
use anodizer_stage_nfpm::NfpmStage;
use anodizer_stage_notarize::NotarizeStage;
use anodizer_stage_nsis::NsisStage;
use anodizer_stage_pkg::PkgStage;
use anodizer_stage_prepublish_guard::PrePublishGuardStage;
use anodizer_stage_publish::{EmissionValidateStage, PublishStage};
use anodizer_stage_release::ReleaseStage;
use anodizer_stage_sbom::SbomStage;
use anodizer_stage_sign::{DockerSignStage, SignStage};
use anodizer_stage_snapcraft::{SnapcraftPublishStage, SnapcraftStage};
use anodizer_stage_source::SourceStage;
use anodizer_stage_srpm::SrpmStage;
use anodizer_stage_templatefiles::TemplateFilesStage;
use anodizer_stage_upx::UpxStage;
use anodizer_stage_verify_release::VerifyReleaseStage;
let mut p = Pipeline::new();
p.expect_binaries();
p.add(Box::new(BuildStage));
p.add(Box::new(UpxStage));
p.add(Box::new(AppBundleStage));
p.add(Box::new(DmgStage));
p.add(Box::new(MsiStage));
p.add(Box::new(PkgStage));
p.add(Box::new(NsisStage));
p.add(Box::new(NotarizeStage));
p.add(Box::new(ChangelogStage));
p.add(Box::new(ArchiveStage));
p.add(Box::new(SourceStage));
p.add(Box::new(NfpmStage));
p.add(Box::new(SrpmStage));
p.add(Box::new(MakeselfStage));
p.add(Box::new(AppImageStage));
p.add(Box::new(SnapcraftStage));
p.add(Box::new(FlatpakStage));
p.add(Box::new(SbomStage));
p.add(Box::new(TemplateFilesStage));
p.add(Box::new(ChecksumStage));
p.add(Box::new(AttestStage));
p.add(Box::new(SignStage));
p.add(Box::new(EmissionValidateStage));
p.add(Box::new(anodizer_core::hooks::BeforePublishStage));
p.add(Box::new(ReleaseStage));
p.add(Box::new(PrePublishGuardStage));
p.add(Box::new(DockerStage::new()));
p.add(Box::new(DockerSignStage));
p.add(Box::new(PublishStage));
p.add(Box::new(BlobStage));
p.add(Box::new(SnapcraftPublishStage));
p.add(Box::new(AnnounceStage));
p.add(Box::new(VerifyReleaseStage));
p
}
pub fn build_split_pipeline() -> Pipeline {
use anodizer_stage_build::BuildStage;
use anodizer_stage_upx::UpxStage;
let mut p = Pipeline::new();
p.add(Box::new(BuildStage));
p.add(Box::new(UpxStage));
p
}
pub fn build_publish_pipeline() -> Pipeline {
use anodizer_stage_blob::BlobStage;
use anodizer_stage_prepublish_guard::PrePublishGuardStage;
use anodizer_stage_publish::PublishStage;
use anodizer_stage_release::ReleaseStage;
use anodizer_stage_snapcraft::SnapcraftPublishStage;
let mut p = Pipeline::new();
p.add(Box::new(anodizer_core::hooks::BeforePublishStage));
p.add(Box::new(ReleaseStage));
p.add(Box::new(PrePublishGuardStage));
p.add(Box::new(PublishStage));
p.add(Box::new(BlobStage));
p.add(Box::new(SnapcraftPublishStage));
p
}
pub(crate) fn build_publish_only_pipeline() -> Pipeline {
use anodizer_stage_announce::AnnounceStage;
use anodizer_stage_attest::AttestStage;
use anodizer_stage_blob::BlobStage;
use anodizer_stage_changelog::ChangelogStage;
use anodizer_stage_checksum::ChecksumStage;
use anodizer_stage_docker::DockerStage;
use anodizer_stage_prepublish_guard::PrePublishGuardStage;
use anodizer_stage_publish::PublishStage;
use anodizer_stage_release::ReleaseStage;
use anodizer_stage_sign::{DockerSignStage, SignStage};
use anodizer_stage_snapcraft::SnapcraftPublishStage;
use anodizer_stage_verify_release::VerifyReleaseStage;
let mut p = Pipeline::new();
p.add(Box::new(ChangelogStage));
p.add(Box::new(SignStage));
p.add(Box::new(ChecksumStage));
p.add(Box::new(AttestStage));
p.add(Box::new(anodizer_core::hooks::BeforePublishStage));
p.add(Box::new(ReleaseStage));
p.add(Box::new(PrePublishGuardStage));
p.add(Box::new(DockerStage::new()));
p.add(Box::new(DockerSignStage));
p.add(Box::new(PublishStage));
p.add(Box::new(BlobStage));
p.add(Box::new(SnapcraftPublishStage));
p.add(Box::new(AnnounceStage));
p.add(Box::new(VerifyReleaseStage));
p
}
pub fn build_announce_pipeline() -> Pipeline {
use anodizer_stage_announce::AnnounceStage;
let mut p = Pipeline::new();
p.add(Box::new(AnnounceStage));
p
}
pub fn build_merge_pipeline() -> Pipeline {
use anodizer_stage_announce::AnnounceStage;
use anodizer_stage_appbundle::AppBundleStage;
use anodizer_stage_appimage::AppImageStage;
use anodizer_stage_archive::ArchiveStage;
use anodizer_stage_attest::AttestStage;
use anodizer_stage_blob::BlobStage;
use anodizer_stage_changelog::ChangelogStage;
use anodizer_stage_checksum::ChecksumStage;
use anodizer_stage_dmg::DmgStage;
use anodizer_stage_docker::DockerStage;
use anodizer_stage_flatpak::FlatpakStage;
use anodizer_stage_makeself::MakeselfStage;
use anodizer_stage_msi::MsiStage;
use anodizer_stage_nfpm::NfpmStage;
use anodizer_stage_notarize::NotarizeStage;
use anodizer_stage_nsis::NsisStage;
use anodizer_stage_pkg::PkgStage;
use anodizer_stage_prepublish_guard::PrePublishGuardStage;
use anodizer_stage_publish::{EmissionValidateStage, PublishStage};
use anodizer_stage_release::ReleaseStage;
use anodizer_stage_sbom::SbomStage;
use anodizer_stage_sign::{DockerSignStage, SignStage};
use anodizer_stage_snapcraft::{SnapcraftPublishStage, SnapcraftStage};
use anodizer_stage_source::SourceStage;
use anodizer_stage_srpm::SrpmStage;
use anodizer_stage_templatefiles::TemplateFilesStage;
use anodizer_stage_verify_release::VerifyReleaseStage;
let mut p = Pipeline::new();
p.expect_binaries();
p.add(Box::new(AppBundleStage));
p.add(Box::new(DmgStage));
p.add(Box::new(MsiStage));
p.add(Box::new(PkgStage));
p.add(Box::new(NsisStage));
p.add(Box::new(NotarizeStage));
p.add(Box::new(ChangelogStage));
p.add(Box::new(ArchiveStage));
p.add(Box::new(SourceStage));
p.add(Box::new(NfpmStage));
p.add(Box::new(SrpmStage));
p.add(Box::new(MakeselfStage));
p.add(Box::new(AppImageStage));
p.add(Box::new(SnapcraftStage));
p.add(Box::new(FlatpakStage));
p.add(Box::new(SbomStage));
p.add(Box::new(TemplateFilesStage));
p.add(Box::new(ChecksumStage));
p.add(Box::new(AttestStage));
p.add(Box::new(SignStage));
p.add(Box::new(EmissionValidateStage));
p.add(Box::new(anodizer_core::hooks::BeforePublishStage));
p.add(Box::new(ReleaseStage));
p.add(Box::new(PrePublishGuardStage));
p.add(Box::new(DockerStage::new()));
p.add(Box::new(DockerSignStage));
p.add(Box::new(PublishStage));
p.add(Box::new(BlobStage));
p.add(Box::new(SnapcraftPublishStage));
p.add(Box::new(AnnounceStage));
p.add(Box::new(VerifyReleaseStage));
p
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn sh_path(p: &std::path::Path) -> String {
p.to_string_lossy().replace('\\', "/")
}
fn assert_blob_before_snapcraft(names: &[&str], pipeline: &str) {
let blob_idx = names
.iter()
.position(|n| *n == "blob")
.unwrap_or_else(|| panic!("{pipeline}: missing blob stage; got {names:?}"));
let snap_idx = names
.iter()
.position(|n| *n == "snapcraft-publish")
.unwrap_or_else(|| panic!("{pipeline}: missing snapcraft-publish; got {names:?}"));
assert!(
blob_idx < snap_idx,
"{pipeline}: blob (idx {blob_idx}) must precede snapcraft-publish (idx {snap_idx}); got {names:?}"
);
}
#[test]
fn release_pipeline_runs_blob_before_snapcraft_publish() {
let p = build_release_pipeline();
let names = p.stage_names();
assert_blob_before_snapcraft(&names, "build_release_pipeline");
}
fn idx(names: &[&str], stage: &str, pipeline: &str) -> usize {
names
.iter()
.position(|n| *n == stage)
.unwrap_or_else(|| panic!("{pipeline}: missing {stage} stage; got {names:?}"))
}
fn assert_guard_after_release_before_publishers(names: &[&str], pipeline: &str) {
let release = idx(names, "release", pipeline);
let guard = idx(names, "prepublish-guard", pipeline);
let publish = idx(names, "publish", pipeline);
assert!(
release < guard,
"{pipeline}: release ({release}) must precede prepublish-guard ({guard}); {names:?}"
);
assert!(
guard < publish,
"{pipeline}: prepublish-guard ({guard}) must precede publish ({publish}); {names:?}"
);
if let Some(docker) = names.iter().position(|n| *n == "docker") {
assert!(
guard < docker,
"{pipeline}: prepublish-guard ({guard}) must precede docker ({docker}); {names:?}"
);
}
if let Some(snap) = names.iter().position(|n| *n == "snapcraft-publish") {
assert!(
guard < snap,
"{pipeline}: prepublish-guard ({guard}) must precede snapcraft-publish ({snap}); {names:?}"
);
}
}
#[test]
fn release_pipeline_runs_guard_after_release_before_publishers() {
let p = build_release_pipeline();
let names = p.stage_names();
assert_guard_after_release_before_publishers(&names, "build_release_pipeline");
}
#[test]
fn merge_pipeline_runs_guard_after_release_before_publishers() {
let p = build_merge_pipeline();
let names = p.stage_names();
assert_guard_after_release_before_publishers(&names, "build_merge_pipeline");
}
#[test]
fn publish_pipeline_runs_guard_after_release_before_publishers() {
let p = build_publish_pipeline();
let names = p.stage_names();
assert_guard_after_release_before_publishers(&names, "build_publish_pipeline");
}
#[test]
fn publish_only_pipeline_runs_guard_after_release_before_publishers() {
let p = build_publish_only_pipeline();
let names = p.stage_names();
assert_guard_after_release_before_publishers(&names, "build_publish_only_pipeline");
}
#[test]
fn publish_pipeline_runs_blob_before_snapcraft_publish() {
let p = build_publish_pipeline();
let names = p.stage_names();
assert_blob_before_snapcraft(&names, "build_publish_pipeline");
}
#[test]
fn merge_pipeline_runs_blob_before_snapcraft_publish() {
let p = build_merge_pipeline();
let names = p.stage_names();
assert_blob_before_snapcraft(&names, "build_merge_pipeline");
}
#[test]
fn publish_only_pipeline_runs_blob_before_snapcraft_publish() {
let p = build_publish_only_pipeline();
let names = p.stage_names();
assert_blob_before_snapcraft(&names, "build_publish_only_pipeline");
}
#[test]
fn publish_only_pipeline_runs_sign_before_release() {
let p = build_publish_only_pipeline();
let names = p.stage_names();
let sign_idx = names
.iter()
.position(|n| *n == "sign")
.expect("publish-only pipeline must include sign stage");
let release_idx = names
.iter()
.position(|n| *n == "release")
.expect("publish-only pipeline must include release stage");
assert!(
sign_idx < release_idx,
"sign (idx {sign_idx}) must precede release (idx {release_idx}); got {names:?}"
);
}
#[test]
fn publish_only_pipeline_runs_docker_after_release_before_publish() {
let p = build_publish_only_pipeline();
let names = p.stage_names();
let release_idx = names
.iter()
.position(|n| *n == "release")
.expect("publish-only pipeline must include release stage");
let docker_idx = names
.iter()
.position(|n| *n == "docker")
.expect("publish-only pipeline must include docker stage");
let docker_sign_idx = names
.iter()
.position(|n| *n == "docker-sign")
.expect("publish-only pipeline must include docker-sign stage");
let publish_idx = names
.iter()
.position(|n| *n == "publish")
.expect("publish-only pipeline must include publish stage");
assert!(
release_idx < docker_idx,
"docker (idx {docker_idx}) must follow release (idx {release_idx}); got {names:?}"
);
assert!(
docker_idx < docker_sign_idx,
"docker-sign (idx {docker_sign_idx}) must follow docker (idx {docker_idx}); got {names:?}"
);
assert!(
docker_sign_idx < publish_idx,
"docker-sign (idx {docker_sign_idx}) must precede publish (idx {publish_idx}); got {names:?}"
);
}
#[test]
fn publish_only_pipeline_runs_changelog_before_release() {
let p = build_publish_only_pipeline();
let names = p.stage_names();
let changelog_idx = names
.iter()
.position(|n| *n == "changelog")
.expect("publish-only pipeline must include changelog stage");
let release_idx = names
.iter()
.position(|n| *n == "release")
.expect("publish-only pipeline must include release stage");
assert!(
changelog_idx < release_idx,
"changelog (idx {changelog_idx}) must precede release (idx {release_idx}); got {names:?}"
);
}
#[test]
fn publish_only_pipeline_runs_checksum_before_publish_after_sign() {
let p = build_publish_only_pipeline();
let names = p.stage_names();
let checksum_idx = names
.iter()
.position(|n| *n == "checksum")
.expect("publish-only pipeline must include checksum stage");
let sign_idx = names
.iter()
.position(|n| *n == "sign")
.expect("publish-only pipeline must include sign stage");
let publish_idx = names
.iter()
.position(|n| *n == "publish")
.expect("publish-only pipeline must include publish stage");
assert!(
sign_idx < checksum_idx,
"checksum (idx {checksum_idx}) must follow sign (idx {sign_idx}) so production-signed bytes get hashed; got {names:?}"
);
assert!(
checksum_idx < publish_idx,
"checksum (idx {checksum_idx}) must precede publish (idx {publish_idx}) so publishers see sha256 metadata; got {names:?}"
);
}
fn assert_announce_then_verify_release_terminal(names: &[&str], label: &str) {
let announce_idx = names
.iter()
.position(|n| *n == "announce")
.unwrap_or_else(|| panic!("{label} must include announce stage"));
let publish_idx = names
.iter()
.position(|n| *n == "publish")
.unwrap_or_else(|| panic!("{label} must include publish stage"));
let verify_release_idx = names
.iter()
.position(|n| *n == "verify-release")
.unwrap_or_else(|| panic!("{label} must include verify-release stage"));
assert!(
announce_idx > publish_idx,
"{label}: announce (idx {announce_idx}) must follow publish (idx {publish_idx}); got {names:?}"
);
assert_eq!(
verify_release_idx,
names.len() - 1,
"{label}: verify-release must be the terminal post-publish stage; got {names:?}"
);
assert_eq!(
announce_idx,
verify_release_idx - 1,
"{label}: announce must be the final publish-phase stage, immediately before the terminal verify-release report; got {names:?}"
);
}
#[test]
fn publish_only_pipeline_runs_announce_after_publish() {
let p = build_publish_only_pipeline();
let names = p.stage_names();
assert_announce_then_verify_release_terminal(&names, "build_publish_only_pipeline");
}
#[test]
fn release_pipeline_runs_announce_after_publish() {
let p = build_release_pipeline();
let names = p.stage_names();
assert_announce_then_verify_release_terminal(&names, "build_release_pipeline");
}
#[test]
fn merge_pipeline_runs_announce_after_publish() {
let p = build_merge_pipeline();
let names = p.stage_names();
assert_announce_then_verify_release_terminal(&names, "build_merge_pipeline");
}
fn add_sentinel_archive(ctx: &mut anodizer_core::context::Context) {
use anodizer_core::artifact::{Artifact, ArtifactKind};
use std::collections::HashMap;
use std::path::PathBuf;
ctx.artifacts.add(Artifact {
kind: ArtifactKind::Archive,
path: PathBuf::from("dist/myapp_linux_amd64.tar.gz"),
name: "myapp_linux_amd64.tar.gz".to_string(),
target: Some("x86_64-unknown-linux-gnu".to_string()),
crate_name: "myapp".to_string(),
metadata: HashMap::new(),
size: None,
});
}
#[test]
fn before_publish_runs_after_sbom_before_publish_dispatch() {
let p = build_release_pipeline();
let names = p.stage_names();
let sbom_idx = names
.iter()
.position(|n| *n == "sbom")
.expect("release pipeline must include sbom stage");
let sign_idx = names
.iter()
.position(|n| *n == "sign")
.expect("release pipeline must include sign stage");
let checksum_idx = names
.iter()
.position(|n| *n == "checksum")
.expect("release pipeline must include checksum stage");
let before_publish_idx = names
.iter()
.position(|n| *n == "before-publish")
.expect("release pipeline must include before-publish stage");
let release_idx = names
.iter()
.position(|n| *n == "release")
.expect("release pipeline must include release stage");
let publish_idx = names
.iter()
.position(|n| *n == "publish")
.expect("release pipeline must include publish stage");
assert!(
sbom_idx < before_publish_idx,
"before-publish ({before_publish_idx}) must follow sbom ({sbom_idx}); got {names:?}"
);
assert!(
sign_idx < before_publish_idx,
"before-publish ({before_publish_idx}) must follow sign ({sign_idx}); got {names:?}"
);
assert!(
checksum_idx < before_publish_idx,
"before-publish ({before_publish_idx}) must follow checksum ({checksum_idx}); got {names:?}"
);
assert!(
before_publish_idx < release_idx,
"before-publish ({before_publish_idx}) must precede release ({release_idx}); got {names:?}"
);
assert!(
before_publish_idx < publish_idx,
"before-publish ({before_publish_idx}) must precede publish ({publish_idx}); got {names:?}"
);
}
#[test]
fn before_publish_hook_failure_aborts_release_before_publish_dispatch() {
use anodizer_core::config::{HookEntry, HooksConfig, StructuredHook};
use anodizer_core::context::ContextOptions;
use anodizer_core::hooks::BeforePublishStage;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
struct RecordingStage(Arc<AtomicBool>);
impl anodizer_core::stage::Stage for RecordingStage {
fn name(&self) -> &str {
"publish"
}
fn run(&self, _ctx: &mut anodizer_core::context::Context) -> anyhow::Result<()> {
self.0.store(true, Ordering::SeqCst);
Ok(())
}
}
let publish_ran = Arc::new(AtomicBool::new(false));
let mut p = Pipeline::new();
p.add(Box::new(BeforePublishStage));
p.add(Box::new(RecordingStage(publish_ran.clone())));
let mut config = anodizer_core::config::Config {
project_name: "myapp".to_string(),
..Default::default()
};
config.before_publish = Some(HooksConfig {
hooks: Some(vec![HookEntry::Structured(StructuredHook {
cmd: "exit 1".to_string(),
..Default::default()
})]),
post: None,
});
let mut ctx = anodizer_core::context::Context::new(config, ContextOptions::default());
ctx.template_vars_mut().set("Tag", "v9.9.9-test");
add_sentinel_archive(&mut ctx);
let log = ctx.logger("pipeline-test");
let result = p.run(&mut ctx, &log);
assert!(
result.is_err(),
"non-zero before_publish hook must abort the pipeline; got Ok",
);
assert!(
!publish_ran.load(Ordering::SeqCst),
"publish stage must NOT run after a failed before_publish hook",
);
}
#[test]
fn before_publish_skip_via_cli_flag_logs_and_continues() {
use anodizer_core::config::{HookEntry, HooksConfig, StructuredHook};
use anodizer_core::context::ContextOptions;
use anodizer_core::hooks::BeforePublishStage;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
struct SentinelStage(Arc<AtomicBool>);
impl anodizer_core::stage::Stage for SentinelStage {
fn name(&self) -> &str {
"publish"
}
fn run(&self, _ctx: &mut anodizer_core::context::Context) -> anyhow::Result<()> {
self.0.store(true, Ordering::SeqCst);
Ok(())
}
}
let publish_ran = Arc::new(AtomicBool::new(false));
let mut p = Pipeline::new();
p.add(Box::new(BeforePublishStage));
p.add(Box::new(SentinelStage(publish_ran.clone())));
let mut config = anodizer_core::config::Config {
project_name: "myapp".to_string(),
..Default::default()
};
config.before_publish = Some(HooksConfig {
hooks: Some(vec![HookEntry::Structured(StructuredHook {
cmd: "exit 1".to_string(),
..Default::default()
})]),
post: None,
});
let opts = ContextOptions {
skip_stages: vec!["before-publish".to_string()],
..ContextOptions::default()
};
let mut ctx = anodizer_core::context::Context::new(config, opts);
ctx.template_vars_mut().set("Tag", "v9.9.9-test");
let log = ctx.logger("pipeline-test");
p.run(&mut ctx, &log)
.expect("pipeline must succeed when before-publish is skipped");
assert!(
publish_ran.load(Ordering::SeqCst),
"publish stage must run when before-publish is operator-skipped",
);
}
#[test]
fn before_publish_skip_via_cli_flag_via_dry_run() {
use anodizer_core::config::{HookEntry, HooksConfig, StructuredHook};
use anodizer_core::context::ContextOptions;
use anodizer_core::hooks::BeforePublishStage;
let mut p = Pipeline::new();
p.add(Box::new(BeforePublishStage));
let mut config = anodizer_core::config::Config {
project_name: "myapp".to_string(),
..Default::default()
};
config.before_publish = Some(HooksConfig {
hooks: Some(vec![HookEntry::Structured(StructuredHook {
cmd: "exit 1".to_string(),
..Default::default()
})]),
post: None,
});
let opts = ContextOptions {
dry_run: true,
..ContextOptions::default()
};
let mut ctx = anodizer_core::context::Context::new(config, opts);
ctx.template_vars_mut().set("Tag", "v9.9.9-test");
add_sentinel_archive(&mut ctx);
let log = ctx.logger("pipeline-test");
p.run(&mut ctx, &log)
.expect("dry-run before_publish hook must NOT execute the subprocess");
}
#[test]
fn before_publish_hook_if_condition_skip_when_falsy() {
use anodizer_core::config::{HookEntry, HooksConfig, StructuredHook};
use anodizer_core::context::ContextOptions;
use anodizer_core::hooks::BeforePublishStage;
let mut p = Pipeline::new();
p.add(Box::new(BeforePublishStage));
let mut config = anodizer_core::config::Config {
project_name: "myapp".to_string(),
..Default::default()
};
config.before_publish = Some(HooksConfig {
hooks: Some(vec![HookEntry::Structured(StructuredHook {
cmd: "exit 1".to_string(),
if_condition: Some("{{ IsSnapshot }}".to_string()),
..Default::default()
})]),
post: None,
});
let mut ctx = anodizer_core::context::Context::new(config, ContextOptions::default());
ctx.template_vars_mut().set("Tag", "v9.9.9-test");
ctx.template_vars_mut().set("IsSnapshot", "false");
add_sentinel_archive(&mut ctx);
let log = ctx.logger("pipeline-test");
p.run(&mut ctx, &log)
.expect("falsy `if:` must skip the hook so the exit-1 cmd never spawns");
}
#[test]
fn before_publish_hook_output_true_streams_logs() {
use anodizer_core::config::{HookEntry, HooksConfig, StructuredHook};
use anodizer_core::context::ContextOptions;
use anodizer_core::hooks::BeforePublishStage;
let mut p = Pipeline::new();
p.add(Box::new(BeforePublishStage));
let mut config = anodizer_core::config::Config {
project_name: "myapp".to_string(),
..Default::default()
};
config.before_publish = Some(HooksConfig {
hooks: Some(vec![HookEntry::Structured(StructuredHook {
cmd: "echo hello-from-before-publish".to_string(),
output: Some(true),
..Default::default()
})]),
post: None,
});
let mut ctx = anodizer_core::context::Context::new(config, ContextOptions::default());
ctx.template_vars_mut().set("Tag", "v9.9.9-test");
add_sentinel_archive(&mut ctx);
let log = ctx.logger("pipeline-test");
p.run(&mut ctx, &log)
.expect("echo hook must succeed under before-publish");
}
#[test]
fn before_publish_hook_env_propagates() {
use anodizer_core::config::{HookEntry, HooksConfig, StructuredHook};
use anodizer_core::context::ContextOptions;
use anodizer_core::hooks::BeforePublishStage;
let mut p = Pipeline::new();
p.add(Box::new(BeforePublishStage));
let mut config = anodizer_core::config::Config {
project_name: "myapp".to_string(),
..Default::default()
};
config.before_publish = Some(HooksConfig {
hooks: Some(vec![HookEntry::Structured(StructuredHook {
cmd: r#"sh -c 'test "$FOO" = "bar"'"#.to_string(),
env: Some(vec!["FOO=bar".to_string()]),
..Default::default()
})]),
post: None,
});
let mut ctx = anodizer_core::context::Context::new(config, ContextOptions::default());
ctx.template_vars_mut().set("Tag", "v9.9.9-test");
add_sentinel_archive(&mut ctx);
let log = ctx.logger("pipeline-test");
p.run(&mut ctx, &log)
.expect("per-hook env must reach the subprocess");
}
#[test]
fn before_publish_string_form_parses() {
use anodizer_core::config::{Config, HookEntry};
let yaml = r#"
project_name: myapp
crates:
- name: myapp
path: ""
before_publish:
hooks:
- "echo foo"
"#;
let cfg: Config = serde_yaml_ng::from_str(yaml).expect("parse yaml");
let hooks = cfg
.before_publish
.as_ref()
.expect("before_publish set")
.hooks
.as_ref()
.expect("hooks set");
assert_eq!(hooks.len(), 1);
match &hooks[0] {
HookEntry::Simple(s) => assert_eq!(s, "echo foo"),
HookEntry::Structured(h) => panic!("expected Simple, got Structured({:?})", h),
}
}
#[test]
fn before_publish_runs_per_matching_artifact() {
use anodizer_core::artifact::{Artifact, ArtifactKind};
use anodizer_core::config::{
BeforePublishArtifactFilter, HookEntry, HooksConfig, StructuredHook,
};
use anodizer_core::context::ContextOptions;
use anodizer_core::hooks::BeforePublishStage;
use std::collections::HashMap;
use std::path::PathBuf;
let tmp = TempDir::new().unwrap();
let log_path = tmp.path().join("hook-invocations.log");
let mut p = Pipeline::new();
p.add(Box::new(BeforePublishStage));
let mut config = anodizer_core::config::Config {
project_name: "myapp".to_string(),
..Default::default()
};
config.before_publish = Some(HooksConfig {
hooks: Some(vec![HookEntry::Structured(StructuredHook {
cmd: format!("echo {{{{ ArtifactPath }}}} >> {}", sh_path(&log_path)),
artifacts: Some(BeforePublishArtifactFilter::Archive),
..Default::default()
})]),
post: None,
});
let mut ctx = anodizer_core::context::Context::new(config, ContextOptions::default());
ctx.template_vars_mut().set("Tag", "v9.9.9-test");
for i in 0..3 {
ctx.artifacts.add(Artifact {
kind: ArtifactKind::Archive,
path: PathBuf::from(format!("dist/myapp_{i}.tar.gz")),
name: format!("myapp_{i}.tar.gz"),
target: Some("x86_64-unknown-linux-gnu".to_string()),
crate_name: "myapp".to_string(),
metadata: HashMap::new(),
size: None,
});
}
let log = ctx.logger("pipeline-test");
p.run(&mut ctx, &log)
.expect("per-artifact iteration must succeed");
let contents = fs::read_to_string(&log_path).expect("log file exists");
let lines: Vec<&str> = contents.lines().filter(|l| !l.is_empty()).collect();
assert_eq!(lines.len(), 3, "hook should run 3 times, got: {lines:?}");
for i in 0..3 {
let expected = format!("dist/myapp_{i}.tar.gz");
assert!(
lines.iter().any(|l| l == &expected),
"missing iteration for {expected}; got {lines:?}"
);
}
}
#[test]
fn before_publish_ids_filter_narrows_to_subset() {
use anodizer_core::artifact::{Artifact, ArtifactKind};
use anodizer_core::config::{HookEntry, HooksConfig, StructuredHook};
use anodizer_core::context::ContextOptions;
use anodizer_core::hooks::BeforePublishStage;
use std::collections::HashMap;
use std::path::PathBuf;
let tmp = TempDir::new().unwrap();
let log_path = tmp.path().join("ids-filter.log");
let mut p = Pipeline::new();
p.add(Box::new(BeforePublishStage));
let mut config = anodizer_core::config::Config {
project_name: "myapp".to_string(),
..Default::default()
};
config.before_publish = Some(HooksConfig {
hooks: Some(vec![HookEntry::Structured(StructuredHook {
cmd: format!("echo {{{{ ArtifactID }}}} >> {}", sh_path(&log_path)),
ids: Some(vec!["a".to_string()]),
..Default::default()
})]),
post: None,
});
let mut ctx = anodizer_core::context::Context::new(config, ContextOptions::default());
ctx.template_vars_mut().set("Tag", "v9.9.9-test");
for id in &["a", "b"] {
let mut meta = HashMap::new();
meta.insert("id".to_string(), (*id).to_string());
ctx.artifacts.add(Artifact {
kind: ArtifactKind::Archive,
path: PathBuf::from(format!("dist/myapp-{id}.tar.gz")),
name: format!("myapp-{id}.tar.gz"),
target: Some("x86_64-unknown-linux-gnu".to_string()),
crate_name: "myapp".to_string(),
metadata: meta,
size: None,
});
}
let log = ctx.logger("pipeline-test");
p.run(&mut ctx, &log).expect("ids filter must not error");
let contents = fs::read_to_string(&log_path).expect("log file exists");
let lines: Vec<&str> = contents.lines().filter(|l| !l.is_empty()).collect();
assert_eq!(lines, vec!["a"], "only id=a should match; got {lines:?}");
}
#[test]
fn before_publish_artifacts_filter_excludes_non_matching_kinds() {
use anodizer_core::artifact::{Artifact, ArtifactKind};
use anodizer_core::config::{
BeforePublishArtifactFilter, HookEntry, HooksConfig, StructuredHook,
};
use anodizer_core::context::ContextOptions;
use anodizer_core::hooks::BeforePublishStage;
use std::collections::HashMap;
use std::path::PathBuf;
let tmp = TempDir::new().unwrap();
let log_path = tmp.path().join("kind-filter.log");
let mut p = Pipeline::new();
p.add(Box::new(BeforePublishStage));
let mut config = anodizer_core::config::Config {
project_name: "myapp".to_string(),
..Default::default()
};
config.before_publish = Some(HooksConfig {
hooks: Some(vec![HookEntry::Structured(StructuredHook {
cmd: format!(
"echo {{{{ ArtifactKind }}}}={{{{ ArtifactName }}}} >> {}",
sh_path(&log_path)
),
artifacts: Some(BeforePublishArtifactFilter::Archive),
..Default::default()
})]),
post: None,
});
let mut ctx = anodizer_core::context::Context::new(config, ContextOptions::default());
ctx.template_vars_mut().set("Tag", "v9.9.9-test");
ctx.artifacts.add(Artifact {
kind: ArtifactKind::Binary,
path: PathBuf::from("dist/myapp"),
name: "myapp".to_string(),
target: Some("x86_64-unknown-linux-gnu".to_string()),
crate_name: "myapp".to_string(),
metadata: HashMap::new(),
size: None,
});
ctx.artifacts.add(Artifact {
kind: ArtifactKind::Archive,
path: PathBuf::from("dist/myapp.tar.gz"),
name: "myapp.tar.gz".to_string(),
target: Some("x86_64-unknown-linux-gnu".to_string()),
crate_name: "myapp".to_string(),
metadata: HashMap::new(),
size: None,
});
let log = ctx.logger("pipeline-test");
p.run(&mut ctx, &log)
.expect("archive filter must not error");
let contents = fs::read_to_string(&log_path).expect("log file exists");
let lines: Vec<&str> = contents.lines().filter(|l| !l.is_empty()).collect();
assert_eq!(
lines,
vec!["archive=myapp.tar.gz"],
"archive filter must skip binary; got {lines:?}"
);
}
#[test]
fn before_publish_template_artifact_vars_bound() {
use anodizer_core::artifact::{Artifact, ArtifactKind};
use anodizer_core::config::{HookEntry, HooksConfig, StructuredHook};
use anodizer_core::context::ContextOptions;
use anodizer_core::hooks::BeforePublishStage;
use std::collections::HashMap;
use std::path::PathBuf;
let tmp = TempDir::new().unwrap();
let log_path = tmp.path().join("vars.log");
let mut p = Pipeline::new();
p.add(Box::new(BeforePublishStage));
let mut config = anodizer_core::config::Config {
project_name: "myapp".to_string(),
..Default::default()
};
config.before_publish = Some(HooksConfig {
hooks: Some(vec![HookEntry::Structured(StructuredHook {
cmd: format!(
"printf '%s %s %s %s %s\\n' {{{{ ArtifactPath }}}} {{{{ ArtifactName }}}} {{{{ ArtifactExt }}}} {{{{ Os }}}} {{{{ Arch }}}} >> {}",
sh_path(&log_path)
),
..Default::default()
})]),
post: None,
});
let mut ctx = anodizer_core::context::Context::new(config, ContextOptions::default());
ctx.template_vars_mut().set("Tag", "v9.9.9-test");
ctx.artifacts.add(Artifact {
kind: ArtifactKind::Archive,
path: PathBuf::from("dist/myapp_linux_amd64.tar.gz"),
name: "myapp_linux_amd64.tar.gz".to_string(),
target: Some("x86_64-unknown-linux-gnu".to_string()),
crate_name: "myapp".to_string(),
metadata: HashMap::new(),
size: None,
});
let log = ctx.logger("pipeline-test");
p.run(&mut ctx, &log)
.expect("template-vars hook must succeed");
let contents = fs::read_to_string(&log_path).expect("log file exists");
let line = contents.lines().next().expect("at least one line").trim();
assert_eq!(
line, "dist/myapp_linux_amd64.tar.gz myapp_linux_amd64.tar.gz .tar.gz linux amd64",
"all per-artifact template vars must bind; got {line:?}"
);
}
#[test]
fn before_publish_failure_on_any_artifact_aborts_release() {
use anodizer_core::artifact::{Artifact, ArtifactKind};
use anodizer_core::config::{HookEntry, HooksConfig, StructuredHook};
use anodizer_core::context::ContextOptions;
use anodizer_core::hooks::BeforePublishStage;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
struct RecordingStage(Arc<AtomicBool>);
impl anodizer_core::stage::Stage for RecordingStage {
fn name(&self) -> &str {
"publish"
}
fn run(&self, _ctx: &mut anodizer_core::context::Context) -> anyhow::Result<()> {
self.0.store(true, Ordering::SeqCst);
Ok(())
}
}
let publish_ran = Arc::new(AtomicBool::new(false));
let tmp = TempDir::new().unwrap();
let counter_path = tmp.path().join("counter");
let mut p = Pipeline::new();
p.add(Box::new(BeforePublishStage));
p.add(Box::new(RecordingStage(publish_ran.clone())));
let mut config = anodizer_core::config::Config {
project_name: "myapp".to_string(),
..Default::default()
};
let cmd = format!(
r#"sh -c 'printf x >> {p}; if [ "$(wc -c < {p})" -ge 2 ]; then exit 1; fi'"#,
p = sh_path(&counter_path),
);
config.before_publish = Some(HooksConfig {
hooks: Some(vec![HookEntry::Structured(StructuredHook {
cmd,
..Default::default()
})]),
post: None,
});
let mut ctx = anodizer_core::context::Context::new(config, ContextOptions::default());
ctx.template_vars_mut().set("Tag", "v9.9.9-test");
for i in 0..3 {
ctx.artifacts.add(Artifact {
kind: ArtifactKind::Archive,
path: PathBuf::from(format!("dist/myapp_{i}.tar.gz")),
name: format!("myapp_{i}.tar.gz"),
target: Some("x86_64-unknown-linux-gnu".to_string()),
crate_name: "myapp".to_string(),
metadata: HashMap::new(),
size: None,
});
}
let log = ctx.logger("pipeline-test");
let result = p.run(&mut ctx, &log);
assert!(
result.is_err(),
"hook failure on any artifact must abort the pipeline",
);
assert!(
!publish_ran.load(Ordering::SeqCst),
"publish stage must NOT run after a mid-iteration hook failure",
);
let count = fs::read_to_string(&counter_path)
.map(|s| s.len())
.unwrap_or(0);
assert_eq!(
count, 2,
"hook should have run exactly twice before aborting; got {count}",
);
}
#[test]
fn before_publish_artifacts_all_default_matches_everything() {
use anodizer_core::artifact::{Artifact, ArtifactKind};
use anodizer_core::config::{HookEntry, HooksConfig, StructuredHook};
use anodizer_core::context::ContextOptions;
use anodizer_core::hooks::BeforePublishStage;
use std::collections::HashMap;
use std::path::PathBuf;
let tmp = TempDir::new().unwrap();
let log_path = tmp.path().join("default-all.log");
let mut p = Pipeline::new();
p.add(Box::new(BeforePublishStage));
let mut config = anodizer_core::config::Config {
project_name: "myapp".to_string(),
..Default::default()
};
config.before_publish = Some(HooksConfig {
hooks: Some(vec![HookEntry::Structured(StructuredHook {
cmd: format!("echo {{{{ ArtifactKind }}}} >> {}", sh_path(&log_path)),
..Default::default()
})]),
post: None,
});
let mut ctx = anodizer_core::context::Context::new(config, ContextOptions::default());
ctx.template_vars_mut().set("Tag", "v9.9.9-test");
let kinds = [
ArtifactKind::Binary,
ArtifactKind::Archive,
ArtifactKind::Checksum,
ArtifactKind::Sbom,
];
for (i, kind) in kinds.iter().enumerate() {
ctx.artifacts.add(Artifact {
kind: *kind,
path: PathBuf::from(format!("dist/a{i}")),
name: format!("a{i}"),
target: None,
crate_name: "myapp".to_string(),
metadata: HashMap::new(),
size: None,
});
}
let log = ctx.logger("pipeline-test");
p.run(&mut ctx, &log)
.expect("default-all filter must fire for every artifact");
let contents = fs::read_to_string(&log_path).expect("log file exists");
let mut lines: Vec<&str> = contents.lines().filter(|l| !l.is_empty()).collect();
lines.sort();
assert_eq!(
lines,
vec!["archive", "binary", "checksum", "sbom"],
"default (artifacts: all) must match every kind; got {lines:?}"
);
}
struct AlwaysFailStage;
impl anodizer_core::stage::Stage for AlwaysFailStage {
fn name(&self) -> &str {
"always-fail"
}
fn run(&self, _ctx: &mut anodizer_core::context::Context) -> anyhow::Result<()> {
anyhow::bail!("synthetic stage failure for the I-3 test")
}
}
#[test]
fn pipeline_emits_summary_even_when_inner_stage_returns_err() {
use anodizer_core::context::ContextOptions;
let tmp = TempDir::new().expect("tempdir");
let summary_path = tmp.path().join("summary.json");
let mut p = Pipeline::new();
p.add(Box::new(AlwaysFailStage));
let opts = ContextOptions {
summary_json_path: Some(summary_path.clone()),
..ContextOptions::default()
};
let config = anodizer_core::config::Config {
project_name: "myapp".to_string(),
..Default::default()
};
let mut ctx = anodizer_core::context::Context::new(config, opts);
ctx.template_vars_mut().set("Tag", "v9.9.9-test");
ctx.publish_report = Some(anodizer_core::publish_report::PublishReport::default());
let log = ctx.logger("pipeline-test");
let result = p.run(&mut ctx, &log);
assert!(
result.is_err(),
"pipeline must propagate the stage's Err verbatim",
);
assert!(
summary_path.exists(),
"summary.json must be written even when the inner pipeline body returns Err",
);
}
#[test]
fn pipeline_emits_summary_when_announce_is_skipped_via_skip_flag() {
use anodizer_core::context::ContextOptions;
use anodizer_stage_announce::AnnounceStage;
let tmp = TempDir::new().expect("tempdir");
let summary_path = tmp.path().join("summary.json");
let mut p = Pipeline::new();
p.add(Box::new(AnnounceStage));
let opts = ContextOptions {
summary_json_path: Some(summary_path.clone()),
skip_stages: vec!["announce".to_string()],
..ContextOptions::default()
};
let config = anodizer_core::config::Config {
project_name: "myapp".to_string(),
..Default::default()
};
let mut ctx = anodizer_core::context::Context::new(config, opts);
ctx.template_vars_mut().set("Tag", "v9.9.9-test");
ctx.publish_report = Some(anodizer_core::publish_report::PublishReport::default());
let log = ctx.logger("pipeline-test");
p.run(&mut ctx, &log).expect("pipeline run");
assert!(
summary_path.exists(),
"summary.json must be written even when announce is operator-skipped",
);
}
}