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}