Skip to main content

anodizer_core/config/
verify_release.rs

1//! Opt-in post-release verification configuration.
2//!
3//! The `verify_release` block drives a verification gate that runs LAST in
4//! the release pipeline — *after* the release is created and every publisher
5//! has run. Because it runs after the irreversible publish, it does NOT
6//! block or undo anything: it REPORTS post-publish defects (and exits
7//! non-zero so CI surfaces them), but the release is already live.
8//!
9//! Three independently-toggleable checks:
10//!
11//! - **asset-existence** ([`Self::assert_assets`]) — every produced artifact
12//!   has a matching UPLOADED asset on the published release. Catches the
13//!   partial uploads GitHub silently tolerates.
14//! - **install smoke-test** ([`Self::install_smoke`]) — installs each Linux
15//!   package (`.deb` / `.rpm` / `.apk`) in a pinned container and runs
16//!   `<bin> --version`. Skipped with a notice when Docker is unavailable.
17//! - **libc ceiling** ([`Self::glibc_ceiling`]) — fails if any glibc-linked
18//!   `.deb` requires a glibc newer than the configured floor. musl binaries
19//!   have no glibc requirement and are skipped — which is the whole point:
20//!   musl hides a glibc-floor regression that this check is meant to surface.
21//!
22//! The block is off unless [`Self::enabled`] is `true`. Defaults mirror the
23//! [`PostPublishPollConfig`](super::PostPublishPollConfig) style:
24//! `#[serde(default, deny_unknown_fields)]`, opt-in `enabled`, sane derived
25//! defaults for everything else.
26
27use schemars::JsonSchema;
28use serde::{Deserialize, Serialize};
29
30/// Default container image for `.deb` install smoke-tests.
31pub const DEFAULT_DEB_IMAGE: &str = "debian:stable-slim";
32/// Default container image for `.rpm` install smoke-tests.
33pub const DEFAULT_RPM_IMAGE: &str = "fedora:latest";
34/// Default container image for `.apk` install smoke-tests.
35pub const DEFAULT_APK_IMAGE: &str = "alpine:latest";
36
37/// Top-level `verify_release:` block.
38///
39/// See the module-level docs for the verification lifecycle. The gate is a
40/// no-op unless `enabled: true`.
41#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
42#[serde(default, deny_unknown_fields)]
43pub struct VerifyReleaseConfig {
44    /// Whether to run the post-release verification gate at all. Default
45    /// `false` — the gate is opt-in because it needs the published release to
46    /// already exist (it runs after publish) and, for install-smoke, a Docker
47    /// daemon.
48    pub enabled: bool,
49    /// Assert that every produced artifact has a matching uploaded asset on
50    /// the published release. Default `true` (no extra config: anodizer
51    /// already knows the produced set and can fetch the release's asset list).
52    /// Independent of Docker and the network smoke-test.
53    pub assert_assets: bool,
54    /// Per-package install smoke-test images. When `None`, smoke-testing is
55    /// off. When present, each package type that produced an artifact is
56    /// installed in its (configured or default) container and `<bin>
57    /// --version` is run.
58    pub install_smoke: Option<InstallSmokeConfig>,
59    /// glibc version ceiling, e.g. `"2.36"`. When any glibc-linked `.deb`
60    /// requires a glibc NEWER than this floor, the gate reports it and exits
61    /// non-zero. `None` (the default) disables the libc check entirely. musl
62    /// binaries have no glibc requirement and are skipped.
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub glibc_ceiling: Option<String>,
65}
66
67impl Default for VerifyReleaseConfig {
68    fn default() -> Self {
69        Self {
70            enabled: false,
71            assert_assets: true,
72            install_smoke: None,
73            glibc_ceiling: None,
74        }
75    }
76}
77
78impl VerifyReleaseConfig {
79    /// Whether the asset-existence check should run: only when the whole gate
80    /// is enabled AND `assert_assets` is set.
81    pub fn assert_assets_enabled(&self) -> bool {
82        self.enabled && self.assert_assets
83    }
84
85    /// Whether the libc-ceiling check should run: only when the whole gate is
86    /// enabled AND a ceiling was configured.
87    pub fn glibc_check_enabled(&self) -> bool {
88        self.enabled && self.glibc_ceiling.is_some()
89    }
90}
91
92/// Per-package install smoke-test image overrides.
93///
94/// Each field is the container image used to install + version-check that
95/// package type. A `None` field falls back to its sane default
96/// ([`DEFAULT_DEB_IMAGE`], [`DEFAULT_RPM_IMAGE`], [`DEFAULT_APK_IMAGE`]).
97#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
98#[serde(default, deny_unknown_fields)]
99pub struct InstallSmokeConfig {
100    /// Image override for `.deb` packages. Default [`DEFAULT_DEB_IMAGE`].
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub deb: Option<SmokeImage>,
103    /// Image override for `.rpm` packages. Default [`DEFAULT_RPM_IMAGE`].
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub rpm: Option<SmokeImage>,
106    /// Image override for `.apk` packages. Default [`DEFAULT_APK_IMAGE`].
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub apk: Option<SmokeImage>,
109}
110
111impl InstallSmokeConfig {
112    /// Resolve the `.deb` smoke image, applying the default when unset.
113    pub fn deb_image(&self) -> &str {
114        self.deb
115            .as_ref()
116            .map(|i| i.image.as_str())
117            .unwrap_or(DEFAULT_DEB_IMAGE)
118    }
119
120    /// Resolve the `.rpm` smoke image, applying the default when unset.
121    pub fn rpm_image(&self) -> &str {
122        self.rpm
123            .as_ref()
124            .map(|i| i.image.as_str())
125            .unwrap_or(DEFAULT_RPM_IMAGE)
126    }
127
128    /// Resolve the `.apk` smoke image, applying the default when unset.
129    pub fn apk_image(&self) -> &str {
130        self.apk
131            .as_ref()
132            .map(|i| i.image.as_str())
133            .unwrap_or(DEFAULT_APK_IMAGE)
134    }
135}
136
137/// A single per-package smoke-test image selection.
138#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
139#[serde(deny_unknown_fields)]
140pub struct SmokeImage {
141    /// The container image reference (e.g. `debian:12`, `fedora:40`).
142    pub image: String,
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn default_is_disabled_with_assets_on() {
151        let c = VerifyReleaseConfig::default();
152        assert!(!c.enabled, "the gate is opt-in");
153        assert!(c.assert_assets, "asset-existence defaults on");
154        assert!(c.install_smoke.is_none(), "smoke off by default");
155        assert!(c.glibc_ceiling.is_none(), "libc check off by default");
156        // The sub-check gates are still off because the whole gate is off.
157        assert!(!c.assert_assets_enabled());
158        assert!(!c.glibc_check_enabled());
159    }
160
161    #[test]
162    fn empty_yaml_yields_defaults() {
163        let c: VerifyReleaseConfig = serde_yaml_ng::from_str("{}").unwrap();
164        assert_eq!(c, VerifyReleaseConfig::default());
165    }
166
167    #[test]
168    fn parses_full_block() {
169        let yaml = r#"
170enabled: true
171assert_assets: true
172install_smoke:
173  deb: { image: "debian:12" }
174  rpm: { image: "fedora:40" }
175  apk: { image: "alpine:3.20" }
176glibc_ceiling: "2.36"
177"#;
178        let c: VerifyReleaseConfig = serde_yaml_ng::from_str(yaml).unwrap();
179        assert!(c.enabled);
180        assert!(c.assert_assets_enabled());
181        assert!(c.glibc_check_enabled());
182        assert_eq!(c.glibc_ceiling.as_deref(), Some("2.36"));
183        let smoke = c.install_smoke.unwrap();
184        assert_eq!(smoke.deb_image(), "debian:12");
185        assert_eq!(smoke.rpm_image(), "fedora:40");
186        assert_eq!(smoke.apk_image(), "alpine:3.20");
187    }
188
189    #[test]
190    fn smoke_images_fall_back_to_defaults() {
191        // Only deb configured; rpm/apk take their sane defaults.
192        let yaml = "enabled: true\ninstall_smoke:\n  deb: { image: \"debian:12\" }\n";
193        let c: VerifyReleaseConfig = serde_yaml_ng::from_str(yaml).unwrap();
194        let smoke = c.install_smoke.unwrap();
195        assert_eq!(smoke.deb_image(), "debian:12");
196        assert_eq!(smoke.rpm_image(), DEFAULT_RPM_IMAGE);
197        assert_eq!(smoke.apk_image(), DEFAULT_APK_IMAGE);
198    }
199
200    #[test]
201    fn empty_install_smoke_uses_all_defaults() {
202        let yaml = "enabled: true\ninstall_smoke: {}\n";
203        let c: VerifyReleaseConfig = serde_yaml_ng::from_str(yaml).unwrap();
204        let smoke = c.install_smoke.unwrap();
205        assert_eq!(smoke.deb_image(), DEFAULT_DEB_IMAGE);
206        assert_eq!(smoke.rpm_image(), DEFAULT_RPM_IMAGE);
207        assert_eq!(smoke.apk_image(), DEFAULT_APK_IMAGE);
208    }
209
210    #[test]
211    fn glibc_ceiling_absent_disables_check() {
212        let yaml = "enabled: true\n";
213        let c: VerifyReleaseConfig = serde_yaml_ng::from_str(yaml).unwrap();
214        assert!(!c.glibc_check_enabled(), "no ceiling => libc check off");
215    }
216
217    #[test]
218    fn assert_assets_independently_toggleable() {
219        let yaml = "enabled: true\nassert_assets: false\n";
220        let c: VerifyReleaseConfig = serde_yaml_ng::from_str(yaml).unwrap();
221        assert!(!c.assert_assets_enabled(), "asset check opted out");
222        assert!(c.enabled, "gate still enabled for other checks");
223    }
224
225    #[test]
226    fn unknown_field_rejected() {
227        let yaml = "enabled: true\nbogus: 1\n";
228        let res: Result<VerifyReleaseConfig, _> = serde_yaml_ng::from_str(yaml);
229        assert!(res.is_err(), "deny_unknown_fields must reject typos");
230    }
231
232    #[test]
233    fn unknown_smoke_field_rejected() {
234        let yaml = "enabled: true\ninstall_smoke:\n  deb: { image: \"x\", typo: 1 }\n";
235        let res: Result<VerifyReleaseConfig, _> = serde_yaml_ng::from_str(yaml);
236        assert!(res.is_err(), "deny_unknown_fields on SmokeImage");
237    }
238}