Files
codex/codex-rs/tui/src/npm_registry.rs
T
Shijie Rao 4e30281a13 Guard npm update readiness (#19389)
## Why
For npm/Bun-managed installs, the update prompt was treating the latest
GitHub release as ready to install. During the `0.124.0` release, GitHub
and npm visibility were not atomic: the root npm wrapper could become
visible before the npm registry marked that version as the package
`latest`. That left a window where users could be prompted to upgrade
before npm was ready for the release.

## What changed
- Keep GitHub Releases as the candidate latest-version source for
npm/Bun installs, but only write the existing `version.json` cache after
npm registry metadata proves that same root version is ready.
- Add `codex-rs/tui/src/npm_registry.rs` to validate npm readiness by
checking `dist-tags.latest` and root package `dist` metadata for the
GitHub candidate version.
- Move version parsing helpers into
`codex-rs/tui/src/update_versions.rs` so that logic can be tested
without compiling the release-only `updates.rs` module under tests.
- Update `.github/workflows/rust-release.yml` so the six known platform
tarballs publish before the root `@openai/codex` wrapper. Other npm
tarballs publish before the root wrapper, and the SDK publishes after
the root package it depends on.
2026-04-25 17:09:29 -07:00

131 lines
4.0 KiB
Rust

use serde::Deserialize;
use std::collections::HashMap;
#[cfg(not(debug_assertions))]
pub(crate) const PACKAGE_URL: &str = "https://registry.npmjs.org/@openai%2fcodex";
#[derive(Deserialize, Debug, Clone)]
pub(crate) struct NpmPackageInfo {
#[serde(rename = "dist-tags")]
dist_tags: HashMap<String, String>,
versions: HashMap<String, NpmPackageVersionInfo>,
}
#[derive(Deserialize, Debug, Clone)]
struct NpmPackageVersionInfo {
dist: Option<NpmPackageDist>,
}
#[derive(Deserialize, Debug, Clone)]
struct NpmPackageDist {
tarball: Option<String>,
integrity: Option<String>,
}
pub(crate) fn ensure_version_ready(
package_info: &NpmPackageInfo,
version: &str,
) -> anyhow::Result<()> {
let version = version.trim();
match package_info.dist_tags.get("latest").map(String::as_str) {
Some(latest) if latest == version => {}
Some(latest) => anyhow::bail!(
"npm latest dist-tag points to {latest}, expected GitHub release {version}"
),
None => anyhow::bail!("npm package is missing latest dist-tag"),
}
version_info_with_dist(package_info, version)?;
Ok(())
}
fn version_info_with_dist<'a>(
package_info: &'a NpmPackageInfo,
version: &str,
) -> anyhow::Result<&'a NpmPackageVersionInfo> {
let info = package_info
.versions
.get(version)
.ok_or_else(|| anyhow::anyhow!("npm package version {version} is missing"))?;
let Some(dist) = info.dist.as_ref() else {
anyhow::bail!("npm package version {version} is missing dist metadata");
};
let has_tarball = dist
.tarball
.as_deref()
.is_some_and(|tarball| !tarball.is_empty());
if !has_tarball {
anyhow::bail!("npm package version {version} is missing dist.tarball");
}
let has_integrity = dist
.integrity
.as_ref()
.is_some_and(|integrity| !integrity.is_empty());
if !has_integrity {
anyhow::bail!("npm package version {version} is missing dist.integrity");
}
Ok(info)
}
#[cfg(test)]
mod tests {
use super::*;
fn version_json(version: &str) -> serde_json::Value {
serde_json::json!({
"dist": {
"integrity": format!("sha512-{version}"),
"tarball": format!("https://registry.npmjs.org/@openai/codex/-/codex-{version}.tgz"),
}
})
}
fn package_info(github_latest: &str, npm_latest: &str) -> NpmPackageInfo {
let mut versions = serde_json::Map::new();
versions.insert(github_latest.to_string(), version_json(github_latest));
serde_json::from_value(serde_json::json!({
"dist-tags": { "latest": npm_latest },
"versions": serde_json::Value::Object(versions),
}))
.expect("valid npm package metadata")
}
#[test]
fn ready_version_requires_latest_dist_tag_and_root_dist() {
let latest = "1.2.3";
let package_info = package_info(latest, latest);
ensure_version_ready(&package_info, latest).expect("npm package is ready");
}
#[test]
fn ready_version_rejects_stale_latest_dist_tag() {
let package_info = package_info("1.2.3", "1.2.2");
let err = ensure_version_ready(&package_info, "1.2.3")
.expect_err("npm latest dist-tag must match GitHub latest");
assert!(
err.to_string().contains("latest dist-tag"),
"error should name stale latest dist-tag: {err}"
);
}
#[test]
fn ready_version_rejects_missing_root_dist() {
let package_info: NpmPackageInfo = serde_json::from_value(serde_json::json!({
"dist-tags": { "latest": "1.2.3" },
"versions": { "1.2.3": {} },
}))
.expect("valid npm package metadata");
let err = ensure_version_ready(&package_info, "1.2.3")
.expect_err("root package must have dist metadata");
assert!(
err.to_string().contains("missing dist metadata"),
"error should name missing dist metadata: {err}"
);
}
}