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, and that every signature / certificate / SBOM
51    /// asset the resolved `signs:` / `sboms:` config demands exists there too
52    /// (derived from config + the artifact set, so a sign or SBOM stage that
53    /// silently produced nothing still fails the gate with the exact missing
54    /// names; intentional skips — `if:` falsy, `skip:` truthy, `--skip=sign` —
55    /// create no expectations). Default `true` (no extra config: anodizer
56    /// already knows the produced set and can fetch the release's asset list).
57    /// Independent of Docker and the network smoke-test.
58    pub assert_assets: bool,
59    /// Per-package install smoke-test images. When `None`, smoke-testing is
60    /// off. When present, each package type that produced an artifact is
61    /// installed in its (configured or default) container and `<bin>
62    /// --version` is run.
63    pub install_smoke: Option<InstallSmokeConfig>,
64    /// glibc version ceiling, e.g. `"2.36"`. When any glibc-linked `.deb`
65    /// requires a glibc NEWER than this floor, the gate reports it and exits
66    /// non-zero. `None` (the default) disables the libc check entirely. musl
67    /// binaries have no glibc requirement and are skipped.
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub glibc_ceiling: Option<String>,
70}
71
72impl Default for VerifyReleaseConfig {
73    fn default() -> Self {
74        Self {
75            enabled: false,
76            assert_assets: true,
77            install_smoke: None,
78            glibc_ceiling: None,
79        }
80    }
81}
82
83impl VerifyReleaseConfig {
84    /// Whether the asset-existence check should run: only when the whole gate
85    /// is enabled AND `assert_assets` is set.
86    pub fn assert_assets_enabled(&self) -> bool {
87        self.enabled && self.assert_assets
88    }
89
90    /// Whether the libc-ceiling check should run: only when the whole gate is
91    /// enabled AND a ceiling was configured.
92    pub fn glibc_check_enabled(&self) -> bool {
93        self.enabled && self.glibc_ceiling.is_some()
94    }
95}
96
97/// Per-package install smoke-test image overrides.
98///
99/// Each field is the container image used to install + version-check that
100/// package type. A `None` field falls back to its sane default
101/// ([`DEFAULT_DEB_IMAGE`], [`DEFAULT_RPM_IMAGE`], [`DEFAULT_APK_IMAGE`]).
102#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
103#[serde(default, deny_unknown_fields)]
104pub struct InstallSmokeConfig {
105    /// Image override for `.deb` packages. Default [`DEFAULT_DEB_IMAGE`].
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub deb: Option<SmokeImage>,
108    /// Image override for `.rpm` packages. Default [`DEFAULT_RPM_IMAGE`].
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub rpm: Option<SmokeImage>,
111    /// Image override for `.apk` packages. Default [`DEFAULT_APK_IMAGE`].
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub apk: Option<SmokeImage>,
114}
115
116impl InstallSmokeConfig {
117    /// Resolve the `.deb` smoke image, applying the default when unset.
118    pub fn deb_image(&self) -> &str {
119        self.deb
120            .as_ref()
121            .map(|i| i.image.as_str())
122            .unwrap_or(DEFAULT_DEB_IMAGE)
123    }
124
125    /// Resolve the `.rpm` smoke image, applying the default when unset.
126    pub fn rpm_image(&self) -> &str {
127        self.rpm
128            .as_ref()
129            .map(|i| i.image.as_str())
130            .unwrap_or(DEFAULT_RPM_IMAGE)
131    }
132
133    /// Resolve the `.apk` smoke image, applying the default when unset.
134    pub fn apk_image(&self) -> &str {
135        self.apk
136            .as_ref()
137            .map(|i| i.image.as_str())
138            .unwrap_or(DEFAULT_APK_IMAGE)
139    }
140}
141
142/// A single per-package smoke-test image selection.
143#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
144#[serde(deny_unknown_fields)]
145pub struct SmokeImage {
146    /// The container image reference (e.g. `debian:12`, `fedora:40`).
147    pub image: String,
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn default_is_disabled_with_assets_on() {
156        let c = VerifyReleaseConfig::default();
157        assert!(!c.enabled, "the gate is opt-in");
158        assert!(c.assert_assets, "asset-existence defaults on");
159        assert!(c.install_smoke.is_none(), "smoke off by default");
160        assert!(c.glibc_ceiling.is_none(), "libc check off by default");
161        // The sub-check gates are still off because the whole gate is off.
162        assert!(!c.assert_assets_enabled());
163        assert!(!c.glibc_check_enabled());
164    }
165
166    #[test]
167    fn empty_yaml_yields_defaults() {
168        let c: VerifyReleaseConfig = serde_yaml_ng::from_str("{}").unwrap();
169        assert_eq!(c, VerifyReleaseConfig::default());
170    }
171
172    #[test]
173    fn parses_full_block() {
174        let yaml = r#"
175enabled: true
176assert_assets: true
177install_smoke:
178  deb: { image: "debian:12" }
179  rpm: { image: "fedora:40" }
180  apk: { image: "alpine:3.20" }
181glibc_ceiling: "2.36"
182"#;
183        let c: VerifyReleaseConfig = serde_yaml_ng::from_str(yaml).unwrap();
184        assert!(c.enabled);
185        assert!(c.assert_assets_enabled());
186        assert!(c.glibc_check_enabled());
187        assert_eq!(c.glibc_ceiling.as_deref(), Some("2.36"));
188        let smoke = c.install_smoke.unwrap();
189        assert_eq!(smoke.deb_image(), "debian:12");
190        assert_eq!(smoke.rpm_image(), "fedora:40");
191        assert_eq!(smoke.apk_image(), "alpine:3.20");
192    }
193
194    #[test]
195    fn smoke_images_fall_back_to_defaults() {
196        // Only deb configured; rpm/apk take their sane defaults.
197        let yaml = "enabled: true\ninstall_smoke:\n  deb: { image: \"debian:12\" }\n";
198        let c: VerifyReleaseConfig = serde_yaml_ng::from_str(yaml).unwrap();
199        let smoke = c.install_smoke.unwrap();
200        assert_eq!(smoke.deb_image(), "debian:12");
201        assert_eq!(smoke.rpm_image(), DEFAULT_RPM_IMAGE);
202        assert_eq!(smoke.apk_image(), DEFAULT_APK_IMAGE);
203    }
204
205    #[test]
206    fn empty_install_smoke_uses_all_defaults() {
207        let yaml = "enabled: true\ninstall_smoke: {}\n";
208        let c: VerifyReleaseConfig = serde_yaml_ng::from_str(yaml).unwrap();
209        let smoke = c.install_smoke.unwrap();
210        assert_eq!(smoke.deb_image(), DEFAULT_DEB_IMAGE);
211        assert_eq!(smoke.rpm_image(), DEFAULT_RPM_IMAGE);
212        assert_eq!(smoke.apk_image(), DEFAULT_APK_IMAGE);
213    }
214
215    #[test]
216    fn glibc_ceiling_absent_disables_check() {
217        let yaml = "enabled: true\n";
218        let c: VerifyReleaseConfig = serde_yaml_ng::from_str(yaml).unwrap();
219        assert!(!c.glibc_check_enabled(), "no ceiling => libc check off");
220    }
221
222    #[test]
223    fn assert_assets_independently_toggleable() {
224        let yaml = "enabled: true\nassert_assets: false\n";
225        let c: VerifyReleaseConfig = serde_yaml_ng::from_str(yaml).unwrap();
226        assert!(!c.assert_assets_enabled(), "asset check opted out");
227        assert!(c.enabled, "gate still enabled for other checks");
228    }
229
230    #[test]
231    fn unknown_field_rejected() {
232        let yaml = "enabled: true\nbogus: 1\n";
233        let res: Result<VerifyReleaseConfig, _> = serde_yaml_ng::from_str(yaml);
234        assert!(res.is_err(), "deny_unknown_fields must reject typos");
235    }
236
237    #[test]
238    fn unknown_smoke_field_rejected() {
239        let yaml = "enabled: true\ninstall_smoke:\n  deb: { image: \"x\", typo: 1 }\n";
240        let res: Result<VerifyReleaseConfig, _> = serde_yaml_ng::from_str(yaml);
241        assert!(res.is_err(), "deny_unknown_fields on SmokeImage");
242    }
243}