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}