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 pub extra_env: Option<&'a [(String, String)]>,
60}
61
62impl<'a> HookRunContext<'a> {
63 pub fn new(
67 dry_run: bool,
68 log: &'a StageLogger,
69 template_vars: Option<&'a TemplateVars>,
70 ) -> Self {
71 Self {
72 dry_run,
73 log,
74 template_vars,
75 build_env: None,
76 extra_env: None,
77 }
78 }
79
80 pub fn with_extra_env(mut self, extra_env: &'a [(String, String)]) -> Self {
82 self.extra_env = Some(extra_env);
83 self
84 }
85}
86
87pub fn run_hooks(hooks: &[HookEntry], label: &str, ctx: HookRunContext<'_>) -> Result<()> {
97 let HookRunContext {
98 dry_run,
99 log,
100 template_vars,
101 build_env,
102 extra_env,
103 } = ctx;
104 for hook in hooks {
105 let (raw_cmd, raw_dir, env, output_flag, if_cond) = match hook {
106 HookEntry::Simple(s) => (s.as_str(), None, None, None, None),
107 HookEntry::Structured(h) => (
108 h.cmd.as_str(),
109 h.dir.as_deref(),
110 h.env.as_ref(),
111 h.output,
112 h.if_condition.as_deref(),
113 ),
114 };
115
116 if let Some(tv) = template_vars {
117 let proceed = config::evaluate_if_condition(if_cond, &format!("{label} hook"), |t| {
118 render_hook_template(t, tv, label)
119 })?;
120 if !proceed {
121 tracing::debug!(
122 label = label,
123 cmd = raw_cmd,
124 "skipped hook — `if` condition evaluated falsy"
125 );
126 continue;
127 }
128 } else if let Some(cond) = if_cond {
129 let trimmed = cond.trim();
135 let falsy = matches!(trimmed, "false" | "0" | "no");
136 if falsy {
137 tracing::debug!(
138 label = label,
139 cmd = raw_cmd,
140 "skipped hook — literal `if` condition is falsy"
141 );
142 continue;
143 }
144 }
145
146 let cmd_str = if let Some(tv) = template_vars {
147 render_hook_template(raw_cmd, tv, label)?
148 } else {
149 raw_cmd.to_string()
150 };
151
152 let dir_str = match raw_dir {
153 Some(d) => Some(if let Some(tv) = template_vars {
154 render_hook_template(d, tv, label)?
155 } else {
156 d.to_string()
157 }),
158 None => None,
159 };
160
161 let expanded_env: Option<Vec<(String, String)>> = match env {
162 Some(envs) => {
163 let pairs = if let Some(tv) = template_vars {
164 config::render_env_entries(envs, |s| render_hook_template(s, tv, label))
165 .with_context(|| format!("{label} hook: render env entries"))?
166 } else {
167 config::parse_env_entries(envs)
168 .with_context(|| format!("{label} hook: parse env entries"))?
169 };
170 Some(pairs)
171 }
172 None => None,
173 };
174
175 if dry_run {
176 log.status(&format!(
177 "(dry-run) would run {} hook via `{}`",
178 label, cmd_str
179 ));
180 } else {
181 log.verbose(&format!("running {} hook via `{}`", label, cmd_str));
182 let mut command = Command::new("sh");
183 command.arg("-c").arg(&cmd_str);
184 if let Some(ref d) = dir_str {
188 command.current_dir(d);
189 }
190 if let Some(be) = build_env {
195 for (k, v) in be {
196 command.env(k, v);
197 }
198 }
199 if let Some(ee) = extra_env {
200 for (k, v) in ee {
201 command.env(k, v);
202 }
203 }
204 if let Some(ref envs) = expanded_env {
205 for (k, v) in envs {
206 command.env(k, v);
207 }
208 }
209 let redacting_log = log.clone().with_env(std::env::vars().collect::<Vec<_>>());
217 let output = crate::run::run_checked(
218 &mut command,
219 &redacting_log,
220 &format!("{} hook: {}", label, cmd_str),
221 )?;
222
223 log.status(&format!("ran {label} hook"));
224
225 if output_flag == Some(true) && !log.is_verbose() {
231 let redacted_stdout = redact_secrets(&String::from_utf8_lossy(&output.stdout));
232 if !redacted_stdout.trim().is_empty() {
233 log.status(&format!("[hook output] {}", redacted_stdout.trim())); }
235 }
236 }
237 }
238 Ok(())
239}
240
241pub struct BeforePublishStage;
270
271impl crate::stage::Stage for BeforePublishStage {
272 fn name(&self) -> &str {
273 "before-publish"
274 }
275
276 fn run(&self, ctx: &mut crate::context::Context) -> Result<()> {
277 let log = ctx.logger("before-publish");
278 let Some(cfg) = ctx.config.before_publish.as_ref() else {
279 return Ok(());
280 };
281 let Some(hooks) = cfg.hooks.as_ref() else {
282 return Ok(());
283 };
284 if hooks.is_empty() {
285 return Ok(());
286 }
287
288 let dry_run = ctx.is_dry_run();
289 let base_vars = ctx.template_vars().clone();
290 let artifacts: Vec<Artifact> = ctx.artifacts.all().to_vec();
291
292 for entry in hooks {
293 run_before_publish_entry(entry, &artifacts, dry_run, &log, &base_vars)?;
294 }
295 Ok(())
296 }
297}
298
299fn run_before_publish_entry(
304 entry: &HookEntry,
305 artifacts: &[Artifact],
306 dry_run: bool,
307 log: &StageLogger,
308 base_vars: &TemplateVars,
309) -> Result<()> {
310 let (ids_filter, kind_filter) = match entry {
311 HookEntry::Simple(_) => (None, BeforePublishArtifactFilter::All),
312 HookEntry::Structured(h) => (
313 h.ids.as_deref(),
314 h.artifacts.unwrap_or(BeforePublishArtifactFilter::All),
315 ),
316 };
317
318 for artifact in artifacts {
319 if !kind_filter.matches(artifact.kind) {
320 continue;
321 }
322 if let Some(allow_ids) = ids_filter {
323 let id = artifact
324 .metadata
325 .get("id")
326 .map(String::as_str)
327 .unwrap_or("");
328 if !allow_ids.iter().any(|a| a == id) {
329 continue;
330 }
331 }
332
333 let mut vars = base_vars.clone();
334 bind_per_artifact_vars(&mut vars, artifact);
335 let single = std::slice::from_ref(entry);
340 run_hooks(
342 single,
343 "before-publish",
344 HookRunContext::new(dry_run, log, Some(&vars)),
345 )?;
346 }
347 Ok(())
348}
349
350fn bind_per_artifact_vars(vars: &mut TemplateVars, artifact: &Artifact) {
356 vars.set("ArtifactPath", &artifact.path.to_string_lossy());
357 vars.set("ArtifactName", artifact.name());
358 vars.set("ArtifactExt", &artifact.ext());
359 vars.set("ArtifactKind", artifact.kind.as_str());
360 vars.set(
361 "ArtifactID",
362 artifact
363 .metadata
364 .get("id")
365 .map(String::as_str)
366 .unwrap_or(""),
367 );
368 if let Some(target) = artifact.target.as_deref() {
369 let (os, arch) = crate::target::map_target(target);
370 vars.set("Os", &os);
371 vars.set("Arch", &arch);
372 vars.set("Target", target);
373 } else {
374 vars.set("Os", "");
375 vars.set("Arch", "");
376 vars.set("Target", "");
377 }
378}
379
380#[cfg(test)]
381mod tests {
382 use super::*;
383 use crate::config::StructuredHook;
384 use crate::log::{StageLogger, Verbosity};
385 use std::collections::HashMap;
386
387 fn test_logger() -> StageLogger {
388 StageLogger::new("test", Verbosity::Normal)
389 }
390
391 fn vars_with_snapshot(is_snapshot: bool) -> TemplateVars {
392 let mut v = TemplateVars::new();
393 v.set("IsSnapshot", if is_snapshot { "true" } else { "false" });
394 v
395 }
396
397 fn structured(cmd: &str, if_cond: Option<&str>) -> HookEntry {
398 HookEntry::Structured(StructuredHook {
399 cmd: cmd.to_string(),
400 if_condition: if_cond.map(str::to_string),
401 ..Default::default()
402 })
403 }
404
405 #[test]
406 fn hook_if_snapshot_template_runs_on_snapshot() {
407 let log = test_logger();
408 let vars = vars_with_snapshot(true);
409 let hooks = vec![structured("true", Some("{{ IsSnapshot }}"))];
410 run_hooks(&hooks, "test", HookRunContext::new(true, &log, Some(&vars)))
412 .expect("snapshot=true must let the hook proceed");
413 }
414
415 #[test]
416 fn hook_if_snapshot_template_skips_when_not_snapshot() {
417 let log = test_logger();
418 let vars = vars_with_snapshot(false);
419 let hooks = vec![structured(
420 "false-cmd-must-be-skipped",
421 Some("{{ IsSnapshot }}"),
422 )];
423 run_hooks(
424 &hooks,
425 "test",
426 HookRunContext::new(false, &log, Some(&vars)),
427 )
428 .expect("falsy `if:` must skip without spawning the cmd");
429 }
430
431 #[test]
432 fn hook_if_literal_true_always_runs() {
433 let log = test_logger();
434 let vars = vars_with_snapshot(false);
435 let hooks = vec![structured("true", Some("true"))];
436 run_hooks(&hooks, "test", HookRunContext::new(true, &log, Some(&vars)))
437 .expect("`if: true` must proceed");
438 }
439
440 #[test]
441 fn hook_if_empty_literal_is_noop_gate() {
442 let log = test_logger();
443 let vars = vars_with_snapshot(false);
444 let hooks = vec![structured("true", Some(""))];
445 run_hooks(&hooks, "test", HookRunContext::new(true, &log, Some(&vars)))
446 .expect("empty `if:` literal must be a no-op (always proceed)");
447 }
448
449 #[test]
450 fn hook_if_empty_literal_no_vars_proceeds() {
451 let log = test_logger();
457 let hooks = vec![structured("true", Some(""))];
458 run_hooks(&hooks, "test", HookRunContext::new(true, &log, None))
459 .expect("empty `if:` with no vars must proceed (no-op gate)");
460 }
461
462 #[test]
463 fn hook_if_falsy_literal_no_vars_skips() {
464 let log = test_logger();
468 let hooks = vec![structured("false-cmd-must-be-skipped", Some("false"))];
469 run_hooks(&hooks, "test", HookRunContext::new(false, &log, None))
470 .expect("falsy literal `if:` with no vars must skip without spawning");
471 }
472
473 fn run_env_probe_hook(
477 out_file: &std::path::Path,
478 keys: &[&str],
479 hook_env: Option<Vec<String>>,
480 build_env: Option<&HashMap<String, String>>,
481 ) -> Result<()> {
482 let log = test_logger();
483 let out = out_file.display().to_string().replace('\\', "/");
485 let probe = keys
486 .iter()
487 .map(|k| format!("echo {k}=${k} >> {out}"))
488 .collect::<Vec<_>>()
489 .join("; ");
490 let hooks = vec![HookEntry::Structured(StructuredHook {
491 cmd: probe,
492 env: hook_env,
493 ..Default::default()
494 })];
495 let vars = TemplateVars::new();
496 run_hooks(
497 &hooks,
498 "build",
499 HookRunContext {
500 dry_run: false,
501 log: &log,
502 template_vars: Some(&vars),
503 build_env,
504 extra_env: None,
505 },
506 )
507 }
508
509 #[test]
510 fn build_env_reaches_build_hook() {
511 let dir = std::env::temp_dir().join(format!("anodizer-be-{}", std::process::id()));
512 std::fs::create_dir_all(&dir).unwrap();
513 let out = dir.join("reaches.txt");
514 let _ = std::fs::remove_file(&out);
515
516 let mut build_env = HashMap::new();
517 build_env.insert("MY_BUILD_VAR".to_string(), "from-build-env".to_string());
518
519 run_env_probe_hook(&out, &["MY_BUILD_VAR"], None, Some(&build_env)).expect("hook must run");
520
521 let contents = std::fs::read_to_string(&out).unwrap();
522 assert!(
523 contents.contains("MY_BUILD_VAR=from-build-env"),
524 "build env var must reach the hook; got: {contents:?}"
525 );
526 let _ = std::fs::remove_file(&out);
527 }
528
529 #[test]
530 fn hook_env_overrides_build_env_on_key_conflict() {
531 let dir = std::env::temp_dir().join(format!("anodizer-be-{}", std::process::id()));
532 std::fs::create_dir_all(&dir).unwrap();
533 let out = dir.join("precedence.txt");
534 let _ = std::fs::remove_file(&out);
535
536 let mut build_env = HashMap::new();
537 build_env.insert("SHARED".to_string(), "build-loses".to_string());
538
539 run_env_probe_hook(
540 &out,
541 &["SHARED"],
542 Some(vec!["SHARED=hook-wins".to_string()]),
543 Some(&build_env),
544 )
545 .expect("hook must run");
546
547 let contents = std::fs::read_to_string(&out).unwrap();
548 assert!(
549 contents.contains("SHARED=hook-wins"),
550 "hook env must override build env on key conflict (GR append order); got: {contents:?}"
551 );
552 assert!(
553 !contents.contains("SHARED=build-loses"),
554 "build env value must not survive a hook-env override; got: {contents:?}"
555 );
556 let _ = std::fs::remove_file(&out);
557 }
558
559 fn run_extra_env_probe_hook(
561 out_file: &std::path::Path,
562 keys: &[&str],
563 hook_env: Option<Vec<String>>,
564 extra_env: &[(String, String)],
565 ) -> Result<()> {
566 let log = test_logger();
567 let out = out_file.display().to_string().replace('\\', "/");
568 let probe = keys
569 .iter()
570 .map(|k| format!("echo {k}=${k} >> {out}"))
571 .collect::<Vec<_>>()
572 .join("; ");
573 let hooks = vec![HookEntry::Structured(StructuredHook {
574 cmd: probe,
575 env: hook_env,
576 ..Default::default()
577 })];
578 let vars = TemplateVars::new();
579 run_hooks(
580 &hooks,
581 "test",
582 HookRunContext::new(false, &log, Some(&vars)).with_extra_env(extra_env),
583 )
584 }
585
586 #[test]
587 fn extra_env_reaches_hook() {
588 let dir = std::env::temp_dir().join(format!("anodizer-ee-{}", std::process::id()));
589 std::fs::create_dir_all(&dir).unwrap();
590 let out = dir.join("reaches.txt");
591 let _ = std::fs::remove_file(&out);
592
593 let extra = vec![("MY_EXTRA_VAR".to_string(), "from-extra-env".to_string())];
594 run_extra_env_probe_hook(&out, &["MY_EXTRA_VAR"], None, &extra).expect("hook must run");
595
596 let contents = std::fs::read_to_string(&out).unwrap();
597 assert!(
598 contents.contains("MY_EXTRA_VAR=from-extra-env"),
599 "extra env var must reach the hook; got: {contents:?}"
600 );
601 let _ = std::fs::remove_file(&out);
602 }
603
604 #[test]
605 fn hook_env_overrides_extra_env_on_key_conflict() {
606 let dir = std::env::temp_dir().join(format!("anodizer-ee-{}", std::process::id()));
607 std::fs::create_dir_all(&dir).unwrap();
608 let out = dir.join("precedence.txt");
609 let _ = std::fs::remove_file(&out);
610
611 let extra = vec![("SHARED".to_string(), "extra-loses".to_string())];
612 run_extra_env_probe_hook(
613 &out,
614 &["SHARED"],
615 Some(vec!["SHARED=hook-wins".to_string()]),
616 &extra,
617 )
618 .expect("hook must run");
619
620 let contents = std::fs::read_to_string(&out).unwrap();
621 assert!(
622 contents.contains("SHARED=hook-wins"),
623 "hook env must override extra env on key conflict; got: {contents:?}"
624 );
625 let _ = std::fs::remove_file(&out);
626 }
627
628 #[test]
629 fn absent_build_env_is_unchanged_behavior() {
630 let dir = std::env::temp_dir().join(format!("anodizer-be-{}", std::process::id()));
631 std::fs::create_dir_all(&dir).unwrap();
632 let out = dir.join("absent.txt");
633 let _ = std::fs::remove_file(&out);
634
635 run_env_probe_hook(&out, &["NOT_SET_ANYWHERE"], None, None).expect("hook must run");
637
638 let contents = std::fs::read_to_string(&out).unwrap();
639 assert!(
640 contents.contains("NOT_SET_ANYWHERE="),
641 "absent build env must leave behavior unchanged; got: {contents:?}"
642 );
643 let _ = std::fs::remove_file(&out);
644 }
645
646 #[test]
647 fn empty_build_env_map_adds_nothing() {
648 let dir = std::env::temp_dir().join(format!("anodizer-be-{}", std::process::id()));
649 std::fs::create_dir_all(&dir).unwrap();
650 let out = dir.join("empty.txt");
651 let _ = std::fs::remove_file(&out);
652
653 let build_env: HashMap<String, String> = HashMap::new();
654 run_env_probe_hook(&out, &["NOT_SET_ANYWHERE"], None, Some(&build_env))
655 .expect("hook must run");
656
657 let contents = std::fs::read_to_string(&out).unwrap();
658 assert!(
659 contents.contains("NOT_SET_ANYWHERE="),
660 "empty build env map must be a no-op; got: {contents:?}"
661 );
662 let _ = std::fs::remove_file(&out);
663 }
664
665 #[test]
666 fn hook_if_render_error_propagates() {
667 let log = test_logger();
668 let vars = vars_with_snapshot(false);
669 let hooks = vec![structured("true", Some("{{ UndefinedSymbol }}"))];
670 let err = run_hooks(&hooks, "test", HookRunContext::new(true, &log, Some(&vars)))
671 .expect_err("unrenderable template must surface as Err");
672 let chain = format!("{err:#}");
673 assert!(
674 chain.contains("template render failed") || chain.contains("UndefinedSymbol"),
675 "expected render-error diagnostic, got: {chain}",
676 );
677 }
678
679 #[cfg(feature = "test-helpers")]
680 #[test]
681 fn hook_output_summary_suppressed_at_verbose() {
682 let (log, cap) = StageLogger::with_capture("test", Verbosity::Verbose);
687 let hooks = vec![HookEntry::Structured(StructuredHook {
688 cmd: "echo HOOKSTDOUTLINE".to_string(),
689 output: Some(true),
690 ..Default::default()
691 })];
692 let vars = TemplateVars::new();
693 run_hooks(
694 &hooks,
695 "test",
696 HookRunContext::new(false, &log, Some(&vars)),
697 )
698 .expect("hook must run");
699 let summarized = cap
700 .all_messages()
701 .into_iter()
702 .any(|(_, m)| m.contains("[hook output]"));
703 assert!(
704 !summarized,
705 "at verbose the live tee owns the hook stdout; the [hook output] \
706 summary must be suppressed to avoid a double print"
707 );
708 }
709
710 #[cfg(feature = "test-helpers")]
711 #[test]
712 fn hook_output_summary_present_at_normal() {
713 let (log, cap) = StageLogger::with_capture("test", Verbosity::Normal);
717 let hooks = vec![HookEntry::Structured(StructuredHook {
718 cmd: "echo HOOKSTDOUTLINE".to_string(),
719 output: Some(true),
720 ..Default::default()
721 })];
722 let vars = TemplateVars::new();
723 run_hooks(
724 &hooks,
725 "test",
726 HookRunContext::new(false, &log, Some(&vars)),
727 )
728 .expect("hook must run");
729 let summarized = cap
730 .all_messages()
731 .into_iter()
732 .any(|(_, m)| m.contains("[hook output]") && m.contains("HOOKSTDOUTLINE"));
733 assert!(
734 summarized,
735 "at normal verbosity the [hook output] summary must carry the hook's stdout"
736 );
737 }
738}