1use crate::artifact::Artifact;
2use crate::config::{self, BeforePublishArtifactFilter, HookEntry};
3use crate::log::StageLogger;
4use crate::template::{self, TemplateVars};
5use anyhow::{Context as _, Result};
6use std::process::Command;
7
8fn redact_secrets(output: &str) -> String {
14 let env: Vec<(String, String)> = std::env::vars().collect();
15 crate::redact::string(output, &env)
16}
17
18fn render_hook_template(template: &str, vars: &TemplateVars, label: &str) -> Result<String> {
24 template::render(template, vars)
25 .with_context(|| format!("{} hook: render template '{}'", label, template))
26}
27
28#[derive(Clone, Copy)]
33pub struct HookRunContext<'a> {
34 pub dry_run: bool,
36 pub log: &'a StageLogger,
38 pub template_vars: Option<&'a TemplateVars>,
43 pub build_env: Option<&'a std::collections::HashMap<String, String>>,
52}
53
54impl<'a> HookRunContext<'a> {
55 pub fn new(
59 dry_run: bool,
60 log: &'a StageLogger,
61 template_vars: Option<&'a TemplateVars>,
62 ) -> Self {
63 Self {
64 dry_run,
65 log,
66 template_vars,
67 build_env: None,
68 }
69 }
70}
71
72pub fn run_hooks(hooks: &[HookEntry], label: &str, ctx: HookRunContext<'_>) -> Result<()> {
82 let HookRunContext {
83 dry_run,
84 log,
85 template_vars,
86 build_env,
87 } = ctx;
88 for hook in hooks {
89 let (raw_cmd, raw_dir, env, output_flag, if_cond) = match hook {
90 HookEntry::Simple(s) => (s.as_str(), None, None, None, None),
91 HookEntry::Structured(h) => (
92 h.cmd.as_str(),
93 h.dir.as_deref(),
94 h.env.as_ref(),
95 h.output,
96 h.if_condition.as_deref(),
97 ),
98 };
99
100 if let Some(tv) = template_vars {
101 let proceed = config::evaluate_if_condition(if_cond, &format!("{label} hook"), |t| {
102 render_hook_template(t, tv, label)
103 })?;
104 if !proceed {
105 tracing::debug!(
106 label = label,
107 cmd = raw_cmd,
108 "skipping hook: `if` condition evaluated falsy"
109 );
110 continue;
111 }
112 } else if let Some(cond) = if_cond {
113 let trimmed = cond.trim();
119 let falsy = matches!(trimmed, "false" | "0" | "no");
120 if falsy {
121 tracing::debug!(
122 label = label,
123 cmd = raw_cmd,
124 "skipping hook: literal `if` condition is falsy"
125 );
126 continue;
127 }
128 }
129
130 let cmd_str = if let Some(tv) = template_vars {
131 render_hook_template(raw_cmd, tv, label)?
132 } else {
133 raw_cmd.to_string()
134 };
135
136 let dir_str = match raw_dir {
137 Some(d) => Some(if let Some(tv) = template_vars {
138 render_hook_template(d, tv, label)?
139 } else {
140 d.to_string()
141 }),
142 None => None,
143 };
144
145 let expanded_env: Option<Vec<(String, String)>> = match env {
146 Some(envs) => {
147 let pairs = if let Some(tv) = template_vars {
148 config::render_env_entries(envs, |s| render_hook_template(s, tv, label))
149 .with_context(|| format!("{label} hook: render env entries"))?
150 } else {
151 config::parse_env_entries(envs)
152 .with_context(|| format!("{label} hook: parse env entries"))?
153 };
154 Some(pairs)
155 }
156 None => None,
157 };
158
159 if dry_run {
160 log.status(&format!("[dry-run] {} hook: {}", label, cmd_str));
161 } else {
162 log.status(&format!("running {} hook: {}", label, cmd_str));
163 let mut command = Command::new("sh");
164 command.arg("-c").arg(&cmd_str);
165 if let Some(ref d) = dir_str {
169 command.current_dir(d);
170 }
171 if let Some(be) = build_env {
176 for (k, v) in be {
177 command.env(k, v);
178 }
179 }
180 if let Some(ref envs) = expanded_env {
181 for (k, v) in envs {
182 command.env(k, v);
183 }
184 }
185 let output = command
186 .output()
187 .with_context(|| format!("failed to spawn {} hook: {}", label, cmd_str))?;
188
189 let redacted_stdout = redact_secrets(&String::from_utf8_lossy(&output.stdout));
192 let redacted_stderr = redact_secrets(&String::from_utf8_lossy(&output.stderr));
193
194 if output_flag == Some(true) && !redacted_stdout.trim().is_empty() {
196 log.status(&format!("[hook output] {}", redacted_stdout.trim()));
197 }
198
199 let redacted_output = std::process::Output {
202 status: output.status,
203 stdout: redacted_stdout.into_bytes(),
204 stderr: redacted_stderr.into_bytes(),
205 };
206
207 log.check_output(redacted_output, &format!("{} hook: {}", label, cmd_str))?;
208 }
209 }
210 Ok(())
211}
212
213pub struct BeforePublishStage;
242
243impl crate::stage::Stage for BeforePublishStage {
244 fn name(&self) -> &str {
245 "before-publish"
246 }
247
248 fn run(&self, ctx: &mut crate::context::Context) -> Result<()> {
249 let log = ctx.logger("before-publish");
250 let Some(cfg) = ctx.config.before_publish.as_ref() else {
251 return Ok(());
252 };
253 let Some(hooks) = cfg.hooks.as_ref() else {
254 return Ok(());
255 };
256 if hooks.is_empty() {
257 return Ok(());
258 }
259
260 let dry_run = ctx.is_dry_run();
261 let base_vars = ctx.template_vars().clone();
262 let artifacts: Vec<Artifact> = ctx.artifacts.all().to_vec();
263
264 for entry in hooks {
265 run_before_publish_entry(entry, &artifacts, dry_run, &log, &base_vars)?;
266 }
267 Ok(())
268 }
269}
270
271fn run_before_publish_entry(
276 entry: &HookEntry,
277 artifacts: &[Artifact],
278 dry_run: bool,
279 log: &StageLogger,
280 base_vars: &TemplateVars,
281) -> Result<()> {
282 let (ids_filter, kind_filter) = match entry {
283 HookEntry::Simple(_) => (None, BeforePublishArtifactFilter::All),
284 HookEntry::Structured(h) => (
285 h.ids.as_deref(),
286 h.artifacts.unwrap_or(BeforePublishArtifactFilter::All),
287 ),
288 };
289
290 for artifact in artifacts {
291 if !kind_filter.matches(artifact.kind) {
292 continue;
293 }
294 if let Some(allow_ids) = ids_filter {
295 let id = artifact
296 .metadata
297 .get("id")
298 .map(String::as_str)
299 .unwrap_or("");
300 if !allow_ids.iter().any(|a| a == id) {
301 continue;
302 }
303 }
304
305 let mut vars = base_vars.clone();
306 bind_per_artifact_vars(&mut vars, artifact);
307 let single = std::slice::from_ref(entry);
312 run_hooks(
314 single,
315 "before-publish",
316 HookRunContext::new(dry_run, log, Some(&vars)),
317 )?;
318 }
319 Ok(())
320}
321
322fn bind_per_artifact_vars(vars: &mut TemplateVars, artifact: &Artifact) {
328 vars.set("ArtifactPath", &artifact.path.to_string_lossy());
329 vars.set("ArtifactName", artifact.name());
330 vars.set("ArtifactExt", &artifact.ext());
331 vars.set("ArtifactKind", artifact.kind.as_str());
332 vars.set(
333 "ArtifactID",
334 artifact
335 .metadata
336 .get("id")
337 .map(String::as_str)
338 .unwrap_or(""),
339 );
340 if let Some(target) = artifact.target.as_deref() {
341 let (os, arch) = crate::target::map_target(target);
342 vars.set("Os", &os);
343 vars.set("Arch", &arch);
344 vars.set("Target", target);
345 } else {
346 vars.set("Os", "");
347 vars.set("Arch", "");
348 vars.set("Target", "");
349 }
350}
351
352#[cfg(test)]
353mod tests {
354 use super::*;
355 use crate::config::StructuredHook;
356 use crate::log::{StageLogger, Verbosity};
357 use std::collections::HashMap;
358
359 fn test_logger() -> StageLogger {
360 StageLogger::new("test", Verbosity::Normal)
361 }
362
363 fn vars_with_snapshot(is_snapshot: bool) -> TemplateVars {
364 let mut v = TemplateVars::new();
365 v.set("IsSnapshot", if is_snapshot { "true" } else { "false" });
366 v
367 }
368
369 fn structured(cmd: &str, if_cond: Option<&str>) -> HookEntry {
370 HookEntry::Structured(StructuredHook {
371 cmd: cmd.to_string(),
372 if_condition: if_cond.map(str::to_string),
373 ..Default::default()
374 })
375 }
376
377 #[test]
378 fn hook_if_snapshot_template_runs_on_snapshot() {
379 let log = test_logger();
380 let vars = vars_with_snapshot(true);
381 let hooks = vec![structured("true", Some("{{ IsSnapshot }}"))];
382 run_hooks(&hooks, "test", HookRunContext::new(true, &log, Some(&vars)))
384 .expect("snapshot=true must let the hook proceed");
385 }
386
387 #[test]
388 fn hook_if_snapshot_template_skips_when_not_snapshot() {
389 let log = test_logger();
390 let vars = vars_with_snapshot(false);
391 let hooks = vec![structured(
392 "false-cmd-must-be-skipped",
393 Some("{{ IsSnapshot }}"),
394 )];
395 run_hooks(
396 &hooks,
397 "test",
398 HookRunContext::new(false, &log, Some(&vars)),
399 )
400 .expect("falsy `if:` must skip without spawning the cmd");
401 }
402
403 #[test]
404 fn hook_if_literal_true_always_runs() {
405 let log = test_logger();
406 let vars = vars_with_snapshot(false);
407 let hooks = vec![structured("true", Some("true"))];
408 run_hooks(&hooks, "test", HookRunContext::new(true, &log, Some(&vars)))
409 .expect("`if: true` must proceed");
410 }
411
412 #[test]
413 fn hook_if_empty_literal_is_noop_gate() {
414 let log = test_logger();
415 let vars = vars_with_snapshot(false);
416 let hooks = vec![structured("true", Some(""))];
417 run_hooks(&hooks, "test", HookRunContext::new(true, &log, Some(&vars)))
418 .expect("empty `if:` literal must be a no-op (always proceed)");
419 }
420
421 #[test]
422 fn hook_if_empty_literal_no_vars_proceeds() {
423 let log = test_logger();
429 let hooks = vec![structured("true", Some(""))];
430 run_hooks(&hooks, "test", HookRunContext::new(true, &log, None))
431 .expect("empty `if:` with no vars must proceed (no-op gate)");
432 }
433
434 #[test]
435 fn hook_if_falsy_literal_no_vars_skips() {
436 let log = test_logger();
440 let hooks = vec![structured("false-cmd-must-be-skipped", Some("false"))];
441 run_hooks(&hooks, "test", HookRunContext::new(false, &log, None))
442 .expect("falsy literal `if:` with no vars must skip without spawning");
443 }
444
445 fn run_env_probe_hook(
449 out_file: &std::path::Path,
450 keys: &[&str],
451 hook_env: Option<Vec<String>>,
452 build_env: Option<&HashMap<String, String>>,
453 ) -> Result<()> {
454 let log = test_logger();
455 let out = out_file.display().to_string().replace('\\', "/");
457 let probe = keys
458 .iter()
459 .map(|k| format!("echo {k}=${k} >> {out}"))
460 .collect::<Vec<_>>()
461 .join("; ");
462 let hooks = vec![HookEntry::Structured(StructuredHook {
463 cmd: probe,
464 env: hook_env,
465 ..Default::default()
466 })];
467 let vars = TemplateVars::new();
468 run_hooks(
469 &hooks,
470 "build",
471 HookRunContext {
472 dry_run: false,
473 log: &log,
474 template_vars: Some(&vars),
475 build_env,
476 },
477 )
478 }
479
480 #[test]
481 fn build_env_reaches_build_hook() {
482 let dir = std::env::temp_dir().join(format!("anodizer-be-{}", std::process::id()));
483 std::fs::create_dir_all(&dir).unwrap();
484 let out = dir.join("reaches.txt");
485 let _ = std::fs::remove_file(&out);
486
487 let mut build_env = HashMap::new();
488 build_env.insert("MY_BUILD_VAR".to_string(), "from-build-env".to_string());
489
490 run_env_probe_hook(&out, &["MY_BUILD_VAR"], None, Some(&build_env)).expect("hook must run");
491
492 let contents = std::fs::read_to_string(&out).unwrap();
493 assert!(
494 contents.contains("MY_BUILD_VAR=from-build-env"),
495 "build env var must reach the hook; got: {contents:?}"
496 );
497 let _ = std::fs::remove_file(&out);
498 }
499
500 #[test]
501 fn hook_env_overrides_build_env_on_key_conflict() {
502 let dir = std::env::temp_dir().join(format!("anodizer-be-{}", std::process::id()));
503 std::fs::create_dir_all(&dir).unwrap();
504 let out = dir.join("precedence.txt");
505 let _ = std::fs::remove_file(&out);
506
507 let mut build_env = HashMap::new();
508 build_env.insert("SHARED".to_string(), "build-loses".to_string());
509
510 run_env_probe_hook(
511 &out,
512 &["SHARED"],
513 Some(vec!["SHARED=hook-wins".to_string()]),
514 Some(&build_env),
515 )
516 .expect("hook must run");
517
518 let contents = std::fs::read_to_string(&out).unwrap();
519 assert!(
520 contents.contains("SHARED=hook-wins"),
521 "hook env must override build env on key conflict (GR append order); got: {contents:?}"
522 );
523 assert!(
524 !contents.contains("SHARED=build-loses"),
525 "build env value must not survive a hook-env override; got: {contents:?}"
526 );
527 let _ = std::fs::remove_file(&out);
528 }
529
530 #[test]
531 fn absent_build_env_is_unchanged_behavior() {
532 let dir = std::env::temp_dir().join(format!("anodizer-be-{}", std::process::id()));
533 std::fs::create_dir_all(&dir).unwrap();
534 let out = dir.join("absent.txt");
535 let _ = std::fs::remove_file(&out);
536
537 run_env_probe_hook(&out, &["NOT_SET_ANYWHERE"], None, None).expect("hook must run");
539
540 let contents = std::fs::read_to_string(&out).unwrap();
541 assert!(
542 contents.contains("NOT_SET_ANYWHERE="),
543 "absent build env must leave behavior unchanged; got: {contents:?}"
544 );
545 let _ = std::fs::remove_file(&out);
546 }
547
548 #[test]
549 fn empty_build_env_map_adds_nothing() {
550 let dir = std::env::temp_dir().join(format!("anodizer-be-{}", std::process::id()));
551 std::fs::create_dir_all(&dir).unwrap();
552 let out = dir.join("empty.txt");
553 let _ = std::fs::remove_file(&out);
554
555 let build_env: HashMap<String, String> = HashMap::new();
556 run_env_probe_hook(&out, &["NOT_SET_ANYWHERE"], None, Some(&build_env))
557 .expect("hook must run");
558
559 let contents = std::fs::read_to_string(&out).unwrap();
560 assert!(
561 contents.contains("NOT_SET_ANYWHERE="),
562 "empty build env map must be a no-op; got: {contents:?}"
563 );
564 let _ = std::fs::remove_file(&out);
565 }
566
567 #[test]
568 fn hook_if_render_error_propagates() {
569 let log = test_logger();
570 let vars = vars_with_snapshot(false);
571 let hooks = vec![structured("true", Some("{{ UndefinedSymbol }}"))];
572 let err = run_hooks(&hooks, "test", HookRunContext::new(true, &log, Some(&vars)))
573 .expect_err("unrenderable template must surface as Err");
574 let chain = format!("{err:#}");
575 assert!(
576 chain.contains("template render failed") || chain.contains("UndefinedSymbol"),
577 "expected render-error diagnostic, got: {chain}",
578 );
579 }
580}