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    /// Assert that every publisher that succeeded this run actually LANDED:
60    /// each published crate version is visible on the crates.io sparse index,
61    /// each npm package version is visible on its registry, each uploaded
62    /// blob object exists in its bucket, and each uploaded snap is live in
63    /// the Snap Store's channel map (catching a manual-review hold that
64    /// parked the revision outside every channel). Default `true` (no extra
65    /// config: the run's own publish report already carries every coordinate
66    /// the probes need). Publishers that did not run — or did not succeed —
67    /// are skipped.
68    pub assert_landing: bool,
69    /// Per-package install smoke-test images. When `None`, smoke-testing is
70    /// off. When present, each package type that produced an artifact is
71    /// installed in its (configured or default) container and `<bin>
72    /// --version` is run.
73    pub install_smoke: Option<InstallSmokeConfig>,
74    /// glibc version ceiling, e.g. `"2.36"`. When any glibc-linked `.deb`
75    /// requires a glibc NEWER than this floor, the gate reports it and exits
76    /// non-zero. `None` (the default) disables the libc check entirely. musl
77    /// binaries have no glibc requirement and are skipped.
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub glibc_ceiling: Option<String>,
80}
81
82impl Default for VerifyReleaseConfig {
83    fn default() -> Self {
84        Self {
85            enabled: false,
86            assert_assets: true,
87            assert_landing: true,
88            install_smoke: None,
89            glibc_ceiling: None,
90        }
91    }
92}
93
94impl VerifyReleaseConfig {
95    /// Whether the asset-existence check should run: only when the whole gate
96    /// is enabled AND `assert_assets` is set.
97    pub fn assert_assets_enabled(&self) -> bool {
98        self.enabled && self.assert_assets
99    }
100
101    /// Whether the libc-ceiling check should run: only when the whole gate is
102    /// enabled AND a ceiling was configured.
103    pub fn glibc_check_enabled(&self) -> bool {
104        self.enabled && self.glibc_ceiling.is_some()
105    }
106
107    /// Whether the per-publisher landing checks should run: only when the
108    /// whole gate is enabled AND `assert_landing` is set.
109    pub fn landing_checks_enabled(&self) -> bool {
110        self.enabled && self.assert_landing
111    }
112}
113
114/// Per-package install smoke-test image overrides.
115///
116/// Each field is the container image used to install + version-check that
117/// package type. A `None` field falls back to its sane default
118/// ([`DEFAULT_DEB_IMAGE`], [`DEFAULT_RPM_IMAGE`], [`DEFAULT_APK_IMAGE`]).
119#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
120#[serde(default, deny_unknown_fields)]
121pub struct InstallSmokeConfig {
122    /// Image override for `.deb` packages. Default [`DEFAULT_DEB_IMAGE`].
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub deb: Option<SmokeImage>,
125    /// Image override for `.rpm` packages. Default [`DEFAULT_RPM_IMAGE`].
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub rpm: Option<SmokeImage>,
128    /// Image override for `.apk` packages. Default [`DEFAULT_APK_IMAGE`].
129    #[serde(skip_serializing_if = "Option::is_none")]
130    pub apk: Option<SmokeImage>,
131}
132
133impl InstallSmokeConfig {
134    /// Resolve the `.deb` smoke image, applying the default when unset.
135    pub fn deb_image(&self) -> &str {
136        self.deb
137            .as_ref()
138            .map(|i| i.image.as_str())
139            .unwrap_or(DEFAULT_DEB_IMAGE)
140    }
141
142    /// Resolve the `.rpm` smoke image, applying the default when unset.
143    pub fn rpm_image(&self) -> &str {
144        self.rpm
145            .as_ref()
146            .map(|i| i.image.as_str())
147            .unwrap_or(DEFAULT_RPM_IMAGE)
148    }
149
150    /// Resolve the `.apk` smoke image, applying the default when unset.
151    pub fn apk_image(&self) -> &str {
152        self.apk
153            .as_ref()
154            .map(|i| i.image.as_str())
155            .unwrap_or(DEFAULT_APK_IMAGE)
156    }
157}
158
159/// A single per-package smoke-test image selection.
160#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
161#[serde(deny_unknown_fields)]
162pub struct SmokeImage {
163    /// The container image reference (e.g. `debian:12`, `fedora:40`).
164    pub image: String,
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn default_is_disabled_with_assets_on() {
173        let c = VerifyReleaseConfig::default();
174        assert!(!c.enabled, "the gate is opt-in");
175        assert!(c.assert_assets, "asset-existence defaults on");
176        assert!(c.assert_landing, "landing checks default on");
177        assert!(c.install_smoke.is_none(), "smoke off by default");
178        assert!(c.glibc_ceiling.is_none(), "libc check off by default");
179        // The sub-check gates are still off because the whole gate is off.
180        assert!(!c.assert_assets_enabled());
181        assert!(!c.glibc_check_enabled());
182        assert!(!c.landing_checks_enabled());
183    }
184
185    #[test]
186    fn assert_landing_independently_toggleable() {
187        let yaml = "enabled: true\nassert_landing: false\n";
188        let c: VerifyReleaseConfig = serde_yaml_ng::from_str(yaml).unwrap();
189        assert!(!c.landing_checks_enabled(), "landing checks opted out");
190        assert!(c.assert_assets_enabled(), "asset check unaffected");
191    }
192
193    #[test]
194    fn empty_yaml_yields_defaults() {
195        let c: VerifyReleaseConfig = serde_yaml_ng::from_str("{}").unwrap();
196        assert_eq!(c, VerifyReleaseConfig::default());
197    }
198
199    #[test]
200    fn parses_full_block() {
201        let yaml = r#"
202enabled: true
203assert_assets: true
204install_smoke:
205  deb: { image: "debian:12" }
206  rpm: { image: "fedora:40" }
207  apk: { image: "alpine:3.20" }
208glibc_ceiling: "2.36"
209"#;
210        let c: VerifyReleaseConfig = serde_yaml_ng::from_str(yaml).unwrap();
211        assert!(c.enabled);
212        assert!(c.assert_assets_enabled());
213        assert!(c.glibc_check_enabled());
214        assert_eq!(c.glibc_ceiling.as_deref(), Some("2.36"));
215        let smoke = c.install_smoke.unwrap();
216        assert_eq!(smoke.deb_image(), "debian:12");
217        assert_eq!(smoke.rpm_image(), "fedora:40");
218        assert_eq!(smoke.apk_image(), "alpine:3.20");
219    }
220
221    #[test]
222    fn smoke_images_fall_back_to_defaults() {
223        // Only deb configured; rpm/apk take their sane defaults.
224        let yaml = "enabled: true\ninstall_smoke:\n  deb: { image: \"debian:12\" }\n";
225        let c: VerifyReleaseConfig = serde_yaml_ng::from_str(yaml).unwrap();
226        let smoke = c.install_smoke.unwrap();
227        assert_eq!(smoke.deb_image(), "debian:12");
228        assert_eq!(smoke.rpm_image(), DEFAULT_RPM_IMAGE);
229        assert_eq!(smoke.apk_image(), DEFAULT_APK_IMAGE);
230    }
231
232    #[test]
233    fn empty_install_smoke_uses_all_defaults() {
234        let yaml = "enabled: true\ninstall_smoke: {}\n";
235        let c: VerifyReleaseConfig = serde_yaml_ng::from_str(yaml).unwrap();
236        let smoke = c.install_smoke.unwrap();
237        assert_eq!(smoke.deb_image(), DEFAULT_DEB_IMAGE);
238        assert_eq!(smoke.rpm_image(), DEFAULT_RPM_IMAGE);
239        assert_eq!(smoke.apk_image(), DEFAULT_APK_IMAGE);
240    }
241
242    #[test]
243    fn glibc_ceiling_absent_disables_check() {
244        let yaml = "enabled: true\n";
245        let c: VerifyReleaseConfig = serde_yaml_ng::from_str(yaml).unwrap();
246        assert!(!c.glibc_check_enabled(), "no ceiling => libc check off");
247    }
248
249    #[test]
250    fn assert_assets_independently_toggleable() {
251        let yaml = "enabled: true\nassert_assets: false\n";
252        let c: VerifyReleaseConfig = serde_yaml_ng::from_str(yaml).unwrap();
253        assert!(!c.assert_assets_enabled(), "asset check opted out");
254        assert!(c.enabled, "gate still enabled for other checks");
255    }
256
257    #[test]
258    fn unknown_field_rejected() {
259        let yaml = "enabled: true\nbogus: 1\n";
260        let res: Result<VerifyReleaseConfig, _> = serde_yaml_ng::from_str(yaml);
261        assert!(res.is_err(), "deny_unknown_fields must reject typos");
262    }
263
264    #[test]
265    fn unknown_smoke_field_rejected() {
266        let yaml = "enabled: true\ninstall_smoke:\n  deb: { image: \"x\", typo: 1 }\n";
267        let res: Result<VerifyReleaseConfig, _> = serde_yaml_ng::from_str(yaml);
268        assert!(res.is_err(), "deny_unknown_fields on SmokeImage");
269    }
270}