use schemars::JsonSchema;
use serde::{Deserialize, Deserializer, Serialize};
use crate::artifact::ArtifactKind;
#[derive(Debug, Clone, PartialEq, Default, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct HooksConfig {
pub hooks: Option<Vec<HookEntry>>,
#[doc(hidden)]
pub post: Option<Vec<HookEntry>>,
}
impl HooksConfig {
fn merge_hook_aliases(&mut self) {
let has_hooks = self.hooks.as_ref().is_some_and(|v| !v.is_empty());
let has_post = self.post.as_ref().is_some_and(|v| !v.is_empty());
match (has_hooks, has_post) {
(true, true) => {
tracing::warn!(
"DEPRECATION: top-level hooks block has both 'hooks:' and 'post:' \
— using 'hooks:' and ignoring 'post:'. The 'post:' spelling is \
renamed to 'hooks:' for GoReleaser parity; remove the 'post:' \
key from your config."
);
self.post = None;
}
(false, true) => {
tracing::warn!(
"DEPRECATION: top-level 'after.post:' / 'before.post:' is renamed to \
'hooks:' for GoReleaser parity. The 'post:' spelling still works \
but will be removed in a future release; switch to 'hooks:'."
);
self.hooks = self.post.take();
}
_ => {}
}
}
}
impl Serialize for HooksConfig {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeStruct;
let count = self.hooks.is_some() as usize + self.post.is_some() as usize;
let mut state = serializer.serialize_struct("HooksConfig", count)?;
if let Some(ref h) = self.hooks {
state.serialize_field("hooks", h)?;
}
if let Some(ref p) = self.post {
state.serialize_field("post", p)?;
}
state.end()
}
}
impl<'de> Deserialize<'de> for HooksConfig {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize, Default)]
#[serde(default, deny_unknown_fields)]
struct Raw {
hooks: Option<Vec<HookEntry>>,
post: Option<Vec<HookEntry>>,
}
let raw = Raw::deserialize(deserializer)?;
let mut out = HooksConfig {
hooks: raw.hooks,
post: raw.post,
};
out.merge_hook_aliases();
Ok(out)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema)]
#[serde(default, deny_unknown_fields)]
pub struct StructuredHook {
pub cmd: String,
pub dir: Option<String>,
#[serde(default)]
pub env: Option<Vec<String>>,
pub output: Option<bool>,
#[serde(rename = "if")]
pub if_condition: Option<String>,
pub ids: Option<Vec<String>>,
pub artifacts: Option<BeforePublishArtifactFilter>,
#[serde(default)]
pub run_once: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum BeforePublishArtifactFilter {
Checksum,
Source,
Package,
Installer,
#[serde(alias = "diskimage")]
DiskImage,
Archive,
Binary,
Sbom,
Image,
#[default]
All,
}
impl BeforePublishArtifactFilter {
pub fn matches(self, kind: ArtifactKind) -> bool {
match self {
Self::All => true,
Self::Checksum => matches!(kind, ArtifactKind::Checksum),
Self::Source => matches!(
kind,
ArtifactKind::SourceArchive
| ArtifactKind::SourcePkgBuild
| ArtifactKind::SourceSrcInfo
| ArtifactKind::SourceRpm
),
Self::Package => matches!(
kind,
ArtifactKind::LinuxPackage
| ArtifactKind::Snap
| ArtifactKind::PublishableSnapcraft
| ArtifactKind::Flatpak
),
Self::Installer => matches!(kind, ArtifactKind::Installer | ArtifactKind::MacOsPackage),
Self::DiskImage => matches!(kind, ArtifactKind::DiskImage),
Self::Archive => matches!(kind, ArtifactKind::Archive | ArtifactKind::Makeself),
Self::Binary => matches!(
kind,
ArtifactKind::Binary
| ArtifactKind::UploadableBinary
| ArtifactKind::UniversalBinary
),
Self::Sbom => matches!(kind, ArtifactKind::Sbom),
Self::Image => matches!(
kind,
ArtifactKind::DockerImage
| ArtifactKind::DockerImageV2
| ArtifactKind::PublishableDockerImage
),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
#[serde(untagged)]
pub enum HookEntry {
Simple(String),
Structured(StructuredHook),
}
impl PartialEq<&str> for HookEntry {
fn eq(&self, other: &&str) -> bool {
match self {
HookEntry::Simple(s) => s.as_str() == *other,
HookEntry::Structured(h) => h.cmd.as_str() == *other,
}
}
}
impl<'de> Deserialize<'de> for HookEntry {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = serde_json::Value::deserialize(deserializer)?;
match &value {
serde_json::Value::String(s) => Ok(HookEntry::Simple(s.clone())),
serde_json::Value::Object(_) => {
let hook: StructuredHook =
serde_json::from_value(value).map_err(serde::de::Error::custom)?;
Ok(HookEntry::Structured(hook))
}
_ => Err(serde::de::Error::custom(
"hook entry must be a string or an object with cmd/dir/env/output",
)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io;
use std::sync::{Arc, Mutex, MutexGuard};
use tracing::subscriber::with_default;
use tracing_subscriber::fmt::MakeWriter;
#[derive(Clone, Default)]
struct BufferWriter(Arc<Mutex<Vec<u8>>>);
impl BufferWriter {
fn captured(&self) -> String {
String::from_utf8_lossy(&self.0.lock().unwrap()).to_string()
}
}
impl io::Write for BufferWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
impl<'a> MakeWriter<'a> for BufferWriter {
type Writer = BufferWriterGuard<'a>;
fn make_writer(&'a self) -> Self::Writer {
BufferWriterGuard(self.0.lock().unwrap())
}
}
struct BufferWriterGuard<'a>(MutexGuard<'a, Vec<u8>>);
impl io::Write for BufferWriterGuard<'_> {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
fn capture_warnings<F: FnOnce()>(body: F) -> String {
let buf = BufferWriter::default();
let subscriber = tracing_subscriber::fmt()
.with_writer(buf.clone())
.with_max_level(tracing::Level::WARN)
.without_time()
.with_ansi(false)
.finish();
with_default(subscriber, body);
buf.captured()
}
#[test]
fn legacy_post_only_folds_and_warns() {
let captured = capture_warnings(|| {
let mut cfg = HooksConfig {
hooks: None,
post: Some(vec![HookEntry::Simple("legacy.sh".to_string())]),
};
cfg.merge_hook_aliases();
assert_eq!(
cfg.hooks.as_deref().map(|v| v.len()),
Some(1),
"post: should have moved into hooks:"
);
assert!(cfg.post.is_none(), "post: must be cleared after merge");
});
assert!(
captured.contains("DEPRECATION"),
"expected DEPRECATION marker in warning: {captured}"
);
assert!(
captured.contains("renamed to 'hooks:'"),
"legacy-only warning must guide to 'hooks:' rename: {captured}"
);
}
#[test]
fn both_present_keeps_hooks_drops_post_and_warns() {
let captured = capture_warnings(|| {
let mut cfg = HooksConfig {
hooks: Some(vec![HookEntry::Simple("modern.sh".to_string())]),
post: Some(vec![HookEntry::Simple("legacy.sh".to_string())]),
};
cfg.merge_hook_aliases();
assert!(cfg.post.is_none(), "post: must be cleared on conflict");
let names: Vec<&str> = cfg
.hooks
.as_deref()
.unwrap()
.iter()
.map(|h| match h {
HookEntry::Simple(s) => s.as_str(),
HookEntry::Structured(s) => s.cmd.as_str(),
})
.collect();
assert_eq!(names, vec!["modern.sh"], "hooks: must win on conflict");
});
assert!(
captured.contains("DEPRECATION"),
"expected DEPRECATION marker: {captured}"
);
assert!(
captured.contains("ignoring 'post:'"),
"conflict warning must mention 'ignoring post': {captured}"
);
}
#[test]
fn canonical_hooks_only_emits_no_warning() {
let captured = capture_warnings(|| {
let mut cfg = HooksConfig {
hooks: Some(vec![HookEntry::Simple("modern.sh".to_string())]),
post: None,
};
cfg.merge_hook_aliases();
assert!(cfg.post.is_none());
assert_eq!(cfg.hooks.as_deref().map(|v| v.len()), Some(1));
});
assert!(
!captured.contains("DEPRECATION"),
"canonical hooks-only must not warn: {captured}"
);
}
#[test]
fn filter_all_matches_every_kind() {
let f = BeforePublishArtifactFilter::All;
assert!(f.matches(ArtifactKind::Checksum));
assert!(f.matches(ArtifactKind::Binary));
assert!(f.matches(ArtifactKind::DockerManifest));
assert!(f.matches(ArtifactKind::Sbom));
}
#[test]
fn filter_default_is_all() {
assert_eq!(
BeforePublishArtifactFilter::default(),
BeforePublishArtifactFilter::All
);
}
#[test]
fn filter_source_buckets_all_source_kinds() {
let f = BeforePublishArtifactFilter::Source;
assert!(f.matches(ArtifactKind::SourceArchive));
assert!(f.matches(ArtifactKind::SourcePkgBuild));
assert!(f.matches(ArtifactKind::SourceSrcInfo));
assert!(f.matches(ArtifactKind::SourceRpm));
assert!(!f.matches(ArtifactKind::Archive));
assert!(!f.matches(ArtifactKind::Binary));
}
#[test]
fn filter_package_excludes_archives_and_binaries() {
let f = BeforePublishArtifactFilter::Package;
assert!(f.matches(ArtifactKind::LinuxPackage));
assert!(f.matches(ArtifactKind::Snap));
assert!(f.matches(ArtifactKind::PublishableSnapcraft));
assert!(f.matches(ArtifactKind::Flatpak));
assert!(!f.matches(ArtifactKind::Archive));
assert!(!f.matches(ArtifactKind::SourceRpm));
}
#[test]
fn filter_installer_covers_msi_and_macos_pkg() {
let f = BeforePublishArtifactFilter::Installer;
assert!(f.matches(ArtifactKind::Installer));
assert!(f.matches(ArtifactKind::MacOsPackage));
assert!(!f.matches(ArtifactKind::DiskImage));
}
#[test]
fn filter_archive_includes_makeself_but_not_source_archive() {
let f = BeforePublishArtifactFilter::Archive;
assert!(f.matches(ArtifactKind::Archive));
assert!(f.matches(ArtifactKind::Makeself));
assert!(!f.matches(ArtifactKind::SourceArchive));
}
#[test]
fn filter_binary_covers_three_binary_kinds() {
let f = BeforePublishArtifactFilter::Binary;
assert!(f.matches(ArtifactKind::Binary));
assert!(f.matches(ArtifactKind::UploadableBinary));
assert!(f.matches(ArtifactKind::UniversalBinary));
assert!(!f.matches(ArtifactKind::Library));
}
#[test]
fn filter_image_excludes_multiarch_manifest() {
let f = BeforePublishArtifactFilter::Image;
assert!(f.matches(ArtifactKind::DockerImage));
assert!(f.matches(ArtifactKind::DockerImageV2));
assert!(f.matches(ArtifactKind::PublishableDockerImage));
assert!(!f.matches(ArtifactKind::DockerManifest));
assert!(!f.matches(ArtifactKind::DockerDigest));
}
#[test]
fn filter_narrow_variants_match_only_themselves() {
assert!(BeforePublishArtifactFilter::Checksum.matches(ArtifactKind::Checksum));
assert!(!BeforePublishArtifactFilter::Checksum.matches(ArtifactKind::Sbom));
assert!(BeforePublishArtifactFilter::DiskImage.matches(ArtifactKind::DiskImage));
assert!(!BeforePublishArtifactFilter::DiskImage.matches(ArtifactKind::Installer));
assert!(BeforePublishArtifactFilter::Sbom.matches(ArtifactKind::Sbom));
assert!(!BeforePublishArtifactFilter::Sbom.matches(ArtifactKind::Checksum));
}
#[test]
fn filter_deserializes_snake_case_and_diskimage_alias() {
let f: BeforePublishArtifactFilter = serde_yaml_ng::from_str("disk_image").unwrap();
assert_eq!(f, BeforePublishArtifactFilter::DiskImage);
let aliased: BeforePublishArtifactFilter = serde_yaml_ng::from_str("diskimage").unwrap();
assert_eq!(aliased, BeforePublishArtifactFilter::DiskImage);
}
#[test]
fn hook_entry_string_deserializes_as_simple() {
let h: HookEntry = serde_yaml_ng::from_str("\"echo hi\"").unwrap();
assert!(matches!(h, HookEntry::Simple(ref s) if s == "echo hi"));
}
#[test]
fn hook_entry_object_deserializes_as_structured() {
let h: HookEntry = serde_yaml_ng::from_str("cmd: build.sh\ndir: subdir").unwrap();
match h {
HookEntry::Structured(s) => {
assert_eq!(s.cmd, "build.sh");
assert_eq!(s.dir.as_deref(), Some("subdir"));
}
HookEntry::Simple(_) => panic!("expected structured hook"),
}
}
#[test]
fn hook_entry_rejects_non_string_non_object() {
let err = serde_yaml_ng::from_str::<HookEntry>("- a\n- b");
assert!(err.is_err());
}
#[test]
fn hook_entry_if_alias_maps_to_if_condition() {
let h: HookEntry = serde_yaml_ng::from_str("cmd: x\nif: \"{{ .IsSnapshot }}\"").unwrap();
match h {
HookEntry::Structured(s) => {
assert_eq!(s.if_condition.as_deref(), Some("{{ .IsSnapshot }}"));
}
HookEntry::Simple(_) => panic!("expected structured hook"),
}
}
#[test]
fn hook_entry_partial_eq_str_matches_both_variants() {
assert!(HookEntry::Simple("go test".to_string()) == "go test");
assert!(HookEntry::Simple("go test".to_string()) != "go vet");
let structured = HookEntry::Structured(StructuredHook {
cmd: "make lint".to_string(),
..Default::default()
});
assert!(structured == "make lint");
assert!(structured != "make build");
}
#[test]
fn deserialize_then_serialize_drops_post_field() {
let cfg: HooksConfig = serde_yaml_ng::from_str("post:\n - legacy.sh").unwrap();
assert!(cfg.post.is_none());
let out = serde_yaml_ng::to_string(&cfg).unwrap();
assert!(out.contains("hooks"), "serialized: {out}");
assert!(
!out.contains("post"),
"serialized must not carry post: {out}"
);
}
#[test]
fn empty_block_neither_spelling_stays_empty_and_silent() {
let captured = capture_warnings(|| {
let mut cfg = HooksConfig {
hooks: None,
post: None,
};
cfg.merge_hook_aliases();
assert!(cfg.hooks.is_none());
assert!(cfg.post.is_none());
});
assert!(
!captured.contains("DEPRECATION"),
"empty block must not warn: {captured}"
);
}
#[test]
fn empty_post_vec_does_not_trigger_fold_or_warn() {
let captured = capture_warnings(|| {
let mut cfg = HooksConfig {
hooks: None,
post: Some(vec![]),
};
cfg.merge_hook_aliases();
assert!(cfg.hooks.is_none(), "empty post must not become hooks");
});
assert!(
!captured.contains("DEPRECATION"),
"empty post must not warn: {captured}"
);
}
#[test]
fn default_hooks_config_is_all_none() {
let cfg = HooksConfig::default();
assert!(cfg.hooks.is_none());
assert!(cfg.post.is_none());
}
#[test]
fn raw_deserialize_rejects_unknown_key() {
let err = serde_yaml_ng::from_str::<HooksConfig>("befor:\n - x");
assert!(err.is_err(), "deny_unknown_fields on Raw must reject typos");
}
#[test]
fn structured_hook_full_fields_parse() {
let h: HookEntry = serde_yaml_ng::from_str(
"cmd: build.sh\nenv: [FOO=1, BAR=2]\noutput: true\nids: [linux-bin]\nartifacts: binary\n",
)
.unwrap();
match h {
HookEntry::Structured(s) => {
assert_eq!(s.cmd, "build.sh");
assert_eq!(
s.env.as_deref(),
Some(&["FOO=1".to_string(), "BAR=2".to_string()][..])
);
assert_eq!(s.output, Some(true));
assert_eq!(s.ids.as_deref(), Some(&["linux-bin".to_string()][..]));
assert_eq!(s.artifacts, Some(BeforePublishArtifactFilter::Binary));
}
HookEntry::Simple(_) => panic!("expected structured hook"),
}
}
#[test]
fn structured_hook_rejects_unknown_field() {
let err = serde_yaml_ng::from_str::<HookEntry>("cmd: x\nbogus: 1");
assert!(
err.is_err(),
"unknown structured-hook field must be rejected"
);
}
#[test]
fn hook_entry_simple_serializes_untagged_as_bare_string() {
let h = HookEntry::Simple("echo hi".to_string());
let out = serde_yaml_ng::to_string(&h).unwrap();
assert_eq!(out.trim(), "echo hi");
}
#[test]
fn hook_entry_structured_serializes_to_mapping() {
let h = HookEntry::Structured(StructuredHook {
cmd: "make".to_string(),
output: Some(true),
..Default::default()
});
let out = serde_yaml_ng::to_string(&h).unwrap();
assert!(out.contains("cmd: make"), "serialized: {out}");
assert!(out.contains("output: true"), "serialized: {out}");
}
#[test]
fn filter_installer_distinct_from_package_and_diskimage() {
let f = BeforePublishArtifactFilter::Installer;
assert!(!f.matches(ArtifactKind::LinuxPackage));
assert!(!f.matches(ArtifactKind::DiskImage));
assert!(!f.matches(ArtifactKind::Archive));
}
}