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::git::{TagPosition, TagSource};
use anodizer_core::log::StageLogger;
use anodizer_stage_publish::reconcile_report::ReconcileReport;
use anyhow::Result;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PreflightScope {
Full,
PublishOnly,
AnnounceOnly,
}
impl PreflightScope {
fn included_stage_set(self) -> Option<std::collections::BTreeSet<String>> {
let pipeline = match self {
PreflightScope::Full => 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(),
)
}
}
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| {
if anodizer_stage_sign::signs_fully_deselected(ctx) {
return Vec::new();
}
vec![
(
"stage:sign".to_string(),
anodizer_stage_sign::sign_env_requirements(ctx),
),
(
"stage:sign".to_string(),
anodizer_stage_sign::binary_sign_env_requirements(ctx),
),
]
},
},
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!(
"install-script",
Skip,
anodizer_stage_install_script::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;
let mut publish_runs = false;
let mut nfpm_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),
};
match gated.stage {
"build" => build_runs = stage_on,
"publish" => publish_runs = stage_on,
"nfpm" => nfpm_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)),
);
}
if publish_runs {
for publisher in anodizer_stage_publish::registry::all_publishers() {
if ctx.publisher_deselected(publisher.name()) {
continue;
}
let source = format!("publish:{}", publisher.name());
out.extend(
publisher
.advisory_requirements(ctx)
.into_iter()
.map(|r| SourcedRequirement::new_advisory(&source, r)),
);
}
}
if nfpm_runs {
out.extend(
anodizer_stage_nfpm::advisory_env_requirements(ctx)
.into_iter()
.map(|r| SourcedRequirement::new_advisory("stage:nfpm", r)),
);
}
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 gpg_sign_cmds(requirements: &[SourcedRequirement]) -> Vec<String> {
let mut cmds: Vec<String> = Vec::new();
for sr in requirements {
if sr.source != "stage:sign" && sr.source != "stage:docker-sign" {
continue;
}
if let anodizer_core::EnvRequirement::Tool { name } = &sr.requirement
&& anodizer_core::signing::is_gpg_command(name)
&& !cmds.contains(name)
{
cmds.push(name.clone());
}
}
cmds
}
fn verify_gpg_faked_system_time(
requirements: &[SourcedRequirement],
ctx: &Context,
log: &StageLogger,
) -> bool {
let sde_set = ctx.env_var("SOURCE_DATE_EPOCH").is_some();
verify_gpg_faked_system_time_with(requirements, sde_set, log, |cmd| {
if !anodizer_core::tool_detect::on_path(cmd) {
return true;
}
anodizer_core::tool_detect::tool_runs_with_args(
cmd,
&["--faked-system-time=19700101T000000!", "--version"],
)
})
}
fn verify_gpg_faked_system_time_with(
requirements: &[SourcedRequirement],
sde_set: bool,
log: &StageLogger,
probe: impl Fn(&str) -> bool,
) -> bool {
let mut all_support = true;
for cmd in gpg_sign_cmds(requirements) {
if probe(&cmd) {
log.status(&format!(
"{cmd} accepts --faked-system-time (deterministic signature timestamps)"
));
continue;
}
if sde_set {
all_support = false;
log.error(&format!(
"{cmd} rejects --faked-system-time (gpg < 2.0.10?) — SOURCE_DATE_EPOCH is set, \
so the sign stage will inject the flag and gpg will fail mid-release"
));
} else {
log.warn(&format!(
"{cmd} rejects --faked-system-time (gpg < 2.0.10?) — harmless now, but any run \
with SOURCE_DATE_EPOCH set injects the flag and fails at sign time"
));
}
}
all_support
}
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",
);
}
if !verify_gpg_faked_system_time(&requirements, ctx, log) {
report.note_failure(
"stage:sign",
"gpg rejects --faked-system-time while SOURCE_DATE_EPOCH is set",
);
}
report
}
pub(crate) struct PreflightOutcome {
pub environment: EnvPreflightReport,
pub publishers: Option<anodizer_core::preflight::PreflightReport>,
pub reconcile: ReconcileReport,
}
impl PreflightOutcome {
pub(crate) fn ok(&self) -> bool {
self.environment.ok()
&& self
.publishers
.as_ref()
.is_none_or(|report| report.blockers.is_empty())
&& self.reconcile.blocking().is_empty()
}
pub(crate) fn failure_message(&self) -> String {
if !self.environment.ok() {
return preflight_failure_message(&self.environment);
}
if let Some(report) = self.publishers.as_ref()
&& !report.blockers.is_empty()
{
return format!(
"preflight: {} resilience blocker(s): {}",
report.blockers.len(),
report.blockers.join("; "),
);
}
let blocking = self.reconcile.blocking().len();
format!(
"preflight: {blocking} required publisher(s) diverged — the version is already \
published with different content; bump the version"
)
}
}
pub(crate) fn preflight_failure_message(report: &EnvPreflightReport) -> String {
format!(
"preflight: {} environment failure(s) across {} check(s); \
fix the issues above before re-running",
report.failures.len(),
report.checks
)
}
pub(crate) fn run_engine(
ctx: &mut Context,
scope: PreflightScope,
log: &StageLogger,
) -> Result<PreflightOutcome> {
let environment = run_env_preflight(ctx, scope, log);
let publishers = if scope == PreflightScope::AnnounceOnly {
log.status("skipped publisher preflight — --announce-only does not publish");
None
} else if ctx.should_skip("publish") {
log.verbose("skipped publisher preflight — publish is skipped");
None
} else {
let report = anodizer_stage_publish::preflight::run_preflight(ctx, log)?;
if report.entries.is_empty() {
log.verbose("skipped one-way-door preflight — no one-way-door publishers configured");
} else {
report.emit(log);
}
if report.blockers.is_empty() {
log.status(&format!(
"preflight found {} publisher(s) clean",
report.clean_count()
));
}
Some(report)
};
let reconcile = match reconcile_sweep(ctx, log) {
ReconcileSweep::Stale { reason } => ReconcileReport::skipped(reason),
ReconcileSweep::Applies => ReconcileReport::probe(ctx),
};
reconcile.emit(log);
Ok(PreflightOutcome {
environment,
publishers,
reconcile,
})
}
#[derive(Debug, PartialEq, Eq)]
enum ReconcileSweep {
Applies,
Stale { reason: String },
}
fn reconcile_sweep(ctx: &Context, log: &StageLogger) -> ReconcileSweep {
let Some(git_info) = ctx.git_info.as_ref() else {
return ReconcileSweep::Applies;
};
if git_info.tag_source == TagSource::Declared {
return ReconcileSweep::Applies;
}
let tag = git_info.tag.as_str();
let root = ctx
.options
.project_root
.clone()
.unwrap_or_else(|| PathBuf::from("."));
match anodizer_core::git::tag_position_in(&root, tag) {
Ok(TagPosition::Missing) | Ok(TagPosition::AtHead) => ReconcileSweep::Applies,
Ok(TagPosition::AncestorOfHead) => ReconcileSweep::Stale {
reason: format!(
"{tag} is already released and HEAD has advanced past it; \
this tree will cut a new version"
),
},
Ok(TagPosition::UnrelatedToHead) => ReconcileSweep::Stale {
reason: format!(
"{tag} is already released and HEAD is not on its history; \
this tree will not publish that version"
),
},
Err(e) => {
log.verbose(&format!(
"could not locate tag {tag} relative to HEAD, probing anyway: {e:#}"
));
ReconcileSweep::Applies
}
}
}
mod standalone;
pub use standalone::{PreflightOpts, run};
#[cfg(test)]
mod tests;