Persist update dismissal without cache (#27783)

## Summary

Choosing “Don’t remind me” can silently fail when `version.json`
disappears before dismissal because `dismiss_version` returns success
without writing anything. The same update can then reappear on the next
launch.

Initialize a minimal `VersionInfo` from the selected version when the
cache cannot be read, then persist the dismissal through the existing
write path.

Fixes #27147
This commit is contained in:
Eric Traut
2026-06-12 09:26:08 -07:00
committed by GitHub
Unverified
parent c375deaf66
commit 096e5e76a2
4 changed files with 88 additions and 39 deletions
+2
View File
@@ -207,6 +207,8 @@ mod update_prompt;
#[cfg(any(not(debug_assertions), test))]
mod update_versions;
mod updates;
#[cfg(any(not(debug_assertions), test))]
mod updates_cache;
mod version;
#[cfg(not(target_os = "linux"))]
mod voice;
+5 -39
View File
@@ -8,17 +8,19 @@ 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 crate::updates_cache::VersionInfo;
use crate::updates_cache::read_version_info;
use crate::updates_cache::version_filepath;
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(crate) use crate::updates_cache::dismiss_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;
@@ -51,16 +53,6 @@ pub fn get_upgrade_version(config: &Config) -> Option<String> {
})
}
#[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";
@@ -75,15 +67,6 @@ 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) => {
@@ -159,20 +142,3 @@ pub fn get_upgrade_version_for_popup(config: &Config) -> Option<String> {
}
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(())
}
+52
View File
@@ -0,0 +1,52 @@
use crate::legacy_core::config::Config;
use chrono::DateTime;
use chrono::Utc;
use serde::Deserialize;
use serde::Serialize;
use std::path::Path;
use std::path::PathBuf;
#[derive(Serialize, Deserialize, Debug, Clone)]
pub(crate) struct VersionInfo {
pub(crate) latest_version: String,
// ISO-8601 timestamp (RFC3339)
pub(crate) last_checked_at: DateTime<Utc>,
#[serde(default)]
pub(crate) dismissed_version: Option<String>,
}
const VERSION_FILENAME: &str = "version.json";
pub(crate) fn version_filepath(config: &Config) -> PathBuf {
config.codex_home.join(VERSION_FILENAME).into_path_buf()
}
pub(crate) 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)?)
}
/// Persist a dismissal for the current latest version so we don't show
/// the update popup again for this version.
pub(crate) 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(_) => VersionInfo {
latest_version: version.to_string(),
last_checked_at: DateTime::<Utc>::UNIX_EPOCH,
dismissed_version: None,
},
};
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(())
}
#[cfg(test)]
#[path = "updates_cache_tests.rs"]
mod tests;
+29
View File
@@ -0,0 +1,29 @@
use super::*;
use crate::legacy_core::config::ConfigBuilder;
use pretty_assertions::assert_eq;
use tempfile::tempdir;
#[tokio::test]
async fn dismiss_version_creates_cache_file_when_missing() {
let codex_home = tempdir().expect("temp codex home");
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.build()
.await
.expect("load config");
let version_file = version_filepath(&config);
dismiss_version(&config, "999.0.0")
.await
.expect("dismiss version");
let info = read_version_info(&version_file).expect("read version info");
assert_eq!(info.last_checked_at, DateTime::<Utc>::UNIX_EPOCH);
assert_eq!(
(
info.latest_version.as_str(),
info.dismissed_version.as_deref()
),
("999.0.0", Some("999.0.0"))
);
}