diff --git a/crates/wright-cli/src/update.rs b/crates/wright-cli/src/update.rs index 3b6d8a75..bbfdac1f 100644 --- a/crates/wright-cli/src/update.rs +++ b/crates/wright-cli/src/update.rs @@ -7,8 +7,7 @@ use std::time::Duration; use clap::{Args, Subcommand, ValueEnum}; use wright_driver::{OpyProviderError, ResolvedOpyProvider, sha256_hex}; -const DEFAULT_BASE_URL: &str = "https://github.com/wrightkit/wright/releases/download"; -const DEFAULT_API_URL: &str = "https://api.github.com/repos/wrightkit/wright/releases/latest"; +const DEFAULT_BASE_URL: &str = "https://releases.wrightkit.dev/wright"; const USER_AGENT: &str = concat!("wright-update/", env!("CARGO_PKG_VERSION")); mod exit { @@ -297,7 +296,7 @@ fn self_update(check_only: bool, requested: Option<&str>) -> Result { let client = update_client()?; - let version = resolve_latest(&client, &env_api_url())?; + let version = resolve_latest(&client, &env_base_url())?; (version, Some(client)) } }; @@ -395,22 +394,27 @@ fn detect_provenance(exe: &Path) -> Provenance { } } +/// Resolve the latest stable version through the shared R2 distribution +/// contract: `/latest/version` is a plain-text version pointer, the same +/// route `install.sh` and `install.ps1` read. fn resolve_latest( client: &reqwest::blocking::Client, - api_url: &str, + base_url: &str, ) -> Result { - let body = fetch_text(client, api_url)?; - let val: serde_json::Value = serde_json::from_str(&body).map_err(|e| { + let url = format!("{}/latest/version", base_url.trim_end_matches('/')); + let body = fetch_text(client, &url)?; + parse_latest_version(&body).ok_or_else(|| { UpdateError::failed(format!( - "could not parse the latest-release response from {api_url}: {e}" + "could not parse the latest release version from {url} (got '{}'); pin a version with `wright update self --version`", + truncate(body.trim()) )) - })?; - let tag = val.get("tag_name").and_then(serde_json::Value::as_str).ok_or_else(|| { - UpdateError::failed(format!("could not find the latest release tag in the response from {api_url}; pin a version with `wright update self --version`")) - })?; - let version = tag.trim_start_matches('v'); - parse_version(version)?; - Ok(version.to_string()) + }) +} + +fn parse_latest_version(body: &str) -> Option { + let version = body.trim().trim_start_matches('v'); + parse_version(version).ok()?; + Some(version.to_string()) } fn install_version( @@ -421,7 +425,10 @@ fn install_version( install_dir: &Path, ) -> Result<(), UpdateError> { let archive_name = format!("wright-{version}-{}.tar.gz", platform.target); - let archive_url = format!("{base_url}/v{version}/{archive_name}"); + let archive_url = format!( + "{}/releases/{version}/{archive_name}", + base_url.trim_end_matches('/') + ); let checksum_url = format!("{archive_url}.sha256"); println!("==> downloading {archive_url}"); @@ -650,10 +657,6 @@ fn env_base_url() -> String { std::env::var("WRIGHT_INSTALL_BASE_URL").unwrap_or_else(|_| DEFAULT_BASE_URL.to_string()) } -fn env_api_url() -> String { - std::env::var("WRIGHT_API_URL").unwrap_or_else(|_| DEFAULT_API_URL.to_string()) -} - #[cfg(test)] mod tests { use super::*; @@ -681,6 +684,18 @@ mod tests { } } + #[test] + fn latest_version_pointer_is_plain_text() { + assert_eq!(parse_latest_version("9.9.9\n"), Some("9.9.9".to_string())); + assert_eq!( + parse_latest_version(" v0.2.10 \n"), + Some("0.2.10".to_string()) + ); + for bad in ["", "latest", "1.2", "{\"tag_name\":\"v9.9.9\"}", "0.1\n0.2"] { + assert_eq!(parse_latest_version(bad), None, "{bad:?} must be rejected"); + } + } + #[test] fn compare_versions_orders_numerically() { use std::cmp::Ordering::*; diff --git a/crates/wright-cli/tests/update.rs b/crates/wright-cli/tests/update.rs index c4f8ea0f..10b7bc63 100644 --- a/crates/wright-cli/tests/update.rs +++ b/crates/wright-cli/tests/update.rs @@ -1,7 +1,9 @@ -//! End-to-end `wright update` tests (#116, #439) against mock release servers. +//! End-to-end `wright update` tests (#116, #439, #455) against a mock release +//! server. //! -//! Serves fake release archives and checksums over a local HTTP server (the -//! same shape `scripts/test-install.sh` uses for `install.sh`) and exercises +//! Serves fake R2 release routes — the `latest/version` pointer plus +//! versioned archives and checksums, the same shape `scripts/test-install.sh` +//! uses for `install.sh` — and exercises //! the real `wright` binary: the consolidated update surface (`update`, //! `update self`, `update provider [opy]`), version resolution, `--check` //! without modification, checksum-verified installs, atomic replacement of @@ -12,8 +14,8 @@ use std::io::{Read, Write}; use std::net::{SocketAddr, TcpListener, TcpStream}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; -use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; use std::thread::JoinHandle; use sha2::Digest; @@ -25,10 +27,12 @@ const TRIPLE: &str = "x86_64-unknown-linux-gnu"; const PROVIDER_OLD: &str = "1.0.0"; const PROVIDER_RELEASE: &str = "1.4.0"; -/// A minimal HTTP/1.1 server serving a fixed path -> body map. +/// A minimal HTTP/1.1 server serving a fixed path -> body map and recording +/// the paths clients requested. struct MockServer { addr: SocketAddr, shutdown: Arc, + requests: Arc>>, thread: Option>, } @@ -37,12 +41,14 @@ impl MockServer { let listener = TcpListener::bind("127.0.0.1:0").expect("binds an ephemeral port"); let addr = listener.local_addr().expect("bound address"); let files = Arc::new(files); + let requests = Arc::new(Mutex::new(Vec::new())); let shutdown = Arc::new(AtomicBool::new(false)); let thread_shutdown = shutdown.clone(); + let thread_requests = requests.clone(); let thread = std::thread::spawn(move || { while !thread_shutdown.load(Ordering::Relaxed) { match listener.accept() { - Ok((stream, _)) => serve(stream, &files), + Ok((stream, _)) => serve(stream, &files, &thread_requests), Err(_) => break, } } @@ -50,6 +56,7 @@ impl MockServer { MockServer { addr, shutdown, + requests, thread: Some(thread), } } @@ -58,15 +65,15 @@ impl MockServer { format!("http://127.0.0.1:{}", self.addr.port()) } - fn api_url(&self) -> String { - format!("{}/repos/wrightkit/wright/releases/latest", self.base_url()) + /// The request paths the server has served so far, in order. + fn requests(&self) -> Vec { + self.requests.lock().unwrap().clone() } /// Environment overrides that point the child at this server. fn env(&self) -> Vec<(&'static str, String)> { vec![ ("WRIGHT_INSTALL_BASE_URL", self.base_url()), - ("WRIGHT_API_URL", self.api_url()), ("WRIGHT_INSTALL_OS", "linux".to_string()), ("WRIGHT_INSTALL_ARCH", "x86_64".to_string()), ] @@ -87,7 +94,7 @@ impl Drop for MockServer { } } -fn serve(mut stream: TcpStream, files: &HashMap>) { +fn serve(mut stream: TcpStream, files: &HashMap>, requests: &Mutex>) { let mut request = Vec::new(); let mut buf = [0u8; 1024]; loop { @@ -108,6 +115,7 @@ fn serve(mut stream: TcpStream, files: &HashMap>) { .and_then(|line| line.split_whitespace().nth(1)) .unwrap_or("/"); let path = path.split('?').next().unwrap_or(path); + requests.lock().unwrap().push(path.to_string()); match files.get(path) { Some(body) => { let header = format!( @@ -126,7 +134,8 @@ fn serve(mut stream: TcpStream, files: &HashMap>) { let _ = stream.flush(); } -/// A release mock: archive + checksum + latest-release metadata. +/// A release mock in the R2 distribution shape: the plain-text latest pointer +/// plus the immutable versioned archive and checksum. struct Release { files: HashMap>, } @@ -136,13 +145,12 @@ fn release(version: &str) -> Release { let name = format!("wright-{version}-{TRIPLE}.tar.gz"); let mut files = HashMap::new(); files.insert( - "/repos/wrightkit/wright/releases/latest".to_string(), - format!("{{\"tag_name\":\"v{version}\",\"draft\":false,\"prerelease\":false}}\n") - .into_bytes(), + "/latest/version".to_string(), + format!("{version}\n").into_bytes(), ); - files.insert(format!("/v{version}/{name}"), archive.clone()); + files.insert(format!("/releases/{version}/{name}"), archive.clone()); files.insert( - format!("/v{version}/{name}.sha256"), + format!("/releases/{version}/{name}.sha256"), format!("{} {name}\n", sha256_hex(&archive)).into_bytes(), ); Release { files } @@ -263,13 +271,9 @@ fn provider_release(version: &str, files: &mut HashMap>) { /// Environment overrides that point the child at this server's provider routes. fn provider_env(server: &MockServer) -> Vec<(&'static str, String)> { let mut env = server.env(); - env.push(( - "WRIGHT_OPY_PROVIDER_LATEST_URL", - format!("{}/opy-rs/latest/version", server.base_url()), - )); env.push(( "WRIGHT_OPY_PROVIDER_BASE_URL", - format!("{}/opy-rs/releases", server.base_url()), + format!("{}/opy-rs", server.base_url()), )); env } @@ -403,6 +407,91 @@ fn update_installs_and_replaces_both_binaries() { leftovers.is_empty(), "staging dirs must be cleaned up: {leftovers:?}" ); + + // Self-update touches only the R2 distribution contract — the latest + // pointer plus the immutable versioned routes — never a release API. + let name = format!("wright-{RELEASE}-{TRIPLE}.tar.gz"); + assert_eq!( + server.requests(), + vec![ + "/latest/version".to_string(), + format!("/releases/{RELEASE}/{name}"), + format!("/releases/{RELEASE}/{name}.sha256"), + ] + ); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn pinned_self_update_uses_only_the_versioned_release_routes() { + let server = MockServer::new(release(RELEASE).files); + let dir = install_dir("update-pinned"); + let output = run_update( + &dir, + &["update", "self", "--version", RELEASE], + &server.env(), + ); + assert_eq!( + output.status.code(), + Some(0), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let name = format!("wright-{RELEASE}-{TRIPLE}.tar.gz"); + assert_eq!( + server.requests(), + vec![ + format!("/releases/{RELEASE}/{name}"), + format!("/releases/{RELEASE}/{name}.sha256"), + ], + "a pinned update must not resolve the latest pointer" + ); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn malformed_latest_version_pointer_is_rejected() { + // A release-API JSON body is not a valid version pointer: the R2 contract + // serves a bare version and anything else must fail without changes. + let mut files = HashMap::new(); + files.insert( + "/latest/version".to_string(), + b"{\"tag_name\":\"v9.9.9\"}\n".to_vec(), + ); + let server = MockServer::new(files); + let dir = install_dir("update-badlatest"); + let before = read(&dir.join("wright")); + let output = run_update(&dir, &["update", "--check"], &server.env()); + assert_eq!(output.status.code(), Some(4)); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("could not parse the latest release version"), + "{stderr}" + ); + assert_eq!(read(&dir.join("wright")), before); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn missing_release_archive_fails_before_any_change() { + // The latest pointer resolves but no archive is published: the download + // failure is an environment error and nothing is replaced. + let mut files = HashMap::new(); + files.insert( + "/latest/version".to_string(), + format!("{RELEASE}\n").into_bytes(), + ); + let server = MockServer::new(files); + let dir = install_dir("update-missing-archive"); + let before = read(&dir.join("wright")); + let output = run_update(&dir, &["update"], &server.env()); + assert_eq!(output.status.code(), Some(4)); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("could not download") && stderr.contains("404"), + "{stderr}" + ); + assert_eq!(read(&dir.join("wright")), before); let _ = std::fs::remove_dir_all(&dir); } @@ -460,7 +549,7 @@ fn checksum_mismatch_is_rejected_before_any_change() { let mut mock = release(RELEASE); let name = format!("wright-{RELEASE}-{TRIPLE}.tar.gz"); mock.files.insert( - format!("/v{RELEASE}/{name}.sha256"), + format!("/releases/{RELEASE}/{name}.sha256"), format!("{} {name}\n", sha256_hex(b"not the archive")).into_bytes(), ); let server = MockServer::new(mock.files); diff --git a/crates/wright-driver/src/opy_provider.rs b/crates/wright-driver/src/opy_provider.rs index ca8f3b5f..97df7317 100644 --- a/crates/wright-driver/src/opy_provider.rs +++ b/crates/wright-driver/src/opy_provider.rs @@ -8,8 +8,7 @@ use flate2::read::GzDecoder; #[cfg(test)] use sha2::{Digest, Sha256}; -const DEFAULT_LATEST_VERSION_URL: &str = "https://releases.wrightkit.dev/opy-rs/latest/version"; -const DEFAULT_BASE_URL: &str = "https://releases.wrightkit.dev/opy-rs/releases"; +const DEFAULT_BASE_URL: &str = "https://releases.wrightkit.dev/opy-rs"; const MAX_DOWNLOAD_BYTES: u64 = 128 * 1024 * 1024; const PROVIDER_ARCHIVE_EXTENSION: &str = "tar.gz"; @@ -60,7 +59,6 @@ pub struct ResolvedOpyProvider { pub struct OpyProviderResolver { store_dir: PathBuf, target: Option, - latest_version_url: String, base_url: String, } @@ -69,8 +67,6 @@ impl OpyProviderResolver { Self { store_dir: store_dir.into(), target: None, - latest_version_url: std::env::var("WRIGHT_OPY_PROVIDER_LATEST_URL") - .unwrap_or_else(|_| DEFAULT_LATEST_VERSION_URL.to_string()), base_url: std::env::var("WRIGHT_OPY_PROVIDER_BASE_URL") .unwrap_or_else(|_| DEFAULT_BASE_URL.to_string()), } @@ -81,12 +77,9 @@ impl OpyProviderResolver { self } - pub fn with_release_urls( - mut self, - latest_version_url: impl Into, - base_url: impl Into, - ) -> Self { - self.latest_version_url = latest_version_url.into(); + /// Override the release-distribution base; the `latest/version` pointer + /// and the `releases//` artifacts live under it. + pub fn with_base_url(mut self, base_url: impl Into) -> Self { self.base_url = base_url.into(); self } @@ -211,7 +204,7 @@ impl OpyProviderResolver { } let archive_name = format!("opy-provider-{version}-{target}.{PROVIDER_ARCHIVE_EXTENSION}"); let archive_url = format!( - "{}/{version}/{archive_name}", + "{}/releases/{version}/{archive_name}", self.base_url.trim_end_matches('/') ); let checksum_url = format!("{archive_url}.sha256"); @@ -229,7 +222,8 @@ impl OpyProviderResolver { &self, client: &reqwest::blocking::Client, ) -> Result { - let body = fetch_text(client, &self.latest_version_url)?; + let url = format!("{}/latest/version", self.base_url.trim_end_matches('/')); + let body = fetch_text(client, &url)?; normalize_version(body.trim()) } @@ -751,10 +745,7 @@ mod tests { let resolver = OpyProviderResolver::new(&root).with_target(target); let bytes = archive("2.0.0", target, b"cached"); resolver.install_archive("2.0.0", target, &bytes).unwrap(); - let offline = resolver.with_release_urls( - "http://127.0.0.1:1/opy-rs/latest/version", - "http://127.0.0.1:1/opy-rs/releases", - ); + let offline = resolver.with_base_url("http://127.0.0.1:1/opy-rs"); let resolved = offline.resolve(None).unwrap(); assert_eq!(resolved.version.as_deref(), Some("2.0.0")); assert_eq!(std::fs::read(resolved.executable).unwrap(), b"cached"); @@ -772,10 +763,7 @@ mod tests { test_server(version.as_bytes().to_vec(), bytes, checksum.into_bytes()); let resolver = OpyProviderResolver::new(&root) .with_target(target) - .with_release_urls( - format!("{base_url}/opy-rs/latest/version"), - format!("{base_url}/opy-rs/releases"), - ); + .with_base_url(format!("{base_url}/opy-rs")); let resolved = resolver.resolve(None).unwrap(); server.join().unwrap(); assert_eq!(requests.load(Ordering::Relaxed), 3); @@ -832,10 +820,7 @@ mod tests { let target = "x86_64-unknown-linux-gnu"; let resolver = OpyProviderResolver::new(&root) .with_target(target) - .with_release_urls( - "http://127.0.0.1:1/opy-rs/latest/version", - "http://127.0.0.1:1/opy-rs/releases", - ); + .with_base_url("http://127.0.0.1:1/opy-rs"); assert_eq!(resolver.installed().unwrap(), None); let bytes = archive("2.0.0", target, b"cached"); resolver.install_archive("2.0.0", target, &bytes).unwrap(); @@ -851,10 +836,7 @@ mod tests { let target = "x86_64-unknown-linux-gnu"; let resolver = OpyProviderResolver::new(&root) .with_target(target) - .with_release_urls( - "http://127.0.0.1:1/opy-rs/latest/version", - "http://127.0.0.1:1/opy-rs/releases", - ); + .with_base_url("http://127.0.0.1:1/opy-rs"); let bytes = archive("1.2.3", target, b"first"); resolver.install_archive("1.2.3", target, &bytes).unwrap(); let resolved = resolver.update(Some("1.2.3")).unwrap(); @@ -875,10 +857,7 @@ mod tests { // version-pointer fetch. let (base_url, requests, server) = test_server_n(1, b"1.0.0".to_vec(), Vec::new(), Vec::new()); - let resolver = resolver.with_release_urls( - format!("{base_url}/opy-rs/latest/version"), - format!("{base_url}/opy-rs/releases"), - ); + let resolver = resolver.with_base_url(format!("{base_url}/opy-rs")); let resolved = resolver.update(None).unwrap(); server.join().unwrap(); assert_eq!(requests.load(Ordering::Relaxed), 1); @@ -894,10 +873,7 @@ mod tests { test_server_n(1, b"4.5.6\n".to_vec(), Vec::new(), Vec::new()); let resolver = OpyProviderResolver::new(&root) .with_target("x86_64-unknown-linux-gnu") - .with_release_urls( - format!("{base_url}/opy-rs/latest/version"), - format!("{base_url}/opy-rs/releases"), - ); + .with_base_url(format!("{base_url}/opy-rs")); assert_eq!(resolver.latest_version().unwrap(), "4.5.6"); server.join().unwrap(); assert_eq!(requests.load(Ordering::Relaxed), 1); @@ -933,10 +909,7 @@ mod tests { test_server(version.as_bytes().to_vec(), bytes, checksum.into_bytes()); let resolver = OpyProviderResolver::new(&root) .with_target(&target) - .with_release_urls( - format!("{base_url}/opy-rs/latest/version"), - format!("{base_url}/opy-rs/releases"), - ); + .with_base_url(format!("{base_url}/opy-rs")); let updated = resolver.update(None).unwrap(); server.join().unwrap(); assert_eq!(requests.load(Ordering::Relaxed), 3); @@ -946,10 +919,7 @@ mod tests { b"windows-provider" ); let resolved = resolver - .with_release_urls( - "http://127.0.0.1:1/opy-rs/latest/version", - "http://127.0.0.1:1/opy-rs/releases", - ) + .with_base_url("http://127.0.0.1:1/opy-rs") .resolve(None) .unwrap(); assert_eq!(resolved, updated); diff --git a/dist/README.md b/dist/README.md index bcb49a8c..e7733107 100644 --- a/dist/README.md +++ b/dist/README.md @@ -2,10 +2,12 @@ This document describes the package-manager and installer channels that make the canonical GitHub Release artifacts installable through -platform-native channels. Nothing here rebuilds Wright: every manifest and -package consumes the published `wright--.` +platform-native channels. Nothing here rebuilds Wright: the package-manager +manifests consume the published `wright--.` archives and their `.sha256` checksums from -`https://github.com/wrightkit/wright/releases/download/v/`. +`https://github.com/wrightkit/wright/releases/download/v/`, while the +installers and `wright update` consume the byte-identical R2 copies described +in [`docs/release.md`](../docs/release.md). | Channel | File(s) | Consumes | | --- | --- | --- | @@ -16,8 +18,9 @@ archives and their `.sha256` checksums from | Scoop | generated `wright.json` | Windows `.zip` with `hash` | Standalone installs (the Unix installer or manual archives) upgrade in place -with `wright update`, which consumes the same release archives and checksums -and refuses to overwrite binaries managed by any channel above; see +with `wright update`, which resolves and downloads through the R2 release +distribution contract in [`docs/release.md`](../docs/release.md) and refuses +to overwrite binaries managed by any channel above; see [`docs/cli.md`](../docs/cli.md). ## Generated metadata diff --git a/docs/cli/update.md b/docs/cli/update.md index 54cd1340..57351059 100644 --- a/docs/cli/update.md +++ b/docs/cli/update.md @@ -16,10 +16,11 @@ workflow, so it is text-only and produces no `wright-result/v1` envelope. * `wright update --check`: resolve the same targets and report available updates without modifying anything. * `wright update self [--version ]`: update only the standalone - installation, from the canonical GitHub Release artifacts (the same - archives and checksums the installer and the package-manager manifests - consume). The checksum is verified before anything is replaced, then - `wright` and `wright-lsp` are swapped atomically as a matched pair and + installation through the R2 release distribution — the same + `latest/version` pointer and immutable `releases//` archive and + checksum routes the canonical installers consume (see + `docs/release.md`). The checksum is verified before anything is replaced, + then `wright` and `wright-lsp` are swapped atomically as a matched pair and smoke-checked against the new version. `--version` installs an exact version instead of the latest stable release and refuses a downgrade (exit 1). @@ -46,10 +47,11 @@ installation directory, fails the self target with reinstall guidance Environment overrides (test/advanced hooks): -* `WRIGHT_INSTALL_BASE_URL`: base URL of Wright release artifacts -* `WRIGHT_API_URL`: URL used to resolve the latest Wright release +* `WRIGHT_INSTALL_BASE_URL`: base URL of the R2-backed Wright release + distribution (the `latest/version` and `releases//` routes live + under it, matching `install.sh` and `install.ps1`) * `WRIGHT_INSTALL_OS` / `WRIGHT_INSTALL_ARCH`: override self-update platform detection (matching `install.sh`) * `WRIGHT_PROVIDER_DATA_DIR`: provider store root -* `WRIGHT_OPY_PROVIDER_LATEST_URL` / `WRIGHT_OPY_PROVIDER_BASE_URL`: provider - release routes +* `WRIGHT_OPY_PROVIDER_BASE_URL`: base URL of the provider release + distribution (the same `latest/version` + `releases//` layout) diff --git a/docs/release.md b/docs/release.md index a87c2e8c..598b10fb 100644 --- a/docs/release.md +++ b/docs/release.md @@ -313,13 +313,15 @@ user `PATH` update instruction. Its functional behavior is covered by Standalone installations are also updatable in place: `wright update` -continues to consume the canonical GitHub Release artifacts and checksums (no -`install.sh` re-execution, no second build path), verifies the checksum before -replacing `wright` and `wright-lsp`, and refuses to overwrite +consumes the same R2 distribution contract as the installers — it resolves +the latest stable version from `wright/latest/version` and downloads the +immutable `wright/releases//` archive and checksum (no `install.sh` +re-execution, no second build path, no GitHub API dependency), verifies the +checksum before replacing `wright` and `wright-lsp`, and refuses to overwrite package-manager-managed binaries. See [`docs/cli.md`](cli.md) for the -command contract and its `WRIGHT_INSTALL_BASE_URL`/`WRIGHT_API_URL`/ -`WRIGHT_INSTALL_OS`/`WRIGHT_INSTALL_ARCH` test overrides. The installer has a -separate R2 route contract and only shares the platform overrides. +command contract and its `WRIGHT_INSTALL_BASE_URL`/`WRIGHT_INSTALL_OS`/ +`WRIGHT_INSTALL_ARCH` test overrides. GitHub Release download URLs remain a +documented manual recovery path, never an automatic runtime fallback. Package-manager availability is not instantaneous: the Homebrew tap is updated automatically by the `publish-tap` job, while the Scoop bucket and WinGet