Files
codex/prs/bolinfest/PR-1764.md
2025-09-02 15:17:45 -07:00

34 KiB
Raw Blame History

PR #1764: check for updates

Description

  1. Ping https://api.github.com/repos/openai/codex/releases/latest (at most once every 20 hrs)
  2. Store the result in ~/.codex/version.jsonl
  3. If CARGO_PKG_VERSION < latest_version, print a message at boot.

Full Diff

diff --git a/codex-cli/bin/codex.js b/codex-cli/bin/codex.js
index ae1fb9593c..df06dd36a7 100755
--- a/codex-cli/bin/codex.js
+++ b/codex-cli/bin/codex.js
@@ -83,6 +83,7 @@ if (wantsNative && process.platform !== 'win32') {
 
   const child = spawn(binaryPath, process.argv.slice(2), {
     stdio: "inherit",
+    env: { ...process.env, CODEX_MANAGED_BY_NPM: "1" },
   });
 
   child.on("error", (err) => {
diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock
index 460a440e63..d71553cf4a 100644
--- a/codex-rs/Cargo.lock
+++ b/codex-rs/Cargo.lock
@@ -843,6 +843,7 @@ version = "0.0.0"
 dependencies = [
  "anyhow",
  "base64 0.22.1",
+ "chrono",
  "clap",
  "codex-ansi-escape",
  "codex-arg0",
@@ -861,6 +862,8 @@ dependencies = [
  "ratatui",
  "ratatui-image",
  "regex-lite",
+ "reqwest",
+ "serde",
  "serde_json",
  "shlex",
  "strum 0.27.2",
diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml
index 468f2f3be6..09b537c6c3 100644
--- a/codex-rs/tui/Cargo.toml
+++ b/codex-rs/tui/Cargo.toml
@@ -17,6 +17,7 @@ workspace = true
 [dependencies]
 anyhow = "1"
 base64 = "0.22.1"
+chrono = { version = "0.4", features = ["serde"] }
 clap = { version = "4", features = ["derive"] }
 codex-ansi-escape = { path = "../ansi-escape" }
 codex-arg0 = { path = "../arg0" }
@@ -41,6 +42,8 @@ ratatui = { version = "0.29.0", features = [
 ] }
 ratatui-image = "8.0.0"
 regex-lite = "0.1"
+reqwest = { version = "0.12", features = ["json"] }
+serde = { version = "1", features = ["derive"] }
 serde_json = { version = "1", features = ["preserve_order"] }
 shlex = "1.3.0"
 strum = "0.27.2"
diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs
index f0a0e9d833..0ec9be6153 100644
--- a/codex-rs/tui/src/lib.rs
+++ b/codex-rs/tui/src/lib.rs
@@ -41,6 +41,11 @@ mod text_formatting;
 mod tui;
 mod user_approval_widget;
 
+#[cfg(not(debug_assertions))]
+mod updates;
+#[cfg(not(debug_assertions))]
+use color_eyre::owo_colors::OwoColorize;
+
 pub use cli::Cli;
 
 pub async fn run_main(
@@ -139,6 +144,38 @@ pub async fn run_main(
         .with(tui_layer)
         .try_init();
 
+    #[allow(clippy::print_stderr)]
+    #[cfg(not(debug_assertions))]
+    if let Some(latest_version) = updates::get_upgrade_version(&config) {
+        let current_version = env!("CARGO_PKG_VERSION");
+        let exe = std::env::current_exe()?;
+        let managed_by_npm = std::env::var_os("CODEX_MANAGED_BY_NPM").is_some();
+
+        eprintln!(
+            "{} {current_version} -> {latest_version}.",
+            "✨⬆️ Update available!".bold().cyan()
+        );
+
+        if managed_by_npm {
+            let npm_cmd = "npm install -g @openai/codex@latest";
+            eprintln!("Run {} to update.", npm_cmd.cyan().on_black());
+        } else if cfg!(target_os = "macos")
+            && (exe.starts_with("/opt/homebrew") || exe.starts_with("/usr/local"))
+        {
+            let brew_cmd = "brew upgrade codex";
+            eprintln!("Run {} to update.", brew_cmd.cyan().on_black());
+        } else {
+            eprintln!(
+                "See {} for the latest releases and installation options.",
+                "https://github.com/openai/codex/releases/latest"
+                    .cyan()
+                    .on_black()
+            );
+        }
+
+        eprintln!("");
+    }
+
     let show_login_screen = should_show_login_screen(&config);
     if show_login_screen {
         std::io::stdout()
diff --git a/codex-rs/tui/src/updates.rs b/codex-rs/tui/src/updates.rs
new file mode 100644
index 0000000000..c7f7afd2a5
--- /dev/null
+++ b/codex-rs/tui/src/updates.rs
@@ -0,0 +1,137 @@
+#![cfg(any(not(debug_assertions), test))]
+
+use chrono::DateTime;
+use chrono::Duration;
+use chrono::Utc;
+use serde::Deserialize;
+use serde::Serialize;
+use std::path::Path;
+use std::path::PathBuf;
+
+use codex_core::config::Config;
+
+pub fn get_upgrade_version(config: &Config) -> Option<String> {
+    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)
+                .await
+                .inspect_err(|e| tracing::error!("Failed to update version: {e}"))
+        });
+    }
+
+    info.and_then(|info| {
+        let current_version = env!("CARGO_PKG_VERSION");
+        if is_newer(&info.latest_version, current_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>,
+}
+
+#[derive(Deserialize, Debug, Clone)]
+struct ReleaseInfo {
+    tag_name: String,
+}
+
+const VERSION_FILENAME: &str = "version.json";
+const LATEST_RELEASE_URL: &str = "https://api.github.com/repos/openai/codex/releases/latest";
+
+fn version_filepath(config: &Config) -> PathBuf {
+    config.codex_home.join(VERSION_FILENAME)
+}
+
+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) -> anyhow::Result<()> {
+    let ReleaseInfo {
+        tag_name: latest_tag_name,
+    } = reqwest::Client::new()
+        .get(LATEST_RELEASE_URL)
+        .header(
+            "User-Agent",
+            format!(
+                "codex/{} (+https://github.com/openai/codex)",
+                env!("CARGO_PKG_VERSION")
+            ),
+        )
+        .send()
+        .await?
+        .error_for_status()?
+        .json::<ReleaseInfo>()
+        .await?;
+
+    let info = VersionInfo {
+        latest_version: latest_tag_name
+            .strip_prefix("rust-v")
+            .ok_or_else(|| anyhow::anyhow!("Failed to parse latest tag name '{latest_tag_name}'"))?
+            .into(),
+        last_checked_at: Utc::now(),
+    };
+
+    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(())
+}
+
+fn is_newer(latest: &str, current: &str) -> Option<bool> {
+    match (parse_version(latest), parse_version(current)) {
+        (Some(l), Some(c)) => Some(l > c),
+        _ => None,
+    }
+}
+
+fn parse_version(v: &str) -> Option<(u64, u64, u64)> {
+    let mut iter = v.trim().split('.');
+    let maj = iter.next()?.parse::<u64>().ok()?;
+    let min = iter.next()?.parse::<u64>().ok()?;
+    let pat = iter.next()?.parse::<u64>().ok()?;
+    Some((maj, min, pat))
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn prerelease_version_is_not_considered_newer() {
+        assert_eq!(is_newer("0.11.0-beta.1", "0.11.0"), None);
+        assert_eq!(is_newer("1.0.0-rc.1", "1.0.0"), None);
+    }
+
+    #[test]
+    fn plain_semver_comparisons_work() {
+        assert_eq!(is_newer("0.11.1", "0.11.0"), Some(true));
+        assert_eq!(is_newer("0.11.0", "0.11.1"), Some(false));
+        assert_eq!(is_newer("1.0.0", "0.9.9"), Some(true));
+        assert_eq!(is_newer("0.9.9", "1.0.0"), Some(false));
+    }
+
+    #[test]
+    fn whitespace_is_ignored() {
+        assert_eq!(parse_version(" 1.2.3 \n"), Some((1, 2, 3)));
+        assert_eq!(is_newer(" 1.2.3 ", "1.2.2"), Some(true));
+    }
+}

Review Comments

codex-rs/tui/Cargo.toml

@@ -42,6 +42,9 @@ ratatui = { version = "0.29.0", features = [
 ratatui-image = "8.0.0"
 regex-lite = "0.1"
 serde_json = { version = "1", features = ["preserve_order"] }
+serde = { version = "1", features = ["derive"] }

alpha-sort?

codex-rs/tui/src/lib.rs

@@ -139,6 +144,26 @@ pub async fn run_main(
         .with(tui_layer)
         .try_init();
 
+    #[allow(clippy::print_stderr)]
+    #[cfg(not(debug_assertions))]
+    if let Some(latest_version) = updates::get_upgrade_version(&config) {
+        let current_version = env!("CARGO_PKG_VERSION");
+        let exe = std::env::current_exe()?;
+        let update_command = if cfg!(target_os = "macos")
+            && (exe.starts_with("/opt/homebrew") || exe.starts_with("/usr/local"))
+        {
+            "brew upgrade codex"
+        } else {
+            "npm install -g @openai/codex@latest"
+        };

To recommend npm install, I think we should add the check in here:

https://github.com/openai/codex/blob/main/codex-cli/bin/codex.js

I think we should have a special build-time env var when building for Homebrew:

https://github.com/bolinfest/homebrew-core/blob/main/Formula/c/codex.rb

And check it with env!() and set the update_command, as appropriate.

If there is no valid update_command, we should point the user to https://github.com/openai/codex/releases/latest (maybe even the artifact within latest, though we have to commit to the artifact names to future-proof it)

@@ -139,6 +144,26 @@ pub async fn run_main(
         .with(tui_layer)
         .try_init();
 
+    #[allow(clippy::print_stderr)]
+    #[cfg(not(debug_assertions))]
+    if let Some(latest_version) = updates::get_upgrade_version(&config) {
+        let current_version = env!("CARGO_PKG_VERSION");
+        let exe = std::env::current_exe()?;
+        let update_command = if cfg!(target_os = "macos")
+            && (exe.starts_with("/opt/homebrew") || exe.starts_with("/usr/local"))
+        {
+            "brew upgrade codex"
+        } else {
+            "npm install -g @openai/codex@latest"
+        };

I generally agree that seems better, though I've been trying to think through that could be abused by a bad actor in some way.

@@ -139,6 +144,26 @@ pub async fn run_main(
         .with(tui_layer)
         .try_init();
 
+    #[allow(clippy::print_stderr)]
+    #[cfg(not(debug_assertions))]
+    if let Some(latest_version) = updates::get_upgrade_version(&config) {
+        let current_version = env!("CARGO_PKG_VERSION");
+        let exe = std::env::current_exe()?;
+        let update_command = if cfg!(target_os = "macos")
+            && (exe.starts_with("/opt/homebrew") || exe.starts_with("/usr/local"))
+        {
+            "brew upgrade codex"
+        } else {
+            "npm install -g @openai/codex@latest"
+        };

Sure, though neither npm install -g nor brew install should be run as root, so they can't write /etc either?

@@ -139,6 +144,26 @@ pub async fn run_main(
         .with(tui_layer)
         .try_init();
 
+    #[allow(clippy::print_stderr)]
+    #[cfg(not(debug_assertions))]
+    if let Some(latest_version) = updates::get_upgrade_version(&config) {
+        let current_version = env!("CARGO_PKG_VERSION");
+        let exe = std::env::current_exe()?;
+        let update_command = if cfg!(target_os = "macos")
+            && (exe.starts_with("/opt/homebrew") || exe.starts_with("/usr/local"))

Why don't we drop exe.starts_with("/usr/local")) for now? There's a lot of ways one can end up with /usr/local/bin/codex and I'm not sure how often I believe it is brew install.

@@ -139,6 +144,38 @@ pub async fn run_main(
         .with(tui_layer)
         .try_init();
 
+    #[allow(clippy::print_stderr)]

Low pri, but I would consider moving this to updates.rs so you can have a smaller thing attached to #[cfg(not(debug_assertions))].

codex-rs/tui/src/updates.rs

@@ -0,0 +1,110 @@
+#![cfg(not(debug_assertions))]
+
+use chrono::Duration;
+use chrono::Utc;
+use std::path::Path;
+use std::path::PathBuf;
+
+use codex_core::config::Config;
+
+pub fn get_upgrade_version(config: &Config) -> Option<String> {
+    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),
+    } {
+        tokio::spawn(async move {
+            update_version(&version_file)
+                .await
+                .inspect_err(|e| tracing::error!("Failed to update version: {e}"))
+        });
+    }
+
+    info.and_then(|info| {
+        let current_version = env!("CARGO_PKG_VERSION");
+        if is_newer(&info.latest_version, current_version).unwrap_or(false) {
+            Some(info.latest_version)
+        } else {
+            None
+        }
+    })
+}
+
+#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
+struct VersionInfo {
+    latest_version: String,
+    // ISO-8601 timestamp (RFC3339)
+    last_checked_at: chrono::DateTime<chrono::Utc>,
+}
+
+const VERSION_FILENAME: &str = "version.jsonl";
+const LATEST_RELEASE_URL: &str = "https://api.github.com/repos/openai/codex/releases/latest";
+
+fn version_filepath(config: &Config) -> PathBuf {
+    let mut path = config.codex_home.clone();
+    path.push(VERSION_FILENAME);
+    path
+}
+
+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 update_version(version_file: &Path) -> anyhow::Result<()> {

Can you add a docstring and/or rename? This doesn't update the version of the CLI, but the version file with metadata, is that right?

@@ -0,0 +1,110 @@
+#![cfg(not(debug_assertions))]
+
+use chrono::Duration;
+use chrono::Utc;
+use std::path::Path;
+use std::path::PathBuf;
+
+use codex_core::config::Config;
+
+pub fn get_upgrade_version(config: &Config) -> Option<String> {
+    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),
+    } {
+        tokio::spawn(async move {
+            update_version(&version_file)
+                .await
+                .inspect_err(|e| tracing::error!("Failed to update version: {e}"))
+        });
+    }
+
+    info.and_then(|info| {
+        let current_version = env!("CARGO_PKG_VERSION");
+        if is_newer(&info.latest_version, current_version).unwrap_or(false) {
+            Some(info.latest_version)
+        } else {
+            None
+        }
+    })
+}
+
+#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
+struct VersionInfo {
+    latest_version: String,
+    // ISO-8601 timestamp (RFC3339)
+    last_checked_at: chrono::DateTime<chrono::Utc>,
+}
+
+const VERSION_FILENAME: &str = "version.jsonl";
+const LATEST_RELEASE_URL: &str = "https://api.github.com/repos/openai/codex/releases/latest";
+
+fn version_filepath(config: &Config) -> PathBuf {
+    let mut path = config.codex_home.clone();
+    path.push(VERSION_FILENAME);
+    path
+}
+
+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 update_version(version_file: &Path) -> anyhow::Result<()> {
+    #[derive(serde::Deserialize, Debug, Clone)]
+    struct ReleaseInfo {
+        tag_name: String,
+    }
+
+    let resp = reqwest::Client::new()
+        .get(LATEST_RELEASE_URL)
+        .header(
+            "User-Agent",
+            format!(
+                "codex/{} (+https://github.com/openai/codex)",
+                env!("CARGO_PKG_VERSION")
+            ),
+        )
+        .send()
+        .await?
+        .error_for_status()?
+        .json::<ReleaseInfo>()
+        .await?;
+
+    let latest_tag_name = resp.tag_name;
+
+    let info = VersionInfo {
+        latest_version: latest_tag_name
+            .strip_prefix("rust-v")
+            .ok_or_else(|| {
+                anyhow::anyhow!("Failed to parse latest tag name '{}'", latest_tag_name)
+            })?
+            .into(),
+        last_checked_at: chrono::Utc::now(),
+    };
+
+    let json_line = format!("{}\n", serde_json::to_string(&info)?);
+    if let Some(parent) = version_file.parent() {
+        tokio::fs::create_dir_all(parent).await.ok();
+    }
+    tokio::fs::write(version_file, json_line).await.ok();
+    Ok(())
+}
+
+fn is_newer(latest: &str, current: &str) -> Option<bool> {

Can you please add some unit tests so we can be sure that something like 0.11.0-beta.1 always fails the is_newer() check? I believe that is the case right now, but I want to be sure we don't regress that.

@@ -0,0 +1,110 @@
+#![cfg(not(debug_assertions))]
+
+use chrono::Duration;
+use chrono::Utc;
+use std::path::Path;
+use std::path::PathBuf;
+
+use codex_core::config::Config;
+
+pub fn get_upgrade_version(config: &Config) -> Option<String> {
+    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),
+    } {
+        tokio::spawn(async move {

So we spawn this, but nothing waits for it? I'm a bit confused what the expectation here is.

@@ -0,0 +1,138 @@
+#![cfg(any(not(debug_assertions), test))]
+
+use chrono::Duration;
+use chrono::Utc;
+use std::path::Path;
+use std::path::PathBuf;
+
+use codex_core::config::Config;
+
+pub fn get_upgrade_version(config: &Config) -> Option<String> {
+    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)
+                .await
+                .inspect_err(|e| tracing::error!("Failed to update version: {e}"))
+        });
+    }
+
+    info.and_then(|info| {
+        let current_version = env!("CARGO_PKG_VERSION");
+        if is_newer(&info.latest_version, current_version).unwrap_or(false) {
+            Some(info.latest_version)
+        } else {
+            None
+        }
+    })
+}
+
+#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]

I use use serde::Deserialize and Deserialize at the top, FYI.

@@ -0,0 +1,138 @@
+#![cfg(any(not(debug_assertions), test))]
+
+use chrono::Duration;
+use chrono::Utc;
+use std::path::Path;
+use std::path::PathBuf;
+
+use codex_core::config::Config;
+
+pub fn get_upgrade_version(config: &Config) -> Option<String> {
+    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)
+                .await
+                .inspect_err(|e| tracing::error!("Failed to update version: {e}"))
+        });
+    }
+
+    info.and_then(|info| {
+        let current_version = env!("CARGO_PKG_VERSION");
+        if is_newer(&info.latest_version, current_version).unwrap_or(false) {
+            Some(info.latest_version)
+        } else {
+            None
+        }
+    })
+}
+
+#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
+struct VersionInfo {
+    latest_version: String,
+    // ISO-8601 timestamp (RFC3339)
+    last_checked_at: chrono::DateTime<chrono::Utc>,
+}
+
+const VERSION_FILENAME: &str = "version.jsonl";

Not just .json?

@@ -0,0 +1,138 @@
+#![cfg(any(not(debug_assertions), test))]
+
+use chrono::Duration;
+use chrono::Utc;
+use std::path::Path;
+use std::path::PathBuf;
+
+use codex_core::config::Config;
+
+pub fn get_upgrade_version(config: &Config) -> Option<String> {
+    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)
+                .await
+                .inspect_err(|e| tracing::error!("Failed to update version: {e}"))
+        });
+    }
+
+    info.and_then(|info| {
+        let current_version = env!("CARGO_PKG_VERSION");
+        if is_newer(&info.latest_version, current_version).unwrap_or(false) {
+            Some(info.latest_version)
+        } else {
+            None
+        }
+    })
+}
+
+#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
+struct VersionInfo {
+    latest_version: String,
+    // ISO-8601 timestamp (RFC3339)
+    last_checked_at: chrono::DateTime<chrono::Utc>,
+}
+
+const VERSION_FILENAME: &str = "version.jsonl";
+const LATEST_RELEASE_URL: &str = "https://api.github.com/repos/openai/codex/releases/latest";
+
+fn version_filepath(config: &Config) -> PathBuf {
+    let mut path = config.codex_home.clone();
+    path.push(VERSION_FILENAME);
+    path

It should be possible to do something like this?

    config.codex_home.join(VERSION_FILENAME).to_path_buf()
@@ -0,0 +1,138 @@
+#![cfg(any(not(debug_assertions), test))]
+
+use chrono::Duration;
+use chrono::Utc;
+use std::path::Path;
+use std::path::PathBuf;
+
+use codex_core::config::Config;
+
+pub fn get_upgrade_version(config: &Config) -> Option<String> {
+    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)
+                .await
+                .inspect_err(|e| tracing::error!("Failed to update version: {e}"))
+        });
+    }
+
+    info.and_then(|info| {
+        let current_version = env!("CARGO_PKG_VERSION");
+        if is_newer(&info.latest_version, current_version).unwrap_or(false) {
+            Some(info.latest_version)
+        } else {
+            None
+        }
+    })
+}
+
+#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
+struct VersionInfo {
+    latest_version: String,
+    // ISO-8601 timestamp (RFC3339)
+    last_checked_at: chrono::DateTime<chrono::Utc>,
+}
+
+const VERSION_FILENAME: &str = "version.jsonl";
+const LATEST_RELEASE_URL: &str = "https://api.github.com/repos/openai/codex/releases/latest";
+
+fn version_filepath(config: &Config) -> PathBuf {
+    let mut path = config.codex_home.clone();
+    path.push(VERSION_FILENAME);
+    path
+}
+
+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) -> anyhow::Result<()> {
+    #[derive(serde::Deserialize, Debug, Clone)]

I would just make this a top level struct, but up to you.

@@ -0,0 +1,138 @@
+#![cfg(any(not(debug_assertions), test))]
+
+use chrono::Duration;
+use chrono::Utc;
+use std::path::Path;
+use std::path::PathBuf;
+
+use codex_core::config::Config;
+
+pub fn get_upgrade_version(config: &Config) -> Option<String> {
+    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)
+                .await
+                .inspect_err(|e| tracing::error!("Failed to update version: {e}"))
+        });
+    }
+
+    info.and_then(|info| {
+        let current_version = env!("CARGO_PKG_VERSION");
+        if is_newer(&info.latest_version, current_version).unwrap_or(false) {
+            Some(info.latest_version)
+        } else {
+            None
+        }
+    })
+}
+
+#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
+struct VersionInfo {
+    latest_version: String,
+    // ISO-8601 timestamp (RFC3339)
+    last_checked_at: chrono::DateTime<chrono::Utc>,
+}
+
+const VERSION_FILENAME: &str = "version.jsonl";
+const LATEST_RELEASE_URL: &str = "https://api.github.com/repos/openai/codex/releases/latest";
+
+fn version_filepath(config: &Config) -> PathBuf {
+    let mut path = config.codex_home.clone();
+    path.push(VERSION_FILENAME);
+    path
+}
+
+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) -> anyhow::Result<()> {
+    #[derive(serde::Deserialize, Debug, Clone)]
+    struct ReleaseInfo {
+        tag_name: String,
+    }
+
+    let resp = reqwest::Client::new()
+        .get(LATEST_RELEASE_URL)
+        .header(
+            "User-Agent",
+            format!(
+                "codex/{} (+https://github.com/openai/codex)",
+                env!("CARGO_PKG_VERSION")
+            ),
+        )
+        .send()
+        .await?
+        .error_for_status()?
+        .json::<ReleaseInfo>()
+        .await?;
+
+    let latest_tag_name = resp.tag_name;
+
+    let info = VersionInfo {
+        latest_version: latest_tag_name
+            .strip_prefix("rust-v")
+            .ok_or_else(|| {
+                anyhow::anyhow!("Failed to parse latest tag name '{}'", latest_tag_name)
                anyhow::anyhow!("Failed to parse latest tag name '{latest_tag_name}'")
@@ -0,0 +1,138 @@
+#![cfg(any(not(debug_assertions), test))]
+
+use chrono::Duration;
+use chrono::Utc;
+use std::path::Path;
+use std::path::PathBuf;
+
+use codex_core::config::Config;
+
+pub fn get_upgrade_version(config: &Config) -> Option<String> {
+    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)
+                .await
+                .inspect_err(|e| tracing::error!("Failed to update version: {e}"))
+        });
+    }
+
+    info.and_then(|info| {
+        let current_version = env!("CARGO_PKG_VERSION");
+        if is_newer(&info.latest_version, current_version).unwrap_or(false) {
+            Some(info.latest_version)
+        } else {
+            None
+        }
+    })
+}
+
+#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
+struct VersionInfo {
+    latest_version: String,
+    // ISO-8601 timestamp (RFC3339)
+    last_checked_at: chrono::DateTime<chrono::Utc>,
+}
+
+const VERSION_FILENAME: &str = "version.jsonl";
+const LATEST_RELEASE_URL: &str = "https://api.github.com/repos/openai/codex/releases/latest";
+
+fn version_filepath(config: &Config) -> PathBuf {
+    let mut path = config.codex_home.clone();
+    path.push(VERSION_FILENAME);
+    path
+}
+
+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) -> anyhow::Result<()> {
+    #[derive(serde::Deserialize, Debug, Clone)]
+    struct ReleaseInfo {
+        tag_name: String,
+    }
+
+    let resp = reqwest::Client::new()
+        .get(LATEST_RELEASE_URL)
+        .header(
+            "User-Agent",
+            format!(
+                "codex/{} (+https://github.com/openai/codex)",
+                env!("CARGO_PKG_VERSION")
+            ),
+        )
+        .send()
+        .await?
+        .error_for_status()?
+        .json::<ReleaseInfo>()
+        .await?;
+
+    let latest_tag_name = resp.tag_name;
+
+    let info = VersionInfo {
+        latest_version: latest_tag_name
+            .strip_prefix("rust-v")
+            .ok_or_else(|| {
+                anyhow::anyhow!("Failed to parse latest tag name '{}'", latest_tag_name)
+            })?
+            .into(),
+        last_checked_at: chrono::Utc::now(),
+    };
+
+    let json_line = format!("{}\n", serde_json::to_string(&info)?);
+    if let Some(parent) = version_file.parent() {
+        tokio::fs::create_dir_all(parent).await.ok();

Why ok() instead of ? (and two lines down)

@@ -0,0 +1,138 @@
+#![cfg(any(not(debug_assertions), test))]
+
+use chrono::Duration;
+use chrono::Utc;
+use std::path::Path;
+use std::path::PathBuf;
+
+use codex_core::config::Config;
+
+pub fn get_upgrade_version(config: &Config) -> Option<String> {
+    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)
+                .await
+                .inspect_err(|e| tracing::error!("Failed to update version: {e}"))
+        });
+    }
+
+    info.and_then(|info| {
+        let current_version = env!("CARGO_PKG_VERSION");
+        if is_newer(&info.latest_version, current_version).unwrap_or(false) {
+            Some(info.latest_version)
+        } else {
+            None
+        }
+    })
+}
+
+#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
+struct VersionInfo {
+    latest_version: String,
+    // ISO-8601 timestamp (RFC3339)
+    last_checked_at: chrono::DateTime<chrono::Utc>,
+}
+
+const VERSION_FILENAME: &str = "version.jsonl";
+const LATEST_RELEASE_URL: &str = "https://api.github.com/repos/openai/codex/releases/latest";
+
+fn version_filepath(config: &Config) -> PathBuf {
+    let mut path = config.codex_home.clone();
+    path.push(VERSION_FILENAME);
+    path
+}
+
+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) -> anyhow::Result<()> {
+    #[derive(serde::Deserialize, Debug, Clone)]
+    struct ReleaseInfo {
+        tag_name: String,
+    }
+
+    let resp = reqwest::Client::new()

Feels like a good candidate for destructuring:

    let ReleaseInfo { tag_name: latest_tag_name} = reqwest::Client::new()

and then you can remove:

let latest_tag_name = resp.tag_name;