Skip to main content

anodizer_core/config/
hooks.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Deserializer, Serialize};
3
4use crate::artifact::ArtifactKind;
5
6// ---------------------------------------------------------------------------
7// HooksConfig
8// ---------------------------------------------------------------------------
9
10/// Top-level lifecycle hooks for `before` and `after` blocks.
11/// Each block carries a list of hook commands that run around the
12/// entire pipeline (not individual stages).
13///
14/// The canonical key is `hooks:` for both `before:` and `after:` to
15/// the conventional spelling. The `post:` spelling is accepted
16/// as a serde alias on `hooks` for back-compat with the previous
17/// anodizer spelling; users with `after: { post: [...] }` keep working
18/// and a deprecation warning is logged when both spellings appear in
19/// the same block (see [`HooksConfig::merge_hook_aliases`]).
20#[derive(Debug, Clone, PartialEq, Default, JsonSchema)]
21#[serde(deny_unknown_fields)]
22pub struct HooksConfig {
23    /// Commands to run when the block fires. The wire format accepts
24    /// either `hooks:` (canonical) or the legacy
25    /// `post:` spelling; both fold into this field at parse time.
26    pub hooks: Option<Vec<HookEntry>>,
27    /// Legacy alias for `hooks:` (anodizer pre-v0.4). Always `None`
28    /// after parsing — `merge_hook_aliases` collapses it into `hooks`.
29    /// Present on the struct only because `Deserialize` writes through
30    /// it before the fold step.
31    #[doc(hidden)]
32    pub post: Option<Vec<HookEntry>>,
33}
34
35impl HooksConfig {
36    /// Fold the deprecated `post:` spelling into `hooks:` so downstream
37    /// readers consult one field. Emits a `tracing::warn!` with one of two
38    /// suffixes depending on whether both spellings appeared or only the
39    /// legacy one — both messages carry the same "switch to 'hooks:'"
40    /// guidance, with the conflict case adding the "and ignoring 'post:'"
41    /// note so the user knows which side won.
42    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            // (true, false): canonical shape, nothing to do.
64            // (false, false): empty block, nothing to do.
65            _ => {}
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    /// Command to run.
110    ///
111    /// The entire string is interpreted by `sh -c`, so shell metacharacters
112    /// (`|`, `;`, `&&`, backticks, `$()`, redirects, globs) are honoured —
113    /// any templated values folded into `cmd` become part of the shell
114    /// command and are subject to word-splitting and metacharacter expansion.
115    /// Keep templated user-config values out of `cmd` when possible, or quote
116    /// them defensively (e.g. `'{{ Env.FOO }}'`). Hooks already run with
117    /// `env_clear()` plus an allow-list, so secrets in `$ENV` are not
118    /// inherited unless explicitly listed in `env`.
119    pub cmd: String,
120    /// Working directory for the command (defaults to project root).
121    pub dir: Option<String>,
122    /// Environment variables for the command.
123    #[serde(default)]
124    pub env: Option<Vec<String>>,
125    /// When true, capture and log stdout/stderr of the command.
126    pub output: Option<bool>,
127    /// Template-conditional: when set, the hook only runs if the rendered
128    /// result is truthy (not `"false"` / `"0"` / `"no"` / empty). Render
129    /// failure hard-errors (not silent-skip).
130    /// `before.hooks[].if:` / per-build / per-archive hook `if:` surface.
131    #[serde(rename = "if")]
132    pub if_condition: Option<String>,
133    /// Artifact-id allow-list (`before_publish:` only). When `Some`, the
134    /// per-artifact iteration only fires for artifacts whose
135    /// `metadata["id"]` matches one of these strings. `None` (the default)
136    /// imposes no id constraint. Ignored by lifecycle hook sites
137    /// (`before:` / `after:` / per-build / per-archive) — those run once
138    /// per pipeline tick, not per artifact.
139    pub ids: Option<Vec<String>>,
140    /// Artifact-kind filter (`before_publish:` only). When `Some`, the
141    /// per-artifact iteration only fires for artifacts whose
142    /// [`ArtifactKind`] matches the filter. `None` is equivalent to
143    /// [`BeforePublishArtifactFilter::All`] (every registered artifact).
144    /// Ignored by lifecycle hook sites for the same reason as `ids`.
145    pub artifacts: Option<BeforePublishArtifactFilter>,
146}
147
148/// Artifact-type filter for `before_publish[*].artifacts`.
149///
150/// The `before_publish[*].artifacts` enum
151/// (`checksum` / `source` / `package` / `installer` / `diskimage` /
152/// `archive` / `binary` / `sbom` / `image` / `all`). Maps each variant
153/// to a predicate over [`ArtifactKind`]:
154///
155/// | Variant | Matched [`ArtifactKind`] values |
156/// |---|---|
157/// | `Checksum` | `Checksum` |
158/// | `Source` | `SourceArchive`, `SourcePkgBuild`, `SourceSrcInfo`, `SourceRpm` |
159/// | `Package` | `LinuxPackage`, `Snap`, `PublishableSnapcraft`, `Flatpak` |
160/// | `Installer` | `Installer`, `MacOsPackage` |
161/// | `DiskImage` | `DiskImage` |
162/// | `Archive` | `Archive`, `Makeself` |
163/// | `Binary` | `Binary`, `UploadableBinary`, `UniversalBinary` |
164/// | `Sbom` | `Sbom` |
165/// | `Image` | `DockerImage`, `DockerImageV2`, `PublishableDockerImage` (multi-arch `DockerManifest` excluded — covers individual images only) |
166/// | `All` | every kind |
167///
168/// Mapping notes:
169/// - `Source` includes RPM-source variants since all source-derived
170///   artifacts under one bucket.
171/// - `Installer` covers macOS `.pkg` (`MacOsPackage`) alongside Windows
172///   MSI/NSIS — "installer" is used without OS qualification.
173/// - `Image` deliberately excludes [`ArtifactKind::DockerManifest`] /
174///   [`ArtifactKind::DockerDigest`]; those are multi-arch index entries
175///   and don't correspond to a scannable image blob. The
176///   per-image hook semantics (the multi-arch manifest is published
177///   separately and isn't a vulnerability scan target).
178#[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    /// Returns `true` when `kind` should run the hook under this filter.
197    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    /// Shared buffer writer that captures `tracing` output into a `Vec<u8>`.
280    #[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    /// `BufferWriterHandle` matches the `MakeWriter` contract: each
300    /// `make_writer` call returns a cheap clone that writes into the
301    /// same `Arc<Mutex<Vec<u8>>>` as every other clone.
302    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    /// Run `body` with a tracing subscriber that captures every event into
321    /// the returned buffer; assertions then inspect the captured text.
322    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    /// Legacy-only spelling (post: set, hooks: unset) folds into `hooks`
335    /// and emits a DEPRECATION warning that points at the canonical name.
336    #[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    /// Both spellings set: `hooks:` wins, `post:` is dropped, and the
362    /// emitted warning explicitly calls out the conflict.
363    #[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    /// Canonical `hooks:`-only block emits no warning and stays as-is.
395    #[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}