use std::path::PathBuf;
use std::time::Duration;
use anodizer_core::context::Context;
use anodizer_core::env_preflight::{self, EnvPreflightReport, EnvProbes, SourcedRequirement};
use anodizer_core::log::{StageLogger, Verbosity};
use anyhow::Result;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PreflightScope {
Full,
PublishOnly,
AnnounceOnly,
SecretsOnly,
}
impl PreflightScope {
fn included_stage_set(self) -> Option<std::collections::BTreeSet<String>> {
let pipeline = match self {
PreflightScope::Full | PreflightScope::SecretsOnly => return None,
PreflightScope::PublishOnly => crate::pipeline::build_publish_only_pipeline(),
PreflightScope::AnnounceOnly => crate::pipeline::build_announce_pipeline(),
};
Some(
pipeline
.stage_names()
.iter()
.map(|s| s.to_string())
.collect(),
)
}
fn retains(self, req: &anodizer_core::EnvRequirement) -> bool {
use anodizer_core::EnvRequirement::{EnvAllOf, EnvAnyOf, KeyEnv};
match self {
PreflightScope::SecretsOnly => {
matches!(req, EnvAllOf { .. } | EnvAnyOf { .. } | KeyEnv { .. })
}
_ => true,
}
}
}
enum StageGate {
Skip,
Selected,
}
type SourcedStageRequirements = Vec<(String, Vec<anodizer_core::EnvRequirement>)>;
struct GatedStage {
stage: &'static str,
gate: StageGate,
requirements: fn(&Context, PreflightScope) -> SourcedStageRequirements,
}
macro_rules! gated_stage {
($name:literal, $gate:ident, $reqs:path) => {
GatedStage {
stage: $name,
gate: StageGate::$gate,
requirements: |ctx, _scope| vec![(concat!("stage:", $name).to_string(), $reqs(ctx))],
}
};
}
const GATED_STAGES: &[GatedStage] = &[
GatedStage {
stage: "build",
gate: StageGate::Skip,
requirements: |_ctx, _scope| {
vec![(
"stage:build".to_string(),
vec![anodizer_core::EnvRequirement::Tool {
name: "cargo".to_string(),
}],
)]
},
},
gated_stage!("nfpm", Skip, anodizer_stage_nfpm::env_requirements),
gated_stage!("srpm", Skip, anodizer_stage_srpm::env_requirements),
gated_stage!(
"snapcraft",
Skip,
anodizer_stage_snapcraft::build_env_requirements
),
gated_stage!(
"snapcraft-publish",
Selected,
anodizer_stage_snapcraft::publish_env_requirements
),
GatedStage {
stage: "sign",
gate: StageGate::Skip,
requirements: |ctx, scope| {
let mut pairs = Vec::new();
if !anodizer_stage_sign::signs_fully_deselected(ctx) {
pairs.push((
"stage:sign".to_string(),
anodizer_stage_sign::sign_env_requirements(ctx),
));
}
if scope != PreflightScope::PublishOnly {
pairs.push((
"stage:sign".to_string(),
anodizer_stage_sign::binary_sign_env_requirements(ctx),
));
}
pairs
},
},
gated_stage!(
"docker-sign",
Selected,
anodizer_stage_sign::docker_sign_env_requirements
),
gated_stage!("sbom", Skip, anodizer_stage_sbom::env_requirements),
gated_stage!("makeself", Skip, anodizer_stage_makeself::env_requirements),
gated_stage!("upx", Skip, anodizer_stage_upx::env_requirements),
gated_stage!("appimage", Skip, anodizer_stage_appimage::env_requirements),
gated_stage!("docker", Selected, anodizer_stage_docker::env_requirements),
gated_stage!("blob", Selected, anodizer_stage_blob::env_requirements),
gated_stage!(
"verify-release",
Skip,
anodizer_stage_verify_release::env_requirements
),
gated_stage!("msi", Skip, anodizer_stage_msi::env_requirements),
gated_stage!("nsis", Skip, anodizer_stage_nsis::env_requirements),
gated_stage!("pkg", Skip, anodizer_stage_pkg::env_requirements),
gated_stage!("dmg", Skip, anodizer_stage_dmg::env_requirements),
gated_stage!(
"appbundle",
Skip,
anodizer_stage_appbundle::env_requirements
),
gated_stage!("flatpak", Skip, anodizer_stage_flatpak::env_requirements),
gated_stage!("notarize", Skip, anodizer_stage_notarize::env_requirements),
gated_stage!(
"announce",
Selected,
anodizer_stage_announce::env_requirements
),
GatedStage {
stage: "release",
gate: StageGate::Skip,
requirements: |ctx, _scope| {
let release_skipped = ctx
.config
.release
.as_ref()
.and_then(|r| r.skip.as_ref())
.is_some_and(|s| {
s.try_evaluates_to_true(|tmpl| ctx.render_template(tmpl))
.unwrap_or(false)
});
if ctx.publisher_deselected("github-release") || release_skipped {
return Vec::new();
}
vec![(
"stage:release".to_string(),
anodizer_core::Publisher::requirements(
&anodizer_stage_release::publisher::GithubReleasePublisher::new(),
ctx,
),
)]
},
},
GatedStage {
stage: "publish",
gate: StageGate::Skip,
requirements: |ctx, _scope| {
anodizer_stage_publish::registry::all_publishers()
.into_iter()
.filter(|publisher| !ctx.publisher_deselected(publisher.name()))
.map(|publisher| {
(
format!("publish:{}", publisher.name()),
publisher.requirements(ctx),
)
})
.collect()
},
},
];
pub fn collect_requirements(ctx: &Context, scope: PreflightScope) -> Vec<SourcedRequirement> {
let mut out: Vec<SourcedRequirement> = Vec::new();
let included = scope.included_stage_set();
let in_scope =
|stage: &str| -> bool { included.as_ref().is_none_or(|set| set.contains(stage)) };
let mut build_runs = false;
for gated in GATED_STAGES {
let stage_on = in_scope(gated.stage)
&& match gated.gate {
StageGate::Skip => !ctx.should_skip(gated.stage),
StageGate::Selected => !ctx.publisher_deselected(gated.stage),
};
if gated.stage == "build" {
build_runs = stage_on;
}
if !stage_on {
continue;
}
for (source, reqs) in (gated.requirements)(ctx, scope) {
out.extend(
reqs.into_iter()
.map(|r| SourcedRequirement::new(&source, r)),
);
}
}
if build_runs {
out.extend(
anodizer_stage_build::cross_tool_requirements(ctx)
.into_iter()
.map(|r| SourcedRequirement::new_advisory("stage:build", r)),
);
}
out.retain(|sr| scope.retains(&sr.requirement));
out
}
pub fn evaluate_against_environment(
ctx: &Context,
requirements: &[SourcedRequirement],
) -> EnvPreflightReport {
let env = |name: &str| -> Option<String> {
ctx.template_vars()
.all_env()
.get(name)
.cloned()
.or_else(|| ctx.env_var(name))
};
let tool = |name: &str| -> bool { anodizer_core::tool_detect::on_path(name) };
let endpoint = |url: &str| -> std::result::Result<(), String> {
let target = if url.contains("://") {
url.to_string()
} else {
format!("https://{url}")
};
let client = anodizer_core::http::blocking_client(Duration::from_secs(10))
.map_err(|e| format!("{e:#}"))?;
client
.get(&target)
.send()
.map(|_| ())
.map_err(|e| format!("{e:#}"))
};
let docker = || anodizer_core::tool_detect::tool_runs_with_args("docker", &["info"]);
env_preflight::evaluate(
requirements,
&env,
&EnvProbes {
tool: &tool,
endpoint: &endpoint,
docker: &docker,
},
)
}
fn cosign_key_refs(requirements: &[SourcedRequirement]) -> Vec<String> {
use anodizer_core::EnvRequirement::KeyEnv;
let mut refs: Vec<String> = Vec::new();
for sr in requirements {
if let KeyEnv {
kind: anodizer_core::KeyKind::Cosign,
var,
} = &sr.requirement
{
let key_ref = format!("env://{var}");
if !refs.contains(&key_ref) {
refs.push(key_ref);
}
}
}
refs
}
fn verify_cosign_keys_load(requirements: &[SourcedRequirement], log: &StageLogger) -> bool {
verify_cosign_keys_load_with(
requirements,
log,
anodizer_stage_sign::verify_cosign_key_loads,
)
}
fn verify_cosign_keys_load_with(
requirements: &[SourcedRequirement],
log: &StageLogger,
load: impl Fn(&str) -> anodizer_stage_sign::CosignKeyLoad,
) -> bool {
let mut all_loaded = true;
for key_ref in cosign_key_refs(requirements) {
match load(&key_ref) {
anodizer_stage_sign::CosignKeyLoad::Loaded => {
log.status(&format!("cosign key {key_ref} loads (offline verify)"));
}
anodizer_stage_sign::CosignKeyLoad::CosignUnavailable => {
log.warn(&format!(
"cosign not installed; skipping offline {key_ref} load verification \
— the key/password combo will be validated at sign time instead"
));
}
anodizer_stage_sign::CosignKeyLoad::CosignProbeFailed(detail) => {
log.warn(&format!(
"{detail}; skipping offline {key_ref} load verification \
— the key/password combo will be validated at sign time instead"
));
}
anodizer_stage_sign::CosignKeyLoad::Failed(detail) => {
all_loaded = false;
log.error(&format!(
"cosign key {key_ref} failed to load (wrong or missing COSIGN_PASSWORD, \
or malformed key): {detail}"
));
}
}
}
all_loaded
}
fn log_preflight_warnings(report: &EnvPreflightReport, log: &StageLogger) {
for w in &report.warnings {
log.warn(&format!(
"{} [recommended by: {}]",
w.message,
w.needed_by.join(", ")
));
}
}
pub fn run_env_preflight(
ctx: &Context,
scope: PreflightScope,
log: &StageLogger,
) -> EnvPreflightReport {
let requirements = collect_requirements(ctx, scope);
let mut report = evaluate_against_environment(ctx, &requirements);
for line in report.to_string().trim_end_matches('\n').lines() {
if report.ok() {
log.status(line);
} else {
log.error(line);
}
}
log_preflight_warnings(&report, log);
if !verify_cosign_keys_load(&requirements, log) {
report.note_failure(
"stage:sign",
"cosign signing key failed offline load verification",
);
}
report
}
pub struct PreflightOpts {
pub config_override: Option<PathBuf>,
pub json: bool,
pub skip: Vec<String>,
pub publishers: Vec<String>,
pub publish_only: bool,
pub token: Option<String>,
pub quiet: bool,
pub verbose: bool,
pub debug: bool,
}
pub fn run(opts: PreflightOpts) -> Result<()> {
let log = StageLogger::new(
"preflight",
Verbosity::from_flags(opts.quiet, opts.verbose, opts.debug),
);
let ctx_opts = anodizer_core::context::ContextOptions {
skip_stages: opts.skip.clone(),
publisher_allowlist: opts.publishers.clone(),
token: opts.token.clone(),
quiet: opts.quiet,
verbose: opts.verbose,
debug: opts.debug,
dry_run: true,
snapshot: true,
..Default::default()
};
let (_config, ctx) =
super::helpers::init_merge_stage_ctx(opts.config_override.as_deref(), ctx_opts, &log)?;
let scope = if opts.publish_only {
PreflightScope::PublishOnly
} else {
PreflightScope::Full
};
let requirements = collect_requirements(&ctx, scope);
let mut report = evaluate_against_environment(&ctx, &requirements);
if !verify_cosign_keys_load(&requirements, &log) {
report.note_failure(
"stage:sign",
"cosign signing key failed offline load verification",
);
}
if opts.json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
for line in report.to_string().trim_end_matches('\n').lines() {
log.status(line);
}
log_preflight_warnings(&report, &log);
}
if !report.ok() {
anyhow::bail!(
"preflight: {} environment failure(s) across {} check(s)",
report.failures.len(),
report.checks
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use anodizer_core::EnvRequirement;
use anodizer_core::test_helpers::TestContextBuilder;
fn crate_from_yaml(yaml: &str) -> anodizer_core::config::CrateConfig {
serde_yaml_ng::from_str(yaml).expect("crate config yaml")
}
#[test]
fn collect_requirements_unions_workspace_crates() {
let top = crate_from_yaml(
r#"
name: top
publish:
scoop:
repository: { owner: o, name: bucket }
"#,
);
let ws_crate = crate_from_yaml(
r#"
name: wscrate
publish:
aur:
private_key: "{{ .Env.PF_TEST_AUR_KEY }}"
"#,
);
let ws = anodizer_core::config::WorkspaceConfig {
name: "ws".to_string(),
crates: vec![ws_crate],
..Default::default()
};
let ctx = TestContextBuilder::new()
.crates(vec![top])
.workspaces(vec![ws])
.build();
let reqs = collect_requirements(&ctx, PreflightScope::Full);
assert!(
reqs.iter().any(|r| r.source == "publish:scoop"),
"top-level crate's scoop requirements missing: {reqs:?}"
);
let aur_key = reqs.iter().any(|r| {
r.source == "publish:aur"
&& matches!(
&r.requirement,
EnvRequirement::KeyEnv { var, .. } if var == "PF_TEST_AUR_KEY"
)
});
assert!(
aur_key,
"workspace crate's aur key requirement missing: {reqs:?}"
);
}
#[test]
fn skip_publish_drops_publisher_requirements() {
let top = crate_from_yaml(
r#"
name: top
publish:
scoop:
repository: { owner: o, name: bucket }
"#,
);
let ctx = TestContextBuilder::new()
.crates(vec![top])
.skip_stages(vec!["publish".to_string()])
.build();
let reqs = collect_requirements(&ctx, PreflightScope::Full);
assert!(
!reqs.iter().any(|r| r.source.starts_with("publish:")),
"publisher requirements survived --skip=publish: {reqs:?}"
);
assert!(
reqs.iter().any(|r| r.source == "stage:build"),
"stage requirements must survive a publish skip: {reqs:?}"
);
}
#[test]
fn empty_config_yields_no_publisher_requirements() {
let ctx = TestContextBuilder::new().build();
let reqs = collect_requirements(&ctx, PreflightScope::Full);
assert!(
!reqs.iter().any(|r| r.source.starts_with("publish:")),
"unconfigured publishers contributed requirements: {reqs:?}"
);
let top = crate_from_yaml("name: top\nrelease: { github: { owner: o, name: r } }");
let ctx = TestContextBuilder::new().crates(vec![top]).build();
let reqs = collect_requirements(&ctx, PreflightScope::Full);
assert!(
reqs.iter().any(|r| r.source == "publish:github-release"),
"a configured release block must require the token ladder: {reqs:?}"
);
}
#[test]
fn bundler_requirements_follow_configured_targets() {
let installer_yaml = |targets: &str| {
crate_from_yaml(&format!(
r#"
name: app
builds:
- binary: app
targets: [{targets}]
msis:
- wxs: app.wxs
version: v4
nsis:
- script: app.nsi
dmgs:
- {{}}
flatpaks:
- app_id: org.example.App
"#
))
};
let ctx = TestContextBuilder::new()
.crates(vec![installer_yaml(
"x86_64-pc-windows-msvc, aarch64-apple-darwin",
)])
.build();
let reqs = collect_requirements(&ctx, PreflightScope::Full);
let tool = |reqs: &[SourcedRequirement], source: &str, name: &str| {
reqs.iter().any(|r| {
r.source == source
&& matches!(&r.requirement, EnvRequirement::Tool { name: n } if n == name)
})
};
assert!(
tool(&reqs, "stage:msi", "wix"),
"windows target must demand the configured WiX v4 toolchain: {reqs:?}"
);
assert!(
tool(&reqs, "stage:nsis", "makensis"),
"windows target must demand makensis: {reqs:?}"
);
let dmg_ladder = reqs.iter().any(|r| {
r.source == "stage:dmg"
&& matches!(
&r.requirement,
EnvRequirement::ToolAnyOf { names } if names.contains(&"hdiutil".to_string())
)
});
assert!(
dmg_ladder,
"darwin target must demand the dmg tool ladder: {reqs:?}"
);
assert!(
!reqs.iter().any(|r| r.source == "stage:flatpak"),
"no linux target configured — flatpak must contribute nothing: {reqs:?}"
);
let ctx = TestContextBuilder::new()
.crates(vec![installer_yaml("x86_64-unknown-linux-gnu")])
.build();
let reqs = collect_requirements(&ctx, PreflightScope::Full);
for absent in ["stage:msi", "stage:nsis", "stage:dmg", "stage:pkg"] {
assert!(
!reqs.iter().any(|r| r.source == absent),
"linux-only matrix must not demand {absent} tools: {reqs:?}"
);
}
assert!(
tool(&reqs, "stage:flatpak", "flatpak-builder"),
"linux target must demand flatpak-builder: {reqs:?}"
);
}
#[test]
fn notarize_requirements_follow_active_entries() {
use anodizer_core::config::{
MacOSNotarizeApiConfig, MacOSSignConfig, MacOSSignNotarizeConfig, NotarizeConfig,
};
let mut ctx = TestContextBuilder::new().build();
ctx.config.notarize = Some(NotarizeConfig {
macos: Some(vec![MacOSSignNotarizeConfig {
sign: Some(MacOSSignConfig {
certificate: Some("{{ .Env.PF_P12_B64 }}".to_string()),
password: Some("{{ .Env.PF_P12_PASSWORD }}".to_string()),
..Default::default()
}),
notarize: Some(MacOSNotarizeApiConfig {
key: Some("{{ .Env.PF_ASC_KEY }}".to_string()),
..Default::default()
}),
..Default::default()
}]),
..Default::default()
});
let reqs = collect_requirements(&ctx, PreflightScope::Full);
assert!(
reqs.iter().any(|r| {
r.source == "stage:notarize"
&& matches!(&r.requirement, EnvRequirement::Tool { name } if name == "rcodesign")
}),
"active macos entry must demand rcodesign: {reqs:?}"
);
for var in ["PF_P12_B64", "PF_P12_PASSWORD", "PF_ASC_KEY"] {
assert!(
reqs.iter().any(|r| {
r.source == "stage:notarize"
&& matches!(
&r.requirement,
EnvRequirement::EnvAllOf { vars } if vars.contains(&var.to_string())
)
}),
"templated notarize field must demand {var}: {reqs:?}"
);
}
}
#[test]
fn announce_requirements_derive_from_announcer_config() {
use anodizer_core::config::{
AnnounceConfig, EmailAnnounce, SlackAnnounce, StringOrBool, TelegramAnnounce,
};
let mut ctx = TestContextBuilder::new().build();
ctx.config.announce = Some(AnnounceConfig {
email: Some(EmailAnnounce {
enabled: Some(StringOrBool::Bool(true)),
host: Some("smtp.example.com".to_string()),
username: Some("releases@example.com".to_string()),
..Default::default()
}),
slack: Some(SlackAnnounce {
enabled: Some(StringOrBool::Bool(true)),
..Default::default()
}),
telegram: Some(TelegramAnnounce {
enabled: Some(StringOrBool::Bool(true)),
bot_token: Some("{{ .Env.PF_TG_TOKEN }}".to_string()),
..Default::default()
}),
reddit: Some(Default::default()),
..Default::default()
});
for scope in [PreflightScope::Full, PreflightScope::PublishOnly] {
let reqs = collect_requirements(&ctx, scope);
let announce_env = |var: &str| {
reqs.iter().any(|r| {
r.source == "stage:announce"
&& matches!(
&r.requirement,
EnvRequirement::EnvAllOf { vars } if vars.contains(&var.to_string())
)
})
};
assert!(
announce_env("SMTP_PASSWORD"),
"{scope:?}: enabled email announcer must demand SMTP_PASSWORD: {reqs:?}"
);
assert!(
announce_env("SLACK_WEBHOOK"),
"{scope:?}: slack without webhook_url must demand the fallback: {reqs:?}"
);
assert!(
announce_env("PF_TG_TOKEN"),
"{scope:?}: templated bot_token must demand its env ref: {reqs:?}"
);
assert!(
!announce_env("TELEGRAM_TOKEN"),
"{scope:?}: configured bot_token must not demand the fallback: {reqs:?}"
);
assert!(
!announce_env("REDDIT_SECRET"),
"{scope:?}: a present-but-disabled announcer must contribute nothing: {reqs:?}"
);
}
let mut skipped = TestContextBuilder::new()
.skip_stages(vec!["announce".to_string()])
.build();
skipped.config.announce = ctx.config.announce.clone();
let reqs = collect_requirements(&skipped, PreflightScope::Full);
assert!(
!reqs.iter().any(|r| r.source == "stage:announce"),
"--skip=announce must drop announce requirements: {reqs:?}"
);
}
#[test]
fn publishers_allowlist_restricts_publisher_requirements() {
let top = crate_from_yaml(
r#"
name: top
publish:
cargo: {}
scoop:
repository: { owner: o, name: bucket }
"#,
);
let mut ctx = TestContextBuilder::new()
.crates(vec![top])
.publisher_allowlist(vec!["npm".to_string()])
.build();
ctx.config.npms = Some(vec![anodizer_core::config::NpmConfig::default()]);
let reqs = collect_requirements(&ctx, PreflightScope::PublishOnly);
let publisher_sources: Vec<&str> = reqs
.iter()
.map(|r| r.source.as_str())
.filter(|s| s.starts_with("publish:"))
.collect();
assert!(
!publisher_sources.is_empty(),
"the npm publisher must still contribute its own requirements: {reqs:?}"
);
assert!(
publisher_sources.iter().all(|s| *s == "publish:npm"),
"--publishers npm must yield only npm publisher requirements: {publisher_sources:?}"
);
for absent in ["publish:cargo", "publish:scoop", "publish:github-release"] {
assert!(
!reqs.iter().any(|r| r.source == absent),
"deselected publisher {absent} must contribute no requirements: {reqs:?}"
);
}
}
#[test]
fn publishers_allowlist_deselects_self_skipping_stages() {
let top = crate_from_yaml(
r#"
name: top
release: { github: { owner: o, name: r } }
blobs:
- provider: s3
bucket: releases
endpoint: "https://minio.example.com"
snapcrafts:
- publish: true
publish:
cargo: {}
"#,
);
let mut ctx = TestContextBuilder::new()
.crates(vec![top])
.signs(vec![anodizer_core::config::SignConfig {
artifacts: Some("all".to_string()),
cmd: Some("cosign".to_string()),
..Default::default()
}])
.binary_signs(vec![anodizer_core::config::SignConfig {
artifacts: Some("all".to_string()),
cmd: Some("cosign".to_string()),
..Default::default()
}])
.publisher_allowlist(vec!["npm".to_string()])
.build();
ctx.config.npms = Some(vec![anodizer_core::config::NpmConfig::default()]);
ctx.config.announce = Some({
use anodizer_core::config::{AnnounceConfig, SlackAnnounce, StringOrBool};
AnnounceConfig {
slack: Some(SlackAnnounce {
enabled: Some(StringOrBool::Bool(true)),
..Default::default()
}),
..Default::default()
}
});
let reqs = collect_requirements(&ctx, PreflightScope::PublishOnly);
let has = |source: &str| reqs.iter().any(|r| r.source == source);
let cosign_sign_reqs = reqs
.iter()
.filter(|r| {
r.source == "stage:sign"
&& matches!(&r.requirement, EnvRequirement::Tool { name } if name == "cosign")
})
.count();
assert!(
has("publish:npm"),
"npm is selected — it must still contribute its requirements: {reqs:?}"
);
for absent in [
"stage:blob",
"stage:snapcraft-publish",
"stage:docker",
"stage:docker-sign",
"stage:announce",
"stage:release",
] {
assert!(
!has(absent),
"allowlist-deselected stage {absent} must contribute no requirements: {reqs:?}"
);
}
assert_eq!(
cosign_sign_reqs, 0,
"under --publish-only --publishers npm BOTH sign slices must self-skip \
(signs: all consumers deselected; binary_signs: publish-only): {reqs:?}"
);
let mut keep_one = TestContextBuilder::new()
.crates(ctx.config.crates.clone())
.signs(vec![anodizer_core::config::SignConfig {
artifacts: Some("all".to_string()),
cmd: Some("cosign".to_string()),
..Default::default()
}])
.binary_signs(vec![anodizer_core::config::SignConfig {
artifacts: Some("all".to_string()),
cmd: Some("cosign".to_string()),
..Default::default()
}])
.publisher_allowlist(vec!["npm".to_string(), "github-release".to_string()])
.build();
keep_one.config.npms = ctx.config.npms.clone();
let reqs = collect_requirements(&keep_one, PreflightScope::PublishOnly);
assert!(
reqs.iter().any(|r| r.source == "stage:release"),
"selecting github-release must keep the release requirement: {reqs:?}"
);
let cosign_sign_reqs = reqs
.iter()
.filter(|r| {
r.source == "stage:sign"
&& matches!(&r.requirement, EnvRequirement::Tool { name } if name == "cosign")
})
.count();
assert_eq!(
cosign_sign_reqs, 1,
"selecting a signs consumer must revive the signs: slice; binary_signs: \
stays publish-only-skipped, so exactly one cosign demand: {reqs:?}"
);
}
#[test]
fn full_scope_keeps_both_sign_slices() {
let top = crate_from_yaml(
r#"
name: top
release: { github: { owner: o, name: r } }
publish:
cargo: {}
"#,
);
let ctx = TestContextBuilder::new()
.crates(vec![top])
.signs(vec![anodizer_core::config::SignConfig {
artifacts: Some("all".to_string()),
cmd: Some("cosign".to_string()),
..Default::default()
}])
.binary_signs(vec![anodizer_core::config::SignConfig {
artifacts: Some("all".to_string()),
cmd: Some("cosign".to_string()),
..Default::default()
}])
.build();
let reqs = collect_requirements(&ctx, PreflightScope::Full);
let cosign_sign_reqs = reqs
.iter()
.filter(|r| {
r.source == "stage:sign"
&& matches!(&r.requirement, EnvRequirement::Tool { name } if name == "cosign")
})
.count();
assert_eq!(
cosign_sign_reqs, 2,
"full scope must keep BOTH sign slices (signs: + binary_signs:): {reqs:?}"
);
}
#[test]
fn empty_allowlist_keeps_release_and_signs_for_main_job() {
let top = crate_from_yaml(
r#"
name: top
release: { github: { owner: o, name: r } }
publish:
cargo: {}
"#,
);
let mut ctx = TestContextBuilder::new()
.crates(vec![top])
.signs(vec![anodizer_core::config::SignConfig {
artifacts: Some("all".to_string()),
cmd: Some("cosign".to_string()),
..Default::default()
}])
.skip_stages(vec!["npm".to_string()])
.build();
ctx.config.npms = Some(vec![anodizer_core::config::NpmConfig::default()]);
let reqs = collect_requirements(&ctx, PreflightScope::PublishOnly);
let has = |source: &str| reqs.iter().any(|r| r.source == source);
assert!(
has("stage:release"),
"empty allowlist must keep the release requirement (main job): {reqs:?}"
);
assert!(
reqs.iter().any(|r| {
r.source == "stage:sign"
&& matches!(&r.requirement, EnvRequirement::Tool { name } if name == "cosign")
}),
"empty allowlist must keep the signs cosign demand (main job): {reqs:?}"
);
assert!(
!reqs.iter().any(|r| r.source == "publish:npm"),
"--skip=npm must still drop npm: {reqs:?}"
);
}
#[test]
fn skip_publisher_drops_only_that_publisher() {
let top = crate_from_yaml(
r#"
name: top
publish:
scoop:
repository: { owner: o, name: bucket }
"#,
);
let mut ctx = TestContextBuilder::new()
.crates(vec![top])
.skip_stages(vec!["npm".to_string()])
.build();
ctx.config.npms = Some(vec![anodizer_core::config::NpmConfig::default()]);
let reqs = collect_requirements(&ctx, PreflightScope::PublishOnly);
assert!(
!reqs.iter().any(|r| r.source == "publish:npm"),
"--skip=npm must drop npm requirements: {reqs:?}"
);
assert!(
reqs.iter().any(|r| r.source == "publish:scoop"),
"--skip=npm must keep other publishers' requirements: {reqs:?}"
);
}
#[test]
fn announce_only_scope_collects_announce_requirements_alone() {
use anodizer_core::config::{AnnounceConfig, StringOrBool, TelegramAnnounce};
let top = crate_from_yaml(
r#"
name: top
publish:
scoop:
repository: { owner: o, name: bucket }
"#,
);
let mut ctx = TestContextBuilder::new().crates(vec![top]).build();
ctx.config.announce = Some(AnnounceConfig {
telegram: Some(TelegramAnnounce {
enabled: Some(StringOrBool::Bool(true)),
..Default::default()
}),
..Default::default()
});
let reqs = collect_requirements(&ctx, PreflightScope::AnnounceOnly);
assert!(
reqs.iter().all(|r| r.source == "stage:announce"),
"announce-only scope must collect only announce requirements: {reqs:?}"
);
assert!(
reqs.iter().any(|r| matches!(
&r.requirement,
EnvRequirement::EnvAllOf { vars } if vars.contains(&"TELEGRAM_TOKEN".to_string())
)),
"enabled telegram announcer must demand its token: {reqs:?}"
);
}
fn mixed_surface_ctx() -> Context {
let top = crate_from_yaml(
r#"
name: top
release: { github: { owner: o, name: r } }
dockers_v2:
- images: [ghcr.io/o/top]
nfpms:
- formats: [rpm]
rpm:
signature:
key_file: /etc/anodizer/rpm-signing.gpg
blobs:
- provider: s3
bucket: releases
endpoint: "https://minio.example.com"
"#,
);
TestContextBuilder::new()
.crates(vec![top])
.signs(vec![anodizer_core::config::SignConfig {
artifacts: Some("all".to_string()),
cmd: Some("cosign".to_string()),
args: Some(vec!["--key".to_string(), "env://COSIGN_KEY".to_string()]),
..Default::default()
}])
.build()
}
#[test]
fn secrets_only_retains_credentials_drops_host_local_requirements() {
let ctx = mixed_surface_ctx();
let full = collect_requirements(&ctx, PreflightScope::Full);
assert!(
full.iter().any(|r| matches!(
&r.requirement,
EnvRequirement::Tool { name } if name == "cosign"
)),
"full scope must surface the cosign tool requirement: {full:?}"
);
assert!(
full.iter()
.any(|r| matches!(&r.requirement, EnvRequirement::DockerDaemon)),
"full scope must surface the docker daemon requirement: {full:?}"
);
assert!(
full.iter()
.any(|r| matches!(&r.requirement, EnvRequirement::Endpoint { .. })),
"full scope must surface the blob endpoint requirement: {full:?}"
);
assert!(
full.iter()
.any(|r| matches!(&r.requirement, EnvRequirement::KeyFile { .. })),
"full scope must surface the nfpm key_file requirement: {full:?}"
);
let secrets = collect_requirements(&ctx, PreflightScope::SecretsOnly);
assert!(
!secrets.is_empty(),
"secrets-only scope must retain the credential requirements: {secrets:?}"
);
for sr in &secrets {
assert!(
matches!(
&sr.requirement,
EnvRequirement::EnvAllOf { .. }
| EnvRequirement::EnvAnyOf { .. }
| EnvRequirement::KeyEnv { .. }
),
"secrets-only retained a non-credential requirement: {:?}",
sr
);
}
assert!(
secrets.iter().any(|r| matches!(
&r.requirement,
EnvRequirement::EnvAnyOf { vars }
if vars.contains(&"GITHUB_TOKEN".to_string())
)),
"secrets-only must retain the github token ladder: {secrets:?}"
);
assert!(
secrets.iter().any(|r| matches!(
&r.requirement,
EnvRequirement::KeyEnv { var, .. } if var == "COSIGN_KEY"
)),
"secrets-only must retain the env-borne cosign key: {secrets:?}"
);
assert!(
!secrets
.iter()
.any(|r| matches!(&r.requirement, EnvRequirement::Tool { .. })),
"secrets-only must drop Tool requirements: {secrets:?}"
);
assert!(
!secrets
.iter()
.any(|r| matches!(&r.requirement, EnvRequirement::DockerDaemon)),
"secrets-only must drop the docker daemon requirement: {secrets:?}"
);
assert!(
!secrets
.iter()
.any(|r| matches!(&r.requirement, EnvRequirement::Endpoint { .. })),
"secrets-only must drop endpoint requirements: {secrets:?}"
);
assert!(
!secrets
.iter()
.any(|r| matches!(&r.requirement, EnvRequirement::KeyFile { .. })),
"secrets-only must drop on-disk key-file requirements: {secrets:?}"
);
}
#[test]
fn secrets_only_unions_workspace_crates() {
let top = crate_from_yaml(
r#"
name: top
release: { github: { owner: o, name: r } }
"#,
);
let ws_crate = crate_from_yaml(
r#"
name: wscrate
publish:
aur:
private_key: "{{ .Env.PF_TEST_AUR_KEY }}"
"#,
);
let ws = anodizer_core::config::WorkspaceConfig {
crates: vec![ws_crate],
..Default::default()
};
let mut ctx = TestContextBuilder::new().crates(vec![top]).build();
ctx.config.workspaces = Some(vec![ws]);
let secrets = collect_requirements(&ctx, PreflightScope::SecretsOnly);
assert!(
secrets.iter().any(|r| {
r.source == "publish:aur"
&& matches!(
&r.requirement,
EnvRequirement::KeyEnv { var, .. } if var == "PF_TEST_AUR_KEY"
)
}),
"secrets-only must union the workspace crate's AUR key requirement: {secrets:?}"
);
for sr in &secrets {
assert!(
matches!(
&sr.requirement,
EnvRequirement::EnvAllOf { .. }
| EnvRequirement::EnvAnyOf { .. }
| EnvRequirement::KeyEnv { .. }
),
"secrets-only retained a non-credential requirement under workspace union: {:?}",
sr
);
}
}
#[test]
fn build_block_reports_cross_toolchain_for_cross_target() {
if anodizer_core::partial::detect_host_target()
.as_deref()
.unwrap_or_default()
!= "x86_64-unknown-linux-gnu"
{
return;
}
let krate = crate_from_yaml(
r#"
name: app
builds:
- binary: app
targets: [aarch64-unknown-linux-gnu]
"#,
);
let ctx = TestContextBuilder::new().crates(vec![krate]).build();
let full = collect_requirements(&ctx, PreflightScope::Full);
let build_tools: Vec<&str> = full
.iter()
.filter(|r| r.source == "stage:build")
.filter_map(|r| match &r.requirement {
EnvRequirement::Tool { name } => Some(name.as_str()),
_ => None,
})
.collect();
assert_eq!(build_tools.first(), Some(&"cargo"));
assert!(
build_tools.contains(&"cargo-zigbuild") && build_tools.contains(&"zig"),
"build block must report cargo-zigbuild + zig for a cross target: {build_tools:?}"
);
let advisory_of = |name: &str| -> bool {
full.iter()
.find(|r| {
r.source == "stage:build"
&& matches!(&r.requirement, EnvRequirement::Tool { name: n } if n == name)
})
.map(|r| r.advisory)
.unwrap_or_else(|| panic!("missing build tool {name}"))
};
assert!(!advisory_of("cargo"), "cargo must stay hard-required");
assert!(advisory_of("zig"), "zig must be advisory");
assert!(
advisory_of("cargo-zigbuild"),
"cargo-zigbuild must be advisory"
);
let report = anodizer_core::env_preflight::evaluate(
&full,
&|_| None,
&anodizer_core::env_preflight::EnvProbes {
tool: &|name| name == "cargo",
endpoint: &|_| Ok(()),
docker: &|| true,
},
);
assert!(
report.ok(),
"release must not be blocked by a missing cross toolchain: {report}"
);
assert!(
report.warnings.iter().any(|w| w.message.contains("zig")),
"missing zig must surface as a warning: {:?}",
report.warnings
);
let secrets = collect_requirements(&ctx, PreflightScope::SecretsOnly);
assert!(
!secrets
.iter()
.any(|r| matches!(&r.requirement, EnvRequirement::Tool { .. })),
"secrets-only must drop the cross toolchain Tool reqs: {secrets:?}"
);
}
#[test]
fn cosign_key_refs_reconstructs_env_scheme_and_dedups() {
let reqs = vec![
SourcedRequirement::new(
"stage:sign",
EnvRequirement::KeyEnv {
kind: anodizer_core::KeyKind::Cosign,
var: "COSIGN_KEY".to_string(),
},
),
SourcedRequirement::new(
"stage:sign",
EnvRequirement::KeyEnv {
kind: anodizer_core::KeyKind::Cosign,
var: "COSIGN_KEY".to_string(),
},
),
SourcedRequirement::new(
"publish:aur",
EnvRequirement::KeyEnv {
kind: anodizer_core::KeyKind::SshPrivate,
var: "AUR_SSH_KEY".to_string(),
},
),
];
assert_eq!(cosign_key_refs(&reqs), vec!["env://COSIGN_KEY".to_string()]);
}
#[test]
fn verify_cosign_keys_load_noop_without_cosign_config() {
let reqs = vec![SourcedRequirement::new(
"stage:build",
EnvRequirement::Tool {
name: "cargo".to_string(),
},
)];
let (log, capture) = StageLogger::with_capture("preflight", Verbosity::Normal);
assert!(verify_cosign_keys_load(&reqs, &log));
assert!(
capture.all_messages().is_empty(),
"no cosign requirement must emit nothing: {:?}",
capture.all_messages()
);
}
#[test]
fn verify_cosign_keys_load_warns_when_cosign_absent() {
use anodizer_core::log::LogLevel;
let reqs = vec![SourcedRequirement::new(
"stage:sign",
EnvRequirement::KeyEnv {
kind: anodizer_core::KeyKind::Cosign,
var: "COSIGN_KEY".to_string(),
},
)];
let (log, capture) = StageLogger::with_capture("preflight", Verbosity::Normal);
let all_loaded = verify_cosign_keys_load_with(&reqs, &log, |_| {
anodizer_stage_sign::CosignKeyLoad::CosignUnavailable
});
assert!(all_loaded, "cosign-absent must not fail the gate");
let msgs = capture.all_messages();
assert!(
msgs.iter()
.any(|(lvl, m)| *lvl == LogLevel::Warn && m.contains("cosign not installed")),
"cosign-absent must WARN: {msgs:?}"
);
assert!(
!msgs.iter().any(|(lvl, _)| *lvl == LogLevel::Error),
"cosign-absent must NOT emit an error: {msgs:?}"
);
}
#[test]
fn verify_cosign_keys_load_fails_on_bad_key() {
use anodizer_core::log::LogLevel;
let reqs = vec![SourcedRequirement::new(
"stage:sign",
EnvRequirement::KeyEnv {
kind: anodizer_core::KeyKind::Cosign,
var: "COSIGN_KEY".to_string(),
},
)];
let (log, capture) = StageLogger::with_capture("preflight", Verbosity::Normal);
let all_loaded = verify_cosign_keys_load_with(&reqs, &log, |_| {
anodizer_stage_sign::CosignKeyLoad::Failed("wrong COSIGN_PASSWORD".to_string())
});
assert!(!all_loaded, "a failing key load must fail the gate");
let msgs = capture.all_messages();
assert!(
msgs.iter()
.any(|(lvl, m)| *lvl == LogLevel::Error && m.contains("failed to load")),
"a bad key must emit an Error: {msgs:?}"
);
}
#[test]
fn reduced_scope_membership_matches_pipeline_builders() {
let to_set = |p: crate::pipeline::Pipeline| -> std::collections::BTreeSet<String> {
p.stage_names().iter().map(|s| s.to_string()).collect()
};
let publish_only = PreflightScope::PublishOnly
.included_stage_set()
.expect("publish-only is a reduced scope");
assert_eq!(
publish_only,
to_set(crate::pipeline::build_publish_only_pipeline()),
"publish-only scope drifted from build_publish_only_pipeline"
);
for producer in [
"build", "nfpm", "srpm", "sbom", "makeself", "upx", "appimage",
] {
assert!(
!publish_only.contains(producer),
"publish-only scope must not preflight the from-scratch producer '{producer}'"
);
}
let announce_only = PreflightScope::AnnounceOnly
.included_stage_set()
.expect("announce-only is a reduced scope");
assert_eq!(
announce_only,
to_set(crate::pipeline::build_announce_pipeline()),
"announce-only scope drifted from build_announce_pipeline"
);
assert!(
PreflightScope::Full.included_stage_set().is_none(),
"full scope must admit every stage"
);
assert!(
PreflightScope::SecretsOnly.included_stage_set().is_none(),
"secrets-only narrows by requirement kind, not stage membership"
);
}
#[test]
fn requirement_gated_stage_names_exist_in_release_pipeline() {
let release = crate::pipeline::build_release_pipeline();
let names = release.stage_names();
for gated in GATED_STAGES {
assert!(
names.contains(&gated.stage),
"collect_requirements gates on '{}' but the release pipeline has no such stage: {names:?}",
gated.stage
);
}
let mut seen = std::collections::BTreeSet::new();
for gated in GATED_STAGES {
assert!(
seen.insert(gated.stage),
"duplicate GATED_STAGES row for '{}'",
gated.stage
);
}
}
}