polyc_runtime/ghcr.rs
1//! Shared OCI-registry helpers: the published-component roster plus immutable
2//! manifest-digest resolution, registry-agnostic.
3//!
4//! The cluster-upgrade path lives in two binaries — the operator CLI's
5//! executor (`polychrome upgrade --cluster`) rolls the images, and the control
6//! plane's mailbox originator captures the digests when it opens the approval
7//! ask. Both must agree on *which* images are published, *where* they live, and
8//! *how* a version's tag resolves to an immutable `sha256:…` content address, so
9//! that logic lives here once rather than duplicated per binary.
10//!
11//! Where the images live is configuration, not a constant. The open-source
12//! default is the public GHCR repositories; a private deployment overrides each
13//! component's full image reference (registry host + repository) through the
14//! `POLYCHROME_UPGRADE_IMAGE_*` environment variables — for example a Google
15//! Artifact Registry path like
16//! `us-east4-docker.pkg.dev/<project>/docker/polychrome-control-plane`. The
17//! reference carries no tag; the tag is derived from the target version.
18//!
19//! Resolution follows the OCI distribution flow: obtain a bearer token for the
20//! registry, `GET` the manifest, and read the registry-computed
21//! `Docker-Content-Digest` header — the canonical content address pinned onto
22//! the cluster. We never float a tag onto a running cluster. Which credential
23//! the token comes from depends on the registry host: a public GHCR repository
24//! uses the anonymous pull-token endpoint, while Google Artifact Registry / GCR
25//! uses a GCP OAuth access token.
26
27use std::time::Duration;
28
29/// Registry host assumed when a configured image reference names no host (the
30/// standard Docker heuristic: a leading path segment without a `.` or `:` is a
31/// repository namespace, not a registry).
32const DEFAULT_REGISTRY: &str = "ghcr.io";
33
34/// Bounds the registry TCP connect so a dead host fails fast.
35const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
36
37/// Overall per-request budget for a registry round-trip.
38const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
39
40/// A digest-resolution failure.
41///
42/// A library error (`thiserror`) so callers on `anyhow` can add context and
43/// callers on `thiserror` can wrap it.
44#[derive(Debug, thiserror::Error)]
45pub enum GhcrError {
46 /// The target version did not parse as semver (so it can't map to a
47 /// published image tag).
48 #[error("target version `{0}` is not valid semver")]
49 Version(String),
50 /// The HTTP client could not be built or a request failed in transport.
51 #[error("registry request failed: {0}")]
52 Http(#[from] reqwest::Error),
53 /// The registry accepted the request but returned no usable body/header
54 /// (no token, or no digest header on the manifest response).
55 #[error("registry response for {repo} had no {what}")]
56 Malformed {
57 /// The image repository the request was for.
58 repo: String,
59 /// What was missing (`pull token` / `digest header`).
60 what: &'static str,
61 },
62 /// A GCP access token could not be obtained for a `*.pkg.dev` / `*.gcr.io`
63 /// registry, so the digest cannot be resolved. Fail closed rather than fall
64 /// back to an anonymous request that would 401.
65 #[error("could not obtain a GCP access token for registry `{registry}`: {reason}")]
66 GcpToken {
67 /// The registry host the token was needed for.
68 registry: String,
69 /// Why the token could not be obtained.
70 reason: String,
71 },
72}
73
74/// One published component the cluster upgrade rolls.
75///
76/// Carries a short stable `name` (also the key an operator decision's signed
77/// digest set is keyed on and the `--digest <name>=…` CLI arg uses), the
78/// `Deployment` resource name, the `container` name inside the pod spec, the
79/// environment variable that overrides its image reference, and the GHCR
80/// default reference used when that variable is unset.
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub struct Component {
83 /// Short stable identifier (`control-plane` / `slack` / `telegram`). The key
84 /// in a signed decision's digest set and the `--digest` CLI arg.
85 pub name: &'static str,
86 /// `metadata.name` of the `Deployment`.
87 pub deployment: &'static str,
88 /// `spec.template.spec.containers[].name` the image lives on.
89 pub container: &'static str,
90 /// Environment variable that, when set, overrides this component's full
91 /// image reference (registry host + repository, no tag).
92 pub image_env: &'static str,
93 /// Default image reference (public GHCR) used when [`Component::image_env`]
94 /// is unset. May name a host or be a bare `owner/repo` (defaults to
95 /// `ghcr.io`).
96 pub default_image: &'static str,
97}
98
99impl Component {
100 /// The bare GHCR image name this component actually publishes under —
101 /// [`Component::default_image`]'s final path segment (e.g. `polychrome`,
102 /// `polychrome-slack`, `polychrome-telegram`).
103 ///
104 /// This, not [`Component::name`], is the key
105 /// `.github/workflows/publish.yml`'s `channel` job and
106 /// `scripts/make_channel.sh` write `release-images.yaml` entries under
107 /// (`crate::release_manifest::ManifestImage::name`) — the manifest is
108 /// keyed by what was actually published, not by the CLI-facing
109 /// identifier. [`Component::name`] remains the short stable id used for
110 /// the `--digest <name>=…` CLI arg and a signed decision's digest set.
111 #[must_use]
112 pub fn ghcr_basename(&self) -> &str {
113 self.default_image
114 .rsplit('/')
115 .next()
116 .unwrap_or(self.default_image)
117 }
118
119 /// Rank that orders a rollout. A component with a lower rank must reach the
120 /// new version before a component with a higher rank.
121 ///
122 /// The state plane rolls first. The control plane dials it as the first
123 /// statement of `run` and fails closed, so a control plane that starts
124 /// against a state plane still on the old version crash-loops. See
125 /// `docs/operations/promotion.md`, "Roll the state plane first".
126 ///
127 /// Every other component shares one rank. The runbook states no order among
128 /// them, so this method does not invent one. A stable sort therefore keeps
129 /// them in [`COMPONENTS`] order.
130 #[must_use]
131 pub fn rollout_rank(&self) -> u8 {
132 u8::from(self.name != STATE_COMPONENT_NAME)
133 }
134}
135
136/// [`Component::name`] of the state plane, the component that rolls first.
137///
138/// Named once so [`Component::rollout_rank`] does not repeat the literal, and
139/// so `state_rolls_before_every_other_component` fails loudly if the roster
140/// entry is ever renamed out from under the ordering.
141pub const STATE_COMPONENT_NAME: &str = "state";
142
143/// The Deployments an upgrade rolls.
144///
145/// Names/containers verified against `manifests/base/*-deployment.yaml`,
146/// `manifests/components/edges/trigger/deployment.yaml`, and
147/// `manifests/components/scaffold/deployment.yaml`; default images against
148/// `publish.yml`.
149///
150/// `scaffold`'s `default_image` names the GHCR basename it *would* publish
151/// under if it ever did, purely so `Component::ghcr_basename` stays
152/// well-formed — `.github/workflows/publish.yml` deliberately does NOT
153/// publish it (`cloudbuild.yaml`: "scaffold is a GAR-only provisioning
154/// connector"), so a hand-run upgrade with no `POLYCHROME_UPGRADE_IMAGE_SCAFFOLD`
155/// override correctly fails loud (`ReleaseManifestError::MissingComponent`)
156/// rather than silently resolving a public image that doesn't exist — the
157/// same no-legacy, no-silent-fallback rule every other digest source already
158/// follows. Every real deployment (including prod) sets the override.
159///
160/// `trigger` (#1325 investigation) — unlike scaffold, it IS published to GHCR
161/// (`publish.yml`'s edge matrix) — was missing from this list entirely, so a
162/// hand-run upgrade could never roll it past its `REPLACE_AT_BUILD_TIME`
163/// placeholder image; a fresh `polychrome install` left that Deployment
164/// permanently stuck at `InvalidImageName` with no sanctioned way to fix it.
165/// Reproduced live bringing up a second GKE cluster with the gke overlay's
166/// full edge set composed.
167///
168/// `whatsapp` joined this list the same release it joined every other
169/// published-image roster (the Dockerfile stage, the GAR build loop, the GHCR
170/// per-arch and merge loops, `publish.yml`, and the upgrade-image env patch).
171/// `scripts/check_image_roster.py` enforces that every roster stays in sync
172/// going forward — see INV-38 in `docs/specifications/invariants.md`.
173pub const COMPONENTS: [Component; 8] = [
174 Component {
175 name: "control-plane",
176 deployment: "polychrome-control-plane",
177 container: "polychrome",
178 image_env: "POLYCHROME_UPGRADE_IMAGE_CONTROL_PLANE",
179 default_image: "ghcr.io/officialunofficial/polychrome",
180 },
181 Component {
182 name: "state",
183 deployment: "polychrome-state",
184 container: "polychrome-state",
185 image_env: "POLYCHROME_UPGRADE_IMAGE_STATE",
186 default_image: "ghcr.io/officialunofficial/polychrome-state",
187 },
188 Component {
189 name: "harness",
190 deployment: "polychrome-harness",
191 container: "polychrome-harness",
192 image_env: "POLYCHROME_UPGRADE_IMAGE_HARNESS",
193 default_image: "ghcr.io/officialunofficial/polychrome-harness",
194 },
195 Component {
196 name: "slack",
197 deployment: "polychrome-slack",
198 container: "polychrome-slack",
199 image_env: "POLYCHROME_UPGRADE_IMAGE_SLACK",
200 default_image: "ghcr.io/officialunofficial/polychrome-slack",
201 },
202 Component {
203 name: "telegram",
204 deployment: "polychrome-telegram",
205 container: "polychrome-telegram",
206 image_env: "POLYCHROME_UPGRADE_IMAGE_TELEGRAM",
207 default_image: "ghcr.io/officialunofficial/polychrome-telegram",
208 },
209 Component {
210 name: "whatsapp",
211 deployment: "polychrome-whatsapp",
212 container: "polychrome-whatsapp",
213 image_env: "POLYCHROME_UPGRADE_IMAGE_WHATSAPP",
214 default_image: "ghcr.io/officialunofficial/polychrome-whatsapp",
215 },
216 Component {
217 name: "trigger",
218 deployment: "polychrome-trigger",
219 container: "polychrome-trigger",
220 image_env: "POLYCHROME_UPGRADE_IMAGE_TRIGGER",
221 default_image: "ghcr.io/officialunofficial/polychrome-trigger",
222 },
223 Component {
224 name: "scaffold",
225 deployment: "polychrome-scaffold",
226 container: "scaffold",
227 image_env: "POLYCHROME_UPGRADE_IMAGE_SCAFFOLD",
228 default_image: "ghcr.io/officialunofficial/polychrome-scaffold",
229 },
230];
231
232/// A resolved image reference — a registry host plus a repository, carrying no
233/// tag. The tag is derived per upgrade from the target version.
234#[derive(Debug, Clone, PartialEq, Eq)]
235pub struct ImageRef {
236 /// Registry host (e.g. `ghcr.io` or `us-east4-docker.pkg.dev`).
237 pub registry: String,
238 /// Repository path within the registry (e.g.
239 /// `officialunofficial/polychrome` or
240 /// `official-unofficial/docker/polychrome-control-plane`).
241 pub repository: String,
242}
243
244/// Split a full image reference string into its registry host and repository.
245///
246/// Applies the standard Docker heuristic: the segment before the first `/` is
247/// the registry host **iff** it contains a `.` or a `:` (a hostname or
248/// `host:port`); otherwise there is no host and the whole string is a
249/// repository under the default registry (`ghcr.io`).
250///
251/// # Examples
252/// - `us-east4-docker.pkg.dev/official-unofficial/docker/polychrome-control-plane`
253/// → registry `us-east4-docker.pkg.dev`, repo
254/// `official-unofficial/docker/polychrome-control-plane`.
255/// - `officialunofficial/polychrome` → registry `ghcr.io`, repo
256/// `officialunofficial/polychrome`.
257#[must_use]
258pub fn parse_image_ref(full: &str) -> ImageRef {
259 match full.split_once('/') {
260 Some((host, rest)) if host.contains('.') || host.contains(':') => ImageRef {
261 registry: host.to_owned(),
262 repository: rest.to_owned(),
263 },
264 _ => ImageRef {
265 registry: DEFAULT_REGISTRY.to_owned(),
266 repository: full.to_owned(),
267 },
268 }
269}
270
271/// Resolve a component's image reference from the environment, falling back to
272/// its public GHCR default.
273///
274/// The environment lookup is injected (rather than read from the process) so
275/// this stays a pure function — unit-testable without touching global state.
276/// Production callers pass `|k| std::env::var(k).ok()`.
277#[must_use]
278pub fn image_ref<F>(component: &Component, env_lookup: F) -> ImageRef
279where
280 F: Fn(&str) -> Option<String>,
281{
282 let raw = env_lookup(component.image_env).unwrap_or_else(|| component.default_image.to_owned());
283 parse_image_ref(&raw)
284}
285
286/// Normalize a `--version` / release tag into the image tag published.
287///
288/// `publish.yml` tags release manifests `{{version}}` (no leading `v`), so a
289/// release `v0.4.0` (or `--version v0.4.0`) maps to the image tag `0.4.0`. The
290/// tag is validated as semver so a typo can't pin the cluster to a
291/// non-existent / floating tag.
292///
293/// # Errors
294/// Returns [`GhcrError::Version`] if the stripped tag is not valid semver.
295pub fn image_tag_for_version(version: &str) -> Result<String, GhcrError> {
296 let tag = version.trim().trim_start_matches('v');
297 semver::Version::parse(tag).map_err(|_| GhcrError::Version(version.to_owned()))?;
298 Ok(tag.to_owned())
299}
300
301/// Build the digest-pinned image reference from a resolved [`ImageRef`] and a
302/// content digest (`{registry}/{repository}@{digest}`).
303#[must_use]
304pub fn pinned_reference(image: &ImageRef, digest: &str) -> String {
305 format!("{}/{}@{}", image.registry, image.repository, digest)
306}
307
308/// How to authenticate a digest-resolution request against a registry host.
309///
310/// Selected purely from the host by [`auth_for`]; the effectful token fetch is
311/// isolated behind each variant in [`resolve_digest`].
312#[derive(Debug, Clone, Copy, PartialEq, Eq)]
313pub enum RegistryAuth {
314 /// The OCI anonymous pull-token flow (public GHCR): `GET /token?scope=…`.
315 AnonymousToken,
316 /// A GCP OAuth access token, for Google Artifact Registry (`*.pkg.dev`) and
317 /// Container Registry (`*.gcr.io`).
318 GcpMetadata,
319}
320
321/// Pick the registry-auth strategy for a host.
322///
323/// Google Artifact Registry (`*.pkg.dev`) and Container Registry (`gcr.io` /
324/// `*.gcr.io`) require a GCP OAuth token; every other host (notably public
325/// GHCR) uses the anonymous pull-token flow.
326#[must_use]
327pub fn auth_for(registry: &str) -> RegistryAuth {
328 if registry.ends_with(".pkg.dev") || registry == "gcr.io" || registry.ends_with(".gcr.io") {
329 RegistryAuth::GcpMetadata
330 } else {
331 RegistryAuth::AnonymousToken
332 }
333}
334
335/// Build the shared HTTP client used for registry round-trips (bounded connect
336/// and request timeouts, a `polychrome/<version>` user agent).
337///
338/// # Errors
339/// Returns [`GhcrError::Http`] if the client can't be constructed.
340pub fn http_client() -> Result<reqwest::Client, GhcrError> {
341 Ok(reqwest::Client::builder()
342 .timeout(REQUEST_TIMEOUT)
343 .connect_timeout(CONNECT_TIMEOUT)
344 .user_agent(concat!("polychrome/", env!("CARGO_PKG_VERSION")))
345 .build()?)
346}
347
348/// Resolve the immutable manifest digest for `repository:tag` against `registry`.
349///
350/// The auth strategy is chosen from the host by [`auth_for`]: a public GHCR
351/// repository authenticates with an anonymous bearer token from the registry
352/// `token` endpoint, while a Google Artifact Registry / GCR host authenticates
353/// with a GCP OAuth access token.
354///
355/// The `GET` reads the `Docker-Content-Digest` response header — the registry
356/// computes it over the manifest bytes, so it is the canonical content address.
357///
358/// # Errors
359/// Returns [`GhcrError`] if the bearer token can't be obtained, the manifest
360/// request fails, or the response carries no digest header.
361pub async fn resolve_digest(
362 client: &reqwest::Client,
363 registry: &str,
364 repository: &str,
365 tag: &str,
366) -> Result<String, GhcrError> {
367 let token = match auth_for(registry) {
368 RegistryAuth::AnonymousToken => anonymous_pull_token(client, registry, repository).await?,
369 RegistryAuth::GcpMetadata => gcp_access_token(client, registry).await?,
370 };
371 let url = format!("https://{registry}/v2/{repository}/manifests/{tag}");
372 let resp = client
373 .get(&url)
374 .bearer_auth(&token)
375 // Accept both the OCI image index and the Docker manifest-list media
376 // types so multi-arch published images resolve to their index digest.
377 .header(
378 reqwest::header::ACCEPT,
379 "application/vnd.oci.image.index.v1+json, \
380 application/vnd.docker.distribution.manifest.list.v2+json, \
381 application/vnd.oci.image.manifest.v1+json, \
382 application/vnd.docker.distribution.manifest.v2+json",
383 )
384 .send()
385 .await?
386 .error_for_status()?;
387 let digest = resp
388 .headers()
389 .get("docker-content-digest")
390 .and_then(|v| v.to_str().ok())
391 .ok_or_else(|| GhcrError::Malformed {
392 repo: repository.to_owned(),
393 what: "digest header",
394 })?
395 .to_owned();
396 Ok(digest)
397}
398
399/// Fetch an anonymous pull token for a public repository (the OCI distribution
400/// flow — GHCR).
401async fn anonymous_pull_token(
402 client: &reqwest::Client,
403 registry: &str,
404 repository: &str,
405) -> Result<String, GhcrError> {
406 let url =
407 format!("https://{registry}/token?service={registry}&scope=repository:{repository}:pull");
408 let body: serde_json::Value = client
409 .get(&url)
410 .send()
411 .await?
412 .error_for_status()?
413 .json()
414 .await?;
415 // GHCR returns the bearer under `token`; some registries use `access_token`.
416 body["token"]
417 .as_str()
418 .or_else(|| body["access_token"].as_str())
419 .map(str::to_owned)
420 .ok_or_else(|| GhcrError::Malformed {
421 repo: repository.to_owned(),
422 what: "pull token",
423 })
424}
425
426/// Fetch a GCP OAuth access token for an Artifact Registry / GCR host.
427///
428/// In-cluster (with Workload Identity) this reads the token off the GCE
429/// metadata server. For local use, point at Application Default Credentials
430/// instead — export a token from `gcloud auth print-access-token` into the
431/// environment ahead of the process, or run against a metadata proxy; the
432/// resolver only needs a valid bearer for the registry host.
433async fn gcp_access_token(client: &reqwest::Client, registry: &str) -> Result<String, GhcrError> {
434 const METADATA_TOKEN_URL: &str = "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token";
435 let body: serde_json::Value = client
436 .get(METADATA_TOKEN_URL)
437 .header("Metadata-Flavor", "Google")
438 .send()
439 .await
440 .map_err(|e| GhcrError::GcpToken {
441 registry: registry.to_owned(),
442 reason: format!("metadata request failed: {e}"),
443 })?
444 .error_for_status()
445 .map_err(|e| GhcrError::GcpToken {
446 registry: registry.to_owned(),
447 reason: format!("metadata server returned an error: {e}"),
448 })?
449 .json()
450 .await
451 .map_err(|e| GhcrError::GcpToken {
452 registry: registry.to_owned(),
453 reason: format!("metadata response was not JSON: {e}"),
454 })?;
455 body["access_token"]
456 .as_str()
457 .map(str::to_owned)
458 .ok_or_else(|| GhcrError::GcpToken {
459 registry: registry.to_owned(),
460 reason: "metadata response carried no `access_token`".to_owned(),
461 })
462}
463
464#[cfg(test)]
465mod tests {
466 #![allow(clippy::pedantic, clippy::nursery)]
467
468 use super::*;
469
470 fn component(name: &str) -> &'static Component {
471 COMPONENTS.iter().find(|c| c.name == name).unwrap()
472 }
473
474 #[test]
475 fn image_tag_strips_v_and_validates() {
476 assert_eq!(image_tag_for_version("v0.4.0").unwrap(), "0.4.0");
477 assert_eq!(image_tag_for_version("0.4.0").unwrap(), "0.4.0");
478 assert_eq!(image_tag_for_version(" v1.2.3 ").unwrap(), "1.2.3");
479 }
480
481 #[test]
482 fn image_tag_rejects_non_semver() {
483 assert!(image_tag_for_version("latest").is_err());
484 assert!(image_tag_for_version("1.2").is_err());
485 assert!(image_tag_for_version("v").is_err());
486 }
487
488 #[test]
489 fn parse_splits_gar_host_from_repository() {
490 let r = parse_image_ref(
491 "us-east4-docker.pkg.dev/official-unofficial/docker/polychrome-control-plane",
492 );
493 assert_eq!(r.registry, "us-east4-docker.pkg.dev");
494 assert_eq!(
495 r.repository,
496 "official-unofficial/docker/polychrome-control-plane"
497 );
498 }
499
500 #[test]
501 fn parse_defaults_hostless_ref_to_ghcr() {
502 let r = parse_image_ref("officialunofficial/polychrome");
503 assert_eq!(r.registry, "ghcr.io");
504 assert_eq!(r.repository, "officialunofficial/polychrome");
505 }
506
507 #[test]
508 fn parse_treats_host_port_as_registry() {
509 let r = parse_image_ref("localhost:5000/team/app");
510 assert_eq!(r.registry, "localhost:5000");
511 assert_eq!(r.repository, "team/app");
512 }
513
514 #[test]
515 fn image_ref_defaults_to_ghcr_with_empty_env() {
516 let cp = image_ref(component("control-plane"), |_| None);
517 assert_eq!(cp.registry, "ghcr.io");
518 assert_eq!(cp.repository, "officialunofficial/polychrome");
519
520 let slack = image_ref(component("slack"), |_| None);
521 assert_eq!(
522 pinned_reference(&slack, "sha256:x"),
523 "ghcr.io/officialunofficial/polychrome-slack@sha256:x"
524 );
525 }
526
527 #[test]
528 fn image_ref_honors_gar_override() {
529 let env = |k: &str| match k {
530 "POLYCHROME_UPGRADE_IMAGE_CONTROL_PLANE" => Some(
531 "us-east4-docker.pkg.dev/official-unofficial/docker/polychrome-control-plane"
532 .to_owned(),
533 ),
534 _ => None,
535 };
536 let cp = image_ref(component("control-plane"), env);
537 assert_eq!(cp.registry, "us-east4-docker.pkg.dev");
538 assert_eq!(
539 cp.repository,
540 "official-unofficial/docker/polychrome-control-plane"
541 );
542 // An unset component still falls back to its GHCR default.
543 let slack = image_ref(component("slack"), env);
544 assert_eq!(slack.registry, "ghcr.io");
545 }
546
547 #[test]
548 fn pinned_reference_format_for_ghcr_and_gar() {
549 let ghcr = ImageRef {
550 registry: "ghcr.io".to_owned(),
551 repository: "officialunofficial/polychrome".to_owned(),
552 };
553 assert_eq!(
554 pinned_reference(&ghcr, "sha256:abc"),
555 "ghcr.io/officialunofficial/polychrome@sha256:abc"
556 );
557 let gar = ImageRef {
558 registry: "us-east4-docker.pkg.dev".to_owned(),
559 repository: "official-unofficial/docker/polychrome-control-plane".to_owned(),
560 };
561 assert_eq!(
562 pinned_reference(&gar, "sha256:def"),
563 "us-east4-docker.pkg.dev/official-unofficial/docker/polychrome-control-plane@sha256:def"
564 );
565 }
566
567 #[test]
568 fn auth_for_selects_by_host() {
569 assert_eq!(auth_for("ghcr.io"), RegistryAuth::AnonymousToken);
570 assert_eq!(
571 auth_for("us-east4-docker.pkg.dev"),
572 RegistryAuth::GcpMetadata
573 );
574 assert_eq!(auth_for("us.gcr.io"), RegistryAuth::GcpMetadata);
575 assert_eq!(auth_for("gcr.io"), RegistryAuth::GcpMetadata);
576 assert_eq!(auth_for("docker.io"), RegistryAuth::AnonymousToken);
577 }
578
579 #[test]
580 fn components_carry_stable_names() {
581 let names: Vec<&str> = COMPONENTS.iter().map(|c| c.name).collect();
582 assert_eq!(
583 names,
584 vec![
585 "control-plane",
586 "state",
587 "harness",
588 "slack",
589 "telegram",
590 "whatsapp",
591 "trigger",
592 "scaffold"
593 ]
594 );
595 }
596
597 /// The state plane must reach the new version before anything that dials
598 /// it. This pins both halves of that: exactly one component ranks first,
599 /// and it is the state `Deployment`. Renaming the roster entry without
600 /// updating [`STATE_COMPONENT_NAME`] would silently flatten the ordering
601 /// back to roster order, which is the defect this rank exists to prevent
602 /// (control-plane is `COMPONENTS[0]`).
603 #[test]
604 fn state_rolls_before_every_other_component() {
605 let first: Vec<&str> = COMPONENTS
606 .iter()
607 .filter(|c| c.rollout_rank() == 0)
608 .map(|c| c.deployment)
609 .collect();
610 assert_eq!(first, vec!["polychrome-state"]);
611
612 let state = component(STATE_COMPONENT_NAME);
613 for c in COMPONENTS.iter().filter(|c| c.name != STATE_COMPONENT_NAME) {
614 assert!(
615 state.rollout_rank() < c.rollout_rank(),
616 "state must rank before {}",
617 c.name
618 );
619 }
620 }
621
622 /// The GHCR basename is deliberately NOT the same string as
623 /// `Component::name` — it's the published image name
624 /// (`.github/workflows/publish.yml`'s matrix), which is what
625 /// `release_manifest::resolve_release_digests` must key its manifest
626 /// lookup on. A regression test for the naming mismatch that let the
627 /// release manifest reader and the publish workflow disagree on the key.
628 #[test]
629 fn ghcr_basename_matches_the_published_image_name_not_component_name() {
630 let basenames: Vec<&str> = COMPONENTS.iter().map(Component::ghcr_basename).collect();
631 assert_eq!(
632 basenames,
633 vec![
634 "polychrome",
635 "polychrome-state",
636 "polychrome-harness",
637 "polychrome-slack",
638 "polychrome-telegram",
639 "polychrome-whatsapp",
640 "polychrome-trigger",
641 "polychrome-scaffold",
642 ]
643 );
644 }
645}