Skip to main content

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}