1use schemars::JsonSchema;
2use serde::{Deserialize, Deserializer, Serialize};
3
4use crate::artifact::ArtifactKind;
5
6#[derive(Debug, Clone, PartialEq, Default, JsonSchema)]
22#[serde(deny_unknown_fields)]
23pub struct HooksConfig {
24 pub hooks: Option<Vec<HookEntry>>,
28 #[doc(hidden)]
33 pub post: Option<Vec<HookEntry>>,
34}
35
36impl HooksConfig {
37 fn merge_hook_aliases(&mut self) {
44 let has_hooks = self.hooks.as_ref().is_some_and(|v| !v.is_empty());
45 let has_post = self.post.as_ref().is_some_and(|v| !v.is_empty());
46 match (has_hooks, has_post) {
47 (true, true) => {
48 tracing::warn!(
49 "DEPRECATION: top-level hooks block has both 'hooks:' and 'post:' \
50 — using 'hooks:' and ignoring 'post:'. The 'post:' spelling is \
51 renamed to 'hooks:' for GoReleaser parity; remove the 'post:' \
52 key from your config."
53 );
54 self.post = None;
55 }
56 (false, true) => {
57 tracing::warn!(
58 "DEPRECATION: top-level 'after.post:' / 'before.post:' is renamed to \
59 'hooks:' for GoReleaser parity. The 'post:' spelling still works \
60 but will be removed in a future release; switch to 'hooks:'."
61 );
62 self.hooks = self.post.take();
63 }
64 _ => {}
67 }
68 }
69}
70
71impl Serialize for HooksConfig {
72 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
73 use serde::ser::SerializeStruct;
74 let count = self.hooks.is_some() as usize + self.post.is_some() as usize;
75 let mut state = serializer.serialize_struct("HooksConfig", count)?;
76 if let Some(ref h) = self.hooks {
77 state.serialize_field("hooks", h)?;
78 }
79 if let Some(ref p) = self.post {
80 state.serialize_field("post", p)?;
81 }
82 state.end()
83 }
84}
85
86impl<'de> Deserialize<'de> for HooksConfig {
87 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
88 where
89 D: Deserializer<'de>,
90 {
91 #[derive(Deserialize, Default)]
92 #[serde(default, deny_unknown_fields)]
93 struct Raw {
94 hooks: Option<Vec<HookEntry>>,
95 post: Option<Vec<HookEntry>>,
96 }
97 let raw = Raw::deserialize(deserializer)?;
98 let mut out = HooksConfig {
99 hooks: raw.hooks,
100 post: raw.post,
101 };
102 out.merge_hook_aliases();
103 Ok(out)
104 }
105}
106
107#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema)]
108#[serde(default, deny_unknown_fields)]
109pub struct StructuredHook {
110 pub cmd: String,
121 pub dir: Option<String>,
123 #[serde(default)]
125 pub env: Option<Vec<String>>,
126 pub output: Option<bool>,
128 #[serde(rename = "if")]
133 pub if_condition: Option<String>,
134 pub ids: Option<Vec<String>>,
141 pub artifacts: Option<BeforePublishArtifactFilter>,
147 #[serde(default)]
158 pub run_once: bool,
159}
160
161#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
192#[serde(rename_all = "snake_case")]
193pub enum BeforePublishArtifactFilter {
194 Checksum,
195 Source,
196 Package,
197 Installer,
198 #[serde(alias = "diskimage")]
199 DiskImage,
200 Archive,
201 Binary,
202 Sbom,
203 Image,
204 #[default]
205 All,
206}
207
208impl BeforePublishArtifactFilter {
209 pub fn matches(self, kind: ArtifactKind) -> bool {
211 match self {
212 Self::All => true,
213 Self::Checksum => matches!(kind, ArtifactKind::Checksum),
214 Self::Source => matches!(
215 kind,
216 ArtifactKind::SourceArchive
217 | ArtifactKind::SourcePkgBuild
218 | ArtifactKind::SourceSrcInfo
219 | ArtifactKind::SourceRpm
220 ),
221 Self::Package => matches!(
222 kind,
223 ArtifactKind::LinuxPackage
224 | ArtifactKind::Snap
225 | ArtifactKind::PublishableSnapcraft
226 | ArtifactKind::Flatpak
227 ),
228 Self::Installer => matches!(kind, ArtifactKind::Installer | ArtifactKind::MacOsPackage),
229 Self::DiskImage => matches!(kind, ArtifactKind::DiskImage),
230 Self::Archive => matches!(kind, ArtifactKind::Archive | ArtifactKind::Makeself),
231 Self::Binary => matches!(
232 kind,
233 ArtifactKind::Binary
234 | ArtifactKind::UploadableBinary
235 | ArtifactKind::UniversalBinary
236 ),
237 Self::Sbom => matches!(kind, ArtifactKind::Sbom),
238 Self::Image => matches!(
239 kind,
240 ArtifactKind::DockerImage
241 | ArtifactKind::DockerImageV2
242 | ArtifactKind::PublishableDockerImage
243 ),
244 }
245 }
246}
247
248#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
249#[serde(untagged)]
250pub enum HookEntry {
251 Simple(String),
252 Structured(StructuredHook),
253}
254
255impl PartialEq<&str> for HookEntry {
256 fn eq(&self, other: &&str) -> bool {
257 match self {
258 HookEntry::Simple(s) => s.as_str() == *other,
259 HookEntry::Structured(h) => h.cmd.as_str() == *other,
260 }
261 }
262}
263
264impl<'de> Deserialize<'de> for HookEntry {
265 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
266 where
267 D: Deserializer<'de>,
268 {
269 let value = serde_json::Value::deserialize(deserializer)?;
270 match &value {
271 serde_json::Value::String(s) => Ok(HookEntry::Simple(s.clone())),
272 serde_json::Value::Object(_) => {
273 let hook: StructuredHook =
274 serde_json::from_value(value).map_err(serde::de::Error::custom)?;
275 Ok(HookEntry::Structured(hook))
276 }
277 _ => Err(serde::de::Error::custom(
278 "hook entry must be a string or an object with cmd/dir/env/output",
279 )),
280 }
281 }
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287 use std::io;
288 use std::sync::{Arc, Mutex, MutexGuard};
289 use tracing::subscriber::with_default;
290 use tracing_subscriber::fmt::MakeWriter;
291
292 #[derive(Clone, Default)]
294 struct BufferWriter(Arc<Mutex<Vec<u8>>>);
295
296 impl BufferWriter {
297 fn captured(&self) -> String {
298 String::from_utf8_lossy(&self.0.lock().unwrap()).to_string()
299 }
300 }
301
302 impl io::Write for BufferWriter {
303 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
304 self.0.lock().unwrap().extend_from_slice(buf);
305 Ok(buf.len())
306 }
307 fn flush(&mut self) -> io::Result<()> {
308 Ok(())
309 }
310 }
311
312 impl<'a> MakeWriter<'a> for BufferWriter {
316 type Writer = BufferWriterGuard<'a>;
317 fn make_writer(&'a self) -> Self::Writer {
318 BufferWriterGuard(self.0.lock().unwrap())
319 }
320 }
321
322 struct BufferWriterGuard<'a>(MutexGuard<'a, Vec<u8>>);
323 impl io::Write for BufferWriterGuard<'_> {
324 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
325 self.0.extend_from_slice(buf);
326 Ok(buf.len())
327 }
328 fn flush(&mut self) -> io::Result<()> {
329 Ok(())
330 }
331 }
332
333 fn capture_warnings<F: FnOnce()>(body: F) -> String {
336 let buf = BufferWriter::default();
337 let subscriber = tracing_subscriber::fmt()
338 .with_writer(buf.clone())
339 .with_max_level(tracing::Level::WARN)
340 .without_time()
341 .with_ansi(false)
342 .finish();
343 with_default(subscriber, body);
344 buf.captured()
345 }
346
347 #[test]
350 fn legacy_post_only_folds_and_warns() {
351 let captured = capture_warnings(|| {
352 let mut cfg = HooksConfig {
353 hooks: None,
354 post: Some(vec![HookEntry::Simple("legacy.sh".to_string())]),
355 };
356 cfg.merge_hook_aliases();
357 assert_eq!(
358 cfg.hooks.as_deref().map(|v| v.len()),
359 Some(1),
360 "post: should have moved into hooks:"
361 );
362 assert!(cfg.post.is_none(), "post: must be cleared after merge");
363 });
364 assert!(
365 captured.contains("DEPRECATION"),
366 "expected DEPRECATION marker in warning: {captured}"
367 );
368 assert!(
369 captured.contains("renamed to 'hooks:'"),
370 "legacy-only warning must guide to 'hooks:' rename: {captured}"
371 );
372 }
373
374 #[test]
377 fn both_present_keeps_hooks_drops_post_and_warns() {
378 let captured = capture_warnings(|| {
379 let mut cfg = HooksConfig {
380 hooks: Some(vec![HookEntry::Simple("modern.sh".to_string())]),
381 post: Some(vec![HookEntry::Simple("legacy.sh".to_string())]),
382 };
383 cfg.merge_hook_aliases();
384 assert!(cfg.post.is_none(), "post: must be cleared on conflict");
385 let names: Vec<&str> = cfg
386 .hooks
387 .as_deref()
388 .unwrap()
389 .iter()
390 .map(|h| match h {
391 HookEntry::Simple(s) => s.as_str(),
392 HookEntry::Structured(s) => s.cmd.as_str(),
393 })
394 .collect();
395 assert_eq!(names, vec!["modern.sh"], "hooks: must win on conflict");
396 });
397 assert!(
398 captured.contains("DEPRECATION"),
399 "expected DEPRECATION marker: {captured}"
400 );
401 assert!(
402 captured.contains("ignoring 'post:'"),
403 "conflict warning must mention 'ignoring post': {captured}"
404 );
405 }
406
407 #[test]
409 fn canonical_hooks_only_emits_no_warning() {
410 let captured = capture_warnings(|| {
411 let mut cfg = HooksConfig {
412 hooks: Some(vec![HookEntry::Simple("modern.sh".to_string())]),
413 post: None,
414 };
415 cfg.merge_hook_aliases();
416 assert!(cfg.post.is_none());
417 assert_eq!(cfg.hooks.as_deref().map(|v| v.len()), Some(1));
418 });
419 assert!(
420 !captured.contains("DEPRECATION"),
421 "canonical hooks-only must not warn: {captured}"
422 );
423 }
424
425 #[test]
426 fn filter_all_matches_every_kind() {
427 let f = BeforePublishArtifactFilter::All;
428 assert!(f.matches(ArtifactKind::Checksum));
429 assert!(f.matches(ArtifactKind::Binary));
430 assert!(f.matches(ArtifactKind::DockerManifest));
431 assert!(f.matches(ArtifactKind::Sbom));
432 }
433
434 #[test]
435 fn filter_default_is_all() {
436 assert_eq!(
437 BeforePublishArtifactFilter::default(),
438 BeforePublishArtifactFilter::All
439 );
440 }
441
442 #[test]
443 fn filter_source_buckets_all_source_kinds() {
444 let f = BeforePublishArtifactFilter::Source;
445 assert!(f.matches(ArtifactKind::SourceArchive));
446 assert!(f.matches(ArtifactKind::SourcePkgBuild));
447 assert!(f.matches(ArtifactKind::SourceSrcInfo));
448 assert!(f.matches(ArtifactKind::SourceRpm));
449 assert!(!f.matches(ArtifactKind::Archive));
450 assert!(!f.matches(ArtifactKind::Binary));
451 }
452
453 #[test]
454 fn filter_package_excludes_archives_and_binaries() {
455 let f = BeforePublishArtifactFilter::Package;
456 assert!(f.matches(ArtifactKind::LinuxPackage));
457 assert!(f.matches(ArtifactKind::Snap));
458 assert!(f.matches(ArtifactKind::PublishableSnapcraft));
459 assert!(f.matches(ArtifactKind::Flatpak));
460 assert!(!f.matches(ArtifactKind::Archive));
461 assert!(!f.matches(ArtifactKind::SourceRpm));
462 }
463
464 #[test]
465 fn filter_installer_covers_msi_and_macos_pkg() {
466 let f = BeforePublishArtifactFilter::Installer;
467 assert!(f.matches(ArtifactKind::Installer));
468 assert!(f.matches(ArtifactKind::MacOsPackage));
469 assert!(!f.matches(ArtifactKind::DiskImage));
470 }
471
472 #[test]
473 fn filter_archive_includes_makeself_but_not_source_archive() {
474 let f = BeforePublishArtifactFilter::Archive;
475 assert!(f.matches(ArtifactKind::Archive));
476 assert!(f.matches(ArtifactKind::Makeself));
477 assert!(!f.matches(ArtifactKind::SourceArchive));
478 }
479
480 #[test]
481 fn filter_binary_covers_three_binary_kinds() {
482 let f = BeforePublishArtifactFilter::Binary;
483 assert!(f.matches(ArtifactKind::Binary));
484 assert!(f.matches(ArtifactKind::UploadableBinary));
485 assert!(f.matches(ArtifactKind::UniversalBinary));
486 assert!(!f.matches(ArtifactKind::Library));
487 }
488
489 #[test]
490 fn filter_image_excludes_multiarch_manifest() {
491 let f = BeforePublishArtifactFilter::Image;
492 assert!(f.matches(ArtifactKind::DockerImage));
493 assert!(f.matches(ArtifactKind::DockerImageV2));
494 assert!(f.matches(ArtifactKind::PublishableDockerImage));
495 assert!(!f.matches(ArtifactKind::DockerManifest));
497 assert!(!f.matches(ArtifactKind::DockerDigest));
498 }
499
500 #[test]
501 fn filter_narrow_variants_match_only_themselves() {
502 assert!(BeforePublishArtifactFilter::Checksum.matches(ArtifactKind::Checksum));
503 assert!(!BeforePublishArtifactFilter::Checksum.matches(ArtifactKind::Sbom));
504 assert!(BeforePublishArtifactFilter::DiskImage.matches(ArtifactKind::DiskImage));
505 assert!(!BeforePublishArtifactFilter::DiskImage.matches(ArtifactKind::Installer));
506 assert!(BeforePublishArtifactFilter::Sbom.matches(ArtifactKind::Sbom));
507 assert!(!BeforePublishArtifactFilter::Sbom.matches(ArtifactKind::Checksum));
508 }
509
510 #[test]
511 fn filter_deserializes_snake_case_and_diskimage_alias() {
512 let f: BeforePublishArtifactFilter = serde_yaml_ng::from_str("disk_image").unwrap();
513 assert_eq!(f, BeforePublishArtifactFilter::DiskImage);
514 let aliased: BeforePublishArtifactFilter = serde_yaml_ng::from_str("diskimage").unwrap();
516 assert_eq!(aliased, BeforePublishArtifactFilter::DiskImage);
517 }
518
519 #[test]
520 fn hook_entry_string_deserializes_as_simple() {
521 let h: HookEntry = serde_yaml_ng::from_str("\"echo hi\"").unwrap();
522 assert!(matches!(h, HookEntry::Simple(ref s) if s == "echo hi"));
523 }
524
525 #[test]
526 fn hook_entry_object_deserializes_as_structured() {
527 let h: HookEntry = serde_yaml_ng::from_str("cmd: build.sh\ndir: subdir").unwrap();
528 match h {
529 HookEntry::Structured(s) => {
530 assert_eq!(s.cmd, "build.sh");
531 assert_eq!(s.dir.as_deref(), Some("subdir"));
532 }
533 HookEntry::Simple(_) => panic!("expected structured hook"),
534 }
535 }
536
537 #[test]
538 fn hook_entry_rejects_non_string_non_object() {
539 let err = serde_yaml_ng::from_str::<HookEntry>("- a\n- b");
541 assert!(err.is_err());
542 }
543
544 #[test]
545 fn hook_entry_if_alias_maps_to_if_condition() {
546 let h: HookEntry = serde_yaml_ng::from_str("cmd: x\nif: \"{{ .IsSnapshot }}\"").unwrap();
547 match h {
548 HookEntry::Structured(s) => {
549 assert_eq!(s.if_condition.as_deref(), Some("{{ .IsSnapshot }}"));
550 }
551 HookEntry::Simple(_) => panic!("expected structured hook"),
552 }
553 }
554
555 #[test]
556 fn hook_entry_partial_eq_str_matches_both_variants() {
557 assert!(HookEntry::Simple("go test".to_string()) == "go test");
558 assert!(HookEntry::Simple("go test".to_string()) != "go vet");
559 let structured = HookEntry::Structured(StructuredHook {
560 cmd: "make lint".to_string(),
561 ..Default::default()
562 });
563 assert!(structured == "make lint");
564 assert!(structured != "make build");
565 }
566
567 #[test]
568 fn deserialize_then_serialize_drops_post_field() {
569 let cfg: HooksConfig = serde_yaml_ng::from_str("post:\n - legacy.sh").unwrap();
571 assert!(cfg.post.is_none());
572 let out = serde_yaml_ng::to_string(&cfg).unwrap();
573 assert!(out.contains("hooks"), "serialized: {out}");
574 assert!(
575 !out.contains("post"),
576 "serialized must not carry post: {out}"
577 );
578 }
579
580 #[test]
581 fn empty_block_neither_spelling_stays_empty_and_silent() {
582 let captured = capture_warnings(|| {
584 let mut cfg = HooksConfig {
585 hooks: None,
586 post: None,
587 };
588 cfg.merge_hook_aliases();
589 assert!(cfg.hooks.is_none());
590 assert!(cfg.post.is_none());
591 });
592 assert!(
593 !captured.contains("DEPRECATION"),
594 "empty block must not warn: {captured}"
595 );
596 }
597
598 #[test]
599 fn empty_post_vec_does_not_trigger_fold_or_warn() {
600 let captured = capture_warnings(|| {
603 let mut cfg = HooksConfig {
604 hooks: None,
605 post: Some(vec![]),
606 };
607 cfg.merge_hook_aliases();
608 assert!(cfg.hooks.is_none(), "empty post must not become hooks");
610 });
611 assert!(
612 !captured.contains("DEPRECATION"),
613 "empty post must not warn: {captured}"
614 );
615 }
616
617 #[test]
618 fn default_hooks_config_is_all_none() {
619 let cfg = HooksConfig::default();
620 assert!(cfg.hooks.is_none());
621 assert!(cfg.post.is_none());
622 }
623
624 #[test]
625 fn raw_deserialize_rejects_unknown_key() {
626 let err = serde_yaml_ng::from_str::<HooksConfig>("befor:\n - x");
627 assert!(err.is_err(), "deny_unknown_fields on Raw must reject typos");
628 }
629
630 #[test]
631 fn structured_hook_full_fields_parse() {
632 let h: HookEntry = serde_yaml_ng::from_str(
633 "cmd: build.sh\nenv: [FOO=1, BAR=2]\noutput: true\nids: [linux-bin]\nartifacts: binary\n",
634 )
635 .unwrap();
636 match h {
637 HookEntry::Structured(s) => {
638 assert_eq!(s.cmd, "build.sh");
639 assert_eq!(
640 s.env.as_deref(),
641 Some(&["FOO=1".to_string(), "BAR=2".to_string()][..])
642 );
643 assert_eq!(s.output, Some(true));
644 assert_eq!(s.ids.as_deref(), Some(&["linux-bin".to_string()][..]));
645 assert_eq!(s.artifacts, Some(BeforePublishArtifactFilter::Binary));
646 }
647 HookEntry::Simple(_) => panic!("expected structured hook"),
648 }
649 }
650
651 #[test]
652 fn structured_hook_rejects_unknown_field() {
653 let err = serde_yaml_ng::from_str::<HookEntry>("cmd: x\nbogus: 1");
655 assert!(
656 err.is_err(),
657 "unknown structured-hook field must be rejected"
658 );
659 }
660
661 #[test]
662 fn hook_entry_simple_serializes_untagged_as_bare_string() {
663 let h = HookEntry::Simple("echo hi".to_string());
664 let out = serde_yaml_ng::to_string(&h).unwrap();
665 assert_eq!(out.trim(), "echo hi");
667 }
668
669 #[test]
670 fn hook_entry_structured_serializes_to_mapping() {
671 let h = HookEntry::Structured(StructuredHook {
672 cmd: "make".to_string(),
673 output: Some(true),
674 ..Default::default()
675 });
676 let out = serde_yaml_ng::to_string(&h).unwrap();
677 assert!(out.contains("cmd: make"), "serialized: {out}");
678 assert!(out.contains("output: true"), "serialized: {out}");
679 }
680
681 #[test]
682 fn filter_installer_distinct_from_package_and_diskimage() {
683 let f = BeforePublishArtifactFilter::Installer;
686 assert!(!f.matches(ArtifactKind::LinuxPackage));
687 assert!(!f.matches(ArtifactKind::DiskImage));
688 assert!(!f.matches(ArtifactKind::Archive));
689 }
690}