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