polyc_runtime/release_manifest.rs
1//! The release digest manifest (`release-images.yaml`).
2//!
3//! Schema, parsing, and the shared digest-resolution precedence rule for
4//! `polychrome upgrade --cluster` and the control-plane operator-mailbox
5//! originator.
6//!
7//! `.github/workflows/publish.yml`'s `channel` job writes one
8//! `release-images.yaml` per tagged release (via `scripts/make_channel.sh`),
9//! records it alongside a versioned Kustomize channel under
10//! `manifests/channels/<tag>/`, and attaches it to the GitHub Release as an
11//! asset. This module is the *reader*: [`fetch`] downloads and
12//! [`parse_and_validate`] checks it, and [`resolve_release_digests`]
13//! implements the one digest-source precedence rule both binaries share —
14//! see `docs/deploy/digest-pinning.md` for the operator-facing walkthrough.
15//!
16//! # Format versioning — refuse, never degrade
17//!
18//! The manifest carries `format_version` so a future breaking schema change
19//! is *detectable*. Polychrome ships zero cross-version compatibility code
20//! (the standing no-legacy rule): [`parse_and_validate`] refuses any
21//! `format_version` other than [`FORMAT_VERSION`] outright rather than
22//! attempting a downgrade-safe read. There is no support window — a format
23//! bump means updating the CLI before the next cluster upgrade, exactly like
24//! every other wire-format change in this repo.
25//!
26//! # Digest-source precedence
27//!
28//! [`resolve_release_digests`] resolves each published component's digest by,
29//! per component:
30//!
31//! 1. If `POLYCHROME_UPGRADE_IMAGE_<COMPONENT>` overrides the component's
32//! image reference (e.g. a Google Artifact Registry mirror in production),
33//! resolve live against that registry — the release manifest genuinely
34//! doesn't describe an image built and pushed to a different registry, so
35//! this is a distinct scenario, not a fallback.
36//! 2. Otherwise (the default public-GHCR reference), require the release
37//! manifest and read the component's digest from it. A release published
38//! before this manifest existed has no asset to read — that is a hard
39//! error naming the release, not a silent fallback to a live registry
40//! resolve (no-legacy rule: no reader degrades to the old behavior).
41//!
42//! A signed `--digest` set (see `crates/cli/src/cmd/upgrade_cluster.rs`) sits
43//! *above* both of these — it is pinned verbatim and never reaches this
44//! module at all.
45
46use std::collections::BTreeMap;
47
48use crate::ghcr;
49
50/// The only release-manifest schema version this binary reads. Bump only in
51/// lockstep with a `scripts/make_channel.sh` change that writes the new shape
52/// — see the module-level "Format versioning" section.
53pub const FORMAT_VERSION: u32 = 1;
54
55/// Name of the release asset carrying the manifest, as attached by
56/// `.github/workflows/publish.yml`'s `channel` job.
57const MANIFEST_ASSET_NAME: &str = "release-images.yaml";
58
59/// One published image's entry in a [`ReleaseManifest`].
60#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
61pub struct ManifestImage {
62 /// The bare GHCR image name this entry was published under — matches
63 /// [`ghcr::Component::ghcr_basename`] for the components the
64 /// cluster-upgrade path rolls (e.g. `polychrome`, `polychrome-slack`);
65 /// other published images — edges the CLI doesn't yet manage — are
66 /// carried too but simply never looked up by name. NOT
67 /// [`ghcr::Component::name`], which is a different, CLI-facing
68 /// identifier (`control-plane`, `slack`, …) used for the `--digest
69 /// <name>=…` argument.
70 pub name: String,
71 /// Full image reference with no tag, always `ghcr.io/officialunofficial/<name>`
72 /// for a genuine entry — validated in [`parse_and_validate`].
73 pub image: String,
74 /// The image's immutable index digest, `sha256:<64 lowercase hex>`.
75 pub digest: String,
76}
77
78/// The parsed, `serde`-mapped shape of `release-images.yaml`.
79///
80/// Use [`parse_and_validate`] rather than deserializing this directly — the
81/// raw `serde` mapping alone does not check the format-version, version-match,
82/// or per-entry integrity rules the release manifest must satisfy before it's
83/// safe to pin a cluster to.
84#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
85pub struct ReleaseManifest {
86 /// Schema version of this document. See the module-level "Format
87 /// versioning" section.
88 pub format_version: u32,
89 /// Release version this manifest was published for, with no leading `v`
90 /// (e.g. `2026.8.0`).
91 pub version: String,
92 /// One entry per image `.github/workflows/publish.yml` publishes.
93 pub images: Vec<ManifestImage>,
94}
95
96impl ReleaseManifest {
97 /// Look up a component's digest by its short stable name
98 /// ([`ManifestImage::name`]).
99 #[must_use]
100 pub fn digest_for(&self, name: &str) -> Option<&str> {
101 self.images
102 .iter()
103 .find(|i| i.name == name)
104 .map(|i| i.digest.as_str())
105 }
106}
107
108/// A release-manifest fetch, parse, or validation failure.
109#[derive(Debug, thiserror::Error)]
110pub enum ReleaseManifestError {
111 /// The manifest's `format_version` is not one this binary understands.
112 /// Refuse rather than degrade — see the module-level "Format versioning"
113 /// section.
114 #[error(
115 "this polychrome binary reads release-manifest format {supported}; release {version} \
116 publishes format {found} — update the binary, then retry"
117 )]
118 UnknownFormatVersion {
119 /// The `format_version` the manifest actually carries.
120 found: u32,
121 /// The only `format_version` this binary reads ([`FORMAT_VERSION`]).
122 supported: u32,
123 /// The release version the manifest was fetched for.
124 version: String,
125 },
126 /// The manifest's `version` field does not match the release it was
127 /// fetched for — it was published for a different release than requested
128 /// (or was tampered with).
129 #[error("release manifest version `{found}` does not match the requested release `{expected}`")]
130 VersionMismatch {
131 /// The version the caller requested.
132 expected: String,
133 /// The version the manifest actually declares.
134 found: String,
135 },
136 /// An entry's `image` field is not exactly
137 /// `ghcr.io/officialunofficial/<lowercase-name>` — could be a typo, a
138 /// different registry/owner entirely, or an uppercase/invalid component
139 /// name.
140 #[error("component `{name}` names image `{image}`, not `ghcr.io/officialunofficial/{name}`")]
141 MalformedImage {
142 /// The component name the entry claims.
143 name: String,
144 /// The image reference the entry actually carries.
145 image: String,
146 },
147 /// An entry's `digest` field is not a `sha256:<64 lowercase hex>` content
148 /// address.
149 #[error("component `{name}` carries digest `{digest}`, not a sha256:<64-hex> content address")]
150 MalformedDigest {
151 /// The component the malformed digest belongs to.
152 name: String,
153 /// The digest string as read from the manifest.
154 digest: String,
155 },
156 /// [`resolve_release_digests`] needed a component's digest from the
157 /// manifest, but the manifest carries no entry for it.
158 #[error("release manifest is missing component `{0}` — refusing to resolve a partial set")]
159 MissingComponent(String),
160 /// The release exists but carries no `release-images.yaml` asset — it
161 /// predates the digest-manifest channel (#115). Not a fallback case: a
162 /// default-GHCR-ref component has nowhere else to resolve its digest from.
163 #[error("release `{0}` predates the digest manifest — pick a newer release")]
164 AssetMissing(String),
165 /// The manifest bytes are not valid YAML, or don't match the expected
166 /// shape.
167 #[error("release manifest is not valid YAML: {0}")]
168 Yaml(#[from] serde_yaml_ng::Error),
169 /// The GitHub Releases API request (or the asset download) failed in
170 /// transport, or returned a non-success status.
171 #[error("release-manifest request failed: {0}")]
172 Http(#[from] reqwest::Error),
173 /// A component's live-registry resolve failed (the
174 /// `POLYCHROME_UPGRADE_IMAGE_*`-overridden path, e.g. Google Artifact
175 /// Registry in production).
176 #[error("live registry resolve failed: {0}")]
177 Ghcr(#[from] ghcr::GhcrError),
178}
179
180/// Is `digest` a lowercase `sha256:<64 hex>` content address?
181fn is_valid_digest(digest: &str) -> bool {
182 digest.strip_prefix("sha256:").is_some_and(|hex| {
183 hex.len() == 64
184 && hex
185 .bytes()
186 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
187 })
188}
189
190/// Is `name` a valid bare component name (`ghcr.io/officialunofficial/<name>`'s
191/// path segment) — lowercase ASCII letters, digits, and hyphens only?
192fn is_valid_component_name(name: &str) -> bool {
193 !name.is_empty()
194 && name
195 .bytes()
196 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
197}
198
199/// Parse `release-images.yaml` bytes and validate every integrity rule a
200/// manifest must satisfy before any of its digests are safe to pin a cluster
201/// to.
202///
203/// Checks, in order: the schema version is [`FORMAT_VERSION`]; the declared
204/// `version` matches `expected_version` (no leading `v` on either side); every
205/// entry's `image` is exactly `ghcr.io/officialunofficial/<name>` with a valid
206/// lowercase `name`; every entry's `digest` is a `sha256:<64-hex>` content
207/// address. All fail closed, naming the offending component/entry.
208///
209/// # Errors
210///
211/// Returns [`ReleaseManifestError::Yaml`] if `bytes` isn't valid YAML matching
212/// the manifest shape, [`ReleaseManifestError::UnknownFormatVersion`],
213/// [`ReleaseManifestError::VersionMismatch`],
214/// [`ReleaseManifestError::MalformedImage`], or
215/// [`ReleaseManifestError::MalformedDigest`] for the corresponding validation
216/// failure.
217pub fn parse_and_validate(
218 bytes: &[u8],
219 expected_version: &str,
220) -> Result<ReleaseManifest, ReleaseManifestError> {
221 let manifest: ReleaseManifest = serde_yaml_ng::from_slice(bytes)?;
222 if manifest.format_version != FORMAT_VERSION {
223 return Err(ReleaseManifestError::UnknownFormatVersion {
224 found: manifest.format_version,
225 supported: FORMAT_VERSION,
226 version: expected_version.to_owned(),
227 });
228 }
229 if manifest.version != expected_version {
230 return Err(ReleaseManifestError::VersionMismatch {
231 expected: expected_version.to_owned(),
232 found: manifest.version,
233 });
234 }
235 for entry in &manifest.images {
236 let expected_image = format!("ghcr.io/officialunofficial/{}", entry.name);
237 if !is_valid_component_name(&entry.name) || entry.image != expected_image {
238 return Err(ReleaseManifestError::MalformedImage {
239 name: entry.name.clone(),
240 image: entry.image.clone(),
241 });
242 }
243 if !is_valid_digest(&entry.digest) {
244 return Err(ReleaseManifestError::MalformedDigest {
245 name: entry.name.clone(),
246 digest: entry.digest.clone(),
247 });
248 }
249 }
250 Ok(manifest)
251}
252
253/// Fetch and validate the release manifest for `release_tag` (the GitHub tag
254/// name, e.g. `v2026.8.0`) from `{base_url}/repos/{owner}/{repo}`.
255///
256/// Mirrors the asset-download shape `crates/cli/src/cmd/upgrade.rs` already
257/// uses for the CLI's own self-update path: `GET
258/// /repos/{owner}/{repo}/releases/tags/{tag}`, find the
259/// `release-images.yaml` asset, then `GET` its API asset URL with `Accept:
260/// application/octet-stream` (the GitHub API's download-bytes contract for a
261/// release asset).
262///
263/// # Errors
264///
265/// Returns [`ReleaseManifestError::Http`] if the release lookup or asset
266/// download fails in transport or returns a non-success status,
267/// [`ReleaseManifestError::AssetMissing`] if the release carries no
268/// `release-images.yaml` asset, or any [`parse_and_validate`] validation
269/// error.
270pub async fn fetch(
271 client: &reqwest::Client,
272 base_url: &str,
273 owner: &str,
274 repo: &str,
275 release_tag: &str,
276 expected_version: &str,
277) -> Result<ReleaseManifest, ReleaseManifestError> {
278 let url = format!("{base_url}/repos/{owner}/{repo}/releases/tags/{release_tag}");
279 let user_agent = concat!("polychrome/", env!("CARGO_PKG_VERSION"));
280 let release: serde_json::Value = client
281 .get(&url)
282 .header(reqwest::header::USER_AGENT, user_agent)
283 .send()
284 .await?
285 .error_for_status()?
286 .json()
287 .await?;
288 let asset_url = release["assets"]
289 .as_array()
290 .into_iter()
291 .flatten()
292 .find(|asset| asset["name"].as_str() == Some(MANIFEST_ASSET_NAME))
293 .and_then(|asset| asset["url"].as_str())
294 .map(str::to_owned)
295 .ok_or_else(|| ReleaseManifestError::AssetMissing(release_tag.to_owned()))?;
296 let bytes = client
297 .get(&asset_url)
298 .header(reqwest::header::USER_AGENT, user_agent)
299 .header(reqwest::header::ACCEPT, "application/octet-stream")
300 .send()
301 .await?
302 .error_for_status()?
303 .bytes()
304 .await?;
305 parse_and_validate(&bytes, expected_version)
306}
307
308/// Resolve every published [`ghcr::Component`]'s target digest for `version`.
309///
310/// Applies the module's digest-source precedence: a component whose image
311/// reference is overridden by its `POLYCHROME_UPGRADE_IMAGE_*` environment
312/// variable is resolved live against that registry; every other component
313/// (the default public-GHCR reference) is resolved from the release manifest,
314/// fetched once and only if at least one component needs it.
315///
316/// `env_lookup` is injected (not read from the process) so the decision of
317/// *which* components need the manifest at all is exercised without a real
318/// environment; production callers pass `|k| std::env::var(k).ok()`.
319///
320/// Shared by the CLI's `upgrade --cluster` executor
321/// (`crates/cli/src/cmd/upgrade_cluster.rs`) and the control plane's
322/// operator-mailbox originator (`crates/control-plane/src/originator.rs`), so
323/// the ask a mailbox item opens with and the digests the executor ultimately
324/// pins are resolved by the exact same rule.
325///
326/// # Errors
327///
328/// Returns [`ReleaseManifestError::Ghcr`] if a `POLYCHROME_UPGRADE_IMAGE_*`-
329/// overridden component's live registry resolve fails, or any [`fetch`] /
330/// [`parse_and_validate`] error (including
331/// [`ReleaseManifestError::MissingComponent`] if the fetched manifest omits a
332/// component this binary still needs from it).
333pub async fn resolve_release_digests<F>(
334 client: &reqwest::Client,
335 base_url: &str,
336 owner: &str,
337 repo: &str,
338 version: &str,
339 env_lookup: F,
340) -> Result<BTreeMap<&'static str, String>, ReleaseManifestError>
341where
342 F: Fn(&str) -> Option<String> + Copy,
343{
344 let image_tag = ghcr::image_tag_for_version(version)?;
345 let release_tag = format!("v{image_tag}");
346
347 let manifest_needed = ghcr::COMPONENTS
348 .iter()
349 .any(|c| env_lookup(c.image_env).is_none());
350 let manifest = if manifest_needed {
351 Some(fetch(client, base_url, owner, repo, &release_tag, &image_tag).await?)
352 } else {
353 None
354 };
355
356 let mut digests = BTreeMap::new();
357 for component in &ghcr::COMPONENTS {
358 let digest = if env_lookup(component.image_env).is_some() {
359 let image = ghcr::image_ref(component, env_lookup);
360 ghcr::resolve_digest(client, &image.registry, &image.repository, &image_tag).await?
361 } else {
362 // The manifest is keyed by the actual published GHCR basename
363 // (`ghcr_basename`), not `component.name` — see
364 // `ghcr::Component::ghcr_basename`'s doc for why these differ.
365 manifest
366 .as_ref()
367 .and_then(|m| m.digest_for(component.ghcr_basename()))
368 .ok_or_else(|| {
369 ReleaseManifestError::MissingComponent(component.ghcr_basename().to_owned())
370 })?
371 .to_owned()
372 };
373 digests.insert(component.name, digest);
374 }
375 Ok(digests)
376}
377
378#[cfg(test)]
379mod tests {
380 #![allow(clippy::pedantic, clippy::nursery)]
381
382 use super::*;
383
384 /// A hand-written manifest literal (not round-tripped through
385 /// `serde_yaml_ng::to_string`) — cross-checks the real on-disk shape
386 /// `scripts/make_channel.sh` writes, independent of how this crate's own
387 /// serializer would format it. Named `polychrome` (not `control-plane`)
388 /// deliberately: that's the real published GHCR basename
389 /// (`ghcr::Component::ghcr_basename`), the key `make_channel.sh` actually
390 /// writes entries under.
391 fn literal_yaml(digest: &str) -> String {
392 format!(
393 "format_version: 1\n\
394 version: 2026.8.0\n\
395 images:\n\
396 \x20\x20- name: polychrome\n\
397 \x20\x20\x20\x20image: ghcr.io/officialunofficial/polychrome\n\
398 \x20\x20\x20\x20digest: {digest}\n"
399 )
400 }
401
402 fn digest64(byte: char) -> String {
403 format!("sha256:{}", byte.to_string().repeat(64))
404 }
405
406 /// Uses the real published GHCR basenames (`polychrome`,
407 /// `polychrome-slack`) as entry names, not `ghcr::Component::name`
408 /// (`control-plane`, `slack`) — see [`literal_yaml`].
409 fn sample_manifest() -> ReleaseManifest {
410 ReleaseManifest {
411 format_version: FORMAT_VERSION,
412 version: "2026.8.0".to_owned(),
413 images: vec![
414 ManifestImage {
415 name: "polychrome".to_owned(),
416 image: "ghcr.io/officialunofficial/polychrome".to_owned(),
417 digest: digest64('1'),
418 },
419 ManifestImage {
420 name: "polychrome-slack".to_owned(),
421 image: "ghcr.io/officialunofficial/polychrome-slack".to_owned(),
422 digest: digest64('2'),
423 },
424 ],
425 }
426 }
427
428 fn to_yaml(manifest: &ReleaseManifest) -> String {
429 serde_yaml_ng::to_string(manifest).unwrap()
430 }
431
432 #[test]
433 fn valid_manifest_parses() {
434 let manifest = sample_manifest();
435 let yaml = to_yaml(&manifest);
436 let parsed = parse_and_validate(yaml.as_bytes(), "2026.8.0").expect("valid manifest");
437 assert_eq!(parsed, manifest);
438 assert_eq!(
439 parsed.digest_for("polychrome"),
440 Some(digest64('1')).as_deref()
441 );
442 assert_eq!(parsed.digest_for("nope"), None);
443 }
444
445 #[test]
446 fn unknown_format_version_is_refused() {
447 let mut manifest = sample_manifest();
448 manifest.format_version = 2;
449 let yaml = to_yaml(&manifest);
450 let err = parse_and_validate(yaml.as_bytes(), "2026.8.0").expect_err("must refuse");
451 let msg = err.to_string();
452 assert!(msg.contains("format 1"), "{msg}");
453 assert!(msg.contains("format 2"), "{msg}");
454 assert!(matches!(
455 err,
456 ReleaseManifestError::UnknownFormatVersion {
457 found: 2,
458 supported: 1,
459 ..
460 }
461 ));
462 }
463
464 #[test]
465 fn version_mismatch_is_rejected() {
466 let manifest = sample_manifest();
467 let yaml = to_yaml(&manifest);
468 let err = parse_and_validate(yaml.as_bytes(), "2026.9.0").expect_err("must reject");
469 assert!(matches!(err, ReleaseManifestError::VersionMismatch { .. }));
470 }
471
472 #[test]
473 fn wrong_repo_owner_is_malformed() {
474 let mut manifest = sample_manifest();
475 manifest.images[0].image = "ghcr.io/evil/polychrome".to_owned();
476 let yaml = to_yaml(&manifest);
477 let err = parse_and_validate(yaml.as_bytes(), "2026.8.0").expect_err("must reject");
478 assert!(matches!(err, ReleaseManifestError::MalformedImage { .. }));
479 }
480
481 #[test]
482 fn short_digest_is_malformed() {
483 let mut manifest = sample_manifest();
484 manifest.images[0].digest = "sha256:abc".to_owned();
485 let yaml = to_yaml(&manifest);
486 let err = parse_and_validate(yaml.as_bytes(), "2026.8.0").expect_err("must reject");
487 assert!(matches!(err, ReleaseManifestError::MalformedDigest { .. }));
488 }
489
490 #[test]
491 fn uppercase_digest_is_malformed() {
492 let mut manifest = sample_manifest();
493 manifest.images[0].digest = format!("sha256:{}", "A".repeat(64));
494 let yaml = to_yaml(&manifest);
495 let err = parse_and_validate(yaml.as_bytes(), "2026.8.0").expect_err("must reject");
496 assert!(matches!(err, ReleaseManifestError::MalformedDigest { .. }));
497 }
498
499 #[test]
500 fn non_sha256_digest_is_malformed() {
501 let mut manifest = sample_manifest();
502 manifest.images[0].digest = format!("sha512:{}", "1".repeat(64));
503 let yaml = to_yaml(&manifest);
504 let err = parse_and_validate(yaml.as_bytes(), "2026.8.0").expect_err("must reject");
505 assert!(matches!(err, ReleaseManifestError::MalformedDigest { .. }));
506 }
507
508 #[test]
509 fn missing_component_is_named() {
510 let manifest = sample_manifest();
511 assert_eq!(manifest.digest_for("polychrome-telegram"), None);
512 }
513
514 #[test]
515 fn literal_manifest_yaml_parses() {
516 let digest = digest64('1');
517 let parsed =
518 parse_and_validate(literal_yaml(&digest).as_bytes(), "2026.8.0").expect("valid");
519 assert_eq!(parsed.format_version, 1);
520 assert_eq!(parsed.digest_for("polychrome"), Some(digest.as_str()));
521 }
522}