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}
147
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
179#[serde(rename_all = "snake_case")]
180pub enum BeforePublishArtifactFilter {
181 Checksum,
182 Source,
183 Package,
184 Installer,
185 #[serde(alias = "diskimage")]
186 DiskImage,
187 Archive,
188 Binary,
189 Sbom,
190 Image,
191 #[default]
192 All,
193}
194
195impl BeforePublishArtifactFilter {
196 pub fn matches(self, kind: ArtifactKind) -> bool {
198 match self {
199 Self::All => true,
200 Self::Checksum => matches!(kind, ArtifactKind::Checksum),
201 Self::Source => matches!(
202 kind,
203 ArtifactKind::SourceArchive
204 | ArtifactKind::SourcePkgBuild
205 | ArtifactKind::SourceSrcInfo
206 | ArtifactKind::SourceRpm
207 ),
208 Self::Package => matches!(
209 kind,
210 ArtifactKind::LinuxPackage
211 | ArtifactKind::Snap
212 | ArtifactKind::PublishableSnapcraft
213 | ArtifactKind::Flatpak
214 ),
215 Self::Installer => matches!(kind, ArtifactKind::Installer | ArtifactKind::MacOsPackage),
216 Self::DiskImage => matches!(kind, ArtifactKind::DiskImage),
217 Self::Archive => matches!(kind, ArtifactKind::Archive | ArtifactKind::Makeself),
218 Self::Binary => matches!(
219 kind,
220 ArtifactKind::Binary
221 | ArtifactKind::UploadableBinary
222 | ArtifactKind::UniversalBinary
223 ),
224 Self::Sbom => matches!(kind, ArtifactKind::Sbom),
225 Self::Image => matches!(
226 kind,
227 ArtifactKind::DockerImage
228 | ArtifactKind::DockerImageV2
229 | ArtifactKind::PublishableDockerImage
230 ),
231 }
232 }
233}
234
235#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
236#[serde(untagged)]
237pub enum HookEntry {
238 Simple(String),
239 Structured(StructuredHook),
240}
241
242impl PartialEq<&str> for HookEntry {
243 fn eq(&self, other: &&str) -> bool {
244 match self {
245 HookEntry::Simple(s) => s.as_str() == *other,
246 HookEntry::Structured(h) => h.cmd.as_str() == *other,
247 }
248 }
249}
250
251impl<'de> Deserialize<'de> for HookEntry {
252 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
253 where
254 D: Deserializer<'de>,
255 {
256 let value = serde_json::Value::deserialize(deserializer)?;
257 match &value {
258 serde_json::Value::String(s) => Ok(HookEntry::Simple(s.clone())),
259 serde_json::Value::Object(_) => {
260 let hook: StructuredHook =
261 serde_json::from_value(value).map_err(serde::de::Error::custom)?;
262 Ok(HookEntry::Structured(hook))
263 }
264 _ => Err(serde::de::Error::custom(
265 "hook entry must be a string or an object with cmd/dir/env/output",
266 )),
267 }
268 }
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274 use std::io;
275 use std::sync::{Arc, Mutex, MutexGuard};
276 use tracing::subscriber::with_default;
277 use tracing_subscriber::fmt::MakeWriter;
278
279 #[derive(Clone, Default)]
281 struct BufferWriter(Arc<Mutex<Vec<u8>>>);
282
283 impl BufferWriter {
284 fn captured(&self) -> String {
285 String::from_utf8_lossy(&self.0.lock().unwrap()).to_string()
286 }
287 }
288
289 impl io::Write for BufferWriter {
290 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
291 self.0.lock().unwrap().extend_from_slice(buf);
292 Ok(buf.len())
293 }
294 fn flush(&mut self) -> io::Result<()> {
295 Ok(())
296 }
297 }
298
299 impl<'a> MakeWriter<'a> for BufferWriter {
303 type Writer = BufferWriterGuard<'a>;
304 fn make_writer(&'a self) -> Self::Writer {
305 BufferWriterGuard(self.0.lock().unwrap())
306 }
307 }
308
309 struct BufferWriterGuard<'a>(MutexGuard<'a, Vec<u8>>);
310 impl io::Write for BufferWriterGuard<'_> {
311 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
312 self.0.extend_from_slice(buf);
313 Ok(buf.len())
314 }
315 fn flush(&mut self) -> io::Result<()> {
316 Ok(())
317 }
318 }
319
320 fn capture_warnings<F: FnOnce()>(body: F) -> String {
323 let buf = BufferWriter::default();
324 let subscriber = tracing_subscriber::fmt()
325 .with_writer(buf.clone())
326 .with_max_level(tracing::Level::WARN)
327 .without_time()
328 .with_ansi(false)
329 .finish();
330 with_default(subscriber, body);
331 buf.captured()
332 }
333
334 #[test]
337 fn legacy_post_only_folds_and_warns() {
338 let captured = capture_warnings(|| {
339 let mut cfg = HooksConfig {
340 hooks: None,
341 post: Some(vec![HookEntry::Simple("legacy.sh".to_string())]),
342 };
343 cfg.merge_hook_aliases();
344 assert_eq!(
345 cfg.hooks.as_deref().map(|v| v.len()),
346 Some(1),
347 "post: should have moved into hooks:"
348 );
349 assert!(cfg.post.is_none(), "post: must be cleared after merge");
350 });
351 assert!(
352 captured.contains("DEPRECATION"),
353 "expected DEPRECATION marker in warning: {captured}"
354 );
355 assert!(
356 captured.contains("renamed to 'hooks:'"),
357 "legacy-only warning must guide to 'hooks:' rename: {captured}"
358 );
359 }
360
361 #[test]
364 fn both_present_keeps_hooks_drops_post_and_warns() {
365 let captured = capture_warnings(|| {
366 let mut cfg = HooksConfig {
367 hooks: Some(vec![HookEntry::Simple("modern.sh".to_string())]),
368 post: Some(vec![HookEntry::Simple("legacy.sh".to_string())]),
369 };
370 cfg.merge_hook_aliases();
371 assert!(cfg.post.is_none(), "post: must be cleared on conflict");
372 let names: Vec<&str> = cfg
373 .hooks
374 .as_deref()
375 .unwrap()
376 .iter()
377 .map(|h| match h {
378 HookEntry::Simple(s) => s.as_str(),
379 HookEntry::Structured(s) => s.cmd.as_str(),
380 })
381 .collect();
382 assert_eq!(names, vec!["modern.sh"], "hooks: must win on conflict");
383 });
384 assert!(
385 captured.contains("DEPRECATION"),
386 "expected DEPRECATION marker: {captured}"
387 );
388 assert!(
389 captured.contains("ignoring 'post:'"),
390 "conflict warning must mention 'ignoring post': {captured}"
391 );
392 }
393
394 #[test]
396 fn canonical_hooks_only_emits_no_warning() {
397 let captured = capture_warnings(|| {
398 let mut cfg = HooksConfig {
399 hooks: Some(vec![HookEntry::Simple("modern.sh".to_string())]),
400 post: None,
401 };
402 cfg.merge_hook_aliases();
403 assert!(cfg.post.is_none());
404 assert_eq!(cfg.hooks.as_deref().map(|v| v.len()), Some(1));
405 });
406 assert!(
407 !captured.contains("DEPRECATION"),
408 "canonical hooks-only must not warn: {captured}"
409 );
410 }
411
412 #[test]
413 fn filter_all_matches_every_kind() {
414 let f = BeforePublishArtifactFilter::All;
415 assert!(f.matches(ArtifactKind::Checksum));
416 assert!(f.matches(ArtifactKind::Binary));
417 assert!(f.matches(ArtifactKind::DockerManifest));
418 assert!(f.matches(ArtifactKind::Sbom));
419 }
420
421 #[test]
422 fn filter_default_is_all() {
423 assert_eq!(
424 BeforePublishArtifactFilter::default(),
425 BeforePublishArtifactFilter::All
426 );
427 }
428
429 #[test]
430 fn filter_source_buckets_all_source_kinds() {
431 let f = BeforePublishArtifactFilter::Source;
432 assert!(f.matches(ArtifactKind::SourceArchive));
433 assert!(f.matches(ArtifactKind::SourcePkgBuild));
434 assert!(f.matches(ArtifactKind::SourceSrcInfo));
435 assert!(f.matches(ArtifactKind::SourceRpm));
436 assert!(!f.matches(ArtifactKind::Archive));
437 assert!(!f.matches(ArtifactKind::Binary));
438 }
439
440 #[test]
441 fn filter_package_excludes_archives_and_binaries() {
442 let f = BeforePublishArtifactFilter::Package;
443 assert!(f.matches(ArtifactKind::LinuxPackage));
444 assert!(f.matches(ArtifactKind::Snap));
445 assert!(f.matches(ArtifactKind::PublishableSnapcraft));
446 assert!(f.matches(ArtifactKind::Flatpak));
447 assert!(!f.matches(ArtifactKind::Archive));
448 assert!(!f.matches(ArtifactKind::SourceRpm));
449 }
450
451 #[test]
452 fn filter_installer_covers_msi_and_macos_pkg() {
453 let f = BeforePublishArtifactFilter::Installer;
454 assert!(f.matches(ArtifactKind::Installer));
455 assert!(f.matches(ArtifactKind::MacOsPackage));
456 assert!(!f.matches(ArtifactKind::DiskImage));
457 }
458
459 #[test]
460 fn filter_archive_includes_makeself_but_not_source_archive() {
461 let f = BeforePublishArtifactFilter::Archive;
462 assert!(f.matches(ArtifactKind::Archive));
463 assert!(f.matches(ArtifactKind::Makeself));
464 assert!(!f.matches(ArtifactKind::SourceArchive));
465 }
466
467 #[test]
468 fn filter_binary_covers_three_binary_kinds() {
469 let f = BeforePublishArtifactFilter::Binary;
470 assert!(f.matches(ArtifactKind::Binary));
471 assert!(f.matches(ArtifactKind::UploadableBinary));
472 assert!(f.matches(ArtifactKind::UniversalBinary));
473 assert!(!f.matches(ArtifactKind::Library));
474 }
475
476 #[test]
477 fn filter_image_excludes_multiarch_manifest() {
478 let f = BeforePublishArtifactFilter::Image;
479 assert!(f.matches(ArtifactKind::DockerImage));
480 assert!(f.matches(ArtifactKind::DockerImageV2));
481 assert!(f.matches(ArtifactKind::PublishableDockerImage));
482 assert!(!f.matches(ArtifactKind::DockerManifest));
484 assert!(!f.matches(ArtifactKind::DockerDigest));
485 }
486
487 #[test]
488 fn filter_narrow_variants_match_only_themselves() {
489 assert!(BeforePublishArtifactFilter::Checksum.matches(ArtifactKind::Checksum));
490 assert!(!BeforePublishArtifactFilter::Checksum.matches(ArtifactKind::Sbom));
491 assert!(BeforePublishArtifactFilter::DiskImage.matches(ArtifactKind::DiskImage));
492 assert!(!BeforePublishArtifactFilter::DiskImage.matches(ArtifactKind::Installer));
493 assert!(BeforePublishArtifactFilter::Sbom.matches(ArtifactKind::Sbom));
494 assert!(!BeforePublishArtifactFilter::Sbom.matches(ArtifactKind::Checksum));
495 }
496
497 #[test]
498 fn filter_deserializes_snake_case_and_diskimage_alias() {
499 let f: BeforePublishArtifactFilter = serde_yaml_ng::from_str("disk_image").unwrap();
500 assert_eq!(f, BeforePublishArtifactFilter::DiskImage);
501 let aliased: BeforePublishArtifactFilter = serde_yaml_ng::from_str("diskimage").unwrap();
503 assert_eq!(aliased, BeforePublishArtifactFilter::DiskImage);
504 }
505
506 #[test]
507 fn hook_entry_string_deserializes_as_simple() {
508 let h: HookEntry = serde_yaml_ng::from_str("\"echo hi\"").unwrap();
509 assert!(matches!(h, HookEntry::Simple(ref s) if s == "echo hi"));
510 }
511
512 #[test]
513 fn hook_entry_object_deserializes_as_structured() {
514 let h: HookEntry = serde_yaml_ng::from_str("cmd: build.sh\ndir: subdir").unwrap();
515 match h {
516 HookEntry::Structured(s) => {
517 assert_eq!(s.cmd, "build.sh");
518 assert_eq!(s.dir.as_deref(), Some("subdir"));
519 }
520 HookEntry::Simple(_) => panic!("expected structured hook"),
521 }
522 }
523
524 #[test]
525 fn hook_entry_rejects_non_string_non_object() {
526 let err = serde_yaml_ng::from_str::<HookEntry>("- a\n- b");
528 assert!(err.is_err());
529 }
530
531 #[test]
532 fn hook_entry_if_alias_maps_to_if_condition() {
533 let h: HookEntry = serde_yaml_ng::from_str("cmd: x\nif: \"{{ .IsSnapshot }}\"").unwrap();
534 match h {
535 HookEntry::Structured(s) => {
536 assert_eq!(s.if_condition.as_deref(), Some("{{ .IsSnapshot }}"));
537 }
538 HookEntry::Simple(_) => panic!("expected structured hook"),
539 }
540 }
541
542 #[test]
543 fn hook_entry_partial_eq_str_matches_both_variants() {
544 assert!(HookEntry::Simple("go test".to_string()) == "go test");
545 assert!(HookEntry::Simple("go test".to_string()) != "go vet");
546 let structured = HookEntry::Structured(StructuredHook {
547 cmd: "make lint".to_string(),
548 ..Default::default()
549 });
550 assert!(structured == "make lint");
551 assert!(structured != "make build");
552 }
553
554 #[test]
555 fn deserialize_then_serialize_drops_post_field() {
556 let cfg: HooksConfig = serde_yaml_ng::from_str("post:\n - legacy.sh").unwrap();
558 assert!(cfg.post.is_none());
559 let out = serde_yaml_ng::to_string(&cfg).unwrap();
560 assert!(out.contains("hooks"), "serialized: {out}");
561 assert!(
562 !out.contains("post"),
563 "serialized must not carry post: {out}"
564 );
565 }
566
567 #[test]
568 fn empty_block_neither_spelling_stays_empty_and_silent() {
569 let captured = capture_warnings(|| {
571 let mut cfg = HooksConfig {
572 hooks: None,
573 post: None,
574 };
575 cfg.merge_hook_aliases();
576 assert!(cfg.hooks.is_none());
577 assert!(cfg.post.is_none());
578 });
579 assert!(
580 !captured.contains("DEPRECATION"),
581 "empty block must not warn: {captured}"
582 );
583 }
584
585 #[test]
586 fn empty_post_vec_does_not_trigger_fold_or_warn() {
587 let captured = capture_warnings(|| {
590 let mut cfg = HooksConfig {
591 hooks: None,
592 post: Some(vec![]),
593 };
594 cfg.merge_hook_aliases();
595 assert!(cfg.hooks.is_none(), "empty post must not become hooks");
597 });
598 assert!(
599 !captured.contains("DEPRECATION"),
600 "empty post must not warn: {captured}"
601 );
602 }
603
604 #[test]
605 fn default_hooks_config_is_all_none() {
606 let cfg = HooksConfig::default();
607 assert!(cfg.hooks.is_none());
608 assert!(cfg.post.is_none());
609 }
610
611 #[test]
612 fn raw_deserialize_rejects_unknown_key() {
613 let err = serde_yaml_ng::from_str::<HooksConfig>("befor:\n - x");
614 assert!(err.is_err(), "deny_unknown_fields on Raw must reject typos");
615 }
616
617 #[test]
618 fn structured_hook_full_fields_parse() {
619 let h: HookEntry = serde_yaml_ng::from_str(
620 "cmd: build.sh\nenv: [FOO=1, BAR=2]\noutput: true\nids: [linux-bin]\nartifacts: binary\n",
621 )
622 .unwrap();
623 match h {
624 HookEntry::Structured(s) => {
625 assert_eq!(s.cmd, "build.sh");
626 assert_eq!(
627 s.env.as_deref(),
628 Some(&["FOO=1".to_string(), "BAR=2".to_string()][..])
629 );
630 assert_eq!(s.output, Some(true));
631 assert_eq!(s.ids.as_deref(), Some(&["linux-bin".to_string()][..]));
632 assert_eq!(s.artifacts, Some(BeforePublishArtifactFilter::Binary));
633 }
634 HookEntry::Simple(_) => panic!("expected structured hook"),
635 }
636 }
637
638 #[test]
639 fn structured_hook_rejects_unknown_field() {
640 let err = serde_yaml_ng::from_str::<HookEntry>("cmd: x\nbogus: 1");
642 assert!(
643 err.is_err(),
644 "unknown structured-hook field must be rejected"
645 );
646 }
647
648 #[test]
649 fn hook_entry_simple_serializes_untagged_as_bare_string() {
650 let h = HookEntry::Simple("echo hi".to_string());
651 let out = serde_yaml_ng::to_string(&h).unwrap();
652 assert_eq!(out.trim(), "echo hi");
654 }
655
656 #[test]
657 fn hook_entry_structured_serializes_to_mapping() {
658 let h = HookEntry::Structured(StructuredHook {
659 cmd: "make".to_string(),
660 output: Some(true),
661 ..Default::default()
662 });
663 let out = serde_yaml_ng::to_string(&h).unwrap();
664 assert!(out.contains("cmd: make"), "serialized: {out}");
665 assert!(out.contains("output: true"), "serialized: {out}");
666 }
667
668 #[test]
669 fn filter_installer_distinct_from_package_and_diskimage() {
670 let f = BeforePublishArtifactFilter::Installer;
673 assert!(!f.matches(ArtifactKind::LinuxPackage));
674 assert!(!f.matches(ArtifactKind::DiskImage));
675 assert!(!f.matches(ArtifactKind::Archive));
676 }
677}