Skip to content

Commit 6a66b76

Browse files
committed
Guard npm update readiness
1 parent 88f300d commit 6a66b76

5 files changed

Lines changed: 386 additions & 91 deletions

File tree

.github/workflows/rust-release.yml

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -651,11 +651,59 @@ jobs:
651651
prefix="${NPM_TAG}-"
652652
fi
653653
654+
root_tarball="dist/npm/codex-npm-${VERSION}.tgz"
655+
sdk_tarball="dist/npm/codex-sdk-npm-${VERSION}.tgz"
656+
# Keep this list in sync with CODEX_PLATFORM_PACKAGES in
657+
# codex-cli/scripts/build_npm_package.py. The root wrapper advances
658+
# @openai/codex@latest as soon as it publishes, so every platform
659+
# package it aliases must already exist in the registry first.
660+
platform_tarballs=(
661+
"dist/npm/codex-npm-linux-x64-${VERSION}.tgz"
662+
"dist/npm/codex-npm-linux-arm64-${VERSION}.tgz"
663+
"dist/npm/codex-npm-darwin-x64-${VERSION}.tgz"
664+
"dist/npm/codex-npm-darwin-arm64-${VERSION}.tgz"
665+
"dist/npm/codex-npm-win32-x64-${VERSION}.tgz"
666+
"dist/npm/codex-npm-win32-arm64-${VERSION}.tgz"
667+
)
668+
669+
for required_tarball in "${platform_tarballs[@]}" "${root_tarball}"; do
670+
if [[ ! -f "${required_tarball}" ]]; then
671+
echo "Missing npm tarball: ${required_tarball}"
672+
exit 1
673+
fi
674+
done
675+
654676
shopt -s nullglob
655-
tarballs=(dist/npm/*-"${VERSION}".tgz)
656-
if [[ ${#tarballs[@]} -eq 0 ]]; then
657-
echo "No npm tarballs found in dist/npm for version ${VERSION}"
658-
exit 1
677+
other_tarballs=()
678+
for tarball in dist/npm/*-"${VERSION}".tgz; do
679+
if [[ "${tarball}" == "${root_tarball}" || "${tarball}" == "${sdk_tarball}" ]]; then
680+
continue
681+
fi
682+
683+
is_platform_tarball=false
684+
for platform_tarball in "${platform_tarballs[@]}"; do
685+
if [[ "${tarball}" == "${platform_tarball}" ]]; then
686+
is_platform_tarball=true
687+
break
688+
fi
689+
done
690+
if [[ "${is_platform_tarball}" == true ]]; then
691+
continue
692+
fi
693+
694+
other_tarballs+=("${tarball}")
695+
done
696+
697+
# Publish the platform packages before the root CLI wrapper. The root
698+
# wrapper advances @openai/codex@latest, so it should only publish
699+
# after the optional dependency versions it references exist.
700+
tarballs=(
701+
"${platform_tarballs[@]}"
702+
"${other_tarballs[@]}"
703+
"${root_tarball}"
704+
)
705+
if [[ -f "${sdk_tarball}" ]]; then
706+
tarballs+=("${sdk_tarball}")
659707
fi
660708
661709
for tarball in "${tarballs[@]}"; do

codex-rs/tui/src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,8 @@ mod model_catalog;
142142
mod model_migration;
143143
mod multi_agents;
144144
mod notifications;
145+
#[cfg(any(not(debug_assertions), test))]
146+
mod npm_registry;
145147
pub(crate) mod onboarding;
146148
mod oss_selection;
147149
mod pager_overlay;
@@ -167,6 +169,8 @@ mod ui_consts;
167169
pub(crate) mod update_action;
168170
pub use update_action::UpdateAction;
169171
mod update_prompt;
172+
#[cfg(any(not(debug_assertions), test))]
173+
mod update_versions;
170174
mod updates;
171175
mod version;
172176
#[cfg(not(target_os = "linux"))]

codex-rs/tui/src/npm_registry.rs

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
use serde::Deserialize;
2+
use std::collections::HashMap;
3+
4+
#[cfg(not(debug_assertions))]
5+
pub(crate) const PACKAGE_URL: &str = "https://registry.npmjs.org/@openai%2fcodex";
6+
const PACKAGE_NAME: &str = "@openai/codex";
7+
8+
#[derive(Deserialize, Debug, Clone)]
9+
pub(crate) struct NpmPackageInfo {
10+
#[serde(rename = "dist-tags")]
11+
dist_tags: HashMap<String, String>,
12+
versions: HashMap<String, NpmPackageVersionInfo>,
13+
}
14+
15+
#[derive(Deserialize, Debug, Clone)]
16+
struct NpmPackageVersionInfo {
17+
#[serde(default, rename = "optionalDependencies")]
18+
optional_dependencies: HashMap<String, String>,
19+
dist: Option<NpmPackageDist>,
20+
}
21+
22+
#[derive(Deserialize, Debug, Clone)]
23+
struct NpmPackageDist {
24+
tarball: Option<String>,
25+
integrity: Option<String>,
26+
}
27+
28+
pub(crate) fn ensure_version_ready(
29+
package_info: &NpmPackageInfo,
30+
version: &str,
31+
) -> anyhow::Result<()> {
32+
let version = version.trim();
33+
34+
match package_info.dist_tags.get("latest").map(String::as_str) {
35+
Some(latest) if latest == version => {}
36+
Some(latest) => anyhow::bail!(
37+
"npm latest dist-tag points to {latest}, expected GitHub release {version}"
38+
),
39+
None => anyhow::bail!("npm package is missing latest dist-tag"),
40+
}
41+
42+
let version_info = version_info_with_dist(package_info, version)?;
43+
let expected_dependency_prefix = format!("{version}-");
44+
let mut codex_optional_dependencies = 0usize;
45+
for (dependency_name, dependency) in &version_info.optional_dependencies {
46+
let Some(dependency_version) = dependency
47+
.strip_prefix("npm:")
48+
.and_then(|dependency| dependency.strip_prefix(PACKAGE_NAME))
49+
.and_then(|dependency| dependency.strip_prefix('@'))
50+
.map(str::trim)
51+
.filter(|dependency| !dependency.is_empty())
52+
else {
53+
continue;
54+
};
55+
codex_optional_dependencies += 1;
56+
57+
if !dependency_version.starts_with(&expected_dependency_prefix) {
58+
anyhow::bail!(
59+
"npm version {version} optional dependency {dependency_name} points to \
60+
{dependency_version}, expected version starting with {expected_dependency_prefix}"
61+
);
62+
}
63+
64+
if let Err(err) = version_info_with_dist(package_info, dependency_version) {
65+
anyhow::bail!(
66+
"npm version {version} optional dependency {dependency_name} points to \
67+
unavailable version {dependency_version}: {err}"
68+
);
69+
}
70+
}
71+
72+
if codex_optional_dependencies == 0 {
73+
anyhow::bail!("npm version {version} does not declare any codex optional dependencies");
74+
}
75+
76+
Ok(())
77+
}
78+
79+
fn version_info_with_dist<'a>(
80+
package_info: &'a NpmPackageInfo,
81+
version: &str,
82+
) -> anyhow::Result<&'a NpmPackageVersionInfo> {
83+
let info = package_info
84+
.versions
85+
.get(version)
86+
.ok_or_else(|| anyhow::anyhow!("npm package version {version} is missing"))?;
87+
let Some(dist) = info.dist.as_ref() else {
88+
anyhow::bail!("npm package version {version} is missing dist metadata");
89+
};
90+
let has_tarball = dist
91+
.tarball
92+
.as_deref()
93+
.is_some_and(|tarball| !tarball.is_empty());
94+
if !has_tarball {
95+
anyhow::bail!("npm package version {version} is missing dist.tarball");
96+
}
97+
let has_integrity = dist
98+
.integrity
99+
.as_ref()
100+
.is_some_and(|integrity| !integrity.is_empty());
101+
if !has_integrity {
102+
anyhow::bail!("npm package version {version} is missing dist.integrity");
103+
}
104+
Ok(info)
105+
}
106+
107+
#[cfg(test)]
108+
mod tests {
109+
use super::*;
110+
111+
fn version_json(version: &str) -> serde_json::Value {
112+
serde_json::json!({
113+
"dist": {
114+
"integrity": format!("sha512-{version}"),
115+
"tarball": format!("https://registry.npmjs.org/@openai/codex/-/codex-{version}.tgz"),
116+
}
117+
})
118+
}
119+
120+
fn package_info<'a>(
121+
github_latest: &str,
122+
npm_latest: &str,
123+
optional_dependencies: impl IntoIterator<Item = (&'a str, &'a str)>,
124+
extra_versions: impl IntoIterator<Item = &'a str>,
125+
) -> NpmPackageInfo {
126+
let mut versions = serde_json::Map::new();
127+
let mut root_version = version_json(github_latest);
128+
let optional_dependencies = optional_dependencies
129+
.into_iter()
130+
.map(|(name, dependency)| (name.to_string(), serde_json::json!(dependency)))
131+
.collect();
132+
root_version["optionalDependencies"] = serde_json::Value::Object(optional_dependencies);
133+
134+
versions.insert(github_latest.to_string(), root_version);
135+
for version in extra_versions {
136+
versions.insert(version.to_string(), version_json(version));
137+
}
138+
139+
serde_json::from_value(serde_json::json!({
140+
"dist-tags": { "latest": npm_latest },
141+
"versions": serde_json::Value::Object(versions),
142+
}))
143+
.expect("valid npm package metadata")
144+
}
145+
146+
#[test]
147+
fn ready_version_requires_latest_dist_tag_and_optional_dependencies() {
148+
let latest = "1.2.3";
149+
let package_info = package_info(
150+
latest,
151+
latest,
152+
[
153+
(
154+
"@openai/codex-darwin-arm64",
155+
"npm:@openai/codex@1.2.3-darwin-arm64",
156+
),
157+
(
158+
"@openai/codex-linux-x64",
159+
"npm:@openai/codex@1.2.3-linux-x64",
160+
),
161+
],
162+
["1.2.3-darwin-arm64", "1.2.3-linux-x64"],
163+
);
164+
165+
ensure_version_ready(&package_info, latest).expect("npm package is ready");
166+
}
167+
168+
#[test]
169+
fn ready_version_rejects_stale_latest_dist_tag() {
170+
let package_info = package_info(
171+
"1.2.3",
172+
"1.2.2",
173+
[(
174+
"@openai/codex-darwin-arm64",
175+
"npm:@openai/codex@1.2.3-darwin-arm64",
176+
)],
177+
["1.2.3-darwin-arm64"],
178+
);
179+
180+
let err = ensure_version_ready(&package_info, "1.2.3")
181+
.expect_err("npm latest dist-tag must match GitHub latest");
182+
assert!(
183+
err.to_string().contains("latest dist-tag"),
184+
"error should name stale latest dist-tag: {err}"
185+
);
186+
}
187+
188+
#[test]
189+
fn ready_version_rejects_missing_platform_version() {
190+
let latest = "1.2.3";
191+
let platform_version = "1.2.3-darwin-arm64";
192+
let package_info = package_info(
193+
latest,
194+
latest,
195+
[(
196+
"@openai/codex-darwin-arm64",
197+
"npm:@openai/codex@1.2.3-darwin-arm64",
198+
)],
199+
[],
200+
);
201+
202+
let err = ensure_version_ready(&package_info, latest)
203+
.expect_err("platform tarball must be published");
204+
assert!(
205+
err.to_string().contains(platform_version),
206+
"error should name missing platform version: {err}"
207+
);
208+
}
209+
210+
#[test]
211+
fn ready_version_rejects_dependency_for_different_release() {
212+
let latest = "1.2.3";
213+
let package_info = package_info(
214+
latest,
215+
latest,
216+
[(
217+
"@openai/codex-darwin-arm64",
218+
"npm:@openai/codex@1.2.2-darwin-arm64",
219+
)],
220+
["1.2.2-darwin-arm64"],
221+
);
222+
223+
let err = ensure_version_ready(&package_info, latest)
224+
.expect_err("platform dependency must match GitHub latest");
225+
assert!(
226+
err.to_string().contains("expected version starting"),
227+
"error should explain expected dependency version: {err}"
228+
);
229+
}
230+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
pub(crate) fn is_newer(latest: &str, current: &str) -> Option<bool> {
2+
match (parse_version(latest), parse_version(current)) {
3+
(Some(l), Some(c)) => Some(l > c),
4+
_ => None,
5+
}
6+
}
7+
8+
pub(crate) fn extract_version_from_latest_tag(latest_tag_name: &str) -> anyhow::Result<String> {
9+
latest_tag_name
10+
.strip_prefix("rust-v")
11+
.map(str::to_owned)
12+
.ok_or_else(|| anyhow::anyhow!("Failed to parse latest tag name '{latest_tag_name}'"))
13+
}
14+
15+
pub(crate) fn is_source_build_version(version: &str) -> bool {
16+
parse_version(version) == Some((0, 0, 0))
17+
}
18+
19+
fn parse_version(v: &str) -> Option<(u64, u64, u64)> {
20+
let mut iter = v.trim().split('.');
21+
let maj = iter.next()?.parse::<u64>().ok()?;
22+
let min = iter.next()?.parse::<u64>().ok()?;
23+
let pat = iter.next()?.parse::<u64>().ok()?;
24+
Some((maj, min, pat))
25+
}
26+
27+
#[cfg(test)]
28+
mod tests {
29+
use super::*;
30+
use pretty_assertions::assert_eq;
31+
32+
#[test]
33+
fn extracts_version_from_latest_tag() {
34+
assert_eq!(
35+
extract_version_from_latest_tag("rust-v1.5.0").expect("failed to parse version"),
36+
"1.5.0"
37+
);
38+
}
39+
40+
#[test]
41+
fn latest_tag_without_prefix_is_invalid() {
42+
assert!(extract_version_from_latest_tag("v1.5.0").is_err());
43+
}
44+
45+
#[test]
46+
fn prerelease_version_is_not_considered_newer() {
47+
assert_eq!(is_newer("0.11.0-beta.1", "0.11.0"), None);
48+
assert_eq!(is_newer("1.0.0-rc.1", "1.0.0"), None);
49+
}
50+
51+
#[test]
52+
fn plain_semver_comparisons_work() {
53+
assert_eq!(is_newer("0.11.1", "0.11.0"), Some(true));
54+
assert_eq!(is_newer("0.11.0", "0.11.1"), Some(false));
55+
assert_eq!(is_newer("1.0.0", "0.9.9"), Some(true));
56+
assert_eq!(is_newer("0.9.9", "1.0.0"), Some(false));
57+
}
58+
59+
#[test]
60+
fn source_build_version_is_not_checked() {
61+
assert!(is_source_build_version("0.0.0"));
62+
assert!(!is_source_build_version("0.1.0"));
63+
}
64+
65+
#[test]
66+
fn whitespace_is_ignored() {
67+
assert_eq!(parse_version(" 1.2.3 \n"), Some((1, 2, 3)));
68+
assert_eq!(is_newer(" 1.2.3 ", "1.2.2"), Some(true));
69+
}
70+
}

0 commit comments

Comments
 (0)