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,
}
impl PreflightScope {
fn includes(self, stage: &str) -> bool {
match self {
PreflightScope::Full => true,
PreflightScope::PublishOnly => matches!(
stage,
"sign"
| "docker"
| "docker-sign"
| "release"
| "publish"
| "blob"
| "snapcraft-publish"
| "announce"
| "verify-release"
),
PreflightScope::AnnounceOnly => stage == "announce",
}
}
}
pub fn collect_requirements(ctx: &Context, scope: PreflightScope) -> Vec<SourcedRequirement> {
let mut out: Vec<SourcedRequirement> = Vec::new();
let mut add = |source: &str, reqs: Vec<anodizer_core::EnvRequirement>| {
out.extend(reqs.into_iter().map(|r| SourcedRequirement::new(source, r)));
};
let runs = |stage: &str| -> bool { scope.includes(stage) && !ctx.should_skip(stage) };
if runs("build") {
add(
"stage:build",
vec![anodizer_core::EnvRequirement::Tool {
name: "cargo".to_string(),
}],
);
}
if runs("nfpm") {
add("stage:nfpm", anodizer_stage_nfpm::env_requirements(ctx));
}
if runs("srpm") {
add("stage:srpm", anodizer_stage_srpm::env_requirements(ctx));
}
if runs("snapcraft") {
add(
"stage:snapcraft",
anodizer_stage_snapcraft::build_env_requirements(ctx),
);
}
if runs("snapcraft-publish") {
add(
"stage:snapcraft-publish",
anodizer_stage_snapcraft::publish_env_requirements(ctx),
);
}
if runs("sign") {
add(
"stage:sign",
anodizer_stage_sign::sign_env_requirements(ctx),
);
add(
"stage:sign",
anodizer_stage_sign::binary_sign_env_requirements(ctx),
);
}
if runs("docker-sign") {
add(
"stage:docker-sign",
anodizer_stage_sign::docker_sign_env_requirements(ctx),
);
}
if runs("sbom") {
add("stage:sbom", anodizer_stage_sbom::env_requirements(ctx));
}
if runs("makeself") {
add(
"stage:makeself",
anodizer_stage_makeself::env_requirements(ctx),
);
}
if runs("upx") {
add("stage:upx", anodizer_stage_upx::env_requirements(ctx));
}
if runs("appimage") {
add(
"stage:appimage",
anodizer_stage_appimage::env_requirements(ctx),
);
}
if runs("docker") {
add("stage:docker", anodizer_stage_docker::env_requirements(ctx));
}
if runs("blob") {
add("stage:blob", anodizer_stage_blob::env_requirements(ctx));
}
if runs("verify-release") {
add(
"stage:verify-release",
anodizer_stage_verify_release::env_requirements(ctx),
);
}
if runs("msi") {
add("stage:msi", anodizer_stage_msi::env_requirements(ctx));
}
if runs("nsis") {
add("stage:nsis", anodizer_stage_nsis::env_requirements(ctx));
}
if runs("pkg") {
add("stage:pkg", anodizer_stage_pkg::env_requirements(ctx));
}
if runs("dmg") {
add("stage:dmg", anodizer_stage_dmg::env_requirements(ctx));
}
if runs("appbundle") {
add(
"stage:appbundle",
anodizer_stage_appbundle::env_requirements(ctx),
);
}
if runs("flatpak") {
add(
"stage:flatpak",
anodizer_stage_flatpak::env_requirements(ctx),
);
}
if runs("notarize") {
add(
"stage:notarize",
anodizer_stage_notarize::env_requirements(ctx),
);
}
if runs("announce") {
add(
"stage:announce",
anodizer_stage_announce::env_requirements(ctx),
);
}
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 runs("release") && !release_skipped {
add(
"stage:release",
anodizer_core::Publisher::requirements(
&anodizer_stage_release::publisher::GithubReleasePublisher::new(),
ctx,
),
);
}
if runs("publish") {
for publisher in anodizer_stage_publish::registry::all_publishers() {
add(
&format!("publish:{}", publisher.name()),
publisher.requirements(ctx),
);
}
}
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::util::find_binary(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,
},
)
}
pub fn run_env_preflight(
ctx: &Context,
scope: PreflightScope,
log: &StageLogger,
) -> EnvPreflightReport {
let requirements = collect_requirements(ctx, scope);
let 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);
}
}
report
}
pub struct PreflightOpts {
pub config_override: Option<PathBuf>,
pub json: bool,
pub skip: 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(),
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 report = evaluate_against_environment(&ctx, &requirements);
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);
}
}
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:
- 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 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:?}"
);
}
}