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 "skipping 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 "skipping 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.status(&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 output = command
210 .output()
211 .with_context(|| format!("failed to spawn {} hook: {}", label, cmd_str))?;
212
213 let redacted_stdout = redact_secrets(&String::from_utf8_lossy(&output.stdout));
216 let redacted_stderr = redact_secrets(&String::from_utf8_lossy(&output.stderr));
217
218 if output_flag == Some(true) && !redacted_stdout.trim().is_empty() {
220 log.status(&format!("[hook output] {}", redacted_stdout.trim()));
221 }
222
223 let redacted_output = std::process::Output {
226 status: output.status,
227 stdout: redacted_stdout.into_bytes(),
228 stderr: redacted_stderr.into_bytes(),
229 };
230
231 log.check_output(redacted_output, &format!("{} hook: {}", label, cmd_str))?;
232 }
233 }
234 Ok(())
235}
236
237pub struct BeforePublishStage;
266
267impl crate::stage::Stage for BeforePublishStage {
268 fn name(&self) -> &str {
269 "before-publish"
270 }
271
272 fn run(&self, ctx: &mut crate::context::Context) -> Result<()> {
273 let log = ctx.logger("before-publish");
274 let Some(cfg) = ctx.config.before_publish.as_ref() else {
275 return Ok(());
276 };
277 let Some(hooks) = cfg.hooks.as_ref() else {
278 return Ok(());
279 };
280 if hooks.is_empty() {
281 return Ok(());
282 }
283
284 let dry_run = ctx.is_dry_run();
285 let base_vars = ctx.template_vars().clone();
286 let artifacts: Vec<Artifact> = ctx.artifacts.all().to_vec();
287
288 for entry in hooks {
289 run_before_publish_entry(entry, &artifacts, dry_run, &log, &base_vars)?;
290 }
291 Ok(())
292 }
293}
294
295fn run_before_publish_entry(
300 entry: &HookEntry,
301 artifacts: &[Artifact],
302 dry_run: bool,
303 log: &StageLogger,
304 base_vars: &TemplateVars,
305) -> Result<()> {
306 let (ids_filter, kind_filter) = match entry {
307 HookEntry::Simple(_) => (None, BeforePublishArtifactFilter::All),
308 HookEntry::Structured(h) => (
309 h.ids.as_deref(),
310 h.artifacts.unwrap_or(BeforePublishArtifactFilter::All),
311 ),
312 };
313
314 for artifact in artifacts {
315 if !kind_filter.matches(artifact.kind) {
316 continue;
317 }
318 if let Some(allow_ids) = ids_filter {
319 let id = artifact
320 .metadata
321 .get("id")
322 .map(String::as_str)
323 .unwrap_or("");
324 if !allow_ids.iter().any(|a| a == id) {
325 continue;
326 }
327 }
328
329 let mut vars = base_vars.clone();
330 bind_per_artifact_vars(&mut vars, artifact);
331 let single = std::slice::from_ref(entry);
336 run_hooks(
338 single,
339 "before-publish",
340 HookRunContext::new(dry_run, log, Some(&vars)),
341 )?;
342 }
343 Ok(())
344}
345
346fn bind_per_artifact_vars(vars: &mut TemplateVars, artifact: &Artifact) {
352 vars.set("ArtifactPath", &artifact.path.to_string_lossy());
353 vars.set("ArtifactName", artifact.name());
354 vars.set("ArtifactExt", &artifact.ext());
355 vars.set("ArtifactKind", artifact.kind.as_str());
356 vars.set(
357 "ArtifactID",
358 artifact
359 .metadata
360 .get("id")
361 .map(String::as_str)
362 .unwrap_or(""),
363 );
364 if let Some(target) = artifact.target.as_deref() {
365 let (os, arch) = crate::target::map_target(target);
366 vars.set("Os", &os);
367 vars.set("Arch", &arch);
368 vars.set("Target", target);
369 } else {
370 vars.set("Os", "");
371 vars.set("Arch", "");
372 vars.set("Target", "");
373 }
374}
375
376#[cfg(test)]
377mod tests {
378 use super::*;
379 use crate::config::StructuredHook;
380 use crate::log::{StageLogger, Verbosity};
381 use std::collections::HashMap;
382
383 fn test_logger() -> StageLogger {
384 StageLogger::new("test", Verbosity::Normal)
385 }
386
387 fn vars_with_snapshot(is_snapshot: bool) -> TemplateVars {
388 let mut v = TemplateVars::new();
389 v.set("IsSnapshot", if is_snapshot { "true" } else { "false" });
390 v
391 }
392
393 fn structured(cmd: &str, if_cond: Option<&str>) -> HookEntry {
394 HookEntry::Structured(StructuredHook {
395 cmd: cmd.to_string(),
396 if_condition: if_cond.map(str::to_string),
397 ..Default::default()
398 })
399 }
400
401 #[test]
402 fn hook_if_snapshot_template_runs_on_snapshot() {
403 let log = test_logger();
404 let vars = vars_with_snapshot(true);
405 let hooks = vec![structured("true", Some("{{ IsSnapshot }}"))];
406 run_hooks(&hooks, "test", HookRunContext::new(true, &log, Some(&vars)))
408 .expect("snapshot=true must let the hook proceed");
409 }
410
411 #[test]
412 fn hook_if_snapshot_template_skips_when_not_snapshot() {
413 let log = test_logger();
414 let vars = vars_with_snapshot(false);
415 let hooks = vec![structured(
416 "false-cmd-must-be-skipped",
417 Some("{{ IsSnapshot }}"),
418 )];
419 run_hooks(
420 &hooks,
421 "test",
422 HookRunContext::new(false, &log, Some(&vars)),
423 )
424 .expect("falsy `if:` must skip without spawning the cmd");
425 }
426
427 #[test]
428 fn hook_if_literal_true_always_runs() {
429 let log = test_logger();
430 let vars = vars_with_snapshot(false);
431 let hooks = vec![structured("true", Some("true"))];
432 run_hooks(&hooks, "test", HookRunContext::new(true, &log, Some(&vars)))
433 .expect("`if: true` must proceed");
434 }
435
436 #[test]
437 fn hook_if_empty_literal_is_noop_gate() {
438 let log = test_logger();
439 let vars = vars_with_snapshot(false);
440 let hooks = vec![structured("true", Some(""))];
441 run_hooks(&hooks, "test", HookRunContext::new(true, &log, Some(&vars)))
442 .expect("empty `if:` literal must be a no-op (always proceed)");
443 }
444
445 #[test]
446 fn hook_if_empty_literal_no_vars_proceeds() {
447 let log = test_logger();
453 let hooks = vec![structured("true", Some(""))];
454 run_hooks(&hooks, "test", HookRunContext::new(true, &log, None))
455 .expect("empty `if:` with no vars must proceed (no-op gate)");
456 }
457
458 #[test]
459 fn hook_if_falsy_literal_no_vars_skips() {
460 let log = test_logger();
464 let hooks = vec![structured("false-cmd-must-be-skipped", Some("false"))];
465 run_hooks(&hooks, "test", HookRunContext::new(false, &log, None))
466 .expect("falsy literal `if:` with no vars must skip without spawning");
467 }
468
469 fn run_env_probe_hook(
473 out_file: &std::path::Path,
474 keys: &[&str],
475 hook_env: Option<Vec<String>>,
476 build_env: Option<&HashMap<String, String>>,
477 ) -> Result<()> {
478 let log = test_logger();
479 let out = out_file.display().to_string().replace('\\', "/");
481 let probe = keys
482 .iter()
483 .map(|k| format!("echo {k}=${k} >> {out}"))
484 .collect::<Vec<_>>()
485 .join("; ");
486 let hooks = vec![HookEntry::Structured(StructuredHook {
487 cmd: probe,
488 env: hook_env,
489 ..Default::default()
490 })];
491 let vars = TemplateVars::new();
492 run_hooks(
493 &hooks,
494 "build",
495 HookRunContext {
496 dry_run: false,
497 log: &log,
498 template_vars: Some(&vars),
499 build_env,
500 extra_env: None,
501 },
502 )
503 }
504
505 #[test]
506 fn build_env_reaches_build_hook() {
507 let dir = std::env::temp_dir().join(format!("anodizer-be-{}", std::process::id()));
508 std::fs::create_dir_all(&dir).unwrap();
509 let out = dir.join("reaches.txt");
510 let _ = std::fs::remove_file(&out);
511
512 let mut build_env = HashMap::new();
513 build_env.insert("MY_BUILD_VAR".to_string(), "from-build-env".to_string());
514
515 run_env_probe_hook(&out, &["MY_BUILD_VAR"], None, Some(&build_env)).expect("hook must run");
516
517 let contents = std::fs::read_to_string(&out).unwrap();
518 assert!(
519 contents.contains("MY_BUILD_VAR=from-build-env"),
520 "build env var must reach the hook; got: {contents:?}"
521 );
522 let _ = std::fs::remove_file(&out);
523 }
524
525 #[test]
526 fn hook_env_overrides_build_env_on_key_conflict() {
527 let dir = std::env::temp_dir().join(format!("anodizer-be-{}", std::process::id()));
528 std::fs::create_dir_all(&dir).unwrap();
529 let out = dir.join("precedence.txt");
530 let _ = std::fs::remove_file(&out);
531
532 let mut build_env = HashMap::new();
533 build_env.insert("SHARED".to_string(), "build-loses".to_string());
534
535 run_env_probe_hook(
536 &out,
537 &["SHARED"],
538 Some(vec!["SHARED=hook-wins".to_string()]),
539 Some(&build_env),
540 )
541 .expect("hook must run");
542
543 let contents = std::fs::read_to_string(&out).unwrap();
544 assert!(
545 contents.contains("SHARED=hook-wins"),
546 "hook env must override build env on key conflict (GR append order); got: {contents:?}"
547 );
548 assert!(
549 !contents.contains("SHARED=build-loses"),
550 "build env value must not survive a hook-env override; got: {contents:?}"
551 );
552 let _ = std::fs::remove_file(&out);
553 }
554
555 fn run_extra_env_probe_hook(
557 out_file: &std::path::Path,
558 keys: &[&str],
559 hook_env: Option<Vec<String>>,
560 extra_env: &[(String, String)],
561 ) -> Result<()> {
562 let log = test_logger();
563 let out = out_file.display().to_string().replace('\\', "/");
564 let probe = keys
565 .iter()
566 .map(|k| format!("echo {k}=${k} >> {out}"))
567 .collect::<Vec<_>>()
568 .join("; ");
569 let hooks = vec![HookEntry::Structured(StructuredHook {
570 cmd: probe,
571 env: hook_env,
572 ..Default::default()
573 })];
574 let vars = TemplateVars::new();
575 run_hooks(
576 &hooks,
577 "test",
578 HookRunContext::new(false, &log, Some(&vars)).with_extra_env(extra_env),
579 )
580 }
581
582 #[test]
583 fn extra_env_reaches_hook() {
584 let dir = std::env::temp_dir().join(format!("anodizer-ee-{}", std::process::id()));
585 std::fs::create_dir_all(&dir).unwrap();
586 let out = dir.join("reaches.txt");
587 let _ = std::fs::remove_file(&out);
588
589 let extra = vec![("MY_EXTRA_VAR".to_string(), "from-extra-env".to_string())];
590 run_extra_env_probe_hook(&out, &["MY_EXTRA_VAR"], None, &extra).expect("hook must run");
591
592 let contents = std::fs::read_to_string(&out).unwrap();
593 assert!(
594 contents.contains("MY_EXTRA_VAR=from-extra-env"),
595 "extra env var must reach the hook; got: {contents:?}"
596 );
597 let _ = std::fs::remove_file(&out);
598 }
599
600 #[test]
601 fn hook_env_overrides_extra_env_on_key_conflict() {
602 let dir = std::env::temp_dir().join(format!("anodizer-ee-{}", std::process::id()));
603 std::fs::create_dir_all(&dir).unwrap();
604 let out = dir.join("precedence.txt");
605 let _ = std::fs::remove_file(&out);
606
607 let extra = vec![("SHARED".to_string(), "extra-loses".to_string())];
608 run_extra_env_probe_hook(
609 &out,
610 &["SHARED"],
611 Some(vec!["SHARED=hook-wins".to_string()]),
612 &extra,
613 )
614 .expect("hook must run");
615
616 let contents = std::fs::read_to_string(&out).unwrap();
617 assert!(
618 contents.contains("SHARED=hook-wins"),
619 "hook env must override extra env on key conflict; got: {contents:?}"
620 );
621 let _ = std::fs::remove_file(&out);
622 }
623
624 #[test]
625 fn absent_build_env_is_unchanged_behavior() {
626 let dir = std::env::temp_dir().join(format!("anodizer-be-{}", std::process::id()));
627 std::fs::create_dir_all(&dir).unwrap();
628 let out = dir.join("absent.txt");
629 let _ = std::fs::remove_file(&out);
630
631 run_env_probe_hook(&out, &["NOT_SET_ANYWHERE"], None, None).expect("hook must run");
633
634 let contents = std::fs::read_to_string(&out).unwrap();
635 assert!(
636 contents.contains("NOT_SET_ANYWHERE="),
637 "absent build env must leave behavior unchanged; got: {contents:?}"
638 );
639 let _ = std::fs::remove_file(&out);
640 }
641
642 #[test]
643 fn empty_build_env_map_adds_nothing() {
644 let dir = std::env::temp_dir().join(format!("anodizer-be-{}", std::process::id()));
645 std::fs::create_dir_all(&dir).unwrap();
646 let out = dir.join("empty.txt");
647 let _ = std::fs::remove_file(&out);
648
649 let build_env: HashMap<String, String> = HashMap::new();
650 run_env_probe_hook(&out, &["NOT_SET_ANYWHERE"], None, Some(&build_env))
651 .expect("hook must run");
652
653 let contents = std::fs::read_to_string(&out).unwrap();
654 assert!(
655 contents.contains("NOT_SET_ANYWHERE="),
656 "empty build env map must be a no-op; got: {contents:?}"
657 );
658 let _ = std::fs::remove_file(&out);
659 }
660
661 #[test]
662 fn hook_if_render_error_propagates() {
663 let log = test_logger();
664 let vars = vars_with_snapshot(false);
665 let hooks = vec![structured("true", Some("{{ UndefinedSymbol }}"))];
666 let err = run_hooks(&hooks, "test", HookRunContext::new(true, &log, Some(&vars)))
667 .expect_err("unrenderable template must surface as Err");
668 let chain = format!("{err:#}");
669 assert!(
670 chain.contains("template render failed") || chain.contains("UndefinedSymbol"),
671 "expected render-error diagnostic, got: {chain}",
672 );
673 }
674}