Files
codex/codex-rs/tui/src/updates.rs
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

179 lines
6.1 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#![cfg(not(debug_assertions))]
use crate::legacy_core::config::Config;
use crate::npm_registry;
use crate::npm_registry::NpmPackageInfo;
use crate::update_action;
use crate::update_action::UpdateAction;
use crate::update_versions::extract_version_from_latest_tag;
use crate::update_versions::is_newer;
use crate::update_versions::is_source_build_version;
use chrono::DateTime;
use chrono::Duration;
use chrono::Utc;
use codex_login::default_client::create_client;
use serde::Deserialize;
use serde::Serialize;
use std::path::Path;
use std::path::PathBuf;
use crate::version::CODEX_CLI_VERSION;
pub fn get_upgrade_version(config: &Config) -> Option<String> {
if !config.check_for_update_on_startup || is_source_build_version(CODEX_CLI_VERSION) {
return None;
}
let action = update_action::get_update_action();
let version_file = version_filepath(config);
let info = read_version_info(&version_file).ok();
if match &info {
None => true,
Some(info) => info.last_checked_at < Utc::now() - Duration::hours(20),
} {
// Refresh the cached latest version in the background so TUI startup
// isnt blocked by a network call. The UI reads the previously cached
// value (if any) for this run; the next run shows the banner if needed.
tokio::spawn(async move {
check_for_update(&version_file, action)
.await
.inspect_err(|e| tracing::error!("Failed to update version: {e}"))
});
}
info.and_then(|info| {
if is_newer(&info.latest_version, CODEX_CLI_VERSION).unwrap_or(false) {
Some(info.latest_version)
} else {
None
}
})
}
#[derive(Serialize, Deserialize, Debug, Clone)]
struct VersionInfo {
latest_version: String,
// ISO-8601 timestamp (RFC3339)
last_checked_at: DateTime<Utc>,
#[serde(default)]
dismissed_version: Option<String>,
}
const VERSION_FILENAME: &str = "version.json";
// We use the latest version from the cask if installation is via homebrew - homebrew does not immediately pick up the latest release and can lag behind.
const HOMEBREW_CASK_API_URL: &str = "https://formulae.brew.sh/api/cask/codex.json";
const LATEST_RELEASE_URL: &str = "https://api.github.com/repos/openai/codex/releases/latest";
#[derive(Deserialize, Debug, Clone)]
struct ReleaseInfo {
tag_name: String,
}
#[derive(Deserialize, Debug, Clone)]
struct HomebrewCaskInfo {
version: String,
}
fn version_filepath(config: &Config) -> PathBuf {
config.codex_home.join(VERSION_FILENAME).into_path_buf()
}
fn read_version_info(version_file: &Path) -> anyhow::Result<VersionInfo> {
let contents = std::fs::read_to_string(version_file)?;
Ok(serde_json::from_str(&contents)?)
}
async fn check_for_update(version_file: &Path, action: Option<UpdateAction>) -> anyhow::Result<()> {
let latest_version = match action {
Some(UpdateAction::BrewUpgrade) => {
let HomebrewCaskInfo { version } = create_client()
.get(HOMEBREW_CASK_API_URL)
.send()
.await?
.error_for_status()?
.json::<HomebrewCaskInfo>()
.await?;
version
}
Some(UpdateAction::NpmGlobalLatest) | Some(UpdateAction::BunGlobalLatest) => {
let latest_version = fetch_latest_github_release_version().await?;
let package_info = create_client()
.get(npm_registry::PACKAGE_URL)
.send()
.await?
.error_for_status()?
.json::<NpmPackageInfo>()
.await?;
npm_registry::ensure_version_ready(&package_info, &latest_version)?;
latest_version
}
Some(UpdateAction::StandaloneUnix) | Some(UpdateAction::StandaloneWindows) | None => {
fetch_latest_github_release_version().await?
}
};
// Preserve any previously dismissed version if present.
let prev_info = read_version_info(version_file).ok();
let info = VersionInfo {
latest_version,
last_checked_at: Utc::now(),
dismissed_version: prev_info.and_then(|p| p.dismissed_version),
};
let json_line = format!("{}\n", serde_json::to_string(&info)?);
if let Some(parent) = version_file.parent() {
tokio::fs::create_dir_all(parent).await?;
}
tokio::fs::write(version_file, json_line).await?;
Ok(())
}
async fn fetch_latest_github_release_version() -> anyhow::Result<String> {
let ReleaseInfo {
tag_name: latest_tag_name,
} = create_client()
.get(LATEST_RELEASE_URL)
.send()
.await?
.error_for_status()?
.json::<ReleaseInfo>()
.await?;
extract_version_from_latest_tag(&latest_tag_name)
}
/// Returns the latest version to show in a popup, if it should be shown.
/// This respects the user's dismissal choice for the current latest version.
pub fn get_upgrade_version_for_popup(config: &Config) -> Option<String> {
if !config.check_for_update_on_startup || is_source_build_version(CODEX_CLI_VERSION) {
return None;
}
let version_file = version_filepath(config);
let latest = get_upgrade_version(config)?;
// If the user dismissed this exact version previously, do not show the popup.
if let Ok(info) = read_version_info(&version_file)
&& info.dismissed_version.as_deref() == Some(latest.as_str())
{
return None;
}
Some(latest)
}
/// Persist a dismissal for the current latest version so we don't show
/// the update popup again for this version.
pub async fn dismiss_version(config: &Config, version: &str) -> anyhow::Result<()> {
let version_file = version_filepath(config);
let mut info = match read_version_info(&version_file) {
Ok(info) => info,
Err(_) => return Ok(()),
};
info.dismissed_version = Some(version.to_string());
let json_line = format!("{}\n", serde_json::to_string(&info)?);
if let Some(parent) = version_file.parent() {
tokio::fs::create_dir_all(parent).await?;
}
tokio::fs::write(version_file, json_line).await?;
Ok(())
}