use crate::artifact::Artifact;
use crate::config::{self, BeforePublishArtifactFilter, HookEntry};
use crate::log::StageLogger;
use crate::template::{self, TemplateVars};
use anyhow::{Context as _, Result};
use std::process::Command;
fn redact_secrets(output: &str) -> String {
let env: Vec<(String, String)> = std::env::vars().collect();
crate::redact::string(output, &env)
}
fn render_hook_template(template: &str, vars: &TemplateVars, label: &str) -> Result<String> {
template::render(template, vars)
.with_context(|| format!("{} hook: render template '{}'", label, template))
}
#[derive(Clone, Copy)]
pub struct HookRunContext<'a> {
pub dry_run: bool,
pub log: &'a StageLogger,
pub template_vars: Option<&'a TemplateVars>,
pub build_env: Option<&'a std::collections::HashMap<String, String>>,
}
impl<'a> HookRunContext<'a> {
pub fn new(
dry_run: bool,
log: &'a StageLogger,
template_vars: Option<&'a TemplateVars>,
) -> Self {
Self {
dry_run,
log,
template_vars,
build_env: None,
}
}
}
pub fn run_hooks(hooks: &[HookEntry], label: &str, ctx: HookRunContext<'_>) -> Result<()> {
let HookRunContext {
dry_run,
log,
template_vars,
build_env,
} = ctx;
for hook in hooks {
let (raw_cmd, raw_dir, env, output_flag, if_cond) = match hook {
HookEntry::Simple(s) => (s.as_str(), None, None, None, None),
HookEntry::Structured(h) => (
h.cmd.as_str(),
h.dir.as_deref(),
h.env.as_ref(),
h.output,
h.if_condition.as_deref(),
),
};
if let Some(tv) = template_vars {
let proceed = config::evaluate_if_condition(if_cond, &format!("{label} hook"), |t| {
render_hook_template(t, tv, label)
})?;
if !proceed {
tracing::debug!(
label = label,
cmd = raw_cmd,
"skipping hook: `if` condition evaluated falsy"
);
continue;
}
} else if let Some(cond) = if_cond {
let trimmed = cond.trim();
let falsy = matches!(trimmed, "false" | "0" | "no");
if falsy {
tracing::debug!(
label = label,
cmd = raw_cmd,
"skipping hook: literal `if` condition is falsy"
);
continue;
}
}
let cmd_str = if let Some(tv) = template_vars {
render_hook_template(raw_cmd, tv, label)?
} else {
raw_cmd.to_string()
};
let dir_str = match raw_dir {
Some(d) => Some(if let Some(tv) = template_vars {
render_hook_template(d, tv, label)?
} else {
d.to_string()
}),
None => None,
};
let expanded_env: Option<Vec<(String, String)>> = match env {
Some(envs) => {
let pairs = if let Some(tv) = template_vars {
config::render_env_entries(envs, |s| render_hook_template(s, tv, label))
.with_context(|| format!("{label} hook: render env entries"))?
} else {
config::parse_env_entries(envs)
.with_context(|| format!("{label} hook: parse env entries"))?
};
Some(pairs)
}
None => None,
};
if dry_run {
log.status(&format!("[dry-run] {} hook: {}", label, cmd_str));
} else {
log.status(&format!("running {} hook: {}", label, cmd_str));
let mut command = Command::new("sh");
command.arg("-c").arg(&cmd_str);
if let Some(ref d) = dir_str {
command.current_dir(d);
}
if let Some(be) = build_env {
for (k, v) in be {
command.env(k, v);
}
}
if let Some(ref envs) = expanded_env {
for (k, v) in envs {
command.env(k, v);
}
}
let output = command
.output()
.with_context(|| format!("failed to spawn {} hook: {}", label, cmd_str))?;
let redacted_stdout = redact_secrets(&String::from_utf8_lossy(&output.stdout));
let redacted_stderr = redact_secrets(&String::from_utf8_lossy(&output.stderr));
if output_flag == Some(true) && !redacted_stdout.trim().is_empty() {
log.status(&format!("[hook output] {}", redacted_stdout.trim()));
}
let redacted_output = std::process::Output {
status: output.status,
stdout: redacted_stdout.into_bytes(),
stderr: redacted_stderr.into_bytes(),
};
log.check_output(redacted_output, &format!("{} hook: {}", label, cmd_str))?;
}
}
Ok(())
}
pub struct BeforePublishStage;
impl crate::stage::Stage for BeforePublishStage {
fn name(&self) -> &str {
"before-publish"
}
fn run(&self, ctx: &mut crate::context::Context) -> Result<()> {
let log = ctx.logger("before-publish");
let Some(cfg) = ctx.config.before_publish.as_ref() else {
return Ok(());
};
let Some(hooks) = cfg.hooks.as_ref() else {
return Ok(());
};
if hooks.is_empty() {
return Ok(());
}
let dry_run = ctx.is_dry_run();
let base_vars = ctx.template_vars().clone();
let artifacts: Vec<Artifact> = ctx.artifacts.all().to_vec();
for entry in hooks {
run_before_publish_entry(entry, &artifacts, dry_run, &log, &base_vars)?;
}
Ok(())
}
}
fn run_before_publish_entry(
entry: &HookEntry,
artifacts: &[Artifact],
dry_run: bool,
log: &StageLogger,
base_vars: &TemplateVars,
) -> Result<()> {
let (ids_filter, kind_filter) = match entry {
HookEntry::Simple(_) => (None, BeforePublishArtifactFilter::All),
HookEntry::Structured(h) => (
h.ids.as_deref(),
h.artifacts.unwrap_or(BeforePublishArtifactFilter::All),
),
};
for artifact in artifacts {
if !kind_filter.matches(artifact.kind) {
continue;
}
if let Some(allow_ids) = ids_filter {
let id = artifact
.metadata
.get("id")
.map(String::as_str)
.unwrap_or("");
if !allow_ids.iter().any(|a| a == id) {
continue;
}
}
let mut vars = base_vars.clone();
bind_per_artifact_vars(&mut vars, artifact);
let single = std::slice::from_ref(entry);
run_hooks(
single,
"before-publish",
HookRunContext::new(dry_run, log, Some(&vars)),
)?;
}
Ok(())
}
fn bind_per_artifact_vars(vars: &mut TemplateVars, artifact: &Artifact) {
vars.set("ArtifactPath", &artifact.path.to_string_lossy());
vars.set("ArtifactName", artifact.name());
vars.set("ArtifactExt", &artifact.ext());
vars.set("ArtifactKind", artifact.kind.as_str());
vars.set(
"ArtifactID",
artifact
.metadata
.get("id")
.map(String::as_str)
.unwrap_or(""),
);
if let Some(target) = artifact.target.as_deref() {
let (os, arch) = crate::target::map_target(target);
vars.set("Os", &os);
vars.set("Arch", &arch);
vars.set("Target", target);
} else {
vars.set("Os", "");
vars.set("Arch", "");
vars.set("Target", "");
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::StructuredHook;
use crate::log::{StageLogger, Verbosity};
use std::collections::HashMap;
fn test_logger() -> StageLogger {
StageLogger::new("test", Verbosity::Normal)
}
fn vars_with_snapshot(is_snapshot: bool) -> TemplateVars {
let mut v = TemplateVars::new();
v.set("IsSnapshot", if is_snapshot { "true" } else { "false" });
v
}
fn structured(cmd: &str, if_cond: Option<&str>) -> HookEntry {
HookEntry::Structured(StructuredHook {
cmd: cmd.to_string(),
if_condition: if_cond.map(str::to_string),
..Default::default()
})
}
#[test]
fn hook_if_snapshot_template_runs_on_snapshot() {
let log = test_logger();
let vars = vars_with_snapshot(true);
let hooks = vec![structured("true", Some("{{ IsSnapshot }}"))];
run_hooks(&hooks, "test", HookRunContext::new(true, &log, Some(&vars)))
.expect("snapshot=true must let the hook proceed");
}
#[test]
fn hook_if_snapshot_template_skips_when_not_snapshot() {
let log = test_logger();
let vars = vars_with_snapshot(false);
let hooks = vec![structured(
"false-cmd-must-be-skipped",
Some("{{ IsSnapshot }}"),
)];
run_hooks(
&hooks,
"test",
HookRunContext::new(false, &log, Some(&vars)),
)
.expect("falsy `if:` must skip without spawning the cmd");
}
#[test]
fn hook_if_literal_true_always_runs() {
let log = test_logger();
let vars = vars_with_snapshot(false);
let hooks = vec![structured("true", Some("true"))];
run_hooks(&hooks, "test", HookRunContext::new(true, &log, Some(&vars)))
.expect("`if: true` must proceed");
}
#[test]
fn hook_if_empty_literal_is_noop_gate() {
let log = test_logger();
let vars = vars_with_snapshot(false);
let hooks = vec![structured("true", Some(""))];
run_hooks(&hooks, "test", HookRunContext::new(true, &log, Some(&vars)))
.expect("empty `if:` literal must be a no-op (always proceed)");
}
#[test]
fn hook_if_empty_literal_no_vars_proceeds() {
let log = test_logger();
let hooks = vec![structured("true", Some(""))];
run_hooks(&hooks, "test", HookRunContext::new(true, &log, None))
.expect("empty `if:` with no vars must proceed (no-op gate)");
}
#[test]
fn hook_if_falsy_literal_no_vars_skips() {
let log = test_logger();
let hooks = vec![structured("false-cmd-must-be-skipped", Some("false"))];
run_hooks(&hooks, "test", HookRunContext::new(false, &log, None))
.expect("falsy literal `if:` with no vars must skip without spawning");
}
fn run_env_probe_hook(
out_file: &std::path::Path,
keys: &[&str],
hook_env: Option<Vec<String>>,
build_env: Option<&HashMap<String, String>>,
) -> Result<()> {
let log = test_logger();
let out = out_file.display().to_string().replace('\\', "/");
let probe = keys
.iter()
.map(|k| format!("echo {k}=${k} >> {out}"))
.collect::<Vec<_>>()
.join("; ");
let hooks = vec![HookEntry::Structured(StructuredHook {
cmd: probe,
env: hook_env,
..Default::default()
})];
let vars = TemplateVars::new();
run_hooks(
&hooks,
"build",
HookRunContext {
dry_run: false,
log: &log,
template_vars: Some(&vars),
build_env,
},
)
}
#[test]
fn build_env_reaches_build_hook() {
let dir = std::env::temp_dir().join(format!("anodizer-be-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let out = dir.join("reaches.txt");
let _ = std::fs::remove_file(&out);
let mut build_env = HashMap::new();
build_env.insert("MY_BUILD_VAR".to_string(), "from-build-env".to_string());
run_env_probe_hook(&out, &["MY_BUILD_VAR"], None, Some(&build_env)).expect("hook must run");
let contents = std::fs::read_to_string(&out).unwrap();
assert!(
contents.contains("MY_BUILD_VAR=from-build-env"),
"build env var must reach the hook; got: {contents:?}"
);
let _ = std::fs::remove_file(&out);
}
#[test]
fn hook_env_overrides_build_env_on_key_conflict() {
let dir = std::env::temp_dir().join(format!("anodizer-be-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let out = dir.join("precedence.txt");
let _ = std::fs::remove_file(&out);
let mut build_env = HashMap::new();
build_env.insert("SHARED".to_string(), "build-loses".to_string());
run_env_probe_hook(
&out,
&["SHARED"],
Some(vec!["SHARED=hook-wins".to_string()]),
Some(&build_env),
)
.expect("hook must run");
let contents = std::fs::read_to_string(&out).unwrap();
assert!(
contents.contains("SHARED=hook-wins"),
"hook env must override build env on key conflict (GR append order); got: {contents:?}"
);
assert!(
!contents.contains("SHARED=build-loses"),
"build env value must not survive a hook-env override; got: {contents:?}"
);
let _ = std::fs::remove_file(&out);
}
#[test]
fn absent_build_env_is_unchanged_behavior() {
let dir = std::env::temp_dir().join(format!("anodizer-be-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let out = dir.join("absent.txt");
let _ = std::fs::remove_file(&out);
run_env_probe_hook(&out, &["NOT_SET_ANYWHERE"], None, None).expect("hook must run");
let contents = std::fs::read_to_string(&out).unwrap();
assert!(
contents.contains("NOT_SET_ANYWHERE="),
"absent build env must leave behavior unchanged; got: {contents:?}"
);
let _ = std::fs::remove_file(&out);
}
#[test]
fn empty_build_env_map_adds_nothing() {
let dir = std::env::temp_dir().join(format!("anodizer-be-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let out = dir.join("empty.txt");
let _ = std::fs::remove_file(&out);
let build_env: HashMap<String, String> = HashMap::new();
run_env_probe_hook(&out, &["NOT_SET_ANYWHERE"], None, Some(&build_env))
.expect("hook must run");
let contents = std::fs::read_to_string(&out).unwrap();
assert!(
contents.contains("NOT_SET_ANYWHERE="),
"empty build env map must be a no-op; got: {contents:?}"
);
let _ = std::fs::remove_file(&out);
}
#[test]
fn hook_if_render_error_propagates() {
let log = test_logger();
let vars = vars_with_snapshot(false);
let hooks = vec![structured("true", Some("{{ UndefinedSymbol }}"))];
let err = run_hooks(&hooks, "test", HookRunContext::new(true, &log, Some(&vars)))
.expect_err("unrenderable template must surface as Err");
let chain = format!("{err:#}");
assert!(
chain.contains("template render failed") || chain.contains("UndefinedSymbol"),
"expected render-error diagnostic, got: {chain}",
);
}
}