use anodizer_core::context::Context;
use anodizer_core::log::StageLogger;
const RELEASE_ON_ERROR_ENV_VARS: [(&str, &str); 4] = [
("ANODIZER_ERROR", "Error"),
("ANODIZER_ROLLED_BACK", "RolledBack"),
("ANODIZER_VERSION", "Version"),
("ANODIZER_TAG", "Tag"),
];
pub(super) fn fire_release_on_error(ctx: &Context, err: &anyhow::Error, log: &StageLogger) {
if ctx.should_skip("on-error") {
return;
}
let Some(hooks) = ctx
.config
.on_error
.as_ref()
.and_then(|h| h.hooks.as_deref())
else {
return;
};
if hooks.is_empty() {
return;
}
let mut vars = ctx.template_vars().clone();
vars.set("Error", &format!("{err:#}"));
vars.set("RolledBack", "false");
let env: Vec<(String, String)> = RELEASE_ON_ERROR_ENV_VARS
.iter()
.map(|(env_key, var_key)| {
let value = vars.get(var_key).cloned().unwrap_or_default();
((*env_key).to_string(), value)
})
.collect();
let hook_ctx = anodizer_core::hooks::HookRunContext::new(ctx.is_dry_run(), log, Some(&vars))
.with_extra_env(&env);
if let Err(hook_err) = anodizer_core::hooks::run_hooks(hooks, "on-error", hook_ctx) {
log.warn(&format!(
"on-error hook failed (ignored — notification/cleanup hooks never mask the \
pipeline error): {hook_err:#}"
));
}
}
#[cfg(test)]
mod tests {
use super::*;
use anodizer_core::config::Config;
use anodizer_core::context::ContextOptions;
use anodizer_core::log::Verbosity;
#[test]
fn on_error_hooks_parse_in_every_config_mode() {
for (label, yaml) in [
(
"single-crate",
"project_name: app\non_error:\n hooks:\n - ./notify.sh\ncrates:\n - name: app\n path: \".\"\n",
),
(
"lockstep",
"project_name: ws\non_error:\n hooks:\n - ./notify.sh\nworkspaces:\n - name: ws\n crates:\n - name: a\n path: crates/a\n tag_template: \"v{{ Version }}\"\n",
),
(
"per-crate",
"project_name: ws\non_error:\n hooks:\n - ./notify.sh\nworkspaces:\n - name: ws\n crates:\n - name: a\n path: crates/a\n tag_template: \"a-v{{ Version }}\"\n",
),
] {
let config: Config = serde_yaml_ng::from_str(yaml)
.unwrap_or_else(|e| panic!("{label}: config must parse: {e}"));
let hooks = config
.on_error
.as_ref()
.and_then(|h| h.hooks.as_deref())
.unwrap_or_default();
assert_eq!(hooks.len(), 1, "{label}: one on_error hook resolves");
}
}
#[test]
#[cfg(unix)]
fn release_on_error_hooks_fire_with_error_env() {
use anodizer_core::config::{HookEntry, HooksConfig, StructuredHook};
let dir = tempfile::tempdir().expect("tempdir");
let out = dir.path().join("fired.txt");
let out_str = out.display().to_string();
let config = Config {
on_error: Some(HooksConfig {
hooks: Some(vec![HookEntry::Structured(StructuredHook {
cmd: format!(
"printf '%s\\n' \"err=$ANODIZER_ERROR rolled=$ANODIZER_ROLLED_BACK\" \
>> {out_str}"
),
..Default::default()
})]),
post: None,
}),
..Default::default()
};
let ctx = Context::new(config, ContextOptions::default());
let log = StageLogger::new("test", Verbosity::Quiet);
fire_release_on_error(&ctx, &anyhow::anyhow!("sign stage failed: boom"), &log);
let fired = std::fs::read_to_string(&out).expect("hook must have fired");
assert!(
fired.contains("err=sign stage failed: boom"),
"hook must see the pipeline error via env: {fired}"
);
assert!(
fired.contains("rolled=false"),
"the pipeline never auto-withdraws, so the hook must see rolled=false: {fired}"
);
}
#[test]
#[cfg(unix)]
fn skip_on_error_suppresses_the_root_on_error_lane() {
use anodizer_core::config::{HookEntry, HooksConfig, StructuredHook};
let dir = tempfile::tempdir().expect("tempdir");
let out = dir.path().join("fired.txt");
let config = Config {
on_error: Some(HooksConfig {
hooks: Some(vec![HookEntry::Structured(StructuredHook {
cmd: format!("printf 'fired\\n' >> {}", out.display()),
..Default::default()
})]),
post: None,
}),
..Default::default()
};
let ctx = Context::new(
config,
ContextOptions {
skip_stages: vec!["on-error".to_string()],
..ContextOptions::default()
},
);
let log = StageLogger::new("test", Verbosity::Quiet);
fire_release_on_error(&ctx, &anyhow::anyhow!("sign stage failed: boom"), &log);
assert!(
!out.exists(),
"--skip=on-error must suppress the root on_error: lane"
);
}
#[test]
fn release_on_error_without_hooks_is_a_noop() {
let ctx = Context::new(Config::default(), ContextOptions::default());
let log = StageLogger::new("test", Verbosity::Quiet);
fire_release_on_error(&ctx, &anyhow::anyhow!("boom"), &log);
}
}