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>,
}
#[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}"
);
}
}