From 74e0161c21701fad2dab450cedecfedff8f9b8c3 Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Mon, 18 May 2026 12:21:56 -0700 Subject: [PATCH 01/64] Release 0.132.0-alpha.1 --- codex-rs/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index efc64577dd70..ff15aef2ca9e 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -117,7 +117,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.0.0" +version = "0.132.0-alpha.1" # Track the edition for all workspace crates in one place. Individual # crates can still override this value, but keeping it here means new # crates created with `cargo new -w ...` automatically inherit the 2024 From 13595c36e218fcbd13df118eeadf00d4eb0e6d31 Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Tue, 19 May 2026 16:22:37 -0700 Subject: [PATCH 02/64] ## New Features - The Python SDK now supports first-class authentication, including API key login, ChatGPT browser and device-code flows, account inspection, and logout APIs. (#23093) - Python turn APIs are easier to use for text-only workflows: you can pass a plain string as input, and handle-based runs now return a richer `TurnResult` with collected items, timing, and usage data. (#23151, #23162) - `codex exec resume` now accepts `--output-schema`, so resumed automations can keep session context while still enforcing structured JSON output. (#23123) - TUI startup is faster because terminal capability probes are now batched instead of waiting on several serial checks before the first interactive frame. (#23175) - Remote executor registration can now use standard Codex auth instead of a separate registry credential flow. (#22769) - App-server turns can preserve requested image fidelity, including original-resolution local images, across user inputs and image-producing tools. (#20693) ## Bug Fixes - Goal continuations now stop when they hit usage limits or a repeated blocker instead of looping and burning more tokens, and completion responses phrase usage more naturally. (#23094, #22907) - The session picker is easier to trust: renamed threads now show `name (thread-id)` in resume hints, and pasted text works in the picker search box. (#23234, #23338) - Multi-session TUI flows are more reliable: in-progress MCP calls stay marked as active during replay, and elicitation replies are sent back to the thread that requested them. (#23236, #23241) - Remote sessions now keep websocket connections alive and show repo-relative diff paths again instead of `/tmp/...`-prefixed paths. (#23226, #23261) - Windows installs are more robust: `codex doctor` now detects npm-managed installs correctly, and MSVC release binaries no longer depend on separately installed VC++ runtime DLLs. (#22967, #22905) - TUI polish fixes include immediate shutdown feedback on exit, hiding the ChatGPT usage link for non-OpenAI providers, and keeping a cleared Fast tier from reappearing after side-thread resume. (#23323, #23127, #23121) ## Documentation - The Python SDK docs, FAQ, and examples were refreshed around the new auth flow and turn APIs, with clearer setup guidance and simpler text-only examples. (#22941, #23093, #23151, #23162) ## Chores - Memory summaries are now versioned and rebuilt when the stored format is stale, which should keep long-lived memory context leaner and more predictable. (#23148) ## Changelog Full Changelog: https://github.com/openai/codex/compare/rust-v0.131.0...rust-v0.132.0 - #20693 Preserve image detail in app-server inputs @fjord-oai - #22891 tui: pass active permission profiles through app commands @bolinfest - #22924 app-server-protocol: remove PermissionProfile from API @bolinfest - #22941 [codex] Refine Python SDK user-facing docs @aibrahim-oai - #22967 Fix Windows doctor npm root probe @etraut-openai - #22920 core: set permission profiles from snapshots @bolinfest - #22939 [codex] Split Python SDK helper logic @aibrahim-oai - #22907 Improve goal completion usage reporting @etraut-openai - #23030 test: construct permission profiles directly @bolinfest - #22769 exec-server: support auth-backed remote executor registration @miz-openai - #22946 [codex] preserve MCP result meta in McpToolCallItemResult @miaolin-oai - #23069 multiagent: trim model-visible description, cap to 5 models @sayan-oai - #22913 [1 of 4] tui: route primary settings writes through app server @etraut-openai - #23093 sdk/python: add first-class login support @aibrahim-oai - #23151 [codex] Return TurnResult from Python turn handles @aibrahim-oai - #23147 Make multi-agent v2 tool namespace configurable @jif-oai - #23036 test: reduce core sandbox policy test setup @bolinfest - #23162 [codex] Accept string input for Python turns @aibrahim-oai - #23226 Add exec-server websocket keepalive @starr-openai - #23148 Densify and version memory summaries @jif-oai - #22448 [codex] Add installed-plugin mention API @xli-oai - #23288 chore: goal ext skeleton @jif-oai - #23291 Make extension lifecycle hooks async @jif-oai - #23293 feat: add extension event sink capability @jif-oai - #23295 chore: isolate thread goal storage behind GoalStore @jif-oai - #23301 chore: goal resumed metrics @jif-oai - #23305 chore: make token usage async @jif-oai - #23306 Emit goal update events from goal extension tools @jif-oai - #23121 tui: keep cleared Fast tier from reappearing after side-thread resume @etraut-openai - #23123 Support --output-schema for exec resume @etraut-openai - #23128 Fix TUI stream cleanup after turn errors @etraut-openai - #23127 Hide ChatGPT usage link for non-OpenAI status @etraut-openai - #23175 [1 of 2] Optimize TUI startup terminal probes @etraut-openai - #22706 [codex] Remove legacy shell output formatting paths @pakrym-oai - #23332 nit: read prompt @jif-oai - #22905 windows: link MSVC release binaries with static CRT @iceweasel-oai - #23323 fix(tui): show shutdown feedback on exit @fcoury-oai - #23261 Fix remote turn diff display roots @starr-openai - #22569 Simplify legacy Windows sandbox ACL persistence @iceweasel-oai - #23273 Upload rust full CI JUnit reports @starr-openai - #22893 fix: harden plugin creator sharing validation @efrazer-oai - #23094 goal: pause continuation loops on usage limits and blockers @etraut-openai - #23234 Clarify resume hints for renamed threads @etraut-openai - #23241 TUI: route elicitation responses to request thread @etraut-openai - #23236 TUI: replay in-progress MCP calls as started @etraut-openai - #23088 goals: keep pause transitions explicit @etraut-openai - #23338 feat(tui): handle paste in session picker @fcoury-oai - #23335 feat(app-server): add optional thread_id to experimentalFeature/list @owenlin0 --- codex-rs/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index efc64577dd70..5e0b16c4cb11 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -117,7 +117,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.0.0" +version = "0.132.0" # Track the edition for all workspace crates in one place. Individual # crates can still override this value, but keeping it here means new # crates created with `cargo new -w ...` automatically inherit the 2024 From 5fde5431ed3d7e7fbec58c65563c9e4f91f0f6b6 Mon Sep 17 00:00:00 2001 From: wallentx Date: Tue, 19 May 2026 21:51:31 -0500 Subject: [PATCH 03/64] Apply Termux compatibility patch --- codex-rs/Cargo.lock | 7 +- codex-rs/Cargo.toml | 4 +- codex-rs/arg0/Cargo.toml | 1 + codex-rs/arg0/src/lib.rs | 29 +++-- codex-rs/core/Cargo.toml | 5 + codex-rs/core/src/installation_id.rs | 8 +- codex-rs/execpolicy/Cargo.toml | 1 + codex-rs/execpolicy/src/amend.rs | 23 +++- codex-rs/utils/file-lock/BUILD.bazel | 6 + codex-rs/utils/file-lock/Cargo.toml | 10 ++ codex-rs/utils/file-lock/src/lib.rs | 168 +++++++++++++++++++++++++++ justfile | 5 + scripts/termux-lock-audit.sh | 126 ++++++++++++++++++++ 13 files changed, 377 insertions(+), 16 deletions(-) create mode 100644 codex-rs/utils/file-lock/BUILD.bazel create mode 100644 codex-rs/utils/file-lock/Cargo.toml create mode 100644 codex-rs/utils/file-lock/src/lib.rs create mode 100755 scripts/termux-lock-audit.sh diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 5ced1803fa4c..e6b21cb95465 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2123,6 +2123,7 @@ dependencies = [ "codex-sandboxing", "codex-shell-escalation", "codex-utils-absolute-path", + "codex-utils-file-lock", "codex-utils-home-dir", "dotenvy", "tempfile", @@ -2530,6 +2531,7 @@ dependencies = [ "codex-utils-absolute-path", "codex-utils-cache", "codex-utils-cargo-bin", + "codex-utils-file-lock", "codex-utils-home-dir", "codex-utils-image", "codex-utils-output-truncation", @@ -2786,6 +2788,7 @@ dependencies = [ "anyhow", "clap", "codex-utils-absolute-path", + "codex-utils-file-lock", "multimap", "pretty_assertions", "serde", @@ -13713,9 +13716,9 @@ dependencies = [ [[package]] name = "v8" -version = "146.4.0" +version = "146.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d97bcac5cdc5a195a4813f1855a6bc658f240452aac36caa12fd6c6f16026ab1" +checksum = "59b21777080203cb33828681f177e53402fa2927a1e2d17adead072b0c26b401" dependencies = [ "bindgen", "bitflags 2.10.0", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index ff15aef2ca9e..319f61b72be6 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -81,6 +81,7 @@ members = [ "v8-poc", "utils/absolute-path", "utils/cargo-bin", + "utils/file-lock", "git-utils", "utils/cache", "utils/image", @@ -219,6 +220,7 @@ codex-utils-cache = { path = "utils/cache" } codex-utils-cargo-bin = { path = "utils/cargo-bin" } codex-utils-cli = { path = "utils/cli" } codex-utils-elapsed = { path = "utils/elapsed" } +codex-utils-file-lock = { path = "utils/file-lock" } codex-utils-fuzzy-match = { path = "utils/fuzzy-match" } codex-utils-home-dir = { path = "utils/home-dir" } codex-utils-image = { path = "utils/image" } @@ -413,7 +415,7 @@ unicode-width = "0.2" url = "2" urlencoding = "2.1" uuid = "1" -v8 = "=146.4.0" +v8 = "=146.9.0" vt100 = "0.16.2" walkdir = "2.5.0" webbrowser = "1.0" diff --git a/codex-rs/arg0/Cargo.toml b/codex-rs/arg0/Cargo.toml index 7ee21a770e49..7c2c17692020 100644 --- a/codex-rs/arg0/Cargo.toml +++ b/codex-rs/arg0/Cargo.toml @@ -20,6 +20,7 @@ codex-linux-sandbox = { workspace = true } codex-sandboxing = { workspace = true } codex-shell-escalation = { workspace = true } codex-utils-absolute-path = { workspace = true } +codex-utils-file-lock = { workspace = true } codex-utils-home-dir = { workspace = true } dotenvy = { workspace = true } tempfile = { workspace = true } diff --git a/codex-rs/arg0/src/lib.rs b/codex-rs/arg0/src/lib.rs index 2f6ae4653c65..5a558769a7e5 100644 --- a/codex-rs/arg0/src/lib.rs +++ b/codex-rs/arg0/src/lib.rs @@ -6,6 +6,8 @@ use std::path::PathBuf; use codex_apply_patch::CODEX_CORE_APPLY_PATCH_ARG1; use codex_exec_server::CODEX_FS_HELPER_ARG1; use codex_sandboxing::landlock::CODEX_LINUX_SANDBOX_ARG0; +use codex_utils_file_lock::TryFileLockOutcome; +use codex_utils_file_lock::try_lock_exclusive_optional; use codex_utils_home_dir::find_codex_home; #[cfg(unix)] use std::os::unix::fs::symlink; @@ -33,12 +35,12 @@ pub struct Arg0DispatchPaths { /// Keeps the per-session PATH entry alive and locked for the process lifetime. pub struct Arg0PathEntryGuard { _temp_dir: TempDir, - _lock_file: File, + _lock_file: Option, paths: Arg0DispatchPaths, } impl Arg0PathEntryGuard { - fn new(temp_dir: TempDir, lock_file: File, paths: Arg0DispatchPaths) -> Self { + fn new(temp_dir: TempDir, lock_file: Option, paths: Arg0DispatchPaths) -> Self { Self { _temp_dir: temp_dir, _lock_file: lock_file, @@ -326,7 +328,13 @@ pub fn prepend_path_entry_for_codex_aliases() -> std::io::Result Some(lock_file), + TryFileLockOutcome::Unsupported => None, + TryFileLockOutcome::WouldBlock => { + return Err(std::io::Error::from(std::io::ErrorKind::WouldBlock).into()); + } + }; for filename in &[ APPLY_PATCH_ARG0, @@ -445,10 +453,9 @@ fn try_lock_dir(dir: &Path) -> std::io::Result> { Err(err) => return Err(err), }; - match lock_file.try_lock() { - Ok(()) => Ok(Some(lock_file)), - Err(std::fs::TryLockError::WouldBlock) => Ok(None), - Err(err) => Err(err.into()), + match try_lock_exclusive_optional(&lock_file)? { + TryFileLockOutcome::Acquired => Ok(Some(lock_file)), + TryFileLockOutcome::WouldBlock | TryFileLockOutcome::Unsupported => Ok(None), } } @@ -562,7 +569,13 @@ mod tests { let dir = root.path().join("locked"); fs::create_dir(&dir)?; let lock_file = create_lock(&dir)?; - lock_file.try_lock()?; + match try_lock_exclusive_optional(&lock_file)? { + TryFileLockOutcome::Acquired => {} + TryFileLockOutcome::Unsupported => return Ok(()), + TryFileLockOutcome::WouldBlock => { + panic!("newly created lock file should not be locked"); + } + } janitor_cleanup(root.path())?; diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 8472918dcae9..fdf1d4b18a9e 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -63,6 +63,7 @@ codex-thread-store = { workspace = true } codex-tools = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-cache = { workspace = true } +codex-utils-file-lock = { workspace = true } codex-utils-image = { workspace = true } codex-utils-home-dir = { workspace = true } codex-utils-output-truncation = { workspace = true } @@ -125,6 +126,10 @@ openssl-sys = { workspace = true, features = ["vendored"] } [target.aarch64-unknown-linux-musl.dependencies] openssl-sys = { workspace = true, features = ["vendored"] } +# Build OpenSSL from source for Android builds. +[target.aarch64-linux-android.dependencies] +openssl-sys = { workspace = true, features = ["vendored"] } + [target.'cfg(unix)'.dependencies] codex-shell-escalation = { workspace = true } diff --git a/codex-rs/core/src/installation_id.rs b/codex-rs/core/src/installation_id.rs index a42e6b6d8353..a87c660f6132 100644 --- a/codex-rs/core/src/installation_id.rs +++ b/codex-rs/core/src/installation_id.rs @@ -11,6 +11,9 @@ use std::os::unix::fs::OpenOptionsExt; use std::os::unix::fs::PermissionsExt; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_file_lock::FileLockOutcome; +use codex_utils_file_lock::acquire_sibling_lock_dir; +use codex_utils_file_lock::lock_exclusive_optional; use tokio::fs; use uuid::Uuid; @@ -29,7 +32,10 @@ pub async fn resolve_installation_id(codex_home: &AbsolutePathBuf) -> Result None, + FileLockOutcome::Unsupported => Some(acquire_sibling_lock_dir(&path)?), + }; #[cfg(unix)] { diff --git a/codex-rs/execpolicy/Cargo.toml b/codex-rs/execpolicy/Cargo.toml index b22226a79e4b..b87af084d7d1 100644 --- a/codex-rs/execpolicy/Cargo.toml +++ b/codex-rs/execpolicy/Cargo.toml @@ -21,6 +21,7 @@ workspace = true anyhow = { workspace = true } clap = { workspace = true, features = ["derive"] } codex-utils-absolute-path = { workspace = true } +codex-utils-file-lock = { workspace = true } multimap = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } diff --git a/codex-rs/execpolicy/src/amend.rs b/codex-rs/execpolicy/src/amend.rs index e25fd1bd9144..1ef36b1ff5d8 100644 --- a/codex-rs/execpolicy/src/amend.rs +++ b/codex-rs/execpolicy/src/amend.rs @@ -6,6 +6,10 @@ use std::io::Write; use std::path::Path; use std::path::PathBuf; +use codex_utils_file_lock::FileLockOutcome; +use codex_utils_file_lock::acquire_sibling_lock_dir; +use codex_utils_file_lock::lock_exclusive_optional; + use crate::decision::Decision; use crate::rule::NetworkRuleProtocol; use crate::rule::normalize_network_rule_host; @@ -154,10 +158,21 @@ fn append_locked_line(policy_path: &Path, line: &str) -> Result<(), AmendError> path: policy_path.to_path_buf(), source, })?; - file.lock().map_err(|source| AmendError::LockPolicyFile { - path: policy_path.to_path_buf(), - source, - })?; + let _lock_dir_guard = + match lock_exclusive_optional(&file).map_err(|source| AmendError::LockPolicyFile { + path: policy_path.to_path_buf(), + source, + })? { + FileLockOutcome::Acquired => None, + FileLockOutcome::Unsupported => { + Some(acquire_sibling_lock_dir(policy_path).map_err(|source| { + AmendError::LockPolicyFile { + path: policy_path.to_path_buf(), + source, + } + })?) + } + }; file.seek(SeekFrom::Start(0)) .map_err(|source| AmendError::SeekPolicyFile { diff --git a/codex-rs/utils/file-lock/BUILD.bazel b/codex-rs/utils/file-lock/BUILD.bazel new file mode 100644 index 000000000000..face70c53518 --- /dev/null +++ b/codex-rs/utils/file-lock/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "file-lock", + crate_name = "codex_utils_file_lock", +) diff --git a/codex-rs/utils/file-lock/Cargo.toml b/codex-rs/utils/file-lock/Cargo.toml new file mode 100644 index 000000000000..5e3877fc1630 --- /dev/null +++ b/codex-rs/utils/file-lock/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "codex-utils-file-lock" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] diff --git a/codex-rs/utils/file-lock/src/lib.rs b/codex-rs/utils/file-lock/src/lib.rs new file mode 100644 index 000000000000..fdcfbdb81e75 --- /dev/null +++ b/codex-rs/utils/file-lock/src/lib.rs @@ -0,0 +1,168 @@ +use std::fs::File; +use std::fs::create_dir; +use std::fs::remove_dir; +use std::io; +use std::path::Path; +use std::path::PathBuf; +use std::thread; +use std::time::Duration; + +const LOCK_DIR_RETRY_SLEEP: Duration = Duration::from_millis(100); + +/// Result of acquiring a blocking advisory file lock. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FileLockOutcome { + Acquired, + Unsupported, +} + +/// Result of attempting to acquire a non-blocking advisory file lock. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TryFileLockOutcome { + Acquired, + WouldBlock, + Unsupported, +} + +/// Result of attempting to acquire a non-blocking lock directory. +#[derive(Debug, PartialEq, Eq)] +pub enum TryLockDirOutcome { + Acquired(LockDirGuard), + WouldBlock, +} + +/// Guard for a sibling lock directory created with an atomic `mkdir`. +#[derive(Debug, PartialEq, Eq)] +pub struct LockDirGuard { + path: PathBuf, +} + +impl Drop for LockDirGuard { + fn drop(&mut self) { + let _ = remove_dir(&self.path); + } +} + +/// Acquires an exclusive advisory file lock, treating unsupported file locking +/// as a distinct outcome for platforms such as Termux. +pub fn lock_exclusive_optional(file: &File) -> io::Result { + match file.lock() { + Ok(()) => Ok(FileLockOutcome::Acquired), + Err(err) if err.kind() == io::ErrorKind::Unsupported => Ok(FileLockOutcome::Unsupported), + Err(err) => Err(err), + } +} + +/// Attempts to acquire an exclusive advisory file lock without blocking, +/// preserving `WouldBlock` and unsupported file locking as distinct outcomes. +pub fn try_lock_exclusive_optional(file: &File) -> io::Result { + match file.try_lock() { + Ok(()) => Ok(TryFileLockOutcome::Acquired), + Err(std::fs::TryLockError::WouldBlock) => Ok(TryFileLockOutcome::WouldBlock), + Err(std::fs::TryLockError::Error(err)) if err.kind() == io::ErrorKind::Unsupported => { + Ok(TryFileLockOutcome::Unsupported) + } + Err(std::fs::TryLockError::Error(err)) => Err(err), + } +} + +/// Returns the sibling directory path used as a fallback lock for `path`. +pub fn sibling_lock_dir(path: &Path) -> PathBuf { + let Some(file_name) = path.file_name() else { + return path.with_file_name(".lock"); + }; + + let mut lock_name = file_name.to_os_string(); + lock_name.push(".lock"); + path.with_file_name(lock_name) +} + +/// Acquires a sibling lock directory, blocking until it is available. +pub fn acquire_sibling_lock_dir(path: &Path) -> io::Result { + loop { + match try_acquire_sibling_lock_dir(path)? { + TryLockDirOutcome::Acquired(guard) => return Ok(guard), + TryLockDirOutcome::WouldBlock => thread::sleep(LOCK_DIR_RETRY_SLEEP), + } + } +} + +/// Attempts to acquire a sibling lock directory without blocking. +pub fn try_acquire_sibling_lock_dir(path: &Path) -> io::Result { + let lock_dir = sibling_lock_dir(path); + match create_dir(&lock_dir) { + Ok(()) => Ok(TryLockDirOutcome::Acquired(LockDirGuard { path: lock_dir })), + Err(err) if err.kind() == io::ErrorKind::AlreadyExists => Ok(TryLockDirOutcome::WouldBlock), + Err(err) => Err(err), + } +} + +/// Attempts to acquire a shared advisory file lock without blocking, +/// preserving `WouldBlock` and unsupported file locking as distinct outcomes. +pub fn try_lock_shared_optional(file: &File) -> io::Result { + match file.try_lock_shared() { + Ok(()) => Ok(TryFileLockOutcome::Acquired), + Err(std::fs::TryLockError::WouldBlock) => Ok(TryFileLockOutcome::WouldBlock), + Err(std::fs::TryLockError::Error(err)) if err.kind() == io::ErrorKind::Unsupported => { + Ok(TryFileLockOutcome::Unsupported) + } + Err(std::fs::TryLockError::Error(err)) => Err(err), + } +} + +#[cfg(test)] +mod tests { + use super::TryLockDirOutcome; + use super::sibling_lock_dir; + use super::try_acquire_sibling_lock_dir; + use std::fs; + use std::path::PathBuf; + use std::time::SystemTime; + + fn unique_temp_file_path(name: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos(); + std::env::temp_dir().join(format!( + "codex-file-lock-{name}-{}-{nanos}", + std::process::id() + )) + } + + #[test] + fn sibling_lock_dir_appends_lock_suffix() { + let path = PathBuf::from("/tmp/history.jsonl"); + + assert_eq!( + sibling_lock_dir(&path), + PathBuf::from("/tmp/history.jsonl.lock") + ); + } + + #[test] + fn try_acquire_sibling_lock_dir_is_exclusive_until_drop() { + let path = unique_temp_file_path("exclusive"); + let lock_dir = sibling_lock_dir(&path); + let _ = fs::remove_dir_all(&lock_dir); + + let guard = match try_acquire_sibling_lock_dir(&path).expect("acquire lock dir") { + TryLockDirOutcome::Acquired(guard) => guard, + TryLockDirOutcome::WouldBlock => panic!("first lock attempt should acquire"), + }; + assert!(lock_dir.is_dir()); + + assert!(matches!( + try_acquire_sibling_lock_dir(&path).expect("try acquire held lock dir"), + TryLockDirOutcome::WouldBlock + )); + + drop(guard); + assert!(!lock_dir.exists()); + + let reacquired = try_acquire_sibling_lock_dir(&path).expect("reacquire lock dir"); + assert!(matches!(reacquired, TryLockDirOutcome::Acquired(_))); + + let _ = fs::remove_dir_all(lock_dir); + } +} diff --git a/justfile b/justfile index ab2fbc63629a..569a04695216 100644 --- a/justfile +++ b/justfile @@ -116,6 +116,11 @@ argument-comment-lint *args: argument-comment-lint-from-source *args: {{ justfile_directory() }}/tools/argument-comment-lint/run.py "$@" +# Audit advisory file locks that may need Termux Unsupported handling. +[no-cd] +termux-lock-audit *args: + {{ justfile_directory() }}/scripts/termux-lock-audit.sh "$@" + # Tail logs from the state SQLite database log *args: if [ "${1:-}" = "--" ]; then shift; fi; cargo run -p codex-state --bin logs_client -- "$@" diff --git a/scripts/termux-lock-audit.sh b/scripts/termux-lock-audit.sh new file mode 100755 index 000000000000..ac40f8e57246 --- /dev/null +++ b/scripts/termux-lock-audit.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: scripts/termux-lock-audit.sh [--strict] + +Audits Rust advisory file-lock usage that may need Termux compatibility handling. + +By default this script prints findings and exits successfully. With --strict, it +exits non-zero when candidate file-lock calls are found in files that do not also +mention Unsupported/TryLockError handling. +USAGE +} + +strict=false +for arg in "$@"; do + case "$arg" in + --strict) + strict=true + ;; + -h | --help) + usage + exit 0 + ;; + *) + echo "unknown argument: $arg" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if ! command -v rg >/dev/null 2>&1; then + echo "error: rg is required for this audit" >&2 + exit 1 +fi + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repo_root" + +unsupported_pattern='std::fs::TryLockError|fs::TryLockError|TryLockError::|ErrorKind::Unsupported|std::io::ErrorKind::Unsupported' +lock_context_pattern='(\.lock\(\)|\.try_lock\(|\.try_lock_shared\(|TryLockError|ErrorKind::Unsupported)' +candidate_file_lock_pattern='\b([A-Za-z_][A-Za-z0-9_]*_file|file)\.(lock|try_lock|try_lock_shared)\(' +candidate_false_positive_pattern='poisoned' +helper_pattern='codex_utils_file_lock|FileLockOutcome|TryFileLockOutcome|lock_exclusive_optional|try_lock_exclusive_optional|try_lock_shared_optional' + +print_section() { + printf '\n== %s ==\n' "$1" +} + +print_section "Unsupported-aware lock handling" +echo "These files already mention Unsupported/TryLockError and are likely patched or intentionally reviewed:" + +unsupported_count=0 +while IFS= read -r file; do + if rg -q "$lock_context_pattern" "$file"; then + rg -n -H "$lock_context_pattern" "$file" + unsupported_count=$((unsupported_count + 1)) + fi +done < <(rg -l "$unsupported_pattern" codex-rs -g '*.rs' || true) + +if [ "$unsupported_count" -eq 0 ]; then + echo "No unsupported-aware lock handling found." +fi + +print_section "Optional file-lock helper usage" +echo "These files use the shared optional advisory file-lock helper:" + +helper_count=0 +while IFS= read -r file; do + rg -n -H "$helper_pattern" "$file" + helper_count=$((helper_count + 1)) +done < <(rg -l "$helper_pattern" codex-rs -g '*.rs' || true) + +if [ "$helper_count" -eq 0 ]; then + echo "No optional file-lock helper usage found." +fi + +print_section "Candidate file-lock calls for manual review" +echo "These receiver names may be std::fs::File advisory locks, but the file does not mention Unsupported/TryLockError handling." +echo "This section can include false positives, such as mutexes wrapping a file." + +review_count=0 +while IFS= read -r file; do + if ! rg -q "$unsupported_pattern" "$file"; then + matches="$(rg -n -H "$candidate_file_lock_pattern" "$file" | rg -v "$candidate_false_positive_pattern" || true)" + if [ -n "$matches" ]; then + echo "$matches" + review_count=$((review_count + 1)) + fi + fi +done < <(rg -l "$candidate_file_lock_pattern" codex-rs -g '*.rs' || true) + +if [ "$review_count" -eq 0 ]; then + echo "No unpatched candidate file-lock calls found." +fi + +print_section "Candidate file-lock calls already in unsupported-aware files" +echo "These are candidate file-lock calls in files that already mention Unsupported/TryLockError handling:" + +aware_candidate_count=0 +while IFS= read -r file; do + if rg -q "$unsupported_pattern" "$file"; then + matches="$(rg -n -H "$candidate_file_lock_pattern" "$file" | rg -v "$candidate_false_positive_pattern" || true)" + if [ -n "$matches" ]; then + echo "$matches" + aware_candidate_count=$((aware_candidate_count + 1)) + fi + fi +done < <(rg -l "$candidate_file_lock_pattern" codex-rs -g '*.rs' || true) + +if [ "$aware_candidate_count" -eq 0 ]; then + echo "No unsupported-aware candidate file-lock calls found." +fi + +print_section "Summary" +echo "unsupported-aware files: $unsupported_count" +echo "optional helper files: $helper_count" +echo "review candidate files: $review_count" +echo "unsupported-aware candidate files: $aware_candidate_count" + +if [ "$strict" = true ] && [ "$review_count" -gt 0 ]; then + echo "strict mode: manual-review candidate files were found" >&2 + exit 1 +fi From 285aa5aa1540f1fab74731c02b36636c36c3f48c Mon Sep 17 00:00:00 2001 From: wallentx Date: Tue, 12 May 2026 02:29:30 -0500 Subject: [PATCH 04/64] Disable realtime audio on Android builds (cherry picked from commit 337303c72c5c624386937c5f2aa9dc3a8dcfa2b4) --- codex-rs/tui/Cargo.toml | 2 +- codex-rs/tui/src/lib.rs | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index c213e92bae41..40624baba0d0 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -126,7 +126,7 @@ uuid = { workspace = true } codex-windows-sandbox = { workspace = true } tokio-util = { workspace = true, features = ["time"] } -[target.'cfg(not(target_os = "linux"))'.dependencies] +[target.'cfg(all(not(target_os = "linux"), not(target_os = "android")))'.dependencies] cpal = "0.15" [target.'cfg(unix)'.dependencies] diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 9ece5e3776f5..fbd1d7eb79bc 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -88,9 +88,9 @@ mod app_server_approval_conversions; mod app_server_session; mod approval_events; mod ascii_animation; -#[cfg(not(target_os = "linux"))] +#[cfg(not(any(target_os = "linux", target_os = "android")))] mod audio_device; -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "android"))] #[allow(dead_code)] mod audio_device { use crate::app_event::RealtimeAudioDeviceKind; @@ -194,11 +194,11 @@ mod update_prompt; mod update_versions; mod updates; mod version; -#[cfg(not(target_os = "linux"))] +#[cfg(not(any(target_os = "linux", target_os = "android")))] mod voice; mod width; mod workspace_command; -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "android"))] #[allow(dead_code)] mod voice { use crate::app_event_sender::AppEventSender; From 7ead295bb5d9e9f176a4cc50a983079ece10b132 Mon Sep 17 00:00:00 2001 From: wallentx Date: Tue, 19 May 2026 21:53:56 -0500 Subject: [PATCH 05/64] Update Termux v8 dependency --- codex-rs/Cargo.lock | 392 ++++++++++++++++++++++++-------------------- codex-rs/Cargo.toml | 2 +- 2 files changed, 211 insertions(+), 183 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index e6b21cb95465..a3b39903eefe 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -402,7 +402,7 @@ checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" [[package]] name = "app_test_support" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "base64 0.22.1", @@ -1518,9 +1518,9 @@ checksum = "ade8366b8bd5ba243f0a58f036cc0ca8a2f069cff1a2351ef1cac6b083e16fc0" [[package]] name = "calendrical_calculations" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a0b39595c6ee54a8d0900204ba4c401d0ab4eb45adaf07178e8d017541529e7" +checksum = "5abbd6eeda6885048d357edc66748eea6e0268e3dd11f326fff5bd248d779c26" dependencies = [ "core_maths", "displaydoc", @@ -1778,7 +1778,7 @@ dependencies = [ [[package]] name = "codex-agent-graph-store" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "codex-protocol", @@ -1793,7 +1793,7 @@ dependencies = [ [[package]] name = "codex-agent-identity" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "base64 0.22.1", @@ -1812,7 +1812,7 @@ dependencies = [ [[package]] name = "codex-analytics" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-app-server-protocol", "codex-git-utils", @@ -1832,7 +1832,7 @@ dependencies = [ [[package]] name = "codex-ansi-escape" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "ansi-to-tui", "ratatui", @@ -1841,7 +1841,7 @@ dependencies = [ [[package]] name = "codex-api" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "assert_matches", @@ -1875,7 +1875,7 @@ dependencies = [ [[package]] name = "codex-app-server" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "app_test_support", @@ -1960,7 +1960,7 @@ dependencies = [ [[package]] name = "codex-app-server-client" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-app-server", "codex-app-server-protocol", @@ -1987,7 +1987,7 @@ dependencies = [ [[package]] name = "codex-app-server-daemon" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "codex-app-server-protocol", @@ -2008,7 +2008,7 @@ dependencies = [ [[package]] name = "codex-app-server-protocol" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "clap", @@ -2035,7 +2035,7 @@ dependencies = [ [[package]] name = "codex-app-server-test-client" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "clap", @@ -2056,7 +2056,7 @@ dependencies = [ [[package]] name = "codex-app-server-transport" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "axum", @@ -2095,7 +2095,7 @@ dependencies = [ [[package]] name = "codex-apply-patch" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "assert_cmd", @@ -2114,7 +2114,7 @@ dependencies = [ [[package]] name = "codex-arg0" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "codex-apply-patch", @@ -2132,7 +2132,7 @@ dependencies = [ [[package]] name = "codex-async-utils" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "pretty_assertions", @@ -2142,7 +2142,7 @@ dependencies = [ [[package]] name = "codex-aws-auth" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "aws-config", "aws-credential-types", @@ -2157,7 +2157,7 @@ dependencies = [ [[package]] name = "codex-backend-client" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "codex-api", @@ -2174,7 +2174,7 @@ dependencies = [ [[package]] name = "codex-backend-openapi-models" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "serde", "serde_json", @@ -2183,7 +2183,7 @@ dependencies = [ [[package]] name = "codex-bwrap" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "cc", "libc", @@ -2192,7 +2192,7 @@ dependencies = [ [[package]] name = "codex-chatgpt" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "clap", @@ -2215,7 +2215,7 @@ dependencies = [ [[package]] name = "codex-cli" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "assert_cmd", @@ -2280,7 +2280,7 @@ dependencies = [ [[package]] name = "codex-client" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "bytes", @@ -2311,7 +2311,7 @@ dependencies = [ [[package]] name = "codex-cloud-requirements" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "base64 0.22.1", @@ -2336,7 +2336,7 @@ dependencies = [ [[package]] name = "codex-cloud-tasks" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "async-trait", @@ -2368,7 +2368,7 @@ dependencies = [ [[package]] name = "codex-cloud-tasks-client" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "async-trait", @@ -2383,7 +2383,7 @@ dependencies = [ [[package]] name = "codex-cloud-tasks-mock-client" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "chrono", @@ -2393,7 +2393,7 @@ dependencies = [ [[package]] name = "codex-code-mode" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-channel", "async-trait", @@ -2410,11 +2410,11 @@ dependencies = [ [[package]] name = "codex-collaboration-mode-templates" -version = "0.0.0" +version = "0.132.0-alpha.1" [[package]] name = "codex-config" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "async-trait", @@ -2461,7 +2461,7 @@ dependencies = [ [[package]] name = "codex-connectors" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "codex-app-server-protocol", @@ -2477,7 +2477,7 @@ dependencies = [ [[package]] name = "codex-core" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "arc-swap", @@ -2596,7 +2596,7 @@ dependencies = [ [[package]] name = "codex-core-api" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-analytics", "codex-app-server-protocol", @@ -2615,7 +2615,7 @@ dependencies = [ [[package]] name = "codex-core-plugins" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "chrono", @@ -2653,7 +2653,7 @@ dependencies = [ [[package]] name = "codex-core-skills" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "codex-analytics", @@ -2684,7 +2684,7 @@ dependencies = [ [[package]] name = "codex-debug-client" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "clap", @@ -2696,7 +2696,7 @@ dependencies = [ [[package]] name = "codex-exec" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "assert_cmd", @@ -2742,7 +2742,7 @@ dependencies = [ [[package]] name = "codex-exec-server" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "arc-swap", @@ -2783,7 +2783,7 @@ dependencies = [ [[package]] name = "codex-execpolicy" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "clap", @@ -2801,7 +2801,7 @@ dependencies = [ [[package]] name = "codex-execpolicy-legacy" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "allocative", "anyhow", @@ -2821,7 +2821,7 @@ dependencies = [ [[package]] name = "codex-experimental-api-macros" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "proc-macro2", "quote", @@ -2830,7 +2830,7 @@ dependencies = [ [[package]] name = "codex-extension-api" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "codex-protocol", @@ -2839,7 +2839,7 @@ dependencies = [ [[package]] name = "codex-external-agent-migration" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-hooks", "pretty_assertions", @@ -2851,7 +2851,7 @@ dependencies = [ [[package]] name = "codex-external-agent-sessions" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "chrono", "codex-app-server-protocol", @@ -2865,7 +2865,7 @@ dependencies = [ [[package]] name = "codex-features" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-otel", "codex-protocol", @@ -2878,7 +2878,7 @@ dependencies = [ [[package]] name = "codex-feedback" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "codex-login", @@ -2891,7 +2891,7 @@ dependencies = [ [[package]] name = "codex-file-search" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "clap", @@ -2907,7 +2907,7 @@ dependencies = [ [[package]] name = "codex-file-system" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "codex-protocol", @@ -2917,7 +2917,7 @@ dependencies = [ [[package]] name = "codex-file-watcher" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "notify", "pretty_assertions", @@ -2928,7 +2928,7 @@ dependencies = [ [[package]] name = "codex-git-utils" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "chrono", @@ -2952,7 +2952,7 @@ dependencies = [ [[package]] name = "codex-goal-extension" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "codex-extension-api", @@ -2964,7 +2964,7 @@ dependencies = [ [[package]] name = "codex-guardian" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "codex-core", @@ -2974,7 +2974,7 @@ dependencies = [ [[package]] name = "codex-hooks" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "chrono", @@ -2997,7 +2997,7 @@ dependencies = [ [[package]] name = "codex-install-context" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-utils-home-dir", "pretty_assertions", @@ -3006,7 +3006,7 @@ dependencies = [ [[package]] name = "codex-keyring-store" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "keyring", "tracing", @@ -3014,7 +3014,7 @@ dependencies = [ [[package]] name = "codex-linux-sandbox" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "clap", "codex-core", @@ -3037,7 +3037,7 @@ dependencies = [ [[package]] name = "codex-lmstudio" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-core", "codex-model-provider-info", @@ -3051,7 +3051,7 @@ dependencies = [ [[package]] name = "codex-login" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "async-trait", @@ -3093,7 +3093,7 @@ dependencies = [ [[package]] name = "codex-mcp" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "async-channel", @@ -3125,7 +3125,7 @@ dependencies = [ [[package]] name = "codex-mcp-server" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "codex-arg0", @@ -3157,7 +3157,7 @@ dependencies = [ [[package]] name = "codex-memories-extension" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "codex-core", @@ -3178,7 +3178,7 @@ dependencies = [ [[package]] name = "codex-memories-mcp" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "codex-utils-absolute-path", @@ -3195,7 +3195,7 @@ dependencies = [ [[package]] name = "codex-memories-read" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-protocol", "codex-shell-command", @@ -3209,7 +3209,7 @@ dependencies = [ [[package]] name = "codex-memories-write" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "chrono", @@ -3244,7 +3244,7 @@ dependencies = [ [[package]] name = "codex-message-history" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-config", "pretty_assertions", @@ -3257,7 +3257,7 @@ dependencies = [ [[package]] name = "codex-model-provider" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "codex-agent-identity", @@ -3281,7 +3281,7 @@ dependencies = [ [[package]] name = "codex-model-provider-info" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-api", "codex-app-server-protocol", @@ -3298,7 +3298,7 @@ dependencies = [ [[package]] name = "codex-models-manager" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "chrono", @@ -3319,7 +3319,7 @@ dependencies = [ [[package]] name = "codex-network-proxy" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "async-trait", @@ -3350,7 +3350,7 @@ dependencies = [ [[package]] name = "codex-ollama" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "assert_matches", "async-stream", @@ -3369,7 +3369,7 @@ dependencies = [ [[package]] name = "codex-otel" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "chrono", "codex-api", @@ -3401,7 +3401,7 @@ dependencies = [ [[package]] name = "codex-plugin" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-config", "codex-utils-absolute-path", @@ -3411,7 +3411,7 @@ dependencies = [ [[package]] name = "codex-process-hardening" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "libc", "pretty_assertions", @@ -3419,7 +3419,7 @@ dependencies = [ [[package]] name = "codex-protocol" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "chardetng", @@ -3459,7 +3459,7 @@ dependencies = [ [[package]] name = "codex-realtime-webrtc" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "libwebrtc", "thiserror 2.0.18", @@ -3468,7 +3468,7 @@ dependencies = [ [[package]] name = "codex-response-debug-context" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "base64 0.22.1", "codex-api", @@ -3479,7 +3479,7 @@ dependencies = [ [[package]] name = "codex-responses-api-proxy" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "clap", @@ -3496,7 +3496,7 @@ dependencies = [ [[package]] name = "codex-rmcp-client" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "axum", @@ -3534,7 +3534,7 @@ dependencies = [ [[package]] name = "codex-rollout" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "async-trait", @@ -3559,7 +3559,7 @@ dependencies = [ [[package]] name = "codex-rollout-trace" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "codex-code-mode", @@ -3575,7 +3575,7 @@ dependencies = [ [[package]] name = "codex-sandboxing" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "async-trait", @@ -3596,7 +3596,7 @@ dependencies = [ [[package]] name = "codex-secrets" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "age", "anyhow", @@ -3617,7 +3617,7 @@ dependencies = [ [[package]] name = "codex-shell-command" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "base64 0.22.1", @@ -3637,7 +3637,7 @@ dependencies = [ [[package]] name = "codex-shell-escalation" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "async-trait", @@ -3658,7 +3658,7 @@ dependencies = [ [[package]] name = "codex-skills" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-utils-absolute-path", "include_dir", @@ -3667,7 +3667,7 @@ dependencies = [ [[package]] name = "codex-state" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "chrono", @@ -3690,7 +3690,7 @@ dependencies = [ [[package]] name = "codex-stdio-to-uds" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "codex-uds", @@ -3702,7 +3702,7 @@ dependencies = [ [[package]] name = "codex-terminal-detection" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "pretty_assertions", "tracing", @@ -3710,7 +3710,7 @@ dependencies = [ [[package]] name = "codex-test-binary-support" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-arg0", "tempfile", @@ -3718,7 +3718,7 @@ dependencies = [ [[package]] name = "codex-thread-manager-sample" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "clap", @@ -3729,7 +3729,7 @@ dependencies = [ [[package]] name = "codex-thread-store" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "chrono", @@ -3750,7 +3750,7 @@ dependencies = [ [[package]] name = "codex-tools" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "codex-app-server-protocol", @@ -3770,7 +3770,7 @@ dependencies = [ [[package]] name = "codex-tui" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "arboard", @@ -3881,7 +3881,7 @@ dependencies = [ [[package]] name = "codex-uds" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-io", "pretty_assertions", @@ -3893,7 +3893,7 @@ dependencies = [ [[package]] name = "codex-utils-absolute-path" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "dirs", "dunce", @@ -3907,14 +3907,14 @@ dependencies = [ [[package]] name = "codex-utils-approval-presets" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-protocol", ] [[package]] name = "codex-utils-cache" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "lru 0.16.3", "sha1", @@ -3923,7 +3923,7 @@ dependencies = [ [[package]] name = "codex-utils-cargo-bin" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "assert_cmd", "runfiles", @@ -3932,7 +3932,7 @@ dependencies = [ [[package]] name = "codex-utils-cli" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "clap", "codex-protocol", @@ -3944,15 +3944,19 @@ dependencies = [ [[package]] name = "codex-utils-elapsed" -version = "0.0.0" +version = "0.132.0-alpha.1" + +[[package]] +name = "codex-utils-file-lock" +version = "0.132.0-alpha.1" [[package]] name = "codex-utils-fuzzy-match" -version = "0.0.0" +version = "0.132.0-alpha.1" [[package]] name = "codex-utils-home-dir" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-utils-absolute-path", "dirs", @@ -3962,7 +3966,7 @@ dependencies = [ [[package]] name = "codex-utils-image" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "base64 0.22.1", "codex-utils-cache", @@ -3974,7 +3978,7 @@ dependencies = [ [[package]] name = "codex-utils-json-to-toml" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "pretty_assertions", "serde_json", @@ -3983,7 +3987,7 @@ dependencies = [ [[package]] name = "codex-utils-oss" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-core", "codex-lmstudio", @@ -3993,7 +3997,7 @@ dependencies = [ [[package]] name = "codex-utils-output-truncation" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-protocol", "codex-utils-string", @@ -4002,7 +4006,7 @@ dependencies = [ [[package]] name = "codex-utils-path" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-utils-absolute-path", "dunce", @@ -4012,7 +4016,7 @@ dependencies = [ [[package]] name = "codex-utils-plugins" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-exec-server", "codex-login", @@ -4025,7 +4029,7 @@ dependencies = [ [[package]] name = "codex-utils-pty" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "filedescriptor", @@ -4041,7 +4045,7 @@ dependencies = [ [[package]] name = "codex-utils-readiness" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "assert_matches", "async-trait", @@ -4052,14 +4056,14 @@ dependencies = [ [[package]] name = "codex-utils-rustls-provider" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "rustls", ] [[package]] name = "codex-utils-sandbox-summary" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-core", "codex-model-provider-info", @@ -4070,7 +4074,7 @@ dependencies = [ [[package]] name = "codex-utils-sleep-inhibitor" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "core-foundation 0.9.4", "libc", @@ -4080,14 +4084,14 @@ dependencies = [ [[package]] name = "codex-utils-stream-parser" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "pretty_assertions", ] [[package]] name = "codex-utils-string" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "pretty_assertions", "regex-lite", @@ -4097,14 +4101,14 @@ dependencies = [ [[package]] name = "codex-utils-template" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "pretty_assertions", ] [[package]] name = "codex-v8-poc" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "pretty_assertions", "v8", @@ -4112,7 +4116,7 @@ dependencies = [ [[package]] name = "codex-windows-sandbox" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "base64 0.22.1", @@ -4363,7 +4367,7 @@ dependencies = [ [[package]] name = "core_test_support" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "assert_cmd", @@ -5101,9 +5105,9 @@ dependencies = [ [[package]] name = "diplomat" -version = "0.14.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9adb46b05e2f53dcf6a7dfc242e4ce9eb60c369b6b6eb10826a01e93167f59c6" +checksum = "7935649d00000f5c5d735448ad3dc07b9738160727017914cf42138b8e8e6611" dependencies = [ "diplomat_core", "proc-macro2", @@ -5113,15 +5117,15 @@ dependencies = [ [[package]] name = "diplomat-runtime" -version = "0.14.0" +version = "0.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0569bd3caaf13829da7ee4e83dbf9197a0e1ecd72772da6d08f0b4c9285c8d29" +checksum = "970ac38ad677632efcee6d517e783958da9bc78ec206d8d5e35b459ffc5e4864" [[package]] name = "diplomat_core" -version = "0.14.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51731530ed7f2d4495019abc7df3744f53338e69e2863a6a64ae91821c763df1" +checksum = "9cf41b94101a4bce993febaf0098092b0bb31deaf0ecaf6e0a2562465f61b383" dependencies = [ "proc-macro2", "quote", @@ -5477,7 +5481,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5665,9 +5669,9 @@ dependencies = [ [[package]] name = "fixed_decimal" -version = "0.7.1" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35eabf480f94d69182677e37571d3be065822acfafd12f2f085db44fbbcc8e57" +checksum = "79c3c892f121fff406e5dd6b28c1b30096b95111c30701a899d4f2b18da6d1bd" dependencies = [ "displaydoc", "smallvec", @@ -7510,9 +7514,9 @@ dependencies = [ [[package]] name = "icu_calendar" -version = "2.1.1" +version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f0e52e009b6b16ba9c0693578796f2dd4aaa59a7f8f920423706714a89ac4e" +checksum = "a2b2acc6263f494f1df50685b53ff8e57869e47d5c6fe39c23d518ae9a4f3e45" dependencies = [ "calendrical_calculations", "displaydoc", @@ -7526,18 +7530,19 @@ dependencies = [ [[package]] name = "icu_calendar_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "527f04223b17edfe0bd43baf14a0cb1b017830db65f3950dc00224860a9a446d" +checksum = "118577bcf3a0fa7c6ac0a7d6e951814da84ee56b9b1f68fb4d8d10b08cefaf4d" [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -7545,14 +7550,16 @@ dependencies = [ [[package]] name = "icu_decimal" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a38c52231bc348f9b982c1868a2af3195199623007ba2c7650f432038f5b3e8e" +checksum = "288247df2e32aa776ac54fdd64de552149ac43cb840f2761811f0e8d09719dd4" dependencies = [ + "displaydoc", "fixed_decimal", "icu_decimal_data", "icu_locale", "icu_locale_core", + "icu_plurals", "icu_provider", "writeable", "zerovec", @@ -7560,15 +7567,15 @@ dependencies = [ [[package]] name = "icu_decimal_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2905b4044eab2dd848fe84199f9195567b63ab3a93094711501363f63546fef7" +checksum = "6f14a5ca9e8af29eef62064f269078424283d90dbaffeac5225addf62aaabc22" [[package]] name = "icu_locale" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "532b11722e350ab6bf916ba6eb0efe3ee54b932666afec989465f9243fe6dd60" +checksum = "d5a396343c7208121dc86e35623d3dfe19814a7613cfd14964994cdc9c9a2e26" dependencies = [ "icu_collections", "icu_locale_core", @@ -7581,9 +7588,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -7595,15 +7602,15 @@ dependencies = [ [[package]] name = "icu_locale_data" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c5f1d16b4c3a2642d3a719f18f6b06070ab0aef246a6418130c955ae08aa831" +checksum = "d5fdcc9ac77c6d74ff5cf6e65ef3181d6af32003b16fce3a77fb451d2f695993" [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -7615,15 +7622,34 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_plurals" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a50023f1d49ad5c4333380328a0d4a19e4b9d6d842ec06639affd5ba47c8103" +dependencies = [ + "fixed_decimal", + "icu_locale", + "icu_plurals_data", + "icu_provider", + "zerovec", +] + +[[package]] +name = "icu_plurals_data" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "8485497155dc865f901decb93ecc20d3e467df67bfeceb91e3ba34e2b11e8e1d" [[package]] name = "icu_properties" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ "icu_collections", "icu_locale_core", @@ -7635,15 +7661,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", @@ -8540,7 +8566,7 @@ dependencies = [ [[package]] name = "mcp_test_support" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "codex-login", @@ -10898,9 +10924,9 @@ dependencies = [ [[package]] name = "resb" -version = "0.1.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a067ab3b5ca3b4dc307d0de9cf75f9f5e6ca9717b192b2f28a36c83e5de9e76" +checksum = "22d392791f3c6802a1905a509e9d1a6039cbbcb5e9e00e5a6d3661f7c874f390" dependencies = [ "potential_utf", "serde_core", @@ -12627,14 +12653,14 @@ dependencies = [ [[package]] name = "temporal_capi" -version = "0.1.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a151e402c2bdb6a3a2a2f3f225eddaead2e7ce7dd5d3fa2090deb11b17aa4ed8" +checksum = "8a2a1f001e756a9f5f2d175a9965c4c0b3a054f09f30de3a75ab49765f2deb36" dependencies = [ "diplomat", "diplomat-runtime", "icu_calendar", - "icu_locale", + "icu_locale_core", "num-traits", "temporal_rs", "timezone_provider", @@ -12644,13 +12670,14 @@ dependencies = [ [[package]] name = "temporal_rs" -version = "0.1.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88afde3bd75d2fc68d77a914bece426aa08aa7649ffd0cdd4a11c3d4d33474d1" +checksum = "9a902a45282e5175186b21d355efc92564601efe6e2d92818dc9e333d50bd4de" dependencies = [ + "calendrical_calculations", "core_maths", "icu_calendar", - "icu_locale", + "icu_locale_core", "ixdtf", "num-traits", "timezone_provider", @@ -12867,9 +12894,9 @@ dependencies = [ [[package]] name = "timezone_provider" -version = "0.1.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df9ba0000e9e73862f3e7ca1ff159e2ddf915c9d8bb11e38a7874760f445d993" +checksum = "c48f9b04628a2b813051e4dfe97c65281e49625eabd09ec343190e31e399a8c2" dependencies = [ "tinystr", "zerotrie", @@ -12900,9 +12927,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "serde_core", @@ -13716,9 +13743,9 @@ dependencies = [ [[package]] name = "v8" -version = "146.9.0" +version = "147.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59b21777080203cb33828681f177e53402fa2927a1e2d17adead072b0c26b401" +checksum = "2df8fffd507fb18ed000673a83d937f58e60fb07f3306b2274284125b15137cd" dependencies = [ "bindgen", "bitflags 2.10.0", @@ -14868,9 +14895,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -14879,9 +14906,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", @@ -15014,20 +15041,21 @@ dependencies = [ [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", "zerofrom", + "zerovec", ] [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "serde", "yoke", @@ -15037,9 +15065,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", @@ -15110,9 +15138,9 @@ checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445" [[package]] name = "zoneinfo64" -version = "0.2.1" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb2e5597efbe7c421da8a7fd396b20b571704e787c21a272eecf35dfe9d386f0" +checksum = "ed6eb2607e906160c457fd573e9297e65029669906b9ac8fb1b5cd5e055f0705" dependencies = [ "calendrical_calculations", "icu_locale_core", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 319f61b72be6..af11ae99099f 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -415,7 +415,7 @@ unicode-width = "0.2" url = "2" urlencoding = "2.1" uuid = "1" -v8 = "=146.9.0" +v8 = "=147.4.0" vt100 = "0.16.2" walkdir = "2.5.0" webbrowser = "1.0" From 14d5179319a2399e0140b45073ec55e6536d9953 Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Tue, 19 May 2026 20:16:15 -0700 Subject: [PATCH 06/64] Release 0.133.0-alpha.1 --- codex-rs/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index ec38a87cff97..91b73d562b5d 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -117,7 +117,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.0.0" +version = "0.133.0-alpha.1" # Track the edition for all workspace crates in one place. Individual # crates can still override this value, but keeping it here means new # crates created with `cargo new -w ...` automatically inherit the 2024 From 33bef1d7c0a719436652af6cf3afd607743042d5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 07:12:26 +0000 Subject: [PATCH 07/64] Seed Termux release automation --- .github/workflows/rust-release.yml | 1710 +++++++---------- .github/workflows/shell-tool-mcp.yml | 461 +++++ .../workflows/termux-release-checkpoint.yml | 103 + .github/workflows/termux-release-deploy.yml | 243 +++ .github/workflows/termux-release-promote.yml | 135 ++ scripts/termux-configure-git.sh | 40 + scripts/termux-create-checkpoint-pr.sh | 275 +++ ...ermux-create-or-update-mirrored-release.sh | 111 ++ scripts/termux-download-release-artifact.sh | 78 + scripts/termux-find-release-pr.sh | 53 + scripts/termux-read-release-metadata.sh | 77 + scripts/termux-release-asset-state.sh | 37 + scripts/termux-release-paths.sh | 55 + scripts/termux-resolve-release-ref.sh | 36 + scripts/termux-validate-gh-env.sh | 16 + 15 files changed, 2387 insertions(+), 1043 deletions(-) create mode 100644 .github/workflows/shell-tool-mcp.yml create mode 100644 .github/workflows/termux-release-checkpoint.yml create mode 100644 .github/workflows/termux-release-deploy.yml create mode 100644 .github/workflows/termux-release-promote.yml create mode 100755 scripts/termux-configure-git.sh create mode 100755 scripts/termux-create-checkpoint-pr.sh create mode 100755 scripts/termux-create-or-update-mirrored-release.sh create mode 100755 scripts/termux-download-release-artifact.sh create mode 100755 scripts/termux-find-release-pr.sh create mode 100755 scripts/termux-read-release-metadata.sh create mode 100755 scripts/termux-release-asset-state.sh create mode 100755 scripts/termux-release-paths.sh create mode 100755 scripts/termux-resolve-release-ref.sh create mode 100755 scripts/termux-validate-gh-env.sh diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index b6c293d6cdc2..248da7df3b7c 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -4,46 +4,49 @@ # git tag -a rust-v0.1.0 -m "Release 0.1.0" # git push origin rust-v0.1.0 # ``` -# -# To use external macOS signing, manually dispatch `release_mode=build_unsigned`, -# sign the unsigned macOS artifacts in a secure enclave, upload the signed handoff -# archive as a GitHub Release asset, then manually dispatch -# `release_mode=promote_signed` with `unsigned_run_id` and `signed_macos_asset`. -# The signed handoff archive should contain target or artifact directories such -# as `aarch64-apple-darwin/` with signed binaries. name: rust-release on: - push: - tags: - - "rust-v*.*.*" + pull_request: + branches: + - "release/**" workflow_dispatch: inputs: - release_mode: - description: "build_unsigned creates unsigned macOS handoff artifacts; promote_signed finishes a release from signed macOS handoff artifacts." + source_ref: + description: "Branch, tag, or commit SHA to build (code only; keeps .github from dispatched ref)" required: false + type: string + default: "" + build_target: + description: "Target to build" + required: true type: choice - default: build_unsigned + default: all options: - - build_unsigned - - promote_signed - sign_macos: - description: "Deprecated compatibility input; use release_mode instead." - required: false - type: boolean - default: false - unsigned_run_id: - description: "For promote_signed: workflow run id from the build_unsigned run." - required: false - type: string - signed_macos_asset: - description: "For promote_signed: exact GitHub Release asset name containing signed macOS handoff artifacts." - required: false - type: string - signed_macos_sha256: - description: "For promote_signed: optional SHA-256 of signed_macos_asset." - required: false - type: string + - all + - aarch64-apple-darwin + - x86_64-apple-darwin + - x86_64-unknown-linux-musl + - x86_64-unknown-linux-gnu + - aarch64-unknown-linux-musl + - aarch64-unknown-linux-gnu + - aarch64-linux-android + - x86_64-pc-windows-msvc + - aarch64-pc-windows-msvc + +permissions: + actions: read + attestations: read + checks: read + contents: read + deployments: read + issues: read + discussions: read + packages: read + pages: read + pull-requests: read + repository-projects: read + statuses: read concurrency: group: ${{ github.workflow }} @@ -51,72 +54,26 @@ concurrency: jobs: tag-check: + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: actions/checkout@v6 + - name: 🧰 Actions Toolbox + # This is required for the GitHub CLI + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: wallentx/gh-actions/composite/actions-toolbox@main + - uses: dtolnay/rust-toolchain@1.92 - name: Validate tag matches Cargo.toml version shell: bash - env: - RELEASE_MODE: ${{ github.event_name == 'workflow_dispatch' && inputs.release_mode || 'signed' }} - REQUESTED_SIGN_MACOS: ${{ inputs.sign_macos }} - SIGNED_MACOS_ASSET: ${{ inputs.signed_macos_asset }} - UNSIGNED_RUN_ID: ${{ inputs.unsigned_run_id }} run: | set -euo pipefail echo "::group::Tag validation" - case "${RELEASE_MODE}" in - signed) - if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then - echo "❌ Manual rust-release runs must use release_mode=build_unsigned or release_mode=promote_signed" - exit 1 - fi - ;; - build_unsigned) - if [[ "${GITHUB_EVENT_NAME}" != "workflow_dispatch" ]]; then - echo "❌ release_mode=build_unsigned is only valid for manual runs" - exit 1 - fi - ;; - promote_signed) - if [[ "${GITHUB_EVENT_NAME}" != "workflow_dispatch" ]]; then - echo "❌ release_mode=promote_signed is only valid for manual runs" - exit 1 - fi - if [[ ! "${UNSIGNED_RUN_ID}" =~ ^[0-9]+$ ]]; then - echo "❌ release_mode=promote_signed requires unsigned_run_id to be a workflow run id" - exit 1 - fi - if [[ -z "${SIGNED_MACOS_ASSET}" ]]; then - echo "❌ release_mode=promote_signed requires signed_macos_asset" - exit 1 - fi - if [[ "${SIGNED_MACOS_ASSET}" == */* || "${SIGNED_MACOS_ASSET}" == *"*"* || "${SIGNED_MACOS_ASSET}" == *"?"* || "${SIGNED_MACOS_ASSET}" == *"["* ]]; then - echo "❌ signed_macos_asset must be an exact release asset name, not a path or glob" - exit 1 - fi - if [[ "${UNSIGNED_RUN_ID}" == "${GITHUB_RUN_ID}" ]]; then - echo "❌ unsigned_run_id must refer to the earlier build_unsigned run, not this run" - exit 1 - fi - ;; - *) - echo "❌ Unknown release_mode '${RELEASE_MODE}'" - exit 1 - ;; - esac - - if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${REQUESTED_SIGN_MACOS}" == "true" ]]; then - echo "::warning title=Deprecated sign_macos input ignored::Use release_mode=build_unsigned or release_mode=promote_signed instead." - fi - # 1. Must be a tag and match the regex [[ "${GITHUB_REF_TYPE}" == "tag" ]] \ || { echo "❌ Not a tag push"; exit 1; } - [[ "${GITHUB_REF_NAME}" =~ ^rust-v[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta)(\.[0-9]+)?)?$ ]] \ + [[ "${GITHUB_REF_NAME}" =~ ^rust-v[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta)(\.[0-9]+)?)?(-termux)?$ ]] \ || { echo "❌ Tag '${GITHUB_REF_NAME}' doesn't match expected format"; exit 1; } # 2. Extract versions @@ -131,115 +88,198 @@ jobs: echo "✅ Tag and Cargo.toml agree (${tag_ver})" echo "::endgroup::" - build: - if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} + select-build-matrix: + if: always() && (github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' || needs.tag-check.result == 'success') needs: tag-check - name: Build - ${{ matrix.runner }} - ${{ matrix.target }} - ${{ matrix.bundle }} - runs-on: ${{ matrix.runs_on || matrix.runner }} - # Release builds can take a long time, so leave some headroom to avoid - # having to restart the full workflow due to a timeout. - timeout-minutes: 90 - permissions: - contents: read - id-token: write + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.select.outputs.matrix }} + build_version: ${{ steps.select.outputs.build_version }} + steps: + - uses: actions/checkout@v6 + - name: 🧰 Actions Toolbox + # This is required for the GitHub CLI + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: wallentx/gh-actions/composite/actions-toolbox@main + - id: select + shell: bash + env: + REPO_OWNER: ${{ github.repository_owner }} + EVENT_NAME: ${{ github.event_name }} + BUILD_TARGET: ${{ inputs.build_target }} + run: | + set -euo pipefail + + matrix='[ + {"runner":"macos-15-xlarge","target":"aarch64-apple-darwin"}, + {"runner":"macos-15-xlarge","target":"x86_64-apple-darwin"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-gnu"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-gnu"}, + {"runner":"ubuntu-24.04","target":"aarch64-linux-android"}, + {"runner":"windows-latest","target":"x86_64-pc-windows-msvc"}, + {"runner":"windows-11-arm","target":"aarch64-pc-windows-msvc"} + ]' + + if [[ "${REPO_OWNER}" != "openai" ]]; then + matrix="$(jq -c '[.[] | select(.runner | startswith("macos") | not)]' <<< "${matrix}")" + fi + + if [[ "${EVENT_NAME}" == "pull_request" ]]; then + matrix="$(jq -c '[.[] | select(.target == "aarch64-linux-android")]' <<< "${matrix}")" + elif [[ "${EVENT_NAME}" == "workflow_dispatch" && -n "${BUILD_TARGET}" && "${BUILD_TARGET}" != "all" ]]; then + matrix="$(jq -c --arg build_target "${BUILD_TARGET}" '[.[] | select(.target == $build_target)]' <<< "${matrix}")" + fi + + if [[ "$(jq 'length' <<< "${matrix}")" -eq 0 ]]; then + echo "No build targets selected after applying owner/dispatch filters." >&2 + exit 1 + fi + + echo "matrix={\"include\":${matrix}}" >> "$GITHUB_OUTPUT" + + if [[ "${EVENT_NAME}" == "pull_request" ]]; then + metadata=".github/termux-release.json" + if [[ ! -f "${metadata}" ]]; then + echo "${metadata} is required for release train PR builds" >&2 + exit 1 + fi + upstream_tag="$(jq -r '.upstream_tag // empty' "${metadata}")" + if [[ -z "${upstream_tag}" || "${upstream_tag}" != rust-v* ]]; then + echo "Unable to determine upstream rust tag from ${metadata}" >&2 + exit 1 + fi + build_version="${upstream_tag#rust-v}" + echo "build_version=${build_version}" >> "$GITHUB_OUTPUT" + elif [[ "${EVENT_NAME}" == "workflow_dispatch" ]]; then + latest_release_tag="$( + GH_TOKEN="${{ github.token }}" gh api repos/openai/codex/releases/latest --jq '.tag_name' + )" + + if [[ -z "${latest_release_tag}" || "${latest_release_tag}" != rust-v* ]]; then + echo "Unable to determine latest stable release tag from openai/codex" >&2 + exit 1 + fi + + latest_release_version="${latest_release_tag#rust-v}" + build_version="${latest_release_version}-dev" + echo "build_version=${build_version}" >> "$GITHUB_OUTPUT" + fi + + build: + if: >- + always() && + (github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' || needs.tag-check.result == 'success') && + needs.select-build-matrix.result == 'success' + needs: + - tag-check + - select-build-matrix + name: Build - ${{ matrix.runner }} - ${{ matrix.target }} + runs-on: ${{ matrix.runner }} + permissions: write-all + timeout-minutes: 120 defaults: run: working-directory: codex-rs env: - # 2026-03-04: temporarily change releases to use thin LTO because - # Ubuntu ARM is timing out at 60 minutes. - CARGO_PROFILE_RELEASE_LTO: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'thin' }} - SIGN_MACOS: ${{ github.event_name != 'workflow_dispatch' }} + CODEX_BWRAP_ENABLE_FFI: ${{ contains(matrix.target, 'unknown-linux') && '1' || '0' }} + CODEX_BUILD_VERSION: ${{ needs.select-build-matrix.outputs.build_version }} + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" strategy: fail-fast: false - matrix: - include: - - runner: macos-15-xlarge - target: aarch64-apple-darwin - bundle: primary - artifact_name: aarch64-apple-darwin - binaries: "codex codex-responses-api-proxy" - build_dmg: "true" - - runner: macos-15-xlarge - target: aarch64-apple-darwin - bundle: app-server - artifact_name: aarch64-apple-darwin-app-server - binaries: "codex-app-server" - build_dmg: "false" - - runner: macos-15-xlarge - target: x86_64-apple-darwin - bundle: primary - artifact_name: x86_64-apple-darwin - binaries: "codex codex-responses-api-proxy" - build_dmg: "true" - - runner: macos-15-xlarge - target: x86_64-apple-darwin - bundle: app-server - artifact_name: x86_64-apple-darwin-app-server - binaries: "codex-app-server" - build_dmg: "false" - # Release artifacts intentionally ship MUSL-linked Linux binaries. - - runner: ubuntu-24.04 - target: x86_64-unknown-linux-musl - bundle: primary - artifact_name: x86_64-unknown-linux-musl - binaries: "codex codex-responses-api-proxy bwrap" - build_dmg: "false" - - runner: ubuntu-24.04 - target: x86_64-unknown-linux-musl - bundle: app-server - artifact_name: x86_64-unknown-linux-musl-app-server - binaries: "codex-app-server" - build_dmg: "false" - - runner: ubuntu-24.04-arm - target: aarch64-unknown-linux-musl - bundle: primary - artifact_name: aarch64-unknown-linux-musl - binaries: "codex codex-responses-api-proxy bwrap" - build_dmg: "false" - - runner: ubuntu-24.04-arm - target: aarch64-unknown-linux-musl - bundle: app-server - artifact_name: aarch64-unknown-linux-musl-app-server - binaries: "codex-app-server" - build_dmg: "false" + matrix: ${{ fromJson(needs.select-build-matrix.outputs.matrix) }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@v6 + - name: 🧰 Actions Toolbox + # This is required for the GitHub CLI + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: wallentx/gh-actions/composite/actions-toolbox@main with: - persist-credentials: false - - name: Print runner specs (Linux) - if: ${{ runner.os == 'Linux' }} + verbose: true + - name: Checkout selected source ref + if: ${{ github.event_name == 'workflow_dispatch' && inputs.source_ref != '' }} + uses: actions/checkout@v6 + with: + ref: ${{ inputs.source_ref }} + path: __source__ + fetch-depth: 1 + - name: Overlay selected source ref (excluding .github) + if: ${{ github.event_name == 'workflow_dispatch' && inputs.source_ref != '' }} shell: bash + working-directory: ${{ github.workspace }} run: | set -euo pipefail - cpu_model="$(lscpu | awk -F: '/Model name/ {gsub(/^[ \t]+/, "", $2); print $2; exit}')" - total_ram="$(awk '/MemTotal/ {printf "%.1f GiB\n", $2 / 1024 / 1024}' /proc/meminfo)" - echo "Runner: ${RUNNER_NAME:-unknown}" - echo "OS: $(uname -a)" - echo "CPU model: ${cpu_model}" - echo "Logical CPUs: $(nproc)" - echo "Total RAM: ${total_ram}" - echo "Disk usage:" - df -h . - - name: Print runner specs (macOS) - if: ${{ runner.os == 'macOS' }} + echo "Using source ref: ${{ inputs.source_ref }}" + + # Keep workflow/actions from the dispatch ref while replacing all other files. + find . -mindepth 1 -maxdepth 1 \ + ! -name .git \ + ! -name .github \ + ! -name __source__ \ + -exec rm -rf -- {} + + tar --exclude='.github' -C __source__ -cf - . | tar -xf - + rm -rf __source__ + + - name: Apply selected build version + if: ${{ github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' }} shell: bash + working-directory: ${{ github.workspace }} run: | set -euo pipefail - total_ram="$(sysctl -n hw.memsize | awk '{printf "%.1f GiB\n", $1 / 1024 / 1024 / 1024}')" - echo "Runner: ${RUNNER_NAME:-unknown}" - echo "OS: $(sw_vers -productName) $(sw_vers -productVersion)" - echo "Hardware model: $(sysctl -n hw.model)" - echo "CPU architecture: $(uname -m)" - echo "Logical CPUs: $(sysctl -n hw.logicalcpu)" - echo "Physical CPUs: $(sysctl -n hw.physicalcpu)" - echo "Total RAM: ${total_ram}" - echo "Disk usage:" - df -h . + if [[ -z "${CODEX_BUILD_VERSION}" ]]; then + echo "CODEX_BUILD_VERSION is empty for workflow_dispatch" >&2 + exit 1 + fi + echo "Using build version: ${CODEX_BUILD_VERSION}" + BUILD_VERSION="${CODEX_BUILD_VERSION}" perl -0777 -i -pe \ + 's/(\[workspace\.package\][^\[]*?version = ")([^"]+)(")/${1}$ENV{BUILD_VERSION}$3/s' \ + codex-rs/Cargo.toml + + grep -n "^\[workspace.package\]\|^version = " codex-rs/Cargo.toml | head -n 4 + + - name: Select Android NDK + if: ${{ matrix.target == 'aarch64-linux-android' }} + shell: bash + run: | + set -euo pipefail + + fallback_version="29.0.13113456" + ndk_root="${ANDROID_HOME}/ndk" + selected_ndk="" + + if [[ -d "${ndk_root}" ]]; then + while IFS= read -r candidate; do + toolchain="${candidate}/toolchains/llvm/prebuilt/linux-x86_64" + if [[ -x "${toolchain}/bin/aarch64-linux-android24-clang" ]]; then + selected_ndk="${candidate}" + break + fi + done < <(find "${ndk_root}" -mindepth 1 -maxdepth 1 -type d | sort -Vr) + fi + + if [[ -z "${selected_ndk}" ]]; then + echo "No usable preinstalled Android NDK found; installing ${fallback_version}" + yes | sudo "${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager" --licenses || true + sudo "${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager" "ndk;${fallback_version}" + selected_ndk="${ndk_root}/${fallback_version}" + fi + + if [[ ! -x "${selected_ndk}/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android24-clang" ]]; then + echo "Selected Android NDK does not contain a usable aarch64 API 24 clang: ${selected_ndk}" >&2 + exit 1 + fi + + echo "Selected Android NDK: ${selected_ndk}" + "${selected_ndk}/toolchains/llvm/prebuilt/linux-x86_64/bin/clang" --version + echo "ANDROID_NDK_HOME=${selected_ndk}" >> "$GITHUB_ENV" + echo "NDK_HOME=${selected_ndk}" >> "$GITHUB_ENV" - name: Install Linux bwrap build dependencies - if: ${{ runner.os == 'Linux' }} + if: ${{ runner.os == 'Linux' && matrix.target != 'aarch64-linux-android' }} shell: bash run: | set -euo pipefail @@ -254,9 +294,173 @@ jobs: sudo apt-get update -y sudo DEBIAN_FRONTEND=noninteractive apt-get install -y libubsan1 fi - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: dtolnay/rust-toolchain@1.93 with: targets: ${{ matrix.target }} + - name: Set up sccache (Android) + if: ${{ matrix.target == 'aarch64-linux-android' }} + uses: mozilla-actions/sccache-action@main + - name: Enable sccache (Android) + if: ${{ matrix.target == 'aarch64-linux-android' }} + shell: bash + run: | + set -euo pipefail + echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV" + echo "SCCACHE_GHA_ENABLED=true" >> "$GITHUB_ENV" + echo "SCCACHE_GHA_VERSION=android-release-${{ matrix.target }}" >> "$GITHUB_ENV" + echo "SCCACHE_CACHE_SIZE=5G" >> "$GITHUB_ENV" + - name: Ensure Rust target is installed + shell: bash + run: | + set -euo pipefail + rustup target add "${{ matrix.target }}" + - if: ${{ matrix.target == 'aarch64-linux-android' }} + name: Configure Android build environment + shell: bash + run: | + set -euo pipefail + ndk="${ANDROID_NDK_HOME}" + target="${{ matrix.target }}" + + # Set up the Android toolchain + toolchain="${ndk}/toolchains/llvm/prebuilt/linux-x86_64" + + # Use API level 24 to follow termux-build convention + api_level="24" + + # Configure Cargo to use the NDK linker + cargo_target_var="CARGO_TARGET_${target^^}_LINKER" + cargo_target_var="${cargo_target_var//-/_}" + echo "${cargo_target_var}=${toolchain}/bin/aarch64-linux-android${api_level}-clang" >> "$GITHUB_ENV" + + # Set CC and AR for the target + target_cc_var="CC_${target}" + target_cc_var="${target_cc_var//-/_}" + echo "${target_cc_var}=${toolchain}/bin/aarch64-linux-android${api_level}-clang" >> "$GITHUB_ENV" + + target_cxx_var="CXX_${target}" + target_cxx_var="${target_cxx_var//-/_}" + echo "${target_cxx_var}=${toolchain}/bin/aarch64-linux-android${api_level}-clang++" >> "$GITHUB_ENV" + + target_ar_var="AR_${target}" + target_ar_var="${target_ar_var//-/_}" + echo "${target_ar_var}=${toolchain}/bin/llvm-ar" >> "$GITHUB_ENV" + + # Add toolchain to PATH + echo "${toolchain}/bin" >> "$GITHUB_PATH" + + tls_align_source="${RUNNER_TEMP}/codex-android-tls-align.S" + tls_align_object="${RUNNER_TEMP}/codex-android-tls-align.o" + cat > "${tls_align_source}" <<'EOF' + .section .tdata.codex_android_tls_align,"awT",%progbits + .p2align 6 + .globl __codex_android_tls_align + .hidden __codex_android_tls_align + .type __codex_android_tls_align, %object + __codex_android_tls_align: + .byte 0 + .size __codex_android_tls_align, 1 + EOF + "${toolchain}/bin/aarch64-linux-android${api_level}-clang" -c "${tls_align_source}" -o "${tls_align_object}" + + v8_compat_source="${RUNNER_TEMP}/codex-android-v8-compat.c" + v8_compat_object="${RUNNER_TEMP}/codex-android-v8-compat.o" + cat > "${v8_compat_source}" <<'EOF' + #include + #include + + extern int posix_memalign(void **memptr, size_t alignment, size_t size); + extern double strtod(const char *nptr, char **endptr); + extern float strtof(const char *nptr, char **endptr); + + void *aligned_alloc(size_t alignment, size_t size) { + void *ptr = 0; + if (posix_memalign(&ptr, alignment, size) != 0) { + return 0; + } + return ptr; + } + + double strtod_l(const char *nptr, char **endptr, locale_t locale) { + (void)locale; + return strtod(nptr, endptr); + } + + float strtof_l(const char *nptr, char **endptr, locale_t locale) { + (void)locale; + return strtof(nptr, endptr); + } + EOF + "${toolchain}/bin/aarch64-linux-android${api_level}-clang" -c "${v8_compat_source}" -o "${v8_compat_object}" + + builtins_archive="$( + find "${toolchain}/lib/clang" \ + -path "*/lib/linux/libclang_rt.builtins-aarch64-android.a" \ + -print -quit + )" + if [[ -z "${builtins_archive}" ]]; then + echo "Could not find Android compiler-rt builtins archive under ${toolchain}/lib/clang" >&2 + exit 1 + fi + builtins_dir="$(dirname "${builtins_archive}")" + + # Provide compatibility symbols and C++ ABI needed by the Android rusty_v8 archive. + rustflags_var="CARGO_TARGET_${target^^}_RUSTFLAGS" + rustflags_var="${rustflags_var//-/_}" + echo "${rustflags_var}=-C link-arg=${tls_align_object} -C link-arg=${v8_compat_object} -C link-arg=-Wl,-u,__codex_android_tls_align -C link-arg=-L${builtins_dir} -C link-arg=-lclang_rt.builtins-aarch64-android -C link-arg=-lc++abi -C link-arg=-Wl,-z,max-page-size=65536" >> "$GITHUB_ENV" + + + # Configure for vendored OpenSSL build + echo "CARGO_BUILD_TARGET_APPLIES_TO_HOST=false" >> "$GITHUB_ENV" + echo "CARGO_BUILD_TARGET=${{ matrix.target }}" >> "$GITHUB_ENV" + + - if: ${{ matrix.target == 'aarch64-linux-android' }} + name: Configure Android rusty_v8 artifact overrides + env: + GH_TOKEN: ${{ github.token }} + TARGET: ${{ matrix.target }} + shell: bash + run: | + set -euo pipefail + binding_dir="${RUNNER_TEMP}/rusty_v8" + archive="${binding_dir}/librusty_v8_release_${TARGET}.a.gz" + binding_path="${binding_dir}/src_binding_release_${TARGET}.rs" + checksums_path="${binding_dir}/rusty_v8_release_${TARGET}.sha256" + artifact_repository="wallentx/codex-termux" + release_tag="rusty-v8-v147.4.0" + mkdir -p "${binding_dir}" + echo "Downloading Android rusty_v8 artifacts from ${artifact_repository}@${release_tag}" + gh release download "${release_tag}" \ + --repo "${artifact_repository}" \ + --dir "${binding_dir}" \ + --pattern "librusty_v8_release_${TARGET}.a.gz" \ + --pattern "src_binding_release_${TARGET}.rs" \ + --pattern "rusty_v8_release_${TARGET}.sha256" + (cd "${binding_dir}" && sha256sum -c "$(basename "${checksums_path}")") + echo "RUSTY_V8_ARCHIVE=${archive}" >> "$GITHUB_ENV" + echo "RUSTY_V8_SRC_BINDING_PATH=${binding_path}" >> "$GITHUB_ENV" + + - if: ${{ matrix.target == 'aarch64-linux-android' }} + name: Install termux-elf-cleaner + shell: bash + run: | + set -euo pipefail + version="v3.0.1" + src_dir="${RUNNER_TEMP}/termux-elf-cleaner-src" + build_dir="${RUNNER_TEMP}/termux-elf-cleaner-build" + bin_dir="${RUNNER_TEMP}/termux-elf-cleaner-bin" + + rm -rf "${src_dir}" "${build_dir}" "${bin_dir}" + mkdir -p "${src_dir}" "${build_dir}" "${bin_dir}" + + curl -fsSL "https://github.com/termux/termux-elf-cleaner/archive/refs/tags/${version}.tar.gz" \ + | tar -xzf - --strip-components=1 -C "${src_dir}" + + cmake -S "${src_dir}" -B "${build_dir}" -DCMAKE_BUILD_TYPE=Release + cmake --build "${build_dir}" --parallel + + install "${build_dir}/termux-elf-cleaner" "${bin_dir}/termux-elf-cleaner" + echo "${bin_dir}" >> "$GITHUB_PATH" - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} name: Use hermetic Cargo home (musl) @@ -269,12 +473,25 @@ jobs: echo "${cargo_home}/bin" >> "$GITHUB_PATH" : > "${cargo_home}/config.toml" + - uses: actions/cache@v5 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + ${{ github.workspace }}/.cargo-home/bin/ + ${{ github.workspace }}/.cargo-home/registry/index/ + ${{ github.workspace }}/.cargo-home/registry/cache/ + ${{ github.workspace }}/.cargo-home/git/db/ + ${{ github.workspace }}/codex-rs/target/ + key: cargo-${{ matrix.runner }}-${{ matrix.target }}-release-${{ hashFiles('**/Cargo.lock') }} + - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} name: Install Zig - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 + uses: mlugg/setup-zig@v2 with: version: 0.14.0 - use-cache: false - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} name: Install musl build tools @@ -310,12 +527,6 @@ jobs: shell: bash run: | set -euo pipefail - # Avoid problematic aws-lc jitter entropy code path on musl builders. - echo "AWS_LC_SYS_NO_JITTER_ENTROPY=1" >> "$GITHUB_ENV" - target_no_jitter="AWS_LC_SYS_NO_JITTER_ENTROPY_${{ matrix.target }}" - target_no_jitter="${target_no_jitter//-/_}" - echo "${target_no_jitter}=1" >> "$GITHUB_ENV" - # Clear global Rust flags so host/proc-macro builds don't pull in UBSan. echo "RUSTFLAGS=" >> "$GITHUB_ENV" echo "CARGO_ENCODED_RUSTFLAGS=" >> "$GITHUB_ENV" @@ -340,94 +551,70 @@ jobs: echo "CFLAGS=${cflags}" >> "$GITHUB_ENV" echo "CXXFLAGS=${cxxflags}" >> "$GITHUB_ENV" - - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl' }} - name: Configure musl rusty_v8 artifact overrides and verify checksums - uses: ./.github/actions/setup-rusty-v8-musl - with: - target: ${{ matrix.target }} - - - if: ${{ contains(matrix.target, 'linux') && matrix.bundle == 'primary' }} - name: Build bwrap and export digest + - name: Cargo build shell: bash run: | set -euo pipefail - target="${{ matrix.target }}" - cargo build --target "$target" --release --timings --bin bwrap - bwrap_path="target/${target}/release/bwrap" - if [[ ! -f "$bwrap_path" ]]; then - echo "bwrap binary ${bwrap_path} not found" - exit 1 + if [[ "${{ matrix.target }}" == 'aarch64-linux-android' ]]; then + export CARGO_BUILD_JOBS=4 + export CARGO_PROFILE_RELEASE_LTO=thin + export CARGO_PROFILE_RELEASE_CODEGEN_UNITS=8 + cargo build --target ${{ matrix.target }} --release --bin codex + exit 0 fi - digest="$(sha256sum "$bwrap_path" | awk '{print $1}')" - echo "CODEX_BWRAP_SHA256=${digest}" >> "$GITHUB_ENV" - echo "Built bwrap ${bwrap_path} with sha256:${digest}" - - - name: Cargo build + if [[ "${{ contains(matrix.target, 'windows') }}" == 'true' ]]; then + cargo build --target ${{ matrix.target }} --release --bin codex --bin codex-responses-api-proxy --bin codex-windows-sandbox-setup --bin codex-command-runner + else + cargo build --target ${{ matrix.target }} --release --bin codex --bin codex-responses-api-proxy + fi + - name: sccache stats (Android) + if: ${{ matrix.target == 'aarch64-linux-android' }} shell: bash - run: | - build_args=() - for binary in ${{ matrix.binaries }}; do - build_args+=(--bin "$binary") - done - echo "CARGO_PROFILE_RELEASE_LTO: ${CARGO_PROFILE_RELEASE_LTO}" - cargo build --target ${{ matrix.target }} --release --timings "${build_args[@]}" + run: ${SCCACHE_PATH:-sccache} --show-stats || true - - name: Upload Cargo timings - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: cargo-timings-rust-release-${{ matrix.target }}-${{ matrix.bundle }} - path: codex-rs/target/**/cargo-timings/cargo-timing.html - if-no-files-found: warn - - - if: ${{ runner.os == 'macOS' && env.SIGN_MACOS != 'true' }} - name: Stage unsigned macOS artifacts + - if: ${{ matrix.target == 'aarch64-linux-android' }} + name: Normalize Android ELF for Termux shell: bash run: | set -euo pipefail - - target="${{ matrix.target }}" - release_dir="target/${target}/release" - dest="unsigned-dist/${target}" - mkdir -p "$dest" - - for binary in ${{ matrix.binaries }}; do - binary_path="${release_dir}/${binary}" - unsigned_name="${binary}-${target}-unsigned" - unsigned_path="${dest}/${unsigned_name}" - if [[ ! -f "${binary_path}" ]]; then - echo "Binary ${binary_path} not found" - exit 1 + for binary in codex codex-responses-api-proxy; do + binary_path="target/${{ matrix.target }}/release/${binary}" + if [[ -f "${binary_path}" ]]; then + termux-elf-cleaner --api-level 24 "${binary_path}" + chmod +x "${binary_path}" + # if [[ "${binary}" == "codex" ]]; then + # # Patch PT_TLS (0x7) to PT_NULL (0x0) at offset 400 to bypass Bionic alignment checks. + # printf '\x00\x00\x00\x00' | dd of="${binary_path}" bs=1 seek=400 count=4 conv=notrunc + # fi fi - - cp "${binary_path}" "${unsigned_path}" - tar -C "$dest" -czf "${unsigned_path}.tar.gz" "${unsigned_name}" - zstd -T0 -19 --rm "${unsigned_path}" done - - if: ${{ runner.os == 'macOS' && env.SIGN_MACOS != 'true' }} - name: Upload unsigned macOS artifacts - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: ${{ matrix.artifact_name }}-unsigned - path: codex-rs/unsigned-dist/${{ matrix.target }}/* - if-no-files-found: error - - - if: ${{ contains(matrix.target, 'linux') }} + - if: ${{ contains(matrix.target, 'linux') && !contains(matrix.target, 'android') && github.repository_owner == 'openai' }} name: Cosign Linux artifacts uses: ./.github/actions/linux-code-sign with: target: ${{ matrix.target }} artifacts-dir: ${{ github.workspace }}/codex-rs/target/${{ matrix.target }}/release - binaries: ${{ matrix.binaries }} - - if: ${{ runner.os == 'macOS' && env.SIGN_MACOS == 'true' }} + - if: ${{ contains(matrix.target, 'windows') && github.repository_owner == 'openai' }} + name: Sign Windows binaries with Azure Trusted Signing + uses: ./.github/actions/windows-code-sign + with: + target: ${{ matrix.target }} + client-id: ${{ secrets.AZURE_TRUSTED_SIGNING_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TRUSTED_SIGNING_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_TRUSTED_SIGNING_SUBSCRIPTION_ID }} + endpoint: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} + account-name: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} + certificate-profile-name: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} + + - if: ${{ runner.os == 'macOS' && github.repository_owner == 'openai' }} name: MacOS code signing (binaries) uses: ./.github/actions/macos-code-sign with: target: ${{ matrix.target }} - binaries: ${{ matrix.binaries }} sign-binaries: "true" sign-dmg: "false" apple-certificate: ${{ secrets.APPLE_CERTIFICATE_P12 }} @@ -436,7 +623,7 @@ jobs: apple-notarization-key-id: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} apple-notarization-issuer-id: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} - - if: ${{ runner.os == 'macOS' && matrix.build_dmg == 'true' && env.SIGN_MACOS == 'true' }} + - if: ${{ runner.os == 'macOS' && github.repository_owner == 'openai' }} name: Build macOS dmg shell: bash run: | @@ -451,17 +638,23 @@ jobs: # The previous "MacOS code signing (binaries)" step signs + notarizes the # built artifacts in `${release_dir}`. This step packages *those same* # signed binaries into a dmg. + codex_binary_path="${release_dir}/codex" + proxy_binary_path="${release_dir}/codex-responses-api-proxy" + rm -rf "$dmg_root" mkdir -p "$dmg_root" - for binary in ${{ matrix.binaries }}; do - binary_path="${release_dir}/${binary}" - if [[ ! -f "${binary_path}" ]]; then - echo "Binary ${binary_path} not found" - exit 1 - fi - ditto "${binary_path}" "${dmg_root}/${binary}" - done + if [[ ! -f "$codex_binary_path" ]]; then + echo "Binary $codex_binary_path not found" + exit 1 + fi + if [[ ! -f "$proxy_binary_path" ]]; then + echo "Binary $proxy_binary_path not found" + exit 1 + fi + + ditto "$codex_binary_path" "${dmg_root}/codex" + ditto "$proxy_binary_path" "${dmg_root}/codex-responses-api-proxy" rm -f "$dmg_path" hdiutil create \ @@ -476,7 +669,7 @@ jobs: exit 1 fi - - if: ${{ runner.os == 'macOS' && matrix.build_dmg == 'true' && env.SIGN_MACOS == 'true' }} + - if: ${{ runner.os == 'macOS' && github.repository_owner == 'openai' }} name: MacOS code signing (dmg) uses: ./.github/actions/macos-code-sign with: @@ -490,107 +683,65 @@ jobs: apple-notarization-issuer-id: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} - name: Stage artifacts - if: ${{ runner.os != 'macOS' || env.SIGN_MACOS == 'true' }} shell: bash run: | dest="dist/${{ matrix.target }}" mkdir -p "$dest" - for binary in ${{ matrix.binaries }}; do - cp "target/${{ matrix.target }}/release/${binary}" "$dest/${binary}-${{ matrix.target }}" - if [[ "${{ matrix.target }}" == *linux* ]]; then - cp "target/${{ matrix.target }}/release/${binary}.sigstore" \ - "$dest/${binary}-${{ matrix.target }}.sigstore" + if [[ "${{ matrix.runner }}" == windows* ]]; then + cp target/${{ matrix.target }}/release/codex.exe "$dest/codex-${{ matrix.target }}.exe" + cp target/${{ matrix.target }}/release/codex-responses-api-proxy.exe "$dest/codex-responses-api-proxy-${{ matrix.target }}.exe" + cp target/${{ matrix.target }}/release/codex-windows-sandbox-setup.exe "$dest/codex-windows-sandbox-setup-${{ matrix.target }}.exe" + cp target/${{ matrix.target }}/release/codex-command-runner.exe "$dest/codex-command-runner-${{ matrix.target }}.exe" + else + # For Android, we want the binary to be named just 'codex' in the archive. + if [[ "${{ matrix.target }}" == "aarch64-linux-android" ]]; then + cp target/${{ matrix.target }}/release/codex "$dest/codex" + else + cp target/${{ matrix.target }}/release/codex "$dest/codex-${{ matrix.target }}" + fi + if [[ -f target/${{ matrix.target }}/release/codex-responses-api-proxy ]]; then + cp target/${{ matrix.target }}/release/codex-responses-api-proxy "$dest/codex-responses-api-proxy-${{ matrix.target }}" fi - done - - if [[ "${{ matrix.target }}" == *linux* && "${{ matrix.bundle }}" == "primary" ]]; then - bundle_root="${RUNNER_TEMP}/codex-${{ matrix.target }}-bundle" - rm -rf "$bundle_root" - mkdir -p "$bundle_root/codex-resources" - cp "$dest/codex-${{ matrix.target }}" "$bundle_root/codex" - cp "$dest/bwrap-${{ matrix.target }}" "$bundle_root/codex-resources/bwrap" - chmod 0755 "$bundle_root/codex" "$bundle_root/codex-resources/bwrap" - tar -C "$bundle_root" -cf - codex codex-resources/bwrap | - zstd -T0 -19 -o "$dest/codex-${{ matrix.target }}-bundle.tar.zst" fi - if [[ "${{ matrix.build_dmg }}" == "true" ]]; then - cp target/${{ matrix.target }}/release/codex-${{ matrix.target }}.dmg "$dest/codex-${{ matrix.target }}.dmg" + if [[ "${{ matrix.target }}" == *linux* && "${{ matrix.target }}" != *android* ]]; then + cp target/${{ matrix.target }}/release/codex.sigstore "$dest/codex-${{ matrix.target }}.sigstore" + cp target/${{ matrix.target }}/release/codex-responses-api-proxy.sigstore "$dest/codex-responses-api-proxy-${{ matrix.target }}.sigstore" fi - - name: Build Python runtime wheel - if: ${{ matrix.bundle == 'primary' && (runner.os != 'macOS' || env.SIGN_MACOS == 'true') }} - shell: bash - run: | - set -euo pipefail - - case "${{ matrix.target }}" in - aarch64-apple-darwin) - platform_tag="macosx_11_0_arm64" - ;; - x86_64-apple-darwin) - platform_tag="macosx_10_9_x86_64" - ;; - aarch64-unknown-linux-musl) - platform_tag="musllinux_1_1_aarch64" - ;; - x86_64-unknown-linux-musl) - platform_tag="musllinux_1_1_x86_64" - ;; - *) - echo "No Python runtime wheel platform tag for ${{ matrix.target }}" - exit 1 - ;; - esac - - python3 -m venv "${RUNNER_TEMP}/python-runtime-build-venv" - # Do not install into the runner's system Python; macOS runners mark - # the Homebrew Python as externally managed under PEP 668. - "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m pip install build - - stage_dir="${RUNNER_TEMP}/openai-codex-cli-bin-${{ matrix.target }}" - wheel_dir="${GITHUB_WORKSPACE}/python-runtime-dist/${{ matrix.target }}" - stage_runtime_args=( - "${GITHUB_WORKSPACE}/sdk/python/scripts/update_sdk_artifacts.py" - stage-runtime - "$stage_dir" - "${GITHUB_WORKSPACE}/codex-rs/target/${{ matrix.target }}/release/codex" - --codex-version "${GITHUB_REF_NAME}" - --platform-tag "$platform_tag" - ) - if [[ "${{ matrix.target }}" == *linux* ]]; then - # Keep bwrap in the runtime wheel so Linux sandbox fallback behavior - # matches the standalone release bundle on hosts without system bwrap. - stage_runtime_args+=( - --resource-binary - "${GITHUB_WORKSPACE}/codex-rs/target/${{ matrix.target }}/release/bwrap" - ) + if [[ "${{ matrix.target }}" == *apple-darwin ]]; then + cp target/${{ matrix.target }}/release/codex-${{ matrix.target }}.dmg "$dest/codex-${{ matrix.target }}.dmg" fi - python3 "${stage_runtime_args[@]}" - "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m build --wheel --outdir "$wheel_dir" "$stage_dir" - - name: Upload Python runtime wheel - if: ${{ matrix.bundle == 'primary' && (runner.os != 'macOS' || env.SIGN_MACOS == 'true') }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: python-runtime-wheel-${{ matrix.target }} - path: python-runtime-dist/${{ matrix.target }}/*.whl - if-no-files-found: error + - if: ${{ matrix.runner == 'windows-11-arm' }} + name: Install zstd + shell: powershell + run: choco install -y zstandard - name: Compress artifacts - if: ${{ runner.os != 'macOS' || env.SIGN_MACOS == 'true' }} shell: bash run: | # Path that contains the uncompressed binaries for the current # ${{ matrix.target }} dest="dist/${{ matrix.target }}" + repo_root=$PWD + + # We want to ship the raw Windows executables in the GitHub Release + # in addition to the compressed archives. Keep the originals for + # Windows targets; remove them elsewhere to limit the number of + # artifacts that end up in the GitHub Release. + keep_originals=false + if [[ "${{ matrix.runner }}" == windows* ]]; then + keep_originals=true + fi # For compatibility with environments that lack the `zstd` tool we - # additionally create a `.tar.gz` alongside every binary we publish. - # The end result is: + # additionally create a `.tar.gz` for all platforms and `.zip` for + # Windows alongside every single binary that we publish. The end result is: # codex-.zst (existing) # codex-.tar.gz (new) + # codex-.zip (only for Windows) # 1. Produce a .tar.gz for every file in the directory *before* we # run `zstd --rm`, because that flag deletes the original files. @@ -598,7 +749,7 @@ jobs: base="$(basename "$f")" # Skip files that are already archives (shouldn't happen, but be # safe). - if [[ "$base" == *.tar.gz || "$base" == *.tar.zst || "$base" == *.zip || "$base" == *.dmg ]]; then + if [[ "$base" == *.tar.gz || "$base" == *.zip || "$base" == *.dmg ]]; then continue fi @@ -608,330 +759,180 @@ jobs: fi # Create per-binary tar.gz - tar -C "$dest" -czf "$dest/${base}.tar.gz" "$base" - - # Also create .zst and remove the uncompressed binaries to keep - # non-Windows artifact directories small. - zstd -T0 -19 --rm "$dest/$base" - done - - - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - if: ${{ runner.os != 'macOS' || env.SIGN_MACOS == 'true' }} - with: - name: ${{ matrix.artifact_name }} - # Upload the per-binary .zst files, .tar.gz equivalents, and any - # prebuilt archives staged above. - path: | - codex-rs/dist/${{ matrix.target }}/* - - stage-signed-macos: - if: ${{ github.event_name == 'workflow_dispatch' && inputs.release_mode == 'promote_signed' }} - needs: tag-check - name: Stage signed macOS handoff - ${{ matrix.target }} - ${{ matrix.bundle }} - runs-on: macos-15-xlarge - timeout-minutes: 30 - permissions: - contents: read - defaults: - run: - working-directory: codex-rs - - strategy: - fail-fast: false - matrix: - include: - - target: aarch64-apple-darwin - bundle: primary - artifact_name: aarch64-apple-darwin - binaries: "codex codex-responses-api-proxy" - build_dmg: "false" - - target: aarch64-apple-darwin - bundle: app-server - artifact_name: aarch64-apple-darwin-app-server - binaries: "codex-app-server" - build_dmg: "false" - - target: x86_64-apple-darwin - bundle: primary - artifact_name: x86_64-apple-darwin - binaries: "codex codex-responses-api-proxy" - build_dmg: "false" - - target: x86_64-apple-darwin - bundle: app-server - artifact_name: x86_64-apple-darwin-app-server - binaries: "codex-app-server" - build_dmg: "false" - - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - - name: Download signed macOS handoff - shell: bash - env: - GH_TOKEN: ${{ github.token }} - SIGNED_MACOS_ASSET: ${{ inputs.signed_macos_asset }} - SIGNED_MACOS_SHA256: ${{ inputs.signed_macos_sha256 }} - run: | - set -euo pipefail - - download_dir="${RUNNER_TEMP}/signed-macos-download" - handoff_dir="${RUNNER_TEMP}/signed-macos-handoff" - rm -rf "$download_dir" "$handoff_dir" - mkdir -p "$download_dir" "$handoff_dir" - - gh release download "$GITHUB_REF_NAME" \ - --repo "$GITHUB_REPOSITORY" \ - --pattern "$SIGNED_MACOS_ASSET" \ - --dir "$download_dir" - - asset_count="$(find "$download_dir" -maxdepth 1 -type f | wc -l | tr -d '[:space:]')" - if [[ "$asset_count" != "1" ]]; then - echo "Expected exactly one signed macOS handoff asset named ${SIGNED_MACOS_ASSET}; found ${asset_count}" - find "$download_dir" -maxdepth 1 -type f -print - exit 1 - fi - - asset_path="$(find "$download_dir" -maxdepth 1 -type f -print -quit)" - if [[ -n "${SIGNED_MACOS_SHA256}" ]]; then - expected_sha="$(printf '%s' "$SIGNED_MACOS_SHA256" | tr '[:upper:]' '[:lower:]')" - actual_sha="$(shasum -a 256 "$asset_path" | awk '{print $1}')" - if [[ "$actual_sha" != "$expected_sha" ]]; then - echo "signed_macos_sha256 mismatch for ${SIGNED_MACOS_ASSET}" - echo "expected: ${expected_sha}" - echo "actual: ${actual_sha}" - exit 1 - fi - fi - - asset_name="$(basename "$asset_path")" - case "$asset_name" in - *.tar.zst) - zstd -dc "$asset_path" | tar -C "$handoff_dir" -xf - - ;; - *.tar.gz|*.tgz) - tar -C "$handoff_dir" -xzf "$asset_path" - ;; - *.zip) - ditto -x -k "$asset_path" "$handoff_dir" - ;; - *) - echo "Unsupported signed macOS handoff archive format: ${asset_name}" - exit 1 - ;; - esac - - echo "SIGNED_MACOS_HANDOFF_DIR=$handoff_dir" >> "$GITHUB_ENV" - - - name: Stage signed macOS artifacts - shell: bash - run: | - set -euo pipefail - - target="${{ matrix.target }}" - artifact_name="${{ matrix.artifact_name }}" - source_dir="${SIGNED_MACOS_HANDOFF_DIR}/${artifact_name}" - if [[ ! -d "$source_dir" && -d "${SIGNED_MACOS_HANDOFF_DIR}/dist/${artifact_name}" ]]; then - source_dir="${SIGNED_MACOS_HANDOFF_DIR}/dist/${artifact_name}" - fi - if [[ ! -d "$source_dir" && -d "${SIGNED_MACOS_HANDOFF_DIR}/${target}" ]]; then - source_dir="${SIGNED_MACOS_HANDOFF_DIR}/${target}" - fi - if [[ ! -d "$source_dir" && -d "${SIGNED_MACOS_HANDOFF_DIR}/dist/${target}" ]]; then - source_dir="${SIGNED_MACOS_HANDOFF_DIR}/dist/${target}" - fi - if [[ ! -d "$source_dir" ]]; then - echo "Signed macOS handoff is missing ${artifact_name}/" - echo "Expected either:" - echo " ${SIGNED_MACOS_HANDOFF_DIR}/${artifact_name}" - echo " ${SIGNED_MACOS_HANDOFF_DIR}/dist/${artifact_name}" - echo " ${SIGNED_MACOS_HANDOFF_DIR}/${target}" - echo " ${SIGNED_MACOS_HANDOFF_DIR}/dist/${target}" - find "$SIGNED_MACOS_HANDOFF_DIR" -maxdepth 3 -type f -print - exit 1 - fi - - dest="dist/${target}" - mkdir -p "$dest" - - for binary in ${{ matrix.binaries }}; do - source_path="${source_dir}/${binary}" - if [[ ! -f "$source_path" ]]; then - source_path="${source_dir}/${binary}-${target}" + # For Android, we want the archive to have the target suffix even though the binary is just 'codex'. + archive_name="${base}" + if [[ "${{ matrix.target }}" == "aarch64-linux-android" && "${base}" == "codex" ]]; then + archive_name="codex-${{ matrix.target }}" fi - if [[ ! -f "$source_path" ]]; then - echo "Signed macOS handoff is missing ${binary} for ${artifact_name}" - exit 1 + tar -C "$dest" -czf "$dest/${archive_name}.tar.gz" "$base" + + # Create zip archive for Windows binaries + # Must run from inside the dest dir so 7z won't + # embed the directory path inside the zip. + if [[ "${{ matrix.runner }}" == windows* ]]; then + if [[ "$base" == "codex-${{ matrix.target }}.exe" ]]; then + # Bundle the sandbox helper binaries into the main codex zip so + # WinGet installs include the required helpers next to codex.exe. + # Fall back to the single-binary zip if the helpers are missing + # to avoid breaking releases. + bundle_dir="$(mktemp -d)" + runner_src="$dest/codex-command-runner-${{ matrix.target }}.exe" + setup_src="$dest/codex-windows-sandbox-setup-${{ matrix.target }}.exe" + if [[ -f "$runner_src" && -f "$setup_src" ]]; then + cp "$dest/$base" "$bundle_dir/$base" + cp "$runner_src" "$bundle_dir/codex-command-runner.exe" + cp "$setup_src" "$bundle_dir/codex-windows-sandbox-setup.exe" + # Use an absolute path so bundle zips land in the real dist + # dir even when 7z runs from a temp directory. + (cd "$bundle_dir" && 7z a "$repo_root/$dest/${base}.zip" .) + else + echo "warning: missing sandbox binaries; falling back to single-binary zip" + echo "warning: expected $runner_src and $setup_src" + (cd "$dest" && 7z a "${base}.zip" "$base") + fi + rm -rf "$bundle_dir" + else + (cd "$dest" && 7z a "${base}.zip" "$base") + fi fi - release_path="${dest}/${binary}-${target}" - ditto "$source_path" "$release_path" - chmod 0755 "$release_path" - codesign --verify --strict --verbose=2 "$release_path" - done - - # DMG staging is disabled for signed promotion because we no longer - # distribute DMGs from this release path. Keep the branch here so the - # handoff can opt back in by flipping matrix.build_dmg if needed. - if [[ "${{ matrix.build_dmg }}" == "true" ]]; then - dmg_name="codex-${target}.dmg" - dmg_source="${source_dir}/${dmg_name}" - if [[ ! -f "$dmg_source" ]]; then - echo "Signed macOS handoff is missing ${dmg_name} for ${artifact_name}" - exit 1 + # Also create .zst (existing behaviour) *and* remove the original + # uncompressed binary to keep the directory small. + zstd_args=(-T0 -19) + if [[ "${keep_originals}" == false ]]; then + zstd_args+=(--rm) fi - codesign --verify --strict --verbose=2 "$dmg_source" - xcrun stapler validate "$dmg_source" - cp "$dmg_source" "$dest/$dmg_name" - fi + if [[ "${archive_name}" != "${base}" ]]; then + zstd "${zstd_args[@]}" "$dest/$base" -o "$dest/${archive_name}.zst" + else + zstd "${zstd_args[@]}" "$dest/$base" + fi + done - - name: Build Python runtime wheel - if: ${{ matrix.bundle == 'primary' }} + - name: Add Termux release metadata + if: ${{ (github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch') && matrix.target == 'aarch64-linux-android' }} shell: bash run: | set -euo pipefail + if [[ ! -f "${GITHUB_WORKSPACE}/.github/termux-release.json" ]]; then + echo "No Termux release metadata found; skipping metadata attachment." + exit 0 + fi + dest="dist/${{ matrix.target }}" + cp "${GITHUB_WORKSPACE}/.github/termux-release.json" "${dest}/termux-release.json" + ( + cd "${dest}" + sha256sum ./* > SHA256SUMS + ) - case "${{ matrix.target }}" in - aarch64-apple-darwin) - platform_tag="macosx_11_0_arm64" - ;; - x86_64-apple-darwin) - platform_tag="macosx_10_9_x86_64" - ;; - *) - echo "No Python runtime wheel platform tag for ${{ matrix.target }}" - exit 1 - ;; - esac - - python3 -m venv "${RUNNER_TEMP}/python-runtime-build-venv" - "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m pip install build - - stage_dir="${RUNNER_TEMP}/openai-codex-cli-bin-${{ matrix.target }}" - wheel_dir="${GITHUB_WORKSPACE}/python-runtime-dist/${{ matrix.target }}" - python3 \ - "${GITHUB_WORKSPACE}/sdk/python/scripts/update_sdk_artifacts.py" \ - stage-runtime \ - "$stage_dir" \ - "${GITHUB_WORKSPACE}/codex-rs/dist/${{ matrix.target }}/codex-${{ matrix.target }}" \ - --codex-version "${GITHUB_REF_NAME}" \ - --platform-tag "$platform_tag" - "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m build --wheel --outdir "$wheel_dir" "$stage_dir" - - - name: Upload Python runtime wheel - if: ${{ matrix.bundle == 'primary' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + - id: upload-artifact + uses: actions/upload-artifact@v6 with: - name: python-runtime-wheel-${{ matrix.target }} - path: python-runtime-dist/${{ matrix.target }}/*.whl - if-no-files-found: error + name: ${{ github.event_name == 'pull_request' && format('termux-android-pr-{0}-{1}', github.event.pull_request.number, github.event.pull_request.head.sha) || matrix.target }} + # Upload the per-binary .zst files as well as the new .tar.gz + # equivalents we generated in the previous step. + path: | + codex-rs/dist/${{ matrix.target }}/* - - name: Compress artifacts + - name: Comment Termux artifact download link + if: ${{ github.event_name == 'pull_request' && startsWith(github.base_ref, 'release/') && matrix.target == 'aarch64-linux-android' }} shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ARTIFACT_ID: ${{ steps.upload-artifact.outputs.artifact-id }} + ARTIFACT_NAME: ${{ github.event_name == 'pull_request' && format('termux-android-pr-{0}-{1}', github.event.pull_request.number, github.event.pull_request.head.sha) || matrix.target }} + PR_NUMBER: ${{ github.event.pull_request.number }} run: | set -euo pipefail + if [[ -z "${ARTIFACT_ID}" ]]; then + echo "upload-artifact did not return an artifact id" >&2 + exit 1 + fi - dest="dist/${{ matrix.target }}" - for f in "$dest"/*; do - base="$(basename "$f")" - if [[ "$base" == *.tar.gz || "$base" == *.tar.zst || "$base" == *.zip || "$base" == *.dmg ]]; then - continue - fi - - tar -C "$dest" -czf "$dest/${base}.tar.gz" "$base" - zstd -T0 -19 --rm "$dest/$base" - done - - - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: ${{ matrix.artifact_name }} - path: | - codex-rs/dist/${{ matrix.target }}/* - - build-windows: - if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} - needs: tag-check - uses: ./.github/workflows/rust-release-windows.yml - with: - release-lto: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'fat' }} - secrets: inherit + marker="" + artifact_url="https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/artifacts/${ARTIFACT_ID}" + run_url="${GH_WORKFLOW_URL:-https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}}" + body_path="${RUNNER_TEMP}/termux-artifact-comment.md" + { + echo "${marker}" + echo "Termux Android artifact ready for testing:" + echo + echo "- Artifact: [${ARTIFACT_NAME}](${artifact_url})" + echo "- Workflow run: [${GITHUB_RUN_ID}](${run_url})" + echo + echo "You must be signed in to GitHub with repository access to download Actions artifacts." + } > "${body_path}" + + existing_comment_id="$( + gh pr view "${PR_NUMBER}" \ + --repo "${GITHUB_REPOSITORY}" \ + --json comments \ + --jq ".comments | map(select(.author.is_bot == true and (.body | contains(\"${marker}\")))) | .[-1].id // \"\"" + )" + if [[ -n "${existing_comment_id}" ]]; then + gh api graphql \ + -f query=' + mutation($id: ID!, $body: String!) { + updateIssueComment(input: {id: $id, body: $body}) { + issueComment { + id + } + } + } + ' \ + -f id="${existing_comment_id}" \ + -f body="$(cat "${body_path}")" \ + >/dev/null + else + gh pr comment "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" --body-file "${body_path}" + fi - argument-comment-lint-release-assets: - if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} - name: argument-comment-lint release assets + gh label create binary-ready \ + --repo "${GITHUB_REPOSITORY}" \ + --color 0e8a16 \ + --description "Android binary is ready for testing" \ + --force + gh pr edit "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" --add-label binary-ready + + shell-tool-mcp: + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && github.repository_owner == 'openai' + name: shell-tool-mcp needs: tag-check - uses: ./.github/workflows/rust-release-argument-comment-lint.yml + permissions: + contents: read + id-token: write + uses: ./.github/workflows/shell-tool-mcp.yml with: + release-tag: ${{ github.ref_name }} publish: true - - zsh-release-assets: - if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} - name: zsh release assets - needs: tag-check - uses: ./.github/workflows/rust-release-zsh.yml + secrets: inherit release: + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') needs: - - tag-check - build - - stage-signed-macos - - build-windows - - argument-comment-lint-release-assets - - zsh-release-assets - if: >- - ${{ - always() && - needs.tag-check.result == 'success' && - ( - ( - github.event_name == 'workflow_dispatch' && - inputs.release_mode == 'promote_signed' && - needs.stage-signed-macos.result == 'success' && - needs.build.result == 'skipped' && - needs.build-windows.result == 'skipped' && - needs.argument-comment-lint-release-assets.result == 'skipped' && - needs.zsh-release-assets.result == 'skipped' - ) || - ( - (github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed') && - needs.build.result == 'success' && - needs.stage-signed-macos.result == 'skipped' && - needs.build-windows.result == 'success' && - needs.argument-comment-lint-release-assets.result == 'success' && - needs.zsh-release-assets.result == 'success' - ) - ) - }} + - shell-tool-mcp name: release runs-on: ubuntu-latest permissions: contents: write actions: read - env: - RELEASE_MODE: ${{ github.event_name == 'workflow_dispatch' && inputs.release_mode || 'signed' }} - SIGN_MACOS: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode == 'promote_signed' }} - SIGNED_MACOS_ASSET: ${{ inputs.signed_macos_asset }} - UNSIGNED_RUN_ID: ${{ inputs.unsigned_run_id }} outputs: version: ${{ steps.release_name.outputs.name }} tag: ${{ github.ref_name }} - sign_macos: ${{ steps.release_mode.outputs.sign_macos }} should_publish_npm: ${{ steps.npm_publish_settings.outputs.should_publish }} npm_tag: ${{ steps.npm_publish_settings.outputs.npm_tag }} - should_publish_python_runtime: ${{ steps.python_runtime_publish_settings.outputs.should_publish }} steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false + uses: actions/checkout@v6 - - name: Define release mode - id: release_mode - run: | - echo "release_mode=${RELEASE_MODE}" >> "$GITHUB_OUTPUT" - echo "sign_macos=${SIGN_MACOS}" >> "$GITHUB_OUTPUT" + - name: 🧰 Actions Toolbox + # This is required for the GitHub CLI + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: wallentx/gh-actions/composite/actions-toolbox@main - name: Generate release notes from tag commit message id: release_notes @@ -953,133 +954,18 @@ jobs: echo "path=${notes_path}" >> "${GITHUB_OUTPUT}" - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + - uses: actions/download-artifact@v7 with: path: dist - - name: Validate unsigned build run - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - - run_summary="$(gh run view "$UNSIGNED_RUN_ID" \ - --repo "$GITHUB_REPOSITORY" \ - --json conclusion,event,headBranch,headSha,status,workflowName,url \ - --jq '[.workflowName, .event, .headBranch, .headSha, .status, .conclusion, .url] | @tsv')" - IFS=$'\t' read -r workflow_name event head_branch head_sha status conclusion run_url <<< "$run_summary" - expected_head_sha="$(git rev-parse "${GITHUB_SHA}^{commit}")" - - if [[ "$workflow_name" != "$GITHUB_WORKFLOW" ]]; then - echo "unsigned_run_id ${UNSIGNED_RUN_ID} is for workflow '${workflow_name}', expected '${GITHUB_WORKFLOW}'" - echo "Run URL: ${run_url}" - exit 1 - fi - - if [[ "$event" != "workflow_dispatch" ]]; then - echo "unsigned_run_id ${UNSIGNED_RUN_ID} was triggered by '${event}', expected 'workflow_dispatch'" - echo "Run URL: ${run_url}" - exit 1 - fi - - if [[ "$head_branch" != "$GITHUB_REF_NAME" ]]; then - echo "unsigned_run_id ${UNSIGNED_RUN_ID} used ref '${head_branch}', expected '${GITHUB_REF_NAME}'" - echo "Run URL: ${run_url}" - exit 1 - fi - - if [[ "$head_sha" != "$expected_head_sha" ]]; then - echo "unsigned_run_id ${UNSIGNED_RUN_ID} used head SHA '${head_sha}', expected '${expected_head_sha}'" - echo "Run URL: ${run_url}" - exit 1 - fi - - if [[ "$status" != "completed" || "$conclusion" != "success" ]]; then - echo "unsigned_run_id ${UNSIGNED_RUN_ID} is ${status}/${conclusion}, expected completed/success" - echo "Run URL: ${run_url}" - exit 1 - fi - - - name: Download artifacts from unsigned build run - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - gh run download "$UNSIGNED_RUN_ID" \ - --repo "$GITHUB_REPOSITORY" \ - --dir dist - - - name: Remove unsigned macOS staging artifacts - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - run: | - set -euo pipefail - find dist -mindepth 1 -maxdepth 1 -type d \ - -name '*-apple-darwin*-unsigned' \ - -exec rm -rf {} + - - - name: Re-upload promoted Linux x64 artifacts - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: x86_64-unknown-linux-musl - path: dist/x86_64-unknown-linux-musl/* - if-no-files-found: error - - - name: Re-upload promoted Linux arm64 artifacts - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: aarch64-unknown-linux-musl - path: dist/aarch64-unknown-linux-musl/* - if-no-files-found: error - - - name: Re-upload promoted Windows x64 artifacts - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: x86_64-pc-windows-msvc - path: dist/x86_64-pc-windows-msvc/* - if-no-files-found: error - - - name: Re-upload promoted Windows arm64 artifacts - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: aarch64-pc-windows-msvc - path: dist/aarch64-pc-windows-msvc/* - if-no-files-found: error - - name: List run: ls -R dist/ - - name: Prune artifacts excluded from unsigned macOS release - if: ${{ env.SIGN_MACOS == 'false' }} - run: | - find dist -mindepth 1 -maxdepth 1 -type d \ - ! -name '*-apple-darwin*-unsigned' \ - ! -name 'aarch64-unknown-linux-musl' \ - ! -name 'aarch64-unknown-linux-musl-app-server' \ - ! -name 'x86_64-unknown-linux-musl' \ - ! -name 'x86_64-unknown-linux-musl-app-server' \ - ! -name 'aarch64-pc-windows-msvc' \ - ! -name 'x86_64-pc-windows-msvc' \ - -exec rm -rf {} + - - if ! find dist -type f -name '*-apple-darwin*-unsigned*' | grep -q .; then - echo "No unsigned macOS artifacts found in downloaded workflow artifacts." - exit 1 - fi - + # This is a temporary fix: we should modify shell-tool-mcp.yml so these + # files do not end up in dist/ in the first place. - name: Delete entries from dist/ that should not go in the release run: | - rm -rf dist/windows-binaries* - # cargo-timing.html appears under multiple target-specific directories. - # If included in files: dist/**, release upload races on duplicate - # asset names and can fail with 404s. - find dist -type f -name 'cargo-timing.html' -delete - find dist -type d -empty -delete + rm -rf dist/shell-tool-mcp* ls -R dist/ @@ -1103,12 +989,6 @@ jobs: set -euo pipefail version="${VERSION}" - if [[ "${SIGN_MACOS}" != "true" ]]; then - echo "should_publish=false" >> "$GITHUB_OUTPUT" - echo "npm_tag=" >> "$GITHUB_OUTPUT" - exit 0 - fi - if [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "should_publish=true" >> "$GITHUB_OUTPUT" echo "npm_tag=" >> "$GITHUB_OUTPUT" @@ -1120,132 +1000,55 @@ jobs: echo "npm_tag=" >> "$GITHUB_OUTPUT" fi - - name: Determine Python runtime publish settings - id: python_runtime_publish_settings - env: - VERSION: ${{ steps.release_name.outputs.name }} - run: | - set -euo pipefail - version="${VERSION}" - - if [[ "${SIGN_MACOS}" != "true" ]]; then - echo "should_publish=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - if [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "should_publish=true" >> "$GITHUB_OUTPUT" - elif [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+-alpha\.[0-9]+$ ]]; then - echo "should_publish=true" >> "$GITHUB_OUTPUT" - else - echo "should_publish=false" >> "$GITHUB_OUTPUT" - fi - - name: Setup pnpm - if: ${{ env.SIGN_MACOS == 'true' }} - uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5 + uses: pnpm/action-setup@v4 with: run_install: false - name: Setup Node.js for npm packaging - if: ${{ env.SIGN_MACOS == 'true' }} - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + uses: actions/setup-node@v6 with: node-version: 22 - name: Install dependencies - if: ${{ env.SIGN_MACOS == 'true' }} run: pnpm install --frozen-lockfile # stage_npm_packages.py requires DotSlash when staging releases. - - uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2 + - uses: facebook/install-dotslash@v2 - name: Stage npm packages - if: ${{ env.SIGN_MACOS == 'true' }} + if: github.repository_owner == 'openai' env: GH_TOKEN: ${{ github.token }} - RELEASE_VERSION: ${{ steps.release_name.outputs.name }} run: | - workflow_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" ./scripts/stage_npm_packages.py \ - --release-version "$RELEASE_VERSION" \ - --workflow-url "$workflow_url" \ + --release-version "${{ steps.release_name.outputs.name }}" \ --package codex \ --package codex-responses-api-proxy \ --package codex-sdk - - name: Stage installer scripts - if: ${{ env.SIGN_MACOS == 'true' }} - run: | - cp scripts/install/install.sh dist/install.sh - cp scripts/install/install.ps1 dist/install.ps1 - - name: Create GitHub Release - uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1 + uses: softprops/action-gh-release@v2 with: name: ${{ steps.release_name.outputs.name }} tag_name: ${{ github.ref_name }} body_path: ${{ steps.release_notes.outputs.path }} files: dist/** - overwrite_files: true - make_latest: ${{ env.SIGN_MACOS == 'true' && !contains(steps.release_name.outputs.name, '-') }} # Mark as prerelease only when the version has a suffix after x.y.z # (e.g. -alpha, -beta). Otherwise publish a normal release. prerelease: ${{ contains(steps.release_name.outputs.name, '-') }} - - name: Clean up signed promotion handoff assets - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - - release_id="$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${GITHUB_REF_NAME}" --jq '.id')" - gh api --paginate "repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets" \ - --jq '.[] | [.id, .name] | @tsv' | - while IFS=$'\t' read -r asset_id asset_name; do - if [[ -z "$asset_id" || -z "$asset_name" ]]; then - continue - fi - - delete_asset=false - if [[ "$asset_name" == *unsigned* || "$asset_name" == "$SIGNED_MACOS_ASSET" ]]; then - delete_asset=true - fi - - if [[ "$delete_asset" == "true" ]]; then - echo "Deleting release asset ${asset_name}" - gh api -X DELETE "repos/${GITHUB_REPOSITORY}/releases/assets/${asset_id}" - fi - done - - - if: ${{ env.SIGN_MACOS == 'true' }} - uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 + - if: github.repository_owner == 'openai' + uses: facebook/dotslash-publish-release@v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: tag: ${{ github.ref_name }} config: .github/dotslash-config.json - - if: ${{ env.SIGN_MACOS == 'true' }} - uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag: ${{ github.ref_name }} - config: .github/dotslash-zsh-config.json - - - if: ${{ env.SIGN_MACOS == 'true' }} - uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag: ${{ github.ref_name }} - config: .github/dotslash-argument-comment-lint-config.json - - name: Trigger developers.openai.com deploy # Only trigger the deploy if the release is not a pre-release. # The deploy is used to update the developers.openai.com website with the new config schema json file. - if: ${{ env.SIGN_MACOS == 'true' && !contains(steps.release_name.outputs.name, '-') }} + if: ${{ !contains(steps.release_name.outputs.name, '-') && github.repository_owner == 'openai' }} continue-on-error: true env: DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL: ${{ secrets.DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL }} @@ -1260,15 +1063,7 @@ jobs: # npm docs: https://docs.npmjs.com/trusted-publishers publish-npm: # Publish to npm for stable releases and alpha pre-releases with numeric suffixes. - # promote_signed intentionally skips build jobs that are ancestors of release; - # include the !cancelled() status function so Actions does not apply its implicit - # success() check to the whole dependency chain before evaluating release outputs. - if: >- - ${{ - !cancelled() && - needs.release.result == 'success' && - needs.release.outputs.should_publish_npm == 'true' - }} + if: ${{ needs.release.outputs.should_publish_npm == 'true' && github.repository_owner == 'openai' }} name: publish-npm needs: release runs-on: ubuntu-latest @@ -1278,37 +1073,36 @@ jobs: steps: - name: Setup Node.js - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + uses: actions/setup-node@v6 with: - # Node 24 bundles npm >= 11.5.1, which trusted publishing requires. - node-version: 24 + node-version: 22 registry-url: "https://registry.npmjs.org" scope: "@openai" + # Trusted publishing requires npm CLI version 11.5.1 or later. + - name: Update npm + run: npm install -g npm@latest + - name: Download npm tarballs from release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_TAG: ${{ needs.release.outputs.tag }} - RELEASE_VERSION: ${{ needs.release.outputs.version }} run: | set -euo pipefail - version="$RELEASE_VERSION" - tag="$RELEASE_TAG" + version="${{ needs.release.outputs.version }}" + tag="${{ needs.release.outputs.tag }}" mkdir -p dist/npm - patterns=( - "codex-npm-${version}.tgz" - "codex-npm-linux-*-${version}.tgz" - "codex-npm-darwin-*-${version}.tgz" - "codex-npm-win32-*-${version}.tgz" - "codex-responses-api-proxy-npm-${version}.tgz" - "codex-sdk-npm-${version}.tgz" - ) - for pattern in "${patterns[@]}"; do - gh release download "$tag" \ - --repo "${GITHUB_REPOSITORY}" \ - --pattern "$pattern" \ - --dir dist/npm - done + gh release download "$tag" \ + --repo "${GITHUB_REPOSITORY}" \ + --pattern "codex-npm-${version}.tgz" \ + --dir dist/npm + gh release download "$tag" \ + --repo "${GITHUB_REPOSITORY}" \ + --pattern "codex-responses-api-proxy-npm-${version}.tgz" \ + --dir dist/npm + gh release download "$tag" \ + --repo "${GITHUB_REPOSITORY}" \ + --pattern "codex-sdk-npm-${version}.tgz" \ + --dir dist/npm # No NODE_AUTH_TOKEN needed because we use OIDC. - name: Publish to npm @@ -1317,193 +1111,23 @@ jobs: NPM_TAG: ${{ needs.release.outputs.npm_tag }} run: | set -euo pipefail - prefix="" + tag_args=() if [[ -n "${NPM_TAG}" ]]; then - prefix="${NPM_TAG}-" + tag_args+=(--tag "${NPM_TAG}") fi - root_tarball="dist/npm/codex-npm-${VERSION}.tgz" - sdk_tarball="dist/npm/codex-sdk-npm-${VERSION}.tgz" - # Keep this list in sync with CODEX_PLATFORM_PACKAGES in - # codex-cli/scripts/build_npm_package.py. The root wrapper advances - # @openai/codex@latest as soon as it publishes, so every platform - # package it aliases must already exist in the registry first. - platform_tarballs=( - "dist/npm/codex-npm-linux-x64-${VERSION}.tgz" - "dist/npm/codex-npm-linux-arm64-${VERSION}.tgz" - "dist/npm/codex-npm-darwin-x64-${VERSION}.tgz" - "dist/npm/codex-npm-darwin-arm64-${VERSION}.tgz" - "dist/npm/codex-npm-win32-x64-${VERSION}.tgz" - "dist/npm/codex-npm-win32-arm64-${VERSION}.tgz" - ) - - for required_tarball in "${platform_tarballs[@]}" "${root_tarball}"; do - if [[ ! -f "${required_tarball}" ]]; then - echo "Missing npm tarball: ${required_tarball}" - exit 1 - fi - done - - shopt -s nullglob - other_tarballs=() - for tarball in dist/npm/*-"${VERSION}".tgz; do - if [[ "${tarball}" == "${root_tarball}" || "${tarball}" == "${sdk_tarball}" ]]; then - continue - fi - - is_platform_tarball=false - for platform_tarball in "${platform_tarballs[@]}"; do - if [[ "${tarball}" == "${platform_tarball}" ]]; then - is_platform_tarball=true - break - fi - done - if [[ "${is_platform_tarball}" == true ]]; then - continue - fi - - other_tarballs+=("${tarball}") - done - - # Publish the platform packages before the root CLI wrapper. The root - # wrapper advances @openai/codex@latest, so it should only publish - # after the optional dependency versions it references exist. tarballs=( - "${platform_tarballs[@]}" - "${other_tarballs[@]}" - "${root_tarball}" + "codex-npm-${VERSION}.tgz" + "codex-responses-api-proxy-npm-${VERSION}.tgz" + "codex-sdk-npm-${VERSION}.tgz" ) - if [[ -f "${sdk_tarball}" ]]; then - tarballs+=("${sdk_tarball}") - fi for tarball in "${tarballs[@]}"; do - filename="$(basename "${tarball}")" - tag="" - - case "${filename}" in - codex-npm-linux-*-"${VERSION}".tgz|codex-npm-darwin-*-"${VERSION}".tgz|codex-npm-win32-*-"${VERSION}".tgz) - platform="${filename#codex-npm-}" - platform="${platform%-${VERSION}.tgz}" - tag="${prefix}${platform}" - ;; - codex-npm-"${VERSION}".tgz|codex-responses-api-proxy-npm-"${VERSION}".tgz|codex-sdk-npm-"${VERSION}".tgz) - tag="${NPM_TAG}" - ;; - *) - echo "Unexpected npm tarball: ${filename}" - exit 1 - ;; - esac - - publish_cmd=(npm publish "${GITHUB_WORKSPACE}/${tarball}") - if [[ -n "${tag}" ]]; then - publish_cmd+=(--tag "${tag}") - fi - - echo "+ ${publish_cmd[*]}" - set +e - publish_output="$("${publish_cmd[@]}" 2>&1)" - publish_status=$? - set -e - - echo "${publish_output}" - if [[ ${publish_status} -eq 0 ]]; then - continue - fi - - if grep -qiE "previously published|cannot publish over|version already exists" <<< "${publish_output}"; then - echo "Skipping already-published package version for ${filename}" - continue - fi - - exit "${publish_status}" + npm publish "${GITHUB_WORKSPACE}/dist/npm/${tarball}" "${tag_args[@]}" done - # Publish the platform-specific Python runtime wheels using PyPI trusted publishing. - # PyPI project configuration must trust this workflow and job. Keep this - # non-blocking while the Python runtime publishing path is new; failures still - # need release follow-up, but should not invalidate the Rust release itself. - publish-python-runtime: - # Publish to PyPI for stable releases and alpha pre-releases with numeric suffixes. - if: >- - ${{ - !cancelled() && - needs.release.result == 'success' && - needs.release.outputs.should_publish_python_runtime == 'true' - }} - name: publish-python-runtime - needs: release - runs-on: ubuntu-latest - continue-on-error: true - environment: pypi - permissions: - id-token: write # Required for PyPI trusted publishing. - contents: read - - steps: - - name: Download Python runtime wheels from release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_TAG: ${{ needs.release.outputs.tag }} - RELEASE_VERSION: ${{ needs.release.outputs.version }} - run: | - set -euo pipefail - python_version="$RELEASE_VERSION" - python_version="${python_version/-alpha./a}" - python_version="${python_version/-beta./b}" - python_version="${python_version/-rc./rc}" - - mkdir -p dist/python-runtime - gh release download "$RELEASE_TAG" \ - --repo "${GITHUB_REPOSITORY}" \ - --pattern "openai_codex_cli_bin-${python_version}-*.whl" \ - --dir dist/python-runtime - ls -lh dist/python-runtime - - - name: Publish Python runtime wheels to PyPI - uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 - with: - packages-dir: dist/python-runtime - skip-existing: true - - winget: - name: winget - needs: release - # Only publish stable/mainline releases to WinGet; pre-releases include a - # '-' in the semver string (e.g., 1.2.3-alpha.1). - if: >- - ${{ - !cancelled() && - needs.release.result == 'success' && - needs.release.outputs.sign_macos == 'true' && - !contains(needs.release.outputs.version, '-') - }} - # This job only invokes a GitHub Action to open/update the winget-pkgs PR; - # it does not execute Windows-only tooling, so Linux is sufficient. - runs-on: ubuntu-latest - permissions: - contents: read - - steps: - - name: Publish to WinGet - uses: vedantmgoyal9/winget-releaser@7bd472be23763def6e16bd06cc8b1cdfab0e2fd5 - with: - identifier: OpenAI.Codex - version: ${{ needs.release.outputs.version }} - release-tag: ${{ needs.release.outputs.tag }} - fork-user: openai-oss-forks - installers-regex: '^codex-(?:x86_64|aarch64)-pc-windows-msvc\.exe\.zip$' - token: ${{ secrets.WINGET_PUBLISH_PAT }} - update-branch: name: Update latest-alpha-cli branch - if: >- - ${{ - !cancelled() && - needs.release.result == 'success' && - needs.release.outputs.sign_macos == 'true' - }} permissions: contents: write needs: release diff --git a/.github/workflows/shell-tool-mcp.yml b/.github/workflows/shell-tool-mcp.yml new file mode 100644 index 000000000000..66a76aa4d938 --- /dev/null +++ b/.github/workflows/shell-tool-mcp.yml @@ -0,0 +1,461 @@ +name: shell-tool-mcp + +on: + workflow_call: + inputs: + release-version: + description: Version to publish (x.y.z or x.y.z-alpha.N). Defaults to GITHUB_REF_NAME when it starts with rust-v. + required: false + type: string + release-tag: + description: Tag name to use when downloading release artifacts (defaults to rust-v). + required: false + type: string + publish: + description: Whether to publish to npm when the version is releasable. + required: false + default: true + type: boolean + +env: + NODE_VERSION: 22 + +jobs: + metadata: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.compute.outputs.version }} + release_tag: ${{ steps.compute.outputs.release_tag }} + should_publish: ${{ steps.compute.outputs.should_publish }} + npm_tag: ${{ steps.compute.outputs.npm_tag }} + steps: + - name: Compute version and tags + id: compute + run: | + set -euo pipefail + + version="${{ inputs.release-version }}" + release_tag="${{ inputs.release-tag }}" + + if [[ -z "$version" ]]; then + if [[ -n "$release_tag" && "$release_tag" =~ ^rust-v.+ ]]; then + version="${release_tag#rust-v}" + elif [[ "${GITHUB_REF_NAME:-}" =~ ^rust-v.+ ]]; then + version="${GITHUB_REF_NAME#rust-v}" + release_tag="${GITHUB_REF_NAME}" + else + echo "release-version is required when GITHUB_REF_NAME is not a rust-v tag." + exit 1 + fi + fi + + if [[ -z "$release_tag" ]]; then + release_tag="rust-v${version}" + fi + + npm_tag="" + should_publish="false" + if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + should_publish="true" + elif [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+-alpha\.[0-9]+$ ]]; then + should_publish="true" + npm_tag="alpha" + fi + + echo "version=${version}" >> "$GITHUB_OUTPUT" + echo "release_tag=${release_tag}" >> "$GITHUB_OUTPUT" + echo "npm_tag=${npm_tag}" >> "$GITHUB_OUTPUT" + echo "should_publish=${should_publish}" >> "$GITHUB_OUTPUT" + + matrix-setup: + runs-on: ubuntu-latest + outputs: + rust-binaries: ${{ steps.compute.outputs.rust-binaries }} + bash-linux: ${{ steps.compute.outputs.bash-linux }} + bash-darwin: ${{ steps.compute.outputs.bash-darwin }} + steps: + - name: Compute matrices + id: compute + shell: bash + run: | + set -euo pipefail + is_openai="${{ github.repository_owner == 'openai' }}" + + # rust-binaries: always include x86_64-musl; conditionally include paid/fork runners + if [[ "$is_openai" == "true" ]]; then + rust='[ + {"runner":"macos-15-xlarge","target":"aarch64-apple-darwin"}, + {"runner":"macos-15-xlarge","target":"x86_64-apple-darwin"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","install_musl":true} + ]' + else + rust='[{"runner":"macos-latest","target":"aarch64-apple-darwin"}]' + fi + rust=$(echo "$rust" | jq -c '. + [{"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","install_musl":true}]') + echo "rust-binaries={\"include\":$rust}" >> "$GITHUB_OUTPUT" + + # bash-linux: always include x86_64 variants; add arm64 on openai + bash_linux='[ + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","variant":"ubuntu-24.04","image":"ubuntu:24.04"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","variant":"ubuntu-22.04","image":"ubuntu:22.04"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","variant":"debian-12","image":"debian:12"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","variant":"debian-11","image":"debian:11"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","variant":"centos-9","image":"quay.io/centos/centos:stream9"} + ]' + if [[ "$is_openai" == "true" ]]; then + bash_linux=$(echo "$bash_linux" | jq -c '. + [ + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"ubuntu-24.04","image":"arm64v8/ubuntu:24.04"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"ubuntu-22.04","image":"arm64v8/ubuntu:22.04"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"ubuntu-20.04","image":"arm64v8/ubuntu:20.04"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"debian-12","image":"arm64v8/debian:12"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"debian-11","image":"arm64v8/debian:11"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"centos-9","image":"quay.io/centos/centos:stream9"} + ]') + fi + echo "bash-linux={\"include\":$(echo "$bash_linux" | jq -c)}" >> "$GITHUB_OUTPUT" + + # bash-darwin: always include macos-14; add macos-15-xlarge on openai + bash_darwin='[{"runner":"macos-14","target":"aarch64-apple-darwin","variant":"macos-14"}]' + if [[ "$is_openai" == "true" ]]; then + bash_darwin=$(echo "$bash_darwin" | jq -c '. + [ + {"runner":"macos-15-xlarge","target":"aarch64-apple-darwin","variant":"macos-15"} + ]') + fi + echo "bash-darwin={\"include\":$(echo "$bash_darwin" | jq -c)}" >> "$GITHUB_OUTPUT" + + rust-binaries: + name: Build Rust - ${{ matrix.target }} + needs: [metadata, matrix-setup] + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + defaults: + run: + working-directory: codex-rs + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.matrix-setup.outputs.rust-binaries) }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Install UBSan runtime (musl) + if: ${{ matrix.install_musl }} + shell: bash + run: | + set -euo pipefail + if command -v apt-get >/dev/null 2>&1; then + sudo apt-get update -y + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y libubsan1 + fi + + - uses: dtolnay/rust-toolchain@1.93 + with: + targets: ${{ matrix.target }} + + - if: ${{ matrix.install_musl }} + name: Install Zig + uses: mlugg/setup-zig@v2 + with: + version: 0.14.0 + + - if: ${{ matrix.install_musl }} + name: Install musl build dependencies + env: + TARGET: ${{ matrix.target }} + run: bash "${GITHUB_WORKSPACE}/.github/scripts/install-musl-build-tools.sh" + + - if: ${{ matrix.install_musl }} + name: Configure rustc UBSan wrapper (musl host) + shell: bash + run: | + set -euo pipefail + ubsan="" + if command -v ldconfig >/dev/null 2>&1; then + ubsan="$(ldconfig -p | grep -m1 'libubsan\.so\.1' | sed -E 's/.*=> (.*)$/\1/')" + fi + wrapper_root="${RUNNER_TEMP:-/tmp}" + wrapper="${wrapper_root}/rustc-ubsan-wrapper" + cat > "${wrapper}" <> "$GITHUB_ENV" + echo "RUSTC_WORKSPACE_WRAPPER=" >> "$GITHUB_ENV" + + - if: ${{ matrix.install_musl }} + name: Clear sanitizer flags (musl) + shell: bash + run: | + set -euo pipefail + # Clear global Rust flags so host/proc-macro builds don't pull in UBSan. + echo "RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_ENCODED_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "RUSTDOCFLAGS=" >> "$GITHUB_ENV" + # Override any runner-level Cargo config rustflags as well. + echo "CARGO_BUILD_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_RUSTFLAGS=" >> "$GITHUB_ENV" + + sanitize_flags() { + local input="$1" + input="${input//-fsanitize=undefined/}" + input="${input//-fno-sanitize-recover=undefined/}" + input="${input//-fno-sanitize-trap=undefined/}" + echo "$input" + } + + cflags="$(sanitize_flags "${CFLAGS-}")" + cxxflags="$(sanitize_flags "${CXXFLAGS-}")" + echo "CFLAGS=${cflags}" >> "$GITHUB_ENV" + echo "CXXFLAGS=${cxxflags}" >> "$GITHUB_ENV" + + - name: Build exec server binaries + run: cargo build --release --target ${{ matrix.target }} --bin codex-exec-mcp-server --bin codex-execve-wrapper + + - name: Stage exec server binaries + run: | + dest="${GITHUB_WORKSPACE}/artifacts/vendor/${{ matrix.target }}" + mkdir -p "$dest" + cp "target/${{ matrix.target }}/release/codex-exec-mcp-server" "$dest/" + cp "target/${{ matrix.target }}/release/codex-execve-wrapper" "$dest/" + + - uses: actions/upload-artifact@v6 + with: + name: shell-tool-mcp-rust-${{ matrix.target }} + path: artifacts/** + if-no-files-found: error + + bash-linux: + name: Build Bash (Linux) - ${{ matrix.variant }} - ${{ matrix.target }} + needs: [metadata, matrix-setup] + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + container: + image: ${{ matrix.image }} + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.matrix-setup.outputs.bash-linux) }} + steps: + - name: Install build prerequisites + shell: bash + run: | + set -euo pipefail + if command -v apt-get >/dev/null 2>&1; then + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y git build-essential bison autoconf gettext + elif command -v dnf >/dev/null 2>&1; then + dnf install -y git gcc gcc-c++ make bison autoconf gettext + elif command -v yum >/dev/null 2>&1; then + yum install -y git gcc gcc-c++ make bison autoconf gettext + else + echo "Unsupported package manager in container" + exit 1 + fi + + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Build patched Bash + shell: bash + run: | + set -euo pipefail + git clone --depth 1 https://github.com/bolinfest/bash /tmp/bash + cd /tmp/bash + git fetch --depth 1 origin a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b + git checkout a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b + git apply "${GITHUB_WORKSPACE}/shell-tool-mcp/patches/bash-exec-wrapper.patch" + ./configure --without-bash-malloc + cores="$(command -v nproc >/dev/null 2>&1 && nproc || getconf _NPROCESSORS_ONLN)" + make -j"${cores}" + + dest="${GITHUB_WORKSPACE}/artifacts/vendor/${{ matrix.target }}/bash/${{ matrix.variant }}" + mkdir -p "$dest" + cp bash "$dest/bash" + + - uses: actions/upload-artifact@v6 + with: + name: shell-tool-mcp-bash-${{ matrix.target }}-${{ matrix.variant }} + path: artifacts/** + if-no-files-found: error + + bash-darwin: + name: Build Bash (macOS) - ${{ matrix.variant }} - ${{ matrix.target }} + needs: [metadata, matrix-setup] + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.matrix-setup.outputs.bash-darwin) }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Build patched Bash + shell: bash + run: | + set -euo pipefail + git clone --depth 1 https://github.com/bolinfest/bash /tmp/bash + cd /tmp/bash + git fetch --depth 1 origin a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b + git checkout a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b + git apply "${GITHUB_WORKSPACE}/shell-tool-mcp/patches/bash-exec-wrapper.patch" + ./configure --without-bash-malloc + cores="$(getconf _NPROCESSORS_ONLN)" + make -j"${cores}" + + dest="${GITHUB_WORKSPACE}/artifacts/vendor/${{ matrix.target }}/bash/${{ matrix.variant }}" + mkdir -p "$dest" + cp bash "$dest/bash" + + - uses: actions/upload-artifact@v6 + with: + name: shell-tool-mcp-bash-${{ matrix.target }}-${{ matrix.variant }} + path: artifacts/** + if-no-files-found: error + + package: + name: Package npm module + needs: + - metadata + - rust-binaries + - bash-linux + - bash-darwin + runs-on: ubuntu-latest + env: + PACKAGE_VERSION: ${{ needs.metadata.outputs.version }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Install JavaScript dependencies + run: pnpm install --frozen-lockfile + + - name: Build (shell-tool-mcp) + run: pnpm --filter @openai/codex-shell-tool-mcp run build + + - name: Download build artifacts + uses: actions/download-artifact@v7 + with: + path: artifacts + + - name: Assemble staging directory + id: staging + shell: bash + run: | + set -euo pipefail + staging="${STAGING_DIR}" + mkdir -p "$staging" "$staging/vendor" + cp shell-tool-mcp/README.md "$staging/" + cp shell-tool-mcp/package.json "$staging/" + cp -R shell-tool-mcp/bin "$staging/" + + found_vendor="false" + shopt -s nullglob + for vendor_dir in artifacts/*/vendor; do + rsync -av "$vendor_dir/" "$staging/vendor/" + found_vendor="true" + done + if [[ "$found_vendor" == "false" ]]; then + echo "No vendor payloads were downloaded." + exit 1 + fi + + node - <<'NODE' + import fs from "node:fs"; + import path from "node:path"; + + const stagingDir = process.env.STAGING_DIR; + const version = process.env.PACKAGE_VERSION; + const pkgPath = path.join(stagingDir, "package.json"); + const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")); + pkg.version = version; + fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n"); + NODE + + echo "dir=$staging" >> "$GITHUB_OUTPUT" + env: + STAGING_DIR: ${{ runner.temp }}/shell-tool-mcp + + - name: Ensure binaries are executable + run: | + set -euo pipefail + staging="${{ steps.staging.outputs.dir }}" + chmod +x \ + "$staging"/vendor/*/codex-exec-mcp-server \ + "$staging"/vendor/*/codex-execve-wrapper \ + "$staging"/vendor/*/bash/*/bash + + - name: Create npm tarball + shell: bash + run: | + set -euo pipefail + mkdir -p dist/npm + staging="${{ steps.staging.outputs.dir }}" + pack_info=$(cd "$staging" && npm pack --ignore-scripts --json --pack-destination "${GITHUB_WORKSPACE}/dist/npm") + filename=$(PACK_INFO="$pack_info" node -e 'const data = JSON.parse(process.env.PACK_INFO); console.log(data[0].filename);') + mv "dist/npm/${filename}" "dist/npm/codex-shell-tool-mcp-npm-${PACKAGE_VERSION}.tgz" + + - uses: actions/upload-artifact@v6 + with: + name: codex-shell-tool-mcp-npm + path: dist/npm/codex-shell-tool-mcp-npm-${{ env.PACKAGE_VERSION }}.tgz + if-no-files-found: error + + publish: + name: Publish npm package + needs: + - metadata + - package + if: ${{ inputs.publish && needs.metadata.outputs.should_publish == 'true' }} + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + steps: + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + registry-url: https://registry.npmjs.org + scope: "@openai" + + # Trusted publishing requires npm CLI version 11.5.1 or later. + - name: Update npm + run: npm install -g npm@latest + + - name: Download npm tarball + uses: actions/download-artifact@v7 + with: + name: codex-shell-tool-mcp-npm + path: dist/npm + + - name: Publish to npm + if: github.repository == 'openai/codex' + env: + NPM_TAG: ${{ needs.metadata.outputs.npm_tag }} + VERSION: ${{ needs.metadata.outputs.version }} + shell: bash + run: | + set -euo pipefail + tag_args=() + if [[ -n "${NPM_TAG}" ]]; then + tag_args+=(--tag "${NPM_TAG}") + fi + npm publish "dist/npm/codex-shell-tool-mcp-npm-${VERSION}.tgz" "${tag_args[@]}" diff --git a/.github/workflows/termux-release-checkpoint.yml b/.github/workflows/termux-release-checkpoint.yml new file mode 100644 index 000000000000..0347e6f1b3d6 --- /dev/null +++ b/.github/workflows/termux-release-checkpoint.yml @@ -0,0 +1,103 @@ +name: termux-release-checkpoint + +on: + workflow_dispatch: + inputs: + source_branch: + description: "Release branch to checkpoint from, for example release/0.123.0" + required: false + type: string + default: "" + source_sha: + description: "Specific source commit SHA to checkpoint; defaults to the source branch tip" + required: false + type: string + default: "" + destination_branch: + description: "Destination patch branch to receive the checkpoint PR" + required: false + type: string + default: "wallentx/termux-target" + reviewer: + description: "GitHub username to request as reviewer" + required: false + type: string + default: "wallentx" + +permissions: + actions: read + attestations: read + checks: read + contents: read + deployments: read + issues: read + discussions: read + packages: read + pages: read + pull-requests: read + repository-projects: read + statuses: read + +concurrency: + group: termux-release-checkpoint-${{ inputs.source_branch || github.ref_name }} + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + checkpoint: + runs-on: ubuntu-slim + permissions: + contents: write + issues: write + pull-requests: write + env: + DESTINATION_BRANCH: ${{ inputs.destination_branch || 'wallentx/termux-target' }} + REVIEWER: ${{ inputs.reviewer || 'wallentx' }} + REQUESTED_SOURCE_BRANCH: ${{ inputs.source_branch }} + REQUESTED_SOURCE_SHA: ${{ inputs.source_sha }} + TERMUX_AUTOMATION_DIR: ${{ github.workspace }}/.termux-release-automation + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Export GitHub App token for gh + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + run: echo "GH_TOKEN=${APP_TOKEN}" >> "${GITHUB_ENV}" + + - name: Checkout release branch + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ inputs.source_branch || github.ref_name }} + token: ${{ steps.app-token.outputs.token }} + + - name: Checkout automation helpers + uses: actions/checkout@v6 + with: + fetch-depth: 1 + ref: ${{ github.workflow_sha }} + path: .termux-release-automation + token: ${{ steps.app-token.outputs.token }} + + - name: 🧰 Actions Toolbox + uses: wallentx/gh-actions/composite/actions-toolbox@main + + - name: Validate GitHub CLI environment + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-validate-gh-env.sh" + + - name: Configure git + run: | + set -euo pipefail + source_branch="${REQUESTED_SOURCE_BRANCH:-${GITHUB_REF_NAME}}" + bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-configure-git.sh" --origin "${DESTINATION_BRANCH}" "${source_branch}" + + - name: Create checkpoint PR + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-create-checkpoint-pr.sh" diff --git a/.github/workflows/termux-release-deploy.yml b/.github/workflows/termux-release-deploy.yml new file mode 100644 index 000000000000..2071611dacc4 --- /dev/null +++ b/.github/workflows/termux-release-deploy.yml @@ -0,0 +1,243 @@ +name: termux-release-deploy + +on: + push: + branches: + - "release/**" + workflow_dispatch: + inputs: + release_branch: + description: "Release branch to deploy, for example release/0.124.0" + required: true + type: string + release_sha: + description: "Release branch commit SHA to deploy. Defaults to the branch head." + required: false + default: "" + type: string + pr_number: + description: "Merged release PR number to promote. Optional; normally discovered automatically." + required: false + default: "" + type: string + pr_head_sha: + description: "Merged release PR head SHA. Required only when pr_number is set." + required: false + default: "" + type: string + destination_branch: + description: "Destination patch branch to receive the checkpoint PR" + required: false + default: "wallentx/termux-target" + type: string + reviewer: + description: "GitHub username to request as reviewer on the checkpoint PR" + required: false + default: "wallentx" + type: string + +permissions: + actions: read + attestations: read + checks: read + contents: read + deployments: read + issues: read + discussions: read + packages: read + pages: read + pull-requests: read + repository-projects: read + statuses: read + +concurrency: + group: termux-release-deploy-${{ github.event_name == 'workflow_dispatch' && inputs.release_branch || github.ref_name }} + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + deploy: + runs-on: ubuntu-24.04 + if: ${{ github.event_name == 'workflow_dispatch' || !startsWith(github.event.head_commit.message, 'Seed Termux release automation') }} + permissions: + actions: read + contents: write + deployments: write + issues: write + pull-requests: write + env: + REQUESTED_RELEASE_BRANCH: ${{ inputs.release_branch }} + REQUESTED_RELEASE_SHA: ${{ inputs.release_sha }} + INPUT_PR_NUMBER: ${{ inputs.pr_number }} + INPUT_PR_HEAD_SHA: ${{ inputs.pr_head_sha }} + DESTINATION_BRANCH: ${{ inputs.destination_branch || 'wallentx/termux-target' }} + REVIEWER: ${{ inputs.reviewer || 'wallentx' }} + TERMUX_AUTOMATION_DIR: ${{ github.workspace }}/.termux-release-automation + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Export GitHub App token for gh + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + run: echo "GH_TOKEN=${APP_TOKEN}" >> "${GITHUB_ENV}" + + - name: Checkout release branch + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.release_branch || github.ref }} + token: ${{ steps.app-token.outputs.token }} + + - name: Checkout automation helpers + uses: actions/checkout@v6 + with: + fetch-depth: 1 + ref: ${{ github.workflow_sha }} + path: .termux-release-automation + token: ${{ steps.app-token.outputs.token }} + + - name: 🧰 Actions Toolbox + uses: wallentx/gh-actions/composite/actions-toolbox@main + + - name: Validate GitHub CLI environment + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-validate-gh-env.sh" + + - name: Configure git + run: | + set -euo pipefail + release_branch="${REQUESTED_RELEASE_BRANCH:-${GITHUB_REF_NAME}}" + bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-configure-git.sh" --origin "${DESTINATION_BRANCH}" "${release_branch}" + + - name: Resolve release ref + id: release-ref + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-resolve-release-ref.sh" + + - name: Read release metadata + id: metadata + env: + TERMUX_RELEASE_ACTION: deploy + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-read-release-metadata.sh" + + - name: Create deployment + if: steps.metadata.outputs.deploy == 'true' + id: deployment + env: + GH_TOKEN: ${{ github.token }} + RELEASE_SHA: ${{ steps.release-ref.outputs.sha }} + TERMUX_TAG: ${{ steps.metadata.outputs.termux_tag }} + run: | + set -euo pipefail + deployment_id="$( + gh api \ + -X POST \ + "repos/${GITHUB_REPOSITORY}/deployments" \ + -f ref="${RELEASE_SHA}" \ + -f environment="termux-release" \ + -F auto_merge=false \ + -F required_contexts[] \ + -f description="Termux release deployment for ${TERMUX_TAG}" \ + --jq '.id' + )" + echo "id=${deployment_id}" >> "$GITHUB_OUTPUT" + + - name: Mark deployment in progress + if: steps.metadata.outputs.deploy == 'true' + env: + GH_TOKEN: ${{ github.token }} + DEPLOYMENT_ID: ${{ steps.deployment.outputs.id }} + run: | + set -euo pipefail + log_url="${GH_WORKFLOW_URL:-${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}}" + gh api \ + -X POST \ + "repos/${GITHUB_REPOSITORY}/deployments/${DEPLOYMENT_ID}/statuses" \ + -f state="in_progress" \ + -f environment="termux-release" \ + -f log_url="${log_url}" \ + -F auto_inactive=false \ + -f description="Promoting Termux release artifact and preparing checkpoint PR" \ + >/dev/null + + - name: Locate merged pull request + if: steps.metadata.outputs.deploy == 'true' && steps.metadata.outputs.asset_exists != 'true' + id: pr + env: + RELEASE_BRANCH: ${{ steps.release-ref.outputs.branch }} + RELEASE_SHA: ${{ steps.release-ref.outputs.sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-find-release-pr.sh" + + - name: Download promoted PR artifact + if: steps.metadata.outputs.deploy == 'true' && steps.metadata.outputs.asset_exists != 'true' + env: + PR_ARTIFACT_NAME: ${{ steps.pr.outputs.artifact_name }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-download-release-artifact.sh" + + - name: Create or update mirrored Termux release + if: steps.metadata.outputs.deploy == 'true' && steps.metadata.outputs.asset_exists != 'true' + env: + UPSTREAM_TAG: ${{ steps.metadata.outputs.upstream_tag }} + UPSTREAM_REPO: ${{ steps.metadata.outputs.upstream_repo }} + UPSTREAM_NAME: ${{ steps.metadata.outputs.upstream_name }} + TERMUX_TAG: ${{ steps.metadata.outputs.termux_tag }} + UPSTREAM_PRERELEASE: ${{ steps.metadata.outputs.upstream_prerelease }} + UPSTREAM_HTML_URL: ${{ steps.metadata.outputs.upstream_html_url }} + RELEASE_TRAIN: ${{ steps.metadata.outputs.release_train }} + RELEASE_EXISTS: ${{ steps.metadata.outputs.release_exists }} + PR_NUMBER: ${{ steps.pr.outputs.number }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + RELEASE_SHA: ${{ steps.release-ref.outputs.sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-create-or-update-mirrored-release.sh" + + - name: Ensure checkpoint PR + if: steps.metadata.outputs.deploy == 'true' + id: checkpoint + env: + SOURCE_BRANCH: ${{ steps.release-ref.outputs.branch }} + SOURCE_SHA: ${{ steps.release-ref.outputs.sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-create-checkpoint-pr.sh" + + - name: Mark deployment success + if: steps.metadata.outputs.deploy == 'true' + env: + GH_TOKEN: ${{ github.token }} + DEPLOYMENT_ID: ${{ steps.deployment.outputs.id }} + TERMUX_TAG: ${{ steps.metadata.outputs.termux_tag }} + run: | + set -euo pipefail + log_url="${GH_WORKFLOW_URL:-${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}}" + gh api \ + -X POST \ + "repos/${GITHUB_REPOSITORY}/deployments/${DEPLOYMENT_ID}/statuses" \ + -f state="success" \ + -f environment="termux-release" \ + -f log_url="${log_url}" \ + -F auto_inactive=false \ + -f description="Termux release deployment completed for ${TERMUX_TAG}" \ + >/dev/null + + - name: Mark deployment failure + if: failure() && steps.deployment.outputs.id != '' + env: + GH_TOKEN: ${{ github.token }} + DEPLOYMENT_ID: ${{ steps.deployment.outputs.id }} + run: | + set -euo pipefail + log_url="${GH_WORKFLOW_URL:-${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}}" + gh api \ + -X POST \ + "repos/${GITHUB_REPOSITORY}/deployments/${DEPLOYMENT_ID}/statuses" \ + -f state="failure" \ + -f environment="termux-release" \ + -f log_url="${log_url}" \ + -F auto_inactive=false \ + -f description="Termux release deployment failed" \ + >/dev/null diff --git a/.github/workflows/termux-release-promote.yml b/.github/workflows/termux-release-promote.yml new file mode 100644 index 000000000000..22c277c21a70 --- /dev/null +++ b/.github/workflows/termux-release-promote.yml @@ -0,0 +1,135 @@ +name: termux-release-promote + +on: + workflow_dispatch: + inputs: + release_branch: + description: "Release branch to promote, for example release/0.122.0" + required: true + type: string + release_sha: + description: "Release branch commit SHA to promote. Defaults to the branch head." + required: false + default: "" + type: string + pr_number: + description: "Merged PR number to promote. Optional; normally discovered automatically." + required: false + default: "" + type: string + pr_head_sha: + description: "Merged PR head SHA. Required only when pr_number is set." + required: false + default: "" + type: string + +permissions: + actions: read + attestations: read + checks: read + contents: read + deployments: read + issues: read + discussions: read + packages: read + pages: read + pull-requests: read + repository-projects: read + statuses: read + +concurrency: + group: termux-release-promote-${{ github.event_name == 'workflow_dispatch' && inputs.release_branch || github.ref_name }} + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + promote: + runs-on: ubuntu-24.04 + permissions: + actions: read + contents: write + pull-requests: read + env: + TERMUX_AUTOMATION_DIR: ${{ github.workspace }}/.termux-release-automation + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Export GitHub App token for gh + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + run: echo "GH_TOKEN=${APP_TOKEN}" >> "${GITHUB_ENV}" + + - name: Checkout release branch + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.release_branch || github.ref }} + token: ${{ steps.app-token.outputs.token }} + + - name: Checkout automation helpers + uses: actions/checkout@v6 + with: + fetch-depth: 1 + ref: ${{ github.workflow_sha }} + path: .termux-release-automation + token: ${{ steps.app-token.outputs.token }} + + - name: 🧰 Actions Toolbox + uses: wallentx/gh-actions/composite/actions-toolbox@main + + - name: Validate GitHub CLI environment + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-validate-gh-env.sh" + + - name: Resolve release ref + id: release-ref + env: + INPUT_RELEASE_BRANCH: ${{ inputs.release_branch }} + INPUT_RELEASE_SHA: ${{ inputs.release_sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-resolve-release-ref.sh" + + - name: Read release metadata + id: metadata + env: + TERMUX_RELEASE_ACTION: promote + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-read-release-metadata.sh" + + - name: Locate merged pull request + if: steps.metadata.outputs.promote == 'true' + id: pr + env: + RELEASE_BRANCH: ${{ steps.release-ref.outputs.branch }} + RELEASE_SHA: ${{ steps.release-ref.outputs.sha }} + INPUT_PR_NUMBER: ${{ inputs.pr_number }} + INPUT_PR_HEAD_SHA: ${{ inputs.pr_head_sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-find-release-pr.sh" + + - name: Download promoted PR artifact + if: steps.metadata.outputs.promote == 'true' + env: + PR_ARTIFACT_NAME: ${{ steps.pr.outputs.artifact_name }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-download-release-artifact.sh" + + - name: Create or update mirrored Termux release + if: steps.metadata.outputs.promote == 'true' + env: + UPSTREAM_TAG: ${{ steps.metadata.outputs.upstream_tag }} + UPSTREAM_REPO: ${{ steps.metadata.outputs.upstream_repo }} + UPSTREAM_NAME: ${{ steps.metadata.outputs.upstream_name }} + TERMUX_TAG: ${{ steps.metadata.outputs.termux_tag }} + UPSTREAM_PRERELEASE: ${{ steps.metadata.outputs.upstream_prerelease }} + UPSTREAM_HTML_URL: ${{ steps.metadata.outputs.upstream_html_url }} + RELEASE_TRAIN: ${{ steps.metadata.outputs.release_train }} + RELEASE_EXISTS: ${{ steps.metadata.outputs.release_exists }} + PR_NUMBER: ${{ steps.pr.outputs.number }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + RELEASE_SHA: ${{ steps.release-ref.outputs.sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-create-or-update-mirrored-release.sh" diff --git a/scripts/termux-configure-git.sh b/scripts/termux-configure-git.sh new file mode 100755 index 000000000000..fae3abcbab48 --- /dev/null +++ b/scripts/termux-configure-git.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash + +# Configure the explicit bot identity used by release automation and fetch any +# refs requested by the calling workflow. + +set -euo pipefail + +git config user.name "github-actions[bot]" +git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + +while (($#)); do + case "$1" in + --origin) + shift + origin_refs=() + while (($#)) && [[ "$1" != --* ]]; do + origin_refs+=("$1") + shift + done + if ((${#origin_refs[@]})); then + git fetch --prune origin "${origin_refs[@]}" + fi + ;; + --upstream-tag) + if (($# < 3)); then + echo "--upstream-tag requires ." >&2 + exit 1 + fi + upstream_repo="$2" + upstream_tag="$3" + git remote add upstream "https://github.com/${upstream_repo}.git" 2>/dev/null || true + git fetch --prune --no-tags upstream "+refs/tags/${upstream_tag}:refs/tags/${upstream_tag}" + shift 3 + ;; + *) + echo "Unknown argument: $1" >&2 + exit 1 + ;; + esac +done diff --git a/scripts/termux-create-checkpoint-pr.sh b/scripts/termux-create-checkpoint-pr.sh new file mode 100755 index 000000000000..ba07c289952d --- /dev/null +++ b/scripts/termux-create-checkpoint-pr.sh @@ -0,0 +1,275 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/termux-release-paths.sh +source "${script_dir}/termux-release-paths.sh" + +source_branch="${SOURCE_BRANCH:-${REQUESTED_SOURCE_BRANCH:-${GITHUB_REF_NAME}}}" +source_sha="${SOURCE_SHA:-${REQUESTED_SOURCE_SHA:-}}" +if [[ -z "${source_sha}" ]]; then + if [[ "${GITHUB_EVENT_NAME:-}" == "push" && "${source_branch}" == "${GITHUB_REF_NAME:-}" ]]; then + source_sha="${GITHUB_SHA}" + else + source_sha="$(git rev-parse "origin/${source_branch}")" + fi +fi + +if [[ -z "${DESTINATION_BRANCH:-}" ]]; then + echo "DESTINATION_BRANCH is required." >&2 + exit 1 +fi + +release_only_checkpoint_paths() { + printf '%s\n' "${TERMUX_RELEASE_BRANCH_SCRIPT_PATHS[@]}" +} + +resolve_source_version_conflicts() { + local path="$1" + local resolved_path + resolved_path="$(mktemp)" + + if ! awk ' + function normalize_versions(text) { + gsub(/version = "[^"]+"/, "version = \"\"", text) + return text + } + + BEGIN { + in_block = 0 + side = "" + ours = "" + theirs = "" + blocks = 0 + } + + /^<<<<<<< / { + if (in_block) { + exit 1 + } + in_block = 1 + side = "ours" + ours = "" + theirs = "" + blocks++ + next + } + + /^=======$/ && in_block { + side = "theirs" + next + } + + /^>>>>>>> / && in_block { + if (normalize_versions(ours) != normalize_versions(theirs)) { + exit 1 + } + printf "%s", theirs + in_block = 0 + side = "" + next + } + + { + if (!in_block) { + print + } else if (side == "ours") { + ours = ours $0 ORS + } else if (side == "theirs") { + theirs = theirs $0 ORS + } else { + exit 1 + } + } + + END { + if (in_block || blocks == 0) { + exit 1 + } + } + ' "${path}" > "${resolved_path}"; then + rm -f "${resolved_path}" + return 1 + fi + + cp "${resolved_path}" "${path}" + rm -f "${resolved_path}" +} + +short_sha="${source_sha:0:12}" +source_slug="${source_branch//\//_}" +dest_slug="${DESTINATION_BRANCH//\//_}" +checkpoint_branch="checkpoint/${dest_slug}_from_${source_slug}_${short_sha}" +pr_title="checkpoint: into ${DESTINATION_BRANCH} from ${source_branch} @ ${short_sha}" +merge_conflicted=false +conflict_summary="" + +existing_pr="$( + gh pr list \ + --repo "${GITHUB_REPOSITORY}" \ + --head "${checkpoint_branch}" \ + --state all \ + --json number,state,mergedAt,url \ + --jq '[.[] | select(.state == "OPEN" or .mergedAt != null)] | .[0] // empty' +)" +if [[ -n "${existing_pr}" ]]; then + existing_url="$(jq -r '.url' <<< "${existing_pr}")" + existing_state="$(jq -r '.state' <<< "${existing_pr}")" + echo "Checkpoint PR already exists for ${checkpoint_branch}: ${existing_url} (${existing_state})." + if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + echo "pr_url=${existing_url}" >> "${GITHUB_OUTPUT}" + fi + exit 0 +fi + +git checkout -B "${checkpoint_branch}" "origin/${DESTINATION_BRANCH}" + +if ! git merge --no-ff --no-edit "${source_sha}"; then + mapfile -t conflicted_paths < <(git diff --name-only --diff-filter=U) + for conflicted_path in "${conflicted_paths[@]}"; do + if termux_is_checkpoint_release_only_path "${conflicted_path}"; then + echo "Auto-resolving release-only checkpoint conflict in ${conflicted_path} by keeping ${DESTINATION_BRANCH}." + if git cat-file -e "HEAD:${conflicted_path}" 2>/dev/null; then + git checkout --ours -- "${conflicted_path}" + git add "${conflicted_path}" + else + git rm -f --ignore-unmatch "${conflicted_path}" + fi + fi + done + + mapfile -t remaining_conflicts < <(git diff --name-only --diff-filter=U) + if ((${#remaining_conflicts[@]})); then + cargo_version_conflicts=true + for remaining_conflict in "${remaining_conflicts[@]}"; do + case "${remaining_conflict}" in + codex-rs/Cargo.toml|codex-rs/Cargo.lock) + ;; + *) + cargo_version_conflicts=false + ;; + esac + done + + if [[ "${cargo_version_conflicts}" == "true" ]]; then + for remaining_conflict in "${remaining_conflicts[@]}"; do + if ! resolve_source_version_conflicts "${remaining_conflict}"; then + cargo_version_conflicts=false + break + fi + done + + if [[ "${cargo_version_conflicts}" == "true" ]]; then + echo "Auto-resolving recurring Cargo version checkpoint conflicts by keeping ${source_branch} versions." + git add -- "${remaining_conflicts[@]}" + fi + fi + fi + + mapfile -t remaining_conflicts < <(git diff --name-only --diff-filter=U) + if [[ "${#remaining_conflicts[@]}" -eq 0 ]]; then + git commit --no-edit + else + merge_conflicted=true + conflict_summary="$( + printf '%s\n' "${remaining_conflicts[@]}" | awk '{ print "- `" $0 "`" }' + )" + echo "Automatic checkpoint merge failed; creating a manual-resolution PR instead." >&2 + if git rev-parse -q --verify MERGE_HEAD >/dev/null; then + git merge --abort + fi + git checkout -B "${checkpoint_branch}" "${source_sha}" + fi +fi + +if git cat-file -e "origin/${DESTINATION_BRANCH}:.github" 2>/dev/null; then + git restore --source="origin/${DESTINATION_BRANCH}" --staged --worktree -- .github + mapfile -t added_github_paths < <( + git diff --name-only --diff-filter=A "origin/${DESTINATION_BRANCH}" -- .github + ) + if ((${#added_github_paths[@]})); then + git rm -f --ignore-unmatch -- "${added_github_paths[@]}" + fi +else + git rm -r --ignore-unmatch .github +fi + +while IFS= read -r release_only_path; do + if git cat-file -e "origin/${DESTINATION_BRANCH}:${release_only_path}" 2>/dev/null; then + git restore --source="origin/${DESTINATION_BRANCH}" --staged --worktree -- "${release_only_path}" + else + git rm -f --ignore-unmatch -- "${release_only_path}" + fi +done < <(release_only_checkpoint_paths) + +if ! git diff --quiet || ! git diff --cached --quiet; then + if [[ -e .github || -L .github ]] || git ls-files --error-unmatch -- .github >/dev/null 2>&1; then + git add -A .github + fi + while IFS= read -r release_only_path; do + if [[ -e "${release_only_path}" || -L "${release_only_path}" ]] || git ls-files --error-unmatch -- "${release_only_path}" >/dev/null 2>&1; then + git add -A -- "${release_only_path}" + fi + done < <(release_only_checkpoint_paths) + if [[ "${merge_conflicted}" == "true" ]]; then + git commit -m "checkpoint: prepare ${source_branch} for ${DESTINATION_BRANCH}" + else + git commit --amend --no-edit + fi +fi + +if git diff --quiet "origin/${DESTINATION_BRANCH}" HEAD; then + echo "Checkpoint merge produced no destination changes after release-only files were restored." + exit 0 +fi + +git push --force-with-lease origin "${checkpoint_branch}" + +remaining="$( + git log --first-parent --pretty=format:%H "${source_sha}..origin/${source_branch}" | wc -w +)" + +body_path="${RUNNER_TEMP}/termux-checkpoint-pr.md" +{ + echo "## Termux release checkpoint" + echo + echo "- Source branch: \`${source_branch}\`" + echo "- Source hash: \`${source_sha}\`" + echo "- Destination branch: \`${DESTINATION_BRANCH}\`" + echo "- Remaining first-parent commits on source: ${remaining}" + echo + echo "This PR carries release-train conflict fixes and follow-up changes back into the reusable Termux patch branch." + if [[ "${merge_conflicted}" == "true" ]]; then + echo + echo "## Merge conflicts" + echo + echo "GitHub Actions could not create the checkpoint merge commit automatically, so this PR was created from the source branch state for manual conflict resolution." + echo + echo "Conflicted paths from the failed merge attempt:" + if [[ -n "${conflict_summary}" ]]; then + printf '%s\n' "${conflict_summary}" + else + echo "- Conflict details unavailable" + fi + fi + echo + echo "Release-only workflow files and metadata under \`.github\` were restored to the destination branch versions before opening this PR." +} > "${body_path}" + +pr_url="$( + gh pr create \ + --repo "${GITHUB_REPOSITORY}" \ + --base "${DESTINATION_BRANCH}" \ + --head "${checkpoint_branch}" \ + --title "${pr_title}" \ + --body-file "${body_path}" +)" +gh pr edit "${pr_url}" --repo "${GITHUB_REPOSITORY}" --add-reviewer "${REVIEWER}" || true +gh label create checkpoint --repo "${GITHUB_REPOSITORY}" --color c5def5 --description "Checkpoint merge" --force +gh label create termux-release --repo "${GITHUB_REPOSITORY}" --color 0e8a16 --description "Termux release automation" --force +gh pr edit "${pr_url}" --repo "${GITHUB_REPOSITORY}" --add-label "checkpoint" --add-label "termux-release" + +if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + echo "pr_url=${pr_url}" >> "${GITHUB_OUTPUT}" +fi diff --git a/scripts/termux-create-or-update-mirrored-release.sh b/scripts/termux-create-or-update-mirrored-release.sh new file mode 100755 index 000000000000..5787e894582b --- /dev/null +++ b/scripts/termux-create-or-update-mirrored-release.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash + +# Create a mirrored Termux release or repair an existing release that is missing +# the Android tarball. + +set -euo pipefail + +: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" +: "${GH_TOKEN:?GH_TOKEN is required}" +: "${RUNNER_TEMP:?RUNNER_TEMP is required}" +: "${UPSTREAM_TAG:?UPSTREAM_TAG is required}" +: "${UPSTREAM_REPO:?UPSTREAM_REPO is required}" +: "${UPSTREAM_NAME?UPSTREAM_NAME is required}" +: "${TERMUX_TAG:?TERMUX_TAG is required}" +: "${UPSTREAM_PRERELEASE:?UPSTREAM_PRERELEASE is required}" +: "${UPSTREAM_HTML_URL?UPSTREAM_HTML_URL is required}" +: "${RELEASE_TRAIN?RELEASE_TRAIN is required}" +: "${RELEASE_EXISTS:?RELEASE_EXISTS is required}" +: "${PR_NUMBER:?PR_NUMBER is required}" +: "${HEAD_SHA:?HEAD_SHA is required}" +: "${RELEASE_SHA:?RELEASE_SHA is required}" + +promoted_dir="${PROMOTED_DIR:-promoted}" +asset_path="${promoted_dir}/codex-aarch64-linux-android.tar.gz" + +body_path="${RUNNER_TEMP}/release-body.md" +upstream_body_path="${RUNNER_TEMP}/upstream-release-body.md" +upstream_body_without_changelog_path="${RUNNER_TEMP}/upstream-release-body-without-changelog.md" +if gh release view "${UPSTREAM_TAG}" \ + --repo "${UPSTREAM_REPO}" \ + --json body \ + --jq '.body // ""' > "${upstream_body_path}"; then + awk ' + function heading_level(line, text) { + if (match(line, /^(#{1,6})[[:space:]]+(.+)$/, parts)) { + text = tolower(parts[2]) + sub(/[[:space:]]+#+[[:space:]]*$/, "", text) + gsub(/^[[:space:]]+|[[:space:]]+$/, "", text) + if (text == "changelog" || text == "change log") { + return length(parts[1]) + } + } + return 0 + } + + { + if (!skip) { + level = heading_level($0) + if (level > 0) { + skip = 1 + skip_level = level + next + } + print + next + } + + if (match($0, /^(#{1,6})[[:space:]]+/, parts) && length(parts[1]) <= skip_level) { + skip = 0 + print + } + } + ' "${upstream_body_path}" > "${upstream_body_without_changelog_path}" +else + echo "::warning title=Upstream release notes unavailable::Could not read ${UPSTREAM_REPO} release ${UPSTREAM_TAG}." + : > "${upstream_body_without_changelog_path}" +fi + +{ + echo "Termux Android build for ${UPSTREAM_TAG}." + echo + echo "- Upstream release: ${UPSTREAM_HTML_URL}" + echo "- Release train: \`${RELEASE_TRAIN}\`" + echo "- Promoted PR: #${PR_NUMBER}" + echo "- Promoted PR head SHA: \`${HEAD_SHA}\`" +} > "${body_path}" +if [[ -s "${upstream_body_without_changelog_path}" ]]; then + { + echo + echo "## Upstream release notes" + echo + cat "${upstream_body_without_changelog_path}" + } >> "${body_path}" +fi + +release_title="${UPSTREAM_NAME}" +if [[ -z "${release_title}" || "${release_title}" == "null" ]]; then + release_title="${TERMUX_TAG}" +fi + +if [[ "${RELEASE_EXISTS}" == "true" ]]; then + gh release upload \ + "${TERMUX_TAG}" \ + "${asset_path}#codex-termux" \ + --repo "${GITHUB_REPOSITORY}" \ + --clobber + exit 0 +fi + +release_args=( + gh release create "${TERMUX_TAG}" + --repo "${GITHUB_REPOSITORY}" + --target "${RELEASE_SHA}" + --title "${release_title}" + --notes-file "${body_path}" +) +if [[ "${UPSTREAM_PRERELEASE}" == "true" ]]; then + release_args+=(--prerelease) +fi +release_args+=("${asset_path}#codex-termux") +"${release_args[@]}" diff --git a/scripts/termux-download-release-artifact.sh b/scripts/termux-download-release-artifact.sh new file mode 100755 index 000000000000..2acdba9025a0 --- /dev/null +++ b/scripts/termux-download-release-artifact.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ -z "${HEAD_SHA:-}" ]]; then + echo "HEAD_SHA is required." >&2 + exit 1 +fi + +if [[ -z "${PR_ARTIFACT_NAME:-}" ]]; then + echo "PR_ARTIFACT_NAME is required." >&2 + exit 1 +fi + +promoted_dir="${PROMOTED_DIR:-promoted}" + +find_run_id() { + local event="$1" + gh run list \ + --repo "${GITHUB_REPOSITORY}" \ + --workflow rust-release.yml \ + --event "${event}" \ + --status success \ + --commit "${HEAD_SHA}" \ + --limit 1 \ + --json databaseId \ + --jq '.[0].databaseId // empty' +} + +find_artifact_run_id() { + local artifact_name="$1" + + gh api --paginate "repos/${GITHUB_REPOSITORY}/actions/artifacts?name=${artifact_name}" \ + | jq -rs \ + --arg artifact_name "${artifact_name}" \ + --arg head_sha "${HEAD_SHA}" \ + ' + [ + .[].artifacts[] + | select(.name == $artifact_name) + | select(.expired == false) + | select(.workflow_run.head_sha == $head_sha) + ] + | sort_by(.created_at) + | reverse + | .[0].workflow_run.id // empty + ' +} + +artifact_name="${PR_ARTIFACT_NAME}" +run_id="$(find_artifact_run_id "${artifact_name}")" +mkdir -p "${promoted_dir}" +if [[ -n "${run_id}" ]] && gh run download "${run_id}" \ + --repo "${GITHUB_REPOSITORY}" \ + --name "${artifact_name}" \ + --dir "${promoted_dir}"; then + : +else + artifact_name="aarch64-linux-android" + run_id="$(find_run_id workflow_dispatch)" + if [[ -z "${run_id}" ]]; then + echo "No successful rust-release run found for ${HEAD_SHA}" >&2 + exit 1 + fi + gh run download "${run_id}" \ + --repo "${GITHUB_REPOSITORY}" \ + --name "${artifact_name}" \ + --dir "${promoted_dir}" +fi + +ls -la "${promoted_dir}" +if [[ -f "${promoted_dir}/SHA256SUMS" ]]; then + (cd "${promoted_dir}" && sha256sum -c SHA256SUMS) +fi +if [[ ! -f "${promoted_dir}/codex-aarch64-linux-android.tar.gz" ]]; then + echo "Expected ${promoted_dir}/codex-aarch64-linux-android.tar.gz in the downloaded artifact." >&2 + exit 1 +fi diff --git a/scripts/termux-find-release-pr.sh b/scripts/termux-find-release-pr.sh new file mode 100755 index 000000000000..3e35fa42eabe --- /dev/null +++ b/scripts/termux-find-release-pr.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ -n "${INPUT_PR_NUMBER:-}" || -n "${INPUT_PR_HEAD_SHA:-}" ]]; then + if [[ -z "${INPUT_PR_NUMBER:-}" || -z "${INPUT_PR_HEAD_SHA:-}" ]]; then + echo "workflow_dispatch inputs pr_number and pr_head_sha must be provided together." >&2 + exit 1 + fi + + pr_number="${INPUT_PR_NUMBER}" + head_sha="${INPUT_PR_HEAD_SHA}" + head_ref="" +else + if [[ -z "${RELEASE_BRANCH:-}" || -z "${RELEASE_SHA:-}" ]]; then + echo "RELEASE_BRANCH and RELEASE_SHA are required." >&2 + exit 1 + fi + + pr_json="$( + gh pr list \ + --repo "${GITHUB_REPOSITORY}" \ + --base "${RELEASE_BRANCH}" \ + --state merged \ + --limit 100 \ + --json number,headRefOid,headRefName,mergeCommit \ + --jq "[.[] | select(.mergeCommit.oid == \"${RELEASE_SHA}\")] | sort_by(.number) | reverse | .[0] // empty" + )" + if [[ -z "${pr_json}" ]]; then + echo "Unable to find merged PR for ${RELEASE_SHA} into ${RELEASE_BRANCH}" >&2 + exit 1 + fi + + pr_number="$(jq -r '.number' <<< "${pr_json}")" + head_sha="$(jq -r '.headRefOid // .head.sha' <<< "${pr_json}")" + head_ref="$(jq -r '.headRefName // .head.ref' <<< "${pr_json}")" +fi + +artifact_name="termux-android-pr-${pr_number}-${head_sha}" +if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + { + echo "number=${pr_number}" + echo "head_sha=${head_sha}" + echo "head_ref=${head_ref}" + echo "artifact_name=${artifact_name}" + } >> "${GITHUB_OUTPUT}" +else + printf 'number=%s\nhead_sha=%s\nhead_ref=%s\nartifact_name=%s\n' \ + "${pr_number}" \ + "${head_sha}" \ + "${head_ref}" \ + "${artifact_name}" +fi diff --git a/scripts/termux-read-release-metadata.sh b/scripts/termux-read-release-metadata.sh new file mode 100755 index 000000000000..519a0635a368 --- /dev/null +++ b/scripts/termux-read-release-metadata.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash + +# Parse .github/termux-release.json and emit workflow outputs for deploy or +# promote jobs. TERMUX_RELEASE_ACTION must be "deploy" or "promote". + +set -euo pipefail + +: "${GITHUB_OUTPUT:?GITHUB_OUTPUT is required}" +: "${GH_TOKEN:?GH_TOKEN is required}" + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + +metadata=".github/termux-release.json" +action="${TERMUX_RELEASE_ACTION:-deploy}" +case "${action}" in + deploy) + missing_context="deployment" + ;; + promote) + missing_context="promotion" + ;; + *) + echo "TERMUX_RELEASE_ACTION must be deploy or promote." >&2 + exit 1 + ;; +esac + +if [[ ! -f "${metadata}" ]]; then + echo "No ${metadata}; this push is not a Termux release ${missing_context}." + echo "${action}=false" >> "${GITHUB_OUTPUT}" + exit 0 +fi + +upstream_tag="$(jq -r '.upstream_tag // empty' "${metadata}")" +upstream_name="$(jq -r '.upstream_name // .upstream_tag // empty' "${metadata}")" +termux_tag="$(jq -r '.termux_tag // empty' "${metadata}")" +upstream_version="${upstream_tag#rust-v}" +upstream_version="${upstream_version%-termux}" +upstream_prerelease=false +if [[ "${upstream_version}" == *-* ]]; then + upstream_prerelease=true +fi +upstream_html_url="$(jq -r '.upstream_html_url // ""' "${metadata}")" +upstream_repo="$(jq -r '.upstream_repo // "openai/codex"' "${metadata}")" +release_train="$(jq -r '.release_train // ""' "${metadata}")" +if [[ -z "${upstream_tag}" || -z "${termux_tag}" ]]; then + echo "Missing upstream_tag or termux_tag in ${metadata}" >&2 + exit 1 +fi + +release_state="$(TERMUX_TAG="${termux_tag}" "${script_dir}/termux-release-asset-state.sh")" +release_exists="$(awk -F= '$1 == "release_exists" { print $2 }' <<< "${release_state}")" +asset_exists="$(awk -F= '$1 == "asset_exists" { print $2 }' <<< "${release_state}")" + +if [[ "${action}" == "promote" ]]; then + if [[ "${asset_exists}" == "true" ]]; then + echo "${termux_tag} already exists with codex-aarch64-linux-android.tar.gz; skipping promotion." + echo "promote=false" >> "${GITHUB_OUTPUT}" + exit 0 + fi + if [[ "${release_exists}" == "true" ]]; then + echo "${termux_tag} exists but is missing codex-aarch64-linux-android.tar.gz; repairing promotion." + fi +fi + +{ + echo "${action}=true" + echo "upstream_tag=${upstream_tag}" + echo "upstream_name=${upstream_name}" + echo "termux_tag=${termux_tag}" + echo "upstream_prerelease=${upstream_prerelease}" + echo "upstream_html_url=${upstream_html_url}" + echo "upstream_repo=${upstream_repo}" + echo "release_train=${release_train}" + echo "release_exists=${release_exists}" + echo "asset_exists=${asset_exists}" +} >> "${GITHUB_OUTPUT}" diff --git a/scripts/termux-release-asset-state.sh b/scripts/termux-release-asset-state.sh new file mode 100755 index 000000000000..8694480b6a36 --- /dev/null +++ b/scripts/termux-release-asset-state.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash + +set -euo pipefail + +termux_tag="${1:-${TERMUX_TAG:-}}" +release_repo="${RELEASE_REPO:-${GITHUB_REPOSITORY:-}}" +asset_name="${ASSET_NAME:-codex-aarch64-linux-android.tar.gz}" + +if [[ -z "${termux_tag}" ]]; then + echo "TERMUX_TAG or tag argument is required." >&2 + exit 1 +fi + +if [[ -z "${release_repo}" ]]; then + echo "GITHUB_REPOSITORY or RELEASE_REPO is required." >&2 + exit 1 +fi + +release_exists=false +asset_exists=false + +if gh release view "${termux_tag}" --repo "${release_repo}" >/dev/null 2>&1; then + release_exists=true + release_asset_exists="$( + gh release view "${termux_tag}" \ + --repo "${release_repo}" \ + --json assets \ + | jq -r --arg asset_name "${asset_name}" \ + '.assets | map(.name) | any(. == $asset_name)' + )" + if [[ "${release_asset_exists}" == "true" ]]; then + asset_exists=true + fi +fi + +printf 'release_exists=%s\n' "${release_exists}" +printf 'asset_exists=%s\n' "${asset_exists}" diff --git a/scripts/termux-release-paths.sh b/scripts/termux-release-paths.sh new file mode 100755 index 000000000000..6990f323ec44 --- /dev/null +++ b/scripts/termux-release-paths.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash + +# Sourceable path lists for files owned by the Termux release automation. + +set -euo pipefail + +readonly -a TERMUX_RELEASE_WORKFLOW_PATHS=( + .github/workflows/rust-release.yml + .github/workflows/shell-tool-mcp.yml + .github/workflows/termux-release-checkpoint.yml + .github/workflows/termux-release-deploy.yml + .github/workflows/termux-release-promote.yml +) + +readonly -a TERMUX_RELEASE_BRANCH_SCRIPT_PATHS=( + scripts/termux-configure-git.sh + scripts/termux-create-checkpoint-pr.sh + scripts/termux-create-or-update-mirrored-release.sh + scripts/termux-download-release-artifact.sh + scripts/termux-find-release-pr.sh + scripts/termux-read-release-metadata.sh + scripts/termux-release-asset-state.sh + scripts/termux-release-paths.sh + scripts/termux-resolve-release-ref.sh + scripts/termux-validate-gh-env.sh +) + +readonly -a TERMUX_RELEASE_AUTOMATION_PATHS=( + "${TERMUX_RELEASE_WORKFLOW_PATHS[@]}" + "${TERMUX_RELEASE_BRANCH_SCRIPT_PATHS[@]}" +) + +readonly -a TERMUX_CHECKPOINT_RELEASE_ONLY_PATHS=( + "${TERMUX_RELEASE_AUTOMATION_PATHS[@]}" + .github/termux-release.json +) + +termux_path_in_list() { + local candidate="$1" + shift + local listed_path + + for listed_path in "$@"; do + [[ "${candidate}" != "${listed_path}" ]] || return 0 + done + return 1 +} + +termux_is_release_automation_path() { + termux_path_in_list "$1" "${TERMUX_RELEASE_AUTOMATION_PATHS[@]}" +} + +termux_is_checkpoint_release_only_path() { + termux_path_in_list "$1" "${TERMUX_CHECKPOINT_RELEASE_ONLY_PATHS[@]}" +} diff --git a/scripts/termux-resolve-release-ref.sh b/scripts/termux-resolve-release-ref.sh new file mode 100755 index 000000000000..194773d45bed --- /dev/null +++ b/scripts/termux-resolve-release-ref.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash + +# Resolve the release branch and SHA for deploy/promote workflows. + +set -euo pipefail + +: "${GITHUB_EVENT_NAME:?GITHUB_EVENT_NAME is required}" +: "${GITHUB_OUTPUT:?GITHUB_OUTPUT is required}" + +input_release_branch="${INPUT_RELEASE_BRANCH:-${REQUESTED_RELEASE_BRANCH:-}}" +input_release_sha="${INPUT_RELEASE_SHA:-${REQUESTED_RELEASE_SHA:-}}" + +if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then + if [[ -z "${input_release_branch}" ]]; then + echo "release_branch is required for workflow_dispatch." >&2 + exit 1 + fi + + release_branch="${input_release_branch}" + if [[ -n "${input_release_sha}" ]]; then + git checkout --detach "${input_release_sha}" + release_sha="${input_release_sha}" + else + release_sha="$(git rev-parse HEAD)" + fi +else + : "${GITHUB_REF_NAME:?GITHUB_REF_NAME is required}" + : "${GITHUB_SHA:?GITHUB_SHA is required}" + release_branch="${GITHUB_REF_NAME}" + release_sha="${GITHUB_SHA}" +fi + +{ + echo "branch=${release_branch}" + echo "sha=${release_sha}" +} >> "${GITHUB_OUTPUT}" diff --git a/scripts/termux-validate-gh-env.sh b/scripts/termux-validate-gh-env.sh new file mode 100755 index 000000000000..02581fdebceb --- /dev/null +++ b/scripts/termux-validate-gh-env.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash + +# Lightweight post-toolbox check for jobs that rely on authenticated gh calls. + +set -euo pipefail + +: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" +: "${GH_TOKEN:?GH_TOKEN is required}" + +command -v gh +gh auth status --hostname github.com + +printf 'GITHUB_REPOSITORY=%s\n' "${GITHUB_REPOSITORY}" +printf 'REPO=%s\n' "${REPO:-}" +printf 'GH_REPO_URL=%s\n' "${GH_REPO_URL:-}" +printf 'GH_WORKFLOW_URL=%s\n' "${GH_WORKFLOW_URL:-}" From bb38ff12834cab48b37638880da7f94c23371d6e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 07:12:29 +0000 Subject: [PATCH 08/64] Prepare Termux rust-v0.132.0 --- .github/termux-release.json | 15 ++ codex-rs/Cargo.lock | 247 ++++++++++++++------------- codex-rs/Cargo.toml | 2 + codex-rs/arg0/Cargo.toml | 1 + codex-rs/arg0/src/lib.rs | 29 +++- codex-rs/core/Cargo.toml | 5 + codex-rs/core/src/installation_id.rs | 8 +- codex-rs/execpolicy/Cargo.toml | 1 + codex-rs/execpolicy/src/amend.rs | 23 ++- codex-rs/tui/Cargo.toml | 2 +- codex-rs/tui/src/lib.rs | 8 +- codex-rs/utils/file-lock/BUILD.bazel | 6 + codex-rs/utils/file-lock/Cargo.toml | 10 ++ codex-rs/utils/file-lock/src/lib.rs | 168 ++++++++++++++++++ justfile | 5 + scripts/termux-lock-audit.sh | 126 ++++++++++++++ 16 files changed, 518 insertions(+), 138 deletions(-) create mode 100644 .github/termux-release.json create mode 100644 codex-rs/utils/file-lock/BUILD.bazel create mode 100644 codex-rs/utils/file-lock/Cargo.toml create mode 100644 codex-rs/utils/file-lock/src/lib.rs create mode 100755 scripts/termux-lock-audit.sh diff --git a/.github/termux-release.json b/.github/termux-release.json new file mode 100644 index 000000000000..3c4f4d419162 --- /dev/null +++ b/.github/termux-release.json @@ -0,0 +1,15 @@ +{ + "upstream_repo": "openai/codex", + "upstream_tag": "rust-v0.132.0", + "upstream_name": "0.132.0", + "upstream_html_url": "https://github.com/openai/codex/releases/tag/rust-v0.132.0", + "upstream_target": "main", + "upstream_release_id": "325545332", + "upstream_prerelease": false, + "release_train": "0.132.0", + "release_branch": "release/0.132.0", + "work_branch": "upstream/rust-v0.132.0", + "patch_branch": "wallentx/termux-target", + "patch_source_sha": "0e4b5a7e6b6a316c6e2f66b8223a490b9f093842", + "termux_tag": "rust-v0.132.0-termux" +} diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 5ced1803fa4c..2f9714c07d2c 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -402,7 +402,7 @@ checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" [[package]] name = "app_test_support" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "base64 0.22.1", @@ -1778,7 +1778,7 @@ dependencies = [ [[package]] name = "codex-agent-graph-store" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "codex-protocol", @@ -1793,7 +1793,7 @@ dependencies = [ [[package]] name = "codex-agent-identity" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "base64 0.22.1", @@ -1812,7 +1812,7 @@ dependencies = [ [[package]] name = "codex-analytics" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-app-server-protocol", "codex-git-utils", @@ -1832,7 +1832,7 @@ dependencies = [ [[package]] name = "codex-ansi-escape" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "ansi-to-tui", "ratatui", @@ -1841,7 +1841,7 @@ dependencies = [ [[package]] name = "codex-api" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "assert_matches", @@ -1875,7 +1875,7 @@ dependencies = [ [[package]] name = "codex-app-server" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "app_test_support", @@ -1960,7 +1960,7 @@ dependencies = [ [[package]] name = "codex-app-server-client" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-app-server", "codex-app-server-protocol", @@ -1987,7 +1987,7 @@ dependencies = [ [[package]] name = "codex-app-server-daemon" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "codex-app-server-protocol", @@ -2008,7 +2008,7 @@ dependencies = [ [[package]] name = "codex-app-server-protocol" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "clap", @@ -2035,7 +2035,7 @@ dependencies = [ [[package]] name = "codex-app-server-test-client" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "clap", @@ -2056,7 +2056,7 @@ dependencies = [ [[package]] name = "codex-app-server-transport" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "axum", @@ -2095,7 +2095,7 @@ dependencies = [ [[package]] name = "codex-apply-patch" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "assert_cmd", @@ -2114,7 +2114,7 @@ dependencies = [ [[package]] name = "codex-arg0" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "codex-apply-patch", @@ -2123,6 +2123,7 @@ dependencies = [ "codex-sandboxing", "codex-shell-escalation", "codex-utils-absolute-path", + "codex-utils-file-lock", "codex-utils-home-dir", "dotenvy", "tempfile", @@ -2131,7 +2132,7 @@ dependencies = [ [[package]] name = "codex-async-utils" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "pretty_assertions", @@ -2141,7 +2142,7 @@ dependencies = [ [[package]] name = "codex-aws-auth" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "aws-config", "aws-credential-types", @@ -2156,7 +2157,7 @@ dependencies = [ [[package]] name = "codex-backend-client" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "codex-api", @@ -2173,7 +2174,7 @@ dependencies = [ [[package]] name = "codex-backend-openapi-models" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "serde", "serde_json", @@ -2182,7 +2183,7 @@ dependencies = [ [[package]] name = "codex-bwrap" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "cc", "libc", @@ -2191,7 +2192,7 @@ dependencies = [ [[package]] name = "codex-chatgpt" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "clap", @@ -2214,7 +2215,7 @@ dependencies = [ [[package]] name = "codex-cli" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "assert_cmd", @@ -2279,7 +2280,7 @@ dependencies = [ [[package]] name = "codex-client" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "bytes", @@ -2310,7 +2311,7 @@ dependencies = [ [[package]] name = "codex-cloud-requirements" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "base64 0.22.1", @@ -2335,7 +2336,7 @@ dependencies = [ [[package]] name = "codex-cloud-tasks" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "async-trait", @@ -2367,7 +2368,7 @@ dependencies = [ [[package]] name = "codex-cloud-tasks-client" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "async-trait", @@ -2382,7 +2383,7 @@ dependencies = [ [[package]] name = "codex-cloud-tasks-mock-client" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "chrono", @@ -2392,7 +2393,7 @@ dependencies = [ [[package]] name = "codex-code-mode" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-channel", "async-trait", @@ -2409,11 +2410,11 @@ dependencies = [ [[package]] name = "codex-collaboration-mode-templates" -version = "0.0.0" +version = "0.132.0-alpha.1" [[package]] name = "codex-config" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "async-trait", @@ -2460,7 +2461,7 @@ dependencies = [ [[package]] name = "codex-connectors" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "codex-app-server-protocol", @@ -2476,7 +2477,7 @@ dependencies = [ [[package]] name = "codex-core" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "arc-swap", @@ -2530,6 +2531,7 @@ dependencies = [ "codex-utils-absolute-path", "codex-utils-cache", "codex-utils-cargo-bin", + "codex-utils-file-lock", "codex-utils-home-dir", "codex-utils-image", "codex-utils-output-truncation", @@ -2594,7 +2596,7 @@ dependencies = [ [[package]] name = "codex-core-api" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-analytics", "codex-app-server-protocol", @@ -2613,7 +2615,7 @@ dependencies = [ [[package]] name = "codex-core-plugins" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "chrono", @@ -2651,7 +2653,7 @@ dependencies = [ [[package]] name = "codex-core-skills" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "codex-analytics", @@ -2682,7 +2684,7 @@ dependencies = [ [[package]] name = "codex-debug-client" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "clap", @@ -2694,7 +2696,7 @@ dependencies = [ [[package]] name = "codex-exec" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "assert_cmd", @@ -2740,7 +2742,7 @@ dependencies = [ [[package]] name = "codex-exec-server" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "arc-swap", @@ -2781,11 +2783,12 @@ dependencies = [ [[package]] name = "codex-execpolicy" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "clap", "codex-utils-absolute-path", + "codex-utils-file-lock", "multimap", "pretty_assertions", "serde", @@ -2798,7 +2801,7 @@ dependencies = [ [[package]] name = "codex-execpolicy-legacy" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "allocative", "anyhow", @@ -2818,7 +2821,7 @@ dependencies = [ [[package]] name = "codex-experimental-api-macros" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "proc-macro2", "quote", @@ -2827,7 +2830,7 @@ dependencies = [ [[package]] name = "codex-extension-api" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "codex-protocol", @@ -2836,7 +2839,7 @@ dependencies = [ [[package]] name = "codex-external-agent-migration" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-hooks", "pretty_assertions", @@ -2848,7 +2851,7 @@ dependencies = [ [[package]] name = "codex-external-agent-sessions" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "chrono", "codex-app-server-protocol", @@ -2862,7 +2865,7 @@ dependencies = [ [[package]] name = "codex-features" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-otel", "codex-protocol", @@ -2875,7 +2878,7 @@ dependencies = [ [[package]] name = "codex-feedback" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "codex-login", @@ -2888,7 +2891,7 @@ dependencies = [ [[package]] name = "codex-file-search" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "clap", @@ -2904,7 +2907,7 @@ dependencies = [ [[package]] name = "codex-file-system" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "codex-protocol", @@ -2914,7 +2917,7 @@ dependencies = [ [[package]] name = "codex-file-watcher" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "notify", "pretty_assertions", @@ -2925,7 +2928,7 @@ dependencies = [ [[package]] name = "codex-git-utils" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "chrono", @@ -2949,7 +2952,7 @@ dependencies = [ [[package]] name = "codex-goal-extension" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "codex-extension-api", @@ -2961,7 +2964,7 @@ dependencies = [ [[package]] name = "codex-guardian" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "codex-core", @@ -2971,7 +2974,7 @@ dependencies = [ [[package]] name = "codex-hooks" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "chrono", @@ -2994,7 +2997,7 @@ dependencies = [ [[package]] name = "codex-install-context" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-utils-home-dir", "pretty_assertions", @@ -3003,7 +3006,7 @@ dependencies = [ [[package]] name = "codex-keyring-store" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "keyring", "tracing", @@ -3011,7 +3014,7 @@ dependencies = [ [[package]] name = "codex-linux-sandbox" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "clap", "codex-core", @@ -3034,7 +3037,7 @@ dependencies = [ [[package]] name = "codex-lmstudio" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-core", "codex-model-provider-info", @@ -3048,7 +3051,7 @@ dependencies = [ [[package]] name = "codex-login" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "async-trait", @@ -3090,7 +3093,7 @@ dependencies = [ [[package]] name = "codex-mcp" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "async-channel", @@ -3122,7 +3125,7 @@ dependencies = [ [[package]] name = "codex-mcp-server" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "codex-arg0", @@ -3154,7 +3157,7 @@ dependencies = [ [[package]] name = "codex-memories-extension" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "codex-core", @@ -3175,7 +3178,7 @@ dependencies = [ [[package]] name = "codex-memories-mcp" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "codex-utils-absolute-path", @@ -3192,7 +3195,7 @@ dependencies = [ [[package]] name = "codex-memories-read" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-protocol", "codex-shell-command", @@ -3206,7 +3209,7 @@ dependencies = [ [[package]] name = "codex-memories-write" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "chrono", @@ -3241,7 +3244,7 @@ dependencies = [ [[package]] name = "codex-message-history" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-config", "pretty_assertions", @@ -3254,7 +3257,7 @@ dependencies = [ [[package]] name = "codex-model-provider" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "codex-agent-identity", @@ -3278,7 +3281,7 @@ dependencies = [ [[package]] name = "codex-model-provider-info" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-api", "codex-app-server-protocol", @@ -3295,7 +3298,7 @@ dependencies = [ [[package]] name = "codex-models-manager" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "chrono", @@ -3316,7 +3319,7 @@ dependencies = [ [[package]] name = "codex-network-proxy" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "async-trait", @@ -3347,7 +3350,7 @@ dependencies = [ [[package]] name = "codex-ollama" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "assert_matches", "async-stream", @@ -3366,7 +3369,7 @@ dependencies = [ [[package]] name = "codex-otel" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "chrono", "codex-api", @@ -3398,7 +3401,7 @@ dependencies = [ [[package]] name = "codex-plugin" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-config", "codex-utils-absolute-path", @@ -3408,7 +3411,7 @@ dependencies = [ [[package]] name = "codex-process-hardening" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "libc", "pretty_assertions", @@ -3416,7 +3419,7 @@ dependencies = [ [[package]] name = "codex-protocol" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "chardetng", @@ -3456,7 +3459,7 @@ dependencies = [ [[package]] name = "codex-realtime-webrtc" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "libwebrtc", "thiserror 2.0.18", @@ -3465,7 +3468,7 @@ dependencies = [ [[package]] name = "codex-response-debug-context" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "base64 0.22.1", "codex-api", @@ -3476,7 +3479,7 @@ dependencies = [ [[package]] name = "codex-responses-api-proxy" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "clap", @@ -3493,7 +3496,7 @@ dependencies = [ [[package]] name = "codex-rmcp-client" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "axum", @@ -3531,7 +3534,7 @@ dependencies = [ [[package]] name = "codex-rollout" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "async-trait", @@ -3556,7 +3559,7 @@ dependencies = [ [[package]] name = "codex-rollout-trace" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "codex-code-mode", @@ -3572,7 +3575,7 @@ dependencies = [ [[package]] name = "codex-sandboxing" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "async-trait", @@ -3593,7 +3596,7 @@ dependencies = [ [[package]] name = "codex-secrets" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "age", "anyhow", @@ -3614,7 +3617,7 @@ dependencies = [ [[package]] name = "codex-shell-command" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "base64 0.22.1", @@ -3634,7 +3637,7 @@ dependencies = [ [[package]] name = "codex-shell-escalation" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "async-trait", @@ -3655,7 +3658,7 @@ dependencies = [ [[package]] name = "codex-skills" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-utils-absolute-path", "include_dir", @@ -3664,7 +3667,7 @@ dependencies = [ [[package]] name = "codex-state" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "chrono", @@ -3687,7 +3690,7 @@ dependencies = [ [[package]] name = "codex-stdio-to-uds" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "codex-uds", @@ -3699,7 +3702,7 @@ dependencies = [ [[package]] name = "codex-terminal-detection" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "pretty_assertions", "tracing", @@ -3707,7 +3710,7 @@ dependencies = [ [[package]] name = "codex-test-binary-support" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-arg0", "tempfile", @@ -3715,7 +3718,7 @@ dependencies = [ [[package]] name = "codex-thread-manager-sample" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "clap", @@ -3726,7 +3729,7 @@ dependencies = [ [[package]] name = "codex-thread-store" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "chrono", @@ -3747,7 +3750,7 @@ dependencies = [ [[package]] name = "codex-tools" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "codex-app-server-protocol", @@ -3767,7 +3770,7 @@ dependencies = [ [[package]] name = "codex-tui" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "arboard", @@ -3878,7 +3881,7 @@ dependencies = [ [[package]] name = "codex-uds" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-io", "pretty_assertions", @@ -3890,7 +3893,7 @@ dependencies = [ [[package]] name = "codex-utils-absolute-path" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "dirs", "dunce", @@ -3904,14 +3907,14 @@ dependencies = [ [[package]] name = "codex-utils-approval-presets" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-protocol", ] [[package]] name = "codex-utils-cache" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "lru 0.16.3", "sha1", @@ -3920,7 +3923,7 @@ dependencies = [ [[package]] name = "codex-utils-cargo-bin" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "assert_cmd", "runfiles", @@ -3929,7 +3932,7 @@ dependencies = [ [[package]] name = "codex-utils-cli" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "clap", "codex-protocol", @@ -3941,15 +3944,19 @@ dependencies = [ [[package]] name = "codex-utils-elapsed" -version = "0.0.0" +version = "0.132.0-alpha.1" + +[[package]] +name = "codex-utils-file-lock" +version = "0.132.0-alpha.1" [[package]] name = "codex-utils-fuzzy-match" -version = "0.0.0" +version = "0.132.0-alpha.1" [[package]] name = "codex-utils-home-dir" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-utils-absolute-path", "dirs", @@ -3959,7 +3966,7 @@ dependencies = [ [[package]] name = "codex-utils-image" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "base64 0.22.1", "codex-utils-cache", @@ -3971,7 +3978,7 @@ dependencies = [ [[package]] name = "codex-utils-json-to-toml" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "pretty_assertions", "serde_json", @@ -3980,7 +3987,7 @@ dependencies = [ [[package]] name = "codex-utils-oss" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-core", "codex-lmstudio", @@ -3990,7 +3997,7 @@ dependencies = [ [[package]] name = "codex-utils-output-truncation" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-protocol", "codex-utils-string", @@ -3999,7 +4006,7 @@ dependencies = [ [[package]] name = "codex-utils-path" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-utils-absolute-path", "dunce", @@ -4009,7 +4016,7 @@ dependencies = [ [[package]] name = "codex-utils-plugins" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-exec-server", "codex-login", @@ -4022,7 +4029,7 @@ dependencies = [ [[package]] name = "codex-utils-pty" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "filedescriptor", @@ -4038,7 +4045,7 @@ dependencies = [ [[package]] name = "codex-utils-readiness" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "assert_matches", "async-trait", @@ -4049,14 +4056,14 @@ dependencies = [ [[package]] name = "codex-utils-rustls-provider" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "rustls", ] [[package]] name = "codex-utils-sandbox-summary" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-core", "codex-model-provider-info", @@ -4067,7 +4074,7 @@ dependencies = [ [[package]] name = "codex-utils-sleep-inhibitor" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "core-foundation 0.9.4", "libc", @@ -4077,14 +4084,14 @@ dependencies = [ [[package]] name = "codex-utils-stream-parser" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "pretty_assertions", ] [[package]] name = "codex-utils-string" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "pretty_assertions", "regex-lite", @@ -4094,14 +4101,14 @@ dependencies = [ [[package]] name = "codex-utils-template" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "pretty_assertions", ] [[package]] name = "codex-v8-poc" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "pretty_assertions", "v8", @@ -4109,7 +4116,7 @@ dependencies = [ [[package]] name = "codex-windows-sandbox" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "base64 0.22.1", @@ -4360,7 +4367,7 @@ dependencies = [ [[package]] name = "core_test_support" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "assert_cmd", @@ -5474,7 +5481,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8537,7 +8544,7 @@ dependencies = [ [[package]] name = "mcp_test_support" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "codex-login", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 5e0b16c4cb11..447c680eabd0 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -81,6 +81,7 @@ members = [ "v8-poc", "utils/absolute-path", "utils/cargo-bin", + "utils/file-lock", "git-utils", "utils/cache", "utils/image", @@ -219,6 +220,7 @@ codex-utils-cache = { path = "utils/cache" } codex-utils-cargo-bin = { path = "utils/cargo-bin" } codex-utils-cli = { path = "utils/cli" } codex-utils-elapsed = { path = "utils/elapsed" } +codex-utils-file-lock = { path = "utils/file-lock" } codex-utils-fuzzy-match = { path = "utils/fuzzy-match" } codex-utils-home-dir = { path = "utils/home-dir" } codex-utils-image = { path = "utils/image" } diff --git a/codex-rs/arg0/Cargo.toml b/codex-rs/arg0/Cargo.toml index 7ee21a770e49..7c2c17692020 100644 --- a/codex-rs/arg0/Cargo.toml +++ b/codex-rs/arg0/Cargo.toml @@ -20,6 +20,7 @@ codex-linux-sandbox = { workspace = true } codex-sandboxing = { workspace = true } codex-shell-escalation = { workspace = true } codex-utils-absolute-path = { workspace = true } +codex-utils-file-lock = { workspace = true } codex-utils-home-dir = { workspace = true } dotenvy = { workspace = true } tempfile = { workspace = true } diff --git a/codex-rs/arg0/src/lib.rs b/codex-rs/arg0/src/lib.rs index 2f6ae4653c65..5a558769a7e5 100644 --- a/codex-rs/arg0/src/lib.rs +++ b/codex-rs/arg0/src/lib.rs @@ -6,6 +6,8 @@ use std::path::PathBuf; use codex_apply_patch::CODEX_CORE_APPLY_PATCH_ARG1; use codex_exec_server::CODEX_FS_HELPER_ARG1; use codex_sandboxing::landlock::CODEX_LINUX_SANDBOX_ARG0; +use codex_utils_file_lock::TryFileLockOutcome; +use codex_utils_file_lock::try_lock_exclusive_optional; use codex_utils_home_dir::find_codex_home; #[cfg(unix)] use std::os::unix::fs::symlink; @@ -33,12 +35,12 @@ pub struct Arg0DispatchPaths { /// Keeps the per-session PATH entry alive and locked for the process lifetime. pub struct Arg0PathEntryGuard { _temp_dir: TempDir, - _lock_file: File, + _lock_file: Option, paths: Arg0DispatchPaths, } impl Arg0PathEntryGuard { - fn new(temp_dir: TempDir, lock_file: File, paths: Arg0DispatchPaths) -> Self { + fn new(temp_dir: TempDir, lock_file: Option, paths: Arg0DispatchPaths) -> Self { Self { _temp_dir: temp_dir, _lock_file: lock_file, @@ -326,7 +328,13 @@ pub fn prepend_path_entry_for_codex_aliases() -> std::io::Result Some(lock_file), + TryFileLockOutcome::Unsupported => None, + TryFileLockOutcome::WouldBlock => { + return Err(std::io::Error::from(std::io::ErrorKind::WouldBlock).into()); + } + }; for filename in &[ APPLY_PATCH_ARG0, @@ -445,10 +453,9 @@ fn try_lock_dir(dir: &Path) -> std::io::Result> { Err(err) => return Err(err), }; - match lock_file.try_lock() { - Ok(()) => Ok(Some(lock_file)), - Err(std::fs::TryLockError::WouldBlock) => Ok(None), - Err(err) => Err(err.into()), + match try_lock_exclusive_optional(&lock_file)? { + TryFileLockOutcome::Acquired => Ok(Some(lock_file)), + TryFileLockOutcome::WouldBlock | TryFileLockOutcome::Unsupported => Ok(None), } } @@ -562,7 +569,13 @@ mod tests { let dir = root.path().join("locked"); fs::create_dir(&dir)?; let lock_file = create_lock(&dir)?; - lock_file.try_lock()?; + match try_lock_exclusive_optional(&lock_file)? { + TryFileLockOutcome::Acquired => {} + TryFileLockOutcome::Unsupported => return Ok(()), + TryFileLockOutcome::WouldBlock => { + panic!("newly created lock file should not be locked"); + } + } janitor_cleanup(root.path())?; diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 8472918dcae9..fdf1d4b18a9e 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -63,6 +63,7 @@ codex-thread-store = { workspace = true } codex-tools = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-cache = { workspace = true } +codex-utils-file-lock = { workspace = true } codex-utils-image = { workspace = true } codex-utils-home-dir = { workspace = true } codex-utils-output-truncation = { workspace = true } @@ -125,6 +126,10 @@ openssl-sys = { workspace = true, features = ["vendored"] } [target.aarch64-unknown-linux-musl.dependencies] openssl-sys = { workspace = true, features = ["vendored"] } +# Build OpenSSL from source for Android builds. +[target.aarch64-linux-android.dependencies] +openssl-sys = { workspace = true, features = ["vendored"] } + [target.'cfg(unix)'.dependencies] codex-shell-escalation = { workspace = true } diff --git a/codex-rs/core/src/installation_id.rs b/codex-rs/core/src/installation_id.rs index a42e6b6d8353..a87c660f6132 100644 --- a/codex-rs/core/src/installation_id.rs +++ b/codex-rs/core/src/installation_id.rs @@ -11,6 +11,9 @@ use std::os::unix::fs::OpenOptionsExt; use std::os::unix::fs::PermissionsExt; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_file_lock::FileLockOutcome; +use codex_utils_file_lock::acquire_sibling_lock_dir; +use codex_utils_file_lock::lock_exclusive_optional; use tokio::fs; use uuid::Uuid; @@ -29,7 +32,10 @@ pub async fn resolve_installation_id(codex_home: &AbsolutePathBuf) -> Result None, + FileLockOutcome::Unsupported => Some(acquire_sibling_lock_dir(&path)?), + }; #[cfg(unix)] { diff --git a/codex-rs/execpolicy/Cargo.toml b/codex-rs/execpolicy/Cargo.toml index b22226a79e4b..b87af084d7d1 100644 --- a/codex-rs/execpolicy/Cargo.toml +++ b/codex-rs/execpolicy/Cargo.toml @@ -21,6 +21,7 @@ workspace = true anyhow = { workspace = true } clap = { workspace = true, features = ["derive"] } codex-utils-absolute-path = { workspace = true } +codex-utils-file-lock = { workspace = true } multimap = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } diff --git a/codex-rs/execpolicy/src/amend.rs b/codex-rs/execpolicy/src/amend.rs index e25fd1bd9144..1ef36b1ff5d8 100644 --- a/codex-rs/execpolicy/src/amend.rs +++ b/codex-rs/execpolicy/src/amend.rs @@ -6,6 +6,10 @@ use std::io::Write; use std::path::Path; use std::path::PathBuf; +use codex_utils_file_lock::FileLockOutcome; +use codex_utils_file_lock::acquire_sibling_lock_dir; +use codex_utils_file_lock::lock_exclusive_optional; + use crate::decision::Decision; use crate::rule::NetworkRuleProtocol; use crate::rule::normalize_network_rule_host; @@ -154,10 +158,21 @@ fn append_locked_line(policy_path: &Path, line: &str) -> Result<(), AmendError> path: policy_path.to_path_buf(), source, })?; - file.lock().map_err(|source| AmendError::LockPolicyFile { - path: policy_path.to_path_buf(), - source, - })?; + let _lock_dir_guard = + match lock_exclusive_optional(&file).map_err(|source| AmendError::LockPolicyFile { + path: policy_path.to_path_buf(), + source, + })? { + FileLockOutcome::Acquired => None, + FileLockOutcome::Unsupported => { + Some(acquire_sibling_lock_dir(policy_path).map_err(|source| { + AmendError::LockPolicyFile { + path: policy_path.to_path_buf(), + source, + } + })?) + } + }; file.seek(SeekFrom::Start(0)) .map_err(|source| AmendError::SeekPolicyFile { diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index c213e92bae41..40624baba0d0 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -126,7 +126,7 @@ uuid = { workspace = true } codex-windows-sandbox = { workspace = true } tokio-util = { workspace = true, features = ["time"] } -[target.'cfg(not(target_os = "linux"))'.dependencies] +[target.'cfg(all(not(target_os = "linux"), not(target_os = "android")))'.dependencies] cpal = "0.15" [target.'cfg(unix)'.dependencies] diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 9ece5e3776f5..fbd1d7eb79bc 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -88,9 +88,9 @@ mod app_server_approval_conversions; mod app_server_session; mod approval_events; mod ascii_animation; -#[cfg(not(target_os = "linux"))] +#[cfg(not(any(target_os = "linux", target_os = "android")))] mod audio_device; -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "android"))] #[allow(dead_code)] mod audio_device { use crate::app_event::RealtimeAudioDeviceKind; @@ -194,11 +194,11 @@ mod update_prompt; mod update_versions; mod updates; mod version; -#[cfg(not(target_os = "linux"))] +#[cfg(not(any(target_os = "linux", target_os = "android")))] mod voice; mod width; mod workspace_command; -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "android"))] #[allow(dead_code)] mod voice { use crate::app_event_sender::AppEventSender; diff --git a/codex-rs/utils/file-lock/BUILD.bazel b/codex-rs/utils/file-lock/BUILD.bazel new file mode 100644 index 000000000000..face70c53518 --- /dev/null +++ b/codex-rs/utils/file-lock/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "file-lock", + crate_name = "codex_utils_file_lock", +) diff --git a/codex-rs/utils/file-lock/Cargo.toml b/codex-rs/utils/file-lock/Cargo.toml new file mode 100644 index 000000000000..5e3877fc1630 --- /dev/null +++ b/codex-rs/utils/file-lock/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "codex-utils-file-lock" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] diff --git a/codex-rs/utils/file-lock/src/lib.rs b/codex-rs/utils/file-lock/src/lib.rs new file mode 100644 index 000000000000..fdcfbdb81e75 --- /dev/null +++ b/codex-rs/utils/file-lock/src/lib.rs @@ -0,0 +1,168 @@ +use std::fs::File; +use std::fs::create_dir; +use std::fs::remove_dir; +use std::io; +use std::path::Path; +use std::path::PathBuf; +use std::thread; +use std::time::Duration; + +const LOCK_DIR_RETRY_SLEEP: Duration = Duration::from_millis(100); + +/// Result of acquiring a blocking advisory file lock. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FileLockOutcome { + Acquired, + Unsupported, +} + +/// Result of attempting to acquire a non-blocking advisory file lock. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TryFileLockOutcome { + Acquired, + WouldBlock, + Unsupported, +} + +/// Result of attempting to acquire a non-blocking lock directory. +#[derive(Debug, PartialEq, Eq)] +pub enum TryLockDirOutcome { + Acquired(LockDirGuard), + WouldBlock, +} + +/// Guard for a sibling lock directory created with an atomic `mkdir`. +#[derive(Debug, PartialEq, Eq)] +pub struct LockDirGuard { + path: PathBuf, +} + +impl Drop for LockDirGuard { + fn drop(&mut self) { + let _ = remove_dir(&self.path); + } +} + +/// Acquires an exclusive advisory file lock, treating unsupported file locking +/// as a distinct outcome for platforms such as Termux. +pub fn lock_exclusive_optional(file: &File) -> io::Result { + match file.lock() { + Ok(()) => Ok(FileLockOutcome::Acquired), + Err(err) if err.kind() == io::ErrorKind::Unsupported => Ok(FileLockOutcome::Unsupported), + Err(err) => Err(err), + } +} + +/// Attempts to acquire an exclusive advisory file lock without blocking, +/// preserving `WouldBlock` and unsupported file locking as distinct outcomes. +pub fn try_lock_exclusive_optional(file: &File) -> io::Result { + match file.try_lock() { + Ok(()) => Ok(TryFileLockOutcome::Acquired), + Err(std::fs::TryLockError::WouldBlock) => Ok(TryFileLockOutcome::WouldBlock), + Err(std::fs::TryLockError::Error(err)) if err.kind() == io::ErrorKind::Unsupported => { + Ok(TryFileLockOutcome::Unsupported) + } + Err(std::fs::TryLockError::Error(err)) => Err(err), + } +} + +/// Returns the sibling directory path used as a fallback lock for `path`. +pub fn sibling_lock_dir(path: &Path) -> PathBuf { + let Some(file_name) = path.file_name() else { + return path.with_file_name(".lock"); + }; + + let mut lock_name = file_name.to_os_string(); + lock_name.push(".lock"); + path.with_file_name(lock_name) +} + +/// Acquires a sibling lock directory, blocking until it is available. +pub fn acquire_sibling_lock_dir(path: &Path) -> io::Result { + loop { + match try_acquire_sibling_lock_dir(path)? { + TryLockDirOutcome::Acquired(guard) => return Ok(guard), + TryLockDirOutcome::WouldBlock => thread::sleep(LOCK_DIR_RETRY_SLEEP), + } + } +} + +/// Attempts to acquire a sibling lock directory without blocking. +pub fn try_acquire_sibling_lock_dir(path: &Path) -> io::Result { + let lock_dir = sibling_lock_dir(path); + match create_dir(&lock_dir) { + Ok(()) => Ok(TryLockDirOutcome::Acquired(LockDirGuard { path: lock_dir })), + Err(err) if err.kind() == io::ErrorKind::AlreadyExists => Ok(TryLockDirOutcome::WouldBlock), + Err(err) => Err(err), + } +} + +/// Attempts to acquire a shared advisory file lock without blocking, +/// preserving `WouldBlock` and unsupported file locking as distinct outcomes. +pub fn try_lock_shared_optional(file: &File) -> io::Result { + match file.try_lock_shared() { + Ok(()) => Ok(TryFileLockOutcome::Acquired), + Err(std::fs::TryLockError::WouldBlock) => Ok(TryFileLockOutcome::WouldBlock), + Err(std::fs::TryLockError::Error(err)) if err.kind() == io::ErrorKind::Unsupported => { + Ok(TryFileLockOutcome::Unsupported) + } + Err(std::fs::TryLockError::Error(err)) => Err(err), + } +} + +#[cfg(test)] +mod tests { + use super::TryLockDirOutcome; + use super::sibling_lock_dir; + use super::try_acquire_sibling_lock_dir; + use std::fs; + use std::path::PathBuf; + use std::time::SystemTime; + + fn unique_temp_file_path(name: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos(); + std::env::temp_dir().join(format!( + "codex-file-lock-{name}-{}-{nanos}", + std::process::id() + )) + } + + #[test] + fn sibling_lock_dir_appends_lock_suffix() { + let path = PathBuf::from("/tmp/history.jsonl"); + + assert_eq!( + sibling_lock_dir(&path), + PathBuf::from("/tmp/history.jsonl.lock") + ); + } + + #[test] + fn try_acquire_sibling_lock_dir_is_exclusive_until_drop() { + let path = unique_temp_file_path("exclusive"); + let lock_dir = sibling_lock_dir(&path); + let _ = fs::remove_dir_all(&lock_dir); + + let guard = match try_acquire_sibling_lock_dir(&path).expect("acquire lock dir") { + TryLockDirOutcome::Acquired(guard) => guard, + TryLockDirOutcome::WouldBlock => panic!("first lock attempt should acquire"), + }; + assert!(lock_dir.is_dir()); + + assert!(matches!( + try_acquire_sibling_lock_dir(&path).expect("try acquire held lock dir"), + TryLockDirOutcome::WouldBlock + )); + + drop(guard); + assert!(!lock_dir.exists()); + + let reacquired = try_acquire_sibling_lock_dir(&path).expect("reacquire lock dir"); + assert!(matches!(reacquired, TryLockDirOutcome::Acquired(_))); + + let _ = fs::remove_dir_all(lock_dir); + } +} diff --git a/justfile b/justfile index ab2fbc63629a..569a04695216 100644 --- a/justfile +++ b/justfile @@ -116,6 +116,11 @@ argument-comment-lint *args: argument-comment-lint-from-source *args: {{ justfile_directory() }}/tools/argument-comment-lint/run.py "$@" +# Audit advisory file locks that may need Termux Unsupported handling. +[no-cd] +termux-lock-audit *args: + {{ justfile_directory() }}/scripts/termux-lock-audit.sh "$@" + # Tail logs from the state SQLite database log *args: if [ "${1:-}" = "--" ]; then shift; fi; cargo run -p codex-state --bin logs_client -- "$@" diff --git a/scripts/termux-lock-audit.sh b/scripts/termux-lock-audit.sh new file mode 100755 index 000000000000..ac40f8e57246 --- /dev/null +++ b/scripts/termux-lock-audit.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: scripts/termux-lock-audit.sh [--strict] + +Audits Rust advisory file-lock usage that may need Termux compatibility handling. + +By default this script prints findings and exits successfully. With --strict, it +exits non-zero when candidate file-lock calls are found in files that do not also +mention Unsupported/TryLockError handling. +USAGE +} + +strict=false +for arg in "$@"; do + case "$arg" in + --strict) + strict=true + ;; + -h | --help) + usage + exit 0 + ;; + *) + echo "unknown argument: $arg" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if ! command -v rg >/dev/null 2>&1; then + echo "error: rg is required for this audit" >&2 + exit 1 +fi + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repo_root" + +unsupported_pattern='std::fs::TryLockError|fs::TryLockError|TryLockError::|ErrorKind::Unsupported|std::io::ErrorKind::Unsupported' +lock_context_pattern='(\.lock\(\)|\.try_lock\(|\.try_lock_shared\(|TryLockError|ErrorKind::Unsupported)' +candidate_file_lock_pattern='\b([A-Za-z_][A-Za-z0-9_]*_file|file)\.(lock|try_lock|try_lock_shared)\(' +candidate_false_positive_pattern='poisoned' +helper_pattern='codex_utils_file_lock|FileLockOutcome|TryFileLockOutcome|lock_exclusive_optional|try_lock_exclusive_optional|try_lock_shared_optional' + +print_section() { + printf '\n== %s ==\n' "$1" +} + +print_section "Unsupported-aware lock handling" +echo "These files already mention Unsupported/TryLockError and are likely patched or intentionally reviewed:" + +unsupported_count=0 +while IFS= read -r file; do + if rg -q "$lock_context_pattern" "$file"; then + rg -n -H "$lock_context_pattern" "$file" + unsupported_count=$((unsupported_count + 1)) + fi +done < <(rg -l "$unsupported_pattern" codex-rs -g '*.rs' || true) + +if [ "$unsupported_count" -eq 0 ]; then + echo "No unsupported-aware lock handling found." +fi + +print_section "Optional file-lock helper usage" +echo "These files use the shared optional advisory file-lock helper:" + +helper_count=0 +while IFS= read -r file; do + rg -n -H "$helper_pattern" "$file" + helper_count=$((helper_count + 1)) +done < <(rg -l "$helper_pattern" codex-rs -g '*.rs' || true) + +if [ "$helper_count" -eq 0 ]; then + echo "No optional file-lock helper usage found." +fi + +print_section "Candidate file-lock calls for manual review" +echo "These receiver names may be std::fs::File advisory locks, but the file does not mention Unsupported/TryLockError handling." +echo "This section can include false positives, such as mutexes wrapping a file." + +review_count=0 +while IFS= read -r file; do + if ! rg -q "$unsupported_pattern" "$file"; then + matches="$(rg -n -H "$candidate_file_lock_pattern" "$file" | rg -v "$candidate_false_positive_pattern" || true)" + if [ -n "$matches" ]; then + echo "$matches" + review_count=$((review_count + 1)) + fi + fi +done < <(rg -l "$candidate_file_lock_pattern" codex-rs -g '*.rs' || true) + +if [ "$review_count" -eq 0 ]; then + echo "No unpatched candidate file-lock calls found." +fi + +print_section "Candidate file-lock calls already in unsupported-aware files" +echo "These are candidate file-lock calls in files that already mention Unsupported/TryLockError handling:" + +aware_candidate_count=0 +while IFS= read -r file; do + if rg -q "$unsupported_pattern" "$file"; then + matches="$(rg -n -H "$candidate_file_lock_pattern" "$file" | rg -v "$candidate_false_positive_pattern" || true)" + if [ -n "$matches" ]; then + echo "$matches" + aware_candidate_count=$((aware_candidate_count + 1)) + fi + fi +done < <(rg -l "$candidate_file_lock_pattern" codex-rs -g '*.rs' || true) + +if [ "$aware_candidate_count" -eq 0 ]; then + echo "No unsupported-aware candidate file-lock calls found." +fi + +print_section "Summary" +echo "unsupported-aware files: $unsupported_count" +echo "optional helper files: $helper_count" +echo "review candidate files: $review_count" +echo "unsupported-aware candidate files: $aware_candidate_count" + +if [ "$strict" = true ] && [ "$review_count" -gt 0 ]; then + echo "strict mode: manual-review candidate files were found" >&2 + exit 1 +fi From d84294c0d67dc0cca7b687ca4ade38579d26fa04 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 16:38:37 +0000 Subject: [PATCH 09/64] Seed Termux release automation --- .github/workflows/rust-release.yml | 1765 +++++++---------- .github/workflows/shell-tool-mcp.yml | 461 +++++ .../workflows/termux-release-checkpoint.yml | 103 + .github/workflows/termux-release-deploy.yml | 243 +++ .github/workflows/termux-release-promote.yml | 135 ++ scripts/termux-configure-git.sh | 40 + scripts/termux-create-checkpoint-pr.sh | 275 +++ ...ermux-create-or-update-mirrored-release.sh | 111 ++ scripts/termux-download-release-artifact.sh | 78 + scripts/termux-find-release-pr.sh | 53 + scripts/termux-read-release-metadata.sh | 77 + scripts/termux-release-asset-state.sh | 37 + scripts/termux-release-paths.sh | 55 + scripts/termux-resolve-release-ref.sh | 36 + scripts/termux-validate-gh-env.sh | 16 + 15 files changed, 2444 insertions(+), 1041 deletions(-) create mode 100644 .github/workflows/shell-tool-mcp.yml create mode 100644 .github/workflows/termux-release-checkpoint.yml create mode 100644 .github/workflows/termux-release-deploy.yml create mode 100644 .github/workflows/termux-release-promote.yml create mode 100755 scripts/termux-configure-git.sh create mode 100755 scripts/termux-create-checkpoint-pr.sh create mode 100755 scripts/termux-create-or-update-mirrored-release.sh create mode 100755 scripts/termux-download-release-artifact.sh create mode 100755 scripts/termux-find-release-pr.sh create mode 100755 scripts/termux-read-release-metadata.sh create mode 100755 scripts/termux-release-asset-state.sh create mode 100755 scripts/termux-release-paths.sh create mode 100755 scripts/termux-resolve-release-ref.sh create mode 100755 scripts/termux-validate-gh-env.sh diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 50953506d325..56d0977e6e67 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -4,46 +4,49 @@ # git tag -a rust-v0.1.0 -m "Release 0.1.0" # git push origin rust-v0.1.0 # ``` -# -# To use external macOS signing, manually dispatch `release_mode=build_unsigned`, -# sign the unsigned macOS artifacts in a secure enclave, upload the signed handoff -# archive as a GitHub Release asset, then manually dispatch -# `release_mode=promote_signed` with `unsigned_run_id` and `signed_macos_asset`. -# The signed handoff archive should contain target or artifact directories such -# as `aarch64-apple-darwin/` with signed binaries. name: rust-release on: - push: - tags: - - "rust-v*.*.*" + pull_request: + branches: + - "release/**" workflow_dispatch: inputs: - release_mode: - description: "build_unsigned creates unsigned macOS handoff artifacts; promote_signed finishes a release from signed macOS handoff artifacts." + source_ref: + description: "Branch, tag, or commit SHA to build (code only; keeps .github from dispatched ref)" required: false + type: string + default: "" + build_target: + description: "Target to build" + required: true type: choice - default: build_unsigned + default: all options: - - build_unsigned - - promote_signed - sign_macos: - description: "Deprecated compatibility input; use release_mode instead." - required: false - type: boolean - default: false - unsigned_run_id: - description: "For promote_signed: workflow run id from the build_unsigned run." - required: false - type: string - signed_macos_asset: - description: "For promote_signed: exact GitHub Release asset name containing signed macOS handoff artifacts." - required: false - type: string - signed_macos_sha256: - description: "For promote_signed: optional SHA-256 of signed_macos_asset." - required: false - type: string + - all + - aarch64-apple-darwin + - x86_64-apple-darwin + - x86_64-unknown-linux-musl + - x86_64-unknown-linux-gnu + - aarch64-unknown-linux-musl + - aarch64-unknown-linux-gnu + - aarch64-linux-android + - x86_64-pc-windows-msvc + - aarch64-pc-windows-msvc + +permissions: + actions: read + attestations: read + checks: read + contents: read + deployments: read + issues: read + discussions: read + packages: read + pages: read + pull-requests: read + repository-projects: read + statuses: read concurrency: group: ${{ github.workflow }} @@ -51,72 +54,26 @@ concurrency: jobs: tag-check: + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: actions/checkout@v6 + - name: 🧰 Actions Toolbox + # This is required for the GitHub CLI + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: wallentx/gh-actions/composite/actions-toolbox@main + - uses: dtolnay/rust-toolchain@1.92 - name: Validate tag matches Cargo.toml version shell: bash - env: - RELEASE_MODE: ${{ github.event_name == 'workflow_dispatch' && inputs.release_mode || 'signed' }} - REQUESTED_SIGN_MACOS: ${{ inputs.sign_macos }} - SIGNED_MACOS_ASSET: ${{ inputs.signed_macos_asset }} - UNSIGNED_RUN_ID: ${{ inputs.unsigned_run_id }} run: | set -euo pipefail echo "::group::Tag validation" - case "${RELEASE_MODE}" in - signed) - if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then - echo "❌ Manual rust-release runs must use release_mode=build_unsigned or release_mode=promote_signed" - exit 1 - fi - ;; - build_unsigned) - if [[ "${GITHUB_EVENT_NAME}" != "workflow_dispatch" ]]; then - echo "❌ release_mode=build_unsigned is only valid for manual runs" - exit 1 - fi - ;; - promote_signed) - if [[ "${GITHUB_EVENT_NAME}" != "workflow_dispatch" ]]; then - echo "❌ release_mode=promote_signed is only valid for manual runs" - exit 1 - fi - if [[ ! "${UNSIGNED_RUN_ID}" =~ ^[0-9]+$ ]]; then - echo "❌ release_mode=promote_signed requires unsigned_run_id to be a workflow run id" - exit 1 - fi - if [[ -z "${SIGNED_MACOS_ASSET}" ]]; then - echo "❌ release_mode=promote_signed requires signed_macos_asset" - exit 1 - fi - if [[ "${SIGNED_MACOS_ASSET}" == */* || "${SIGNED_MACOS_ASSET}" == *"*"* || "${SIGNED_MACOS_ASSET}" == *"?"* || "${SIGNED_MACOS_ASSET}" == *"["* ]]; then - echo "❌ signed_macos_asset must be an exact release asset name, not a path or glob" - exit 1 - fi - if [[ "${UNSIGNED_RUN_ID}" == "${GITHUB_RUN_ID}" ]]; then - echo "❌ unsigned_run_id must refer to the earlier build_unsigned run, not this run" - exit 1 - fi - ;; - *) - echo "❌ Unknown release_mode '${RELEASE_MODE}'" - exit 1 - ;; - esac - - if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${REQUESTED_SIGN_MACOS}" == "true" ]]; then - echo "::warning title=Deprecated sign_macos input ignored::Use release_mode=build_unsigned or release_mode=promote_signed instead." - fi - # 1. Must be a tag and match the regex [[ "${GITHUB_REF_TYPE}" == "tag" ]] \ || { echo "❌ Not a tag push"; exit 1; } - [[ "${GITHUB_REF_NAME}" =~ ^rust-v[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta)(\.[0-9]+)?)?$ ]] \ + [[ "${GITHUB_REF_NAME}" =~ ^rust-v[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta)(\.[0-9]+)?)?(-termux)?$ ]] \ || { echo "❌ Tag '${GITHUB_REF_NAME}' doesn't match expected format"; exit 1; } # 2. Extract versions @@ -131,115 +88,198 @@ jobs: echo "✅ Tag and Cargo.toml agree (${tag_ver})" echo "::endgroup::" - build: - if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} + select-build-matrix: + if: always() && (github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' || needs.tag-check.result == 'success') needs: tag-check - name: Build - ${{ matrix.runner }} - ${{ matrix.target }} - ${{ matrix.bundle }} - runs-on: ${{ matrix.runs_on || matrix.runner }} - # Release builds can take a long time, so leave some headroom to avoid - # having to restart the full workflow due to a timeout. - timeout-minutes: 90 - permissions: - contents: read - id-token: write + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.select.outputs.matrix }} + build_version: ${{ steps.select.outputs.build_version }} + steps: + - uses: actions/checkout@v6 + - name: 🧰 Actions Toolbox + # This is required for the GitHub CLI + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: wallentx/gh-actions/composite/actions-toolbox@main + - id: select + shell: bash + env: + REPO_OWNER: ${{ github.repository_owner }} + EVENT_NAME: ${{ github.event_name }} + BUILD_TARGET: ${{ inputs.build_target }} + run: | + set -euo pipefail + + matrix='[ + {"runner":"macos-15-xlarge","target":"aarch64-apple-darwin"}, + {"runner":"macos-15-xlarge","target":"x86_64-apple-darwin"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-gnu"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-gnu"}, + {"runner":"ubuntu-24.04","target":"aarch64-linux-android"}, + {"runner":"windows-latest","target":"x86_64-pc-windows-msvc"}, + {"runner":"windows-11-arm","target":"aarch64-pc-windows-msvc"} + ]' + + if [[ "${REPO_OWNER}" != "openai" ]]; then + matrix="$(jq -c '[.[] | select(.runner | startswith("macos") | not)]' <<< "${matrix}")" + fi + + if [[ "${EVENT_NAME}" == "pull_request" ]]; then + matrix="$(jq -c '[.[] | select(.target == "aarch64-linux-android")]' <<< "${matrix}")" + elif [[ "${EVENT_NAME}" == "workflow_dispatch" && -n "${BUILD_TARGET}" && "${BUILD_TARGET}" != "all" ]]; then + matrix="$(jq -c --arg build_target "${BUILD_TARGET}" '[.[] | select(.target == $build_target)]' <<< "${matrix}")" + fi + + if [[ "$(jq 'length' <<< "${matrix}")" -eq 0 ]]; then + echo "No build targets selected after applying owner/dispatch filters." >&2 + exit 1 + fi + + echo "matrix={\"include\":${matrix}}" >> "$GITHUB_OUTPUT" + + if [[ "${EVENT_NAME}" == "pull_request" ]]; then + metadata=".github/termux-release.json" + if [[ ! -f "${metadata}" ]]; then + echo "${metadata} is required for release train PR builds" >&2 + exit 1 + fi + upstream_tag="$(jq -r '.upstream_tag // empty' "${metadata}")" + if [[ -z "${upstream_tag}" || "${upstream_tag}" != rust-v* ]]; then + echo "Unable to determine upstream rust tag from ${metadata}" >&2 + exit 1 + fi + build_version="${upstream_tag#rust-v}" + echo "build_version=${build_version}" >> "$GITHUB_OUTPUT" + elif [[ "${EVENT_NAME}" == "workflow_dispatch" ]]; then + latest_release_tag="$( + GH_TOKEN="${{ github.token }}" gh api repos/openai/codex/releases/latest --jq '.tag_name' + )" + + if [[ -z "${latest_release_tag}" || "${latest_release_tag}" != rust-v* ]]; then + echo "Unable to determine latest stable release tag from openai/codex" >&2 + exit 1 + fi + + latest_release_version="${latest_release_tag#rust-v}" + build_version="${latest_release_version}-dev" + echo "build_version=${build_version}" >> "$GITHUB_OUTPUT" + fi + + build: + if: >- + always() && + (github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' || needs.tag-check.result == 'success') && + needs.select-build-matrix.result == 'success' + needs: + - tag-check + - select-build-matrix + name: Build - ${{ matrix.runner }} - ${{ matrix.target }} + runs-on: ${{ matrix.runner }} + permissions: write-all + timeout-minutes: 120 defaults: run: working-directory: codex-rs env: - # 2026-03-04: temporarily change releases to use thin LTO because - # Ubuntu ARM is timing out at 60 minutes. - CARGO_PROFILE_RELEASE_LTO: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'thin' }} - SIGN_MACOS: ${{ github.event_name != 'workflow_dispatch' }} + CODEX_BWRAP_ENABLE_FFI: ${{ contains(matrix.target, 'unknown-linux') && '1' || '0' }} + CODEX_BUILD_VERSION: ${{ needs.select-build-matrix.outputs.build_version }} + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" strategy: fail-fast: false - matrix: - include: - - runner: macos-15-xlarge - target: aarch64-apple-darwin - bundle: primary - artifact_name: aarch64-apple-darwin - binaries: "codex codex-responses-api-proxy" - build_dmg: "true" - - runner: macos-15-xlarge - target: aarch64-apple-darwin - bundle: app-server - artifact_name: aarch64-apple-darwin-app-server - binaries: "codex-app-server" - build_dmg: "false" - - runner: macos-15-xlarge - target: x86_64-apple-darwin - bundle: primary - artifact_name: x86_64-apple-darwin - binaries: "codex codex-responses-api-proxy" - build_dmg: "true" - - runner: macos-15-xlarge - target: x86_64-apple-darwin - bundle: app-server - artifact_name: x86_64-apple-darwin-app-server - binaries: "codex-app-server" - build_dmg: "false" - # Release artifacts intentionally ship MUSL-linked Linux binaries. - - runner: ubuntu-24.04 - target: x86_64-unknown-linux-musl - bundle: primary - artifact_name: x86_64-unknown-linux-musl - binaries: "codex codex-responses-api-proxy bwrap" - build_dmg: "false" - - runner: ubuntu-24.04 - target: x86_64-unknown-linux-musl - bundle: app-server - artifact_name: x86_64-unknown-linux-musl-app-server - binaries: "codex-app-server" - build_dmg: "false" - - runner: ubuntu-24.04-arm - target: aarch64-unknown-linux-musl - bundle: primary - artifact_name: aarch64-unknown-linux-musl - binaries: "codex codex-responses-api-proxy bwrap" - build_dmg: "false" - - runner: ubuntu-24.04-arm - target: aarch64-unknown-linux-musl - bundle: app-server - artifact_name: aarch64-unknown-linux-musl-app-server - binaries: "codex-app-server" - build_dmg: "false" + matrix: ${{ fromJson(needs.select-build-matrix.outputs.matrix) }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@v6 + - name: 🧰 Actions Toolbox + # This is required for the GitHub CLI + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: wallentx/gh-actions/composite/actions-toolbox@main with: - persist-credentials: false - - name: Print runner specs (Linux) - if: ${{ runner.os == 'Linux' }} + verbose: true + - name: Checkout selected source ref + if: ${{ github.event_name == 'workflow_dispatch' && inputs.source_ref != '' }} + uses: actions/checkout@v6 + with: + ref: ${{ inputs.source_ref }} + path: __source__ + fetch-depth: 1 + - name: Overlay selected source ref (excluding .github) + if: ${{ github.event_name == 'workflow_dispatch' && inputs.source_ref != '' }} shell: bash + working-directory: ${{ github.workspace }} run: | set -euo pipefail - cpu_model="$(lscpu | awk -F: '/Model name/ {gsub(/^[ \t]+/, "", $2); print $2; exit}')" - total_ram="$(awk '/MemTotal/ {printf "%.1f GiB\n", $2 / 1024 / 1024}' /proc/meminfo)" - echo "Runner: ${RUNNER_NAME:-unknown}" - echo "OS: $(uname -a)" - echo "CPU model: ${cpu_model}" - echo "Logical CPUs: $(nproc)" - echo "Total RAM: ${total_ram}" - echo "Disk usage:" - df -h . - - name: Print runner specs (macOS) - if: ${{ runner.os == 'macOS' }} + echo "Using source ref: ${{ inputs.source_ref }}" + + # Keep workflow/actions from the dispatch ref while replacing all other files. + find . -mindepth 1 -maxdepth 1 \ + ! -name .git \ + ! -name .github \ + ! -name __source__ \ + -exec rm -rf -- {} + + tar --exclude='.github' -C __source__ -cf - . | tar -xf - + rm -rf __source__ + + - name: Apply selected build version + if: ${{ github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' }} shell: bash + working-directory: ${{ github.workspace }} run: | set -euo pipefail - total_ram="$(sysctl -n hw.memsize | awk '{printf "%.1f GiB\n", $1 / 1024 / 1024 / 1024}')" - echo "Runner: ${RUNNER_NAME:-unknown}" - echo "OS: $(sw_vers -productName) $(sw_vers -productVersion)" - echo "Hardware model: $(sysctl -n hw.model)" - echo "CPU architecture: $(uname -m)" - echo "Logical CPUs: $(sysctl -n hw.logicalcpu)" - echo "Physical CPUs: $(sysctl -n hw.physicalcpu)" - echo "Total RAM: ${total_ram}" - echo "Disk usage:" - df -h . + if [[ -z "${CODEX_BUILD_VERSION}" ]]; then + echo "CODEX_BUILD_VERSION is empty for workflow_dispatch" >&2 + exit 1 + fi + echo "Using build version: ${CODEX_BUILD_VERSION}" + BUILD_VERSION="${CODEX_BUILD_VERSION}" perl -0777 -i -pe \ + 's/(\[workspace\.package\][^\[]*?version = ")([^"]+)(")/${1}$ENV{BUILD_VERSION}$3/s' \ + codex-rs/Cargo.toml + + grep -n "^\[workspace.package\]\|^version = " codex-rs/Cargo.toml | head -n 4 + + - name: Select Android NDK + if: ${{ matrix.target == 'aarch64-linux-android' }} + shell: bash + run: | + set -euo pipefail + + fallback_version="29.0.13113456" + ndk_root="${ANDROID_HOME}/ndk" + selected_ndk="" + + if [[ -d "${ndk_root}" ]]; then + while IFS= read -r candidate; do + toolchain="${candidate}/toolchains/llvm/prebuilt/linux-x86_64" + if [[ -x "${toolchain}/bin/aarch64-linux-android24-clang" ]]; then + selected_ndk="${candidate}" + break + fi + done < <(find "${ndk_root}" -mindepth 1 -maxdepth 1 -type d | sort -Vr) + fi + + if [[ -z "${selected_ndk}" ]]; then + echo "No usable preinstalled Android NDK found; installing ${fallback_version}" + yes | sudo "${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager" --licenses || true + sudo "${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager" "ndk;${fallback_version}" + selected_ndk="${ndk_root}/${fallback_version}" + fi + + if [[ ! -x "${selected_ndk}/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android24-clang" ]]; then + echo "Selected Android NDK does not contain a usable aarch64 API 24 clang: ${selected_ndk}" >&2 + exit 1 + fi + + echo "Selected Android NDK: ${selected_ndk}" + "${selected_ndk}/toolchains/llvm/prebuilt/linux-x86_64/bin/clang" --version + echo "ANDROID_NDK_HOME=${selected_ndk}" >> "$GITHUB_ENV" + echo "NDK_HOME=${selected_ndk}" >> "$GITHUB_ENV" - name: Install Linux bwrap build dependencies - if: ${{ runner.os == 'Linux' }} + if: ${{ runner.os == 'Linux' && matrix.target != 'aarch64-linux-android' }} shell: bash run: | set -euo pipefail @@ -254,9 +294,173 @@ jobs: sudo apt-get update -y sudo DEBIAN_FRONTEND=noninteractive apt-get install -y libubsan1 fi - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: dtolnay/rust-toolchain@1.93 with: targets: ${{ matrix.target }} + - name: Set up sccache (Android) + if: ${{ matrix.target == 'aarch64-linux-android' }} + uses: mozilla-actions/sccache-action@main + - name: Enable sccache (Android) + if: ${{ matrix.target == 'aarch64-linux-android' }} + shell: bash + run: | + set -euo pipefail + echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV" + echo "SCCACHE_GHA_ENABLED=true" >> "$GITHUB_ENV" + echo "SCCACHE_GHA_VERSION=android-release-${{ matrix.target }}" >> "$GITHUB_ENV" + echo "SCCACHE_CACHE_SIZE=5G" >> "$GITHUB_ENV" + - name: Ensure Rust target is installed + shell: bash + run: | + set -euo pipefail + rustup target add "${{ matrix.target }}" + - if: ${{ matrix.target == 'aarch64-linux-android' }} + name: Configure Android build environment + shell: bash + run: | + set -euo pipefail + ndk="${ANDROID_NDK_HOME}" + target="${{ matrix.target }}" + + # Set up the Android toolchain + toolchain="${ndk}/toolchains/llvm/prebuilt/linux-x86_64" + + # Use API level 24 to follow termux-build convention + api_level="24" + + # Configure Cargo to use the NDK linker + cargo_target_var="CARGO_TARGET_${target^^}_LINKER" + cargo_target_var="${cargo_target_var//-/_}" + echo "${cargo_target_var}=${toolchain}/bin/aarch64-linux-android${api_level}-clang" >> "$GITHUB_ENV" + + # Set CC and AR for the target + target_cc_var="CC_${target}" + target_cc_var="${target_cc_var//-/_}" + echo "${target_cc_var}=${toolchain}/bin/aarch64-linux-android${api_level}-clang" >> "$GITHUB_ENV" + + target_cxx_var="CXX_${target}" + target_cxx_var="${target_cxx_var//-/_}" + echo "${target_cxx_var}=${toolchain}/bin/aarch64-linux-android${api_level}-clang++" >> "$GITHUB_ENV" + + target_ar_var="AR_${target}" + target_ar_var="${target_ar_var//-/_}" + echo "${target_ar_var}=${toolchain}/bin/llvm-ar" >> "$GITHUB_ENV" + + # Add toolchain to PATH + echo "${toolchain}/bin" >> "$GITHUB_PATH" + + tls_align_source="${RUNNER_TEMP}/codex-android-tls-align.S" + tls_align_object="${RUNNER_TEMP}/codex-android-tls-align.o" + cat > "${tls_align_source}" <<'EOF' + .section .tdata.codex_android_tls_align,"awT",%progbits + .p2align 6 + .globl __codex_android_tls_align + .hidden __codex_android_tls_align + .type __codex_android_tls_align, %object + __codex_android_tls_align: + .byte 0 + .size __codex_android_tls_align, 1 + EOF + "${toolchain}/bin/aarch64-linux-android${api_level}-clang" -c "${tls_align_source}" -o "${tls_align_object}" + + v8_compat_source="${RUNNER_TEMP}/codex-android-v8-compat.c" + v8_compat_object="${RUNNER_TEMP}/codex-android-v8-compat.o" + cat > "${v8_compat_source}" <<'EOF' + #include + #include + + extern int posix_memalign(void **memptr, size_t alignment, size_t size); + extern double strtod(const char *nptr, char **endptr); + extern float strtof(const char *nptr, char **endptr); + + void *aligned_alloc(size_t alignment, size_t size) { + void *ptr = 0; + if (posix_memalign(&ptr, alignment, size) != 0) { + return 0; + } + return ptr; + } + + double strtod_l(const char *nptr, char **endptr, locale_t locale) { + (void)locale; + return strtod(nptr, endptr); + } + + float strtof_l(const char *nptr, char **endptr, locale_t locale) { + (void)locale; + return strtof(nptr, endptr); + } + EOF + "${toolchain}/bin/aarch64-linux-android${api_level}-clang" -c "${v8_compat_source}" -o "${v8_compat_object}" + + builtins_archive="$( + find "${toolchain}/lib/clang" \ + -path "*/lib/linux/libclang_rt.builtins-aarch64-android.a" \ + -print -quit + )" + if [[ -z "${builtins_archive}" ]]; then + echo "Could not find Android compiler-rt builtins archive under ${toolchain}/lib/clang" >&2 + exit 1 + fi + builtins_dir="$(dirname "${builtins_archive}")" + + # Provide compatibility symbols and C++ ABI needed by the Android rusty_v8 archive. + rustflags_var="CARGO_TARGET_${target^^}_RUSTFLAGS" + rustflags_var="${rustflags_var//-/_}" + echo "${rustflags_var}=-C link-arg=${tls_align_object} -C link-arg=${v8_compat_object} -C link-arg=-Wl,-u,__codex_android_tls_align -C link-arg=-L${builtins_dir} -C link-arg=-lclang_rt.builtins-aarch64-android -C link-arg=-lc++abi -C link-arg=-Wl,-z,max-page-size=65536" >> "$GITHUB_ENV" + + + # Configure for vendored OpenSSL build + echo "CARGO_BUILD_TARGET_APPLIES_TO_HOST=false" >> "$GITHUB_ENV" + echo "CARGO_BUILD_TARGET=${{ matrix.target }}" >> "$GITHUB_ENV" + + - if: ${{ matrix.target == 'aarch64-linux-android' }} + name: Configure Android rusty_v8 artifact overrides + env: + GH_TOKEN: ${{ github.token }} + TARGET: ${{ matrix.target }} + shell: bash + run: | + set -euo pipefail + binding_dir="${RUNNER_TEMP}/rusty_v8" + archive="${binding_dir}/librusty_v8_release_${TARGET}.a.gz" + binding_path="${binding_dir}/src_binding_release_${TARGET}.rs" + checksums_path="${binding_dir}/rusty_v8_release_${TARGET}.sha256" + artifact_repository="wallentx/codex-termux" + release_tag="rusty-v8-v147.4.0" + mkdir -p "${binding_dir}" + echo "Downloading Android rusty_v8 artifacts from ${artifact_repository}@${release_tag}" + gh release download "${release_tag}" \ + --repo "${artifact_repository}" \ + --dir "${binding_dir}" \ + --pattern "librusty_v8_release_${TARGET}.a.gz" \ + --pattern "src_binding_release_${TARGET}.rs" \ + --pattern "rusty_v8_release_${TARGET}.sha256" + (cd "${binding_dir}" && sha256sum -c "$(basename "${checksums_path}")") + echo "RUSTY_V8_ARCHIVE=${archive}" >> "$GITHUB_ENV" + echo "RUSTY_V8_SRC_BINDING_PATH=${binding_path}" >> "$GITHUB_ENV" + + - if: ${{ matrix.target == 'aarch64-linux-android' }} + name: Install termux-elf-cleaner + shell: bash + run: | + set -euo pipefail + version="v3.0.1" + src_dir="${RUNNER_TEMP}/termux-elf-cleaner-src" + build_dir="${RUNNER_TEMP}/termux-elf-cleaner-build" + bin_dir="${RUNNER_TEMP}/termux-elf-cleaner-bin" + + rm -rf "${src_dir}" "${build_dir}" "${bin_dir}" + mkdir -p "${src_dir}" "${build_dir}" "${bin_dir}" + + curl -fsSL "https://github.com/termux/termux-elf-cleaner/archive/refs/tags/${version}.tar.gz" \ + | tar -xzf - --strip-components=1 -C "${src_dir}" + + cmake -S "${src_dir}" -B "${build_dir}" -DCMAKE_BUILD_TYPE=Release + cmake --build "${build_dir}" --parallel + + install "${build_dir}/termux-elf-cleaner" "${bin_dir}/termux-elf-cleaner" + echo "${bin_dir}" >> "$GITHUB_PATH" - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} name: Use hermetic Cargo home (musl) @@ -269,12 +473,25 @@ jobs: echo "${cargo_home}/bin" >> "$GITHUB_PATH" : > "${cargo_home}/config.toml" + - uses: actions/cache@v5 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + ${{ github.workspace }}/.cargo-home/bin/ + ${{ github.workspace }}/.cargo-home/registry/index/ + ${{ github.workspace }}/.cargo-home/registry/cache/ + ${{ github.workspace }}/.cargo-home/git/db/ + ${{ github.workspace }}/codex-rs/target/ + key: cargo-${{ matrix.runner }}-${{ matrix.target }}-release-${{ hashFiles('**/Cargo.lock') }} + - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} name: Install Zig - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 + uses: mlugg/setup-zig@v2 with: version: 0.14.0 - use-cache: false - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} name: Install musl build tools @@ -310,12 +527,6 @@ jobs: shell: bash run: | set -euo pipefail - # Avoid problematic aws-lc jitter entropy code path on musl builders. - echo "AWS_LC_SYS_NO_JITTER_ENTROPY=1" >> "$GITHUB_ENV" - target_no_jitter="AWS_LC_SYS_NO_JITTER_ENTROPY_${{ matrix.target }}" - target_no_jitter="${target_no_jitter//-/_}" - echo "${target_no_jitter}=1" >> "$GITHUB_ENV" - # Clear global Rust flags so host/proc-macro builds don't pull in UBSan. echo "RUSTFLAGS=" >> "$GITHUB_ENV" echo "CARGO_ENCODED_RUSTFLAGS=" >> "$GITHUB_ENV" @@ -340,94 +551,70 @@ jobs: echo "CFLAGS=${cflags}" >> "$GITHUB_ENV" echo "CXXFLAGS=${cxxflags}" >> "$GITHUB_ENV" - - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl' }} - name: Configure musl rusty_v8 artifact overrides and verify checksums - uses: ./.github/actions/setup-rusty-v8-musl - with: - target: ${{ matrix.target }} - - - if: ${{ contains(matrix.target, 'linux') && matrix.bundle == 'primary' }} - name: Build bwrap and export digest + - name: Cargo build shell: bash run: | set -euo pipefail - target="${{ matrix.target }}" - cargo build --target "$target" --release --timings --bin bwrap - bwrap_path="target/${target}/release/bwrap" - if [[ ! -f "$bwrap_path" ]]; then - echo "bwrap binary ${bwrap_path} not found" - exit 1 + if [[ "${{ matrix.target }}" == 'aarch64-linux-android' ]]; then + export CARGO_BUILD_JOBS=4 + export CARGO_PROFILE_RELEASE_LTO=thin + export CARGO_PROFILE_RELEASE_CODEGEN_UNITS=8 + cargo build --target ${{ matrix.target }} --release --bin codex + exit 0 fi - digest="$(sha256sum "$bwrap_path" | awk '{print $1}')" - echo "CODEX_BWRAP_SHA256=${digest}" >> "$GITHUB_ENV" - echo "Built bwrap ${bwrap_path} with sha256:${digest}" - - - name: Cargo build + if [[ "${{ contains(matrix.target, 'windows') }}" == 'true' ]]; then + cargo build --target ${{ matrix.target }} --release --bin codex --bin codex-responses-api-proxy --bin codex-windows-sandbox-setup --bin codex-command-runner + else + cargo build --target ${{ matrix.target }} --release --bin codex --bin codex-responses-api-proxy + fi + - name: sccache stats (Android) + if: ${{ matrix.target == 'aarch64-linux-android' }} shell: bash - run: | - build_args=() - for binary in ${{ matrix.binaries }}; do - build_args+=(--bin "$binary") - done - echo "CARGO_PROFILE_RELEASE_LTO: ${CARGO_PROFILE_RELEASE_LTO}" - cargo build --target ${{ matrix.target }} --release --timings "${build_args[@]}" + run: ${SCCACHE_PATH:-sccache} --show-stats || true - - name: Upload Cargo timings - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: cargo-timings-rust-release-${{ matrix.target }}-${{ matrix.bundle }} - path: codex-rs/target/**/cargo-timings/cargo-timing.html - if-no-files-found: warn - - - if: ${{ runner.os == 'macOS' && env.SIGN_MACOS != 'true' }} - name: Stage unsigned macOS artifacts + - if: ${{ matrix.target == 'aarch64-linux-android' }} + name: Normalize Android ELF for Termux shell: bash run: | set -euo pipefail - - target="${{ matrix.target }}" - release_dir="target/${target}/release" - dest="unsigned-dist/${target}" - mkdir -p "$dest" - - for binary in ${{ matrix.binaries }}; do - binary_path="${release_dir}/${binary}" - unsigned_name="${binary}-${target}-unsigned" - unsigned_path="${dest}/${unsigned_name}" - if [[ ! -f "${binary_path}" ]]; then - echo "Binary ${binary_path} not found" - exit 1 + for binary in codex codex-responses-api-proxy; do + binary_path="target/${{ matrix.target }}/release/${binary}" + if [[ -f "${binary_path}" ]]; then + termux-elf-cleaner --api-level 24 "${binary_path}" + chmod +x "${binary_path}" + # if [[ "${binary}" == "codex" ]]; then + # # Patch PT_TLS (0x7) to PT_NULL (0x0) at offset 400 to bypass Bionic alignment checks. + # printf '\x00\x00\x00\x00' | dd of="${binary_path}" bs=1 seek=400 count=4 conv=notrunc + # fi fi - - cp "${binary_path}" "${unsigned_path}" - tar -C "$dest" -czf "${unsigned_path}.tar.gz" "${unsigned_name}" - zstd -T0 -19 --rm "${unsigned_path}" done - - if: ${{ runner.os == 'macOS' && env.SIGN_MACOS != 'true' }} - name: Upload unsigned macOS artifacts - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: ${{ matrix.artifact_name }}-unsigned - path: codex-rs/unsigned-dist/${{ matrix.target }}/* - if-no-files-found: error - - - if: ${{ contains(matrix.target, 'linux') }} + - if: ${{ contains(matrix.target, 'linux') && !contains(matrix.target, 'android') && github.repository_owner == 'openai' }} name: Cosign Linux artifacts uses: ./.github/actions/linux-code-sign with: target: ${{ matrix.target }} artifacts-dir: ${{ github.workspace }}/codex-rs/target/${{ matrix.target }}/release - binaries: ${{ matrix.binaries }} - - if: ${{ runner.os == 'macOS' && env.SIGN_MACOS == 'true' }} + - if: ${{ contains(matrix.target, 'windows') && github.repository_owner == 'openai' }} + name: Sign Windows binaries with Azure Trusted Signing + uses: ./.github/actions/windows-code-sign + with: + target: ${{ matrix.target }} + client-id: ${{ secrets.AZURE_TRUSTED_SIGNING_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TRUSTED_SIGNING_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_TRUSTED_SIGNING_SUBSCRIPTION_ID }} + endpoint: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} + account-name: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} + certificate-profile-name: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} + + - if: ${{ runner.os == 'macOS' && github.repository_owner == 'openai' }} name: MacOS code signing (binaries) uses: ./.github/actions/macos-code-sign with: target: ${{ matrix.target }} - binaries: ${{ matrix.binaries }} sign-binaries: "true" sign-dmg: "false" apple-certificate: ${{ secrets.APPLE_CERTIFICATE_P12 }} @@ -436,7 +623,7 @@ jobs: apple-notarization-key-id: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} apple-notarization-issuer-id: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} - - if: ${{ runner.os == 'macOS' && matrix.build_dmg == 'true' && env.SIGN_MACOS == 'true' }} + - if: ${{ runner.os == 'macOS' && github.repository_owner == 'openai' }} name: Build macOS dmg shell: bash run: | @@ -451,17 +638,23 @@ jobs: # The previous "MacOS code signing (binaries)" step signs + notarizes the # built artifacts in `${release_dir}`. This step packages *those same* # signed binaries into a dmg. + codex_binary_path="${release_dir}/codex" + proxy_binary_path="${release_dir}/codex-responses-api-proxy" + rm -rf "$dmg_root" mkdir -p "$dmg_root" - for binary in ${{ matrix.binaries }}; do - binary_path="${release_dir}/${binary}" - if [[ ! -f "${binary_path}" ]]; then - echo "Binary ${binary_path} not found" - exit 1 - fi - ditto "${binary_path}" "${dmg_root}/${binary}" - done + if [[ ! -f "$codex_binary_path" ]]; then + echo "Binary $codex_binary_path not found" + exit 1 + fi + if [[ ! -f "$proxy_binary_path" ]]; then + echo "Binary $proxy_binary_path not found" + exit 1 + fi + + ditto "$codex_binary_path" "${dmg_root}/codex" + ditto "$proxy_binary_path" "${dmg_root}/codex-responses-api-proxy" rm -f "$dmg_path" hdiutil create \ @@ -476,7 +669,7 @@ jobs: exit 1 fi - - if: ${{ runner.os == 'macOS' && matrix.build_dmg == 'true' && env.SIGN_MACOS == 'true' }} + - if: ${{ runner.os == 'macOS' && github.repository_owner == 'openai' }} name: MacOS code signing (dmg) uses: ./.github/actions/macos-code-sign with: @@ -490,107 +683,65 @@ jobs: apple-notarization-issuer-id: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} - name: Stage artifacts - if: ${{ runner.os != 'macOS' || env.SIGN_MACOS == 'true' }} shell: bash run: | dest="dist/${{ matrix.target }}" mkdir -p "$dest" - for binary in ${{ matrix.binaries }}; do - cp "target/${{ matrix.target }}/release/${binary}" "$dest/${binary}-${{ matrix.target }}" - if [[ "${{ matrix.target }}" == *linux* ]]; then - cp "target/${{ matrix.target }}/release/${binary}.sigstore" \ - "$dest/${binary}-${{ matrix.target }}.sigstore" + if [[ "${{ matrix.runner }}" == windows* ]]; then + cp target/${{ matrix.target }}/release/codex.exe "$dest/codex-${{ matrix.target }}.exe" + cp target/${{ matrix.target }}/release/codex-responses-api-proxy.exe "$dest/codex-responses-api-proxy-${{ matrix.target }}.exe" + cp target/${{ matrix.target }}/release/codex-windows-sandbox-setup.exe "$dest/codex-windows-sandbox-setup-${{ matrix.target }}.exe" + cp target/${{ matrix.target }}/release/codex-command-runner.exe "$dest/codex-command-runner-${{ matrix.target }}.exe" + else + # For Android, we want the binary to be named just 'codex' in the archive. + if [[ "${{ matrix.target }}" == "aarch64-linux-android" ]]; then + cp target/${{ matrix.target }}/release/codex "$dest/codex" + else + cp target/${{ matrix.target }}/release/codex "$dest/codex-${{ matrix.target }}" + fi + if [[ -f target/${{ matrix.target }}/release/codex-responses-api-proxy ]]; then + cp target/${{ matrix.target }}/release/codex-responses-api-proxy "$dest/codex-responses-api-proxy-${{ matrix.target }}" fi - done - - if [[ "${{ matrix.target }}" == *linux* && "${{ matrix.bundle }}" == "primary" ]]; then - bundle_root="${RUNNER_TEMP}/codex-${{ matrix.target }}-bundle" - rm -rf "$bundle_root" - mkdir -p "$bundle_root/codex-resources" - cp "$dest/codex-${{ matrix.target }}" "$bundle_root/codex" - cp "$dest/bwrap-${{ matrix.target }}" "$bundle_root/codex-resources/bwrap" - chmod 0755 "$bundle_root/codex" "$bundle_root/codex-resources/bwrap" - tar -C "$bundle_root" -cf - codex codex-resources/bwrap | - zstd -T0 -19 -o "$dest/codex-${{ matrix.target }}-bundle.tar.zst" fi - if [[ "${{ matrix.build_dmg }}" == "true" ]]; then - cp target/${{ matrix.target }}/release/codex-${{ matrix.target }}.dmg "$dest/codex-${{ matrix.target }}.dmg" + if [[ "${{ matrix.target }}" == *linux* && "${{ matrix.target }}" != *android* ]]; then + cp target/${{ matrix.target }}/release/codex.sigstore "$dest/codex-${{ matrix.target }}.sigstore" + cp target/${{ matrix.target }}/release/codex-responses-api-proxy.sigstore "$dest/codex-responses-api-proxy-${{ matrix.target }}.sigstore" fi - - name: Build Python runtime wheel - if: ${{ matrix.bundle == 'primary' && (runner.os != 'macOS' || env.SIGN_MACOS == 'true') }} - shell: bash - run: | - set -euo pipefail - - case "${{ matrix.target }}" in - aarch64-apple-darwin) - platform_tag="macosx_11_0_arm64" - ;; - x86_64-apple-darwin) - platform_tag="macosx_10_9_x86_64" - ;; - aarch64-unknown-linux-musl) - platform_tag="manylinux_2_17_aarch64" - ;; - x86_64-unknown-linux-musl) - platform_tag="manylinux_2_17_x86_64" - ;; - *) - echo "No Python runtime wheel platform tag for ${{ matrix.target }}" - exit 1 - ;; - esac - - python3 -m venv "${RUNNER_TEMP}/python-runtime-build-venv" - # Do not install into the runner's system Python; macOS runners mark - # the Homebrew Python as externally managed under PEP 668. - "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m pip install build - - stage_dir="${RUNNER_TEMP}/openai-codex-cli-bin-${{ matrix.target }}" - wheel_dir="${GITHUB_WORKSPACE}/python-runtime-dist/${{ matrix.target }}" - stage_runtime_args=( - "${GITHUB_WORKSPACE}/sdk/python/scripts/update_sdk_artifacts.py" - stage-runtime - "$stage_dir" - "${GITHUB_WORKSPACE}/codex-rs/target/${{ matrix.target }}/release/codex" - --codex-version "${GITHUB_REF_NAME}" - --platform-tag "$platform_tag" - ) - if [[ "${{ matrix.target }}" == *linux* ]]; then - # Keep bwrap in the runtime wheel so Linux sandbox fallback behavior - # matches the standalone release bundle on hosts without system bwrap. - stage_runtime_args+=( - --resource-binary - "${GITHUB_WORKSPACE}/codex-rs/target/${{ matrix.target }}/release/bwrap" - ) + if [[ "${{ matrix.target }}" == *apple-darwin ]]; then + cp target/${{ matrix.target }}/release/codex-${{ matrix.target }}.dmg "$dest/codex-${{ matrix.target }}.dmg" fi - python3 "${stage_runtime_args[@]}" - "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m build --wheel --outdir "$wheel_dir" "$stage_dir" - - name: Upload Python runtime wheel - if: ${{ matrix.bundle == 'primary' && (runner.os != 'macOS' || env.SIGN_MACOS == 'true') }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: python-runtime-wheel-${{ matrix.target }} - path: python-runtime-dist/${{ matrix.target }}/*.whl - if-no-files-found: error + - if: ${{ matrix.runner == 'windows-11-arm' }} + name: Install zstd + shell: powershell + run: choco install -y zstandard - name: Compress artifacts - if: ${{ runner.os != 'macOS' || env.SIGN_MACOS == 'true' }} shell: bash run: | # Path that contains the uncompressed binaries for the current # ${{ matrix.target }} dest="dist/${{ matrix.target }}" + repo_root=$PWD + + # We want to ship the raw Windows executables in the GitHub Release + # in addition to the compressed archives. Keep the originals for + # Windows targets; remove them elsewhere to limit the number of + # artifacts that end up in the GitHub Release. + keep_originals=false + if [[ "${{ matrix.runner }}" == windows* ]]; then + keep_originals=true + fi # For compatibility with environments that lack the `zstd` tool we - # additionally create a `.tar.gz` alongside every binary we publish. - # The end result is: + # additionally create a `.tar.gz` for all platforms and `.zip` for + # Windows alongside every single binary that we publish. The end result is: # codex-.zst (existing) # codex-.tar.gz (new) + # codex-.zip (only for Windows) # 1. Produce a .tar.gz for every file in the directory *before* we # run `zstd --rm`, because that flag deletes the original files. @@ -598,7 +749,7 @@ jobs: base="$(basename "$f")" # Skip files that are already archives (shouldn't happen, but be # safe). - if [[ "$base" == *.tar.gz || "$base" == *.tar.zst || "$base" == *.zip || "$base" == *.dmg ]]; then + if [[ "$base" == *.tar.gz || "$base" == *.zip || "$base" == *.dmg ]]; then continue fi @@ -608,330 +759,239 @@ jobs: fi # Create per-binary tar.gz - tar -C "$dest" -czf "$dest/${base}.tar.gz" "$base" - - # Also create .zst and remove the uncompressed binaries to keep - # non-Windows artifact directories small. - zstd -T0 -19 --rm "$dest/$base" - done - - - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - if: ${{ runner.os != 'macOS' || env.SIGN_MACOS == 'true' }} - with: - name: ${{ matrix.artifact_name }} - # Upload the per-binary .zst files, .tar.gz equivalents, and any - # prebuilt archives staged above. - path: | - codex-rs/dist/${{ matrix.target }}/* - - stage-signed-macos: - if: ${{ github.event_name == 'workflow_dispatch' && inputs.release_mode == 'promote_signed' }} - needs: tag-check - name: Stage signed macOS handoff - ${{ matrix.target }} - ${{ matrix.bundle }} - runs-on: macos-15-xlarge - timeout-minutes: 30 - permissions: - contents: read - defaults: - run: - working-directory: codex-rs + # For Android, we want the archive to have the target suffix even though the binary is just 'codex'. + archive_name="${base}" + if [[ "${{ matrix.target }}" == "aarch64-linux-android" && "${base}" == "codex" ]]; then + archive_name="codex-${{ matrix.target }}" + fi + tar -C "$dest" -czf "$dest/${archive_name}.tar.gz" "$base" + + # Create zip archive for Windows binaries + # Must run from inside the dest dir so 7z won't + # embed the directory path inside the zip. + if [[ "${{ matrix.runner }}" == windows* ]]; then + if [[ "$base" == "codex-${{ matrix.target }}.exe" ]]; then + # Bundle the sandbox helper binaries into the main codex zip so + # WinGet installs include the required helpers next to codex.exe. + # Fall back to the single-binary zip if the helpers are missing + # to avoid breaking releases. + bundle_dir="$(mktemp -d)" + runner_src="$dest/codex-command-runner-${{ matrix.target }}.exe" + setup_src="$dest/codex-windows-sandbox-setup-${{ matrix.target }}.exe" + if [[ -f "$runner_src" && -f "$setup_src" ]]; then + cp "$dest/$base" "$bundle_dir/$base" + cp "$runner_src" "$bundle_dir/codex-command-runner.exe" + cp "$setup_src" "$bundle_dir/codex-windows-sandbox-setup.exe" + # Use an absolute path so bundle zips land in the real dist + # dir even when 7z runs from a temp directory. + (cd "$bundle_dir" && 7z a "$repo_root/$dest/${base}.zip" .) + else + echo "warning: missing sandbox binaries; falling back to single-binary zip" + echo "warning: expected $runner_src and $setup_src" + (cd "$dest" && 7z a "${base}.zip" "$base") + fi + rm -rf "$bundle_dir" + else + (cd "$dest" && 7z a "${base}.zip" "$base") + fi + fi - strategy: - fail-fast: false - matrix: - include: - - target: aarch64-apple-darwin - bundle: primary - artifact_name: aarch64-apple-darwin - binaries: "codex codex-responses-api-proxy" - build_dmg: "false" - - target: aarch64-apple-darwin - bundle: app-server - artifact_name: aarch64-apple-darwin-app-server - binaries: "codex-app-server" - build_dmg: "false" - - target: x86_64-apple-darwin - bundle: primary - artifact_name: x86_64-apple-darwin - binaries: "codex codex-responses-api-proxy" - build_dmg: "false" - - target: x86_64-apple-darwin - bundle: app-server - artifact_name: x86_64-apple-darwin-app-server - binaries: "codex-app-server" - build_dmg: "false" + # Also create .zst (existing behaviour) *and* remove the original + # uncompressed binary to keep the directory small. + zstd_args=(-T0 -19) + if [[ "${keep_originals}" == false ]]; then + zstd_args+=(--rm) + fi - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false + if [[ "${archive_name}" != "${base}" ]]; then + zstd "${zstd_args[@]}" "$dest/$base" -o "$dest/${archive_name}.zst" + else + zstd "${zstd_args[@]}" "$dest/$base" + fi + done - - name: Download signed macOS handoff + - name: Add Termux release metadata + if: ${{ (github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch') && matrix.target == 'aarch64-linux-android' }} shell: bash - env: - GH_TOKEN: ${{ github.token }} - SIGNED_MACOS_ASSET: ${{ inputs.signed_macos_asset }} - SIGNED_MACOS_SHA256: ${{ inputs.signed_macos_sha256 }} run: | set -euo pipefail - - download_dir="${RUNNER_TEMP}/signed-macos-download" - handoff_dir="${RUNNER_TEMP}/signed-macos-handoff" - rm -rf "$download_dir" "$handoff_dir" - mkdir -p "$download_dir" "$handoff_dir" - - gh release download "$GITHUB_REF_NAME" \ - --repo "$GITHUB_REPOSITORY" \ - --pattern "$SIGNED_MACOS_ASSET" \ - --dir "$download_dir" - - asset_count="$(find "$download_dir" -maxdepth 1 -type f | wc -l | tr -d '[:space:]')" - if [[ "$asset_count" != "1" ]]; then - echo "Expected exactly one signed macOS handoff asset named ${SIGNED_MACOS_ASSET}; found ${asset_count}" - find "$download_dir" -maxdepth 1 -type f -print - exit 1 - fi - - asset_path="$(find "$download_dir" -maxdepth 1 -type f -print -quit)" - if [[ -n "${SIGNED_MACOS_SHA256}" ]]; then - expected_sha="$(printf '%s' "$SIGNED_MACOS_SHA256" | tr '[:upper:]' '[:lower:]')" - actual_sha="$(shasum -a 256 "$asset_path" | awk '{print $1}')" - if [[ "$actual_sha" != "$expected_sha" ]]; then - echo "signed_macos_sha256 mismatch for ${SIGNED_MACOS_ASSET}" - echo "expected: ${expected_sha}" - echo "actual: ${actual_sha}" - exit 1 - fi + if [[ ! -f "${GITHUB_WORKSPACE}/.github/termux-release.json" ]]; then + echo "No Termux release metadata found; skipping metadata attachment." + exit 0 fi + dest="dist/${{ matrix.target }}" + cp "${GITHUB_WORKSPACE}/.github/termux-release.json" "${dest}/termux-release.json" + ( + cd "${dest}" + sha256sum ./* > SHA256SUMS + ) - asset_name="$(basename "$asset_path")" - case "$asset_name" in - *.tar.zst) - zstd -dc "$asset_path" | tar -C "$handoff_dir" -xf - - ;; - *.tar.gz|*.tgz) - tar -C "$handoff_dir" -xzf "$asset_path" - ;; - *.zip) - ditto -x -k "$asset_path" "$handoff_dir" - ;; - *) - echo "Unsupported signed macOS handoff archive format: ${asset_name}" - exit 1 - ;; - esac - - echo "SIGNED_MACOS_HANDOFF_DIR=$handoff_dir" >> "$GITHUB_ENV" + - id: upload-artifact + uses: actions/upload-artifact@v6 + with: + name: ${{ github.event_name == 'pull_request' && format('termux-android-pr-{0}-{1}', github.event.pull_request.number, github.event.pull_request.head.sha) || matrix.target }} + # Upload the per-binary .zst files as well as the new .tar.gz + # equivalents we generated in the previous step. + path: | + codex-rs/dist/${{ matrix.target }}/* - - name: Stage signed macOS artifacts + - name: Publish Termux artifact notification + if: ${{ github.event_name == 'pull_request' && startsWith(github.base_ref, 'release/') && matrix.target == 'aarch64-linux-android' }} shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ARTIFACT_ID: ${{ steps.upload-artifact.outputs.artifact-id }} + ARTIFACT_NAME: ${{ github.event_name == 'pull_request' && format('termux-android-pr-{0}-{1}', github.event.pull_request.number, github.event.pull_request.head.sha) || matrix.target }} + PR_NUMBER: ${{ github.event.pull_request.number }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} run: | set -euo pipefail - - target="${{ matrix.target }}" - artifact_name="${{ matrix.artifact_name }}" - source_dir="${SIGNED_MACOS_HANDOFF_DIR}/${artifact_name}" - if [[ ! -d "$source_dir" && -d "${SIGNED_MACOS_HANDOFF_DIR}/dist/${artifact_name}" ]]; then - source_dir="${SIGNED_MACOS_HANDOFF_DIR}/dist/${artifact_name}" - fi - if [[ ! -d "$source_dir" && -d "${SIGNED_MACOS_HANDOFF_DIR}/${target}" ]]; then - source_dir="${SIGNED_MACOS_HANDOFF_DIR}/${target}" - fi - if [[ ! -d "$source_dir" && -d "${SIGNED_MACOS_HANDOFF_DIR}/dist/${target}" ]]; then - source_dir="${SIGNED_MACOS_HANDOFF_DIR}/dist/${target}" - fi - if [[ ! -d "$source_dir" ]]; then - echo "Signed macOS handoff is missing ${artifact_name}/" - echo "Expected either:" - echo " ${SIGNED_MACOS_HANDOFF_DIR}/${artifact_name}" - echo " ${SIGNED_MACOS_HANDOFF_DIR}/dist/${artifact_name}" - echo " ${SIGNED_MACOS_HANDOFF_DIR}/${target}" - echo " ${SIGNED_MACOS_HANDOFF_DIR}/dist/${target}" - find "$SIGNED_MACOS_HANDOFF_DIR" -maxdepth 3 -type f -print + if [[ -z "${ARTIFACT_ID}" ]]; then + echo "upload-artifact did not return an artifact id" >&2 exit 1 fi - dest="dist/${target}" - mkdir -p "$dest" - - for binary in ${{ matrix.binaries }}; do - source_path="${source_dir}/${binary}" - if [[ ! -f "$source_path" ]]; then - source_path="${source_dir}/${binary}-${target}" - fi - if [[ ! -f "$source_path" ]]; then - echo "Signed macOS handoff is missing ${binary} for ${artifact_name}" - exit 1 - fi - - release_path="${dest}/${binary}-${target}" - ditto "$source_path" "$release_path" - chmod 0755 "$release_path" - codesign --verify --strict --verbose=2 "$release_path" - done - - # DMG staging is disabled for signed promotion because we no longer - # distribute DMGs from this release path. Keep the branch here so the - # handoff can opt back in by flipping matrix.build_dmg if needed. - if [[ "${{ matrix.build_dmg }}" == "true" ]]; then - dmg_name="codex-${target}.dmg" - dmg_source="${source_dir}/${dmg_name}" - if [[ ! -f "$dmg_source" ]]; then - echo "Signed macOS handoff is missing ${dmg_name} for ${artifact_name}" - exit 1 - fi - - codesign --verify --strict --verbose=2 "$dmg_source" - xcrun stapler validate "$dmg_source" - cp "$dmg_source" "$dest/$dmg_name" + marker="" + artifact_url="https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/artifacts/${ARTIFACT_ID}" + run_url="${GH_WORKFLOW_URL:-https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}}" + body_path="${RUNNER_TEMP}/termux-artifact-comment.md" + { + echo "${marker}" + echo "Termux Android artifact ready for testing:" + echo + echo "- Artifact: [${ARTIFACT_NAME}](${artifact_url})" + echo "- Workflow run: [${GITHUB_RUN_ID}](${run_url})" + echo + echo "You must be signed in to GitHub with repository access to download Actions artifacts." + } > "${body_path}" + + existing_comment_id="$( + gh pr view "${PR_NUMBER}" \ + --repo "${GITHUB_REPOSITORY}" \ + --json comments \ + --jq ".comments | map(select(.author.is_bot == true and (.body | contains(\"${marker}\")))) | .[-1].id // \"\"" + )" + if [[ -n "${existing_comment_id}" ]]; then + gh api graphql \ + -f query=' + mutation($id: ID!, $body: String!) { + updateIssueComment(input: {id: $id, body: $body}) { + issueComment { + id + } + } + } + ' \ + -f id="${existing_comment_id}" \ + -f body="$(cat "${body_path}")" \ + >/dev/null + else + gh pr comment "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" --body-file "${body_path}" fi - - name: Build Python runtime wheel - if: ${{ matrix.bundle == 'primary' }} - shell: bash - run: | - set -euo pipefail - - case "${{ matrix.target }}" in - aarch64-apple-darwin) - platform_tag="macosx_11_0_arm64" - ;; - x86_64-apple-darwin) - platform_tag="macosx_10_9_x86_64" - ;; - *) - echo "No Python runtime wheel platform tag for ${{ matrix.target }}" - exit 1 - ;; - esac - - python3 -m venv "${RUNNER_TEMP}/python-runtime-build-venv" - "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m pip install build - - stage_dir="${RUNNER_TEMP}/openai-codex-cli-bin-${{ matrix.target }}" - wheel_dir="${GITHUB_WORKSPACE}/python-runtime-dist/${{ matrix.target }}" - python3 \ - "${GITHUB_WORKSPACE}/sdk/python/scripts/update_sdk_artifacts.py" \ - stage-runtime \ - "$stage_dir" \ - "${GITHUB_WORKSPACE}/codex-rs/dist/${{ matrix.target }}/codex-${{ matrix.target }}" \ - --codex-version "${GITHUB_REF_NAME}" \ - --platform-tag "$platform_tag" - "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m build --wheel --outdir "$wheel_dir" "$stage_dir" - - - name: Upload Python runtime wheel - if: ${{ matrix.bundle == 'primary' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: python-runtime-wheel-${{ matrix.target }} - path: python-runtime-dist/${{ matrix.target }}/*.whl - if-no-files-found: error - - - name: Compress artifacts - shell: bash - run: | - set -euo pipefail - - dest="dist/${{ matrix.target }}" - for f in "$dest"/*; do - base="$(basename "$f")" - if [[ "$base" == *.tar.gz || "$base" == *.tar.zst || "$base" == *.zip || "$base" == *.dmg ]]; then - continue + gh label create binary-ready \ + --repo "${GITHUB_REPOSITORY}" \ + --color 0e8a16 \ + --description "Android binary is ready for testing" \ + --force + gh pr edit "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" --add-label binary-ready + + if [[ -n "${SLACK_WEBHOOK_URL:-}" ]]; then + slack_payload_path="${RUNNER_TEMP}/termux-artifact-slack.json" + pr_url="https://github.com/${GITHUB_REPOSITORY}/pull/${PR_NUMBER}" + jq -n \ + --arg artifact_name "${ARTIFACT_NAME}" \ + --arg artifact_url "${artifact_url}" \ + --arg pr_number "${PR_NUMBER}" \ + --arg pr_url "${pr_url}" \ + --arg run_id "${GITHUB_RUN_ID}" \ + --arg run_url "${run_url}" \ + '{ + text: ("Termux Android artifact ready for testing: " + $artifact_url), + blocks: [ + { + type: "section", + text: { + type: "mrkdwn", + text: ("*Termux Android artifact ready for testing*\n" + $artifact_url) + } + }, + { + type: "section", + fields: [ + { + type: "mrkdwn", + text: ("*Artifact:*\n<" + $artifact_url + "|" + $artifact_name + ">") + }, + { + type: "mrkdwn", + text: ("*Pull request:*\n<" + $pr_url + "|#" + $pr_number + ">") + }, + { + type: "mrkdwn", + text: ("*Workflow run:*\n<" + $run_url + "|" + $run_id + ">") + } + ] + }, + { + type: "context", + elements: [ + { + type: "mrkdwn", + text: "You must be signed in to GitHub with repository access to download Actions artifacts." + } + ] + } + ] + }' > "${slack_payload_path}" + + if ! curl -fsS \ + -X POST \ + -H "Content-type: application/json" \ + --data @"${slack_payload_path}" \ + "${SLACK_WEBHOOK_URL}"; then + echo "::warning::Failed to send Slack artifact notification" fi + fi - tar -C "$dest" -czf "$dest/${base}.tar.gz" "$base" - zstd -T0 -19 --rm "$dest/$base" - done - - - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: ${{ matrix.artifact_name }} - path: | - codex-rs/dist/${{ matrix.target }}/* - - build-windows: - if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} - needs: tag-check - uses: ./.github/workflows/rust-release-windows.yml - with: - release-lto: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'fat' }} - secrets: inherit - - argument-comment-lint-release-assets: - if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} - name: argument-comment-lint release assets + shell-tool-mcp: + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && github.repository_owner == 'openai' + name: shell-tool-mcp needs: tag-check - uses: ./.github/workflows/rust-release-argument-comment-lint.yml + permissions: + contents: read + id-token: write + uses: ./.github/workflows/shell-tool-mcp.yml with: + release-tag: ${{ github.ref_name }} publish: true - - zsh-release-assets: - if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} - name: zsh release assets - needs: tag-check - uses: ./.github/workflows/rust-release-zsh.yml + secrets: inherit release: + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') needs: - - tag-check - build - - stage-signed-macos - - build-windows - - argument-comment-lint-release-assets - - zsh-release-assets - if: >- - ${{ - always() && - needs.tag-check.result == 'success' && - ( - ( - github.event_name == 'workflow_dispatch' && - inputs.release_mode == 'promote_signed' && - needs.stage-signed-macos.result == 'success' && - needs.build.result == 'skipped' && - needs.build-windows.result == 'skipped' && - needs.argument-comment-lint-release-assets.result == 'skipped' && - needs.zsh-release-assets.result == 'skipped' - ) || - ( - (github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed') && - needs.build.result == 'success' && - needs.stage-signed-macos.result == 'skipped' && - needs.build-windows.result == 'success' && - needs.argument-comment-lint-release-assets.result == 'success' && - needs.zsh-release-assets.result == 'success' - ) - ) - }} + - shell-tool-mcp name: release runs-on: ubuntu-latest permissions: contents: write actions: read - env: - RELEASE_MODE: ${{ github.event_name == 'workflow_dispatch' && inputs.release_mode || 'signed' }} - SIGN_MACOS: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode == 'promote_signed' }} - SIGNED_MACOS_ASSET: ${{ inputs.signed_macos_asset }} - UNSIGNED_RUN_ID: ${{ inputs.unsigned_run_id }} outputs: version: ${{ steps.release_name.outputs.name }} tag: ${{ github.ref_name }} - sign_macos: ${{ steps.release_mode.outputs.sign_macos }} should_publish_npm: ${{ steps.npm_publish_settings.outputs.should_publish }} npm_tag: ${{ steps.npm_publish_settings.outputs.npm_tag }} - should_publish_python_runtime: ${{ steps.python_runtime_publish_settings.outputs.should_publish }} steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false + uses: actions/checkout@v6 - - name: Define release mode - id: release_mode - run: | - echo "release_mode=${RELEASE_MODE}" >> "$GITHUB_OUTPUT" - echo "sign_macos=${SIGN_MACOS}" >> "$GITHUB_OUTPUT" + - name: 🧰 Actions Toolbox + # This is required for the GitHub CLI + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: wallentx/gh-actions/composite/actions-toolbox@main - name: Generate release notes from tag commit message id: release_notes @@ -953,133 +1013,18 @@ jobs: echo "path=${notes_path}" >> "${GITHUB_OUTPUT}" - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + - uses: actions/download-artifact@v7 with: path: dist - - name: Validate unsigned build run - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - - run_summary="$(gh run view "$UNSIGNED_RUN_ID" \ - --repo "$GITHUB_REPOSITORY" \ - --json conclusion,event,headBranch,headSha,status,workflowName,url \ - --jq '[.workflowName, .event, .headBranch, .headSha, .status, .conclusion, .url] | @tsv')" - IFS=$'\t' read -r workflow_name event head_branch head_sha status conclusion run_url <<< "$run_summary" - expected_head_sha="$(git rev-parse "${GITHUB_SHA}^{commit}")" - - if [[ "$workflow_name" != "$GITHUB_WORKFLOW" ]]; then - echo "unsigned_run_id ${UNSIGNED_RUN_ID} is for workflow '${workflow_name}', expected '${GITHUB_WORKFLOW}'" - echo "Run URL: ${run_url}" - exit 1 - fi - - if [[ "$event" != "workflow_dispatch" ]]; then - echo "unsigned_run_id ${UNSIGNED_RUN_ID} was triggered by '${event}', expected 'workflow_dispatch'" - echo "Run URL: ${run_url}" - exit 1 - fi - - if [[ "$head_branch" != "$GITHUB_REF_NAME" ]]; then - echo "unsigned_run_id ${UNSIGNED_RUN_ID} used ref '${head_branch}', expected '${GITHUB_REF_NAME}'" - echo "Run URL: ${run_url}" - exit 1 - fi - - if [[ "$head_sha" != "$expected_head_sha" ]]; then - echo "unsigned_run_id ${UNSIGNED_RUN_ID} used head SHA '${head_sha}', expected '${expected_head_sha}'" - echo "Run URL: ${run_url}" - exit 1 - fi - - if [[ "$status" != "completed" || "$conclusion" != "success" ]]; then - echo "unsigned_run_id ${UNSIGNED_RUN_ID} is ${status}/${conclusion}, expected completed/success" - echo "Run URL: ${run_url}" - exit 1 - fi - - - name: Download artifacts from unsigned build run - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - gh run download "$UNSIGNED_RUN_ID" \ - --repo "$GITHUB_REPOSITORY" \ - --dir dist - - - name: Remove unsigned macOS staging artifacts - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - run: | - set -euo pipefail - find dist -mindepth 1 -maxdepth 1 -type d \ - -name '*-apple-darwin*-unsigned' \ - -exec rm -rf {} + - - - name: Re-upload promoted Linux x64 artifacts - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: x86_64-unknown-linux-musl - path: dist/x86_64-unknown-linux-musl/* - if-no-files-found: error - - - name: Re-upload promoted Linux arm64 artifacts - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: aarch64-unknown-linux-musl - path: dist/aarch64-unknown-linux-musl/* - if-no-files-found: error - - - name: Re-upload promoted Windows x64 artifacts - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: x86_64-pc-windows-msvc - path: dist/x86_64-pc-windows-msvc/* - if-no-files-found: error - - - name: Re-upload promoted Windows arm64 artifacts - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: aarch64-pc-windows-msvc - path: dist/aarch64-pc-windows-msvc/* - if-no-files-found: error - - name: List run: ls -R dist/ - - name: Prune artifacts excluded from unsigned macOS release - if: ${{ env.SIGN_MACOS == 'false' }} - run: | - find dist -mindepth 1 -maxdepth 1 -type d \ - ! -name '*-apple-darwin*-unsigned' \ - ! -name 'aarch64-unknown-linux-musl' \ - ! -name 'aarch64-unknown-linux-musl-app-server' \ - ! -name 'x86_64-unknown-linux-musl' \ - ! -name 'x86_64-unknown-linux-musl-app-server' \ - ! -name 'aarch64-pc-windows-msvc' \ - ! -name 'x86_64-pc-windows-msvc' \ - -exec rm -rf {} + - - if ! find dist -type f -name '*-apple-darwin*-unsigned*' | grep -q .; then - echo "No unsigned macOS artifacts found in downloaded workflow artifacts." - exit 1 - fi - + # This is a temporary fix: we should modify shell-tool-mcp.yml so these + # files do not end up in dist/ in the first place. - name: Delete entries from dist/ that should not go in the release run: | - rm -rf dist/windows-binaries* - # cargo-timing.html appears under multiple target-specific directories. - # If included in files: dist/**, release upload races on duplicate - # asset names and can fail with 404s. - find dist -type f -name 'cargo-timing.html' -delete - find dist -type d -empty -delete + rm -rf dist/shell-tool-mcp* ls -R dist/ @@ -1103,12 +1048,6 @@ jobs: set -euo pipefail version="${VERSION}" - if [[ "${SIGN_MACOS}" != "true" ]]; then - echo "should_publish=false" >> "$GITHUB_OUTPUT" - echo "npm_tag=" >> "$GITHUB_OUTPUT" - exit 0 - fi - if [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "should_publish=true" >> "$GITHUB_OUTPUT" echo "npm_tag=" >> "$GITHUB_OUTPUT" @@ -1120,132 +1059,55 @@ jobs: echo "npm_tag=" >> "$GITHUB_OUTPUT" fi - - name: Determine Python runtime publish settings - id: python_runtime_publish_settings - env: - VERSION: ${{ steps.release_name.outputs.name }} - run: | - set -euo pipefail - version="${VERSION}" - - if [[ "${SIGN_MACOS}" != "true" ]]; then - echo "should_publish=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - if [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "should_publish=true" >> "$GITHUB_OUTPUT" - elif [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+-alpha\.[0-9]+$ ]]; then - echo "should_publish=true" >> "$GITHUB_OUTPUT" - else - echo "should_publish=false" >> "$GITHUB_OUTPUT" - fi - - name: Setup pnpm - if: ${{ env.SIGN_MACOS == 'true' }} - uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5 + uses: pnpm/action-setup@v4 with: run_install: false - name: Setup Node.js for npm packaging - if: ${{ env.SIGN_MACOS == 'true' }} - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + uses: actions/setup-node@v6 with: node-version: 22 - name: Install dependencies - if: ${{ env.SIGN_MACOS == 'true' }} run: pnpm install --frozen-lockfile # stage_npm_packages.py requires DotSlash when staging releases. - - uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2 + - uses: facebook/install-dotslash@v2 - name: Stage npm packages - if: ${{ env.SIGN_MACOS == 'true' }} + if: github.repository_owner == 'openai' env: GH_TOKEN: ${{ github.token }} - RELEASE_VERSION: ${{ steps.release_name.outputs.name }} run: | - workflow_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" ./scripts/stage_npm_packages.py \ - --release-version "$RELEASE_VERSION" \ - --workflow-url "$workflow_url" \ + --release-version "${{ steps.release_name.outputs.name }}" \ --package codex \ --package codex-responses-api-proxy \ --package codex-sdk - - name: Stage installer scripts - if: ${{ env.SIGN_MACOS == 'true' }} - run: | - cp scripts/install/install.sh dist/install.sh - cp scripts/install/install.ps1 dist/install.ps1 - - name: Create GitHub Release - uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1 + uses: softprops/action-gh-release@v2 with: name: ${{ steps.release_name.outputs.name }} tag_name: ${{ github.ref_name }} body_path: ${{ steps.release_notes.outputs.path }} files: dist/** - overwrite_files: true - make_latest: ${{ env.SIGN_MACOS == 'true' && !contains(steps.release_name.outputs.name, '-') }} # Mark as prerelease only when the version has a suffix after x.y.z # (e.g. -alpha, -beta). Otherwise publish a normal release. prerelease: ${{ contains(steps.release_name.outputs.name, '-') }} - - name: Clean up signed promotion handoff assets - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - - release_id="$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${GITHUB_REF_NAME}" --jq '.id')" - gh api --paginate "repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets" \ - --jq '.[] | [.id, .name] | @tsv' | - while IFS=$'\t' read -r asset_id asset_name; do - if [[ -z "$asset_id" || -z "$asset_name" ]]; then - continue - fi - - delete_asset=false - if [[ "$asset_name" == *unsigned* || "$asset_name" == "$SIGNED_MACOS_ASSET" ]]; then - delete_asset=true - fi - - if [[ "$delete_asset" == "true" ]]; then - echo "Deleting release asset ${asset_name}" - gh api -X DELETE "repos/${GITHUB_REPOSITORY}/releases/assets/${asset_id}" - fi - done - - - if: ${{ env.SIGN_MACOS == 'true' }} - uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 + - if: github.repository_owner == 'openai' + uses: facebook/dotslash-publish-release@v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: tag: ${{ github.ref_name }} config: .github/dotslash-config.json - - if: ${{ env.SIGN_MACOS == 'true' }} - uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag: ${{ github.ref_name }} - config: .github/dotslash-zsh-config.json - - - if: ${{ env.SIGN_MACOS == 'true' }} - uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag: ${{ github.ref_name }} - config: .github/dotslash-argument-comment-lint-config.json - - name: Trigger developers.openai.com deploy # Only trigger the deploy if the release is not a pre-release. # The deploy is used to update the developers.openai.com website with the new config schema json file. - if: ${{ env.SIGN_MACOS == 'true' && !contains(steps.release_name.outputs.name, '-') }} + if: ${{ !contains(steps.release_name.outputs.name, '-') && github.repository_owner == 'openai' }} continue-on-error: true env: DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL: ${{ secrets.DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL }} @@ -1260,15 +1122,7 @@ jobs: # npm docs: https://docs.npmjs.com/trusted-publishers publish-npm: # Publish to npm for stable releases and alpha pre-releases with numeric suffixes. - # promote_signed intentionally skips build jobs that are ancestors of release; - # include the !cancelled() status function so Actions does not apply its implicit - # success() check to the whole dependency chain before evaluating release outputs. - if: >- - ${{ - !cancelled() && - needs.release.result == 'success' && - needs.release.outputs.should_publish_npm == 'true' - }} + if: ${{ needs.release.outputs.should_publish_npm == 'true' && github.repository_owner == 'openai' }} name: publish-npm needs: release runs-on: ubuntu-latest @@ -1278,37 +1132,36 @@ jobs: steps: - name: Setup Node.js - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + uses: actions/setup-node@v6 with: - # Node 24 bundles npm >= 11.5.1, which trusted publishing requires. - node-version: 24 + node-version: 22 registry-url: "https://registry.npmjs.org" scope: "@openai" + # Trusted publishing requires npm CLI version 11.5.1 or later. + - name: Update npm + run: npm install -g npm@latest + - name: Download npm tarballs from release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_TAG: ${{ needs.release.outputs.tag }} - RELEASE_VERSION: ${{ needs.release.outputs.version }} run: | set -euo pipefail - version="$RELEASE_VERSION" - tag="$RELEASE_TAG" + version="${{ needs.release.outputs.version }}" + tag="${{ needs.release.outputs.tag }}" mkdir -p dist/npm - patterns=( - "codex-npm-${version}.tgz" - "codex-npm-linux-*-${version}.tgz" - "codex-npm-darwin-*-${version}.tgz" - "codex-npm-win32-*-${version}.tgz" - "codex-responses-api-proxy-npm-${version}.tgz" - "codex-sdk-npm-${version}.tgz" - ) - for pattern in "${patterns[@]}"; do - gh release download "$tag" \ - --repo "${GITHUB_REPOSITORY}" \ - --pattern "$pattern" \ - --dir dist/npm - done + gh release download "$tag" \ + --repo "${GITHUB_REPOSITORY}" \ + --pattern "codex-npm-${version}.tgz" \ + --dir dist/npm + gh release download "$tag" \ + --repo "${GITHUB_REPOSITORY}" \ + --pattern "codex-responses-api-proxy-npm-${version}.tgz" \ + --dir dist/npm + gh release download "$tag" \ + --repo "${GITHUB_REPOSITORY}" \ + --pattern "codex-sdk-npm-${version}.tgz" \ + --dir dist/npm # No NODE_AUTH_TOKEN needed because we use OIDC. - name: Publish to npm @@ -1317,193 +1170,23 @@ jobs: NPM_TAG: ${{ needs.release.outputs.npm_tag }} run: | set -euo pipefail - prefix="" + tag_args=() if [[ -n "${NPM_TAG}" ]]; then - prefix="${NPM_TAG}-" + tag_args+=(--tag "${NPM_TAG}") fi - root_tarball="dist/npm/codex-npm-${VERSION}.tgz" - sdk_tarball="dist/npm/codex-sdk-npm-${VERSION}.tgz" - # Keep this list in sync with CODEX_PLATFORM_PACKAGES in - # codex-cli/scripts/build_npm_package.py. The root wrapper advances - # @openai/codex@latest as soon as it publishes, so every platform - # package it aliases must already exist in the registry first. - platform_tarballs=( - "dist/npm/codex-npm-linux-x64-${VERSION}.tgz" - "dist/npm/codex-npm-linux-arm64-${VERSION}.tgz" - "dist/npm/codex-npm-darwin-x64-${VERSION}.tgz" - "dist/npm/codex-npm-darwin-arm64-${VERSION}.tgz" - "dist/npm/codex-npm-win32-x64-${VERSION}.tgz" - "dist/npm/codex-npm-win32-arm64-${VERSION}.tgz" - ) - - for required_tarball in "${platform_tarballs[@]}" "${root_tarball}"; do - if [[ ! -f "${required_tarball}" ]]; then - echo "Missing npm tarball: ${required_tarball}" - exit 1 - fi - done - - shopt -s nullglob - other_tarballs=() - for tarball in dist/npm/*-"${VERSION}".tgz; do - if [[ "${tarball}" == "${root_tarball}" || "${tarball}" == "${sdk_tarball}" ]]; then - continue - fi - - is_platform_tarball=false - for platform_tarball in "${platform_tarballs[@]}"; do - if [[ "${tarball}" == "${platform_tarball}" ]]; then - is_platform_tarball=true - break - fi - done - if [[ "${is_platform_tarball}" == true ]]; then - continue - fi - - other_tarballs+=("${tarball}") - done - - # Publish the platform packages before the root CLI wrapper. The root - # wrapper advances @openai/codex@latest, so it should only publish - # after the optional dependency versions it references exist. tarballs=( - "${platform_tarballs[@]}" - "${other_tarballs[@]}" - "${root_tarball}" + "codex-npm-${VERSION}.tgz" + "codex-responses-api-proxy-npm-${VERSION}.tgz" + "codex-sdk-npm-${VERSION}.tgz" ) - if [[ -f "${sdk_tarball}" ]]; then - tarballs+=("${sdk_tarball}") - fi for tarball in "${tarballs[@]}"; do - filename="$(basename "${tarball}")" - tag="" - - case "${filename}" in - codex-npm-linux-*-"${VERSION}".tgz|codex-npm-darwin-*-"${VERSION}".tgz|codex-npm-win32-*-"${VERSION}".tgz) - platform="${filename#codex-npm-}" - platform="${platform%-${VERSION}.tgz}" - tag="${prefix}${platform}" - ;; - codex-npm-"${VERSION}".tgz|codex-responses-api-proxy-npm-"${VERSION}".tgz|codex-sdk-npm-"${VERSION}".tgz) - tag="${NPM_TAG}" - ;; - *) - echo "Unexpected npm tarball: ${filename}" - exit 1 - ;; - esac - - publish_cmd=(npm publish "${GITHUB_WORKSPACE}/${tarball}") - if [[ -n "${tag}" ]]; then - publish_cmd+=(--tag "${tag}") - fi - - echo "+ ${publish_cmd[*]}" - set +e - publish_output="$("${publish_cmd[@]}" 2>&1)" - publish_status=$? - set -e - - echo "${publish_output}" - if [[ ${publish_status} -eq 0 ]]; then - continue - fi - - if grep -qiE "previously published|cannot publish over|version already exists" <<< "${publish_output}"; then - echo "Skipping already-published package version for ${filename}" - continue - fi - - exit "${publish_status}" + npm publish "${GITHUB_WORKSPACE}/dist/npm/${tarball}" "${tag_args[@]}" done - # Publish the platform-specific Python runtime wheels using PyPI trusted publishing. - # PyPI project configuration must trust this workflow and job. Keep this - # non-blocking while the Python runtime publishing path is new; failures still - # need release follow-up, but should not invalidate the Rust release itself. - publish-python-runtime: - # Publish to PyPI for stable releases and alpha pre-releases with numeric suffixes. - if: >- - ${{ - !cancelled() && - needs.release.result == 'success' && - needs.release.outputs.should_publish_python_runtime == 'true' - }} - name: publish-python-runtime - needs: release - runs-on: ubuntu-latest - continue-on-error: true - environment: pypi - permissions: - id-token: write # Required for PyPI trusted publishing. - contents: read - - steps: - - name: Download Python runtime wheels from release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_TAG: ${{ needs.release.outputs.tag }} - RELEASE_VERSION: ${{ needs.release.outputs.version }} - run: | - set -euo pipefail - python_version="$RELEASE_VERSION" - python_version="${python_version/-alpha./a}" - python_version="${python_version/-beta./b}" - python_version="${python_version/-rc./rc}" - - mkdir -p dist/python-runtime - gh release download "$RELEASE_TAG" \ - --repo "${GITHUB_REPOSITORY}" \ - --pattern "openai_codex_cli_bin-${python_version}-*.whl" \ - --dir dist/python-runtime - ls -lh dist/python-runtime - - - name: Publish Python runtime wheels to PyPI - uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 - with: - packages-dir: dist/python-runtime - skip-existing: true - - winget: - name: winget - needs: release - # Only publish stable/mainline releases to WinGet; pre-releases include a - # '-' in the semver string (e.g., 1.2.3-alpha.1). - if: >- - ${{ - !cancelled() && - needs.release.result == 'success' && - needs.release.outputs.sign_macos == 'true' && - !contains(needs.release.outputs.version, '-') - }} - # This job only invokes a GitHub Action to open/update the winget-pkgs PR; - # it does not execute Windows-only tooling, so Linux is sufficient. - runs-on: ubuntu-latest - permissions: - contents: read - - steps: - - name: Publish to WinGet - uses: vedantmgoyal9/winget-releaser@7bd472be23763def6e16bd06cc8b1cdfab0e2fd5 - with: - identifier: OpenAI.Codex - version: ${{ needs.release.outputs.version }} - release-tag: ${{ needs.release.outputs.tag }} - fork-user: openai-oss-forks - installers-regex: '^codex-(?:x86_64|aarch64)-pc-windows-msvc\.exe\.zip$' - token: ${{ secrets.WINGET_PUBLISH_PAT }} - update-branch: name: Update latest-alpha-cli branch - if: >- - ${{ - !cancelled() && - needs.release.result == 'success' && - needs.release.outputs.sign_macos == 'true' - }} permissions: contents: write needs: release diff --git a/.github/workflows/shell-tool-mcp.yml b/.github/workflows/shell-tool-mcp.yml new file mode 100644 index 000000000000..66a76aa4d938 --- /dev/null +++ b/.github/workflows/shell-tool-mcp.yml @@ -0,0 +1,461 @@ +name: shell-tool-mcp + +on: + workflow_call: + inputs: + release-version: + description: Version to publish (x.y.z or x.y.z-alpha.N). Defaults to GITHUB_REF_NAME when it starts with rust-v. + required: false + type: string + release-tag: + description: Tag name to use when downloading release artifacts (defaults to rust-v). + required: false + type: string + publish: + description: Whether to publish to npm when the version is releasable. + required: false + default: true + type: boolean + +env: + NODE_VERSION: 22 + +jobs: + metadata: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.compute.outputs.version }} + release_tag: ${{ steps.compute.outputs.release_tag }} + should_publish: ${{ steps.compute.outputs.should_publish }} + npm_tag: ${{ steps.compute.outputs.npm_tag }} + steps: + - name: Compute version and tags + id: compute + run: | + set -euo pipefail + + version="${{ inputs.release-version }}" + release_tag="${{ inputs.release-tag }}" + + if [[ -z "$version" ]]; then + if [[ -n "$release_tag" && "$release_tag" =~ ^rust-v.+ ]]; then + version="${release_tag#rust-v}" + elif [[ "${GITHUB_REF_NAME:-}" =~ ^rust-v.+ ]]; then + version="${GITHUB_REF_NAME#rust-v}" + release_tag="${GITHUB_REF_NAME}" + else + echo "release-version is required when GITHUB_REF_NAME is not a rust-v tag." + exit 1 + fi + fi + + if [[ -z "$release_tag" ]]; then + release_tag="rust-v${version}" + fi + + npm_tag="" + should_publish="false" + if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + should_publish="true" + elif [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+-alpha\.[0-9]+$ ]]; then + should_publish="true" + npm_tag="alpha" + fi + + echo "version=${version}" >> "$GITHUB_OUTPUT" + echo "release_tag=${release_tag}" >> "$GITHUB_OUTPUT" + echo "npm_tag=${npm_tag}" >> "$GITHUB_OUTPUT" + echo "should_publish=${should_publish}" >> "$GITHUB_OUTPUT" + + matrix-setup: + runs-on: ubuntu-latest + outputs: + rust-binaries: ${{ steps.compute.outputs.rust-binaries }} + bash-linux: ${{ steps.compute.outputs.bash-linux }} + bash-darwin: ${{ steps.compute.outputs.bash-darwin }} + steps: + - name: Compute matrices + id: compute + shell: bash + run: | + set -euo pipefail + is_openai="${{ github.repository_owner == 'openai' }}" + + # rust-binaries: always include x86_64-musl; conditionally include paid/fork runners + if [[ "$is_openai" == "true" ]]; then + rust='[ + {"runner":"macos-15-xlarge","target":"aarch64-apple-darwin"}, + {"runner":"macos-15-xlarge","target":"x86_64-apple-darwin"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","install_musl":true} + ]' + else + rust='[{"runner":"macos-latest","target":"aarch64-apple-darwin"}]' + fi + rust=$(echo "$rust" | jq -c '. + [{"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","install_musl":true}]') + echo "rust-binaries={\"include\":$rust}" >> "$GITHUB_OUTPUT" + + # bash-linux: always include x86_64 variants; add arm64 on openai + bash_linux='[ + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","variant":"ubuntu-24.04","image":"ubuntu:24.04"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","variant":"ubuntu-22.04","image":"ubuntu:22.04"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","variant":"debian-12","image":"debian:12"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","variant":"debian-11","image":"debian:11"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","variant":"centos-9","image":"quay.io/centos/centos:stream9"} + ]' + if [[ "$is_openai" == "true" ]]; then + bash_linux=$(echo "$bash_linux" | jq -c '. + [ + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"ubuntu-24.04","image":"arm64v8/ubuntu:24.04"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"ubuntu-22.04","image":"arm64v8/ubuntu:22.04"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"ubuntu-20.04","image":"arm64v8/ubuntu:20.04"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"debian-12","image":"arm64v8/debian:12"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"debian-11","image":"arm64v8/debian:11"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"centos-9","image":"quay.io/centos/centos:stream9"} + ]') + fi + echo "bash-linux={\"include\":$(echo "$bash_linux" | jq -c)}" >> "$GITHUB_OUTPUT" + + # bash-darwin: always include macos-14; add macos-15-xlarge on openai + bash_darwin='[{"runner":"macos-14","target":"aarch64-apple-darwin","variant":"macos-14"}]' + if [[ "$is_openai" == "true" ]]; then + bash_darwin=$(echo "$bash_darwin" | jq -c '. + [ + {"runner":"macos-15-xlarge","target":"aarch64-apple-darwin","variant":"macos-15"} + ]') + fi + echo "bash-darwin={\"include\":$(echo "$bash_darwin" | jq -c)}" >> "$GITHUB_OUTPUT" + + rust-binaries: + name: Build Rust - ${{ matrix.target }} + needs: [metadata, matrix-setup] + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + defaults: + run: + working-directory: codex-rs + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.matrix-setup.outputs.rust-binaries) }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Install UBSan runtime (musl) + if: ${{ matrix.install_musl }} + shell: bash + run: | + set -euo pipefail + if command -v apt-get >/dev/null 2>&1; then + sudo apt-get update -y + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y libubsan1 + fi + + - uses: dtolnay/rust-toolchain@1.93 + with: + targets: ${{ matrix.target }} + + - if: ${{ matrix.install_musl }} + name: Install Zig + uses: mlugg/setup-zig@v2 + with: + version: 0.14.0 + + - if: ${{ matrix.install_musl }} + name: Install musl build dependencies + env: + TARGET: ${{ matrix.target }} + run: bash "${GITHUB_WORKSPACE}/.github/scripts/install-musl-build-tools.sh" + + - if: ${{ matrix.install_musl }} + name: Configure rustc UBSan wrapper (musl host) + shell: bash + run: | + set -euo pipefail + ubsan="" + if command -v ldconfig >/dev/null 2>&1; then + ubsan="$(ldconfig -p | grep -m1 'libubsan\.so\.1' | sed -E 's/.*=> (.*)$/\1/')" + fi + wrapper_root="${RUNNER_TEMP:-/tmp}" + wrapper="${wrapper_root}/rustc-ubsan-wrapper" + cat > "${wrapper}" <> "$GITHUB_ENV" + echo "RUSTC_WORKSPACE_WRAPPER=" >> "$GITHUB_ENV" + + - if: ${{ matrix.install_musl }} + name: Clear sanitizer flags (musl) + shell: bash + run: | + set -euo pipefail + # Clear global Rust flags so host/proc-macro builds don't pull in UBSan. + echo "RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_ENCODED_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "RUSTDOCFLAGS=" >> "$GITHUB_ENV" + # Override any runner-level Cargo config rustflags as well. + echo "CARGO_BUILD_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_RUSTFLAGS=" >> "$GITHUB_ENV" + + sanitize_flags() { + local input="$1" + input="${input//-fsanitize=undefined/}" + input="${input//-fno-sanitize-recover=undefined/}" + input="${input//-fno-sanitize-trap=undefined/}" + echo "$input" + } + + cflags="$(sanitize_flags "${CFLAGS-}")" + cxxflags="$(sanitize_flags "${CXXFLAGS-}")" + echo "CFLAGS=${cflags}" >> "$GITHUB_ENV" + echo "CXXFLAGS=${cxxflags}" >> "$GITHUB_ENV" + + - name: Build exec server binaries + run: cargo build --release --target ${{ matrix.target }} --bin codex-exec-mcp-server --bin codex-execve-wrapper + + - name: Stage exec server binaries + run: | + dest="${GITHUB_WORKSPACE}/artifacts/vendor/${{ matrix.target }}" + mkdir -p "$dest" + cp "target/${{ matrix.target }}/release/codex-exec-mcp-server" "$dest/" + cp "target/${{ matrix.target }}/release/codex-execve-wrapper" "$dest/" + + - uses: actions/upload-artifact@v6 + with: + name: shell-tool-mcp-rust-${{ matrix.target }} + path: artifacts/** + if-no-files-found: error + + bash-linux: + name: Build Bash (Linux) - ${{ matrix.variant }} - ${{ matrix.target }} + needs: [metadata, matrix-setup] + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + container: + image: ${{ matrix.image }} + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.matrix-setup.outputs.bash-linux) }} + steps: + - name: Install build prerequisites + shell: bash + run: | + set -euo pipefail + if command -v apt-get >/dev/null 2>&1; then + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y git build-essential bison autoconf gettext + elif command -v dnf >/dev/null 2>&1; then + dnf install -y git gcc gcc-c++ make bison autoconf gettext + elif command -v yum >/dev/null 2>&1; then + yum install -y git gcc gcc-c++ make bison autoconf gettext + else + echo "Unsupported package manager in container" + exit 1 + fi + + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Build patched Bash + shell: bash + run: | + set -euo pipefail + git clone --depth 1 https://github.com/bolinfest/bash /tmp/bash + cd /tmp/bash + git fetch --depth 1 origin a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b + git checkout a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b + git apply "${GITHUB_WORKSPACE}/shell-tool-mcp/patches/bash-exec-wrapper.patch" + ./configure --without-bash-malloc + cores="$(command -v nproc >/dev/null 2>&1 && nproc || getconf _NPROCESSORS_ONLN)" + make -j"${cores}" + + dest="${GITHUB_WORKSPACE}/artifacts/vendor/${{ matrix.target }}/bash/${{ matrix.variant }}" + mkdir -p "$dest" + cp bash "$dest/bash" + + - uses: actions/upload-artifact@v6 + with: + name: shell-tool-mcp-bash-${{ matrix.target }}-${{ matrix.variant }} + path: artifacts/** + if-no-files-found: error + + bash-darwin: + name: Build Bash (macOS) - ${{ matrix.variant }} - ${{ matrix.target }} + needs: [metadata, matrix-setup] + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.matrix-setup.outputs.bash-darwin) }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Build patched Bash + shell: bash + run: | + set -euo pipefail + git clone --depth 1 https://github.com/bolinfest/bash /tmp/bash + cd /tmp/bash + git fetch --depth 1 origin a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b + git checkout a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b + git apply "${GITHUB_WORKSPACE}/shell-tool-mcp/patches/bash-exec-wrapper.patch" + ./configure --without-bash-malloc + cores="$(getconf _NPROCESSORS_ONLN)" + make -j"${cores}" + + dest="${GITHUB_WORKSPACE}/artifacts/vendor/${{ matrix.target }}/bash/${{ matrix.variant }}" + mkdir -p "$dest" + cp bash "$dest/bash" + + - uses: actions/upload-artifact@v6 + with: + name: shell-tool-mcp-bash-${{ matrix.target }}-${{ matrix.variant }} + path: artifacts/** + if-no-files-found: error + + package: + name: Package npm module + needs: + - metadata + - rust-binaries + - bash-linux + - bash-darwin + runs-on: ubuntu-latest + env: + PACKAGE_VERSION: ${{ needs.metadata.outputs.version }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Install JavaScript dependencies + run: pnpm install --frozen-lockfile + + - name: Build (shell-tool-mcp) + run: pnpm --filter @openai/codex-shell-tool-mcp run build + + - name: Download build artifacts + uses: actions/download-artifact@v7 + with: + path: artifacts + + - name: Assemble staging directory + id: staging + shell: bash + run: | + set -euo pipefail + staging="${STAGING_DIR}" + mkdir -p "$staging" "$staging/vendor" + cp shell-tool-mcp/README.md "$staging/" + cp shell-tool-mcp/package.json "$staging/" + cp -R shell-tool-mcp/bin "$staging/" + + found_vendor="false" + shopt -s nullglob + for vendor_dir in artifacts/*/vendor; do + rsync -av "$vendor_dir/" "$staging/vendor/" + found_vendor="true" + done + if [[ "$found_vendor" == "false" ]]; then + echo "No vendor payloads were downloaded." + exit 1 + fi + + node - <<'NODE' + import fs from "node:fs"; + import path from "node:path"; + + const stagingDir = process.env.STAGING_DIR; + const version = process.env.PACKAGE_VERSION; + const pkgPath = path.join(stagingDir, "package.json"); + const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")); + pkg.version = version; + fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n"); + NODE + + echo "dir=$staging" >> "$GITHUB_OUTPUT" + env: + STAGING_DIR: ${{ runner.temp }}/shell-tool-mcp + + - name: Ensure binaries are executable + run: | + set -euo pipefail + staging="${{ steps.staging.outputs.dir }}" + chmod +x \ + "$staging"/vendor/*/codex-exec-mcp-server \ + "$staging"/vendor/*/codex-execve-wrapper \ + "$staging"/vendor/*/bash/*/bash + + - name: Create npm tarball + shell: bash + run: | + set -euo pipefail + mkdir -p dist/npm + staging="${{ steps.staging.outputs.dir }}" + pack_info=$(cd "$staging" && npm pack --ignore-scripts --json --pack-destination "${GITHUB_WORKSPACE}/dist/npm") + filename=$(PACK_INFO="$pack_info" node -e 'const data = JSON.parse(process.env.PACK_INFO); console.log(data[0].filename);') + mv "dist/npm/${filename}" "dist/npm/codex-shell-tool-mcp-npm-${PACKAGE_VERSION}.tgz" + + - uses: actions/upload-artifact@v6 + with: + name: codex-shell-tool-mcp-npm + path: dist/npm/codex-shell-tool-mcp-npm-${{ env.PACKAGE_VERSION }}.tgz + if-no-files-found: error + + publish: + name: Publish npm package + needs: + - metadata + - package + if: ${{ inputs.publish && needs.metadata.outputs.should_publish == 'true' }} + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + steps: + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + registry-url: https://registry.npmjs.org + scope: "@openai" + + # Trusted publishing requires npm CLI version 11.5.1 or later. + - name: Update npm + run: npm install -g npm@latest + + - name: Download npm tarball + uses: actions/download-artifact@v7 + with: + name: codex-shell-tool-mcp-npm + path: dist/npm + + - name: Publish to npm + if: github.repository == 'openai/codex' + env: + NPM_TAG: ${{ needs.metadata.outputs.npm_tag }} + VERSION: ${{ needs.metadata.outputs.version }} + shell: bash + run: | + set -euo pipefail + tag_args=() + if [[ -n "${NPM_TAG}" ]]; then + tag_args+=(--tag "${NPM_TAG}") + fi + npm publish "dist/npm/codex-shell-tool-mcp-npm-${VERSION}.tgz" "${tag_args[@]}" diff --git a/.github/workflows/termux-release-checkpoint.yml b/.github/workflows/termux-release-checkpoint.yml new file mode 100644 index 000000000000..0347e6f1b3d6 --- /dev/null +++ b/.github/workflows/termux-release-checkpoint.yml @@ -0,0 +1,103 @@ +name: termux-release-checkpoint + +on: + workflow_dispatch: + inputs: + source_branch: + description: "Release branch to checkpoint from, for example release/0.123.0" + required: false + type: string + default: "" + source_sha: + description: "Specific source commit SHA to checkpoint; defaults to the source branch tip" + required: false + type: string + default: "" + destination_branch: + description: "Destination patch branch to receive the checkpoint PR" + required: false + type: string + default: "wallentx/termux-target" + reviewer: + description: "GitHub username to request as reviewer" + required: false + type: string + default: "wallentx" + +permissions: + actions: read + attestations: read + checks: read + contents: read + deployments: read + issues: read + discussions: read + packages: read + pages: read + pull-requests: read + repository-projects: read + statuses: read + +concurrency: + group: termux-release-checkpoint-${{ inputs.source_branch || github.ref_name }} + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + checkpoint: + runs-on: ubuntu-slim + permissions: + contents: write + issues: write + pull-requests: write + env: + DESTINATION_BRANCH: ${{ inputs.destination_branch || 'wallentx/termux-target' }} + REVIEWER: ${{ inputs.reviewer || 'wallentx' }} + REQUESTED_SOURCE_BRANCH: ${{ inputs.source_branch }} + REQUESTED_SOURCE_SHA: ${{ inputs.source_sha }} + TERMUX_AUTOMATION_DIR: ${{ github.workspace }}/.termux-release-automation + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Export GitHub App token for gh + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + run: echo "GH_TOKEN=${APP_TOKEN}" >> "${GITHUB_ENV}" + + - name: Checkout release branch + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ inputs.source_branch || github.ref_name }} + token: ${{ steps.app-token.outputs.token }} + + - name: Checkout automation helpers + uses: actions/checkout@v6 + with: + fetch-depth: 1 + ref: ${{ github.workflow_sha }} + path: .termux-release-automation + token: ${{ steps.app-token.outputs.token }} + + - name: 🧰 Actions Toolbox + uses: wallentx/gh-actions/composite/actions-toolbox@main + + - name: Validate GitHub CLI environment + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-validate-gh-env.sh" + + - name: Configure git + run: | + set -euo pipefail + source_branch="${REQUESTED_SOURCE_BRANCH:-${GITHUB_REF_NAME}}" + bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-configure-git.sh" --origin "${DESTINATION_BRANCH}" "${source_branch}" + + - name: Create checkpoint PR + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-create-checkpoint-pr.sh" diff --git a/.github/workflows/termux-release-deploy.yml b/.github/workflows/termux-release-deploy.yml new file mode 100644 index 000000000000..2071611dacc4 --- /dev/null +++ b/.github/workflows/termux-release-deploy.yml @@ -0,0 +1,243 @@ +name: termux-release-deploy + +on: + push: + branches: + - "release/**" + workflow_dispatch: + inputs: + release_branch: + description: "Release branch to deploy, for example release/0.124.0" + required: true + type: string + release_sha: + description: "Release branch commit SHA to deploy. Defaults to the branch head." + required: false + default: "" + type: string + pr_number: + description: "Merged release PR number to promote. Optional; normally discovered automatically." + required: false + default: "" + type: string + pr_head_sha: + description: "Merged release PR head SHA. Required only when pr_number is set." + required: false + default: "" + type: string + destination_branch: + description: "Destination patch branch to receive the checkpoint PR" + required: false + default: "wallentx/termux-target" + type: string + reviewer: + description: "GitHub username to request as reviewer on the checkpoint PR" + required: false + default: "wallentx" + type: string + +permissions: + actions: read + attestations: read + checks: read + contents: read + deployments: read + issues: read + discussions: read + packages: read + pages: read + pull-requests: read + repository-projects: read + statuses: read + +concurrency: + group: termux-release-deploy-${{ github.event_name == 'workflow_dispatch' && inputs.release_branch || github.ref_name }} + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + deploy: + runs-on: ubuntu-24.04 + if: ${{ github.event_name == 'workflow_dispatch' || !startsWith(github.event.head_commit.message, 'Seed Termux release automation') }} + permissions: + actions: read + contents: write + deployments: write + issues: write + pull-requests: write + env: + REQUESTED_RELEASE_BRANCH: ${{ inputs.release_branch }} + REQUESTED_RELEASE_SHA: ${{ inputs.release_sha }} + INPUT_PR_NUMBER: ${{ inputs.pr_number }} + INPUT_PR_HEAD_SHA: ${{ inputs.pr_head_sha }} + DESTINATION_BRANCH: ${{ inputs.destination_branch || 'wallentx/termux-target' }} + REVIEWER: ${{ inputs.reviewer || 'wallentx' }} + TERMUX_AUTOMATION_DIR: ${{ github.workspace }}/.termux-release-automation + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Export GitHub App token for gh + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + run: echo "GH_TOKEN=${APP_TOKEN}" >> "${GITHUB_ENV}" + + - name: Checkout release branch + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.release_branch || github.ref }} + token: ${{ steps.app-token.outputs.token }} + + - name: Checkout automation helpers + uses: actions/checkout@v6 + with: + fetch-depth: 1 + ref: ${{ github.workflow_sha }} + path: .termux-release-automation + token: ${{ steps.app-token.outputs.token }} + + - name: 🧰 Actions Toolbox + uses: wallentx/gh-actions/composite/actions-toolbox@main + + - name: Validate GitHub CLI environment + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-validate-gh-env.sh" + + - name: Configure git + run: | + set -euo pipefail + release_branch="${REQUESTED_RELEASE_BRANCH:-${GITHUB_REF_NAME}}" + bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-configure-git.sh" --origin "${DESTINATION_BRANCH}" "${release_branch}" + + - name: Resolve release ref + id: release-ref + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-resolve-release-ref.sh" + + - name: Read release metadata + id: metadata + env: + TERMUX_RELEASE_ACTION: deploy + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-read-release-metadata.sh" + + - name: Create deployment + if: steps.metadata.outputs.deploy == 'true' + id: deployment + env: + GH_TOKEN: ${{ github.token }} + RELEASE_SHA: ${{ steps.release-ref.outputs.sha }} + TERMUX_TAG: ${{ steps.metadata.outputs.termux_tag }} + run: | + set -euo pipefail + deployment_id="$( + gh api \ + -X POST \ + "repos/${GITHUB_REPOSITORY}/deployments" \ + -f ref="${RELEASE_SHA}" \ + -f environment="termux-release" \ + -F auto_merge=false \ + -F required_contexts[] \ + -f description="Termux release deployment for ${TERMUX_TAG}" \ + --jq '.id' + )" + echo "id=${deployment_id}" >> "$GITHUB_OUTPUT" + + - name: Mark deployment in progress + if: steps.metadata.outputs.deploy == 'true' + env: + GH_TOKEN: ${{ github.token }} + DEPLOYMENT_ID: ${{ steps.deployment.outputs.id }} + run: | + set -euo pipefail + log_url="${GH_WORKFLOW_URL:-${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}}" + gh api \ + -X POST \ + "repos/${GITHUB_REPOSITORY}/deployments/${DEPLOYMENT_ID}/statuses" \ + -f state="in_progress" \ + -f environment="termux-release" \ + -f log_url="${log_url}" \ + -F auto_inactive=false \ + -f description="Promoting Termux release artifact and preparing checkpoint PR" \ + >/dev/null + + - name: Locate merged pull request + if: steps.metadata.outputs.deploy == 'true' && steps.metadata.outputs.asset_exists != 'true' + id: pr + env: + RELEASE_BRANCH: ${{ steps.release-ref.outputs.branch }} + RELEASE_SHA: ${{ steps.release-ref.outputs.sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-find-release-pr.sh" + + - name: Download promoted PR artifact + if: steps.metadata.outputs.deploy == 'true' && steps.metadata.outputs.asset_exists != 'true' + env: + PR_ARTIFACT_NAME: ${{ steps.pr.outputs.artifact_name }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-download-release-artifact.sh" + + - name: Create or update mirrored Termux release + if: steps.metadata.outputs.deploy == 'true' && steps.metadata.outputs.asset_exists != 'true' + env: + UPSTREAM_TAG: ${{ steps.metadata.outputs.upstream_tag }} + UPSTREAM_REPO: ${{ steps.metadata.outputs.upstream_repo }} + UPSTREAM_NAME: ${{ steps.metadata.outputs.upstream_name }} + TERMUX_TAG: ${{ steps.metadata.outputs.termux_tag }} + UPSTREAM_PRERELEASE: ${{ steps.metadata.outputs.upstream_prerelease }} + UPSTREAM_HTML_URL: ${{ steps.metadata.outputs.upstream_html_url }} + RELEASE_TRAIN: ${{ steps.metadata.outputs.release_train }} + RELEASE_EXISTS: ${{ steps.metadata.outputs.release_exists }} + PR_NUMBER: ${{ steps.pr.outputs.number }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + RELEASE_SHA: ${{ steps.release-ref.outputs.sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-create-or-update-mirrored-release.sh" + + - name: Ensure checkpoint PR + if: steps.metadata.outputs.deploy == 'true' + id: checkpoint + env: + SOURCE_BRANCH: ${{ steps.release-ref.outputs.branch }} + SOURCE_SHA: ${{ steps.release-ref.outputs.sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-create-checkpoint-pr.sh" + + - name: Mark deployment success + if: steps.metadata.outputs.deploy == 'true' + env: + GH_TOKEN: ${{ github.token }} + DEPLOYMENT_ID: ${{ steps.deployment.outputs.id }} + TERMUX_TAG: ${{ steps.metadata.outputs.termux_tag }} + run: | + set -euo pipefail + log_url="${GH_WORKFLOW_URL:-${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}}" + gh api \ + -X POST \ + "repos/${GITHUB_REPOSITORY}/deployments/${DEPLOYMENT_ID}/statuses" \ + -f state="success" \ + -f environment="termux-release" \ + -f log_url="${log_url}" \ + -F auto_inactive=false \ + -f description="Termux release deployment completed for ${TERMUX_TAG}" \ + >/dev/null + + - name: Mark deployment failure + if: failure() && steps.deployment.outputs.id != '' + env: + GH_TOKEN: ${{ github.token }} + DEPLOYMENT_ID: ${{ steps.deployment.outputs.id }} + run: | + set -euo pipefail + log_url="${GH_WORKFLOW_URL:-${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}}" + gh api \ + -X POST \ + "repos/${GITHUB_REPOSITORY}/deployments/${DEPLOYMENT_ID}/statuses" \ + -f state="failure" \ + -f environment="termux-release" \ + -f log_url="${log_url}" \ + -F auto_inactive=false \ + -f description="Termux release deployment failed" \ + >/dev/null diff --git a/.github/workflows/termux-release-promote.yml b/.github/workflows/termux-release-promote.yml new file mode 100644 index 000000000000..22c277c21a70 --- /dev/null +++ b/.github/workflows/termux-release-promote.yml @@ -0,0 +1,135 @@ +name: termux-release-promote + +on: + workflow_dispatch: + inputs: + release_branch: + description: "Release branch to promote, for example release/0.122.0" + required: true + type: string + release_sha: + description: "Release branch commit SHA to promote. Defaults to the branch head." + required: false + default: "" + type: string + pr_number: + description: "Merged PR number to promote. Optional; normally discovered automatically." + required: false + default: "" + type: string + pr_head_sha: + description: "Merged PR head SHA. Required only when pr_number is set." + required: false + default: "" + type: string + +permissions: + actions: read + attestations: read + checks: read + contents: read + deployments: read + issues: read + discussions: read + packages: read + pages: read + pull-requests: read + repository-projects: read + statuses: read + +concurrency: + group: termux-release-promote-${{ github.event_name == 'workflow_dispatch' && inputs.release_branch || github.ref_name }} + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + promote: + runs-on: ubuntu-24.04 + permissions: + actions: read + contents: write + pull-requests: read + env: + TERMUX_AUTOMATION_DIR: ${{ github.workspace }}/.termux-release-automation + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Export GitHub App token for gh + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + run: echo "GH_TOKEN=${APP_TOKEN}" >> "${GITHUB_ENV}" + + - name: Checkout release branch + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.release_branch || github.ref }} + token: ${{ steps.app-token.outputs.token }} + + - name: Checkout automation helpers + uses: actions/checkout@v6 + with: + fetch-depth: 1 + ref: ${{ github.workflow_sha }} + path: .termux-release-automation + token: ${{ steps.app-token.outputs.token }} + + - name: 🧰 Actions Toolbox + uses: wallentx/gh-actions/composite/actions-toolbox@main + + - name: Validate GitHub CLI environment + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-validate-gh-env.sh" + + - name: Resolve release ref + id: release-ref + env: + INPUT_RELEASE_BRANCH: ${{ inputs.release_branch }} + INPUT_RELEASE_SHA: ${{ inputs.release_sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-resolve-release-ref.sh" + + - name: Read release metadata + id: metadata + env: + TERMUX_RELEASE_ACTION: promote + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-read-release-metadata.sh" + + - name: Locate merged pull request + if: steps.metadata.outputs.promote == 'true' + id: pr + env: + RELEASE_BRANCH: ${{ steps.release-ref.outputs.branch }} + RELEASE_SHA: ${{ steps.release-ref.outputs.sha }} + INPUT_PR_NUMBER: ${{ inputs.pr_number }} + INPUT_PR_HEAD_SHA: ${{ inputs.pr_head_sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-find-release-pr.sh" + + - name: Download promoted PR artifact + if: steps.metadata.outputs.promote == 'true' + env: + PR_ARTIFACT_NAME: ${{ steps.pr.outputs.artifact_name }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-download-release-artifact.sh" + + - name: Create or update mirrored Termux release + if: steps.metadata.outputs.promote == 'true' + env: + UPSTREAM_TAG: ${{ steps.metadata.outputs.upstream_tag }} + UPSTREAM_REPO: ${{ steps.metadata.outputs.upstream_repo }} + UPSTREAM_NAME: ${{ steps.metadata.outputs.upstream_name }} + TERMUX_TAG: ${{ steps.metadata.outputs.termux_tag }} + UPSTREAM_PRERELEASE: ${{ steps.metadata.outputs.upstream_prerelease }} + UPSTREAM_HTML_URL: ${{ steps.metadata.outputs.upstream_html_url }} + RELEASE_TRAIN: ${{ steps.metadata.outputs.release_train }} + RELEASE_EXISTS: ${{ steps.metadata.outputs.release_exists }} + PR_NUMBER: ${{ steps.pr.outputs.number }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + RELEASE_SHA: ${{ steps.release-ref.outputs.sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-create-or-update-mirrored-release.sh" diff --git a/scripts/termux-configure-git.sh b/scripts/termux-configure-git.sh new file mode 100755 index 000000000000..fae3abcbab48 --- /dev/null +++ b/scripts/termux-configure-git.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash + +# Configure the explicit bot identity used by release automation and fetch any +# refs requested by the calling workflow. + +set -euo pipefail + +git config user.name "github-actions[bot]" +git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + +while (($#)); do + case "$1" in + --origin) + shift + origin_refs=() + while (($#)) && [[ "$1" != --* ]]; do + origin_refs+=("$1") + shift + done + if ((${#origin_refs[@]})); then + git fetch --prune origin "${origin_refs[@]}" + fi + ;; + --upstream-tag) + if (($# < 3)); then + echo "--upstream-tag requires ." >&2 + exit 1 + fi + upstream_repo="$2" + upstream_tag="$3" + git remote add upstream "https://github.com/${upstream_repo}.git" 2>/dev/null || true + git fetch --prune --no-tags upstream "+refs/tags/${upstream_tag}:refs/tags/${upstream_tag}" + shift 3 + ;; + *) + echo "Unknown argument: $1" >&2 + exit 1 + ;; + esac +done diff --git a/scripts/termux-create-checkpoint-pr.sh b/scripts/termux-create-checkpoint-pr.sh new file mode 100755 index 000000000000..ba07c289952d --- /dev/null +++ b/scripts/termux-create-checkpoint-pr.sh @@ -0,0 +1,275 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/termux-release-paths.sh +source "${script_dir}/termux-release-paths.sh" + +source_branch="${SOURCE_BRANCH:-${REQUESTED_SOURCE_BRANCH:-${GITHUB_REF_NAME}}}" +source_sha="${SOURCE_SHA:-${REQUESTED_SOURCE_SHA:-}}" +if [[ -z "${source_sha}" ]]; then + if [[ "${GITHUB_EVENT_NAME:-}" == "push" && "${source_branch}" == "${GITHUB_REF_NAME:-}" ]]; then + source_sha="${GITHUB_SHA}" + else + source_sha="$(git rev-parse "origin/${source_branch}")" + fi +fi + +if [[ -z "${DESTINATION_BRANCH:-}" ]]; then + echo "DESTINATION_BRANCH is required." >&2 + exit 1 +fi + +release_only_checkpoint_paths() { + printf '%s\n' "${TERMUX_RELEASE_BRANCH_SCRIPT_PATHS[@]}" +} + +resolve_source_version_conflicts() { + local path="$1" + local resolved_path + resolved_path="$(mktemp)" + + if ! awk ' + function normalize_versions(text) { + gsub(/version = "[^"]+"/, "version = \"\"", text) + return text + } + + BEGIN { + in_block = 0 + side = "" + ours = "" + theirs = "" + blocks = 0 + } + + /^<<<<<<< / { + if (in_block) { + exit 1 + } + in_block = 1 + side = "ours" + ours = "" + theirs = "" + blocks++ + next + } + + /^=======$/ && in_block { + side = "theirs" + next + } + + /^>>>>>>> / && in_block { + if (normalize_versions(ours) != normalize_versions(theirs)) { + exit 1 + } + printf "%s", theirs + in_block = 0 + side = "" + next + } + + { + if (!in_block) { + print + } else if (side == "ours") { + ours = ours $0 ORS + } else if (side == "theirs") { + theirs = theirs $0 ORS + } else { + exit 1 + } + } + + END { + if (in_block || blocks == 0) { + exit 1 + } + } + ' "${path}" > "${resolved_path}"; then + rm -f "${resolved_path}" + return 1 + fi + + cp "${resolved_path}" "${path}" + rm -f "${resolved_path}" +} + +short_sha="${source_sha:0:12}" +source_slug="${source_branch//\//_}" +dest_slug="${DESTINATION_BRANCH//\//_}" +checkpoint_branch="checkpoint/${dest_slug}_from_${source_slug}_${short_sha}" +pr_title="checkpoint: into ${DESTINATION_BRANCH} from ${source_branch} @ ${short_sha}" +merge_conflicted=false +conflict_summary="" + +existing_pr="$( + gh pr list \ + --repo "${GITHUB_REPOSITORY}" \ + --head "${checkpoint_branch}" \ + --state all \ + --json number,state,mergedAt,url \ + --jq '[.[] | select(.state == "OPEN" or .mergedAt != null)] | .[0] // empty' +)" +if [[ -n "${existing_pr}" ]]; then + existing_url="$(jq -r '.url' <<< "${existing_pr}")" + existing_state="$(jq -r '.state' <<< "${existing_pr}")" + echo "Checkpoint PR already exists for ${checkpoint_branch}: ${existing_url} (${existing_state})." + if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + echo "pr_url=${existing_url}" >> "${GITHUB_OUTPUT}" + fi + exit 0 +fi + +git checkout -B "${checkpoint_branch}" "origin/${DESTINATION_BRANCH}" + +if ! git merge --no-ff --no-edit "${source_sha}"; then + mapfile -t conflicted_paths < <(git diff --name-only --diff-filter=U) + for conflicted_path in "${conflicted_paths[@]}"; do + if termux_is_checkpoint_release_only_path "${conflicted_path}"; then + echo "Auto-resolving release-only checkpoint conflict in ${conflicted_path} by keeping ${DESTINATION_BRANCH}." + if git cat-file -e "HEAD:${conflicted_path}" 2>/dev/null; then + git checkout --ours -- "${conflicted_path}" + git add "${conflicted_path}" + else + git rm -f --ignore-unmatch "${conflicted_path}" + fi + fi + done + + mapfile -t remaining_conflicts < <(git diff --name-only --diff-filter=U) + if ((${#remaining_conflicts[@]})); then + cargo_version_conflicts=true + for remaining_conflict in "${remaining_conflicts[@]}"; do + case "${remaining_conflict}" in + codex-rs/Cargo.toml|codex-rs/Cargo.lock) + ;; + *) + cargo_version_conflicts=false + ;; + esac + done + + if [[ "${cargo_version_conflicts}" == "true" ]]; then + for remaining_conflict in "${remaining_conflicts[@]}"; do + if ! resolve_source_version_conflicts "${remaining_conflict}"; then + cargo_version_conflicts=false + break + fi + done + + if [[ "${cargo_version_conflicts}" == "true" ]]; then + echo "Auto-resolving recurring Cargo version checkpoint conflicts by keeping ${source_branch} versions." + git add -- "${remaining_conflicts[@]}" + fi + fi + fi + + mapfile -t remaining_conflicts < <(git diff --name-only --diff-filter=U) + if [[ "${#remaining_conflicts[@]}" -eq 0 ]]; then + git commit --no-edit + else + merge_conflicted=true + conflict_summary="$( + printf '%s\n' "${remaining_conflicts[@]}" | awk '{ print "- `" $0 "`" }' + )" + echo "Automatic checkpoint merge failed; creating a manual-resolution PR instead." >&2 + if git rev-parse -q --verify MERGE_HEAD >/dev/null; then + git merge --abort + fi + git checkout -B "${checkpoint_branch}" "${source_sha}" + fi +fi + +if git cat-file -e "origin/${DESTINATION_BRANCH}:.github" 2>/dev/null; then + git restore --source="origin/${DESTINATION_BRANCH}" --staged --worktree -- .github + mapfile -t added_github_paths < <( + git diff --name-only --diff-filter=A "origin/${DESTINATION_BRANCH}" -- .github + ) + if ((${#added_github_paths[@]})); then + git rm -f --ignore-unmatch -- "${added_github_paths[@]}" + fi +else + git rm -r --ignore-unmatch .github +fi + +while IFS= read -r release_only_path; do + if git cat-file -e "origin/${DESTINATION_BRANCH}:${release_only_path}" 2>/dev/null; then + git restore --source="origin/${DESTINATION_BRANCH}" --staged --worktree -- "${release_only_path}" + else + git rm -f --ignore-unmatch -- "${release_only_path}" + fi +done < <(release_only_checkpoint_paths) + +if ! git diff --quiet || ! git diff --cached --quiet; then + if [[ -e .github || -L .github ]] || git ls-files --error-unmatch -- .github >/dev/null 2>&1; then + git add -A .github + fi + while IFS= read -r release_only_path; do + if [[ -e "${release_only_path}" || -L "${release_only_path}" ]] || git ls-files --error-unmatch -- "${release_only_path}" >/dev/null 2>&1; then + git add -A -- "${release_only_path}" + fi + done < <(release_only_checkpoint_paths) + if [[ "${merge_conflicted}" == "true" ]]; then + git commit -m "checkpoint: prepare ${source_branch} for ${DESTINATION_BRANCH}" + else + git commit --amend --no-edit + fi +fi + +if git diff --quiet "origin/${DESTINATION_BRANCH}" HEAD; then + echo "Checkpoint merge produced no destination changes after release-only files were restored." + exit 0 +fi + +git push --force-with-lease origin "${checkpoint_branch}" + +remaining="$( + git log --first-parent --pretty=format:%H "${source_sha}..origin/${source_branch}" | wc -w +)" + +body_path="${RUNNER_TEMP}/termux-checkpoint-pr.md" +{ + echo "## Termux release checkpoint" + echo + echo "- Source branch: \`${source_branch}\`" + echo "- Source hash: \`${source_sha}\`" + echo "- Destination branch: \`${DESTINATION_BRANCH}\`" + echo "- Remaining first-parent commits on source: ${remaining}" + echo + echo "This PR carries release-train conflict fixes and follow-up changes back into the reusable Termux patch branch." + if [[ "${merge_conflicted}" == "true" ]]; then + echo + echo "## Merge conflicts" + echo + echo "GitHub Actions could not create the checkpoint merge commit automatically, so this PR was created from the source branch state for manual conflict resolution." + echo + echo "Conflicted paths from the failed merge attempt:" + if [[ -n "${conflict_summary}" ]]; then + printf '%s\n' "${conflict_summary}" + else + echo "- Conflict details unavailable" + fi + fi + echo + echo "Release-only workflow files and metadata under \`.github\` were restored to the destination branch versions before opening this PR." +} > "${body_path}" + +pr_url="$( + gh pr create \ + --repo "${GITHUB_REPOSITORY}" \ + --base "${DESTINATION_BRANCH}" \ + --head "${checkpoint_branch}" \ + --title "${pr_title}" \ + --body-file "${body_path}" +)" +gh pr edit "${pr_url}" --repo "${GITHUB_REPOSITORY}" --add-reviewer "${REVIEWER}" || true +gh label create checkpoint --repo "${GITHUB_REPOSITORY}" --color c5def5 --description "Checkpoint merge" --force +gh label create termux-release --repo "${GITHUB_REPOSITORY}" --color 0e8a16 --description "Termux release automation" --force +gh pr edit "${pr_url}" --repo "${GITHUB_REPOSITORY}" --add-label "checkpoint" --add-label "termux-release" + +if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + echo "pr_url=${pr_url}" >> "${GITHUB_OUTPUT}" +fi diff --git a/scripts/termux-create-or-update-mirrored-release.sh b/scripts/termux-create-or-update-mirrored-release.sh new file mode 100755 index 000000000000..5787e894582b --- /dev/null +++ b/scripts/termux-create-or-update-mirrored-release.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash + +# Create a mirrored Termux release or repair an existing release that is missing +# the Android tarball. + +set -euo pipefail + +: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" +: "${GH_TOKEN:?GH_TOKEN is required}" +: "${RUNNER_TEMP:?RUNNER_TEMP is required}" +: "${UPSTREAM_TAG:?UPSTREAM_TAG is required}" +: "${UPSTREAM_REPO:?UPSTREAM_REPO is required}" +: "${UPSTREAM_NAME?UPSTREAM_NAME is required}" +: "${TERMUX_TAG:?TERMUX_TAG is required}" +: "${UPSTREAM_PRERELEASE:?UPSTREAM_PRERELEASE is required}" +: "${UPSTREAM_HTML_URL?UPSTREAM_HTML_URL is required}" +: "${RELEASE_TRAIN?RELEASE_TRAIN is required}" +: "${RELEASE_EXISTS:?RELEASE_EXISTS is required}" +: "${PR_NUMBER:?PR_NUMBER is required}" +: "${HEAD_SHA:?HEAD_SHA is required}" +: "${RELEASE_SHA:?RELEASE_SHA is required}" + +promoted_dir="${PROMOTED_DIR:-promoted}" +asset_path="${promoted_dir}/codex-aarch64-linux-android.tar.gz" + +body_path="${RUNNER_TEMP}/release-body.md" +upstream_body_path="${RUNNER_TEMP}/upstream-release-body.md" +upstream_body_without_changelog_path="${RUNNER_TEMP}/upstream-release-body-without-changelog.md" +if gh release view "${UPSTREAM_TAG}" \ + --repo "${UPSTREAM_REPO}" \ + --json body \ + --jq '.body // ""' > "${upstream_body_path}"; then + awk ' + function heading_level(line, text) { + if (match(line, /^(#{1,6})[[:space:]]+(.+)$/, parts)) { + text = tolower(parts[2]) + sub(/[[:space:]]+#+[[:space:]]*$/, "", text) + gsub(/^[[:space:]]+|[[:space:]]+$/, "", text) + if (text == "changelog" || text == "change log") { + return length(parts[1]) + } + } + return 0 + } + + { + if (!skip) { + level = heading_level($0) + if (level > 0) { + skip = 1 + skip_level = level + next + } + print + next + } + + if (match($0, /^(#{1,6})[[:space:]]+/, parts) && length(parts[1]) <= skip_level) { + skip = 0 + print + } + } + ' "${upstream_body_path}" > "${upstream_body_without_changelog_path}" +else + echo "::warning title=Upstream release notes unavailable::Could not read ${UPSTREAM_REPO} release ${UPSTREAM_TAG}." + : > "${upstream_body_without_changelog_path}" +fi + +{ + echo "Termux Android build for ${UPSTREAM_TAG}." + echo + echo "- Upstream release: ${UPSTREAM_HTML_URL}" + echo "- Release train: \`${RELEASE_TRAIN}\`" + echo "- Promoted PR: #${PR_NUMBER}" + echo "- Promoted PR head SHA: \`${HEAD_SHA}\`" +} > "${body_path}" +if [[ -s "${upstream_body_without_changelog_path}" ]]; then + { + echo + echo "## Upstream release notes" + echo + cat "${upstream_body_without_changelog_path}" + } >> "${body_path}" +fi + +release_title="${UPSTREAM_NAME}" +if [[ -z "${release_title}" || "${release_title}" == "null" ]]; then + release_title="${TERMUX_TAG}" +fi + +if [[ "${RELEASE_EXISTS}" == "true" ]]; then + gh release upload \ + "${TERMUX_TAG}" \ + "${asset_path}#codex-termux" \ + --repo "${GITHUB_REPOSITORY}" \ + --clobber + exit 0 +fi + +release_args=( + gh release create "${TERMUX_TAG}" + --repo "${GITHUB_REPOSITORY}" + --target "${RELEASE_SHA}" + --title "${release_title}" + --notes-file "${body_path}" +) +if [[ "${UPSTREAM_PRERELEASE}" == "true" ]]; then + release_args+=(--prerelease) +fi +release_args+=("${asset_path}#codex-termux") +"${release_args[@]}" diff --git a/scripts/termux-download-release-artifact.sh b/scripts/termux-download-release-artifact.sh new file mode 100755 index 000000000000..2acdba9025a0 --- /dev/null +++ b/scripts/termux-download-release-artifact.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ -z "${HEAD_SHA:-}" ]]; then + echo "HEAD_SHA is required." >&2 + exit 1 +fi + +if [[ -z "${PR_ARTIFACT_NAME:-}" ]]; then + echo "PR_ARTIFACT_NAME is required." >&2 + exit 1 +fi + +promoted_dir="${PROMOTED_DIR:-promoted}" + +find_run_id() { + local event="$1" + gh run list \ + --repo "${GITHUB_REPOSITORY}" \ + --workflow rust-release.yml \ + --event "${event}" \ + --status success \ + --commit "${HEAD_SHA}" \ + --limit 1 \ + --json databaseId \ + --jq '.[0].databaseId // empty' +} + +find_artifact_run_id() { + local artifact_name="$1" + + gh api --paginate "repos/${GITHUB_REPOSITORY}/actions/artifacts?name=${artifact_name}" \ + | jq -rs \ + --arg artifact_name "${artifact_name}" \ + --arg head_sha "${HEAD_SHA}" \ + ' + [ + .[].artifacts[] + | select(.name == $artifact_name) + | select(.expired == false) + | select(.workflow_run.head_sha == $head_sha) + ] + | sort_by(.created_at) + | reverse + | .[0].workflow_run.id // empty + ' +} + +artifact_name="${PR_ARTIFACT_NAME}" +run_id="$(find_artifact_run_id "${artifact_name}")" +mkdir -p "${promoted_dir}" +if [[ -n "${run_id}" ]] && gh run download "${run_id}" \ + --repo "${GITHUB_REPOSITORY}" \ + --name "${artifact_name}" \ + --dir "${promoted_dir}"; then + : +else + artifact_name="aarch64-linux-android" + run_id="$(find_run_id workflow_dispatch)" + if [[ -z "${run_id}" ]]; then + echo "No successful rust-release run found for ${HEAD_SHA}" >&2 + exit 1 + fi + gh run download "${run_id}" \ + --repo "${GITHUB_REPOSITORY}" \ + --name "${artifact_name}" \ + --dir "${promoted_dir}" +fi + +ls -la "${promoted_dir}" +if [[ -f "${promoted_dir}/SHA256SUMS" ]]; then + (cd "${promoted_dir}" && sha256sum -c SHA256SUMS) +fi +if [[ ! -f "${promoted_dir}/codex-aarch64-linux-android.tar.gz" ]]; then + echo "Expected ${promoted_dir}/codex-aarch64-linux-android.tar.gz in the downloaded artifact." >&2 + exit 1 +fi diff --git a/scripts/termux-find-release-pr.sh b/scripts/termux-find-release-pr.sh new file mode 100755 index 000000000000..3e35fa42eabe --- /dev/null +++ b/scripts/termux-find-release-pr.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ -n "${INPUT_PR_NUMBER:-}" || -n "${INPUT_PR_HEAD_SHA:-}" ]]; then + if [[ -z "${INPUT_PR_NUMBER:-}" || -z "${INPUT_PR_HEAD_SHA:-}" ]]; then + echo "workflow_dispatch inputs pr_number and pr_head_sha must be provided together." >&2 + exit 1 + fi + + pr_number="${INPUT_PR_NUMBER}" + head_sha="${INPUT_PR_HEAD_SHA}" + head_ref="" +else + if [[ -z "${RELEASE_BRANCH:-}" || -z "${RELEASE_SHA:-}" ]]; then + echo "RELEASE_BRANCH and RELEASE_SHA are required." >&2 + exit 1 + fi + + pr_json="$( + gh pr list \ + --repo "${GITHUB_REPOSITORY}" \ + --base "${RELEASE_BRANCH}" \ + --state merged \ + --limit 100 \ + --json number,headRefOid,headRefName,mergeCommit \ + --jq "[.[] | select(.mergeCommit.oid == \"${RELEASE_SHA}\")] | sort_by(.number) | reverse | .[0] // empty" + )" + if [[ -z "${pr_json}" ]]; then + echo "Unable to find merged PR for ${RELEASE_SHA} into ${RELEASE_BRANCH}" >&2 + exit 1 + fi + + pr_number="$(jq -r '.number' <<< "${pr_json}")" + head_sha="$(jq -r '.headRefOid // .head.sha' <<< "${pr_json}")" + head_ref="$(jq -r '.headRefName // .head.ref' <<< "${pr_json}")" +fi + +artifact_name="termux-android-pr-${pr_number}-${head_sha}" +if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + { + echo "number=${pr_number}" + echo "head_sha=${head_sha}" + echo "head_ref=${head_ref}" + echo "artifact_name=${artifact_name}" + } >> "${GITHUB_OUTPUT}" +else + printf 'number=%s\nhead_sha=%s\nhead_ref=%s\nartifact_name=%s\n' \ + "${pr_number}" \ + "${head_sha}" \ + "${head_ref}" \ + "${artifact_name}" +fi diff --git a/scripts/termux-read-release-metadata.sh b/scripts/termux-read-release-metadata.sh new file mode 100755 index 000000000000..519a0635a368 --- /dev/null +++ b/scripts/termux-read-release-metadata.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash + +# Parse .github/termux-release.json and emit workflow outputs for deploy or +# promote jobs. TERMUX_RELEASE_ACTION must be "deploy" or "promote". + +set -euo pipefail + +: "${GITHUB_OUTPUT:?GITHUB_OUTPUT is required}" +: "${GH_TOKEN:?GH_TOKEN is required}" + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + +metadata=".github/termux-release.json" +action="${TERMUX_RELEASE_ACTION:-deploy}" +case "${action}" in + deploy) + missing_context="deployment" + ;; + promote) + missing_context="promotion" + ;; + *) + echo "TERMUX_RELEASE_ACTION must be deploy or promote." >&2 + exit 1 + ;; +esac + +if [[ ! -f "${metadata}" ]]; then + echo "No ${metadata}; this push is not a Termux release ${missing_context}." + echo "${action}=false" >> "${GITHUB_OUTPUT}" + exit 0 +fi + +upstream_tag="$(jq -r '.upstream_tag // empty' "${metadata}")" +upstream_name="$(jq -r '.upstream_name // .upstream_tag // empty' "${metadata}")" +termux_tag="$(jq -r '.termux_tag // empty' "${metadata}")" +upstream_version="${upstream_tag#rust-v}" +upstream_version="${upstream_version%-termux}" +upstream_prerelease=false +if [[ "${upstream_version}" == *-* ]]; then + upstream_prerelease=true +fi +upstream_html_url="$(jq -r '.upstream_html_url // ""' "${metadata}")" +upstream_repo="$(jq -r '.upstream_repo // "openai/codex"' "${metadata}")" +release_train="$(jq -r '.release_train // ""' "${metadata}")" +if [[ -z "${upstream_tag}" || -z "${termux_tag}" ]]; then + echo "Missing upstream_tag or termux_tag in ${metadata}" >&2 + exit 1 +fi + +release_state="$(TERMUX_TAG="${termux_tag}" "${script_dir}/termux-release-asset-state.sh")" +release_exists="$(awk -F= '$1 == "release_exists" { print $2 }' <<< "${release_state}")" +asset_exists="$(awk -F= '$1 == "asset_exists" { print $2 }' <<< "${release_state}")" + +if [[ "${action}" == "promote" ]]; then + if [[ "${asset_exists}" == "true" ]]; then + echo "${termux_tag} already exists with codex-aarch64-linux-android.tar.gz; skipping promotion." + echo "promote=false" >> "${GITHUB_OUTPUT}" + exit 0 + fi + if [[ "${release_exists}" == "true" ]]; then + echo "${termux_tag} exists but is missing codex-aarch64-linux-android.tar.gz; repairing promotion." + fi +fi + +{ + echo "${action}=true" + echo "upstream_tag=${upstream_tag}" + echo "upstream_name=${upstream_name}" + echo "termux_tag=${termux_tag}" + echo "upstream_prerelease=${upstream_prerelease}" + echo "upstream_html_url=${upstream_html_url}" + echo "upstream_repo=${upstream_repo}" + echo "release_train=${release_train}" + echo "release_exists=${release_exists}" + echo "asset_exists=${asset_exists}" +} >> "${GITHUB_OUTPUT}" diff --git a/scripts/termux-release-asset-state.sh b/scripts/termux-release-asset-state.sh new file mode 100755 index 000000000000..8694480b6a36 --- /dev/null +++ b/scripts/termux-release-asset-state.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash + +set -euo pipefail + +termux_tag="${1:-${TERMUX_TAG:-}}" +release_repo="${RELEASE_REPO:-${GITHUB_REPOSITORY:-}}" +asset_name="${ASSET_NAME:-codex-aarch64-linux-android.tar.gz}" + +if [[ -z "${termux_tag}" ]]; then + echo "TERMUX_TAG or tag argument is required." >&2 + exit 1 +fi + +if [[ -z "${release_repo}" ]]; then + echo "GITHUB_REPOSITORY or RELEASE_REPO is required." >&2 + exit 1 +fi + +release_exists=false +asset_exists=false + +if gh release view "${termux_tag}" --repo "${release_repo}" >/dev/null 2>&1; then + release_exists=true + release_asset_exists="$( + gh release view "${termux_tag}" \ + --repo "${release_repo}" \ + --json assets \ + | jq -r --arg asset_name "${asset_name}" \ + '.assets | map(.name) | any(. == $asset_name)' + )" + if [[ "${release_asset_exists}" == "true" ]]; then + asset_exists=true + fi +fi + +printf 'release_exists=%s\n' "${release_exists}" +printf 'asset_exists=%s\n' "${asset_exists}" diff --git a/scripts/termux-release-paths.sh b/scripts/termux-release-paths.sh new file mode 100755 index 000000000000..6990f323ec44 --- /dev/null +++ b/scripts/termux-release-paths.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash + +# Sourceable path lists for files owned by the Termux release automation. + +set -euo pipefail + +readonly -a TERMUX_RELEASE_WORKFLOW_PATHS=( + .github/workflows/rust-release.yml + .github/workflows/shell-tool-mcp.yml + .github/workflows/termux-release-checkpoint.yml + .github/workflows/termux-release-deploy.yml + .github/workflows/termux-release-promote.yml +) + +readonly -a TERMUX_RELEASE_BRANCH_SCRIPT_PATHS=( + scripts/termux-configure-git.sh + scripts/termux-create-checkpoint-pr.sh + scripts/termux-create-or-update-mirrored-release.sh + scripts/termux-download-release-artifact.sh + scripts/termux-find-release-pr.sh + scripts/termux-read-release-metadata.sh + scripts/termux-release-asset-state.sh + scripts/termux-release-paths.sh + scripts/termux-resolve-release-ref.sh + scripts/termux-validate-gh-env.sh +) + +readonly -a TERMUX_RELEASE_AUTOMATION_PATHS=( + "${TERMUX_RELEASE_WORKFLOW_PATHS[@]}" + "${TERMUX_RELEASE_BRANCH_SCRIPT_PATHS[@]}" +) + +readonly -a TERMUX_CHECKPOINT_RELEASE_ONLY_PATHS=( + "${TERMUX_RELEASE_AUTOMATION_PATHS[@]}" + .github/termux-release.json +) + +termux_path_in_list() { + local candidate="$1" + shift + local listed_path + + for listed_path in "$@"; do + [[ "${candidate}" != "${listed_path}" ]] || return 0 + done + return 1 +} + +termux_is_release_automation_path() { + termux_path_in_list "$1" "${TERMUX_RELEASE_AUTOMATION_PATHS[@]}" +} + +termux_is_checkpoint_release_only_path() { + termux_path_in_list "$1" "${TERMUX_CHECKPOINT_RELEASE_ONLY_PATHS[@]}" +} diff --git a/scripts/termux-resolve-release-ref.sh b/scripts/termux-resolve-release-ref.sh new file mode 100755 index 000000000000..194773d45bed --- /dev/null +++ b/scripts/termux-resolve-release-ref.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash + +# Resolve the release branch and SHA for deploy/promote workflows. + +set -euo pipefail + +: "${GITHUB_EVENT_NAME:?GITHUB_EVENT_NAME is required}" +: "${GITHUB_OUTPUT:?GITHUB_OUTPUT is required}" + +input_release_branch="${INPUT_RELEASE_BRANCH:-${REQUESTED_RELEASE_BRANCH:-}}" +input_release_sha="${INPUT_RELEASE_SHA:-${REQUESTED_RELEASE_SHA:-}}" + +if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then + if [[ -z "${input_release_branch}" ]]; then + echo "release_branch is required for workflow_dispatch." >&2 + exit 1 + fi + + release_branch="${input_release_branch}" + if [[ -n "${input_release_sha}" ]]; then + git checkout --detach "${input_release_sha}" + release_sha="${input_release_sha}" + else + release_sha="$(git rev-parse HEAD)" + fi +else + : "${GITHUB_REF_NAME:?GITHUB_REF_NAME is required}" + : "${GITHUB_SHA:?GITHUB_SHA is required}" + release_branch="${GITHUB_REF_NAME}" + release_sha="${GITHUB_SHA}" +fi + +{ + echo "branch=${release_branch}" + echo "sha=${release_sha}" +} >> "${GITHUB_OUTPUT}" diff --git a/scripts/termux-validate-gh-env.sh b/scripts/termux-validate-gh-env.sh new file mode 100755 index 000000000000..02581fdebceb --- /dev/null +++ b/scripts/termux-validate-gh-env.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash + +# Lightweight post-toolbox check for jobs that rely on authenticated gh calls. + +set -euo pipefail + +: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" +: "${GH_TOKEN:?GH_TOKEN is required}" + +command -v gh +gh auth status --hostname github.com + +printf 'GITHUB_REPOSITORY=%s\n' "${GITHUB_REPOSITORY}" +printf 'REPO=%s\n' "${REPO:-}" +printf 'GH_REPO_URL=%s\n' "${GH_REPO_URL:-}" +printf 'GH_WORKFLOW_URL=%s\n' "${GH_WORKFLOW_URL:-}" From 820e9df09476c5d357fabcd7ee3e050e1856b3cb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 16:38:39 +0000 Subject: [PATCH 10/64] Prepare Termux rust-v0.133.0-alpha.1 --- .github/termux-release.json | 15 ++ codex-rs/Cargo.lock | 247 ++++++++++++++------------- codex-rs/Cargo.toml | 2 + codex-rs/arg0/Cargo.toml | 1 + codex-rs/arg0/src/lib.rs | 29 +++- codex-rs/core/Cargo.toml | 5 + codex-rs/core/src/installation_id.rs | 8 +- codex-rs/execpolicy/Cargo.toml | 1 + codex-rs/execpolicy/src/amend.rs | 23 ++- codex-rs/tui/Cargo.toml | 2 +- codex-rs/tui/src/lib.rs | 8 +- codex-rs/utils/file-lock/BUILD.bazel | 6 + codex-rs/utils/file-lock/Cargo.toml | 10 ++ codex-rs/utils/file-lock/src/lib.rs | 168 ++++++++++++++++++ justfile | 5 + scripts/termux-lock-audit.sh | 126 ++++++++++++++ 16 files changed, 518 insertions(+), 138 deletions(-) create mode 100644 .github/termux-release.json create mode 100644 codex-rs/utils/file-lock/BUILD.bazel create mode 100644 codex-rs/utils/file-lock/Cargo.toml create mode 100644 codex-rs/utils/file-lock/src/lib.rs create mode 100755 scripts/termux-lock-audit.sh diff --git a/.github/termux-release.json b/.github/termux-release.json new file mode 100644 index 000000000000..3cf1ce21c8eb --- /dev/null +++ b/.github/termux-release.json @@ -0,0 +1,15 @@ +{ + "upstream_repo": "openai/codex", + "upstream_tag": "rust-v0.133.0-alpha.1", + "upstream_name": "0.133.0-alpha.1", + "upstream_html_url": "https://github.com/openai/codex/releases/tag/rust-v0.133.0-alpha.1", + "upstream_target": "main", + "upstream_release_id": "326079618", + "upstream_prerelease": true, + "release_train": "0.133.0", + "release_branch": "release/0.133.0", + "work_branch": "upstream/rust-v0.133.0", + "patch_branch": "wallentx/termux-target", + "patch_source_sha": "b32aadb5610138201c219029a65486f448139b53", + "termux_tag": "rust-v0.133.0-alpha.1-termux" +} diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 1950939e652e..54e1f7a1a078 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -402,7 +402,7 @@ checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" [[package]] name = "app_test_support" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "base64 0.22.1", @@ -1778,7 +1778,7 @@ dependencies = [ [[package]] name = "codex-agent-graph-store" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "codex-protocol", @@ -1793,7 +1793,7 @@ dependencies = [ [[package]] name = "codex-agent-identity" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "base64 0.22.1", @@ -1812,7 +1812,7 @@ dependencies = [ [[package]] name = "codex-analytics" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-app-server-protocol", "codex-git-utils", @@ -1832,7 +1832,7 @@ dependencies = [ [[package]] name = "codex-ansi-escape" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "ansi-to-tui", "ratatui", @@ -1841,7 +1841,7 @@ dependencies = [ [[package]] name = "codex-api" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "assert_matches", @@ -1875,7 +1875,7 @@ dependencies = [ [[package]] name = "codex-app-server" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "app_test_support", @@ -1960,7 +1960,7 @@ dependencies = [ [[package]] name = "codex-app-server-client" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-app-server", "codex-app-server-protocol", @@ -1987,7 +1987,7 @@ dependencies = [ [[package]] name = "codex-app-server-daemon" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "codex-app-server-protocol", @@ -2008,7 +2008,7 @@ dependencies = [ [[package]] name = "codex-app-server-protocol" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "clap", @@ -2035,7 +2035,7 @@ dependencies = [ [[package]] name = "codex-app-server-test-client" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "clap", @@ -2056,7 +2056,7 @@ dependencies = [ [[package]] name = "codex-app-server-transport" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "axum", @@ -2095,7 +2095,7 @@ dependencies = [ [[package]] name = "codex-apply-patch" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "assert_cmd", @@ -2114,7 +2114,7 @@ dependencies = [ [[package]] name = "codex-arg0" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "codex-apply-patch", @@ -2123,6 +2123,7 @@ dependencies = [ "codex-sandboxing", "codex-shell-escalation", "codex-utils-absolute-path", + "codex-utils-file-lock", "codex-utils-home-dir", "dotenvy", "tempfile", @@ -2131,7 +2132,7 @@ dependencies = [ [[package]] name = "codex-async-utils" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "pretty_assertions", @@ -2141,7 +2142,7 @@ dependencies = [ [[package]] name = "codex-aws-auth" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "aws-config", "aws-credential-types", @@ -2156,7 +2157,7 @@ dependencies = [ [[package]] name = "codex-backend-client" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "codex-api", @@ -2173,7 +2174,7 @@ dependencies = [ [[package]] name = "codex-backend-openapi-models" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "serde", "serde_json", @@ -2182,7 +2183,7 @@ dependencies = [ [[package]] name = "codex-bwrap" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "cc", "libc", @@ -2191,7 +2192,7 @@ dependencies = [ [[package]] name = "codex-chatgpt" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "clap", @@ -2214,7 +2215,7 @@ dependencies = [ [[package]] name = "codex-cli" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "assert_cmd", @@ -2279,7 +2280,7 @@ dependencies = [ [[package]] name = "codex-client" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "bytes", @@ -2310,7 +2311,7 @@ dependencies = [ [[package]] name = "codex-cloud-requirements" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "base64 0.22.1", @@ -2335,7 +2336,7 @@ dependencies = [ [[package]] name = "codex-cloud-tasks" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "async-trait", @@ -2367,7 +2368,7 @@ dependencies = [ [[package]] name = "codex-cloud-tasks-client" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "async-trait", @@ -2382,7 +2383,7 @@ dependencies = [ [[package]] name = "codex-cloud-tasks-mock-client" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "chrono", @@ -2392,7 +2393,7 @@ dependencies = [ [[package]] name = "codex-code-mode" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-channel", "async-trait", @@ -2409,11 +2410,11 @@ dependencies = [ [[package]] name = "codex-collaboration-mode-templates" -version = "0.0.0" +version = "0.132.0-alpha.1" [[package]] name = "codex-config" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "async-trait", @@ -2460,7 +2461,7 @@ dependencies = [ [[package]] name = "codex-connectors" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "codex-app-server-protocol", @@ -2476,7 +2477,7 @@ dependencies = [ [[package]] name = "codex-core" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "arc-swap", @@ -2530,6 +2531,7 @@ dependencies = [ "codex-utils-absolute-path", "codex-utils-cache", "codex-utils-cargo-bin", + "codex-utils-file-lock", "codex-utils-home-dir", "codex-utils-image", "codex-utils-output-truncation", @@ -2594,7 +2596,7 @@ dependencies = [ [[package]] name = "codex-core-api" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-analytics", "codex-app-server-protocol", @@ -2613,7 +2615,7 @@ dependencies = [ [[package]] name = "codex-core-plugins" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "chrono", @@ -2652,7 +2654,7 @@ dependencies = [ [[package]] name = "codex-core-skills" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "codex-analytics", @@ -2683,7 +2685,7 @@ dependencies = [ [[package]] name = "codex-debug-client" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "clap", @@ -2695,7 +2697,7 @@ dependencies = [ [[package]] name = "codex-exec" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "assert_cmd", @@ -2741,7 +2743,7 @@ dependencies = [ [[package]] name = "codex-exec-server" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "arc-swap", @@ -2782,11 +2784,12 @@ dependencies = [ [[package]] name = "codex-execpolicy" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "clap", "codex-utils-absolute-path", + "codex-utils-file-lock", "multimap", "pretty_assertions", "serde", @@ -2799,7 +2802,7 @@ dependencies = [ [[package]] name = "codex-execpolicy-legacy" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "allocative", "anyhow", @@ -2819,7 +2822,7 @@ dependencies = [ [[package]] name = "codex-experimental-api-macros" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "proc-macro2", "quote", @@ -2828,7 +2831,7 @@ dependencies = [ [[package]] name = "codex-extension-api" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "codex-protocol", @@ -2837,7 +2840,7 @@ dependencies = [ [[package]] name = "codex-external-agent-migration" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-hooks", "pretty_assertions", @@ -2849,7 +2852,7 @@ dependencies = [ [[package]] name = "codex-external-agent-sessions" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "chrono", "codex-app-server-protocol", @@ -2863,7 +2866,7 @@ dependencies = [ [[package]] name = "codex-features" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-otel", "codex-protocol", @@ -2876,7 +2879,7 @@ dependencies = [ [[package]] name = "codex-feedback" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "codex-login", @@ -2889,7 +2892,7 @@ dependencies = [ [[package]] name = "codex-file-search" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "clap", @@ -2905,7 +2908,7 @@ dependencies = [ [[package]] name = "codex-file-system" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "codex-protocol", @@ -2915,7 +2918,7 @@ dependencies = [ [[package]] name = "codex-file-watcher" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "notify", "pretty_assertions", @@ -2926,7 +2929,7 @@ dependencies = [ [[package]] name = "codex-git-utils" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "chrono", @@ -2950,7 +2953,7 @@ dependencies = [ [[package]] name = "codex-goal-extension" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "codex-extension-api", @@ -2962,7 +2965,7 @@ dependencies = [ [[package]] name = "codex-guardian" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "codex-core", @@ -2972,7 +2975,7 @@ dependencies = [ [[package]] name = "codex-hooks" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "chrono", @@ -2995,7 +2998,7 @@ dependencies = [ [[package]] name = "codex-install-context" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-utils-home-dir", "pretty_assertions", @@ -3004,7 +3007,7 @@ dependencies = [ [[package]] name = "codex-keyring-store" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "keyring", "tracing", @@ -3012,7 +3015,7 @@ dependencies = [ [[package]] name = "codex-linux-sandbox" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "clap", "codex-core", @@ -3035,7 +3038,7 @@ dependencies = [ [[package]] name = "codex-lmstudio" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-core", "codex-model-provider-info", @@ -3049,7 +3052,7 @@ dependencies = [ [[package]] name = "codex-login" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "async-trait", @@ -3091,7 +3094,7 @@ dependencies = [ [[package]] name = "codex-mcp" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "async-channel", @@ -3123,7 +3126,7 @@ dependencies = [ [[package]] name = "codex-mcp-server" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "codex-arg0", @@ -3155,7 +3158,7 @@ dependencies = [ [[package]] name = "codex-memories-extension" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "codex-core", @@ -3176,7 +3179,7 @@ dependencies = [ [[package]] name = "codex-memories-mcp" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "codex-utils-absolute-path", @@ -3193,7 +3196,7 @@ dependencies = [ [[package]] name = "codex-memories-read" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-protocol", "codex-shell-command", @@ -3207,7 +3210,7 @@ dependencies = [ [[package]] name = "codex-memories-write" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "chrono", @@ -3242,7 +3245,7 @@ dependencies = [ [[package]] name = "codex-message-history" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-config", "pretty_assertions", @@ -3255,7 +3258,7 @@ dependencies = [ [[package]] name = "codex-model-provider" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "codex-agent-identity", @@ -3279,7 +3282,7 @@ dependencies = [ [[package]] name = "codex-model-provider-info" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-api", "codex-app-server-protocol", @@ -3296,7 +3299,7 @@ dependencies = [ [[package]] name = "codex-models-manager" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "chrono", @@ -3317,7 +3320,7 @@ dependencies = [ [[package]] name = "codex-network-proxy" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "async-trait", @@ -3348,7 +3351,7 @@ dependencies = [ [[package]] name = "codex-ollama" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "assert_matches", "async-stream", @@ -3367,7 +3370,7 @@ dependencies = [ [[package]] name = "codex-otel" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "chrono", "codex-api", @@ -3399,7 +3402,7 @@ dependencies = [ [[package]] name = "codex-plugin" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-config", "codex-utils-absolute-path", @@ -3409,7 +3412,7 @@ dependencies = [ [[package]] name = "codex-process-hardening" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "libc", "pretty_assertions", @@ -3417,7 +3420,7 @@ dependencies = [ [[package]] name = "codex-protocol" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "chardetng", @@ -3457,7 +3460,7 @@ dependencies = [ [[package]] name = "codex-realtime-webrtc" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "libwebrtc", "thiserror 2.0.18", @@ -3466,7 +3469,7 @@ dependencies = [ [[package]] name = "codex-response-debug-context" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "base64 0.22.1", "codex-api", @@ -3477,7 +3480,7 @@ dependencies = [ [[package]] name = "codex-responses-api-proxy" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "clap", @@ -3494,7 +3497,7 @@ dependencies = [ [[package]] name = "codex-rmcp-client" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "axum", @@ -3532,7 +3535,7 @@ dependencies = [ [[package]] name = "codex-rollout" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "async-trait", @@ -3557,7 +3560,7 @@ dependencies = [ [[package]] name = "codex-rollout-trace" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "codex-code-mode", @@ -3573,7 +3576,7 @@ dependencies = [ [[package]] name = "codex-sandboxing" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "async-trait", @@ -3594,7 +3597,7 @@ dependencies = [ [[package]] name = "codex-secrets" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "age", "anyhow", @@ -3615,7 +3618,7 @@ dependencies = [ [[package]] name = "codex-shell-command" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "base64 0.22.1", @@ -3635,7 +3638,7 @@ dependencies = [ [[package]] name = "codex-shell-escalation" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "async-trait", @@ -3656,7 +3659,7 @@ dependencies = [ [[package]] name = "codex-skills" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-utils-absolute-path", "include_dir", @@ -3665,7 +3668,7 @@ dependencies = [ [[package]] name = "codex-state" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "chrono", @@ -3688,7 +3691,7 @@ dependencies = [ [[package]] name = "codex-stdio-to-uds" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "codex-uds", @@ -3700,7 +3703,7 @@ dependencies = [ [[package]] name = "codex-terminal-detection" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "pretty_assertions", "tracing", @@ -3708,7 +3711,7 @@ dependencies = [ [[package]] name = "codex-test-binary-support" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-arg0", "tempfile", @@ -3716,7 +3719,7 @@ dependencies = [ [[package]] name = "codex-thread-manager-sample" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "clap", @@ -3727,7 +3730,7 @@ dependencies = [ [[package]] name = "codex-thread-store" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "chrono", @@ -3748,7 +3751,7 @@ dependencies = [ [[package]] name = "codex-tools" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "codex-app-server-protocol", @@ -3768,7 +3771,7 @@ dependencies = [ [[package]] name = "codex-tui" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "arboard", @@ -3879,7 +3882,7 @@ dependencies = [ [[package]] name = "codex-uds" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-io", "pretty_assertions", @@ -3891,7 +3894,7 @@ dependencies = [ [[package]] name = "codex-utils-absolute-path" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "dirs", "dunce", @@ -3905,14 +3908,14 @@ dependencies = [ [[package]] name = "codex-utils-approval-presets" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-protocol", ] [[package]] name = "codex-utils-cache" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "lru 0.16.3", "sha1", @@ -3921,7 +3924,7 @@ dependencies = [ [[package]] name = "codex-utils-cargo-bin" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "assert_cmd", "runfiles", @@ -3930,7 +3933,7 @@ dependencies = [ [[package]] name = "codex-utils-cli" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "clap", "codex-protocol", @@ -3942,15 +3945,19 @@ dependencies = [ [[package]] name = "codex-utils-elapsed" -version = "0.0.0" +version = "0.132.0-alpha.1" + +[[package]] +name = "codex-utils-file-lock" +version = "0.132.0-alpha.1" [[package]] name = "codex-utils-fuzzy-match" -version = "0.0.0" +version = "0.132.0-alpha.1" [[package]] name = "codex-utils-home-dir" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-utils-absolute-path", "dirs", @@ -3960,7 +3967,7 @@ dependencies = [ [[package]] name = "codex-utils-image" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "base64 0.22.1", "codex-utils-cache", @@ -3972,7 +3979,7 @@ dependencies = [ [[package]] name = "codex-utils-json-to-toml" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "pretty_assertions", "serde_json", @@ -3981,7 +3988,7 @@ dependencies = [ [[package]] name = "codex-utils-oss" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-core", "codex-lmstudio", @@ -3991,7 +3998,7 @@ dependencies = [ [[package]] name = "codex-utils-output-truncation" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-protocol", "codex-utils-string", @@ -4000,7 +4007,7 @@ dependencies = [ [[package]] name = "codex-utils-path" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-utils-absolute-path", "dunce", @@ -4010,7 +4017,7 @@ dependencies = [ [[package]] name = "codex-utils-plugins" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-exec-server", "codex-login", @@ -4023,7 +4030,7 @@ dependencies = [ [[package]] name = "codex-utils-pty" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "filedescriptor", @@ -4039,7 +4046,7 @@ dependencies = [ [[package]] name = "codex-utils-readiness" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "assert_matches", "async-trait", @@ -4050,14 +4057,14 @@ dependencies = [ [[package]] name = "codex-utils-rustls-provider" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "rustls", ] [[package]] name = "codex-utils-sandbox-summary" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-core", "codex-model-provider-info", @@ -4068,7 +4075,7 @@ dependencies = [ [[package]] name = "codex-utils-sleep-inhibitor" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "core-foundation 0.9.4", "libc", @@ -4078,14 +4085,14 @@ dependencies = [ [[package]] name = "codex-utils-stream-parser" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "pretty_assertions", ] [[package]] name = "codex-utils-string" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "pretty_assertions", "regex-lite", @@ -4095,14 +4102,14 @@ dependencies = [ [[package]] name = "codex-utils-template" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "pretty_assertions", ] [[package]] name = "codex-v8-poc" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "pretty_assertions", "v8", @@ -4110,7 +4117,7 @@ dependencies = [ [[package]] name = "codex-windows-sandbox" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "base64 0.22.1", @@ -4361,7 +4368,7 @@ dependencies = [ [[package]] name = "core_test_support" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "assert_cmd", @@ -5475,7 +5482,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8560,7 +8567,7 @@ dependencies = [ [[package]] name = "mcp_test_support" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "codex-login", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 91b73d562b5d..c2ae163ee1cb 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -81,6 +81,7 @@ members = [ "v8-poc", "utils/absolute-path", "utils/cargo-bin", + "utils/file-lock", "git-utils", "utils/cache", "utils/image", @@ -219,6 +220,7 @@ codex-utils-cache = { path = "utils/cache" } codex-utils-cargo-bin = { path = "utils/cargo-bin" } codex-utils-cli = { path = "utils/cli" } codex-utils-elapsed = { path = "utils/elapsed" } +codex-utils-file-lock = { path = "utils/file-lock" } codex-utils-fuzzy-match = { path = "utils/fuzzy-match" } codex-utils-home-dir = { path = "utils/home-dir" } codex-utils-image = { path = "utils/image" } diff --git a/codex-rs/arg0/Cargo.toml b/codex-rs/arg0/Cargo.toml index 7ee21a770e49..7c2c17692020 100644 --- a/codex-rs/arg0/Cargo.toml +++ b/codex-rs/arg0/Cargo.toml @@ -20,6 +20,7 @@ codex-linux-sandbox = { workspace = true } codex-sandboxing = { workspace = true } codex-shell-escalation = { workspace = true } codex-utils-absolute-path = { workspace = true } +codex-utils-file-lock = { workspace = true } codex-utils-home-dir = { workspace = true } dotenvy = { workspace = true } tempfile = { workspace = true } diff --git a/codex-rs/arg0/src/lib.rs b/codex-rs/arg0/src/lib.rs index 2f6ae4653c65..5a558769a7e5 100644 --- a/codex-rs/arg0/src/lib.rs +++ b/codex-rs/arg0/src/lib.rs @@ -6,6 +6,8 @@ use std::path::PathBuf; use codex_apply_patch::CODEX_CORE_APPLY_PATCH_ARG1; use codex_exec_server::CODEX_FS_HELPER_ARG1; use codex_sandboxing::landlock::CODEX_LINUX_SANDBOX_ARG0; +use codex_utils_file_lock::TryFileLockOutcome; +use codex_utils_file_lock::try_lock_exclusive_optional; use codex_utils_home_dir::find_codex_home; #[cfg(unix)] use std::os::unix::fs::symlink; @@ -33,12 +35,12 @@ pub struct Arg0DispatchPaths { /// Keeps the per-session PATH entry alive and locked for the process lifetime. pub struct Arg0PathEntryGuard { _temp_dir: TempDir, - _lock_file: File, + _lock_file: Option, paths: Arg0DispatchPaths, } impl Arg0PathEntryGuard { - fn new(temp_dir: TempDir, lock_file: File, paths: Arg0DispatchPaths) -> Self { + fn new(temp_dir: TempDir, lock_file: Option, paths: Arg0DispatchPaths) -> Self { Self { _temp_dir: temp_dir, _lock_file: lock_file, @@ -326,7 +328,13 @@ pub fn prepend_path_entry_for_codex_aliases() -> std::io::Result Some(lock_file), + TryFileLockOutcome::Unsupported => None, + TryFileLockOutcome::WouldBlock => { + return Err(std::io::Error::from(std::io::ErrorKind::WouldBlock).into()); + } + }; for filename in &[ APPLY_PATCH_ARG0, @@ -445,10 +453,9 @@ fn try_lock_dir(dir: &Path) -> std::io::Result> { Err(err) => return Err(err), }; - match lock_file.try_lock() { - Ok(()) => Ok(Some(lock_file)), - Err(std::fs::TryLockError::WouldBlock) => Ok(None), - Err(err) => Err(err.into()), + match try_lock_exclusive_optional(&lock_file)? { + TryFileLockOutcome::Acquired => Ok(Some(lock_file)), + TryFileLockOutcome::WouldBlock | TryFileLockOutcome::Unsupported => Ok(None), } } @@ -562,7 +569,13 @@ mod tests { let dir = root.path().join("locked"); fs::create_dir(&dir)?; let lock_file = create_lock(&dir)?; - lock_file.try_lock()?; + match try_lock_exclusive_optional(&lock_file)? { + TryFileLockOutcome::Acquired => {} + TryFileLockOutcome::Unsupported => return Ok(()), + TryFileLockOutcome::WouldBlock => { + panic!("newly created lock file should not be locked"); + } + } janitor_cleanup(root.path())?; diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 8472918dcae9..fdf1d4b18a9e 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -63,6 +63,7 @@ codex-thread-store = { workspace = true } codex-tools = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-cache = { workspace = true } +codex-utils-file-lock = { workspace = true } codex-utils-image = { workspace = true } codex-utils-home-dir = { workspace = true } codex-utils-output-truncation = { workspace = true } @@ -125,6 +126,10 @@ openssl-sys = { workspace = true, features = ["vendored"] } [target.aarch64-unknown-linux-musl.dependencies] openssl-sys = { workspace = true, features = ["vendored"] } +# Build OpenSSL from source for Android builds. +[target.aarch64-linux-android.dependencies] +openssl-sys = { workspace = true, features = ["vendored"] } + [target.'cfg(unix)'.dependencies] codex-shell-escalation = { workspace = true } diff --git a/codex-rs/core/src/installation_id.rs b/codex-rs/core/src/installation_id.rs index a42e6b6d8353..a87c660f6132 100644 --- a/codex-rs/core/src/installation_id.rs +++ b/codex-rs/core/src/installation_id.rs @@ -11,6 +11,9 @@ use std::os::unix::fs::OpenOptionsExt; use std::os::unix::fs::PermissionsExt; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_file_lock::FileLockOutcome; +use codex_utils_file_lock::acquire_sibling_lock_dir; +use codex_utils_file_lock::lock_exclusive_optional; use tokio::fs; use uuid::Uuid; @@ -29,7 +32,10 @@ pub async fn resolve_installation_id(codex_home: &AbsolutePathBuf) -> Result None, + FileLockOutcome::Unsupported => Some(acquire_sibling_lock_dir(&path)?), + }; #[cfg(unix)] { diff --git a/codex-rs/execpolicy/Cargo.toml b/codex-rs/execpolicy/Cargo.toml index b22226a79e4b..b87af084d7d1 100644 --- a/codex-rs/execpolicy/Cargo.toml +++ b/codex-rs/execpolicy/Cargo.toml @@ -21,6 +21,7 @@ workspace = true anyhow = { workspace = true } clap = { workspace = true, features = ["derive"] } codex-utils-absolute-path = { workspace = true } +codex-utils-file-lock = { workspace = true } multimap = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } diff --git a/codex-rs/execpolicy/src/amend.rs b/codex-rs/execpolicy/src/amend.rs index e25fd1bd9144..1ef36b1ff5d8 100644 --- a/codex-rs/execpolicy/src/amend.rs +++ b/codex-rs/execpolicy/src/amend.rs @@ -6,6 +6,10 @@ use std::io::Write; use std::path::Path; use std::path::PathBuf; +use codex_utils_file_lock::FileLockOutcome; +use codex_utils_file_lock::acquire_sibling_lock_dir; +use codex_utils_file_lock::lock_exclusive_optional; + use crate::decision::Decision; use crate::rule::NetworkRuleProtocol; use crate::rule::normalize_network_rule_host; @@ -154,10 +158,21 @@ fn append_locked_line(policy_path: &Path, line: &str) -> Result<(), AmendError> path: policy_path.to_path_buf(), source, })?; - file.lock().map_err(|source| AmendError::LockPolicyFile { - path: policy_path.to_path_buf(), - source, - })?; + let _lock_dir_guard = + match lock_exclusive_optional(&file).map_err(|source| AmendError::LockPolicyFile { + path: policy_path.to_path_buf(), + source, + })? { + FileLockOutcome::Acquired => None, + FileLockOutcome::Unsupported => { + Some(acquire_sibling_lock_dir(policy_path).map_err(|source| { + AmendError::LockPolicyFile { + path: policy_path.to_path_buf(), + source, + } + })?) + } + }; file.seek(SeekFrom::Start(0)) .map_err(|source| AmendError::SeekPolicyFile { diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index c213e92bae41..40624baba0d0 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -126,7 +126,7 @@ uuid = { workspace = true } codex-windows-sandbox = { workspace = true } tokio-util = { workspace = true, features = ["time"] } -[target.'cfg(not(target_os = "linux"))'.dependencies] +[target.'cfg(all(not(target_os = "linux"), not(target_os = "android")))'.dependencies] cpal = "0.15" [target.'cfg(unix)'.dependencies] diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 40cd121c7135..8a71fd738e1e 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -89,9 +89,9 @@ mod app_server_approval_conversions; mod app_server_session; mod approval_events; mod ascii_animation; -#[cfg(not(target_os = "linux"))] +#[cfg(not(any(target_os = "linux", target_os = "android")))] mod audio_device; -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "android"))] #[allow(dead_code)] mod audio_device { use crate::app_event::RealtimeAudioDeviceKind; @@ -195,11 +195,11 @@ mod update_prompt; mod update_versions; mod updates; mod version; -#[cfg(not(target_os = "linux"))] +#[cfg(not(any(target_os = "linux", target_os = "android")))] mod voice; mod width; mod workspace_command; -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "android"))] #[allow(dead_code)] mod voice { use crate::app_event_sender::AppEventSender; diff --git a/codex-rs/utils/file-lock/BUILD.bazel b/codex-rs/utils/file-lock/BUILD.bazel new file mode 100644 index 000000000000..face70c53518 --- /dev/null +++ b/codex-rs/utils/file-lock/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "file-lock", + crate_name = "codex_utils_file_lock", +) diff --git a/codex-rs/utils/file-lock/Cargo.toml b/codex-rs/utils/file-lock/Cargo.toml new file mode 100644 index 000000000000..5e3877fc1630 --- /dev/null +++ b/codex-rs/utils/file-lock/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "codex-utils-file-lock" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] diff --git a/codex-rs/utils/file-lock/src/lib.rs b/codex-rs/utils/file-lock/src/lib.rs new file mode 100644 index 000000000000..fdcfbdb81e75 --- /dev/null +++ b/codex-rs/utils/file-lock/src/lib.rs @@ -0,0 +1,168 @@ +use std::fs::File; +use std::fs::create_dir; +use std::fs::remove_dir; +use std::io; +use std::path::Path; +use std::path::PathBuf; +use std::thread; +use std::time::Duration; + +const LOCK_DIR_RETRY_SLEEP: Duration = Duration::from_millis(100); + +/// Result of acquiring a blocking advisory file lock. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FileLockOutcome { + Acquired, + Unsupported, +} + +/// Result of attempting to acquire a non-blocking advisory file lock. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TryFileLockOutcome { + Acquired, + WouldBlock, + Unsupported, +} + +/// Result of attempting to acquire a non-blocking lock directory. +#[derive(Debug, PartialEq, Eq)] +pub enum TryLockDirOutcome { + Acquired(LockDirGuard), + WouldBlock, +} + +/// Guard for a sibling lock directory created with an atomic `mkdir`. +#[derive(Debug, PartialEq, Eq)] +pub struct LockDirGuard { + path: PathBuf, +} + +impl Drop for LockDirGuard { + fn drop(&mut self) { + let _ = remove_dir(&self.path); + } +} + +/// Acquires an exclusive advisory file lock, treating unsupported file locking +/// as a distinct outcome for platforms such as Termux. +pub fn lock_exclusive_optional(file: &File) -> io::Result { + match file.lock() { + Ok(()) => Ok(FileLockOutcome::Acquired), + Err(err) if err.kind() == io::ErrorKind::Unsupported => Ok(FileLockOutcome::Unsupported), + Err(err) => Err(err), + } +} + +/// Attempts to acquire an exclusive advisory file lock without blocking, +/// preserving `WouldBlock` and unsupported file locking as distinct outcomes. +pub fn try_lock_exclusive_optional(file: &File) -> io::Result { + match file.try_lock() { + Ok(()) => Ok(TryFileLockOutcome::Acquired), + Err(std::fs::TryLockError::WouldBlock) => Ok(TryFileLockOutcome::WouldBlock), + Err(std::fs::TryLockError::Error(err)) if err.kind() == io::ErrorKind::Unsupported => { + Ok(TryFileLockOutcome::Unsupported) + } + Err(std::fs::TryLockError::Error(err)) => Err(err), + } +} + +/// Returns the sibling directory path used as a fallback lock for `path`. +pub fn sibling_lock_dir(path: &Path) -> PathBuf { + let Some(file_name) = path.file_name() else { + return path.with_file_name(".lock"); + }; + + let mut lock_name = file_name.to_os_string(); + lock_name.push(".lock"); + path.with_file_name(lock_name) +} + +/// Acquires a sibling lock directory, blocking until it is available. +pub fn acquire_sibling_lock_dir(path: &Path) -> io::Result { + loop { + match try_acquire_sibling_lock_dir(path)? { + TryLockDirOutcome::Acquired(guard) => return Ok(guard), + TryLockDirOutcome::WouldBlock => thread::sleep(LOCK_DIR_RETRY_SLEEP), + } + } +} + +/// Attempts to acquire a sibling lock directory without blocking. +pub fn try_acquire_sibling_lock_dir(path: &Path) -> io::Result { + let lock_dir = sibling_lock_dir(path); + match create_dir(&lock_dir) { + Ok(()) => Ok(TryLockDirOutcome::Acquired(LockDirGuard { path: lock_dir })), + Err(err) if err.kind() == io::ErrorKind::AlreadyExists => Ok(TryLockDirOutcome::WouldBlock), + Err(err) => Err(err), + } +} + +/// Attempts to acquire a shared advisory file lock without blocking, +/// preserving `WouldBlock` and unsupported file locking as distinct outcomes. +pub fn try_lock_shared_optional(file: &File) -> io::Result { + match file.try_lock_shared() { + Ok(()) => Ok(TryFileLockOutcome::Acquired), + Err(std::fs::TryLockError::WouldBlock) => Ok(TryFileLockOutcome::WouldBlock), + Err(std::fs::TryLockError::Error(err)) if err.kind() == io::ErrorKind::Unsupported => { + Ok(TryFileLockOutcome::Unsupported) + } + Err(std::fs::TryLockError::Error(err)) => Err(err), + } +} + +#[cfg(test)] +mod tests { + use super::TryLockDirOutcome; + use super::sibling_lock_dir; + use super::try_acquire_sibling_lock_dir; + use std::fs; + use std::path::PathBuf; + use std::time::SystemTime; + + fn unique_temp_file_path(name: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos(); + std::env::temp_dir().join(format!( + "codex-file-lock-{name}-{}-{nanos}", + std::process::id() + )) + } + + #[test] + fn sibling_lock_dir_appends_lock_suffix() { + let path = PathBuf::from("/tmp/history.jsonl"); + + assert_eq!( + sibling_lock_dir(&path), + PathBuf::from("/tmp/history.jsonl.lock") + ); + } + + #[test] + fn try_acquire_sibling_lock_dir_is_exclusive_until_drop() { + let path = unique_temp_file_path("exclusive"); + let lock_dir = sibling_lock_dir(&path); + let _ = fs::remove_dir_all(&lock_dir); + + let guard = match try_acquire_sibling_lock_dir(&path).expect("acquire lock dir") { + TryLockDirOutcome::Acquired(guard) => guard, + TryLockDirOutcome::WouldBlock => panic!("first lock attempt should acquire"), + }; + assert!(lock_dir.is_dir()); + + assert!(matches!( + try_acquire_sibling_lock_dir(&path).expect("try acquire held lock dir"), + TryLockDirOutcome::WouldBlock + )); + + drop(guard); + assert!(!lock_dir.exists()); + + let reacquired = try_acquire_sibling_lock_dir(&path).expect("reacquire lock dir"); + assert!(matches!(reacquired, TryLockDirOutcome::Acquired(_))); + + let _ = fs::remove_dir_all(lock_dir); + } +} diff --git a/justfile b/justfile index ab2fbc63629a..569a04695216 100644 --- a/justfile +++ b/justfile @@ -116,6 +116,11 @@ argument-comment-lint *args: argument-comment-lint-from-source *args: {{ justfile_directory() }}/tools/argument-comment-lint/run.py "$@" +# Audit advisory file locks that may need Termux Unsupported handling. +[no-cd] +termux-lock-audit *args: + {{ justfile_directory() }}/scripts/termux-lock-audit.sh "$@" + # Tail logs from the state SQLite database log *args: if [ "${1:-}" = "--" ]; then shift; fi; cargo run -p codex-state --bin logs_client -- "$@" diff --git a/scripts/termux-lock-audit.sh b/scripts/termux-lock-audit.sh new file mode 100755 index 000000000000..ac40f8e57246 --- /dev/null +++ b/scripts/termux-lock-audit.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: scripts/termux-lock-audit.sh [--strict] + +Audits Rust advisory file-lock usage that may need Termux compatibility handling. + +By default this script prints findings and exits successfully. With --strict, it +exits non-zero when candidate file-lock calls are found in files that do not also +mention Unsupported/TryLockError handling. +USAGE +} + +strict=false +for arg in "$@"; do + case "$arg" in + --strict) + strict=true + ;; + -h | --help) + usage + exit 0 + ;; + *) + echo "unknown argument: $arg" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if ! command -v rg >/dev/null 2>&1; then + echo "error: rg is required for this audit" >&2 + exit 1 +fi + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repo_root" + +unsupported_pattern='std::fs::TryLockError|fs::TryLockError|TryLockError::|ErrorKind::Unsupported|std::io::ErrorKind::Unsupported' +lock_context_pattern='(\.lock\(\)|\.try_lock\(|\.try_lock_shared\(|TryLockError|ErrorKind::Unsupported)' +candidate_file_lock_pattern='\b([A-Za-z_][A-Za-z0-9_]*_file|file)\.(lock|try_lock|try_lock_shared)\(' +candidate_false_positive_pattern='poisoned' +helper_pattern='codex_utils_file_lock|FileLockOutcome|TryFileLockOutcome|lock_exclusive_optional|try_lock_exclusive_optional|try_lock_shared_optional' + +print_section() { + printf '\n== %s ==\n' "$1" +} + +print_section "Unsupported-aware lock handling" +echo "These files already mention Unsupported/TryLockError and are likely patched or intentionally reviewed:" + +unsupported_count=0 +while IFS= read -r file; do + if rg -q "$lock_context_pattern" "$file"; then + rg -n -H "$lock_context_pattern" "$file" + unsupported_count=$((unsupported_count + 1)) + fi +done < <(rg -l "$unsupported_pattern" codex-rs -g '*.rs' || true) + +if [ "$unsupported_count" -eq 0 ]; then + echo "No unsupported-aware lock handling found." +fi + +print_section "Optional file-lock helper usage" +echo "These files use the shared optional advisory file-lock helper:" + +helper_count=0 +while IFS= read -r file; do + rg -n -H "$helper_pattern" "$file" + helper_count=$((helper_count + 1)) +done < <(rg -l "$helper_pattern" codex-rs -g '*.rs' || true) + +if [ "$helper_count" -eq 0 ]; then + echo "No optional file-lock helper usage found." +fi + +print_section "Candidate file-lock calls for manual review" +echo "These receiver names may be std::fs::File advisory locks, but the file does not mention Unsupported/TryLockError handling." +echo "This section can include false positives, such as mutexes wrapping a file." + +review_count=0 +while IFS= read -r file; do + if ! rg -q "$unsupported_pattern" "$file"; then + matches="$(rg -n -H "$candidate_file_lock_pattern" "$file" | rg -v "$candidate_false_positive_pattern" || true)" + if [ -n "$matches" ]; then + echo "$matches" + review_count=$((review_count + 1)) + fi + fi +done < <(rg -l "$candidate_file_lock_pattern" codex-rs -g '*.rs' || true) + +if [ "$review_count" -eq 0 ]; then + echo "No unpatched candidate file-lock calls found." +fi + +print_section "Candidate file-lock calls already in unsupported-aware files" +echo "These are candidate file-lock calls in files that already mention Unsupported/TryLockError handling:" + +aware_candidate_count=0 +while IFS= read -r file; do + if rg -q "$unsupported_pattern" "$file"; then + matches="$(rg -n -H "$candidate_file_lock_pattern" "$file" | rg -v "$candidate_false_positive_pattern" || true)" + if [ -n "$matches" ]; then + echo "$matches" + aware_candidate_count=$((aware_candidate_count + 1)) + fi + fi +done < <(rg -l "$candidate_file_lock_pattern" codex-rs -g '*.rs' || true) + +if [ "$aware_candidate_count" -eq 0 ]; then + echo "No unsupported-aware candidate file-lock calls found." +fi + +print_section "Summary" +echo "unsupported-aware files: $unsupported_count" +echo "optional helper files: $helper_count" +echo "review candidate files: $review_count" +echo "unsupported-aware candidate files: $aware_candidate_count" + +if [ "$strict" = true ] && [ "$review_count" -gt 0 ]; then + echo "strict mode: manual-review candidate files were found" >&2 + exit 1 +fi From b5d89d1555efea55f65bfe60b12b04dd4805a192 Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Wed, 20 May 2026 14:54:59 -0700 Subject: [PATCH 11/64] Release 0.133.0-alpha.3 --- codex-rs/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index ec38a87cff97..9dfb0bf3d316 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -117,7 +117,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.0.0" +version = "0.133.0-alpha.3" # Track the edition for all workspace crates in one place. Individual # crates can still override this value, but keeping it here means new # crates created with `cargo new -w ...` automatically inherit the 2024 From a282351abcb5a3a87b8433ddbeec2a16bb667c0f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 01:23:49 +0000 Subject: [PATCH 12/64] Seed Termux release automation --- .github/workflows/rust-release.yml | 1816 +++++++---------- .github/workflows/shell-tool-mcp.yml | 461 +++++ .../workflows/termux-release-checkpoint.yml | 103 + .github/workflows/termux-release-deploy.yml | 243 +++ .github/workflows/termux-release-promote.yml | 135 ++ scripts/termux-configure-git.sh | 40 + scripts/termux-create-checkpoint-pr.sh | 275 +++ ...ermux-create-or-update-mirrored-release.sh | 111 + scripts/termux-download-release-artifact.sh | 78 + scripts/termux-find-release-pr.sh | 53 + scripts/termux-read-release-metadata.sh | 77 + scripts/termux-release-asset-state.sh | 37 + scripts/termux-release-paths.sh | 55 + scripts/termux-resolve-release-ref.sh | 36 + scripts/termux-validate-gh-env.sh | 16 + 15 files changed, 2445 insertions(+), 1091 deletions(-) create mode 100644 .github/workflows/shell-tool-mcp.yml create mode 100644 .github/workflows/termux-release-checkpoint.yml create mode 100644 .github/workflows/termux-release-deploy.yml create mode 100644 .github/workflows/termux-release-promote.yml create mode 100755 scripts/termux-configure-git.sh create mode 100755 scripts/termux-create-checkpoint-pr.sh create mode 100755 scripts/termux-create-or-update-mirrored-release.sh create mode 100755 scripts/termux-download-release-artifact.sh create mode 100755 scripts/termux-find-release-pr.sh create mode 100755 scripts/termux-read-release-metadata.sh create mode 100755 scripts/termux-release-asset-state.sh create mode 100755 scripts/termux-release-paths.sh create mode 100755 scripts/termux-resolve-release-ref.sh create mode 100755 scripts/termux-validate-gh-env.sh diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index c88fede7fa8b..56d0977e6e67 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -4,46 +4,49 @@ # git tag -a rust-v0.1.0 -m "Release 0.1.0" # git push origin rust-v0.1.0 # ``` -# -# To use external macOS signing, manually dispatch `release_mode=build_unsigned`, -# sign the unsigned macOS artifacts in a secure enclave, upload the signed handoff -# archive as a GitHub Release asset, then manually dispatch -# `release_mode=promote_signed` with `unsigned_run_id` and `signed_macos_asset`. -# The signed handoff archive should contain target or artifact directories such -# as `aarch64-apple-darwin/` with signed binaries. name: rust-release on: - push: - tags: - - "rust-v*.*.*" + pull_request: + branches: + - "release/**" workflow_dispatch: inputs: - release_mode: - description: "build_unsigned creates unsigned macOS handoff artifacts; promote_signed finishes a release from signed macOS handoff artifacts." + source_ref: + description: "Branch, tag, or commit SHA to build (code only; keeps .github from dispatched ref)" required: false + type: string + default: "" + build_target: + description: "Target to build" + required: true type: choice - default: build_unsigned + default: all options: - - build_unsigned - - promote_signed - sign_macos: - description: "Deprecated compatibility input; use release_mode instead." - required: false - type: boolean - default: false - unsigned_run_id: - description: "For promote_signed: workflow run id from the build_unsigned run." - required: false - type: string - signed_macos_asset: - description: "For promote_signed: exact GitHub Release asset name containing signed macOS handoff artifacts." - required: false - type: string - signed_macos_sha256: - description: "For promote_signed: optional SHA-256 of signed_macos_asset." - required: false - type: string + - all + - aarch64-apple-darwin + - x86_64-apple-darwin + - x86_64-unknown-linux-musl + - x86_64-unknown-linux-gnu + - aarch64-unknown-linux-musl + - aarch64-unknown-linux-gnu + - aarch64-linux-android + - x86_64-pc-windows-msvc + - aarch64-pc-windows-msvc + +permissions: + actions: read + attestations: read + checks: read + contents: read + deployments: read + issues: read + discussions: read + packages: read + pages: read + pull-requests: read + repository-projects: read + statuses: read concurrency: group: ${{ github.workflow }} @@ -51,72 +54,26 @@ concurrency: jobs: tag-check: + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: actions/checkout@v6 + - name: 🧰 Actions Toolbox + # This is required for the GitHub CLI + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: wallentx/gh-actions/composite/actions-toolbox@main + - uses: dtolnay/rust-toolchain@1.92 - name: Validate tag matches Cargo.toml version shell: bash - env: - RELEASE_MODE: ${{ github.event_name == 'workflow_dispatch' && inputs.release_mode || 'signed' }} - REQUESTED_SIGN_MACOS: ${{ inputs.sign_macos }} - SIGNED_MACOS_ASSET: ${{ inputs.signed_macos_asset }} - UNSIGNED_RUN_ID: ${{ inputs.unsigned_run_id }} run: | set -euo pipefail echo "::group::Tag validation" - case "${RELEASE_MODE}" in - signed) - if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then - echo "❌ Manual rust-release runs must use release_mode=build_unsigned or release_mode=promote_signed" - exit 1 - fi - ;; - build_unsigned) - if [[ "${GITHUB_EVENT_NAME}" != "workflow_dispatch" ]]; then - echo "❌ release_mode=build_unsigned is only valid for manual runs" - exit 1 - fi - ;; - promote_signed) - if [[ "${GITHUB_EVENT_NAME}" != "workflow_dispatch" ]]; then - echo "❌ release_mode=promote_signed is only valid for manual runs" - exit 1 - fi - if [[ ! "${UNSIGNED_RUN_ID}" =~ ^[0-9]+$ ]]; then - echo "❌ release_mode=promote_signed requires unsigned_run_id to be a workflow run id" - exit 1 - fi - if [[ -z "${SIGNED_MACOS_ASSET}" ]]; then - echo "❌ release_mode=promote_signed requires signed_macos_asset" - exit 1 - fi - if [[ "${SIGNED_MACOS_ASSET}" == */* || "${SIGNED_MACOS_ASSET}" == *"*"* || "${SIGNED_MACOS_ASSET}" == *"?"* || "${SIGNED_MACOS_ASSET}" == *"["* ]]; then - echo "❌ signed_macos_asset must be an exact release asset name, not a path or glob" - exit 1 - fi - if [[ "${UNSIGNED_RUN_ID}" == "${GITHUB_RUN_ID}" ]]; then - echo "❌ unsigned_run_id must refer to the earlier build_unsigned run, not this run" - exit 1 - fi - ;; - *) - echo "❌ Unknown release_mode '${RELEASE_MODE}'" - exit 1 - ;; - esac - - if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${REQUESTED_SIGN_MACOS}" == "true" ]]; then - echo "::warning title=Deprecated sign_macos input ignored::Use release_mode=build_unsigned or release_mode=promote_signed instead." - fi - # 1. Must be a tag and match the regex [[ "${GITHUB_REF_TYPE}" == "tag" ]] \ || { echo "❌ Not a tag push"; exit 1; } - [[ "${GITHUB_REF_NAME}" =~ ^rust-v[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta)(\.[0-9]+)?)?$ ]] \ + [[ "${GITHUB_REF_NAME}" =~ ^rust-v[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta)(\.[0-9]+)?)?(-termux)?$ ]] \ || { echo "❌ Tag '${GITHUB_REF_NAME}' doesn't match expected format"; exit 1; } # 2. Extract versions @@ -131,115 +88,198 @@ jobs: echo "✅ Tag and Cargo.toml agree (${tag_ver})" echo "::endgroup::" - build: - if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} + select-build-matrix: + if: always() && (github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' || needs.tag-check.result == 'success') needs: tag-check - name: Build - ${{ matrix.runner }} - ${{ matrix.target }} - ${{ matrix.bundle }} - runs-on: ${{ matrix.runs_on || matrix.runner }} - # Release builds can take a long time, so leave some headroom to avoid - # having to restart the full workflow due to a timeout. - timeout-minutes: 90 - permissions: - contents: read - id-token: write + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.select.outputs.matrix }} + build_version: ${{ steps.select.outputs.build_version }} + steps: + - uses: actions/checkout@v6 + - name: 🧰 Actions Toolbox + # This is required for the GitHub CLI + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: wallentx/gh-actions/composite/actions-toolbox@main + - id: select + shell: bash + env: + REPO_OWNER: ${{ github.repository_owner }} + EVENT_NAME: ${{ github.event_name }} + BUILD_TARGET: ${{ inputs.build_target }} + run: | + set -euo pipefail + + matrix='[ + {"runner":"macos-15-xlarge","target":"aarch64-apple-darwin"}, + {"runner":"macos-15-xlarge","target":"x86_64-apple-darwin"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-gnu"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-gnu"}, + {"runner":"ubuntu-24.04","target":"aarch64-linux-android"}, + {"runner":"windows-latest","target":"x86_64-pc-windows-msvc"}, + {"runner":"windows-11-arm","target":"aarch64-pc-windows-msvc"} + ]' + + if [[ "${REPO_OWNER}" != "openai" ]]; then + matrix="$(jq -c '[.[] | select(.runner | startswith("macos") | not)]' <<< "${matrix}")" + fi + + if [[ "${EVENT_NAME}" == "pull_request" ]]; then + matrix="$(jq -c '[.[] | select(.target == "aarch64-linux-android")]' <<< "${matrix}")" + elif [[ "${EVENT_NAME}" == "workflow_dispatch" && -n "${BUILD_TARGET}" && "${BUILD_TARGET}" != "all" ]]; then + matrix="$(jq -c --arg build_target "${BUILD_TARGET}" '[.[] | select(.target == $build_target)]' <<< "${matrix}")" + fi + + if [[ "$(jq 'length' <<< "${matrix}")" -eq 0 ]]; then + echo "No build targets selected after applying owner/dispatch filters." >&2 + exit 1 + fi + + echo "matrix={\"include\":${matrix}}" >> "$GITHUB_OUTPUT" + + if [[ "${EVENT_NAME}" == "pull_request" ]]; then + metadata=".github/termux-release.json" + if [[ ! -f "${metadata}" ]]; then + echo "${metadata} is required for release train PR builds" >&2 + exit 1 + fi + upstream_tag="$(jq -r '.upstream_tag // empty' "${metadata}")" + if [[ -z "${upstream_tag}" || "${upstream_tag}" != rust-v* ]]; then + echo "Unable to determine upstream rust tag from ${metadata}" >&2 + exit 1 + fi + build_version="${upstream_tag#rust-v}" + echo "build_version=${build_version}" >> "$GITHUB_OUTPUT" + elif [[ "${EVENT_NAME}" == "workflow_dispatch" ]]; then + latest_release_tag="$( + GH_TOKEN="${{ github.token }}" gh api repos/openai/codex/releases/latest --jq '.tag_name' + )" + + if [[ -z "${latest_release_tag}" || "${latest_release_tag}" != rust-v* ]]; then + echo "Unable to determine latest stable release tag from openai/codex" >&2 + exit 1 + fi + + latest_release_version="${latest_release_tag#rust-v}" + build_version="${latest_release_version}-dev" + echo "build_version=${build_version}" >> "$GITHUB_OUTPUT" + fi + + build: + if: >- + always() && + (github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' || needs.tag-check.result == 'success') && + needs.select-build-matrix.result == 'success' + needs: + - tag-check + - select-build-matrix + name: Build - ${{ matrix.runner }} - ${{ matrix.target }} + runs-on: ${{ matrix.runner }} + permissions: write-all + timeout-minutes: 120 defaults: run: working-directory: codex-rs env: - # 2026-03-04: temporarily change releases to use thin LTO because - # Ubuntu ARM is timing out at 60 minutes. - CARGO_PROFILE_RELEASE_LTO: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'thin' }} - SIGN_MACOS: ${{ github.event_name != 'workflow_dispatch' }} + CODEX_BWRAP_ENABLE_FFI: ${{ contains(matrix.target, 'unknown-linux') && '1' || '0' }} + CODEX_BUILD_VERSION: ${{ needs.select-build-matrix.outputs.build_version }} + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" strategy: fail-fast: false - matrix: - include: - - runner: macos-15-xlarge - target: aarch64-apple-darwin - bundle: primary - artifact_name: aarch64-apple-darwin - binaries: "codex codex-responses-api-proxy" - build_dmg: "true" - - runner: macos-15-xlarge - target: aarch64-apple-darwin - bundle: app-server - artifact_name: aarch64-apple-darwin-app-server - binaries: "codex-app-server" - build_dmg: "false" - - runner: macos-15-xlarge - target: x86_64-apple-darwin - bundle: primary - artifact_name: x86_64-apple-darwin - binaries: "codex codex-responses-api-proxy" - build_dmg: "true" - - runner: macos-15-xlarge - target: x86_64-apple-darwin - bundle: app-server - artifact_name: x86_64-apple-darwin-app-server - binaries: "codex-app-server" - build_dmg: "false" - # Release artifacts intentionally ship MUSL-linked Linux binaries. - - runner: codex-linux-x64-xl - target: x86_64-unknown-linux-musl - bundle: primary - artifact_name: x86_64-unknown-linux-musl - binaries: "codex codex-responses-api-proxy bwrap" - build_dmg: "false" - - runner: codex-linux-x64-xl - target: x86_64-unknown-linux-musl - bundle: app-server - artifact_name: x86_64-unknown-linux-musl-app-server - binaries: "codex-app-server" - build_dmg: "false" - - runner: codex-linux-arm64 - target: aarch64-unknown-linux-musl - bundle: primary - artifact_name: aarch64-unknown-linux-musl - binaries: "codex codex-responses-api-proxy bwrap" - build_dmg: "false" - - runner: codex-linux-arm64 - target: aarch64-unknown-linux-musl - bundle: app-server - artifact_name: aarch64-unknown-linux-musl-app-server - binaries: "codex-app-server" - build_dmg: "false" + matrix: ${{ fromJson(needs.select-build-matrix.outputs.matrix) }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@v6 + - name: 🧰 Actions Toolbox + # This is required for the GitHub CLI + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: wallentx/gh-actions/composite/actions-toolbox@main + with: + verbose: true + - name: Checkout selected source ref + if: ${{ github.event_name == 'workflow_dispatch' && inputs.source_ref != '' }} + uses: actions/checkout@v6 with: - persist-credentials: false - - name: Print runner specs (Linux) - if: ${{ runner.os == 'Linux' }} + ref: ${{ inputs.source_ref }} + path: __source__ + fetch-depth: 1 + - name: Overlay selected source ref (excluding .github) + if: ${{ github.event_name == 'workflow_dispatch' && inputs.source_ref != '' }} + shell: bash + working-directory: ${{ github.workspace }} + run: | + set -euo pipefail + echo "Using source ref: ${{ inputs.source_ref }}" + + # Keep workflow/actions from the dispatch ref while replacing all other files. + find . -mindepth 1 -maxdepth 1 \ + ! -name .git \ + ! -name .github \ + ! -name __source__ \ + -exec rm -rf -- {} + + tar --exclude='.github' -C __source__ -cf - . | tar -xf - + rm -rf __source__ + + - name: Apply selected build version + if: ${{ github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' }} shell: bash + working-directory: ${{ github.workspace }} run: | set -euo pipefail - cpu_model="$(lscpu | awk -F: '/Model name/ {gsub(/^[ \t]+/, "", $2); print $2; exit}')" - total_ram="$(awk '/MemTotal/ {printf "%.1f GiB\n", $2 / 1024 / 1024}' /proc/meminfo)" - echo "Runner: ${RUNNER_NAME:-unknown}" - echo "OS: $(uname -a)" - echo "CPU model: ${cpu_model}" - echo "Logical CPUs: $(nproc)" - echo "Total RAM: ${total_ram}" - echo "Disk usage:" - df -h . - - name: Print runner specs (macOS) - if: ${{ runner.os == 'macOS' }} + if [[ -z "${CODEX_BUILD_VERSION}" ]]; then + echo "CODEX_BUILD_VERSION is empty for workflow_dispatch" >&2 + exit 1 + fi + echo "Using build version: ${CODEX_BUILD_VERSION}" + BUILD_VERSION="${CODEX_BUILD_VERSION}" perl -0777 -i -pe \ + 's/(\[workspace\.package\][^\[]*?version = ")([^"]+)(")/${1}$ENV{BUILD_VERSION}$3/s' \ + codex-rs/Cargo.toml + + grep -n "^\[workspace.package\]\|^version = " codex-rs/Cargo.toml | head -n 4 + + - name: Select Android NDK + if: ${{ matrix.target == 'aarch64-linux-android' }} shell: bash run: | set -euo pipefail - total_ram="$(sysctl -n hw.memsize | awk '{printf "%.1f GiB\n", $1 / 1024 / 1024 / 1024}')" - echo "Runner: ${RUNNER_NAME:-unknown}" - echo "OS: $(sw_vers -productName) $(sw_vers -productVersion)" - echo "Hardware model: $(sysctl -n hw.model)" - echo "CPU architecture: $(uname -m)" - echo "Logical CPUs: $(sysctl -n hw.logicalcpu)" - echo "Physical CPUs: $(sysctl -n hw.physicalcpu)" - echo "Total RAM: ${total_ram}" - echo "Disk usage:" - df -h . + + fallback_version="29.0.13113456" + ndk_root="${ANDROID_HOME}/ndk" + selected_ndk="" + + if [[ -d "${ndk_root}" ]]; then + while IFS= read -r candidate; do + toolchain="${candidate}/toolchains/llvm/prebuilt/linux-x86_64" + if [[ -x "${toolchain}/bin/aarch64-linux-android24-clang" ]]; then + selected_ndk="${candidate}" + break + fi + done < <(find "${ndk_root}" -mindepth 1 -maxdepth 1 -type d | sort -Vr) + fi + + if [[ -z "${selected_ndk}" ]]; then + echo "No usable preinstalled Android NDK found; installing ${fallback_version}" + yes | sudo "${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager" --licenses || true + sudo "${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager" "ndk;${fallback_version}" + selected_ndk="${ndk_root}/${fallback_version}" + fi + + if [[ ! -x "${selected_ndk}/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android24-clang" ]]; then + echo "Selected Android NDK does not contain a usable aarch64 API 24 clang: ${selected_ndk}" >&2 + exit 1 + fi + + echo "Selected Android NDK: ${selected_ndk}" + "${selected_ndk}/toolchains/llvm/prebuilt/linux-x86_64/bin/clang" --version + echo "ANDROID_NDK_HOME=${selected_ndk}" >> "$GITHUB_ENV" + echo "NDK_HOME=${selected_ndk}" >> "$GITHUB_ENV" - name: Install Linux bwrap build dependencies - if: ${{ runner.os == 'Linux' }} + if: ${{ runner.os == 'Linux' && matrix.target != 'aarch64-linux-android' }} shell: bash run: | set -euo pipefail @@ -254,9 +294,173 @@ jobs: sudo apt-get update -y sudo DEBIAN_FRONTEND=noninteractive apt-get install -y libubsan1 fi - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: dtolnay/rust-toolchain@1.93 with: targets: ${{ matrix.target }} + - name: Set up sccache (Android) + if: ${{ matrix.target == 'aarch64-linux-android' }} + uses: mozilla-actions/sccache-action@main + - name: Enable sccache (Android) + if: ${{ matrix.target == 'aarch64-linux-android' }} + shell: bash + run: | + set -euo pipefail + echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV" + echo "SCCACHE_GHA_ENABLED=true" >> "$GITHUB_ENV" + echo "SCCACHE_GHA_VERSION=android-release-${{ matrix.target }}" >> "$GITHUB_ENV" + echo "SCCACHE_CACHE_SIZE=5G" >> "$GITHUB_ENV" + - name: Ensure Rust target is installed + shell: bash + run: | + set -euo pipefail + rustup target add "${{ matrix.target }}" + - if: ${{ matrix.target == 'aarch64-linux-android' }} + name: Configure Android build environment + shell: bash + run: | + set -euo pipefail + ndk="${ANDROID_NDK_HOME}" + target="${{ matrix.target }}" + + # Set up the Android toolchain + toolchain="${ndk}/toolchains/llvm/prebuilt/linux-x86_64" + + # Use API level 24 to follow termux-build convention + api_level="24" + + # Configure Cargo to use the NDK linker + cargo_target_var="CARGO_TARGET_${target^^}_LINKER" + cargo_target_var="${cargo_target_var//-/_}" + echo "${cargo_target_var}=${toolchain}/bin/aarch64-linux-android${api_level}-clang" >> "$GITHUB_ENV" + + # Set CC and AR for the target + target_cc_var="CC_${target}" + target_cc_var="${target_cc_var//-/_}" + echo "${target_cc_var}=${toolchain}/bin/aarch64-linux-android${api_level}-clang" >> "$GITHUB_ENV" + + target_cxx_var="CXX_${target}" + target_cxx_var="${target_cxx_var//-/_}" + echo "${target_cxx_var}=${toolchain}/bin/aarch64-linux-android${api_level}-clang++" >> "$GITHUB_ENV" + + target_ar_var="AR_${target}" + target_ar_var="${target_ar_var//-/_}" + echo "${target_ar_var}=${toolchain}/bin/llvm-ar" >> "$GITHUB_ENV" + + # Add toolchain to PATH + echo "${toolchain}/bin" >> "$GITHUB_PATH" + + tls_align_source="${RUNNER_TEMP}/codex-android-tls-align.S" + tls_align_object="${RUNNER_TEMP}/codex-android-tls-align.o" + cat > "${tls_align_source}" <<'EOF' + .section .tdata.codex_android_tls_align,"awT",%progbits + .p2align 6 + .globl __codex_android_tls_align + .hidden __codex_android_tls_align + .type __codex_android_tls_align, %object + __codex_android_tls_align: + .byte 0 + .size __codex_android_tls_align, 1 + EOF + "${toolchain}/bin/aarch64-linux-android${api_level}-clang" -c "${tls_align_source}" -o "${tls_align_object}" + + v8_compat_source="${RUNNER_TEMP}/codex-android-v8-compat.c" + v8_compat_object="${RUNNER_TEMP}/codex-android-v8-compat.o" + cat > "${v8_compat_source}" <<'EOF' + #include + #include + + extern int posix_memalign(void **memptr, size_t alignment, size_t size); + extern double strtod(const char *nptr, char **endptr); + extern float strtof(const char *nptr, char **endptr); + + void *aligned_alloc(size_t alignment, size_t size) { + void *ptr = 0; + if (posix_memalign(&ptr, alignment, size) != 0) { + return 0; + } + return ptr; + } + + double strtod_l(const char *nptr, char **endptr, locale_t locale) { + (void)locale; + return strtod(nptr, endptr); + } + + float strtof_l(const char *nptr, char **endptr, locale_t locale) { + (void)locale; + return strtof(nptr, endptr); + } + EOF + "${toolchain}/bin/aarch64-linux-android${api_level}-clang" -c "${v8_compat_source}" -o "${v8_compat_object}" + + builtins_archive="$( + find "${toolchain}/lib/clang" \ + -path "*/lib/linux/libclang_rt.builtins-aarch64-android.a" \ + -print -quit + )" + if [[ -z "${builtins_archive}" ]]; then + echo "Could not find Android compiler-rt builtins archive under ${toolchain}/lib/clang" >&2 + exit 1 + fi + builtins_dir="$(dirname "${builtins_archive}")" + + # Provide compatibility symbols and C++ ABI needed by the Android rusty_v8 archive. + rustflags_var="CARGO_TARGET_${target^^}_RUSTFLAGS" + rustflags_var="${rustflags_var//-/_}" + echo "${rustflags_var}=-C link-arg=${tls_align_object} -C link-arg=${v8_compat_object} -C link-arg=-Wl,-u,__codex_android_tls_align -C link-arg=-L${builtins_dir} -C link-arg=-lclang_rt.builtins-aarch64-android -C link-arg=-lc++abi -C link-arg=-Wl,-z,max-page-size=65536" >> "$GITHUB_ENV" + + + # Configure for vendored OpenSSL build + echo "CARGO_BUILD_TARGET_APPLIES_TO_HOST=false" >> "$GITHUB_ENV" + echo "CARGO_BUILD_TARGET=${{ matrix.target }}" >> "$GITHUB_ENV" + + - if: ${{ matrix.target == 'aarch64-linux-android' }} + name: Configure Android rusty_v8 artifact overrides + env: + GH_TOKEN: ${{ github.token }} + TARGET: ${{ matrix.target }} + shell: bash + run: | + set -euo pipefail + binding_dir="${RUNNER_TEMP}/rusty_v8" + archive="${binding_dir}/librusty_v8_release_${TARGET}.a.gz" + binding_path="${binding_dir}/src_binding_release_${TARGET}.rs" + checksums_path="${binding_dir}/rusty_v8_release_${TARGET}.sha256" + artifact_repository="wallentx/codex-termux" + release_tag="rusty-v8-v147.4.0" + mkdir -p "${binding_dir}" + echo "Downloading Android rusty_v8 artifacts from ${artifact_repository}@${release_tag}" + gh release download "${release_tag}" \ + --repo "${artifact_repository}" \ + --dir "${binding_dir}" \ + --pattern "librusty_v8_release_${TARGET}.a.gz" \ + --pattern "src_binding_release_${TARGET}.rs" \ + --pattern "rusty_v8_release_${TARGET}.sha256" + (cd "${binding_dir}" && sha256sum -c "$(basename "${checksums_path}")") + echo "RUSTY_V8_ARCHIVE=${archive}" >> "$GITHUB_ENV" + echo "RUSTY_V8_SRC_BINDING_PATH=${binding_path}" >> "$GITHUB_ENV" + + - if: ${{ matrix.target == 'aarch64-linux-android' }} + name: Install termux-elf-cleaner + shell: bash + run: | + set -euo pipefail + version="v3.0.1" + src_dir="${RUNNER_TEMP}/termux-elf-cleaner-src" + build_dir="${RUNNER_TEMP}/termux-elf-cleaner-build" + bin_dir="${RUNNER_TEMP}/termux-elf-cleaner-bin" + + rm -rf "${src_dir}" "${build_dir}" "${bin_dir}" + mkdir -p "${src_dir}" "${build_dir}" "${bin_dir}" + + curl -fsSL "https://github.com/termux/termux-elf-cleaner/archive/refs/tags/${version}.tar.gz" \ + | tar -xzf - --strip-components=1 -C "${src_dir}" + + cmake -S "${src_dir}" -B "${build_dir}" -DCMAKE_BUILD_TYPE=Release + cmake --build "${build_dir}" --parallel + + install "${build_dir}/termux-elf-cleaner" "${bin_dir}/termux-elf-cleaner" + echo "${bin_dir}" >> "$GITHUB_PATH" - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} name: Use hermetic Cargo home (musl) @@ -269,12 +473,25 @@ jobs: echo "${cargo_home}/bin" >> "$GITHUB_PATH" : > "${cargo_home}/config.toml" + - uses: actions/cache@v5 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + ${{ github.workspace }}/.cargo-home/bin/ + ${{ github.workspace }}/.cargo-home/registry/index/ + ${{ github.workspace }}/.cargo-home/registry/cache/ + ${{ github.workspace }}/.cargo-home/git/db/ + ${{ github.workspace }}/codex-rs/target/ + key: cargo-${{ matrix.runner }}-${{ matrix.target }}-release-${{ hashFiles('**/Cargo.lock') }} + - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} name: Install Zig - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 + uses: mlugg/setup-zig@v2 with: version: 0.14.0 - use-cache: false - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} name: Install musl build tools @@ -310,12 +527,6 @@ jobs: shell: bash run: | set -euo pipefail - # Avoid problematic aws-lc jitter entropy code path on musl builders. - echo "AWS_LC_SYS_NO_JITTER_ENTROPY=1" >> "$GITHUB_ENV" - target_no_jitter="AWS_LC_SYS_NO_JITTER_ENTROPY_${{ matrix.target }}" - target_no_jitter="${target_no_jitter//-/_}" - echo "${target_no_jitter}=1" >> "$GITHUB_ENV" - # Clear global Rust flags so host/proc-macro builds don't pull in UBSan. echo "RUSTFLAGS=" >> "$GITHUB_ENV" echo "CARGO_ENCODED_RUSTFLAGS=" >> "$GITHUB_ENV" @@ -340,94 +551,70 @@ jobs: echo "CFLAGS=${cflags}" >> "$GITHUB_ENV" echo "CXXFLAGS=${cxxflags}" >> "$GITHUB_ENV" - - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl' }} - name: Configure musl rusty_v8 artifact overrides and verify checksums - uses: ./.github/actions/setup-rusty-v8-musl - with: - target: ${{ matrix.target }} - - - if: ${{ contains(matrix.target, 'linux') }} - name: Build bwrap and export digest + - name: Cargo build shell: bash run: | set -euo pipefail - target="${{ matrix.target }}" - cargo build --target "$target" --release --timings --bin bwrap - bwrap_path="target/${target}/release/bwrap" - if [[ ! -f "$bwrap_path" ]]; then - echo "bwrap binary ${bwrap_path} not found" - exit 1 + if [[ "${{ matrix.target }}" == 'aarch64-linux-android' ]]; then + export CARGO_BUILD_JOBS=4 + export CARGO_PROFILE_RELEASE_LTO=thin + export CARGO_PROFILE_RELEASE_CODEGEN_UNITS=8 + cargo build --target ${{ matrix.target }} --release --bin codex + exit 0 fi - digest="$(sha256sum "$bwrap_path" | awk '{print $1}')" - echo "CODEX_BWRAP_SHA256=${digest}" >> "$GITHUB_ENV" - echo "Built bwrap ${bwrap_path} with sha256:${digest}" - - - name: Cargo build + if [[ "${{ contains(matrix.target, 'windows') }}" == 'true' ]]; then + cargo build --target ${{ matrix.target }} --release --bin codex --bin codex-responses-api-proxy --bin codex-windows-sandbox-setup --bin codex-command-runner + else + cargo build --target ${{ matrix.target }} --release --bin codex --bin codex-responses-api-proxy + fi + - name: sccache stats (Android) + if: ${{ matrix.target == 'aarch64-linux-android' }} shell: bash - run: | - build_args=() - for binary in ${{ matrix.binaries }}; do - build_args+=(--bin "$binary") - done - echo "CARGO_PROFILE_RELEASE_LTO: ${CARGO_PROFILE_RELEASE_LTO}" - cargo build --target ${{ matrix.target }} --release --timings "${build_args[@]}" - - - name: Upload Cargo timings - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: cargo-timings-rust-release-${{ matrix.target }}-${{ matrix.bundle }} - path: codex-rs/target/**/cargo-timings/cargo-timing.html - if-no-files-found: warn + run: ${SCCACHE_PATH:-sccache} --show-stats || true - - if: ${{ runner.os == 'macOS' && env.SIGN_MACOS != 'true' }} - name: Stage unsigned macOS artifacts + - if: ${{ matrix.target == 'aarch64-linux-android' }} + name: Normalize Android ELF for Termux shell: bash run: | set -euo pipefail - - target="${{ matrix.target }}" - release_dir="target/${target}/release" - dest="unsigned-dist/${target}" - mkdir -p "$dest" - - for binary in ${{ matrix.binaries }}; do - binary_path="${release_dir}/${binary}" - unsigned_name="${binary}-${target}-unsigned" - unsigned_path="${dest}/${unsigned_name}" - if [[ ! -f "${binary_path}" ]]; then - echo "Binary ${binary_path} not found" - exit 1 + for binary in codex codex-responses-api-proxy; do + binary_path="target/${{ matrix.target }}/release/${binary}" + if [[ -f "${binary_path}" ]]; then + termux-elf-cleaner --api-level 24 "${binary_path}" + chmod +x "${binary_path}" + # if [[ "${binary}" == "codex" ]]; then + # # Patch PT_TLS (0x7) to PT_NULL (0x0) at offset 400 to bypass Bionic alignment checks. + # printf '\x00\x00\x00\x00' | dd of="${binary_path}" bs=1 seek=400 count=4 conv=notrunc + # fi fi - - cp "${binary_path}" "${unsigned_path}" - tar -C "$dest" -czf "${unsigned_path}.tar.gz" "${unsigned_name}" - zstd -T0 -19 --rm "${unsigned_path}" done - - if: ${{ runner.os == 'macOS' && env.SIGN_MACOS != 'true' }} - name: Upload unsigned macOS artifacts - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: ${{ matrix.artifact_name }}-unsigned - path: codex-rs/unsigned-dist/${{ matrix.target }}/* - if-no-files-found: error - - - if: ${{ contains(matrix.target, 'linux') }} + - if: ${{ contains(matrix.target, 'linux') && !contains(matrix.target, 'android') && github.repository_owner == 'openai' }} name: Cosign Linux artifacts uses: ./.github/actions/linux-code-sign with: target: ${{ matrix.target }} artifacts-dir: ${{ github.workspace }}/codex-rs/target/${{ matrix.target }}/release - binaries: ${{ matrix.binaries }} - - if: ${{ runner.os == 'macOS' && env.SIGN_MACOS == 'true' }} + - if: ${{ contains(matrix.target, 'windows') && github.repository_owner == 'openai' }} + name: Sign Windows binaries with Azure Trusted Signing + uses: ./.github/actions/windows-code-sign + with: + target: ${{ matrix.target }} + client-id: ${{ secrets.AZURE_TRUSTED_SIGNING_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TRUSTED_SIGNING_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_TRUSTED_SIGNING_SUBSCRIPTION_ID }} + endpoint: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} + account-name: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} + certificate-profile-name: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} + + - if: ${{ runner.os == 'macOS' && github.repository_owner == 'openai' }} name: MacOS code signing (binaries) uses: ./.github/actions/macos-code-sign with: target: ${{ matrix.target }} - binaries: ${{ matrix.binaries }} sign-binaries: "true" sign-dmg: "false" apple-certificate: ${{ secrets.APPLE_CERTIFICATE_P12 }} @@ -436,7 +623,7 @@ jobs: apple-notarization-key-id: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} apple-notarization-issuer-id: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} - - if: ${{ runner.os == 'macOS' && matrix.build_dmg == 'true' && env.SIGN_MACOS == 'true' }} + - if: ${{ runner.os == 'macOS' && github.repository_owner == 'openai' }} name: Build macOS dmg shell: bash run: | @@ -451,17 +638,23 @@ jobs: # The previous "MacOS code signing (binaries)" step signs + notarizes the # built artifacts in `${release_dir}`. This step packages *those same* # signed binaries into a dmg. + codex_binary_path="${release_dir}/codex" + proxy_binary_path="${release_dir}/codex-responses-api-proxy" + rm -rf "$dmg_root" mkdir -p "$dmg_root" - for binary in ${{ matrix.binaries }}; do - binary_path="${release_dir}/${binary}" - if [[ ! -f "${binary_path}" ]]; then - echo "Binary ${binary_path} not found" - exit 1 - fi - ditto "${binary_path}" "${dmg_root}/${binary}" - done + if [[ ! -f "$codex_binary_path" ]]; then + echo "Binary $codex_binary_path not found" + exit 1 + fi + if [[ ! -f "$proxy_binary_path" ]]; then + echo "Binary $proxy_binary_path not found" + exit 1 + fi + + ditto "$codex_binary_path" "${dmg_root}/codex" + ditto "$proxy_binary_path" "${dmg_root}/codex-responses-api-proxy" rm -f "$dmg_path" hdiutil create \ @@ -476,7 +669,7 @@ jobs: exit 1 fi - - if: ${{ runner.os == 'macOS' && matrix.build_dmg == 'true' && env.SIGN_MACOS == 'true' }} + - if: ${{ runner.os == 'macOS' && github.repository_owner == 'openai' }} name: MacOS code signing (dmg) uses: ./.github/actions/macos-code-sign with: @@ -490,121 +683,65 @@ jobs: apple-notarization-issuer-id: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} - name: Stage artifacts - if: ${{ runner.os != 'macOS' || env.SIGN_MACOS == 'true' }} shell: bash run: | dest="dist/${{ matrix.target }}" mkdir -p "$dest" - for binary in ${{ matrix.binaries }}; do - cp "target/${{ matrix.target }}/release/${binary}" "$dest/${binary}-${{ matrix.target }}" - if [[ "${{ matrix.target }}" == *linux* ]]; then - cp "target/${{ matrix.target }}/release/${binary}.sigstore" \ - "$dest/${binary}-${{ matrix.target }}.sigstore" + if [[ "${{ matrix.runner }}" == windows* ]]; then + cp target/${{ matrix.target }}/release/codex.exe "$dest/codex-${{ matrix.target }}.exe" + cp target/${{ matrix.target }}/release/codex-responses-api-proxy.exe "$dest/codex-responses-api-proxy-${{ matrix.target }}.exe" + cp target/${{ matrix.target }}/release/codex-windows-sandbox-setup.exe "$dest/codex-windows-sandbox-setup-${{ matrix.target }}.exe" + cp target/${{ matrix.target }}/release/codex-command-runner.exe "$dest/codex-command-runner-${{ matrix.target }}.exe" + else + # For Android, we want the binary to be named just 'codex' in the archive. + if [[ "${{ matrix.target }}" == "aarch64-linux-android" ]]; then + cp target/${{ matrix.target }}/release/codex "$dest/codex" + else + cp target/${{ matrix.target }}/release/codex "$dest/codex-${{ matrix.target }}" + fi + if [[ -f target/${{ matrix.target }}/release/codex-responses-api-proxy ]]; then + cp target/${{ matrix.target }}/release/codex-responses-api-proxy "$dest/codex-responses-api-proxy-${{ matrix.target }}" fi - done - - if [[ "${{ matrix.target }}" == *linux* && "${{ matrix.bundle }}" == "primary" ]]; then - bundle_root="${RUNNER_TEMP}/codex-${{ matrix.target }}-bundle" - rm -rf "$bundle_root" - mkdir -p "$bundle_root/codex-resources" - cp "$dest/codex-${{ matrix.target }}" "$bundle_root/codex" - cp "$dest/bwrap-${{ matrix.target }}" "$bundle_root/codex-resources/bwrap" - chmod 0755 "$bundle_root/codex" "$bundle_root/codex-resources/bwrap" - tar -C "$bundle_root" -cf - codex codex-resources/bwrap | - zstd -T0 -19 -o "$dest/codex-${{ matrix.target }}-bundle.tar.zst" fi - if [[ "${{ matrix.build_dmg }}" == "true" ]]; then - cp target/${{ matrix.target }}/release/codex-${{ matrix.target }}.dmg "$dest/codex-${{ matrix.target }}.dmg" + if [[ "${{ matrix.target }}" == *linux* && "${{ matrix.target }}" != *android* ]]; then + cp target/${{ matrix.target }}/release/codex.sigstore "$dest/codex-${{ matrix.target }}.sigstore" + cp target/${{ matrix.target }}/release/codex-responses-api-proxy.sigstore "$dest/codex-responses-api-proxy-${{ matrix.target }}.sigstore" fi - - name: Build Codex package archive - if: ${{ runner.os != 'macOS' || env.SIGN_MACOS == 'true' }} - shell: bash - env: - TARGET: ${{ matrix.target }} - BUNDLE: ${{ matrix.bundle }} - run: | - set -euo pipefail - bash "${GITHUB_WORKSPACE}/.github/scripts/build-codex-package-archive.sh" \ - --target "$TARGET" \ - --bundle "$BUNDLE" \ - --entrypoint-dir "target/${TARGET}/release" \ - --archive-dir "dist/${TARGET}" - - - name: Build Python runtime wheel - if: ${{ matrix.bundle == 'primary' && (runner.os != 'macOS' || env.SIGN_MACOS == 'true') }} - shell: bash - run: | - set -euo pipefail - - case "${{ matrix.target }}" in - aarch64-apple-darwin) - platform_tag="macosx_11_0_arm64" - ;; - x86_64-apple-darwin) - platform_tag="macosx_10_9_x86_64" - ;; - aarch64-unknown-linux-musl) - platform_tag="manylinux_2_17_aarch64" - ;; - x86_64-unknown-linux-musl) - platform_tag="manylinux_2_17_x86_64" - ;; - *) - echo "No Python runtime wheel platform tag for ${{ matrix.target }}" - exit 1 - ;; - esac - - python3 -m venv "${RUNNER_TEMP}/python-runtime-build-venv" - # Do not install into the runner's system Python; macOS runners mark - # the Homebrew Python as externally managed under PEP 668. - "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m pip install build - - stage_dir="${RUNNER_TEMP}/openai-codex-cli-bin-${{ matrix.target }}" - wheel_dir="${GITHUB_WORKSPACE}/python-runtime-dist/${{ matrix.target }}" - stage_runtime_args=( - "${GITHUB_WORKSPACE}/sdk/python/scripts/update_sdk_artifacts.py" - stage-runtime - "$stage_dir" - "${GITHUB_WORKSPACE}/codex-rs/target/${{ matrix.target }}/release/codex" - --codex-version "${GITHUB_REF_NAME}" - --platform-tag "$platform_tag" - ) - if [[ "${{ matrix.target }}" == *linux* ]]; then - # Keep bwrap in the runtime wheel so Linux sandbox fallback behavior - # matches the standalone release bundle on hosts without system bwrap. - stage_runtime_args+=( - --resource-binary - "${GITHUB_WORKSPACE}/codex-rs/target/${{ matrix.target }}/release/bwrap" - ) + if [[ "${{ matrix.target }}" == *apple-darwin ]]; then + cp target/${{ matrix.target }}/release/codex-${{ matrix.target }}.dmg "$dest/codex-${{ matrix.target }}.dmg" fi - python3 "${stage_runtime_args[@]}" - "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m build --wheel --outdir "$wheel_dir" "$stage_dir" - - name: Upload Python runtime wheel - if: ${{ matrix.bundle == 'primary' && (runner.os != 'macOS' || env.SIGN_MACOS == 'true') }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: python-runtime-wheel-${{ matrix.target }} - path: python-runtime-dist/${{ matrix.target }}/*.whl - if-no-files-found: error + - if: ${{ matrix.runner == 'windows-11-arm' }} + name: Install zstd + shell: powershell + run: choco install -y zstandard - name: Compress artifacts - if: ${{ runner.os != 'macOS' || env.SIGN_MACOS == 'true' }} shell: bash run: | # Path that contains the uncompressed binaries for the current # ${{ matrix.target }} dest="dist/${{ matrix.target }}" + repo_root=$PWD + + # We want to ship the raw Windows executables in the GitHub Release + # in addition to the compressed archives. Keep the originals for + # Windows targets; remove them elsewhere to limit the number of + # artifacts that end up in the GitHub Release. + keep_originals=false + if [[ "${{ matrix.runner }}" == windows* ]]; then + keep_originals=true + fi # For compatibility with environments that lack the `zstd` tool we - # additionally create a `.tar.gz` alongside every binary we publish. - # The end result is: + # additionally create a `.tar.gz` for all platforms and `.zip` for + # Windows alongside every single binary that we publish. The end result is: # codex-.zst (existing) # codex-.tar.gz (new) + # codex-.zip (only for Windows) # 1. Produce a .tar.gz for every file in the directory *before* we # run `zstd --rm`, because that flag deletes the original files. @@ -612,7 +749,7 @@ jobs: base="$(basename "$f")" # Skip files that are already archives (shouldn't happen, but be # safe). - if [[ "$base" == *.tar.gz || "$base" == *.tar.zst || "$base" == *.zip || "$base" == *.dmg ]]; then + if [[ "$base" == *.tar.gz || "$base" == *.zip || "$base" == *.dmg ]]; then continue fi @@ -622,344 +759,239 @@ jobs: fi # Create per-binary tar.gz - tar -C "$dest" -czf "$dest/${base}.tar.gz" "$base" - - # Also create .zst and remove the uncompressed binaries to keep - # non-Windows artifact directories small. - zstd -T0 -19 --rm "$dest/$base" - done - - - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - if: ${{ runner.os != 'macOS' || env.SIGN_MACOS == 'true' }} - with: - name: ${{ matrix.artifact_name }} - # Upload the per-binary .zst files, .tar.gz equivalents, and any - # prebuilt archives staged above. - path: | - codex-rs/dist/${{ matrix.target }}/* - - stage-signed-macos: - if: ${{ github.event_name == 'workflow_dispatch' && inputs.release_mode == 'promote_signed' }} - needs: tag-check - name: Stage signed macOS handoff - ${{ matrix.target }} - ${{ matrix.bundle }} - runs-on: macos-15-xlarge - timeout-minutes: 30 - permissions: - contents: read - defaults: - run: - working-directory: codex-rs + # For Android, we want the archive to have the target suffix even though the binary is just 'codex'. + archive_name="${base}" + if [[ "${{ matrix.target }}" == "aarch64-linux-android" && "${base}" == "codex" ]]; then + archive_name="codex-${{ matrix.target }}" + fi + tar -C "$dest" -czf "$dest/${archive_name}.tar.gz" "$base" + + # Create zip archive for Windows binaries + # Must run from inside the dest dir so 7z won't + # embed the directory path inside the zip. + if [[ "${{ matrix.runner }}" == windows* ]]; then + if [[ "$base" == "codex-${{ matrix.target }}.exe" ]]; then + # Bundle the sandbox helper binaries into the main codex zip so + # WinGet installs include the required helpers next to codex.exe. + # Fall back to the single-binary zip if the helpers are missing + # to avoid breaking releases. + bundle_dir="$(mktemp -d)" + runner_src="$dest/codex-command-runner-${{ matrix.target }}.exe" + setup_src="$dest/codex-windows-sandbox-setup-${{ matrix.target }}.exe" + if [[ -f "$runner_src" && -f "$setup_src" ]]; then + cp "$dest/$base" "$bundle_dir/$base" + cp "$runner_src" "$bundle_dir/codex-command-runner.exe" + cp "$setup_src" "$bundle_dir/codex-windows-sandbox-setup.exe" + # Use an absolute path so bundle zips land in the real dist + # dir even when 7z runs from a temp directory. + (cd "$bundle_dir" && 7z a "$repo_root/$dest/${base}.zip" .) + else + echo "warning: missing sandbox binaries; falling back to single-binary zip" + echo "warning: expected $runner_src and $setup_src" + (cd "$dest" && 7z a "${base}.zip" "$base") + fi + rm -rf "$bundle_dir" + else + (cd "$dest" && 7z a "${base}.zip" "$base") + fi + fi - strategy: - fail-fast: false - matrix: - include: - - target: aarch64-apple-darwin - bundle: primary - artifact_name: aarch64-apple-darwin - binaries: "codex codex-responses-api-proxy" - build_dmg: "false" - - target: aarch64-apple-darwin - bundle: app-server - artifact_name: aarch64-apple-darwin-app-server - binaries: "codex-app-server" - build_dmg: "false" - - target: x86_64-apple-darwin - bundle: primary - artifact_name: x86_64-apple-darwin - binaries: "codex codex-responses-api-proxy" - build_dmg: "false" - - target: x86_64-apple-darwin - bundle: app-server - artifact_name: x86_64-apple-darwin-app-server - binaries: "codex-app-server" - build_dmg: "false" + # Also create .zst (existing behaviour) *and* remove the original + # uncompressed binary to keep the directory small. + zstd_args=(-T0 -19) + if [[ "${keep_originals}" == false ]]; then + zstd_args+=(--rm) + fi - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false + if [[ "${archive_name}" != "${base}" ]]; then + zstd "${zstd_args[@]}" "$dest/$base" -o "$dest/${archive_name}.zst" + else + zstd "${zstd_args[@]}" "$dest/$base" + fi + done - - name: Download signed macOS handoff + - name: Add Termux release metadata + if: ${{ (github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch') && matrix.target == 'aarch64-linux-android' }} shell: bash - env: - GH_TOKEN: ${{ github.token }} - SIGNED_MACOS_ASSET: ${{ inputs.signed_macos_asset }} - SIGNED_MACOS_SHA256: ${{ inputs.signed_macos_sha256 }} run: | set -euo pipefail - - download_dir="${RUNNER_TEMP}/signed-macos-download" - handoff_dir="${RUNNER_TEMP}/signed-macos-handoff" - rm -rf "$download_dir" "$handoff_dir" - mkdir -p "$download_dir" "$handoff_dir" - - gh release download "$GITHUB_REF_NAME" \ - --repo "$GITHUB_REPOSITORY" \ - --pattern "$SIGNED_MACOS_ASSET" \ - --dir "$download_dir" - - asset_count="$(find "$download_dir" -maxdepth 1 -type f | wc -l | tr -d '[:space:]')" - if [[ "$asset_count" != "1" ]]; then - echo "Expected exactly one signed macOS handoff asset named ${SIGNED_MACOS_ASSET}; found ${asset_count}" - find "$download_dir" -maxdepth 1 -type f -print - exit 1 - fi - - asset_path="$(find "$download_dir" -maxdepth 1 -type f -print -quit)" - if [[ -n "${SIGNED_MACOS_SHA256}" ]]; then - expected_sha="$(printf '%s' "$SIGNED_MACOS_SHA256" | tr '[:upper:]' '[:lower:]')" - actual_sha="$(shasum -a 256 "$asset_path" | awk '{print $1}')" - if [[ "$actual_sha" != "$expected_sha" ]]; then - echo "signed_macos_sha256 mismatch for ${SIGNED_MACOS_ASSET}" - echo "expected: ${expected_sha}" - echo "actual: ${actual_sha}" - exit 1 - fi + if [[ ! -f "${GITHUB_WORKSPACE}/.github/termux-release.json" ]]; then + echo "No Termux release metadata found; skipping metadata attachment." + exit 0 fi + dest="dist/${{ matrix.target }}" + cp "${GITHUB_WORKSPACE}/.github/termux-release.json" "${dest}/termux-release.json" + ( + cd "${dest}" + sha256sum ./* > SHA256SUMS + ) - asset_name="$(basename "$asset_path")" - case "$asset_name" in - *.tar.zst) - zstd -dc "$asset_path" | tar -C "$handoff_dir" -xf - - ;; - *.tar.gz|*.tgz) - tar -C "$handoff_dir" -xzf "$asset_path" - ;; - *.zip) - ditto -x -k "$asset_path" "$handoff_dir" - ;; - *) - echo "Unsupported signed macOS handoff archive format: ${asset_name}" - exit 1 - ;; - esac - - echo "SIGNED_MACOS_HANDOFF_DIR=$handoff_dir" >> "$GITHUB_ENV" + - id: upload-artifact + uses: actions/upload-artifact@v6 + with: + name: ${{ github.event_name == 'pull_request' && format('termux-android-pr-{0}-{1}', github.event.pull_request.number, github.event.pull_request.head.sha) || matrix.target }} + # Upload the per-binary .zst files as well as the new .tar.gz + # equivalents we generated in the previous step. + path: | + codex-rs/dist/${{ matrix.target }}/* - - name: Stage signed macOS artifacts + - name: Publish Termux artifact notification + if: ${{ github.event_name == 'pull_request' && startsWith(github.base_ref, 'release/') && matrix.target == 'aarch64-linux-android' }} shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ARTIFACT_ID: ${{ steps.upload-artifact.outputs.artifact-id }} + ARTIFACT_NAME: ${{ github.event_name == 'pull_request' && format('termux-android-pr-{0}-{1}', github.event.pull_request.number, github.event.pull_request.head.sha) || matrix.target }} + PR_NUMBER: ${{ github.event.pull_request.number }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} run: | set -euo pipefail - - target="${{ matrix.target }}" - artifact_name="${{ matrix.artifact_name }}" - source_dir="${SIGNED_MACOS_HANDOFF_DIR}/${artifact_name}" - if [[ ! -d "$source_dir" && -d "${SIGNED_MACOS_HANDOFF_DIR}/dist/${artifact_name}" ]]; then - source_dir="${SIGNED_MACOS_HANDOFF_DIR}/dist/${artifact_name}" - fi - if [[ ! -d "$source_dir" && -d "${SIGNED_MACOS_HANDOFF_DIR}/${target}" ]]; then - source_dir="${SIGNED_MACOS_HANDOFF_DIR}/${target}" - fi - if [[ ! -d "$source_dir" && -d "${SIGNED_MACOS_HANDOFF_DIR}/dist/${target}" ]]; then - source_dir="${SIGNED_MACOS_HANDOFF_DIR}/dist/${target}" - fi - if [[ ! -d "$source_dir" ]]; then - echo "Signed macOS handoff is missing ${artifact_name}/" - echo "Expected either:" - echo " ${SIGNED_MACOS_HANDOFF_DIR}/${artifact_name}" - echo " ${SIGNED_MACOS_HANDOFF_DIR}/dist/${artifact_name}" - echo " ${SIGNED_MACOS_HANDOFF_DIR}/${target}" - echo " ${SIGNED_MACOS_HANDOFF_DIR}/dist/${target}" - find "$SIGNED_MACOS_HANDOFF_DIR" -maxdepth 3 -type f -print + if [[ -z "${ARTIFACT_ID}" ]]; then + echo "upload-artifact did not return an artifact id" >&2 exit 1 fi - dest="dist/${target}" - mkdir -p "$dest" - - for binary in ${{ matrix.binaries }}; do - source_path="${source_dir}/${binary}" - if [[ ! -f "$source_path" ]]; then - source_path="${source_dir}/${binary}-${target}" - fi - if [[ ! -f "$source_path" ]]; then - echo "Signed macOS handoff is missing ${binary} for ${artifact_name}" - exit 1 - fi - - release_path="${dest}/${binary}-${target}" - ditto "$source_path" "$release_path" - chmod 0755 "$release_path" - codesign --verify --strict --verbose=2 "$release_path" - done - - # DMG staging is disabled for signed promotion because we no longer - # distribute DMGs from this release path. Keep the branch here so the - # handoff can opt back in by flipping matrix.build_dmg if needed. - if [[ "${{ matrix.build_dmg }}" == "true" ]]; then - dmg_name="codex-${target}.dmg" - dmg_source="${source_dir}/${dmg_name}" - if [[ ! -f "$dmg_source" ]]; then - echo "Signed macOS handoff is missing ${dmg_name} for ${artifact_name}" - exit 1 - fi - - codesign --verify --strict --verbose=2 "$dmg_source" - xcrun stapler validate "$dmg_source" - cp "$dmg_source" "$dest/$dmg_name" + marker="" + artifact_url="https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/artifacts/${ARTIFACT_ID}" + run_url="${GH_WORKFLOW_URL:-https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}}" + body_path="${RUNNER_TEMP}/termux-artifact-comment.md" + { + echo "${marker}" + echo "Termux Android artifact ready for testing:" + echo + echo "- Artifact: [${ARTIFACT_NAME}](${artifact_url})" + echo "- Workflow run: [${GITHUB_RUN_ID}](${run_url})" + echo + echo "You must be signed in to GitHub with repository access to download Actions artifacts." + } > "${body_path}" + + existing_comment_id="$( + gh pr view "${PR_NUMBER}" \ + --repo "${GITHUB_REPOSITORY}" \ + --json comments \ + --jq ".comments | map(select(.author.is_bot == true and (.body | contains(\"${marker}\")))) | .[-1].id // \"\"" + )" + if [[ -n "${existing_comment_id}" ]]; then + gh api graphql \ + -f query=' + mutation($id: ID!, $body: String!) { + updateIssueComment(input: {id: $id, body: $body}) { + issueComment { + id + } + } + } + ' \ + -f id="${existing_comment_id}" \ + -f body="$(cat "${body_path}")" \ + >/dev/null + else + gh pr comment "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" --body-file "${body_path}" fi - - name: Build Python runtime wheel - if: ${{ matrix.bundle == 'primary' }} - shell: bash - run: | - set -euo pipefail - - case "${{ matrix.target }}" in - aarch64-apple-darwin) - platform_tag="macosx_11_0_arm64" - ;; - x86_64-apple-darwin) - platform_tag="macosx_10_9_x86_64" - ;; - *) - echo "No Python runtime wheel platform tag for ${{ matrix.target }}" - exit 1 - ;; - esac - - python3 -m venv "${RUNNER_TEMP}/python-runtime-build-venv" - "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m pip install build - - stage_dir="${RUNNER_TEMP}/openai-codex-cli-bin-${{ matrix.target }}" - wheel_dir="${GITHUB_WORKSPACE}/python-runtime-dist/${{ matrix.target }}" - python3 \ - "${GITHUB_WORKSPACE}/sdk/python/scripts/update_sdk_artifacts.py" \ - stage-runtime \ - "$stage_dir" \ - "${GITHUB_WORKSPACE}/codex-rs/dist/${{ matrix.target }}/codex-${{ matrix.target }}" \ - --codex-version "${GITHUB_REF_NAME}" \ - --platform-tag "$platform_tag" - "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m build --wheel --outdir "$wheel_dir" "$stage_dir" - - - name: Build Codex package archive - shell: bash - env: - TARGET: ${{ matrix.target }} - BUNDLE: ${{ matrix.bundle }} - run: | - set -euo pipefail - bash "${GITHUB_WORKSPACE}/.github/scripts/build-codex-package-archive.sh" \ - --target "$TARGET" \ - --bundle "$BUNDLE" \ - --entrypoint-dir "dist/${TARGET}" \ - --archive-dir "dist/${TARGET}" \ - --target-suffixed-entrypoint - - - name: Upload Python runtime wheel - if: ${{ matrix.bundle == 'primary' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: python-runtime-wheel-${{ matrix.target }} - path: python-runtime-dist/${{ matrix.target }}/*.whl - if-no-files-found: error - - - name: Compress artifacts - shell: bash - run: | - set -euo pipefail - - dest="dist/${{ matrix.target }}" - for f in "$dest"/*; do - base="$(basename "$f")" - if [[ "$base" == *.tar.gz || "$base" == *.tar.zst || "$base" == *.zip || "$base" == *.dmg ]]; then - continue + gh label create binary-ready \ + --repo "${GITHUB_REPOSITORY}" \ + --color 0e8a16 \ + --description "Android binary is ready for testing" \ + --force + gh pr edit "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" --add-label binary-ready + + if [[ -n "${SLACK_WEBHOOK_URL:-}" ]]; then + slack_payload_path="${RUNNER_TEMP}/termux-artifact-slack.json" + pr_url="https://github.com/${GITHUB_REPOSITORY}/pull/${PR_NUMBER}" + jq -n \ + --arg artifact_name "${ARTIFACT_NAME}" \ + --arg artifact_url "${artifact_url}" \ + --arg pr_number "${PR_NUMBER}" \ + --arg pr_url "${pr_url}" \ + --arg run_id "${GITHUB_RUN_ID}" \ + --arg run_url "${run_url}" \ + '{ + text: ("Termux Android artifact ready for testing: " + $artifact_url), + blocks: [ + { + type: "section", + text: { + type: "mrkdwn", + text: ("*Termux Android artifact ready for testing*\n" + $artifact_url) + } + }, + { + type: "section", + fields: [ + { + type: "mrkdwn", + text: ("*Artifact:*\n<" + $artifact_url + "|" + $artifact_name + ">") + }, + { + type: "mrkdwn", + text: ("*Pull request:*\n<" + $pr_url + "|#" + $pr_number + ">") + }, + { + type: "mrkdwn", + text: ("*Workflow run:*\n<" + $run_url + "|" + $run_id + ">") + } + ] + }, + { + type: "context", + elements: [ + { + type: "mrkdwn", + text: "You must be signed in to GitHub with repository access to download Actions artifacts." + } + ] + } + ] + }' > "${slack_payload_path}" + + if ! curl -fsS \ + -X POST \ + -H "Content-type: application/json" \ + --data @"${slack_payload_path}" \ + "${SLACK_WEBHOOK_URL}"; then + echo "::warning::Failed to send Slack artifact notification" fi + fi - tar -C "$dest" -czf "$dest/${base}.tar.gz" "$base" - zstd -T0 -19 --rm "$dest/$base" - done - - - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: ${{ matrix.artifact_name }} - path: | - codex-rs/dist/${{ matrix.target }}/* - - build-windows: - if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} - needs: tag-check - uses: ./.github/workflows/rust-release-windows.yml - with: - release-lto: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'fat' }} - secrets: inherit - - argument-comment-lint-release-assets: - if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} - name: argument-comment-lint release assets + shell-tool-mcp: + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && github.repository_owner == 'openai' + name: shell-tool-mcp needs: tag-check - uses: ./.github/workflows/rust-release-argument-comment-lint.yml + permissions: + contents: read + id-token: write + uses: ./.github/workflows/shell-tool-mcp.yml with: + release-tag: ${{ github.ref_name }} publish: true - - zsh-release-assets: - if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} - name: zsh release assets - needs: tag-check - uses: ./.github/workflows/rust-release-zsh.yml + secrets: inherit release: + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') needs: - - tag-check - build - - stage-signed-macos - - build-windows - - argument-comment-lint-release-assets - - zsh-release-assets - if: >- - ${{ - always() && - needs.tag-check.result == 'success' && - ( - ( - github.event_name == 'workflow_dispatch' && - inputs.release_mode == 'promote_signed' && - needs.stage-signed-macos.result == 'success' && - needs.build.result == 'skipped' && - needs.build-windows.result == 'skipped' && - needs.argument-comment-lint-release-assets.result == 'skipped' && - needs.zsh-release-assets.result == 'skipped' - ) || - ( - (github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed') && - needs.build.result == 'success' && - needs.stage-signed-macos.result == 'skipped' && - needs.build-windows.result == 'success' && - needs.argument-comment-lint-release-assets.result == 'success' && - needs.zsh-release-assets.result == 'success' - ) - ) - }} + - shell-tool-mcp name: release runs-on: ubuntu-latest permissions: contents: write actions: read - env: - RELEASE_MODE: ${{ github.event_name == 'workflow_dispatch' && inputs.release_mode || 'signed' }} - SIGN_MACOS: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode == 'promote_signed' }} - SIGNED_MACOS_ASSET: ${{ inputs.signed_macos_asset }} - UNSIGNED_RUN_ID: ${{ inputs.unsigned_run_id }} outputs: version: ${{ steps.release_name.outputs.name }} tag: ${{ github.ref_name }} - sign_macos: ${{ steps.release_mode.outputs.sign_macos }} should_publish_npm: ${{ steps.npm_publish_settings.outputs.should_publish }} npm_tag: ${{ steps.npm_publish_settings.outputs.npm_tag }} - should_publish_python_runtime: ${{ steps.python_runtime_publish_settings.outputs.should_publish }} steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false + uses: actions/checkout@v6 - - name: Define release mode - id: release_mode - run: | - echo "release_mode=${RELEASE_MODE}" >> "$GITHUB_OUTPUT" - echo "sign_macos=${SIGN_MACOS}" >> "$GITHUB_OUTPUT" + - name: 🧰 Actions Toolbox + # This is required for the GitHub CLI + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: wallentx/gh-actions/composite/actions-toolbox@main - name: Generate release notes from tag commit message id: release_notes @@ -981,159 +1013,21 @@ jobs: echo "path=${notes_path}" >> "${GITHUB_OUTPUT}" - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + - uses: actions/download-artifact@v7 with: path: dist - - name: Validate unsigned build run - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - - run_summary="$(gh run view "$UNSIGNED_RUN_ID" \ - --repo "$GITHUB_REPOSITORY" \ - --json conclusion,event,headBranch,headSha,status,workflowName,url \ - --jq '[.workflowName, .event, .headBranch, .headSha, .status, .conclusion, .url] | @tsv')" - IFS=$'\t' read -r workflow_name event head_branch head_sha status conclusion run_url <<< "$run_summary" - expected_head_sha="$(git rev-parse "${GITHUB_SHA}^{commit}")" - - if [[ "$workflow_name" != "$GITHUB_WORKFLOW" ]]; then - echo "unsigned_run_id ${UNSIGNED_RUN_ID} is for workflow '${workflow_name}', expected '${GITHUB_WORKFLOW}'" - echo "Run URL: ${run_url}" - exit 1 - fi - - if [[ "$event" != "workflow_dispatch" ]]; then - echo "unsigned_run_id ${UNSIGNED_RUN_ID} was triggered by '${event}', expected 'workflow_dispatch'" - echo "Run URL: ${run_url}" - exit 1 - fi - - if [[ "$head_branch" != "$GITHUB_REF_NAME" ]]; then - echo "unsigned_run_id ${UNSIGNED_RUN_ID} used ref '${head_branch}', expected '${GITHUB_REF_NAME}'" - echo "Run URL: ${run_url}" - exit 1 - fi - - if [[ "$head_sha" != "$expected_head_sha" ]]; then - echo "unsigned_run_id ${UNSIGNED_RUN_ID} used head SHA '${head_sha}', expected '${expected_head_sha}'" - echo "Run URL: ${run_url}" - exit 1 - fi - - if [[ "$status" != "completed" || "$conclusion" != "success" ]]; then - echo "unsigned_run_id ${UNSIGNED_RUN_ID} is ${status}/${conclusion}, expected completed/success" - echo "Run URL: ${run_url}" - exit 1 - fi - - - name: Download artifacts from unsigned build run - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - gh run download "$UNSIGNED_RUN_ID" \ - --repo "$GITHUB_REPOSITORY" \ - --dir dist - - - name: Remove unsigned macOS staging artifacts - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - run: | - set -euo pipefail - find dist -mindepth 1 -maxdepth 1 -type d \ - -name '*-apple-darwin*-unsigned' \ - -exec rm -rf {} + - - - name: Re-upload promoted Linux x64 artifacts - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: x86_64-unknown-linux-musl - path: dist/x86_64-unknown-linux-musl/* - if-no-files-found: error - - - name: Re-upload promoted Linux arm64 artifacts - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: aarch64-unknown-linux-musl - path: dist/aarch64-unknown-linux-musl/* - if-no-files-found: error - - - name: Re-upload promoted Windows x64 artifacts - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: x86_64-pc-windows-msvc - path: dist/x86_64-pc-windows-msvc/* - if-no-files-found: error - - - name: Re-upload promoted Windows arm64 artifacts - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: aarch64-pc-windows-msvc - path: dist/aarch64-pc-windows-msvc/* - if-no-files-found: error - - name: List run: ls -R dist/ - - name: Prune artifacts excluded from unsigned macOS release - if: ${{ env.SIGN_MACOS == 'false' }} - run: | - find dist -mindepth 1 -maxdepth 1 -type d \ - ! -name '*-apple-darwin*-unsigned' \ - ! -name 'aarch64-unknown-linux-musl' \ - ! -name 'aarch64-unknown-linux-musl-app-server' \ - ! -name 'x86_64-unknown-linux-musl' \ - ! -name 'x86_64-unknown-linux-musl-app-server' \ - ! -name 'aarch64-pc-windows-msvc' \ - ! -name 'x86_64-pc-windows-msvc' \ - -exec rm -rf {} + - - if ! find dist -type f -name '*-apple-darwin*-unsigned*' | grep -q .; then - echo "No unsigned macOS artifacts found in downloaded workflow artifacts." - exit 1 - fi - + # This is a temporary fix: we should modify shell-tool-mcp.yml so these + # files do not end up in dist/ in the first place. - name: Delete entries from dist/ that should not go in the release run: | - rm -rf dist/windows-binaries* - # cargo-timing.html appears under multiple target-specific directories. - # If included in files: dist/**, release upload races on duplicate - # asset names and can fail with 404s. - find dist -type f -name 'cargo-timing.html' -delete - find dist -type d -empty -delete + rm -rf dist/shell-tool-mcp* ls -R dist/ - - name: Add Codex package checksum manifest - run: | - set -euo pipefail - - manifest="dist/codex-package_SHA256SUMS" - tmp_manifest="$(mktemp)" - find dist -type f \ - \( -name 'codex-package-*.tar.gz' -o -name 'codex-app-server-package-*.tar.gz' \) \ - -print | - sort | - while IFS= read -r archive; do - sha256sum "$archive" | - awk -v name="$(basename "$archive")" '{ print $1 " " name }' - done > "$tmp_manifest" - - if [[ ! -s "$tmp_manifest" ]]; then - echo "No Codex package archives found for checksum manifest" - exit 1 - fi - - mv "$tmp_manifest" "$manifest" - cat "$manifest" - - name: Add config schema release asset run: | cp codex-rs/core/config.schema.json dist/config-schema.json @@ -1154,12 +1048,6 @@ jobs: set -euo pipefail version="${VERSION}" - if [[ "${SIGN_MACOS}" != "true" ]]; then - echo "should_publish=false" >> "$GITHUB_OUTPUT" - echo "npm_tag=" >> "$GITHUB_OUTPUT" - exit 0 - fi - if [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "should_publish=true" >> "$GITHUB_OUTPUT" echo "npm_tag=" >> "$GITHUB_OUTPUT" @@ -1171,130 +1059,55 @@ jobs: echo "npm_tag=" >> "$GITHUB_OUTPUT" fi - - name: Determine Python runtime publish settings - id: python_runtime_publish_settings - env: - VERSION: ${{ steps.release_name.outputs.name }} - run: | - set -euo pipefail - version="${VERSION}" - - if [[ "${SIGN_MACOS}" != "true" ]]; then - echo "should_publish=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - if [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "should_publish=true" >> "$GITHUB_OUTPUT" - elif [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+-alpha\.[0-9]+$ ]]; then - echo "should_publish=true" >> "$GITHUB_OUTPUT" - else - echo "should_publish=false" >> "$GITHUB_OUTPUT" - fi - - name: Setup pnpm - if: ${{ env.SIGN_MACOS == 'true' }} - uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5 + uses: pnpm/action-setup@v4 with: run_install: false - name: Setup Node.js for npm packaging - if: ${{ env.SIGN_MACOS == 'true' }} - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + uses: actions/setup-node@v6 with: node-version: 22 - name: Install dependencies - if: ${{ env.SIGN_MACOS == 'true' }} run: pnpm install --frozen-lockfile + # stage_npm_packages.py requires DotSlash when staging releases. + - uses: facebook/install-dotslash@v2 - name: Stage npm packages - if: ${{ env.SIGN_MACOS == 'true' }} + if: github.repository_owner == 'openai' env: GH_TOKEN: ${{ github.token }} - RELEASE_VERSION: ${{ steps.release_name.outputs.name }} run: | - workflow_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" ./scripts/stage_npm_packages.py \ - --release-version "$RELEASE_VERSION" \ - --workflow-url "$workflow_url" \ + --release-version "${{ steps.release_name.outputs.name }}" \ --package codex \ --package codex-responses-api-proxy \ --package codex-sdk - - name: Stage installer scripts - if: ${{ env.SIGN_MACOS == 'true' }} - run: | - cp scripts/install/install.sh dist/install.sh - cp scripts/install/install.ps1 dist/install.ps1 - - name: Create GitHub Release - uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1 + uses: softprops/action-gh-release@v2 with: name: ${{ steps.release_name.outputs.name }} tag_name: ${{ github.ref_name }} body_path: ${{ steps.release_notes.outputs.path }} files: dist/** - overwrite_files: true - make_latest: ${{ env.SIGN_MACOS == 'true' && !contains(steps.release_name.outputs.name, '-') }} # Mark as prerelease only when the version has a suffix after x.y.z # (e.g. -alpha, -beta). Otherwise publish a normal release. prerelease: ${{ contains(steps.release_name.outputs.name, '-') }} - - name: Clean up signed promotion handoff assets - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - - release_id="$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${GITHUB_REF_NAME}" --jq '.id')" - gh api --paginate "repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets" \ - --jq '.[] | [.id, .name] | @tsv' | - while IFS=$'\t' read -r asset_id asset_name; do - if [[ -z "$asset_id" || -z "$asset_name" ]]; then - continue - fi - - delete_asset=false - if [[ "$asset_name" == *unsigned* || "$asset_name" == "$SIGNED_MACOS_ASSET" ]]; then - delete_asset=true - fi - - if [[ "$delete_asset" == "true" ]]; then - echo "Deleting release asset ${asset_name}" - gh api -X DELETE "repos/${GITHUB_REPOSITORY}/releases/assets/${asset_id}" - fi - done - - - if: ${{ env.SIGN_MACOS == 'true' }} - uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 + - if: github.repository_owner == 'openai' + uses: facebook/dotslash-publish-release@v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: tag: ${{ github.ref_name }} config: .github/dotslash-config.json - - if: ${{ env.SIGN_MACOS == 'true' }} - uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag: ${{ github.ref_name }} - config: .github/dotslash-zsh-config.json - - - if: ${{ env.SIGN_MACOS == 'true' }} - uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag: ${{ github.ref_name }} - config: .github/dotslash-argument-comment-lint-config.json - - name: Trigger developers.openai.com deploy # Only trigger the deploy if the release is not a pre-release. # The deploy is used to update the developers.openai.com website with the new config schema json file. - if: ${{ env.SIGN_MACOS == 'true' && !contains(steps.release_name.outputs.name, '-') }} + if: ${{ !contains(steps.release_name.outputs.name, '-') && github.repository_owner == 'openai' }} continue-on-error: true env: DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL: ${{ secrets.DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL }} @@ -1309,15 +1122,7 @@ jobs: # npm docs: https://docs.npmjs.com/trusted-publishers publish-npm: # Publish to npm for stable releases and alpha pre-releases with numeric suffixes. - # promote_signed intentionally skips build jobs that are ancestors of release; - # include the !cancelled() status function so Actions does not apply its implicit - # success() check to the whole dependency chain before evaluating release outputs. - if: >- - ${{ - !cancelled() && - needs.release.result == 'success' && - needs.release.outputs.should_publish_npm == 'true' - }} + if: ${{ needs.release.outputs.should_publish_npm == 'true' && github.repository_owner == 'openai' }} name: publish-npm needs: release runs-on: ubuntu-latest @@ -1327,37 +1132,36 @@ jobs: steps: - name: Setup Node.js - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + uses: actions/setup-node@v6 with: - # Node 24 bundles npm >= 11.5.1, which trusted publishing requires. - node-version: 24 + node-version: 22 registry-url: "https://registry.npmjs.org" scope: "@openai" + # Trusted publishing requires npm CLI version 11.5.1 or later. + - name: Update npm + run: npm install -g npm@latest + - name: Download npm tarballs from release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_TAG: ${{ needs.release.outputs.tag }} - RELEASE_VERSION: ${{ needs.release.outputs.version }} run: | set -euo pipefail - version="$RELEASE_VERSION" - tag="$RELEASE_TAG" + version="${{ needs.release.outputs.version }}" + tag="${{ needs.release.outputs.tag }}" mkdir -p dist/npm - patterns=( - "codex-npm-${version}.tgz" - "codex-npm-linux-*-${version}.tgz" - "codex-npm-darwin-*-${version}.tgz" - "codex-npm-win32-*-${version}.tgz" - "codex-responses-api-proxy-npm-${version}.tgz" - "codex-sdk-npm-${version}.tgz" - ) - for pattern in "${patterns[@]}"; do - gh release download "$tag" \ - --repo "${GITHUB_REPOSITORY}" \ - --pattern "$pattern" \ - --dir dist/npm - done + gh release download "$tag" \ + --repo "${GITHUB_REPOSITORY}" \ + --pattern "codex-npm-${version}.tgz" \ + --dir dist/npm + gh release download "$tag" \ + --repo "${GITHUB_REPOSITORY}" \ + --pattern "codex-responses-api-proxy-npm-${version}.tgz" \ + --dir dist/npm + gh release download "$tag" \ + --repo "${GITHUB_REPOSITORY}" \ + --pattern "codex-sdk-npm-${version}.tgz" \ + --dir dist/npm # No NODE_AUTH_TOKEN needed because we use OIDC. - name: Publish to npm @@ -1366,193 +1170,23 @@ jobs: NPM_TAG: ${{ needs.release.outputs.npm_tag }} run: | set -euo pipefail - prefix="" + tag_args=() if [[ -n "${NPM_TAG}" ]]; then - prefix="${NPM_TAG}-" + tag_args+=(--tag "${NPM_TAG}") fi - root_tarball="dist/npm/codex-npm-${VERSION}.tgz" - sdk_tarball="dist/npm/codex-sdk-npm-${VERSION}.tgz" - # Keep this list in sync with CODEX_PLATFORM_PACKAGES in - # codex-cli/scripts/build_npm_package.py. The root wrapper advances - # @openai/codex@latest as soon as it publishes, so every platform - # package it aliases must already exist in the registry first. - platform_tarballs=( - "dist/npm/codex-npm-linux-x64-${VERSION}.tgz" - "dist/npm/codex-npm-linux-arm64-${VERSION}.tgz" - "dist/npm/codex-npm-darwin-x64-${VERSION}.tgz" - "dist/npm/codex-npm-darwin-arm64-${VERSION}.tgz" - "dist/npm/codex-npm-win32-x64-${VERSION}.tgz" - "dist/npm/codex-npm-win32-arm64-${VERSION}.tgz" - ) - - for required_tarball in "${platform_tarballs[@]}" "${root_tarball}"; do - if [[ ! -f "${required_tarball}" ]]; then - echo "Missing npm tarball: ${required_tarball}" - exit 1 - fi - done - - shopt -s nullglob - other_tarballs=() - for tarball in dist/npm/*-"${VERSION}".tgz; do - if [[ "${tarball}" == "${root_tarball}" || "${tarball}" == "${sdk_tarball}" ]]; then - continue - fi - - is_platform_tarball=false - for platform_tarball in "${platform_tarballs[@]}"; do - if [[ "${tarball}" == "${platform_tarball}" ]]; then - is_platform_tarball=true - break - fi - done - if [[ "${is_platform_tarball}" == true ]]; then - continue - fi - - other_tarballs+=("${tarball}") - done - - # Publish the platform packages before the root CLI wrapper. The root - # wrapper advances @openai/codex@latest, so it should only publish - # after the optional dependency versions it references exist. tarballs=( - "${platform_tarballs[@]}" - "${other_tarballs[@]}" - "${root_tarball}" + "codex-npm-${VERSION}.tgz" + "codex-responses-api-proxy-npm-${VERSION}.tgz" + "codex-sdk-npm-${VERSION}.tgz" ) - if [[ -f "${sdk_tarball}" ]]; then - tarballs+=("${sdk_tarball}") - fi for tarball in "${tarballs[@]}"; do - filename="$(basename "${tarball}")" - tag="" - - case "${filename}" in - codex-npm-linux-*-"${VERSION}".tgz|codex-npm-darwin-*-"${VERSION}".tgz|codex-npm-win32-*-"${VERSION}".tgz) - platform="${filename#codex-npm-}" - platform="${platform%-${VERSION}.tgz}" - tag="${prefix}${platform}" - ;; - codex-npm-"${VERSION}".tgz|codex-responses-api-proxy-npm-"${VERSION}".tgz|codex-sdk-npm-"${VERSION}".tgz) - tag="${NPM_TAG}" - ;; - *) - echo "Unexpected npm tarball: ${filename}" - exit 1 - ;; - esac - - publish_cmd=(npm publish "${GITHUB_WORKSPACE}/${tarball}") - if [[ -n "${tag}" ]]; then - publish_cmd+=(--tag "${tag}") - fi - - echo "+ ${publish_cmd[*]}" - set +e - publish_output="$("${publish_cmd[@]}" 2>&1)" - publish_status=$? - set -e - - echo "${publish_output}" - if [[ ${publish_status} -eq 0 ]]; then - continue - fi - - if grep -qiE "previously published|cannot publish over|version already exists" <<< "${publish_output}"; then - echo "Skipping already-published package version for ${filename}" - continue - fi - - exit "${publish_status}" + npm publish "${GITHUB_WORKSPACE}/dist/npm/${tarball}" "${tag_args[@]}" done - # Publish the platform-specific Python runtime wheels using PyPI trusted publishing. - # PyPI project configuration must trust this workflow and job. Keep this - # non-blocking while the Python runtime publishing path is new; failures still - # need release follow-up, but should not invalidate the Rust release itself. - publish-python-runtime: - # Publish to PyPI for stable releases and alpha pre-releases with numeric suffixes. - if: >- - ${{ - !cancelled() && - needs.release.result == 'success' && - needs.release.outputs.should_publish_python_runtime == 'true' - }} - name: publish-python-runtime - needs: release - runs-on: ubuntu-latest - continue-on-error: true - environment: pypi - permissions: - id-token: write # Required for PyPI trusted publishing. - contents: read - - steps: - - name: Download Python runtime wheels from release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_TAG: ${{ needs.release.outputs.tag }} - RELEASE_VERSION: ${{ needs.release.outputs.version }} - run: | - set -euo pipefail - python_version="$RELEASE_VERSION" - python_version="${python_version/-alpha./a}" - python_version="${python_version/-beta./b}" - python_version="${python_version/-rc./rc}" - - mkdir -p dist/python-runtime - gh release download "$RELEASE_TAG" \ - --repo "${GITHUB_REPOSITORY}" \ - --pattern "openai_codex_cli_bin-${python_version}-*.whl" \ - --dir dist/python-runtime - ls -lh dist/python-runtime - - - name: Publish Python runtime wheels to PyPI - uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 - with: - packages-dir: dist/python-runtime - skip-existing: true - - winget: - name: winget - needs: release - # Only publish stable/mainline releases to WinGet; pre-releases include a - # '-' in the semver string (e.g., 1.2.3-alpha.1). - if: >- - ${{ - !cancelled() && - needs.release.result == 'success' && - needs.release.outputs.sign_macos == 'true' && - !contains(needs.release.outputs.version, '-') - }} - # This job only invokes a GitHub Action to open/update the winget-pkgs PR; - # it does not execute Windows-only tooling, so Linux is sufficient. - runs-on: ubuntu-latest - permissions: - contents: read - - steps: - - name: Publish to WinGet - uses: vedantmgoyal9/winget-releaser@7bd472be23763def6e16bd06cc8b1cdfab0e2fd5 - with: - identifier: OpenAI.Codex - version: ${{ needs.release.outputs.version }} - release-tag: ${{ needs.release.outputs.tag }} - fork-user: openai-oss-forks - installers-regex: '^codex-(?:x86_64|aarch64)-pc-windows-msvc\.exe\.zip$' - token: ${{ secrets.WINGET_PUBLISH_PAT }} - update-branch: name: Update latest-alpha-cli branch - if: >- - ${{ - !cancelled() && - needs.release.result == 'success' && - needs.release.outputs.sign_macos == 'true' - }} permissions: contents: write needs: release diff --git a/.github/workflows/shell-tool-mcp.yml b/.github/workflows/shell-tool-mcp.yml new file mode 100644 index 000000000000..66a76aa4d938 --- /dev/null +++ b/.github/workflows/shell-tool-mcp.yml @@ -0,0 +1,461 @@ +name: shell-tool-mcp + +on: + workflow_call: + inputs: + release-version: + description: Version to publish (x.y.z or x.y.z-alpha.N). Defaults to GITHUB_REF_NAME when it starts with rust-v. + required: false + type: string + release-tag: + description: Tag name to use when downloading release artifacts (defaults to rust-v). + required: false + type: string + publish: + description: Whether to publish to npm when the version is releasable. + required: false + default: true + type: boolean + +env: + NODE_VERSION: 22 + +jobs: + metadata: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.compute.outputs.version }} + release_tag: ${{ steps.compute.outputs.release_tag }} + should_publish: ${{ steps.compute.outputs.should_publish }} + npm_tag: ${{ steps.compute.outputs.npm_tag }} + steps: + - name: Compute version and tags + id: compute + run: | + set -euo pipefail + + version="${{ inputs.release-version }}" + release_tag="${{ inputs.release-tag }}" + + if [[ -z "$version" ]]; then + if [[ -n "$release_tag" && "$release_tag" =~ ^rust-v.+ ]]; then + version="${release_tag#rust-v}" + elif [[ "${GITHUB_REF_NAME:-}" =~ ^rust-v.+ ]]; then + version="${GITHUB_REF_NAME#rust-v}" + release_tag="${GITHUB_REF_NAME}" + else + echo "release-version is required when GITHUB_REF_NAME is not a rust-v tag." + exit 1 + fi + fi + + if [[ -z "$release_tag" ]]; then + release_tag="rust-v${version}" + fi + + npm_tag="" + should_publish="false" + if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + should_publish="true" + elif [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+-alpha\.[0-9]+$ ]]; then + should_publish="true" + npm_tag="alpha" + fi + + echo "version=${version}" >> "$GITHUB_OUTPUT" + echo "release_tag=${release_tag}" >> "$GITHUB_OUTPUT" + echo "npm_tag=${npm_tag}" >> "$GITHUB_OUTPUT" + echo "should_publish=${should_publish}" >> "$GITHUB_OUTPUT" + + matrix-setup: + runs-on: ubuntu-latest + outputs: + rust-binaries: ${{ steps.compute.outputs.rust-binaries }} + bash-linux: ${{ steps.compute.outputs.bash-linux }} + bash-darwin: ${{ steps.compute.outputs.bash-darwin }} + steps: + - name: Compute matrices + id: compute + shell: bash + run: | + set -euo pipefail + is_openai="${{ github.repository_owner == 'openai' }}" + + # rust-binaries: always include x86_64-musl; conditionally include paid/fork runners + if [[ "$is_openai" == "true" ]]; then + rust='[ + {"runner":"macos-15-xlarge","target":"aarch64-apple-darwin"}, + {"runner":"macos-15-xlarge","target":"x86_64-apple-darwin"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","install_musl":true} + ]' + else + rust='[{"runner":"macos-latest","target":"aarch64-apple-darwin"}]' + fi + rust=$(echo "$rust" | jq -c '. + [{"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","install_musl":true}]') + echo "rust-binaries={\"include\":$rust}" >> "$GITHUB_OUTPUT" + + # bash-linux: always include x86_64 variants; add arm64 on openai + bash_linux='[ + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","variant":"ubuntu-24.04","image":"ubuntu:24.04"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","variant":"ubuntu-22.04","image":"ubuntu:22.04"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","variant":"debian-12","image":"debian:12"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","variant":"debian-11","image":"debian:11"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","variant":"centos-9","image":"quay.io/centos/centos:stream9"} + ]' + if [[ "$is_openai" == "true" ]]; then + bash_linux=$(echo "$bash_linux" | jq -c '. + [ + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"ubuntu-24.04","image":"arm64v8/ubuntu:24.04"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"ubuntu-22.04","image":"arm64v8/ubuntu:22.04"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"ubuntu-20.04","image":"arm64v8/ubuntu:20.04"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"debian-12","image":"arm64v8/debian:12"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"debian-11","image":"arm64v8/debian:11"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"centos-9","image":"quay.io/centos/centos:stream9"} + ]') + fi + echo "bash-linux={\"include\":$(echo "$bash_linux" | jq -c)}" >> "$GITHUB_OUTPUT" + + # bash-darwin: always include macos-14; add macos-15-xlarge on openai + bash_darwin='[{"runner":"macos-14","target":"aarch64-apple-darwin","variant":"macos-14"}]' + if [[ "$is_openai" == "true" ]]; then + bash_darwin=$(echo "$bash_darwin" | jq -c '. + [ + {"runner":"macos-15-xlarge","target":"aarch64-apple-darwin","variant":"macos-15"} + ]') + fi + echo "bash-darwin={\"include\":$(echo "$bash_darwin" | jq -c)}" >> "$GITHUB_OUTPUT" + + rust-binaries: + name: Build Rust - ${{ matrix.target }} + needs: [metadata, matrix-setup] + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + defaults: + run: + working-directory: codex-rs + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.matrix-setup.outputs.rust-binaries) }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Install UBSan runtime (musl) + if: ${{ matrix.install_musl }} + shell: bash + run: | + set -euo pipefail + if command -v apt-get >/dev/null 2>&1; then + sudo apt-get update -y + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y libubsan1 + fi + + - uses: dtolnay/rust-toolchain@1.93 + with: + targets: ${{ matrix.target }} + + - if: ${{ matrix.install_musl }} + name: Install Zig + uses: mlugg/setup-zig@v2 + with: + version: 0.14.0 + + - if: ${{ matrix.install_musl }} + name: Install musl build dependencies + env: + TARGET: ${{ matrix.target }} + run: bash "${GITHUB_WORKSPACE}/.github/scripts/install-musl-build-tools.sh" + + - if: ${{ matrix.install_musl }} + name: Configure rustc UBSan wrapper (musl host) + shell: bash + run: | + set -euo pipefail + ubsan="" + if command -v ldconfig >/dev/null 2>&1; then + ubsan="$(ldconfig -p | grep -m1 'libubsan\.so\.1' | sed -E 's/.*=> (.*)$/\1/')" + fi + wrapper_root="${RUNNER_TEMP:-/tmp}" + wrapper="${wrapper_root}/rustc-ubsan-wrapper" + cat > "${wrapper}" <> "$GITHUB_ENV" + echo "RUSTC_WORKSPACE_WRAPPER=" >> "$GITHUB_ENV" + + - if: ${{ matrix.install_musl }} + name: Clear sanitizer flags (musl) + shell: bash + run: | + set -euo pipefail + # Clear global Rust flags so host/proc-macro builds don't pull in UBSan. + echo "RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_ENCODED_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "RUSTDOCFLAGS=" >> "$GITHUB_ENV" + # Override any runner-level Cargo config rustflags as well. + echo "CARGO_BUILD_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_RUSTFLAGS=" >> "$GITHUB_ENV" + + sanitize_flags() { + local input="$1" + input="${input//-fsanitize=undefined/}" + input="${input//-fno-sanitize-recover=undefined/}" + input="${input//-fno-sanitize-trap=undefined/}" + echo "$input" + } + + cflags="$(sanitize_flags "${CFLAGS-}")" + cxxflags="$(sanitize_flags "${CXXFLAGS-}")" + echo "CFLAGS=${cflags}" >> "$GITHUB_ENV" + echo "CXXFLAGS=${cxxflags}" >> "$GITHUB_ENV" + + - name: Build exec server binaries + run: cargo build --release --target ${{ matrix.target }} --bin codex-exec-mcp-server --bin codex-execve-wrapper + + - name: Stage exec server binaries + run: | + dest="${GITHUB_WORKSPACE}/artifacts/vendor/${{ matrix.target }}" + mkdir -p "$dest" + cp "target/${{ matrix.target }}/release/codex-exec-mcp-server" "$dest/" + cp "target/${{ matrix.target }}/release/codex-execve-wrapper" "$dest/" + + - uses: actions/upload-artifact@v6 + with: + name: shell-tool-mcp-rust-${{ matrix.target }} + path: artifacts/** + if-no-files-found: error + + bash-linux: + name: Build Bash (Linux) - ${{ matrix.variant }} - ${{ matrix.target }} + needs: [metadata, matrix-setup] + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + container: + image: ${{ matrix.image }} + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.matrix-setup.outputs.bash-linux) }} + steps: + - name: Install build prerequisites + shell: bash + run: | + set -euo pipefail + if command -v apt-get >/dev/null 2>&1; then + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y git build-essential bison autoconf gettext + elif command -v dnf >/dev/null 2>&1; then + dnf install -y git gcc gcc-c++ make bison autoconf gettext + elif command -v yum >/dev/null 2>&1; then + yum install -y git gcc gcc-c++ make bison autoconf gettext + else + echo "Unsupported package manager in container" + exit 1 + fi + + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Build patched Bash + shell: bash + run: | + set -euo pipefail + git clone --depth 1 https://github.com/bolinfest/bash /tmp/bash + cd /tmp/bash + git fetch --depth 1 origin a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b + git checkout a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b + git apply "${GITHUB_WORKSPACE}/shell-tool-mcp/patches/bash-exec-wrapper.patch" + ./configure --without-bash-malloc + cores="$(command -v nproc >/dev/null 2>&1 && nproc || getconf _NPROCESSORS_ONLN)" + make -j"${cores}" + + dest="${GITHUB_WORKSPACE}/artifacts/vendor/${{ matrix.target }}/bash/${{ matrix.variant }}" + mkdir -p "$dest" + cp bash "$dest/bash" + + - uses: actions/upload-artifact@v6 + with: + name: shell-tool-mcp-bash-${{ matrix.target }}-${{ matrix.variant }} + path: artifacts/** + if-no-files-found: error + + bash-darwin: + name: Build Bash (macOS) - ${{ matrix.variant }} - ${{ matrix.target }} + needs: [metadata, matrix-setup] + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.matrix-setup.outputs.bash-darwin) }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Build patched Bash + shell: bash + run: | + set -euo pipefail + git clone --depth 1 https://github.com/bolinfest/bash /tmp/bash + cd /tmp/bash + git fetch --depth 1 origin a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b + git checkout a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b + git apply "${GITHUB_WORKSPACE}/shell-tool-mcp/patches/bash-exec-wrapper.patch" + ./configure --without-bash-malloc + cores="$(getconf _NPROCESSORS_ONLN)" + make -j"${cores}" + + dest="${GITHUB_WORKSPACE}/artifacts/vendor/${{ matrix.target }}/bash/${{ matrix.variant }}" + mkdir -p "$dest" + cp bash "$dest/bash" + + - uses: actions/upload-artifact@v6 + with: + name: shell-tool-mcp-bash-${{ matrix.target }}-${{ matrix.variant }} + path: artifacts/** + if-no-files-found: error + + package: + name: Package npm module + needs: + - metadata + - rust-binaries + - bash-linux + - bash-darwin + runs-on: ubuntu-latest + env: + PACKAGE_VERSION: ${{ needs.metadata.outputs.version }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Install JavaScript dependencies + run: pnpm install --frozen-lockfile + + - name: Build (shell-tool-mcp) + run: pnpm --filter @openai/codex-shell-tool-mcp run build + + - name: Download build artifacts + uses: actions/download-artifact@v7 + with: + path: artifacts + + - name: Assemble staging directory + id: staging + shell: bash + run: | + set -euo pipefail + staging="${STAGING_DIR}" + mkdir -p "$staging" "$staging/vendor" + cp shell-tool-mcp/README.md "$staging/" + cp shell-tool-mcp/package.json "$staging/" + cp -R shell-tool-mcp/bin "$staging/" + + found_vendor="false" + shopt -s nullglob + for vendor_dir in artifacts/*/vendor; do + rsync -av "$vendor_dir/" "$staging/vendor/" + found_vendor="true" + done + if [[ "$found_vendor" == "false" ]]; then + echo "No vendor payloads were downloaded." + exit 1 + fi + + node - <<'NODE' + import fs from "node:fs"; + import path from "node:path"; + + const stagingDir = process.env.STAGING_DIR; + const version = process.env.PACKAGE_VERSION; + const pkgPath = path.join(stagingDir, "package.json"); + const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")); + pkg.version = version; + fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n"); + NODE + + echo "dir=$staging" >> "$GITHUB_OUTPUT" + env: + STAGING_DIR: ${{ runner.temp }}/shell-tool-mcp + + - name: Ensure binaries are executable + run: | + set -euo pipefail + staging="${{ steps.staging.outputs.dir }}" + chmod +x \ + "$staging"/vendor/*/codex-exec-mcp-server \ + "$staging"/vendor/*/codex-execve-wrapper \ + "$staging"/vendor/*/bash/*/bash + + - name: Create npm tarball + shell: bash + run: | + set -euo pipefail + mkdir -p dist/npm + staging="${{ steps.staging.outputs.dir }}" + pack_info=$(cd "$staging" && npm pack --ignore-scripts --json --pack-destination "${GITHUB_WORKSPACE}/dist/npm") + filename=$(PACK_INFO="$pack_info" node -e 'const data = JSON.parse(process.env.PACK_INFO); console.log(data[0].filename);') + mv "dist/npm/${filename}" "dist/npm/codex-shell-tool-mcp-npm-${PACKAGE_VERSION}.tgz" + + - uses: actions/upload-artifact@v6 + with: + name: codex-shell-tool-mcp-npm + path: dist/npm/codex-shell-tool-mcp-npm-${{ env.PACKAGE_VERSION }}.tgz + if-no-files-found: error + + publish: + name: Publish npm package + needs: + - metadata + - package + if: ${{ inputs.publish && needs.metadata.outputs.should_publish == 'true' }} + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + steps: + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + registry-url: https://registry.npmjs.org + scope: "@openai" + + # Trusted publishing requires npm CLI version 11.5.1 or later. + - name: Update npm + run: npm install -g npm@latest + + - name: Download npm tarball + uses: actions/download-artifact@v7 + with: + name: codex-shell-tool-mcp-npm + path: dist/npm + + - name: Publish to npm + if: github.repository == 'openai/codex' + env: + NPM_TAG: ${{ needs.metadata.outputs.npm_tag }} + VERSION: ${{ needs.metadata.outputs.version }} + shell: bash + run: | + set -euo pipefail + tag_args=() + if [[ -n "${NPM_TAG}" ]]; then + tag_args+=(--tag "${NPM_TAG}") + fi + npm publish "dist/npm/codex-shell-tool-mcp-npm-${VERSION}.tgz" "${tag_args[@]}" diff --git a/.github/workflows/termux-release-checkpoint.yml b/.github/workflows/termux-release-checkpoint.yml new file mode 100644 index 000000000000..0347e6f1b3d6 --- /dev/null +++ b/.github/workflows/termux-release-checkpoint.yml @@ -0,0 +1,103 @@ +name: termux-release-checkpoint + +on: + workflow_dispatch: + inputs: + source_branch: + description: "Release branch to checkpoint from, for example release/0.123.0" + required: false + type: string + default: "" + source_sha: + description: "Specific source commit SHA to checkpoint; defaults to the source branch tip" + required: false + type: string + default: "" + destination_branch: + description: "Destination patch branch to receive the checkpoint PR" + required: false + type: string + default: "wallentx/termux-target" + reviewer: + description: "GitHub username to request as reviewer" + required: false + type: string + default: "wallentx" + +permissions: + actions: read + attestations: read + checks: read + contents: read + deployments: read + issues: read + discussions: read + packages: read + pages: read + pull-requests: read + repository-projects: read + statuses: read + +concurrency: + group: termux-release-checkpoint-${{ inputs.source_branch || github.ref_name }} + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + checkpoint: + runs-on: ubuntu-slim + permissions: + contents: write + issues: write + pull-requests: write + env: + DESTINATION_BRANCH: ${{ inputs.destination_branch || 'wallentx/termux-target' }} + REVIEWER: ${{ inputs.reviewer || 'wallentx' }} + REQUESTED_SOURCE_BRANCH: ${{ inputs.source_branch }} + REQUESTED_SOURCE_SHA: ${{ inputs.source_sha }} + TERMUX_AUTOMATION_DIR: ${{ github.workspace }}/.termux-release-automation + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Export GitHub App token for gh + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + run: echo "GH_TOKEN=${APP_TOKEN}" >> "${GITHUB_ENV}" + + - name: Checkout release branch + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ inputs.source_branch || github.ref_name }} + token: ${{ steps.app-token.outputs.token }} + + - name: Checkout automation helpers + uses: actions/checkout@v6 + with: + fetch-depth: 1 + ref: ${{ github.workflow_sha }} + path: .termux-release-automation + token: ${{ steps.app-token.outputs.token }} + + - name: 🧰 Actions Toolbox + uses: wallentx/gh-actions/composite/actions-toolbox@main + + - name: Validate GitHub CLI environment + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-validate-gh-env.sh" + + - name: Configure git + run: | + set -euo pipefail + source_branch="${REQUESTED_SOURCE_BRANCH:-${GITHUB_REF_NAME}}" + bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-configure-git.sh" --origin "${DESTINATION_BRANCH}" "${source_branch}" + + - name: Create checkpoint PR + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-create-checkpoint-pr.sh" diff --git a/.github/workflows/termux-release-deploy.yml b/.github/workflows/termux-release-deploy.yml new file mode 100644 index 000000000000..2071611dacc4 --- /dev/null +++ b/.github/workflows/termux-release-deploy.yml @@ -0,0 +1,243 @@ +name: termux-release-deploy + +on: + push: + branches: + - "release/**" + workflow_dispatch: + inputs: + release_branch: + description: "Release branch to deploy, for example release/0.124.0" + required: true + type: string + release_sha: + description: "Release branch commit SHA to deploy. Defaults to the branch head." + required: false + default: "" + type: string + pr_number: + description: "Merged release PR number to promote. Optional; normally discovered automatically." + required: false + default: "" + type: string + pr_head_sha: + description: "Merged release PR head SHA. Required only when pr_number is set." + required: false + default: "" + type: string + destination_branch: + description: "Destination patch branch to receive the checkpoint PR" + required: false + default: "wallentx/termux-target" + type: string + reviewer: + description: "GitHub username to request as reviewer on the checkpoint PR" + required: false + default: "wallentx" + type: string + +permissions: + actions: read + attestations: read + checks: read + contents: read + deployments: read + issues: read + discussions: read + packages: read + pages: read + pull-requests: read + repository-projects: read + statuses: read + +concurrency: + group: termux-release-deploy-${{ github.event_name == 'workflow_dispatch' && inputs.release_branch || github.ref_name }} + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + deploy: + runs-on: ubuntu-24.04 + if: ${{ github.event_name == 'workflow_dispatch' || !startsWith(github.event.head_commit.message, 'Seed Termux release automation') }} + permissions: + actions: read + contents: write + deployments: write + issues: write + pull-requests: write + env: + REQUESTED_RELEASE_BRANCH: ${{ inputs.release_branch }} + REQUESTED_RELEASE_SHA: ${{ inputs.release_sha }} + INPUT_PR_NUMBER: ${{ inputs.pr_number }} + INPUT_PR_HEAD_SHA: ${{ inputs.pr_head_sha }} + DESTINATION_BRANCH: ${{ inputs.destination_branch || 'wallentx/termux-target' }} + REVIEWER: ${{ inputs.reviewer || 'wallentx' }} + TERMUX_AUTOMATION_DIR: ${{ github.workspace }}/.termux-release-automation + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Export GitHub App token for gh + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + run: echo "GH_TOKEN=${APP_TOKEN}" >> "${GITHUB_ENV}" + + - name: Checkout release branch + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.release_branch || github.ref }} + token: ${{ steps.app-token.outputs.token }} + + - name: Checkout automation helpers + uses: actions/checkout@v6 + with: + fetch-depth: 1 + ref: ${{ github.workflow_sha }} + path: .termux-release-automation + token: ${{ steps.app-token.outputs.token }} + + - name: 🧰 Actions Toolbox + uses: wallentx/gh-actions/composite/actions-toolbox@main + + - name: Validate GitHub CLI environment + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-validate-gh-env.sh" + + - name: Configure git + run: | + set -euo pipefail + release_branch="${REQUESTED_RELEASE_BRANCH:-${GITHUB_REF_NAME}}" + bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-configure-git.sh" --origin "${DESTINATION_BRANCH}" "${release_branch}" + + - name: Resolve release ref + id: release-ref + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-resolve-release-ref.sh" + + - name: Read release metadata + id: metadata + env: + TERMUX_RELEASE_ACTION: deploy + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-read-release-metadata.sh" + + - name: Create deployment + if: steps.metadata.outputs.deploy == 'true' + id: deployment + env: + GH_TOKEN: ${{ github.token }} + RELEASE_SHA: ${{ steps.release-ref.outputs.sha }} + TERMUX_TAG: ${{ steps.metadata.outputs.termux_tag }} + run: | + set -euo pipefail + deployment_id="$( + gh api \ + -X POST \ + "repos/${GITHUB_REPOSITORY}/deployments" \ + -f ref="${RELEASE_SHA}" \ + -f environment="termux-release" \ + -F auto_merge=false \ + -F required_contexts[] \ + -f description="Termux release deployment for ${TERMUX_TAG}" \ + --jq '.id' + )" + echo "id=${deployment_id}" >> "$GITHUB_OUTPUT" + + - name: Mark deployment in progress + if: steps.metadata.outputs.deploy == 'true' + env: + GH_TOKEN: ${{ github.token }} + DEPLOYMENT_ID: ${{ steps.deployment.outputs.id }} + run: | + set -euo pipefail + log_url="${GH_WORKFLOW_URL:-${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}}" + gh api \ + -X POST \ + "repos/${GITHUB_REPOSITORY}/deployments/${DEPLOYMENT_ID}/statuses" \ + -f state="in_progress" \ + -f environment="termux-release" \ + -f log_url="${log_url}" \ + -F auto_inactive=false \ + -f description="Promoting Termux release artifact and preparing checkpoint PR" \ + >/dev/null + + - name: Locate merged pull request + if: steps.metadata.outputs.deploy == 'true' && steps.metadata.outputs.asset_exists != 'true' + id: pr + env: + RELEASE_BRANCH: ${{ steps.release-ref.outputs.branch }} + RELEASE_SHA: ${{ steps.release-ref.outputs.sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-find-release-pr.sh" + + - name: Download promoted PR artifact + if: steps.metadata.outputs.deploy == 'true' && steps.metadata.outputs.asset_exists != 'true' + env: + PR_ARTIFACT_NAME: ${{ steps.pr.outputs.artifact_name }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-download-release-artifact.sh" + + - name: Create or update mirrored Termux release + if: steps.metadata.outputs.deploy == 'true' && steps.metadata.outputs.asset_exists != 'true' + env: + UPSTREAM_TAG: ${{ steps.metadata.outputs.upstream_tag }} + UPSTREAM_REPO: ${{ steps.metadata.outputs.upstream_repo }} + UPSTREAM_NAME: ${{ steps.metadata.outputs.upstream_name }} + TERMUX_TAG: ${{ steps.metadata.outputs.termux_tag }} + UPSTREAM_PRERELEASE: ${{ steps.metadata.outputs.upstream_prerelease }} + UPSTREAM_HTML_URL: ${{ steps.metadata.outputs.upstream_html_url }} + RELEASE_TRAIN: ${{ steps.metadata.outputs.release_train }} + RELEASE_EXISTS: ${{ steps.metadata.outputs.release_exists }} + PR_NUMBER: ${{ steps.pr.outputs.number }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + RELEASE_SHA: ${{ steps.release-ref.outputs.sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-create-or-update-mirrored-release.sh" + + - name: Ensure checkpoint PR + if: steps.metadata.outputs.deploy == 'true' + id: checkpoint + env: + SOURCE_BRANCH: ${{ steps.release-ref.outputs.branch }} + SOURCE_SHA: ${{ steps.release-ref.outputs.sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-create-checkpoint-pr.sh" + + - name: Mark deployment success + if: steps.metadata.outputs.deploy == 'true' + env: + GH_TOKEN: ${{ github.token }} + DEPLOYMENT_ID: ${{ steps.deployment.outputs.id }} + TERMUX_TAG: ${{ steps.metadata.outputs.termux_tag }} + run: | + set -euo pipefail + log_url="${GH_WORKFLOW_URL:-${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}}" + gh api \ + -X POST \ + "repos/${GITHUB_REPOSITORY}/deployments/${DEPLOYMENT_ID}/statuses" \ + -f state="success" \ + -f environment="termux-release" \ + -f log_url="${log_url}" \ + -F auto_inactive=false \ + -f description="Termux release deployment completed for ${TERMUX_TAG}" \ + >/dev/null + + - name: Mark deployment failure + if: failure() && steps.deployment.outputs.id != '' + env: + GH_TOKEN: ${{ github.token }} + DEPLOYMENT_ID: ${{ steps.deployment.outputs.id }} + run: | + set -euo pipefail + log_url="${GH_WORKFLOW_URL:-${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}}" + gh api \ + -X POST \ + "repos/${GITHUB_REPOSITORY}/deployments/${DEPLOYMENT_ID}/statuses" \ + -f state="failure" \ + -f environment="termux-release" \ + -f log_url="${log_url}" \ + -F auto_inactive=false \ + -f description="Termux release deployment failed" \ + >/dev/null diff --git a/.github/workflows/termux-release-promote.yml b/.github/workflows/termux-release-promote.yml new file mode 100644 index 000000000000..22c277c21a70 --- /dev/null +++ b/.github/workflows/termux-release-promote.yml @@ -0,0 +1,135 @@ +name: termux-release-promote + +on: + workflow_dispatch: + inputs: + release_branch: + description: "Release branch to promote, for example release/0.122.0" + required: true + type: string + release_sha: + description: "Release branch commit SHA to promote. Defaults to the branch head." + required: false + default: "" + type: string + pr_number: + description: "Merged PR number to promote. Optional; normally discovered automatically." + required: false + default: "" + type: string + pr_head_sha: + description: "Merged PR head SHA. Required only when pr_number is set." + required: false + default: "" + type: string + +permissions: + actions: read + attestations: read + checks: read + contents: read + deployments: read + issues: read + discussions: read + packages: read + pages: read + pull-requests: read + repository-projects: read + statuses: read + +concurrency: + group: termux-release-promote-${{ github.event_name == 'workflow_dispatch' && inputs.release_branch || github.ref_name }} + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + promote: + runs-on: ubuntu-24.04 + permissions: + actions: read + contents: write + pull-requests: read + env: + TERMUX_AUTOMATION_DIR: ${{ github.workspace }}/.termux-release-automation + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Export GitHub App token for gh + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + run: echo "GH_TOKEN=${APP_TOKEN}" >> "${GITHUB_ENV}" + + - name: Checkout release branch + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.release_branch || github.ref }} + token: ${{ steps.app-token.outputs.token }} + + - name: Checkout automation helpers + uses: actions/checkout@v6 + with: + fetch-depth: 1 + ref: ${{ github.workflow_sha }} + path: .termux-release-automation + token: ${{ steps.app-token.outputs.token }} + + - name: 🧰 Actions Toolbox + uses: wallentx/gh-actions/composite/actions-toolbox@main + + - name: Validate GitHub CLI environment + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-validate-gh-env.sh" + + - name: Resolve release ref + id: release-ref + env: + INPUT_RELEASE_BRANCH: ${{ inputs.release_branch }} + INPUT_RELEASE_SHA: ${{ inputs.release_sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-resolve-release-ref.sh" + + - name: Read release metadata + id: metadata + env: + TERMUX_RELEASE_ACTION: promote + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-read-release-metadata.sh" + + - name: Locate merged pull request + if: steps.metadata.outputs.promote == 'true' + id: pr + env: + RELEASE_BRANCH: ${{ steps.release-ref.outputs.branch }} + RELEASE_SHA: ${{ steps.release-ref.outputs.sha }} + INPUT_PR_NUMBER: ${{ inputs.pr_number }} + INPUT_PR_HEAD_SHA: ${{ inputs.pr_head_sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-find-release-pr.sh" + + - name: Download promoted PR artifact + if: steps.metadata.outputs.promote == 'true' + env: + PR_ARTIFACT_NAME: ${{ steps.pr.outputs.artifact_name }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-download-release-artifact.sh" + + - name: Create or update mirrored Termux release + if: steps.metadata.outputs.promote == 'true' + env: + UPSTREAM_TAG: ${{ steps.metadata.outputs.upstream_tag }} + UPSTREAM_REPO: ${{ steps.metadata.outputs.upstream_repo }} + UPSTREAM_NAME: ${{ steps.metadata.outputs.upstream_name }} + TERMUX_TAG: ${{ steps.metadata.outputs.termux_tag }} + UPSTREAM_PRERELEASE: ${{ steps.metadata.outputs.upstream_prerelease }} + UPSTREAM_HTML_URL: ${{ steps.metadata.outputs.upstream_html_url }} + RELEASE_TRAIN: ${{ steps.metadata.outputs.release_train }} + RELEASE_EXISTS: ${{ steps.metadata.outputs.release_exists }} + PR_NUMBER: ${{ steps.pr.outputs.number }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + RELEASE_SHA: ${{ steps.release-ref.outputs.sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-create-or-update-mirrored-release.sh" diff --git a/scripts/termux-configure-git.sh b/scripts/termux-configure-git.sh new file mode 100755 index 000000000000..fae3abcbab48 --- /dev/null +++ b/scripts/termux-configure-git.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash + +# Configure the explicit bot identity used by release automation and fetch any +# refs requested by the calling workflow. + +set -euo pipefail + +git config user.name "github-actions[bot]" +git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + +while (($#)); do + case "$1" in + --origin) + shift + origin_refs=() + while (($#)) && [[ "$1" != --* ]]; do + origin_refs+=("$1") + shift + done + if ((${#origin_refs[@]})); then + git fetch --prune origin "${origin_refs[@]}" + fi + ;; + --upstream-tag) + if (($# < 3)); then + echo "--upstream-tag requires ." >&2 + exit 1 + fi + upstream_repo="$2" + upstream_tag="$3" + git remote add upstream "https://github.com/${upstream_repo}.git" 2>/dev/null || true + git fetch --prune --no-tags upstream "+refs/tags/${upstream_tag}:refs/tags/${upstream_tag}" + shift 3 + ;; + *) + echo "Unknown argument: $1" >&2 + exit 1 + ;; + esac +done diff --git a/scripts/termux-create-checkpoint-pr.sh b/scripts/termux-create-checkpoint-pr.sh new file mode 100755 index 000000000000..ba07c289952d --- /dev/null +++ b/scripts/termux-create-checkpoint-pr.sh @@ -0,0 +1,275 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/termux-release-paths.sh +source "${script_dir}/termux-release-paths.sh" + +source_branch="${SOURCE_BRANCH:-${REQUESTED_SOURCE_BRANCH:-${GITHUB_REF_NAME}}}" +source_sha="${SOURCE_SHA:-${REQUESTED_SOURCE_SHA:-}}" +if [[ -z "${source_sha}" ]]; then + if [[ "${GITHUB_EVENT_NAME:-}" == "push" && "${source_branch}" == "${GITHUB_REF_NAME:-}" ]]; then + source_sha="${GITHUB_SHA}" + else + source_sha="$(git rev-parse "origin/${source_branch}")" + fi +fi + +if [[ -z "${DESTINATION_BRANCH:-}" ]]; then + echo "DESTINATION_BRANCH is required." >&2 + exit 1 +fi + +release_only_checkpoint_paths() { + printf '%s\n' "${TERMUX_RELEASE_BRANCH_SCRIPT_PATHS[@]}" +} + +resolve_source_version_conflicts() { + local path="$1" + local resolved_path + resolved_path="$(mktemp)" + + if ! awk ' + function normalize_versions(text) { + gsub(/version = "[^"]+"/, "version = \"\"", text) + return text + } + + BEGIN { + in_block = 0 + side = "" + ours = "" + theirs = "" + blocks = 0 + } + + /^<<<<<<< / { + if (in_block) { + exit 1 + } + in_block = 1 + side = "ours" + ours = "" + theirs = "" + blocks++ + next + } + + /^=======$/ && in_block { + side = "theirs" + next + } + + /^>>>>>>> / && in_block { + if (normalize_versions(ours) != normalize_versions(theirs)) { + exit 1 + } + printf "%s", theirs + in_block = 0 + side = "" + next + } + + { + if (!in_block) { + print + } else if (side == "ours") { + ours = ours $0 ORS + } else if (side == "theirs") { + theirs = theirs $0 ORS + } else { + exit 1 + } + } + + END { + if (in_block || blocks == 0) { + exit 1 + } + } + ' "${path}" > "${resolved_path}"; then + rm -f "${resolved_path}" + return 1 + fi + + cp "${resolved_path}" "${path}" + rm -f "${resolved_path}" +} + +short_sha="${source_sha:0:12}" +source_slug="${source_branch//\//_}" +dest_slug="${DESTINATION_BRANCH//\//_}" +checkpoint_branch="checkpoint/${dest_slug}_from_${source_slug}_${short_sha}" +pr_title="checkpoint: into ${DESTINATION_BRANCH} from ${source_branch} @ ${short_sha}" +merge_conflicted=false +conflict_summary="" + +existing_pr="$( + gh pr list \ + --repo "${GITHUB_REPOSITORY}" \ + --head "${checkpoint_branch}" \ + --state all \ + --json number,state,mergedAt,url \ + --jq '[.[] | select(.state == "OPEN" or .mergedAt != null)] | .[0] // empty' +)" +if [[ -n "${existing_pr}" ]]; then + existing_url="$(jq -r '.url' <<< "${existing_pr}")" + existing_state="$(jq -r '.state' <<< "${existing_pr}")" + echo "Checkpoint PR already exists for ${checkpoint_branch}: ${existing_url} (${existing_state})." + if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + echo "pr_url=${existing_url}" >> "${GITHUB_OUTPUT}" + fi + exit 0 +fi + +git checkout -B "${checkpoint_branch}" "origin/${DESTINATION_BRANCH}" + +if ! git merge --no-ff --no-edit "${source_sha}"; then + mapfile -t conflicted_paths < <(git diff --name-only --diff-filter=U) + for conflicted_path in "${conflicted_paths[@]}"; do + if termux_is_checkpoint_release_only_path "${conflicted_path}"; then + echo "Auto-resolving release-only checkpoint conflict in ${conflicted_path} by keeping ${DESTINATION_BRANCH}." + if git cat-file -e "HEAD:${conflicted_path}" 2>/dev/null; then + git checkout --ours -- "${conflicted_path}" + git add "${conflicted_path}" + else + git rm -f --ignore-unmatch "${conflicted_path}" + fi + fi + done + + mapfile -t remaining_conflicts < <(git diff --name-only --diff-filter=U) + if ((${#remaining_conflicts[@]})); then + cargo_version_conflicts=true + for remaining_conflict in "${remaining_conflicts[@]}"; do + case "${remaining_conflict}" in + codex-rs/Cargo.toml|codex-rs/Cargo.lock) + ;; + *) + cargo_version_conflicts=false + ;; + esac + done + + if [[ "${cargo_version_conflicts}" == "true" ]]; then + for remaining_conflict in "${remaining_conflicts[@]}"; do + if ! resolve_source_version_conflicts "${remaining_conflict}"; then + cargo_version_conflicts=false + break + fi + done + + if [[ "${cargo_version_conflicts}" == "true" ]]; then + echo "Auto-resolving recurring Cargo version checkpoint conflicts by keeping ${source_branch} versions." + git add -- "${remaining_conflicts[@]}" + fi + fi + fi + + mapfile -t remaining_conflicts < <(git diff --name-only --diff-filter=U) + if [[ "${#remaining_conflicts[@]}" -eq 0 ]]; then + git commit --no-edit + else + merge_conflicted=true + conflict_summary="$( + printf '%s\n' "${remaining_conflicts[@]}" | awk '{ print "- `" $0 "`" }' + )" + echo "Automatic checkpoint merge failed; creating a manual-resolution PR instead." >&2 + if git rev-parse -q --verify MERGE_HEAD >/dev/null; then + git merge --abort + fi + git checkout -B "${checkpoint_branch}" "${source_sha}" + fi +fi + +if git cat-file -e "origin/${DESTINATION_BRANCH}:.github" 2>/dev/null; then + git restore --source="origin/${DESTINATION_BRANCH}" --staged --worktree -- .github + mapfile -t added_github_paths < <( + git diff --name-only --diff-filter=A "origin/${DESTINATION_BRANCH}" -- .github + ) + if ((${#added_github_paths[@]})); then + git rm -f --ignore-unmatch -- "${added_github_paths[@]}" + fi +else + git rm -r --ignore-unmatch .github +fi + +while IFS= read -r release_only_path; do + if git cat-file -e "origin/${DESTINATION_BRANCH}:${release_only_path}" 2>/dev/null; then + git restore --source="origin/${DESTINATION_BRANCH}" --staged --worktree -- "${release_only_path}" + else + git rm -f --ignore-unmatch -- "${release_only_path}" + fi +done < <(release_only_checkpoint_paths) + +if ! git diff --quiet || ! git diff --cached --quiet; then + if [[ -e .github || -L .github ]] || git ls-files --error-unmatch -- .github >/dev/null 2>&1; then + git add -A .github + fi + while IFS= read -r release_only_path; do + if [[ -e "${release_only_path}" || -L "${release_only_path}" ]] || git ls-files --error-unmatch -- "${release_only_path}" >/dev/null 2>&1; then + git add -A -- "${release_only_path}" + fi + done < <(release_only_checkpoint_paths) + if [[ "${merge_conflicted}" == "true" ]]; then + git commit -m "checkpoint: prepare ${source_branch} for ${DESTINATION_BRANCH}" + else + git commit --amend --no-edit + fi +fi + +if git diff --quiet "origin/${DESTINATION_BRANCH}" HEAD; then + echo "Checkpoint merge produced no destination changes after release-only files were restored." + exit 0 +fi + +git push --force-with-lease origin "${checkpoint_branch}" + +remaining="$( + git log --first-parent --pretty=format:%H "${source_sha}..origin/${source_branch}" | wc -w +)" + +body_path="${RUNNER_TEMP}/termux-checkpoint-pr.md" +{ + echo "## Termux release checkpoint" + echo + echo "- Source branch: \`${source_branch}\`" + echo "- Source hash: \`${source_sha}\`" + echo "- Destination branch: \`${DESTINATION_BRANCH}\`" + echo "- Remaining first-parent commits on source: ${remaining}" + echo + echo "This PR carries release-train conflict fixes and follow-up changes back into the reusable Termux patch branch." + if [[ "${merge_conflicted}" == "true" ]]; then + echo + echo "## Merge conflicts" + echo + echo "GitHub Actions could not create the checkpoint merge commit automatically, so this PR was created from the source branch state for manual conflict resolution." + echo + echo "Conflicted paths from the failed merge attempt:" + if [[ -n "${conflict_summary}" ]]; then + printf '%s\n' "${conflict_summary}" + else + echo "- Conflict details unavailable" + fi + fi + echo + echo "Release-only workflow files and metadata under \`.github\` were restored to the destination branch versions before opening this PR." +} > "${body_path}" + +pr_url="$( + gh pr create \ + --repo "${GITHUB_REPOSITORY}" \ + --base "${DESTINATION_BRANCH}" \ + --head "${checkpoint_branch}" \ + --title "${pr_title}" \ + --body-file "${body_path}" +)" +gh pr edit "${pr_url}" --repo "${GITHUB_REPOSITORY}" --add-reviewer "${REVIEWER}" || true +gh label create checkpoint --repo "${GITHUB_REPOSITORY}" --color c5def5 --description "Checkpoint merge" --force +gh label create termux-release --repo "${GITHUB_REPOSITORY}" --color 0e8a16 --description "Termux release automation" --force +gh pr edit "${pr_url}" --repo "${GITHUB_REPOSITORY}" --add-label "checkpoint" --add-label "termux-release" + +if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + echo "pr_url=${pr_url}" >> "${GITHUB_OUTPUT}" +fi diff --git a/scripts/termux-create-or-update-mirrored-release.sh b/scripts/termux-create-or-update-mirrored-release.sh new file mode 100755 index 000000000000..5787e894582b --- /dev/null +++ b/scripts/termux-create-or-update-mirrored-release.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash + +# Create a mirrored Termux release or repair an existing release that is missing +# the Android tarball. + +set -euo pipefail + +: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" +: "${GH_TOKEN:?GH_TOKEN is required}" +: "${RUNNER_TEMP:?RUNNER_TEMP is required}" +: "${UPSTREAM_TAG:?UPSTREAM_TAG is required}" +: "${UPSTREAM_REPO:?UPSTREAM_REPO is required}" +: "${UPSTREAM_NAME?UPSTREAM_NAME is required}" +: "${TERMUX_TAG:?TERMUX_TAG is required}" +: "${UPSTREAM_PRERELEASE:?UPSTREAM_PRERELEASE is required}" +: "${UPSTREAM_HTML_URL?UPSTREAM_HTML_URL is required}" +: "${RELEASE_TRAIN?RELEASE_TRAIN is required}" +: "${RELEASE_EXISTS:?RELEASE_EXISTS is required}" +: "${PR_NUMBER:?PR_NUMBER is required}" +: "${HEAD_SHA:?HEAD_SHA is required}" +: "${RELEASE_SHA:?RELEASE_SHA is required}" + +promoted_dir="${PROMOTED_DIR:-promoted}" +asset_path="${promoted_dir}/codex-aarch64-linux-android.tar.gz" + +body_path="${RUNNER_TEMP}/release-body.md" +upstream_body_path="${RUNNER_TEMP}/upstream-release-body.md" +upstream_body_without_changelog_path="${RUNNER_TEMP}/upstream-release-body-without-changelog.md" +if gh release view "${UPSTREAM_TAG}" \ + --repo "${UPSTREAM_REPO}" \ + --json body \ + --jq '.body // ""' > "${upstream_body_path}"; then + awk ' + function heading_level(line, text) { + if (match(line, /^(#{1,6})[[:space:]]+(.+)$/, parts)) { + text = tolower(parts[2]) + sub(/[[:space:]]+#+[[:space:]]*$/, "", text) + gsub(/^[[:space:]]+|[[:space:]]+$/, "", text) + if (text == "changelog" || text == "change log") { + return length(parts[1]) + } + } + return 0 + } + + { + if (!skip) { + level = heading_level($0) + if (level > 0) { + skip = 1 + skip_level = level + next + } + print + next + } + + if (match($0, /^(#{1,6})[[:space:]]+/, parts) && length(parts[1]) <= skip_level) { + skip = 0 + print + } + } + ' "${upstream_body_path}" > "${upstream_body_without_changelog_path}" +else + echo "::warning title=Upstream release notes unavailable::Could not read ${UPSTREAM_REPO} release ${UPSTREAM_TAG}." + : > "${upstream_body_without_changelog_path}" +fi + +{ + echo "Termux Android build for ${UPSTREAM_TAG}." + echo + echo "- Upstream release: ${UPSTREAM_HTML_URL}" + echo "- Release train: \`${RELEASE_TRAIN}\`" + echo "- Promoted PR: #${PR_NUMBER}" + echo "- Promoted PR head SHA: \`${HEAD_SHA}\`" +} > "${body_path}" +if [[ -s "${upstream_body_without_changelog_path}" ]]; then + { + echo + echo "## Upstream release notes" + echo + cat "${upstream_body_without_changelog_path}" + } >> "${body_path}" +fi + +release_title="${UPSTREAM_NAME}" +if [[ -z "${release_title}" || "${release_title}" == "null" ]]; then + release_title="${TERMUX_TAG}" +fi + +if [[ "${RELEASE_EXISTS}" == "true" ]]; then + gh release upload \ + "${TERMUX_TAG}" \ + "${asset_path}#codex-termux" \ + --repo "${GITHUB_REPOSITORY}" \ + --clobber + exit 0 +fi + +release_args=( + gh release create "${TERMUX_TAG}" + --repo "${GITHUB_REPOSITORY}" + --target "${RELEASE_SHA}" + --title "${release_title}" + --notes-file "${body_path}" +) +if [[ "${UPSTREAM_PRERELEASE}" == "true" ]]; then + release_args+=(--prerelease) +fi +release_args+=("${asset_path}#codex-termux") +"${release_args[@]}" diff --git a/scripts/termux-download-release-artifact.sh b/scripts/termux-download-release-artifact.sh new file mode 100755 index 000000000000..2acdba9025a0 --- /dev/null +++ b/scripts/termux-download-release-artifact.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ -z "${HEAD_SHA:-}" ]]; then + echo "HEAD_SHA is required." >&2 + exit 1 +fi + +if [[ -z "${PR_ARTIFACT_NAME:-}" ]]; then + echo "PR_ARTIFACT_NAME is required." >&2 + exit 1 +fi + +promoted_dir="${PROMOTED_DIR:-promoted}" + +find_run_id() { + local event="$1" + gh run list \ + --repo "${GITHUB_REPOSITORY}" \ + --workflow rust-release.yml \ + --event "${event}" \ + --status success \ + --commit "${HEAD_SHA}" \ + --limit 1 \ + --json databaseId \ + --jq '.[0].databaseId // empty' +} + +find_artifact_run_id() { + local artifact_name="$1" + + gh api --paginate "repos/${GITHUB_REPOSITORY}/actions/artifacts?name=${artifact_name}" \ + | jq -rs \ + --arg artifact_name "${artifact_name}" \ + --arg head_sha "${HEAD_SHA}" \ + ' + [ + .[].artifacts[] + | select(.name == $artifact_name) + | select(.expired == false) + | select(.workflow_run.head_sha == $head_sha) + ] + | sort_by(.created_at) + | reverse + | .[0].workflow_run.id // empty + ' +} + +artifact_name="${PR_ARTIFACT_NAME}" +run_id="$(find_artifact_run_id "${artifact_name}")" +mkdir -p "${promoted_dir}" +if [[ -n "${run_id}" ]] && gh run download "${run_id}" \ + --repo "${GITHUB_REPOSITORY}" \ + --name "${artifact_name}" \ + --dir "${promoted_dir}"; then + : +else + artifact_name="aarch64-linux-android" + run_id="$(find_run_id workflow_dispatch)" + if [[ -z "${run_id}" ]]; then + echo "No successful rust-release run found for ${HEAD_SHA}" >&2 + exit 1 + fi + gh run download "${run_id}" \ + --repo "${GITHUB_REPOSITORY}" \ + --name "${artifact_name}" \ + --dir "${promoted_dir}" +fi + +ls -la "${promoted_dir}" +if [[ -f "${promoted_dir}/SHA256SUMS" ]]; then + (cd "${promoted_dir}" && sha256sum -c SHA256SUMS) +fi +if [[ ! -f "${promoted_dir}/codex-aarch64-linux-android.tar.gz" ]]; then + echo "Expected ${promoted_dir}/codex-aarch64-linux-android.tar.gz in the downloaded artifact." >&2 + exit 1 +fi diff --git a/scripts/termux-find-release-pr.sh b/scripts/termux-find-release-pr.sh new file mode 100755 index 000000000000..3e35fa42eabe --- /dev/null +++ b/scripts/termux-find-release-pr.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ -n "${INPUT_PR_NUMBER:-}" || -n "${INPUT_PR_HEAD_SHA:-}" ]]; then + if [[ -z "${INPUT_PR_NUMBER:-}" || -z "${INPUT_PR_HEAD_SHA:-}" ]]; then + echo "workflow_dispatch inputs pr_number and pr_head_sha must be provided together." >&2 + exit 1 + fi + + pr_number="${INPUT_PR_NUMBER}" + head_sha="${INPUT_PR_HEAD_SHA}" + head_ref="" +else + if [[ -z "${RELEASE_BRANCH:-}" || -z "${RELEASE_SHA:-}" ]]; then + echo "RELEASE_BRANCH and RELEASE_SHA are required." >&2 + exit 1 + fi + + pr_json="$( + gh pr list \ + --repo "${GITHUB_REPOSITORY}" \ + --base "${RELEASE_BRANCH}" \ + --state merged \ + --limit 100 \ + --json number,headRefOid,headRefName,mergeCommit \ + --jq "[.[] | select(.mergeCommit.oid == \"${RELEASE_SHA}\")] | sort_by(.number) | reverse | .[0] // empty" + )" + if [[ -z "${pr_json}" ]]; then + echo "Unable to find merged PR for ${RELEASE_SHA} into ${RELEASE_BRANCH}" >&2 + exit 1 + fi + + pr_number="$(jq -r '.number' <<< "${pr_json}")" + head_sha="$(jq -r '.headRefOid // .head.sha' <<< "${pr_json}")" + head_ref="$(jq -r '.headRefName // .head.ref' <<< "${pr_json}")" +fi + +artifact_name="termux-android-pr-${pr_number}-${head_sha}" +if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + { + echo "number=${pr_number}" + echo "head_sha=${head_sha}" + echo "head_ref=${head_ref}" + echo "artifact_name=${artifact_name}" + } >> "${GITHUB_OUTPUT}" +else + printf 'number=%s\nhead_sha=%s\nhead_ref=%s\nartifact_name=%s\n' \ + "${pr_number}" \ + "${head_sha}" \ + "${head_ref}" \ + "${artifact_name}" +fi diff --git a/scripts/termux-read-release-metadata.sh b/scripts/termux-read-release-metadata.sh new file mode 100755 index 000000000000..519a0635a368 --- /dev/null +++ b/scripts/termux-read-release-metadata.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash + +# Parse .github/termux-release.json and emit workflow outputs for deploy or +# promote jobs. TERMUX_RELEASE_ACTION must be "deploy" or "promote". + +set -euo pipefail + +: "${GITHUB_OUTPUT:?GITHUB_OUTPUT is required}" +: "${GH_TOKEN:?GH_TOKEN is required}" + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + +metadata=".github/termux-release.json" +action="${TERMUX_RELEASE_ACTION:-deploy}" +case "${action}" in + deploy) + missing_context="deployment" + ;; + promote) + missing_context="promotion" + ;; + *) + echo "TERMUX_RELEASE_ACTION must be deploy or promote." >&2 + exit 1 + ;; +esac + +if [[ ! -f "${metadata}" ]]; then + echo "No ${metadata}; this push is not a Termux release ${missing_context}." + echo "${action}=false" >> "${GITHUB_OUTPUT}" + exit 0 +fi + +upstream_tag="$(jq -r '.upstream_tag // empty' "${metadata}")" +upstream_name="$(jq -r '.upstream_name // .upstream_tag // empty' "${metadata}")" +termux_tag="$(jq -r '.termux_tag // empty' "${metadata}")" +upstream_version="${upstream_tag#rust-v}" +upstream_version="${upstream_version%-termux}" +upstream_prerelease=false +if [[ "${upstream_version}" == *-* ]]; then + upstream_prerelease=true +fi +upstream_html_url="$(jq -r '.upstream_html_url // ""' "${metadata}")" +upstream_repo="$(jq -r '.upstream_repo // "openai/codex"' "${metadata}")" +release_train="$(jq -r '.release_train // ""' "${metadata}")" +if [[ -z "${upstream_tag}" || -z "${termux_tag}" ]]; then + echo "Missing upstream_tag or termux_tag in ${metadata}" >&2 + exit 1 +fi + +release_state="$(TERMUX_TAG="${termux_tag}" "${script_dir}/termux-release-asset-state.sh")" +release_exists="$(awk -F= '$1 == "release_exists" { print $2 }' <<< "${release_state}")" +asset_exists="$(awk -F= '$1 == "asset_exists" { print $2 }' <<< "${release_state}")" + +if [[ "${action}" == "promote" ]]; then + if [[ "${asset_exists}" == "true" ]]; then + echo "${termux_tag} already exists with codex-aarch64-linux-android.tar.gz; skipping promotion." + echo "promote=false" >> "${GITHUB_OUTPUT}" + exit 0 + fi + if [[ "${release_exists}" == "true" ]]; then + echo "${termux_tag} exists but is missing codex-aarch64-linux-android.tar.gz; repairing promotion." + fi +fi + +{ + echo "${action}=true" + echo "upstream_tag=${upstream_tag}" + echo "upstream_name=${upstream_name}" + echo "termux_tag=${termux_tag}" + echo "upstream_prerelease=${upstream_prerelease}" + echo "upstream_html_url=${upstream_html_url}" + echo "upstream_repo=${upstream_repo}" + echo "release_train=${release_train}" + echo "release_exists=${release_exists}" + echo "asset_exists=${asset_exists}" +} >> "${GITHUB_OUTPUT}" diff --git a/scripts/termux-release-asset-state.sh b/scripts/termux-release-asset-state.sh new file mode 100755 index 000000000000..8694480b6a36 --- /dev/null +++ b/scripts/termux-release-asset-state.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash + +set -euo pipefail + +termux_tag="${1:-${TERMUX_TAG:-}}" +release_repo="${RELEASE_REPO:-${GITHUB_REPOSITORY:-}}" +asset_name="${ASSET_NAME:-codex-aarch64-linux-android.tar.gz}" + +if [[ -z "${termux_tag}" ]]; then + echo "TERMUX_TAG or tag argument is required." >&2 + exit 1 +fi + +if [[ -z "${release_repo}" ]]; then + echo "GITHUB_REPOSITORY or RELEASE_REPO is required." >&2 + exit 1 +fi + +release_exists=false +asset_exists=false + +if gh release view "${termux_tag}" --repo "${release_repo}" >/dev/null 2>&1; then + release_exists=true + release_asset_exists="$( + gh release view "${termux_tag}" \ + --repo "${release_repo}" \ + --json assets \ + | jq -r --arg asset_name "${asset_name}" \ + '.assets | map(.name) | any(. == $asset_name)' + )" + if [[ "${release_asset_exists}" == "true" ]]; then + asset_exists=true + fi +fi + +printf 'release_exists=%s\n' "${release_exists}" +printf 'asset_exists=%s\n' "${asset_exists}" diff --git a/scripts/termux-release-paths.sh b/scripts/termux-release-paths.sh new file mode 100755 index 000000000000..6990f323ec44 --- /dev/null +++ b/scripts/termux-release-paths.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash + +# Sourceable path lists for files owned by the Termux release automation. + +set -euo pipefail + +readonly -a TERMUX_RELEASE_WORKFLOW_PATHS=( + .github/workflows/rust-release.yml + .github/workflows/shell-tool-mcp.yml + .github/workflows/termux-release-checkpoint.yml + .github/workflows/termux-release-deploy.yml + .github/workflows/termux-release-promote.yml +) + +readonly -a TERMUX_RELEASE_BRANCH_SCRIPT_PATHS=( + scripts/termux-configure-git.sh + scripts/termux-create-checkpoint-pr.sh + scripts/termux-create-or-update-mirrored-release.sh + scripts/termux-download-release-artifact.sh + scripts/termux-find-release-pr.sh + scripts/termux-read-release-metadata.sh + scripts/termux-release-asset-state.sh + scripts/termux-release-paths.sh + scripts/termux-resolve-release-ref.sh + scripts/termux-validate-gh-env.sh +) + +readonly -a TERMUX_RELEASE_AUTOMATION_PATHS=( + "${TERMUX_RELEASE_WORKFLOW_PATHS[@]}" + "${TERMUX_RELEASE_BRANCH_SCRIPT_PATHS[@]}" +) + +readonly -a TERMUX_CHECKPOINT_RELEASE_ONLY_PATHS=( + "${TERMUX_RELEASE_AUTOMATION_PATHS[@]}" + .github/termux-release.json +) + +termux_path_in_list() { + local candidate="$1" + shift + local listed_path + + for listed_path in "$@"; do + [[ "${candidate}" != "${listed_path}" ]] || return 0 + done + return 1 +} + +termux_is_release_automation_path() { + termux_path_in_list "$1" "${TERMUX_RELEASE_AUTOMATION_PATHS[@]}" +} + +termux_is_checkpoint_release_only_path() { + termux_path_in_list "$1" "${TERMUX_CHECKPOINT_RELEASE_ONLY_PATHS[@]}" +} diff --git a/scripts/termux-resolve-release-ref.sh b/scripts/termux-resolve-release-ref.sh new file mode 100755 index 000000000000..194773d45bed --- /dev/null +++ b/scripts/termux-resolve-release-ref.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash + +# Resolve the release branch and SHA for deploy/promote workflows. + +set -euo pipefail + +: "${GITHUB_EVENT_NAME:?GITHUB_EVENT_NAME is required}" +: "${GITHUB_OUTPUT:?GITHUB_OUTPUT is required}" + +input_release_branch="${INPUT_RELEASE_BRANCH:-${REQUESTED_RELEASE_BRANCH:-}}" +input_release_sha="${INPUT_RELEASE_SHA:-${REQUESTED_RELEASE_SHA:-}}" + +if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then + if [[ -z "${input_release_branch}" ]]; then + echo "release_branch is required for workflow_dispatch." >&2 + exit 1 + fi + + release_branch="${input_release_branch}" + if [[ -n "${input_release_sha}" ]]; then + git checkout --detach "${input_release_sha}" + release_sha="${input_release_sha}" + else + release_sha="$(git rev-parse HEAD)" + fi +else + : "${GITHUB_REF_NAME:?GITHUB_REF_NAME is required}" + : "${GITHUB_SHA:?GITHUB_SHA is required}" + release_branch="${GITHUB_REF_NAME}" + release_sha="${GITHUB_SHA}" +fi + +{ + echo "branch=${release_branch}" + echo "sha=${release_sha}" +} >> "${GITHUB_OUTPUT}" diff --git a/scripts/termux-validate-gh-env.sh b/scripts/termux-validate-gh-env.sh new file mode 100755 index 000000000000..02581fdebceb --- /dev/null +++ b/scripts/termux-validate-gh-env.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash + +# Lightweight post-toolbox check for jobs that rely on authenticated gh calls. + +set -euo pipefail + +: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" +: "${GH_TOKEN:?GH_TOKEN is required}" + +command -v gh +gh auth status --hostname github.com + +printf 'GITHUB_REPOSITORY=%s\n' "${GITHUB_REPOSITORY}" +printf 'REPO=%s\n' "${REPO:-}" +printf 'GH_REPO_URL=%s\n' "${GH_REPO_URL:-}" +printf 'GH_WORKFLOW_URL=%s\n' "${GH_WORKFLOW_URL:-}" From 289dd1edaba7c167895cc0a014d46f9be79e311d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 01:23:52 +0000 Subject: [PATCH 13/64] Prepare Termux rust-v0.133.0-alpha.3 --- .github/termux-release.json | 15 ++ codex-rs/Cargo.lock | 247 ++++++++++++++------------- codex-rs/Cargo.toml | 2 + codex-rs/arg0/Cargo.toml | 1 + codex-rs/arg0/src/lib.rs | 29 +++- codex-rs/core/Cargo.toml | 5 + codex-rs/core/src/installation_id.rs | 8 +- codex-rs/execpolicy/Cargo.toml | 1 + codex-rs/execpolicy/src/amend.rs | 23 ++- codex-rs/tui/Cargo.toml | 2 +- codex-rs/tui/src/lib.rs | 8 +- codex-rs/utils/file-lock/BUILD.bazel | 6 + codex-rs/utils/file-lock/Cargo.toml | 10 ++ codex-rs/utils/file-lock/src/lib.rs | 168 ++++++++++++++++++ justfile | 5 + scripts/termux-lock-audit.sh | 126 ++++++++++++++ 16 files changed, 518 insertions(+), 138 deletions(-) create mode 100644 .github/termux-release.json create mode 100644 codex-rs/utils/file-lock/BUILD.bazel create mode 100644 codex-rs/utils/file-lock/Cargo.toml create mode 100644 codex-rs/utils/file-lock/src/lib.rs create mode 100755 scripts/termux-lock-audit.sh diff --git a/.github/termux-release.json b/.github/termux-release.json new file mode 100644 index 000000000000..86ebe8919467 --- /dev/null +++ b/.github/termux-release.json @@ -0,0 +1,15 @@ +{ + "upstream_repo": "openai/codex", + "upstream_tag": "rust-v0.133.0-alpha.3", + "upstream_name": "0.133.0-alpha.3", + "upstream_html_url": "https://github.com/openai/codex/releases/tag/rust-v0.133.0-alpha.3", + "upstream_target": "main", + "upstream_release_id": "326321425", + "upstream_prerelease": true, + "release_train": "0.133.0", + "release_branch": "release/0.133.0", + "work_branch": "upstream/rust-v0.133.0", + "patch_branch": "wallentx/termux-target", + "patch_source_sha": "80124321fabd642ef157e775498b2a9dc0b24581", + "termux_tag": "rust-v0.133.0-alpha.3-termux" +} diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 823eb6d3c1a1..38395b2766f6 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -402,7 +402,7 @@ checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" [[package]] name = "app_test_support" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "base64 0.22.1", @@ -1778,7 +1778,7 @@ dependencies = [ [[package]] name = "codex-agent-graph-store" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "codex-protocol", @@ -1793,7 +1793,7 @@ dependencies = [ [[package]] name = "codex-agent-identity" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "base64 0.22.1", @@ -1812,7 +1812,7 @@ dependencies = [ [[package]] name = "codex-analytics" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-app-server-protocol", "codex-git-utils", @@ -1832,7 +1832,7 @@ dependencies = [ [[package]] name = "codex-ansi-escape" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "ansi-to-tui", "ratatui", @@ -1841,7 +1841,7 @@ dependencies = [ [[package]] name = "codex-api" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "assert_matches", @@ -1875,7 +1875,7 @@ dependencies = [ [[package]] name = "codex-app-server" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "app_test_support", @@ -1960,7 +1960,7 @@ dependencies = [ [[package]] name = "codex-app-server-client" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-app-server", "codex-app-server-protocol", @@ -1987,7 +1987,7 @@ dependencies = [ [[package]] name = "codex-app-server-daemon" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "codex-app-server-protocol", @@ -2008,7 +2008,7 @@ dependencies = [ [[package]] name = "codex-app-server-protocol" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "clap", @@ -2035,7 +2035,7 @@ dependencies = [ [[package]] name = "codex-app-server-test-client" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "clap", @@ -2056,7 +2056,7 @@ dependencies = [ [[package]] name = "codex-app-server-transport" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "axum", @@ -2095,7 +2095,7 @@ dependencies = [ [[package]] name = "codex-apply-patch" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "assert_cmd", @@ -2114,7 +2114,7 @@ dependencies = [ [[package]] name = "codex-arg0" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "codex-apply-patch", @@ -2123,6 +2123,7 @@ dependencies = [ "codex-sandboxing", "codex-shell-escalation", "codex-utils-absolute-path", + "codex-utils-file-lock", "codex-utils-home-dir", "dotenvy", "tempfile", @@ -2131,7 +2132,7 @@ dependencies = [ [[package]] name = "codex-async-utils" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "pretty_assertions", @@ -2141,7 +2142,7 @@ dependencies = [ [[package]] name = "codex-aws-auth" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "aws-config", "aws-credential-types", @@ -2156,7 +2157,7 @@ dependencies = [ [[package]] name = "codex-backend-client" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "codex-api", @@ -2173,7 +2174,7 @@ dependencies = [ [[package]] name = "codex-backend-openapi-models" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "serde", "serde_json", @@ -2182,7 +2183,7 @@ dependencies = [ [[package]] name = "codex-bwrap" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "cc", "libc", @@ -2191,7 +2192,7 @@ dependencies = [ [[package]] name = "codex-chatgpt" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "clap", @@ -2214,7 +2215,7 @@ dependencies = [ [[package]] name = "codex-cli" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "assert_cmd", @@ -2279,7 +2280,7 @@ dependencies = [ [[package]] name = "codex-client" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "bytes", @@ -2310,7 +2311,7 @@ dependencies = [ [[package]] name = "codex-cloud-requirements" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "base64 0.22.1", @@ -2336,7 +2337,7 @@ dependencies = [ [[package]] name = "codex-cloud-tasks" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "async-trait", @@ -2368,7 +2369,7 @@ dependencies = [ [[package]] name = "codex-cloud-tasks-client" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "async-trait", @@ -2383,7 +2384,7 @@ dependencies = [ [[package]] name = "codex-cloud-tasks-mock-client" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "chrono", @@ -2393,7 +2394,7 @@ dependencies = [ [[package]] name = "codex-code-mode" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-channel", "async-trait", @@ -2410,11 +2411,11 @@ dependencies = [ [[package]] name = "codex-collaboration-mode-templates" -version = "0.0.0" +version = "0.132.0-alpha.1" [[package]] name = "codex-config" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "async-trait", @@ -2461,7 +2462,7 @@ dependencies = [ [[package]] name = "codex-connectors" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "codex-app-server-protocol", @@ -2477,7 +2478,7 @@ dependencies = [ [[package]] name = "codex-core" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "arc-swap", @@ -2531,6 +2532,7 @@ dependencies = [ "codex-utils-absolute-path", "codex-utils-cache", "codex-utils-cargo-bin", + "codex-utils-file-lock", "codex-utils-home-dir", "codex-utils-image", "codex-utils-output-truncation", @@ -2595,7 +2597,7 @@ dependencies = [ [[package]] name = "codex-core-api" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-analytics", "codex-app-server-protocol", @@ -2614,7 +2616,7 @@ dependencies = [ [[package]] name = "codex-core-plugins" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "chrono", @@ -2653,7 +2655,7 @@ dependencies = [ [[package]] name = "codex-core-skills" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "codex-analytics", @@ -2684,7 +2686,7 @@ dependencies = [ [[package]] name = "codex-debug-client" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "clap", @@ -2696,7 +2698,7 @@ dependencies = [ [[package]] name = "codex-exec" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "assert_cmd", @@ -2742,7 +2744,7 @@ dependencies = [ [[package]] name = "codex-exec-server" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "arc-swap", @@ -2783,11 +2785,12 @@ dependencies = [ [[package]] name = "codex-execpolicy" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "clap", "codex-utils-absolute-path", + "codex-utils-file-lock", "multimap", "pretty_assertions", "serde", @@ -2800,7 +2803,7 @@ dependencies = [ [[package]] name = "codex-execpolicy-legacy" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "allocative", "anyhow", @@ -2820,7 +2823,7 @@ dependencies = [ [[package]] name = "codex-experimental-api-macros" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "proc-macro2", "quote", @@ -2829,7 +2832,7 @@ dependencies = [ [[package]] name = "codex-extension-api" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "codex-protocol", @@ -2838,7 +2841,7 @@ dependencies = [ [[package]] name = "codex-external-agent-migration" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-hooks", "pretty_assertions", @@ -2850,7 +2853,7 @@ dependencies = [ [[package]] name = "codex-external-agent-sessions" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "chrono", "codex-app-server-protocol", @@ -2864,7 +2867,7 @@ dependencies = [ [[package]] name = "codex-features" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-otel", "codex-protocol", @@ -2877,7 +2880,7 @@ dependencies = [ [[package]] name = "codex-feedback" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "codex-login", @@ -2890,7 +2893,7 @@ dependencies = [ [[package]] name = "codex-file-search" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "clap", @@ -2906,7 +2909,7 @@ dependencies = [ [[package]] name = "codex-file-system" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "codex-protocol", @@ -2916,7 +2919,7 @@ dependencies = [ [[package]] name = "codex-file-watcher" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "notify", "pretty_assertions", @@ -2927,7 +2930,7 @@ dependencies = [ [[package]] name = "codex-git-utils" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "chrono", @@ -2951,7 +2954,7 @@ dependencies = [ [[package]] name = "codex-goal-extension" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "async-trait", @@ -2970,7 +2973,7 @@ dependencies = [ [[package]] name = "codex-guardian" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "codex-core", @@ -2980,7 +2983,7 @@ dependencies = [ [[package]] name = "codex-hooks" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "chrono", @@ -3003,7 +3006,7 @@ dependencies = [ [[package]] name = "codex-install-context" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-utils-absolute-path", "codex-utils-home-dir", @@ -3013,7 +3016,7 @@ dependencies = [ [[package]] name = "codex-keyring-store" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "keyring", "tracing", @@ -3021,7 +3024,7 @@ dependencies = [ [[package]] name = "codex-linux-sandbox" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "clap", "codex-core", @@ -3045,7 +3048,7 @@ dependencies = [ [[package]] name = "codex-lmstudio" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-core", "codex-model-provider-info", @@ -3059,7 +3062,7 @@ dependencies = [ [[package]] name = "codex-login" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "async-trait", @@ -3101,7 +3104,7 @@ dependencies = [ [[package]] name = "codex-mcp" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "async-channel", @@ -3133,7 +3136,7 @@ dependencies = [ [[package]] name = "codex-mcp-server" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "codex-arg0", @@ -3165,7 +3168,7 @@ dependencies = [ [[package]] name = "codex-memories-extension" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "codex-core", @@ -3186,7 +3189,7 @@ dependencies = [ [[package]] name = "codex-memories-mcp" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "codex-utils-absolute-path", @@ -3203,7 +3206,7 @@ dependencies = [ [[package]] name = "codex-memories-read" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-protocol", "codex-shell-command", @@ -3217,7 +3220,7 @@ dependencies = [ [[package]] name = "codex-memories-write" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "chrono", @@ -3252,7 +3255,7 @@ dependencies = [ [[package]] name = "codex-message-history" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-config", "pretty_assertions", @@ -3265,7 +3268,7 @@ dependencies = [ [[package]] name = "codex-model-provider" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "codex-agent-identity", @@ -3289,7 +3292,7 @@ dependencies = [ [[package]] name = "codex-model-provider-info" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-api", "codex-app-server-protocol", @@ -3306,7 +3309,7 @@ dependencies = [ [[package]] name = "codex-models-manager" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "chrono", @@ -3327,7 +3330,7 @@ dependencies = [ [[package]] name = "codex-network-proxy" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "async-trait", @@ -3358,7 +3361,7 @@ dependencies = [ [[package]] name = "codex-ollama" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "assert_matches", "async-stream", @@ -3377,7 +3380,7 @@ dependencies = [ [[package]] name = "codex-otel" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "chrono", "codex-api", @@ -3409,7 +3412,7 @@ dependencies = [ [[package]] name = "codex-plugin" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-config", "codex-utils-absolute-path", @@ -3419,7 +3422,7 @@ dependencies = [ [[package]] name = "codex-process-hardening" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "libc", "pretty_assertions", @@ -3427,7 +3430,7 @@ dependencies = [ [[package]] name = "codex-protocol" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "chardetng", @@ -3467,7 +3470,7 @@ dependencies = [ [[package]] name = "codex-realtime-webrtc" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "libwebrtc", "thiserror 2.0.18", @@ -3476,7 +3479,7 @@ dependencies = [ [[package]] name = "codex-response-debug-context" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "base64 0.22.1", "codex-api", @@ -3487,7 +3490,7 @@ dependencies = [ [[package]] name = "codex-responses-api-proxy" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "clap", @@ -3504,7 +3507,7 @@ dependencies = [ [[package]] name = "codex-rmcp-client" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "axum", @@ -3542,7 +3545,7 @@ dependencies = [ [[package]] name = "codex-rollout" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "async-trait", @@ -3567,7 +3570,7 @@ dependencies = [ [[package]] name = "codex-rollout-trace" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "codex-code-mode", @@ -3583,7 +3586,7 @@ dependencies = [ [[package]] name = "codex-sandboxing" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "async-trait", @@ -3604,7 +3607,7 @@ dependencies = [ [[package]] name = "codex-secrets" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "age", "anyhow", @@ -3625,7 +3628,7 @@ dependencies = [ [[package]] name = "codex-shell-command" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "base64 0.22.1", @@ -3645,7 +3648,7 @@ dependencies = [ [[package]] name = "codex-shell-escalation" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "async-trait", @@ -3666,7 +3669,7 @@ dependencies = [ [[package]] name = "codex-skills" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-utils-absolute-path", "include_dir", @@ -3675,7 +3678,7 @@ dependencies = [ [[package]] name = "codex-state" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "chrono", @@ -3698,7 +3701,7 @@ dependencies = [ [[package]] name = "codex-stdio-to-uds" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "codex-uds", @@ -3710,7 +3713,7 @@ dependencies = [ [[package]] name = "codex-terminal-detection" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "pretty_assertions", "tracing", @@ -3718,7 +3721,7 @@ dependencies = [ [[package]] name = "codex-test-binary-support" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-arg0", "tempfile", @@ -3726,7 +3729,7 @@ dependencies = [ [[package]] name = "codex-thread-manager-sample" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "clap", @@ -3737,7 +3740,7 @@ dependencies = [ [[package]] name = "codex-thread-store" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "chrono", @@ -3758,7 +3761,7 @@ dependencies = [ [[package]] name = "codex-tools" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "codex-app-server-protocol", @@ -3779,7 +3782,7 @@ dependencies = [ [[package]] name = "codex-tui" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "arboard", @@ -3890,7 +3893,7 @@ dependencies = [ [[package]] name = "codex-uds" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-io", "pretty_assertions", @@ -3902,7 +3905,7 @@ dependencies = [ [[package]] name = "codex-utils-absolute-path" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "dirs", "dunce", @@ -3916,14 +3919,14 @@ dependencies = [ [[package]] name = "codex-utils-approval-presets" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-protocol", ] [[package]] name = "codex-utils-cache" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "lru 0.16.3", "sha1", @@ -3932,7 +3935,7 @@ dependencies = [ [[package]] name = "codex-utils-cargo-bin" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "assert_cmd", "runfiles", @@ -3941,7 +3944,7 @@ dependencies = [ [[package]] name = "codex-utils-cli" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "clap", "codex-protocol", @@ -3953,15 +3956,19 @@ dependencies = [ [[package]] name = "codex-utils-elapsed" -version = "0.0.0" +version = "0.132.0-alpha.1" + +[[package]] +name = "codex-utils-file-lock" +version = "0.132.0-alpha.1" [[package]] name = "codex-utils-fuzzy-match" -version = "0.0.0" +version = "0.132.0-alpha.1" [[package]] name = "codex-utils-home-dir" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-utils-absolute-path", "dirs", @@ -3971,7 +3978,7 @@ dependencies = [ [[package]] name = "codex-utils-image" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "base64 0.22.1", "codex-utils-cache", @@ -3983,7 +3990,7 @@ dependencies = [ [[package]] name = "codex-utils-json-to-toml" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "pretty_assertions", "serde_json", @@ -3992,7 +3999,7 @@ dependencies = [ [[package]] name = "codex-utils-oss" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-core", "codex-lmstudio", @@ -4002,7 +4009,7 @@ dependencies = [ [[package]] name = "codex-utils-output-truncation" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-protocol", "codex-utils-string", @@ -4011,7 +4018,7 @@ dependencies = [ [[package]] name = "codex-utils-path" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-utils-absolute-path", "dunce", @@ -4021,7 +4028,7 @@ dependencies = [ [[package]] name = "codex-utils-plugins" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-exec-server", "codex-login", @@ -4034,7 +4041,7 @@ dependencies = [ [[package]] name = "codex-utils-pty" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "filedescriptor", @@ -4050,7 +4057,7 @@ dependencies = [ [[package]] name = "codex-utils-readiness" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "assert_matches", "async-trait", @@ -4061,14 +4068,14 @@ dependencies = [ [[package]] name = "codex-utils-rustls-provider" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "rustls", ] [[package]] name = "codex-utils-sandbox-summary" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-core", "codex-model-provider-info", @@ -4079,7 +4086,7 @@ dependencies = [ [[package]] name = "codex-utils-sleep-inhibitor" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "core-foundation 0.9.4", "libc", @@ -4089,14 +4096,14 @@ dependencies = [ [[package]] name = "codex-utils-stream-parser" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "pretty_assertions", ] [[package]] name = "codex-utils-string" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "pretty_assertions", "regex-lite", @@ -4106,14 +4113,14 @@ dependencies = [ [[package]] name = "codex-utils-template" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "pretty_assertions", ] [[package]] name = "codex-v8-poc" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "pretty_assertions", "v8", @@ -4121,7 +4128,7 @@ dependencies = [ [[package]] name = "codex-windows-sandbox" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "base64 0.22.1", @@ -4372,7 +4379,7 @@ dependencies = [ [[package]] name = "core_test_support" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "assert_cmd", @@ -5486,7 +5493,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8571,7 +8578,7 @@ dependencies = [ [[package]] name = "mcp_test_support" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "codex-login", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 9dfb0bf3d316..ce3b943429e7 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -81,6 +81,7 @@ members = [ "v8-poc", "utils/absolute-path", "utils/cargo-bin", + "utils/file-lock", "git-utils", "utils/cache", "utils/image", @@ -219,6 +220,7 @@ codex-utils-cache = { path = "utils/cache" } codex-utils-cargo-bin = { path = "utils/cargo-bin" } codex-utils-cli = { path = "utils/cli" } codex-utils-elapsed = { path = "utils/elapsed" } +codex-utils-file-lock = { path = "utils/file-lock" } codex-utils-fuzzy-match = { path = "utils/fuzzy-match" } codex-utils-home-dir = { path = "utils/home-dir" } codex-utils-image = { path = "utils/image" } diff --git a/codex-rs/arg0/Cargo.toml b/codex-rs/arg0/Cargo.toml index 7ee21a770e49..7c2c17692020 100644 --- a/codex-rs/arg0/Cargo.toml +++ b/codex-rs/arg0/Cargo.toml @@ -20,6 +20,7 @@ codex-linux-sandbox = { workspace = true } codex-sandboxing = { workspace = true } codex-shell-escalation = { workspace = true } codex-utils-absolute-path = { workspace = true } +codex-utils-file-lock = { workspace = true } codex-utils-home-dir = { workspace = true } dotenvy = { workspace = true } tempfile = { workspace = true } diff --git a/codex-rs/arg0/src/lib.rs b/codex-rs/arg0/src/lib.rs index 2f6ae4653c65..5a558769a7e5 100644 --- a/codex-rs/arg0/src/lib.rs +++ b/codex-rs/arg0/src/lib.rs @@ -6,6 +6,8 @@ use std::path::PathBuf; use codex_apply_patch::CODEX_CORE_APPLY_PATCH_ARG1; use codex_exec_server::CODEX_FS_HELPER_ARG1; use codex_sandboxing::landlock::CODEX_LINUX_SANDBOX_ARG0; +use codex_utils_file_lock::TryFileLockOutcome; +use codex_utils_file_lock::try_lock_exclusive_optional; use codex_utils_home_dir::find_codex_home; #[cfg(unix)] use std::os::unix::fs::symlink; @@ -33,12 +35,12 @@ pub struct Arg0DispatchPaths { /// Keeps the per-session PATH entry alive and locked for the process lifetime. pub struct Arg0PathEntryGuard { _temp_dir: TempDir, - _lock_file: File, + _lock_file: Option, paths: Arg0DispatchPaths, } impl Arg0PathEntryGuard { - fn new(temp_dir: TempDir, lock_file: File, paths: Arg0DispatchPaths) -> Self { + fn new(temp_dir: TempDir, lock_file: Option, paths: Arg0DispatchPaths) -> Self { Self { _temp_dir: temp_dir, _lock_file: lock_file, @@ -326,7 +328,13 @@ pub fn prepend_path_entry_for_codex_aliases() -> std::io::Result Some(lock_file), + TryFileLockOutcome::Unsupported => None, + TryFileLockOutcome::WouldBlock => { + return Err(std::io::Error::from(std::io::ErrorKind::WouldBlock).into()); + } + }; for filename in &[ APPLY_PATCH_ARG0, @@ -445,10 +453,9 @@ fn try_lock_dir(dir: &Path) -> std::io::Result> { Err(err) => return Err(err), }; - match lock_file.try_lock() { - Ok(()) => Ok(Some(lock_file)), - Err(std::fs::TryLockError::WouldBlock) => Ok(None), - Err(err) => Err(err.into()), + match try_lock_exclusive_optional(&lock_file)? { + TryFileLockOutcome::Acquired => Ok(Some(lock_file)), + TryFileLockOutcome::WouldBlock | TryFileLockOutcome::Unsupported => Ok(None), } } @@ -562,7 +569,13 @@ mod tests { let dir = root.path().join("locked"); fs::create_dir(&dir)?; let lock_file = create_lock(&dir)?; - lock_file.try_lock()?; + match try_lock_exclusive_optional(&lock_file)? { + TryFileLockOutcome::Acquired => {} + TryFileLockOutcome::Unsupported => return Ok(()), + TryFileLockOutcome::WouldBlock => { + panic!("newly created lock file should not be locked"); + } + } janitor_cleanup(root.path())?; diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 8472918dcae9..fdf1d4b18a9e 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -63,6 +63,7 @@ codex-thread-store = { workspace = true } codex-tools = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-cache = { workspace = true } +codex-utils-file-lock = { workspace = true } codex-utils-image = { workspace = true } codex-utils-home-dir = { workspace = true } codex-utils-output-truncation = { workspace = true } @@ -125,6 +126,10 @@ openssl-sys = { workspace = true, features = ["vendored"] } [target.aarch64-unknown-linux-musl.dependencies] openssl-sys = { workspace = true, features = ["vendored"] } +# Build OpenSSL from source for Android builds. +[target.aarch64-linux-android.dependencies] +openssl-sys = { workspace = true, features = ["vendored"] } + [target.'cfg(unix)'.dependencies] codex-shell-escalation = { workspace = true } diff --git a/codex-rs/core/src/installation_id.rs b/codex-rs/core/src/installation_id.rs index a42e6b6d8353..a87c660f6132 100644 --- a/codex-rs/core/src/installation_id.rs +++ b/codex-rs/core/src/installation_id.rs @@ -11,6 +11,9 @@ use std::os::unix::fs::OpenOptionsExt; use std::os::unix::fs::PermissionsExt; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_file_lock::FileLockOutcome; +use codex_utils_file_lock::acquire_sibling_lock_dir; +use codex_utils_file_lock::lock_exclusive_optional; use tokio::fs; use uuid::Uuid; @@ -29,7 +32,10 @@ pub async fn resolve_installation_id(codex_home: &AbsolutePathBuf) -> Result None, + FileLockOutcome::Unsupported => Some(acquire_sibling_lock_dir(&path)?), + }; #[cfg(unix)] { diff --git a/codex-rs/execpolicy/Cargo.toml b/codex-rs/execpolicy/Cargo.toml index b22226a79e4b..b87af084d7d1 100644 --- a/codex-rs/execpolicy/Cargo.toml +++ b/codex-rs/execpolicy/Cargo.toml @@ -21,6 +21,7 @@ workspace = true anyhow = { workspace = true } clap = { workspace = true, features = ["derive"] } codex-utils-absolute-path = { workspace = true } +codex-utils-file-lock = { workspace = true } multimap = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } diff --git a/codex-rs/execpolicy/src/amend.rs b/codex-rs/execpolicy/src/amend.rs index e25fd1bd9144..1ef36b1ff5d8 100644 --- a/codex-rs/execpolicy/src/amend.rs +++ b/codex-rs/execpolicy/src/amend.rs @@ -6,6 +6,10 @@ use std::io::Write; use std::path::Path; use std::path::PathBuf; +use codex_utils_file_lock::FileLockOutcome; +use codex_utils_file_lock::acquire_sibling_lock_dir; +use codex_utils_file_lock::lock_exclusive_optional; + use crate::decision::Decision; use crate::rule::NetworkRuleProtocol; use crate::rule::normalize_network_rule_host; @@ -154,10 +158,21 @@ fn append_locked_line(policy_path: &Path, line: &str) -> Result<(), AmendError> path: policy_path.to_path_buf(), source, })?; - file.lock().map_err(|source| AmendError::LockPolicyFile { - path: policy_path.to_path_buf(), - source, - })?; + let _lock_dir_guard = + match lock_exclusive_optional(&file).map_err(|source| AmendError::LockPolicyFile { + path: policy_path.to_path_buf(), + source, + })? { + FileLockOutcome::Acquired => None, + FileLockOutcome::Unsupported => { + Some(acquire_sibling_lock_dir(policy_path).map_err(|source| { + AmendError::LockPolicyFile { + path: policy_path.to_path_buf(), + source, + } + })?) + } + }; file.seek(SeekFrom::Start(0)) .map_err(|source| AmendError::SeekPolicyFile { diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index c213e92bae41..40624baba0d0 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -126,7 +126,7 @@ uuid = { workspace = true } codex-windows-sandbox = { workspace = true } tokio-util = { workspace = true, features = ["time"] } -[target.'cfg(not(target_os = "linux"))'.dependencies] +[target.'cfg(all(not(target_os = "linux"), not(target_os = "android")))'.dependencies] cpal = "0.15" [target.'cfg(unix)'.dependencies] diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 40cd121c7135..8a71fd738e1e 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -89,9 +89,9 @@ mod app_server_approval_conversions; mod app_server_session; mod approval_events; mod ascii_animation; -#[cfg(not(target_os = "linux"))] +#[cfg(not(any(target_os = "linux", target_os = "android")))] mod audio_device; -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "android"))] #[allow(dead_code)] mod audio_device { use crate::app_event::RealtimeAudioDeviceKind; @@ -195,11 +195,11 @@ mod update_prompt; mod update_versions; mod updates; mod version; -#[cfg(not(target_os = "linux"))] +#[cfg(not(any(target_os = "linux", target_os = "android")))] mod voice; mod width; mod workspace_command; -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "android"))] #[allow(dead_code)] mod voice { use crate::app_event_sender::AppEventSender; diff --git a/codex-rs/utils/file-lock/BUILD.bazel b/codex-rs/utils/file-lock/BUILD.bazel new file mode 100644 index 000000000000..face70c53518 --- /dev/null +++ b/codex-rs/utils/file-lock/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "file-lock", + crate_name = "codex_utils_file_lock", +) diff --git a/codex-rs/utils/file-lock/Cargo.toml b/codex-rs/utils/file-lock/Cargo.toml new file mode 100644 index 000000000000..5e3877fc1630 --- /dev/null +++ b/codex-rs/utils/file-lock/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "codex-utils-file-lock" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] diff --git a/codex-rs/utils/file-lock/src/lib.rs b/codex-rs/utils/file-lock/src/lib.rs new file mode 100644 index 000000000000..fdcfbdb81e75 --- /dev/null +++ b/codex-rs/utils/file-lock/src/lib.rs @@ -0,0 +1,168 @@ +use std::fs::File; +use std::fs::create_dir; +use std::fs::remove_dir; +use std::io; +use std::path::Path; +use std::path::PathBuf; +use std::thread; +use std::time::Duration; + +const LOCK_DIR_RETRY_SLEEP: Duration = Duration::from_millis(100); + +/// Result of acquiring a blocking advisory file lock. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FileLockOutcome { + Acquired, + Unsupported, +} + +/// Result of attempting to acquire a non-blocking advisory file lock. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TryFileLockOutcome { + Acquired, + WouldBlock, + Unsupported, +} + +/// Result of attempting to acquire a non-blocking lock directory. +#[derive(Debug, PartialEq, Eq)] +pub enum TryLockDirOutcome { + Acquired(LockDirGuard), + WouldBlock, +} + +/// Guard for a sibling lock directory created with an atomic `mkdir`. +#[derive(Debug, PartialEq, Eq)] +pub struct LockDirGuard { + path: PathBuf, +} + +impl Drop for LockDirGuard { + fn drop(&mut self) { + let _ = remove_dir(&self.path); + } +} + +/// Acquires an exclusive advisory file lock, treating unsupported file locking +/// as a distinct outcome for platforms such as Termux. +pub fn lock_exclusive_optional(file: &File) -> io::Result { + match file.lock() { + Ok(()) => Ok(FileLockOutcome::Acquired), + Err(err) if err.kind() == io::ErrorKind::Unsupported => Ok(FileLockOutcome::Unsupported), + Err(err) => Err(err), + } +} + +/// Attempts to acquire an exclusive advisory file lock without blocking, +/// preserving `WouldBlock` and unsupported file locking as distinct outcomes. +pub fn try_lock_exclusive_optional(file: &File) -> io::Result { + match file.try_lock() { + Ok(()) => Ok(TryFileLockOutcome::Acquired), + Err(std::fs::TryLockError::WouldBlock) => Ok(TryFileLockOutcome::WouldBlock), + Err(std::fs::TryLockError::Error(err)) if err.kind() == io::ErrorKind::Unsupported => { + Ok(TryFileLockOutcome::Unsupported) + } + Err(std::fs::TryLockError::Error(err)) => Err(err), + } +} + +/// Returns the sibling directory path used as a fallback lock for `path`. +pub fn sibling_lock_dir(path: &Path) -> PathBuf { + let Some(file_name) = path.file_name() else { + return path.with_file_name(".lock"); + }; + + let mut lock_name = file_name.to_os_string(); + lock_name.push(".lock"); + path.with_file_name(lock_name) +} + +/// Acquires a sibling lock directory, blocking until it is available. +pub fn acquire_sibling_lock_dir(path: &Path) -> io::Result { + loop { + match try_acquire_sibling_lock_dir(path)? { + TryLockDirOutcome::Acquired(guard) => return Ok(guard), + TryLockDirOutcome::WouldBlock => thread::sleep(LOCK_DIR_RETRY_SLEEP), + } + } +} + +/// Attempts to acquire a sibling lock directory without blocking. +pub fn try_acquire_sibling_lock_dir(path: &Path) -> io::Result { + let lock_dir = sibling_lock_dir(path); + match create_dir(&lock_dir) { + Ok(()) => Ok(TryLockDirOutcome::Acquired(LockDirGuard { path: lock_dir })), + Err(err) if err.kind() == io::ErrorKind::AlreadyExists => Ok(TryLockDirOutcome::WouldBlock), + Err(err) => Err(err), + } +} + +/// Attempts to acquire a shared advisory file lock without blocking, +/// preserving `WouldBlock` and unsupported file locking as distinct outcomes. +pub fn try_lock_shared_optional(file: &File) -> io::Result { + match file.try_lock_shared() { + Ok(()) => Ok(TryFileLockOutcome::Acquired), + Err(std::fs::TryLockError::WouldBlock) => Ok(TryFileLockOutcome::WouldBlock), + Err(std::fs::TryLockError::Error(err)) if err.kind() == io::ErrorKind::Unsupported => { + Ok(TryFileLockOutcome::Unsupported) + } + Err(std::fs::TryLockError::Error(err)) => Err(err), + } +} + +#[cfg(test)] +mod tests { + use super::TryLockDirOutcome; + use super::sibling_lock_dir; + use super::try_acquire_sibling_lock_dir; + use std::fs; + use std::path::PathBuf; + use std::time::SystemTime; + + fn unique_temp_file_path(name: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos(); + std::env::temp_dir().join(format!( + "codex-file-lock-{name}-{}-{nanos}", + std::process::id() + )) + } + + #[test] + fn sibling_lock_dir_appends_lock_suffix() { + let path = PathBuf::from("/tmp/history.jsonl"); + + assert_eq!( + sibling_lock_dir(&path), + PathBuf::from("/tmp/history.jsonl.lock") + ); + } + + #[test] + fn try_acquire_sibling_lock_dir_is_exclusive_until_drop() { + let path = unique_temp_file_path("exclusive"); + let lock_dir = sibling_lock_dir(&path); + let _ = fs::remove_dir_all(&lock_dir); + + let guard = match try_acquire_sibling_lock_dir(&path).expect("acquire lock dir") { + TryLockDirOutcome::Acquired(guard) => guard, + TryLockDirOutcome::WouldBlock => panic!("first lock attempt should acquire"), + }; + assert!(lock_dir.is_dir()); + + assert!(matches!( + try_acquire_sibling_lock_dir(&path).expect("try acquire held lock dir"), + TryLockDirOutcome::WouldBlock + )); + + drop(guard); + assert!(!lock_dir.exists()); + + let reacquired = try_acquire_sibling_lock_dir(&path).expect("reacquire lock dir"); + assert!(matches!(reacquired, TryLockDirOutcome::Acquired(_))); + + let _ = fs::remove_dir_all(lock_dir); + } +} diff --git a/justfile b/justfile index ab2fbc63629a..569a04695216 100644 --- a/justfile +++ b/justfile @@ -116,6 +116,11 @@ argument-comment-lint *args: argument-comment-lint-from-source *args: {{ justfile_directory() }}/tools/argument-comment-lint/run.py "$@" +# Audit advisory file locks that may need Termux Unsupported handling. +[no-cd] +termux-lock-audit *args: + {{ justfile_directory() }}/scripts/termux-lock-audit.sh "$@" + # Tail logs from the state SQLite database log *args: if [ "${1:-}" = "--" ]; then shift; fi; cargo run -p codex-state --bin logs_client -- "$@" diff --git a/scripts/termux-lock-audit.sh b/scripts/termux-lock-audit.sh new file mode 100755 index 000000000000..ac40f8e57246 --- /dev/null +++ b/scripts/termux-lock-audit.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: scripts/termux-lock-audit.sh [--strict] + +Audits Rust advisory file-lock usage that may need Termux compatibility handling. + +By default this script prints findings and exits successfully. With --strict, it +exits non-zero when candidate file-lock calls are found in files that do not also +mention Unsupported/TryLockError handling. +USAGE +} + +strict=false +for arg in "$@"; do + case "$arg" in + --strict) + strict=true + ;; + -h | --help) + usage + exit 0 + ;; + *) + echo "unknown argument: $arg" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if ! command -v rg >/dev/null 2>&1; then + echo "error: rg is required for this audit" >&2 + exit 1 +fi + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repo_root" + +unsupported_pattern='std::fs::TryLockError|fs::TryLockError|TryLockError::|ErrorKind::Unsupported|std::io::ErrorKind::Unsupported' +lock_context_pattern='(\.lock\(\)|\.try_lock\(|\.try_lock_shared\(|TryLockError|ErrorKind::Unsupported)' +candidate_file_lock_pattern='\b([A-Za-z_][A-Za-z0-9_]*_file|file)\.(lock|try_lock|try_lock_shared)\(' +candidate_false_positive_pattern='poisoned' +helper_pattern='codex_utils_file_lock|FileLockOutcome|TryFileLockOutcome|lock_exclusive_optional|try_lock_exclusive_optional|try_lock_shared_optional' + +print_section() { + printf '\n== %s ==\n' "$1" +} + +print_section "Unsupported-aware lock handling" +echo "These files already mention Unsupported/TryLockError and are likely patched or intentionally reviewed:" + +unsupported_count=0 +while IFS= read -r file; do + if rg -q "$lock_context_pattern" "$file"; then + rg -n -H "$lock_context_pattern" "$file" + unsupported_count=$((unsupported_count + 1)) + fi +done < <(rg -l "$unsupported_pattern" codex-rs -g '*.rs' || true) + +if [ "$unsupported_count" -eq 0 ]; then + echo "No unsupported-aware lock handling found." +fi + +print_section "Optional file-lock helper usage" +echo "These files use the shared optional advisory file-lock helper:" + +helper_count=0 +while IFS= read -r file; do + rg -n -H "$helper_pattern" "$file" + helper_count=$((helper_count + 1)) +done < <(rg -l "$helper_pattern" codex-rs -g '*.rs' || true) + +if [ "$helper_count" -eq 0 ]; then + echo "No optional file-lock helper usage found." +fi + +print_section "Candidate file-lock calls for manual review" +echo "These receiver names may be std::fs::File advisory locks, but the file does not mention Unsupported/TryLockError handling." +echo "This section can include false positives, such as mutexes wrapping a file." + +review_count=0 +while IFS= read -r file; do + if ! rg -q "$unsupported_pattern" "$file"; then + matches="$(rg -n -H "$candidate_file_lock_pattern" "$file" | rg -v "$candidate_false_positive_pattern" || true)" + if [ -n "$matches" ]; then + echo "$matches" + review_count=$((review_count + 1)) + fi + fi +done < <(rg -l "$candidate_file_lock_pattern" codex-rs -g '*.rs' || true) + +if [ "$review_count" -eq 0 ]; then + echo "No unpatched candidate file-lock calls found." +fi + +print_section "Candidate file-lock calls already in unsupported-aware files" +echo "These are candidate file-lock calls in files that already mention Unsupported/TryLockError handling:" + +aware_candidate_count=0 +while IFS= read -r file; do + if rg -q "$unsupported_pattern" "$file"; then + matches="$(rg -n -H "$candidate_file_lock_pattern" "$file" | rg -v "$candidate_false_positive_pattern" || true)" + if [ -n "$matches" ]; then + echo "$matches" + aware_candidate_count=$((aware_candidate_count + 1)) + fi + fi +done < <(rg -l "$candidate_file_lock_pattern" codex-rs -g '*.rs' || true) + +if [ "$aware_candidate_count" -eq 0 ]; then + echo "No unsupported-aware candidate file-lock calls found." +fi + +print_section "Summary" +echo "unsupported-aware files: $unsupported_count" +echo "optional helper files: $helper_count" +echo "review candidate files: $review_count" +echo "unsupported-aware candidate files: $aware_candidate_count" + +if [ "$strict" = true ] && [ "$review_count" -gt 0 ]; then + echo "strict mode: manual-review candidate files were found" >&2 + exit 1 +fi From 9474e5cfc4494b0ba319352aa86ce436c59e65c8 Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Thu, 21 May 2026 08:27:49 -0700 Subject: [PATCH 14/64] ## New Features - Goals are now enabled by default, backed by dedicated storage, and track progress across active turns. (#23300, #23685, #23696, #23732) - `codex remote-control` now runs like a foreground command, waits for readiness, reports machine status, and keeps explicit daemon-style `start`/`stop` commands. (#22878) - Permission profiles gained list APIs, inheritance, managed `requirements.toml` support, runtime refresh behavior, and stronger Windows sandbox integration. (#22928, #23412, #22270, #23433, #22931, #23715) - Plugin discovery is easier to inspect, with marketplace-aware list output, installed versions, visible marketplace roots, and remote collection support. (#23372, #23584, #23727, #23730) - Extensions can observe more lifecycle events, including subagent start/stop, tool execution, turn metadata, and async approval/turn processing. (#22782, #22873, #23309, #23688, #23690, #23692) ## Bug Fixes - Fixed TUI startup choosing the wrong working directory when reusing a local app-server socket. (#23538) - Fixed plan-mode free-form answers so modified Enter keys, like Shift+Enter, no longer submit unexpectedly. (#23536) - Removed stale background terminal poll events after a process exits. (#23231) - Preserved raw code-mode exec output unless an explicit output token limit is requested. (#23564) - Made AGENTS instruction loading more reliable, including local global reads and warnings for invalid UTF-8 instead of silent drops. (#23343, #23232) - Fixed app-server startup/shutdown races, empty resume/fork paths, plugin upgrade failures, and realtime v1 websocket compatibility. (#23516, #23578, #23400, #23356, #23771) ## Documentation - Added clearer plugin-creator guidance for updating and reinstalling local personal plugins. (#23542) - Expanded app-server/API docs and schema coverage around managed permission profile requirements. (#23433, #23555) ## Chores - Added a canonical Codex package archive pipeline and moved installers, npm packages, DotSlash, and SDK runtimes toward that shared layout. (#23513, #23582, #23586, #23596, #23635, #23636, #23637, #23638, #23786) - Fixed Linux Python runtime wheel tags so glibc-based systems can install the runtime artifacts. (#21812) - Improved release and CI reliability with package-builder tests, prebuilt resource packaging, DotSlash zstd handling, platform-sharded Rust tests, and Codex Linux release runners. (#23760, #23759, #23752, #23358, #23761) ## Changelog Full Changelog: https://github.com/openai/codex/compare/rust-v0.132.0...rust-v0.133.0 - #23343 codex: route global AGENTS reads through LOCAL_FS @starr-openai - #22380 fix: default unknown tool schemas to empty schemas @celia-oai - #23309 Add tool lifecycle extension contributor @jif-oai - #23253 Reduce rust-ci-full Windows nextest timeout flakes @starr-openai - #22878 Improve `codex remote-control` CLI UX @owenlin0 - #21812 Publish Linux runtime wheels with glibc-compatible tags @aibrahim-oai - #22709 [codex] Trim unused TurnContextItem fields @pakrym-oai - #23353 Include plugin id in plugin MCP tool metadata @mzeng-openai - #22728 [codex] Move pending input into input queue @pakrym-oai - #23371 fix(tui): warn on unsupported iTerm2 pet versions @fcoury-oai - #23376 [codex-analytics] preserve user thread source for exec threads @marksteinbrick-oai - #23360 app-server: use profile ids in v2 permission params @bolinfest - #23384 [codex] Remove external websocket session resets @pakrym-oai - #22721 cleanup: Remove skill env var dependency prompting @xl-openai - #23389 Remove ToolSearch feature toggle @sayan-oai - #23080 [1 of 7] Add thread settings to UserInput @etraut-openai - #23081 [2 of 7] Remove UserInputWithTurnContext @etraut-openai - #23075 [3 of 7] Remove UserTurn @etraut-openai - #23396 [codex] Extract turn skill and plugin injections @pakrym-oai - #23356 fix(plugins): keep version upgrades additive @iceweasel-oai - #22508 [5 of 7] Replace OverrideTurnContext with ThreadSettings @etraut-openai - #22086 CI: Customize v8 building @cconger - #23390 Remove explicit connector tool undeferral @sayan-oai - #22928 core: expose permission profile picker metadata @viyatb-oai - #23352 Preserve context baselines for full-history agent forks @jif-oai - #23300 feat: dedicated goal DB @jif-oai - #22835 Remove ToolsConfig from tool planning @jif-oai - #22870 Add `body_after_prefix` auto-compact token limit scope @jif-oai - #23144 Defer v1 multi-agent tools behind tool search @jif-oai - #23409 [codex] Allow empty turn/start requests @pakrym-oai - #23388 [codex] Move hook request plumbing into hook runtime @pakrym-oai - #23405 [codex] Preserve steer input as user input @pakrym-oai - #22914 [2 of 4] tui: route app and skill enablement through app server @etraut-openai - #23397 [codex] Make contextual user fragments dyn-renderable @pakrym-oai - #23475 chore: namespace v1 sub-agent tools @jif-oai - #23493 Make `deny` canonical for filesystem permission entries @viyatb-oai - #22929 Harden CLI rate limit window labels @ase-openai - #22782 Add SubagentStart hook @abhinav-oai - #23513 build: add Codex package builder @bolinfest - #23369 Make local environment optional in EnvironmentManager @starr-openai - #23327 Refactor exec-server websocket pump @starr-openai - #23536 fix(tui): preserve modified enter in plan questions @fcoury-oai - #23400 Fix empty rollout path app-server handling @wiltzius-openai - #23551 Route local-only app-server gating through processors @starr-openai - #23372 Split plugin install discovery into list and request tools @mzeng-openai - #23516 fix: serialize unix app-server startup @efrazer-oai - #22169 [codex] Honor role-defined spawn service tiers @aibrahim-oai - #23555 Add CUA requirements subsection for locked computer use @adams-oai - #23538 Fix: TUI starting in wrong CWD @canvrno-oai - #23526 build: fetch rg for Codex packages @bolinfest - #23573 Remove unused ARC monitor path @mzeng-openai - #23576 test: fix multi-agent service tier assertion @bolinfest - #23541 build: default Codex package target and output @bolinfest - #23358 Fan out rust-ci-full nextest by platform @starr-openai - #23593 feat: expose codex-app-server version flag @bolinfest - #23412 feat: add permission profile list api @viyatb-oai - #23535 Move plugin and skill warmup into session startup @aibrahim-oai - #23231 Fix stale background terminal poll events @etraut-openai - #23564 [codex] Preserve raw code-mode exec output by default @aibrahim-oai - #23232 Warn on invalid UTF-8 in AGENTS.md files @etraut-openai - #23584 feat: Add vertical remote plugin collection support @xl-openai - #23586 build: package prebuilt Codex entrypoints @bolinfest - #23582 ci: build Codex package archives in release workflow @bolinfest - #23596 runtime: detect Codex package layout @bolinfest - #23500 add encryptedcontent to functioncalloutput @sayan-oai - #23633 Migrate exec-server remote registration to environments @richardopenai - #23451 Add timeout for remote compaction requests @jif-oai - #23667 feat: rename 1 @jif-oai - #23669 feat: rename 3 @jif-oai - #23668 feat: rename 2 @jif-oai - #23675 fix: main @jif-oai - #23685 feat: wire goal extension tools to the dedicated goal store @jif-oai - #23690 feat: async approval contrib @jif-oai - #23692 feat: async turn item process @jif-oai - #23688 feat: expose turn-start metadata to extensions @jif-oai - #23605 [codex] Hide deferred tools from code mode prompt @pakrym-oai - #23634 runtime: use install context for bundled bwrap @bolinfest - #23635 release: publish Codex package archive checksums @bolinfest - #23592 feat: Add btw alias for side slash command @anp-oai - #23696 feat: account active goal progress in the goal extension @jif-oai - #23176 [2 of 2] Start fresh TUI thread in background @etraut-openai - #23578 fix(app-server): speed up shutdown @fcoury-oai - #22896 windows-sandbox: add resolved permissions helper @bolinfest - #23502 Add thread/settings/update app-server API @etraut-openai - #23507 Sync TUI thread settings through app server @etraut-openai - #23666 feat: add turn_id and truncation_policy to extension tool calls @jif-oai - #23636 install: consume Codex package archives @bolinfest - #23717 [codex] Preserve failed goal accounting flushes @jif-oai - #23655 add standalone websearch api client @sayan-oai - #23724 Fix thread settings clippy failure @etraut-openai - #23637 npm: ship platform packages in Codex package layout @bolinfest - #23729 fix(config): resolve cloud requirements deny-read globs @viyatb-oai - #23638 dotslash: publish Codex entrypoints from package archives @bolinfest - #22918 windows-sandbox: send permission profiles to elevated runner @bolinfest - #23735 windows-sandbox: share bundled helper lookup @bolinfest - #18868 Add MITM hook config model @evawong-oai - #22270 feat(permissions): resolve permission profile inheritance @viyatb-oai - #23719 cli: add strict config to exec-server @bolinfest - #23542 [skills] Create a personal update flow for plugin creator @caseychow-oai - #21272 Support compact SessionStart hooks @abhinav-oai - #20659 Wire MITM hooks into runtime enforcement @evawong-oai - #23752 release: use DotSlash zstd for package archives @bolinfest - #22923 windows-sandbox: drive write roots from resolved permissions @bolinfest - #23761 chore: use Codex Linux runners for Rust releases @bolinfest - #23759 release: package prebuilt resource binaries @bolinfest - #23167 windows-sandbox: feed setup from resolved permissions @bolinfest - #22931 core: refresh active permission profiles at runtime @viyatb-oai - #22873 Add SubagentStop hook @abhinav-oai - #23727 feat(plugins): tabulate plugin list output @caseychow-oai - #23732 Make goals feature on by default and no longer experimental @etraut-openai - #23537 Honor client-resolved service tier defaults @shijie-oai - #23771 [codex] Fix realtime v1 websocket compatibility @guinness-oai - #23764 Remove Windows sandbox resource stamping @iceweasel-oai - #23730 [codex] List marketplaces considered by plugin discovery @caseychow-oai - #23760 ci: run Codex package builder tests @bolinfest - #23737 [codex] Add plugin id to MCP tool call items @mzeng-openai - #18240 Use named MITM permissions config @evawong-oai - #23774 [codex] Reject read-only fallback with approvals disabled @viyatb-oai - #23714 windows-sandbox: add profile-native elevated APIs @bolinfest - #23433 feat: support managed permission profiles in requirements.toml @viyatb-oai - #23715 core: pass permission profiles to Windows runner @bolinfest - #23786 sdk: launch packaged Codex runtimes @bolinfest --- codex-rs/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index ec38a87cff97..1808715c0839 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -117,7 +117,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.0.0" +version = "0.133.0" # Track the edition for all workspace crates in one place. Individual # crates can still override this value, but keeping it here means new # crates created with `cargo new -w ...` automatically inherit the 2024 From 5382ed7a2d473560b54edace538c7f03376fe832 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 19:01:37 +0000 Subject: [PATCH 15/64] Seed Termux release automation --- .github/workflows/rust-release.yml | 1808 +++++++---------- .github/workflows/shell-tool-mcp.yml | 461 +++++ .../workflows/termux-release-checkpoint.yml | 103 + .github/workflows/termux-release-deploy.yml | 243 +++ .github/workflows/termux-release-promote.yml | 135 ++ scripts/termux-configure-git.sh | 40 + scripts/termux-create-checkpoint-pr.sh | 275 +++ ...ermux-create-or-update-mirrored-release.sh | 111 + scripts/termux-download-release-artifact.sh | 78 + scripts/termux-find-release-pr.sh | 53 + scripts/termux-read-release-metadata.sh | 77 + scripts/termux-release-asset-state.sh | 37 + scripts/termux-release-paths.sh | 55 + scripts/termux-resolve-release-ref.sh | 36 + scripts/termux-validate-gh-env.sh | 16 + 15 files changed, 2445 insertions(+), 1083 deletions(-) create mode 100644 .github/workflows/shell-tool-mcp.yml create mode 100644 .github/workflows/termux-release-checkpoint.yml create mode 100644 .github/workflows/termux-release-deploy.yml create mode 100644 .github/workflows/termux-release-promote.yml create mode 100755 scripts/termux-configure-git.sh create mode 100755 scripts/termux-create-checkpoint-pr.sh create mode 100755 scripts/termux-create-or-update-mirrored-release.sh create mode 100755 scripts/termux-download-release-artifact.sh create mode 100755 scripts/termux-find-release-pr.sh create mode 100755 scripts/termux-read-release-metadata.sh create mode 100755 scripts/termux-release-asset-state.sh create mode 100755 scripts/termux-release-paths.sh create mode 100755 scripts/termux-resolve-release-ref.sh create mode 100755 scripts/termux-validate-gh-env.sh diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 4f10efa9dcf5..56d0977e6e67 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -4,46 +4,49 @@ # git tag -a rust-v0.1.0 -m "Release 0.1.0" # git push origin rust-v0.1.0 # ``` -# -# To use external macOS signing, manually dispatch `release_mode=build_unsigned`, -# sign the unsigned macOS artifacts in a secure enclave, upload the signed handoff -# archive as a GitHub Release asset, then manually dispatch -# `release_mode=promote_signed` with `unsigned_run_id` and `signed_macos_asset`. -# The signed handoff archive should contain target or artifact directories such -# as `aarch64-apple-darwin/` with signed binaries. name: rust-release on: - push: - tags: - - "rust-v*.*.*" + pull_request: + branches: + - "release/**" workflow_dispatch: inputs: - release_mode: - description: "build_unsigned creates unsigned macOS handoff artifacts; promote_signed finishes a release from signed macOS handoff artifacts." + source_ref: + description: "Branch, tag, or commit SHA to build (code only; keeps .github from dispatched ref)" required: false + type: string + default: "" + build_target: + description: "Target to build" + required: true type: choice - default: build_unsigned + default: all options: - - build_unsigned - - promote_signed - sign_macos: - description: "Deprecated compatibility input; use release_mode instead." - required: false - type: boolean - default: false - unsigned_run_id: - description: "For promote_signed: workflow run id from the build_unsigned run." - required: false - type: string - signed_macos_asset: - description: "For promote_signed: exact GitHub Release asset name containing signed macOS handoff artifacts." - required: false - type: string - signed_macos_sha256: - description: "For promote_signed: optional SHA-256 of signed_macos_asset." - required: false - type: string + - all + - aarch64-apple-darwin + - x86_64-apple-darwin + - x86_64-unknown-linux-musl + - x86_64-unknown-linux-gnu + - aarch64-unknown-linux-musl + - aarch64-unknown-linux-gnu + - aarch64-linux-android + - x86_64-pc-windows-msvc + - aarch64-pc-windows-msvc + +permissions: + actions: read + attestations: read + checks: read + contents: read + deployments: read + issues: read + discussions: read + packages: read + pages: read + pull-requests: read + repository-projects: read + statuses: read concurrency: group: ${{ github.workflow }} @@ -51,72 +54,26 @@ concurrency: jobs: tag-check: + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: actions/checkout@v6 + - name: 🧰 Actions Toolbox + # This is required for the GitHub CLI + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: wallentx/gh-actions/composite/actions-toolbox@main + - uses: dtolnay/rust-toolchain@1.92 - name: Validate tag matches Cargo.toml version shell: bash - env: - RELEASE_MODE: ${{ github.event_name == 'workflow_dispatch' && inputs.release_mode || 'signed' }} - REQUESTED_SIGN_MACOS: ${{ inputs.sign_macos }} - SIGNED_MACOS_ASSET: ${{ inputs.signed_macos_asset }} - UNSIGNED_RUN_ID: ${{ inputs.unsigned_run_id }} run: | set -euo pipefail echo "::group::Tag validation" - case "${RELEASE_MODE}" in - signed) - if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then - echo "❌ Manual rust-release runs must use release_mode=build_unsigned or release_mode=promote_signed" - exit 1 - fi - ;; - build_unsigned) - if [[ "${GITHUB_EVENT_NAME}" != "workflow_dispatch" ]]; then - echo "❌ release_mode=build_unsigned is only valid for manual runs" - exit 1 - fi - ;; - promote_signed) - if [[ "${GITHUB_EVENT_NAME}" != "workflow_dispatch" ]]; then - echo "❌ release_mode=promote_signed is only valid for manual runs" - exit 1 - fi - if [[ ! "${UNSIGNED_RUN_ID}" =~ ^[0-9]+$ ]]; then - echo "❌ release_mode=promote_signed requires unsigned_run_id to be a workflow run id" - exit 1 - fi - if [[ -z "${SIGNED_MACOS_ASSET}" ]]; then - echo "❌ release_mode=promote_signed requires signed_macos_asset" - exit 1 - fi - if [[ "${SIGNED_MACOS_ASSET}" == */* || "${SIGNED_MACOS_ASSET}" == *"*"* || "${SIGNED_MACOS_ASSET}" == *"?"* || "${SIGNED_MACOS_ASSET}" == *"["* ]]; then - echo "❌ signed_macos_asset must be an exact release asset name, not a path or glob" - exit 1 - fi - if [[ "${UNSIGNED_RUN_ID}" == "${GITHUB_RUN_ID}" ]]; then - echo "❌ unsigned_run_id must refer to the earlier build_unsigned run, not this run" - exit 1 - fi - ;; - *) - echo "❌ Unknown release_mode '${RELEASE_MODE}'" - exit 1 - ;; - esac - - if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${REQUESTED_SIGN_MACOS}" == "true" ]]; then - echo "::warning title=Deprecated sign_macos input ignored::Use release_mode=build_unsigned or release_mode=promote_signed instead." - fi - # 1. Must be a tag and match the regex [[ "${GITHUB_REF_TYPE}" == "tag" ]] \ || { echo "❌ Not a tag push"; exit 1; } - [[ "${GITHUB_REF_NAME}" =~ ^rust-v[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta)(\.[0-9]+)?)?$ ]] \ + [[ "${GITHUB_REF_NAME}" =~ ^rust-v[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta)(\.[0-9]+)?)?(-termux)?$ ]] \ || { echo "❌ Tag '${GITHUB_REF_NAME}' doesn't match expected format"; exit 1; } # 2. Extract versions @@ -131,115 +88,198 @@ jobs: echo "✅ Tag and Cargo.toml agree (${tag_ver})" echo "::endgroup::" - build: - if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} + select-build-matrix: + if: always() && (github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' || needs.tag-check.result == 'success') needs: tag-check - name: Build - ${{ matrix.runner }} - ${{ matrix.target }} - ${{ matrix.bundle }} - runs-on: ${{ matrix.runs_on || matrix.runner }} - # Release builds can take a long time, so leave some headroom to avoid - # having to restart the full workflow due to a timeout. - timeout-minutes: 90 - permissions: - contents: read - id-token: write + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.select.outputs.matrix }} + build_version: ${{ steps.select.outputs.build_version }} + steps: + - uses: actions/checkout@v6 + - name: 🧰 Actions Toolbox + # This is required for the GitHub CLI + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: wallentx/gh-actions/composite/actions-toolbox@main + - id: select + shell: bash + env: + REPO_OWNER: ${{ github.repository_owner }} + EVENT_NAME: ${{ github.event_name }} + BUILD_TARGET: ${{ inputs.build_target }} + run: | + set -euo pipefail + + matrix='[ + {"runner":"macos-15-xlarge","target":"aarch64-apple-darwin"}, + {"runner":"macos-15-xlarge","target":"x86_64-apple-darwin"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-gnu"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-gnu"}, + {"runner":"ubuntu-24.04","target":"aarch64-linux-android"}, + {"runner":"windows-latest","target":"x86_64-pc-windows-msvc"}, + {"runner":"windows-11-arm","target":"aarch64-pc-windows-msvc"} + ]' + + if [[ "${REPO_OWNER}" != "openai" ]]; then + matrix="$(jq -c '[.[] | select(.runner | startswith("macos") | not)]' <<< "${matrix}")" + fi + + if [[ "${EVENT_NAME}" == "pull_request" ]]; then + matrix="$(jq -c '[.[] | select(.target == "aarch64-linux-android")]' <<< "${matrix}")" + elif [[ "${EVENT_NAME}" == "workflow_dispatch" && -n "${BUILD_TARGET}" && "${BUILD_TARGET}" != "all" ]]; then + matrix="$(jq -c --arg build_target "${BUILD_TARGET}" '[.[] | select(.target == $build_target)]' <<< "${matrix}")" + fi + + if [[ "$(jq 'length' <<< "${matrix}")" -eq 0 ]]; then + echo "No build targets selected after applying owner/dispatch filters." >&2 + exit 1 + fi + + echo "matrix={\"include\":${matrix}}" >> "$GITHUB_OUTPUT" + + if [[ "${EVENT_NAME}" == "pull_request" ]]; then + metadata=".github/termux-release.json" + if [[ ! -f "${metadata}" ]]; then + echo "${metadata} is required for release train PR builds" >&2 + exit 1 + fi + upstream_tag="$(jq -r '.upstream_tag // empty' "${metadata}")" + if [[ -z "${upstream_tag}" || "${upstream_tag}" != rust-v* ]]; then + echo "Unable to determine upstream rust tag from ${metadata}" >&2 + exit 1 + fi + build_version="${upstream_tag#rust-v}" + echo "build_version=${build_version}" >> "$GITHUB_OUTPUT" + elif [[ "${EVENT_NAME}" == "workflow_dispatch" ]]; then + latest_release_tag="$( + GH_TOKEN="${{ github.token }}" gh api repos/openai/codex/releases/latest --jq '.tag_name' + )" + + if [[ -z "${latest_release_tag}" || "${latest_release_tag}" != rust-v* ]]; then + echo "Unable to determine latest stable release tag from openai/codex" >&2 + exit 1 + fi + + latest_release_version="${latest_release_tag#rust-v}" + build_version="${latest_release_version}-dev" + echo "build_version=${build_version}" >> "$GITHUB_OUTPUT" + fi + + build: + if: >- + always() && + (github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' || needs.tag-check.result == 'success') && + needs.select-build-matrix.result == 'success' + needs: + - tag-check + - select-build-matrix + name: Build - ${{ matrix.runner }} - ${{ matrix.target }} + runs-on: ${{ matrix.runner }} + permissions: write-all + timeout-minutes: 120 defaults: run: working-directory: codex-rs env: - # 2026-03-04: temporarily change releases to use thin LTO because - # Ubuntu ARM is timing out at 60 minutes. - CARGO_PROFILE_RELEASE_LTO: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'thin' }} - SIGN_MACOS: ${{ github.event_name != 'workflow_dispatch' }} + CODEX_BWRAP_ENABLE_FFI: ${{ contains(matrix.target, 'unknown-linux') && '1' || '0' }} + CODEX_BUILD_VERSION: ${{ needs.select-build-matrix.outputs.build_version }} + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" strategy: fail-fast: false - matrix: - include: - - runner: macos-15-xlarge - target: aarch64-apple-darwin - bundle: primary - artifact_name: aarch64-apple-darwin - binaries: "codex codex-responses-api-proxy" - build_dmg: "true" - - runner: macos-15-xlarge - target: aarch64-apple-darwin - bundle: app-server - artifact_name: aarch64-apple-darwin-app-server - binaries: "codex-app-server" - build_dmg: "false" - - runner: macos-15-xlarge - target: x86_64-apple-darwin - bundle: primary - artifact_name: x86_64-apple-darwin - binaries: "codex codex-responses-api-proxy" - build_dmg: "true" - - runner: macos-15-xlarge - target: x86_64-apple-darwin - bundle: app-server - artifact_name: x86_64-apple-darwin-app-server - binaries: "codex-app-server" - build_dmg: "false" - # Release artifacts intentionally ship MUSL-linked Linux binaries. - - runner: codex-linux-x64-xl - target: x86_64-unknown-linux-musl - bundle: primary - artifact_name: x86_64-unknown-linux-musl - binaries: "codex codex-responses-api-proxy bwrap" - build_dmg: "false" - - runner: codex-linux-x64-xl - target: x86_64-unknown-linux-musl - bundle: app-server - artifact_name: x86_64-unknown-linux-musl-app-server - binaries: "codex-app-server" - build_dmg: "false" - - runner: codex-linux-arm64 - target: aarch64-unknown-linux-musl - bundle: primary - artifact_name: aarch64-unknown-linux-musl - binaries: "codex codex-responses-api-proxy bwrap" - build_dmg: "false" - - runner: codex-linux-arm64 - target: aarch64-unknown-linux-musl - bundle: app-server - artifact_name: aarch64-unknown-linux-musl-app-server - binaries: "codex-app-server" - build_dmg: "false" + matrix: ${{ fromJson(needs.select-build-matrix.outputs.matrix) }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@v6 + - name: 🧰 Actions Toolbox + # This is required for the GitHub CLI + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: wallentx/gh-actions/composite/actions-toolbox@main + with: + verbose: true + - name: Checkout selected source ref + if: ${{ github.event_name == 'workflow_dispatch' && inputs.source_ref != '' }} + uses: actions/checkout@v6 with: - persist-credentials: false - - name: Print runner specs (Linux) - if: ${{ runner.os == 'Linux' }} + ref: ${{ inputs.source_ref }} + path: __source__ + fetch-depth: 1 + - name: Overlay selected source ref (excluding .github) + if: ${{ github.event_name == 'workflow_dispatch' && inputs.source_ref != '' }} + shell: bash + working-directory: ${{ github.workspace }} + run: | + set -euo pipefail + echo "Using source ref: ${{ inputs.source_ref }}" + + # Keep workflow/actions from the dispatch ref while replacing all other files. + find . -mindepth 1 -maxdepth 1 \ + ! -name .git \ + ! -name .github \ + ! -name __source__ \ + -exec rm -rf -- {} + + tar --exclude='.github' -C __source__ -cf - . | tar -xf - + rm -rf __source__ + + - name: Apply selected build version + if: ${{ github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' }} shell: bash + working-directory: ${{ github.workspace }} run: | set -euo pipefail - cpu_model="$(lscpu | awk -F: '/Model name/ {gsub(/^[ \t]+/, "", $2); print $2; exit}')" - total_ram="$(awk '/MemTotal/ {printf "%.1f GiB\n", $2 / 1024 / 1024}' /proc/meminfo)" - echo "Runner: ${RUNNER_NAME:-unknown}" - echo "OS: $(uname -a)" - echo "CPU model: ${cpu_model}" - echo "Logical CPUs: $(nproc)" - echo "Total RAM: ${total_ram}" - echo "Disk usage:" - df -h . - - name: Print runner specs (macOS) - if: ${{ runner.os == 'macOS' }} + if [[ -z "${CODEX_BUILD_VERSION}" ]]; then + echo "CODEX_BUILD_VERSION is empty for workflow_dispatch" >&2 + exit 1 + fi + echo "Using build version: ${CODEX_BUILD_VERSION}" + BUILD_VERSION="${CODEX_BUILD_VERSION}" perl -0777 -i -pe \ + 's/(\[workspace\.package\][^\[]*?version = ")([^"]+)(")/${1}$ENV{BUILD_VERSION}$3/s' \ + codex-rs/Cargo.toml + + grep -n "^\[workspace.package\]\|^version = " codex-rs/Cargo.toml | head -n 4 + + - name: Select Android NDK + if: ${{ matrix.target == 'aarch64-linux-android' }} shell: bash run: | set -euo pipefail - total_ram="$(sysctl -n hw.memsize | awk '{printf "%.1f GiB\n", $1 / 1024 / 1024 / 1024}')" - echo "Runner: ${RUNNER_NAME:-unknown}" - echo "OS: $(sw_vers -productName) $(sw_vers -productVersion)" - echo "Hardware model: $(sysctl -n hw.model)" - echo "CPU architecture: $(uname -m)" - echo "Logical CPUs: $(sysctl -n hw.logicalcpu)" - echo "Physical CPUs: $(sysctl -n hw.physicalcpu)" - echo "Total RAM: ${total_ram}" - echo "Disk usage:" - df -h . + + fallback_version="29.0.13113456" + ndk_root="${ANDROID_HOME}/ndk" + selected_ndk="" + + if [[ -d "${ndk_root}" ]]; then + while IFS= read -r candidate; do + toolchain="${candidate}/toolchains/llvm/prebuilt/linux-x86_64" + if [[ -x "${toolchain}/bin/aarch64-linux-android24-clang" ]]; then + selected_ndk="${candidate}" + break + fi + done < <(find "${ndk_root}" -mindepth 1 -maxdepth 1 -type d | sort -Vr) + fi + + if [[ -z "${selected_ndk}" ]]; then + echo "No usable preinstalled Android NDK found; installing ${fallback_version}" + yes | sudo "${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager" --licenses || true + sudo "${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager" "ndk;${fallback_version}" + selected_ndk="${ndk_root}/${fallback_version}" + fi + + if [[ ! -x "${selected_ndk}/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android24-clang" ]]; then + echo "Selected Android NDK does not contain a usable aarch64 API 24 clang: ${selected_ndk}" >&2 + exit 1 + fi + + echo "Selected Android NDK: ${selected_ndk}" + "${selected_ndk}/toolchains/llvm/prebuilt/linux-x86_64/bin/clang" --version + echo "ANDROID_NDK_HOME=${selected_ndk}" >> "$GITHUB_ENV" + echo "NDK_HOME=${selected_ndk}" >> "$GITHUB_ENV" - name: Install Linux bwrap build dependencies - if: ${{ runner.os == 'Linux' }} + if: ${{ runner.os == 'Linux' && matrix.target != 'aarch64-linux-android' }} shell: bash run: | set -euo pipefail @@ -254,9 +294,173 @@ jobs: sudo apt-get update -y sudo DEBIAN_FRONTEND=noninteractive apt-get install -y libubsan1 fi - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: dtolnay/rust-toolchain@1.93 with: targets: ${{ matrix.target }} + - name: Set up sccache (Android) + if: ${{ matrix.target == 'aarch64-linux-android' }} + uses: mozilla-actions/sccache-action@main + - name: Enable sccache (Android) + if: ${{ matrix.target == 'aarch64-linux-android' }} + shell: bash + run: | + set -euo pipefail + echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV" + echo "SCCACHE_GHA_ENABLED=true" >> "$GITHUB_ENV" + echo "SCCACHE_GHA_VERSION=android-release-${{ matrix.target }}" >> "$GITHUB_ENV" + echo "SCCACHE_CACHE_SIZE=5G" >> "$GITHUB_ENV" + - name: Ensure Rust target is installed + shell: bash + run: | + set -euo pipefail + rustup target add "${{ matrix.target }}" + - if: ${{ matrix.target == 'aarch64-linux-android' }} + name: Configure Android build environment + shell: bash + run: | + set -euo pipefail + ndk="${ANDROID_NDK_HOME}" + target="${{ matrix.target }}" + + # Set up the Android toolchain + toolchain="${ndk}/toolchains/llvm/prebuilt/linux-x86_64" + + # Use API level 24 to follow termux-build convention + api_level="24" + + # Configure Cargo to use the NDK linker + cargo_target_var="CARGO_TARGET_${target^^}_LINKER" + cargo_target_var="${cargo_target_var//-/_}" + echo "${cargo_target_var}=${toolchain}/bin/aarch64-linux-android${api_level}-clang" >> "$GITHUB_ENV" + + # Set CC and AR for the target + target_cc_var="CC_${target}" + target_cc_var="${target_cc_var//-/_}" + echo "${target_cc_var}=${toolchain}/bin/aarch64-linux-android${api_level}-clang" >> "$GITHUB_ENV" + + target_cxx_var="CXX_${target}" + target_cxx_var="${target_cxx_var//-/_}" + echo "${target_cxx_var}=${toolchain}/bin/aarch64-linux-android${api_level}-clang++" >> "$GITHUB_ENV" + + target_ar_var="AR_${target}" + target_ar_var="${target_ar_var//-/_}" + echo "${target_ar_var}=${toolchain}/bin/llvm-ar" >> "$GITHUB_ENV" + + # Add toolchain to PATH + echo "${toolchain}/bin" >> "$GITHUB_PATH" + + tls_align_source="${RUNNER_TEMP}/codex-android-tls-align.S" + tls_align_object="${RUNNER_TEMP}/codex-android-tls-align.o" + cat > "${tls_align_source}" <<'EOF' + .section .tdata.codex_android_tls_align,"awT",%progbits + .p2align 6 + .globl __codex_android_tls_align + .hidden __codex_android_tls_align + .type __codex_android_tls_align, %object + __codex_android_tls_align: + .byte 0 + .size __codex_android_tls_align, 1 + EOF + "${toolchain}/bin/aarch64-linux-android${api_level}-clang" -c "${tls_align_source}" -o "${tls_align_object}" + + v8_compat_source="${RUNNER_TEMP}/codex-android-v8-compat.c" + v8_compat_object="${RUNNER_TEMP}/codex-android-v8-compat.o" + cat > "${v8_compat_source}" <<'EOF' + #include + #include + + extern int posix_memalign(void **memptr, size_t alignment, size_t size); + extern double strtod(const char *nptr, char **endptr); + extern float strtof(const char *nptr, char **endptr); + + void *aligned_alloc(size_t alignment, size_t size) { + void *ptr = 0; + if (posix_memalign(&ptr, alignment, size) != 0) { + return 0; + } + return ptr; + } + + double strtod_l(const char *nptr, char **endptr, locale_t locale) { + (void)locale; + return strtod(nptr, endptr); + } + + float strtof_l(const char *nptr, char **endptr, locale_t locale) { + (void)locale; + return strtof(nptr, endptr); + } + EOF + "${toolchain}/bin/aarch64-linux-android${api_level}-clang" -c "${v8_compat_source}" -o "${v8_compat_object}" + + builtins_archive="$( + find "${toolchain}/lib/clang" \ + -path "*/lib/linux/libclang_rt.builtins-aarch64-android.a" \ + -print -quit + )" + if [[ -z "${builtins_archive}" ]]; then + echo "Could not find Android compiler-rt builtins archive under ${toolchain}/lib/clang" >&2 + exit 1 + fi + builtins_dir="$(dirname "${builtins_archive}")" + + # Provide compatibility symbols and C++ ABI needed by the Android rusty_v8 archive. + rustflags_var="CARGO_TARGET_${target^^}_RUSTFLAGS" + rustflags_var="${rustflags_var//-/_}" + echo "${rustflags_var}=-C link-arg=${tls_align_object} -C link-arg=${v8_compat_object} -C link-arg=-Wl,-u,__codex_android_tls_align -C link-arg=-L${builtins_dir} -C link-arg=-lclang_rt.builtins-aarch64-android -C link-arg=-lc++abi -C link-arg=-Wl,-z,max-page-size=65536" >> "$GITHUB_ENV" + + + # Configure for vendored OpenSSL build + echo "CARGO_BUILD_TARGET_APPLIES_TO_HOST=false" >> "$GITHUB_ENV" + echo "CARGO_BUILD_TARGET=${{ matrix.target }}" >> "$GITHUB_ENV" + + - if: ${{ matrix.target == 'aarch64-linux-android' }} + name: Configure Android rusty_v8 artifact overrides + env: + GH_TOKEN: ${{ github.token }} + TARGET: ${{ matrix.target }} + shell: bash + run: | + set -euo pipefail + binding_dir="${RUNNER_TEMP}/rusty_v8" + archive="${binding_dir}/librusty_v8_release_${TARGET}.a.gz" + binding_path="${binding_dir}/src_binding_release_${TARGET}.rs" + checksums_path="${binding_dir}/rusty_v8_release_${TARGET}.sha256" + artifact_repository="wallentx/codex-termux" + release_tag="rusty-v8-v147.4.0" + mkdir -p "${binding_dir}" + echo "Downloading Android rusty_v8 artifacts from ${artifact_repository}@${release_tag}" + gh release download "${release_tag}" \ + --repo "${artifact_repository}" \ + --dir "${binding_dir}" \ + --pattern "librusty_v8_release_${TARGET}.a.gz" \ + --pattern "src_binding_release_${TARGET}.rs" \ + --pattern "rusty_v8_release_${TARGET}.sha256" + (cd "${binding_dir}" && sha256sum -c "$(basename "${checksums_path}")") + echo "RUSTY_V8_ARCHIVE=${archive}" >> "$GITHUB_ENV" + echo "RUSTY_V8_SRC_BINDING_PATH=${binding_path}" >> "$GITHUB_ENV" + + - if: ${{ matrix.target == 'aarch64-linux-android' }} + name: Install termux-elf-cleaner + shell: bash + run: | + set -euo pipefail + version="v3.0.1" + src_dir="${RUNNER_TEMP}/termux-elf-cleaner-src" + build_dir="${RUNNER_TEMP}/termux-elf-cleaner-build" + bin_dir="${RUNNER_TEMP}/termux-elf-cleaner-bin" + + rm -rf "${src_dir}" "${build_dir}" "${bin_dir}" + mkdir -p "${src_dir}" "${build_dir}" "${bin_dir}" + + curl -fsSL "https://github.com/termux/termux-elf-cleaner/archive/refs/tags/${version}.tar.gz" \ + | tar -xzf - --strip-components=1 -C "${src_dir}" + + cmake -S "${src_dir}" -B "${build_dir}" -DCMAKE_BUILD_TYPE=Release + cmake --build "${build_dir}" --parallel + + install "${build_dir}/termux-elf-cleaner" "${bin_dir}/termux-elf-cleaner" + echo "${bin_dir}" >> "$GITHUB_PATH" - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} name: Use hermetic Cargo home (musl) @@ -269,12 +473,25 @@ jobs: echo "${cargo_home}/bin" >> "$GITHUB_PATH" : > "${cargo_home}/config.toml" + - uses: actions/cache@v5 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + ${{ github.workspace }}/.cargo-home/bin/ + ${{ github.workspace }}/.cargo-home/registry/index/ + ${{ github.workspace }}/.cargo-home/registry/cache/ + ${{ github.workspace }}/.cargo-home/git/db/ + ${{ github.workspace }}/codex-rs/target/ + key: cargo-${{ matrix.runner }}-${{ matrix.target }}-release-${{ hashFiles('**/Cargo.lock') }} + - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} name: Install Zig - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 + uses: mlugg/setup-zig@v2 with: version: 0.14.0 - use-cache: false - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} name: Install musl build tools @@ -310,12 +527,6 @@ jobs: shell: bash run: | set -euo pipefail - # Avoid problematic aws-lc jitter entropy code path on musl builders. - echo "AWS_LC_SYS_NO_JITTER_ENTROPY=1" >> "$GITHUB_ENV" - target_no_jitter="AWS_LC_SYS_NO_JITTER_ENTROPY_${{ matrix.target }}" - target_no_jitter="${target_no_jitter//-/_}" - echo "${target_no_jitter}=1" >> "$GITHUB_ENV" - # Clear global Rust flags so host/proc-macro builds don't pull in UBSan. echo "RUSTFLAGS=" >> "$GITHUB_ENV" echo "CARGO_ENCODED_RUSTFLAGS=" >> "$GITHUB_ENV" @@ -340,94 +551,70 @@ jobs: echo "CFLAGS=${cflags}" >> "$GITHUB_ENV" echo "CXXFLAGS=${cxxflags}" >> "$GITHUB_ENV" - - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl' }} - name: Configure musl rusty_v8 artifact overrides and verify checksums - uses: ./.github/actions/setup-rusty-v8-musl - with: - target: ${{ matrix.target }} - - - if: ${{ contains(matrix.target, 'linux') }} - name: Build bwrap and export digest + - name: Cargo build shell: bash run: | set -euo pipefail - target="${{ matrix.target }}" - cargo build --target "$target" --release --timings --bin bwrap - bwrap_path="target/${target}/release/bwrap" - if [[ ! -f "$bwrap_path" ]]; then - echo "bwrap binary ${bwrap_path} not found" - exit 1 + if [[ "${{ matrix.target }}" == 'aarch64-linux-android' ]]; then + export CARGO_BUILD_JOBS=4 + export CARGO_PROFILE_RELEASE_LTO=thin + export CARGO_PROFILE_RELEASE_CODEGEN_UNITS=8 + cargo build --target ${{ matrix.target }} --release --bin codex + exit 0 fi - digest="$(sha256sum "$bwrap_path" | awk '{print $1}')" - echo "CODEX_BWRAP_SHA256=${digest}" >> "$GITHUB_ENV" - echo "Built bwrap ${bwrap_path} with sha256:${digest}" - - - name: Cargo build + if [[ "${{ contains(matrix.target, 'windows') }}" == 'true' ]]; then + cargo build --target ${{ matrix.target }} --release --bin codex --bin codex-responses-api-proxy --bin codex-windows-sandbox-setup --bin codex-command-runner + else + cargo build --target ${{ matrix.target }} --release --bin codex --bin codex-responses-api-proxy + fi + - name: sccache stats (Android) + if: ${{ matrix.target == 'aarch64-linux-android' }} shell: bash - run: | - build_args=() - for binary in ${{ matrix.binaries }}; do - build_args+=(--bin "$binary") - done - echo "CARGO_PROFILE_RELEASE_LTO: ${CARGO_PROFILE_RELEASE_LTO}" - cargo build --target ${{ matrix.target }} --release --timings "${build_args[@]}" - - - name: Upload Cargo timings - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: cargo-timings-rust-release-${{ matrix.target }}-${{ matrix.bundle }} - path: codex-rs/target/**/cargo-timings/cargo-timing.html - if-no-files-found: warn + run: ${SCCACHE_PATH:-sccache} --show-stats || true - - if: ${{ runner.os == 'macOS' && env.SIGN_MACOS != 'true' }} - name: Stage unsigned macOS artifacts + - if: ${{ matrix.target == 'aarch64-linux-android' }} + name: Normalize Android ELF for Termux shell: bash run: | set -euo pipefail - - target="${{ matrix.target }}" - release_dir="target/${target}/release" - dest="unsigned-dist/${target}" - mkdir -p "$dest" - - for binary in ${{ matrix.binaries }}; do - binary_path="${release_dir}/${binary}" - unsigned_name="${binary}-${target}-unsigned" - unsigned_path="${dest}/${unsigned_name}" - if [[ ! -f "${binary_path}" ]]; then - echo "Binary ${binary_path} not found" - exit 1 + for binary in codex codex-responses-api-proxy; do + binary_path="target/${{ matrix.target }}/release/${binary}" + if [[ -f "${binary_path}" ]]; then + termux-elf-cleaner --api-level 24 "${binary_path}" + chmod +x "${binary_path}" + # if [[ "${binary}" == "codex" ]]; then + # # Patch PT_TLS (0x7) to PT_NULL (0x0) at offset 400 to bypass Bionic alignment checks. + # printf '\x00\x00\x00\x00' | dd of="${binary_path}" bs=1 seek=400 count=4 conv=notrunc + # fi fi - - cp "${binary_path}" "${unsigned_path}" - tar -C "$dest" -czf "${unsigned_path}.tar.gz" "${unsigned_name}" - zstd -T0 -19 --rm "${unsigned_path}" done - - if: ${{ runner.os == 'macOS' && env.SIGN_MACOS != 'true' }} - name: Upload unsigned macOS artifacts - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: ${{ matrix.artifact_name }}-unsigned - path: codex-rs/unsigned-dist/${{ matrix.target }}/* - if-no-files-found: error - - - if: ${{ contains(matrix.target, 'linux') }} + - if: ${{ contains(matrix.target, 'linux') && !contains(matrix.target, 'android') && github.repository_owner == 'openai' }} name: Cosign Linux artifacts uses: ./.github/actions/linux-code-sign with: target: ${{ matrix.target }} artifacts-dir: ${{ github.workspace }}/codex-rs/target/${{ matrix.target }}/release - binaries: ${{ matrix.binaries }} - - if: ${{ runner.os == 'macOS' && env.SIGN_MACOS == 'true' }} + - if: ${{ contains(matrix.target, 'windows') && github.repository_owner == 'openai' }} + name: Sign Windows binaries with Azure Trusted Signing + uses: ./.github/actions/windows-code-sign + with: + target: ${{ matrix.target }} + client-id: ${{ secrets.AZURE_TRUSTED_SIGNING_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TRUSTED_SIGNING_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_TRUSTED_SIGNING_SUBSCRIPTION_ID }} + endpoint: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} + account-name: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} + certificate-profile-name: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} + + - if: ${{ runner.os == 'macOS' && github.repository_owner == 'openai' }} name: MacOS code signing (binaries) uses: ./.github/actions/macos-code-sign with: target: ${{ matrix.target }} - binaries: ${{ matrix.binaries }} sign-binaries: "true" sign-dmg: "false" apple-certificate: ${{ secrets.APPLE_CERTIFICATE_P12 }} @@ -436,7 +623,7 @@ jobs: apple-notarization-key-id: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} apple-notarization-issuer-id: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} - - if: ${{ runner.os == 'macOS' && matrix.build_dmg == 'true' && env.SIGN_MACOS == 'true' }} + - if: ${{ runner.os == 'macOS' && github.repository_owner == 'openai' }} name: Build macOS dmg shell: bash run: | @@ -451,17 +638,23 @@ jobs: # The previous "MacOS code signing (binaries)" step signs + notarizes the # built artifacts in `${release_dir}`. This step packages *those same* # signed binaries into a dmg. + codex_binary_path="${release_dir}/codex" + proxy_binary_path="${release_dir}/codex-responses-api-proxy" + rm -rf "$dmg_root" mkdir -p "$dmg_root" - for binary in ${{ matrix.binaries }}; do - binary_path="${release_dir}/${binary}" - if [[ ! -f "${binary_path}" ]]; then - echo "Binary ${binary_path} not found" - exit 1 - fi - ditto "${binary_path}" "${dmg_root}/${binary}" - done + if [[ ! -f "$codex_binary_path" ]]; then + echo "Binary $codex_binary_path not found" + exit 1 + fi + if [[ ! -f "$proxy_binary_path" ]]; then + echo "Binary $proxy_binary_path not found" + exit 1 + fi + + ditto "$codex_binary_path" "${dmg_root}/codex" + ditto "$proxy_binary_path" "${dmg_root}/codex-responses-api-proxy" rm -f "$dmg_path" hdiutil create \ @@ -476,7 +669,7 @@ jobs: exit 1 fi - - if: ${{ runner.os == 'macOS' && matrix.build_dmg == 'true' && env.SIGN_MACOS == 'true' }} + - if: ${{ runner.os == 'macOS' && github.repository_owner == 'openai' }} name: MacOS code signing (dmg) uses: ./.github/actions/macos-code-sign with: @@ -490,113 +683,65 @@ jobs: apple-notarization-issuer-id: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} - name: Stage artifacts - if: ${{ runner.os != 'macOS' || env.SIGN_MACOS == 'true' }} shell: bash run: | dest="dist/${{ matrix.target }}" mkdir -p "$dest" - for binary in ${{ matrix.binaries }}; do - cp "target/${{ matrix.target }}/release/${binary}" "$dest/${binary}-${{ matrix.target }}" - if [[ "${{ matrix.target }}" == *linux* ]]; then - cp "target/${{ matrix.target }}/release/${binary}.sigstore" \ - "$dest/${binary}-${{ matrix.target }}.sigstore" + if [[ "${{ matrix.runner }}" == windows* ]]; then + cp target/${{ matrix.target }}/release/codex.exe "$dest/codex-${{ matrix.target }}.exe" + cp target/${{ matrix.target }}/release/codex-responses-api-proxy.exe "$dest/codex-responses-api-proxy-${{ matrix.target }}.exe" + cp target/${{ matrix.target }}/release/codex-windows-sandbox-setup.exe "$dest/codex-windows-sandbox-setup-${{ matrix.target }}.exe" + cp target/${{ matrix.target }}/release/codex-command-runner.exe "$dest/codex-command-runner-${{ matrix.target }}.exe" + else + # For Android, we want the binary to be named just 'codex' in the archive. + if [[ "${{ matrix.target }}" == "aarch64-linux-android" ]]; then + cp target/${{ matrix.target }}/release/codex "$dest/codex" + else + cp target/${{ matrix.target }}/release/codex "$dest/codex-${{ matrix.target }}" fi - done + if [[ -f target/${{ matrix.target }}/release/codex-responses-api-proxy ]]; then + cp target/${{ matrix.target }}/release/codex-responses-api-proxy "$dest/codex-responses-api-proxy-${{ matrix.target }}" + fi + fi - if [[ "${{ matrix.target }}" == *linux* && "${{ matrix.bundle }}" == "primary" ]]; then - bundle_root="${RUNNER_TEMP}/codex-${{ matrix.target }}-bundle" - rm -rf "$bundle_root" - mkdir -p "$bundle_root/codex-resources" - cp "$dest/codex-${{ matrix.target }}" "$bundle_root/codex" - cp "$dest/bwrap-${{ matrix.target }}" "$bundle_root/codex-resources/bwrap" - chmod 0755 "$bundle_root/codex" "$bundle_root/codex-resources/bwrap" - tar -C "$bundle_root" -cf - codex codex-resources/bwrap | - zstd -T0 -19 -o "$dest/codex-${{ matrix.target }}-bundle.tar.zst" + if [[ "${{ matrix.target }}" == *linux* && "${{ matrix.target }}" != *android* ]]; then + cp target/${{ matrix.target }}/release/codex.sigstore "$dest/codex-${{ matrix.target }}.sigstore" + cp target/${{ matrix.target }}/release/codex-responses-api-proxy.sigstore "$dest/codex-responses-api-proxy-${{ matrix.target }}.sigstore" fi - if [[ "${{ matrix.build_dmg }}" == "true" ]]; then + if [[ "${{ matrix.target }}" == *apple-darwin ]]; then cp target/${{ matrix.target }}/release/codex-${{ matrix.target }}.dmg "$dest/codex-${{ matrix.target }}.dmg" fi - - name: Build Codex package archive - if: ${{ runner.os != 'macOS' || env.SIGN_MACOS == 'true' }} - shell: bash - env: - TARGET: ${{ matrix.target }} - BUNDLE: ${{ matrix.bundle }} - run: | - set -euo pipefail - bash "${GITHUB_WORKSPACE}/.github/scripts/build-codex-package-archive.sh" \ - --target "$TARGET" \ - --bundle "$BUNDLE" \ - --entrypoint-dir "target/${TARGET}/release" \ - --archive-dir "dist/${TARGET}" - - - name: Build Python runtime wheel - if: ${{ matrix.bundle == 'primary' && (runner.os != 'macOS' || env.SIGN_MACOS == 'true') }} - shell: bash - run: | - set -euo pipefail - - case "${{ matrix.target }}" in - aarch64-apple-darwin) - platform_tag="macosx_11_0_arm64" - ;; - x86_64-apple-darwin) - platform_tag="macosx_10_9_x86_64" - ;; - aarch64-unknown-linux-musl) - platform_tag="manylinux_2_17_aarch64" - ;; - x86_64-unknown-linux-musl) - platform_tag="manylinux_2_17_x86_64" - ;; - *) - echo "No Python runtime wheel platform tag for ${{ matrix.target }}" - exit 1 - ;; - esac - - python3 -m venv "${RUNNER_TEMP}/python-runtime-build-venv" - # Do not install into the runner's system Python; macOS runners mark - # the Homebrew Python as externally managed under PEP 668. - "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m pip install build - - stage_dir="${RUNNER_TEMP}/openai-codex-cli-bin-${{ matrix.target }}" - wheel_dir="${GITHUB_WORKSPACE}/python-runtime-dist/${{ matrix.target }}" - stage_runtime_args=( - "${GITHUB_WORKSPACE}/sdk/python/scripts/update_sdk_artifacts.py" - stage-runtime - "$stage_dir" - "dist/${{ matrix.target }}/codex-package-${{ matrix.target }}.tar.gz" - --codex-version "${GITHUB_REF_NAME}" - --platform-tag "$platform_tag" - ) - python3 "${stage_runtime_args[@]}" - "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m build --wheel --outdir "$wheel_dir" "$stage_dir" - - - name: Upload Python runtime wheel - if: ${{ matrix.bundle == 'primary' && (runner.os != 'macOS' || env.SIGN_MACOS == 'true') }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: python-runtime-wheel-${{ matrix.target }} - path: python-runtime-dist/${{ matrix.target }}/*.whl - if-no-files-found: error + - if: ${{ matrix.runner == 'windows-11-arm' }} + name: Install zstd + shell: powershell + run: choco install -y zstandard - name: Compress artifacts - if: ${{ runner.os != 'macOS' || env.SIGN_MACOS == 'true' }} shell: bash run: | # Path that contains the uncompressed binaries for the current # ${{ matrix.target }} dest="dist/${{ matrix.target }}" + repo_root=$PWD + + # We want to ship the raw Windows executables in the GitHub Release + # in addition to the compressed archives. Keep the originals for + # Windows targets; remove them elsewhere to limit the number of + # artifacts that end up in the GitHub Release. + keep_originals=false + if [[ "${{ matrix.runner }}" == windows* ]]; then + keep_originals=true + fi # For compatibility with environments that lack the `zstd` tool we - # additionally create a `.tar.gz` alongside every binary we publish. - # The end result is: + # additionally create a `.tar.gz` for all platforms and `.zip` for + # Windows alongside every single binary that we publish. The end result is: # codex-.zst (existing) # codex-.tar.gz (new) + # codex-.zip (only for Windows) # 1. Produce a .tar.gz for every file in the directory *before* we # run `zstd --rm`, because that flag deletes the original files. @@ -604,7 +749,7 @@ jobs: base="$(basename "$f")" # Skip files that are already archives (shouldn't happen, but be # safe). - if [[ "$base" == *.tar.gz || "$base" == *.tar.zst || "$base" == *.zip || "$base" == *.dmg ]]; then + if [[ "$base" == *.tar.gz || "$base" == *.zip || "$base" == *.dmg ]]; then continue fi @@ -614,344 +759,239 @@ jobs: fi # Create per-binary tar.gz - tar -C "$dest" -czf "$dest/${base}.tar.gz" "$base" - - # Also create .zst and remove the uncompressed binaries to keep - # non-Windows artifact directories small. - zstd -T0 -19 --rm "$dest/$base" - done - - - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - if: ${{ runner.os != 'macOS' || env.SIGN_MACOS == 'true' }} - with: - name: ${{ matrix.artifact_name }} - # Upload the per-binary .zst files, .tar.gz equivalents, and any - # prebuilt archives staged above. - path: | - codex-rs/dist/${{ matrix.target }}/* - - stage-signed-macos: - if: ${{ github.event_name == 'workflow_dispatch' && inputs.release_mode == 'promote_signed' }} - needs: tag-check - name: Stage signed macOS handoff - ${{ matrix.target }} - ${{ matrix.bundle }} - runs-on: macos-15-xlarge - timeout-minutes: 30 - permissions: - contents: read - defaults: - run: - working-directory: codex-rs + # For Android, we want the archive to have the target suffix even though the binary is just 'codex'. + archive_name="${base}" + if [[ "${{ matrix.target }}" == "aarch64-linux-android" && "${base}" == "codex" ]]; then + archive_name="codex-${{ matrix.target }}" + fi + tar -C "$dest" -czf "$dest/${archive_name}.tar.gz" "$base" + + # Create zip archive for Windows binaries + # Must run from inside the dest dir so 7z won't + # embed the directory path inside the zip. + if [[ "${{ matrix.runner }}" == windows* ]]; then + if [[ "$base" == "codex-${{ matrix.target }}.exe" ]]; then + # Bundle the sandbox helper binaries into the main codex zip so + # WinGet installs include the required helpers next to codex.exe. + # Fall back to the single-binary zip if the helpers are missing + # to avoid breaking releases. + bundle_dir="$(mktemp -d)" + runner_src="$dest/codex-command-runner-${{ matrix.target }}.exe" + setup_src="$dest/codex-windows-sandbox-setup-${{ matrix.target }}.exe" + if [[ -f "$runner_src" && -f "$setup_src" ]]; then + cp "$dest/$base" "$bundle_dir/$base" + cp "$runner_src" "$bundle_dir/codex-command-runner.exe" + cp "$setup_src" "$bundle_dir/codex-windows-sandbox-setup.exe" + # Use an absolute path so bundle zips land in the real dist + # dir even when 7z runs from a temp directory. + (cd "$bundle_dir" && 7z a "$repo_root/$dest/${base}.zip" .) + else + echo "warning: missing sandbox binaries; falling back to single-binary zip" + echo "warning: expected $runner_src and $setup_src" + (cd "$dest" && 7z a "${base}.zip" "$base") + fi + rm -rf "$bundle_dir" + else + (cd "$dest" && 7z a "${base}.zip" "$base") + fi + fi - strategy: - fail-fast: false - matrix: - include: - - target: aarch64-apple-darwin - bundle: primary - artifact_name: aarch64-apple-darwin - binaries: "codex codex-responses-api-proxy" - build_dmg: "false" - - target: aarch64-apple-darwin - bundle: app-server - artifact_name: aarch64-apple-darwin-app-server - binaries: "codex-app-server" - build_dmg: "false" - - target: x86_64-apple-darwin - bundle: primary - artifact_name: x86_64-apple-darwin - binaries: "codex codex-responses-api-proxy" - build_dmg: "false" - - target: x86_64-apple-darwin - bundle: app-server - artifact_name: x86_64-apple-darwin-app-server - binaries: "codex-app-server" - build_dmg: "false" + # Also create .zst (existing behaviour) *and* remove the original + # uncompressed binary to keep the directory small. + zstd_args=(-T0 -19) + if [[ "${keep_originals}" == false ]]; then + zstd_args+=(--rm) + fi - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false + if [[ "${archive_name}" != "${base}" ]]; then + zstd "${zstd_args[@]}" "$dest/$base" -o "$dest/${archive_name}.zst" + else + zstd "${zstd_args[@]}" "$dest/$base" + fi + done - - name: Download signed macOS handoff + - name: Add Termux release metadata + if: ${{ (github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch') && matrix.target == 'aarch64-linux-android' }} shell: bash - env: - GH_TOKEN: ${{ github.token }} - SIGNED_MACOS_ASSET: ${{ inputs.signed_macos_asset }} - SIGNED_MACOS_SHA256: ${{ inputs.signed_macos_sha256 }} run: | set -euo pipefail - - download_dir="${RUNNER_TEMP}/signed-macos-download" - handoff_dir="${RUNNER_TEMP}/signed-macos-handoff" - rm -rf "$download_dir" "$handoff_dir" - mkdir -p "$download_dir" "$handoff_dir" - - gh release download "$GITHUB_REF_NAME" \ - --repo "$GITHUB_REPOSITORY" \ - --pattern "$SIGNED_MACOS_ASSET" \ - --dir "$download_dir" - - asset_count="$(find "$download_dir" -maxdepth 1 -type f | wc -l | tr -d '[:space:]')" - if [[ "$asset_count" != "1" ]]; then - echo "Expected exactly one signed macOS handoff asset named ${SIGNED_MACOS_ASSET}; found ${asset_count}" - find "$download_dir" -maxdepth 1 -type f -print - exit 1 - fi - - asset_path="$(find "$download_dir" -maxdepth 1 -type f -print -quit)" - if [[ -n "${SIGNED_MACOS_SHA256}" ]]; then - expected_sha="$(printf '%s' "$SIGNED_MACOS_SHA256" | tr '[:upper:]' '[:lower:]')" - actual_sha="$(shasum -a 256 "$asset_path" | awk '{print $1}')" - if [[ "$actual_sha" != "$expected_sha" ]]; then - echo "signed_macos_sha256 mismatch for ${SIGNED_MACOS_ASSET}" - echo "expected: ${expected_sha}" - echo "actual: ${actual_sha}" - exit 1 - fi + if [[ ! -f "${GITHUB_WORKSPACE}/.github/termux-release.json" ]]; then + echo "No Termux release metadata found; skipping metadata attachment." + exit 0 fi + dest="dist/${{ matrix.target }}" + cp "${GITHUB_WORKSPACE}/.github/termux-release.json" "${dest}/termux-release.json" + ( + cd "${dest}" + sha256sum ./* > SHA256SUMS + ) - asset_name="$(basename "$asset_path")" - case "$asset_name" in - *.tar.zst) - zstd -dc "$asset_path" | tar -C "$handoff_dir" -xf - - ;; - *.tar.gz|*.tgz) - tar -C "$handoff_dir" -xzf "$asset_path" - ;; - *.zip) - ditto -x -k "$asset_path" "$handoff_dir" - ;; - *) - echo "Unsupported signed macOS handoff archive format: ${asset_name}" - exit 1 - ;; - esac - - echo "SIGNED_MACOS_HANDOFF_DIR=$handoff_dir" >> "$GITHUB_ENV" + - id: upload-artifact + uses: actions/upload-artifact@v6 + with: + name: ${{ github.event_name == 'pull_request' && format('termux-android-pr-{0}-{1}', github.event.pull_request.number, github.event.pull_request.head.sha) || matrix.target }} + # Upload the per-binary .zst files as well as the new .tar.gz + # equivalents we generated in the previous step. + path: | + codex-rs/dist/${{ matrix.target }}/* - - name: Stage signed macOS artifacts + - name: Publish Termux artifact notification + if: ${{ github.event_name == 'pull_request' && startsWith(github.base_ref, 'release/') && matrix.target == 'aarch64-linux-android' }} shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ARTIFACT_ID: ${{ steps.upload-artifact.outputs.artifact-id }} + ARTIFACT_NAME: ${{ github.event_name == 'pull_request' && format('termux-android-pr-{0}-{1}', github.event.pull_request.number, github.event.pull_request.head.sha) || matrix.target }} + PR_NUMBER: ${{ github.event.pull_request.number }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} run: | set -euo pipefail - - target="${{ matrix.target }}" - artifact_name="${{ matrix.artifact_name }}" - source_dir="${SIGNED_MACOS_HANDOFF_DIR}/${artifact_name}" - if [[ ! -d "$source_dir" && -d "${SIGNED_MACOS_HANDOFF_DIR}/dist/${artifact_name}" ]]; then - source_dir="${SIGNED_MACOS_HANDOFF_DIR}/dist/${artifact_name}" - fi - if [[ ! -d "$source_dir" && -d "${SIGNED_MACOS_HANDOFF_DIR}/${target}" ]]; then - source_dir="${SIGNED_MACOS_HANDOFF_DIR}/${target}" - fi - if [[ ! -d "$source_dir" && -d "${SIGNED_MACOS_HANDOFF_DIR}/dist/${target}" ]]; then - source_dir="${SIGNED_MACOS_HANDOFF_DIR}/dist/${target}" - fi - if [[ ! -d "$source_dir" ]]; then - echo "Signed macOS handoff is missing ${artifact_name}/" - echo "Expected either:" - echo " ${SIGNED_MACOS_HANDOFF_DIR}/${artifact_name}" - echo " ${SIGNED_MACOS_HANDOFF_DIR}/dist/${artifact_name}" - echo " ${SIGNED_MACOS_HANDOFF_DIR}/${target}" - echo " ${SIGNED_MACOS_HANDOFF_DIR}/dist/${target}" - find "$SIGNED_MACOS_HANDOFF_DIR" -maxdepth 3 -type f -print + if [[ -z "${ARTIFACT_ID}" ]]; then + echo "upload-artifact did not return an artifact id" >&2 exit 1 fi - dest="dist/${target}" - mkdir -p "$dest" - - for binary in ${{ matrix.binaries }}; do - source_path="${source_dir}/${binary}" - if [[ ! -f "$source_path" ]]; then - source_path="${source_dir}/${binary}-${target}" - fi - if [[ ! -f "$source_path" ]]; then - echo "Signed macOS handoff is missing ${binary} for ${artifact_name}" - exit 1 - fi - - release_path="${dest}/${binary}-${target}" - ditto "$source_path" "$release_path" - chmod 0755 "$release_path" - codesign --verify --strict --verbose=2 "$release_path" - done - - # DMG staging is disabled for signed promotion because we no longer - # distribute DMGs from this release path. Keep the branch here so the - # handoff can opt back in by flipping matrix.build_dmg if needed. - if [[ "${{ matrix.build_dmg }}" == "true" ]]; then - dmg_name="codex-${target}.dmg" - dmg_source="${source_dir}/${dmg_name}" - if [[ ! -f "$dmg_source" ]]; then - echo "Signed macOS handoff is missing ${dmg_name} for ${artifact_name}" - exit 1 - fi - - codesign --verify --strict --verbose=2 "$dmg_source" - xcrun stapler validate "$dmg_source" - cp "$dmg_source" "$dest/$dmg_name" + marker="" + artifact_url="https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/artifacts/${ARTIFACT_ID}" + run_url="${GH_WORKFLOW_URL:-https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}}" + body_path="${RUNNER_TEMP}/termux-artifact-comment.md" + { + echo "${marker}" + echo "Termux Android artifact ready for testing:" + echo + echo "- Artifact: [${ARTIFACT_NAME}](${artifact_url})" + echo "- Workflow run: [${GITHUB_RUN_ID}](${run_url})" + echo + echo "You must be signed in to GitHub with repository access to download Actions artifacts." + } > "${body_path}" + + existing_comment_id="$( + gh pr view "${PR_NUMBER}" \ + --repo "${GITHUB_REPOSITORY}" \ + --json comments \ + --jq ".comments | map(select(.author.is_bot == true and (.body | contains(\"${marker}\")))) | .[-1].id // \"\"" + )" + if [[ -n "${existing_comment_id}" ]]; then + gh api graphql \ + -f query=' + mutation($id: ID!, $body: String!) { + updateIssueComment(input: {id: $id, body: $body}) { + issueComment { + id + } + } + } + ' \ + -f id="${existing_comment_id}" \ + -f body="$(cat "${body_path}")" \ + >/dev/null + else + gh pr comment "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" --body-file "${body_path}" fi - - name: Build Codex package archive - shell: bash - env: - TARGET: ${{ matrix.target }} - BUNDLE: ${{ matrix.bundle }} - run: | - set -euo pipefail - bash "${GITHUB_WORKSPACE}/.github/scripts/build-codex-package-archive.sh" \ - --target "$TARGET" \ - --bundle "$BUNDLE" \ - --entrypoint-dir "dist/${TARGET}" \ - --archive-dir "dist/${TARGET}" \ - --target-suffixed-entrypoint - - - name: Build Python runtime wheel - if: ${{ matrix.bundle == 'primary' }} - shell: bash - run: | - set -euo pipefail - - case "${{ matrix.target }}" in - aarch64-apple-darwin) - platform_tag="macosx_11_0_arm64" - ;; - x86_64-apple-darwin) - platform_tag="macosx_10_9_x86_64" - ;; - *) - echo "No Python runtime wheel platform tag for ${{ matrix.target }}" - exit 1 - ;; - esac - - python3 -m venv "${RUNNER_TEMP}/python-runtime-build-venv" - "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m pip install build - - stage_dir="${RUNNER_TEMP}/openai-codex-cli-bin-${{ matrix.target }}" - wheel_dir="${GITHUB_WORKSPACE}/python-runtime-dist/${{ matrix.target }}" - python3 \ - "${GITHUB_WORKSPACE}/sdk/python/scripts/update_sdk_artifacts.py" \ - stage-runtime \ - "$stage_dir" \ - "dist/${{ matrix.target }}/codex-package-${{ matrix.target }}.tar.gz" \ - --codex-version "${GITHUB_REF_NAME}" \ - --platform-tag "$platform_tag" - "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m build --wheel --outdir "$wheel_dir" "$stage_dir" - - - name: Upload Python runtime wheel - if: ${{ matrix.bundle == 'primary' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: python-runtime-wheel-${{ matrix.target }} - path: python-runtime-dist/${{ matrix.target }}/*.whl - if-no-files-found: error - - - name: Compress artifacts - shell: bash - run: | - set -euo pipefail - - dest="dist/${{ matrix.target }}" - for f in "$dest"/*; do - base="$(basename "$f")" - if [[ "$base" == *.tar.gz || "$base" == *.tar.zst || "$base" == *.zip || "$base" == *.dmg ]]; then - continue + gh label create binary-ready \ + --repo "${GITHUB_REPOSITORY}" \ + --color 0e8a16 \ + --description "Android binary is ready for testing" \ + --force + gh pr edit "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" --add-label binary-ready + + if [[ -n "${SLACK_WEBHOOK_URL:-}" ]]; then + slack_payload_path="${RUNNER_TEMP}/termux-artifact-slack.json" + pr_url="https://github.com/${GITHUB_REPOSITORY}/pull/${PR_NUMBER}" + jq -n \ + --arg artifact_name "${ARTIFACT_NAME}" \ + --arg artifact_url "${artifact_url}" \ + --arg pr_number "${PR_NUMBER}" \ + --arg pr_url "${pr_url}" \ + --arg run_id "${GITHUB_RUN_ID}" \ + --arg run_url "${run_url}" \ + '{ + text: ("Termux Android artifact ready for testing: " + $artifact_url), + blocks: [ + { + type: "section", + text: { + type: "mrkdwn", + text: ("*Termux Android artifact ready for testing*\n" + $artifact_url) + } + }, + { + type: "section", + fields: [ + { + type: "mrkdwn", + text: ("*Artifact:*\n<" + $artifact_url + "|" + $artifact_name + ">") + }, + { + type: "mrkdwn", + text: ("*Pull request:*\n<" + $pr_url + "|#" + $pr_number + ">") + }, + { + type: "mrkdwn", + text: ("*Workflow run:*\n<" + $run_url + "|" + $run_id + ">") + } + ] + }, + { + type: "context", + elements: [ + { + type: "mrkdwn", + text: "You must be signed in to GitHub with repository access to download Actions artifacts." + } + ] + } + ] + }' > "${slack_payload_path}" + + if ! curl -fsS \ + -X POST \ + -H "Content-type: application/json" \ + --data @"${slack_payload_path}" \ + "${SLACK_WEBHOOK_URL}"; then + echo "::warning::Failed to send Slack artifact notification" fi + fi - tar -C "$dest" -czf "$dest/${base}.tar.gz" "$base" - zstd -T0 -19 --rm "$dest/$base" - done - - - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: ${{ matrix.artifact_name }} - path: | - codex-rs/dist/${{ matrix.target }}/* - - build-windows: - if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} - needs: tag-check - uses: ./.github/workflows/rust-release-windows.yml - with: - release-lto: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'fat' }} - secrets: inherit - - argument-comment-lint-release-assets: - if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} - name: argument-comment-lint release assets + shell-tool-mcp: + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && github.repository_owner == 'openai' + name: shell-tool-mcp needs: tag-check - uses: ./.github/workflows/rust-release-argument-comment-lint.yml + permissions: + contents: read + id-token: write + uses: ./.github/workflows/shell-tool-mcp.yml with: + release-tag: ${{ github.ref_name }} publish: true - - zsh-release-assets: - if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} - name: zsh release assets - needs: tag-check - uses: ./.github/workflows/rust-release-zsh.yml + secrets: inherit release: + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') needs: - - tag-check - build - - stage-signed-macos - - build-windows - - argument-comment-lint-release-assets - - zsh-release-assets - if: >- - ${{ - always() && - needs.tag-check.result == 'success' && - ( - ( - github.event_name == 'workflow_dispatch' && - inputs.release_mode == 'promote_signed' && - needs.stage-signed-macos.result == 'success' && - needs.build.result == 'skipped' && - needs.build-windows.result == 'skipped' && - needs.argument-comment-lint-release-assets.result == 'skipped' && - needs.zsh-release-assets.result == 'skipped' - ) || - ( - (github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed') && - needs.build.result == 'success' && - needs.stage-signed-macos.result == 'skipped' && - needs.build-windows.result == 'success' && - needs.argument-comment-lint-release-assets.result == 'success' && - needs.zsh-release-assets.result == 'success' - ) - ) - }} + - shell-tool-mcp name: release runs-on: ubuntu-latest permissions: contents: write actions: read - env: - RELEASE_MODE: ${{ github.event_name == 'workflow_dispatch' && inputs.release_mode || 'signed' }} - SIGN_MACOS: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode == 'promote_signed' }} - SIGNED_MACOS_ASSET: ${{ inputs.signed_macos_asset }} - UNSIGNED_RUN_ID: ${{ inputs.unsigned_run_id }} outputs: version: ${{ steps.release_name.outputs.name }} tag: ${{ github.ref_name }} - sign_macos: ${{ steps.release_mode.outputs.sign_macos }} should_publish_npm: ${{ steps.npm_publish_settings.outputs.should_publish }} npm_tag: ${{ steps.npm_publish_settings.outputs.npm_tag }} - should_publish_python_runtime: ${{ steps.python_runtime_publish_settings.outputs.should_publish }} steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false + uses: actions/checkout@v6 - - name: Define release mode - id: release_mode - run: | - echo "release_mode=${RELEASE_MODE}" >> "$GITHUB_OUTPUT" - echo "sign_macos=${SIGN_MACOS}" >> "$GITHUB_OUTPUT" + - name: 🧰 Actions Toolbox + # This is required for the GitHub CLI + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: wallentx/gh-actions/composite/actions-toolbox@main - name: Generate release notes from tag commit message id: release_notes @@ -973,159 +1013,21 @@ jobs: echo "path=${notes_path}" >> "${GITHUB_OUTPUT}" - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + - uses: actions/download-artifact@v7 with: path: dist - - name: Validate unsigned build run - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - - run_summary="$(gh run view "$UNSIGNED_RUN_ID" \ - --repo "$GITHUB_REPOSITORY" \ - --json conclusion,event,headBranch,headSha,status,workflowName,url \ - --jq '[.workflowName, .event, .headBranch, .headSha, .status, .conclusion, .url] | @tsv')" - IFS=$'\t' read -r workflow_name event head_branch head_sha status conclusion run_url <<< "$run_summary" - expected_head_sha="$(git rev-parse "${GITHUB_SHA}^{commit}")" - - if [[ "$workflow_name" != "$GITHUB_WORKFLOW" ]]; then - echo "unsigned_run_id ${UNSIGNED_RUN_ID} is for workflow '${workflow_name}', expected '${GITHUB_WORKFLOW}'" - echo "Run URL: ${run_url}" - exit 1 - fi - - if [[ "$event" != "workflow_dispatch" ]]; then - echo "unsigned_run_id ${UNSIGNED_RUN_ID} was triggered by '${event}', expected 'workflow_dispatch'" - echo "Run URL: ${run_url}" - exit 1 - fi - - if [[ "$head_branch" != "$GITHUB_REF_NAME" ]]; then - echo "unsigned_run_id ${UNSIGNED_RUN_ID} used ref '${head_branch}', expected '${GITHUB_REF_NAME}'" - echo "Run URL: ${run_url}" - exit 1 - fi - - if [[ "$head_sha" != "$expected_head_sha" ]]; then - echo "unsigned_run_id ${UNSIGNED_RUN_ID} used head SHA '${head_sha}', expected '${expected_head_sha}'" - echo "Run URL: ${run_url}" - exit 1 - fi - - if [[ "$status" != "completed" || "$conclusion" != "success" ]]; then - echo "unsigned_run_id ${UNSIGNED_RUN_ID} is ${status}/${conclusion}, expected completed/success" - echo "Run URL: ${run_url}" - exit 1 - fi - - - name: Download artifacts from unsigned build run - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - gh run download "$UNSIGNED_RUN_ID" \ - --repo "$GITHUB_REPOSITORY" \ - --dir dist - - - name: Remove unsigned macOS staging artifacts - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - run: | - set -euo pipefail - find dist -mindepth 1 -maxdepth 1 -type d \ - -name '*-apple-darwin*-unsigned' \ - -exec rm -rf {} + - - - name: Re-upload promoted Linux x64 artifacts - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: x86_64-unknown-linux-musl - path: dist/x86_64-unknown-linux-musl/* - if-no-files-found: error - - - name: Re-upload promoted Linux arm64 artifacts - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: aarch64-unknown-linux-musl - path: dist/aarch64-unknown-linux-musl/* - if-no-files-found: error - - - name: Re-upload promoted Windows x64 artifacts - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: x86_64-pc-windows-msvc - path: dist/x86_64-pc-windows-msvc/* - if-no-files-found: error - - - name: Re-upload promoted Windows arm64 artifacts - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: aarch64-pc-windows-msvc - path: dist/aarch64-pc-windows-msvc/* - if-no-files-found: error - - name: List run: ls -R dist/ - - name: Prune artifacts excluded from unsigned macOS release - if: ${{ env.SIGN_MACOS == 'false' }} - run: | - find dist -mindepth 1 -maxdepth 1 -type d \ - ! -name '*-apple-darwin*-unsigned' \ - ! -name 'aarch64-unknown-linux-musl' \ - ! -name 'aarch64-unknown-linux-musl-app-server' \ - ! -name 'x86_64-unknown-linux-musl' \ - ! -name 'x86_64-unknown-linux-musl-app-server' \ - ! -name 'aarch64-pc-windows-msvc' \ - ! -name 'x86_64-pc-windows-msvc' \ - -exec rm -rf {} + - - if ! find dist -type f -name '*-apple-darwin*-unsigned*' | grep -q .; then - echo "No unsigned macOS artifacts found in downloaded workflow artifacts." - exit 1 - fi - + # This is a temporary fix: we should modify shell-tool-mcp.yml so these + # files do not end up in dist/ in the first place. - name: Delete entries from dist/ that should not go in the release run: | - rm -rf dist/windows-binaries* - # cargo-timing.html appears under multiple target-specific directories. - # If included in files: dist/**, release upload races on duplicate - # asset names and can fail with 404s. - find dist -type f -name 'cargo-timing.html' -delete - find dist -type d -empty -delete + rm -rf dist/shell-tool-mcp* ls -R dist/ - - name: Add Codex package checksum manifest - run: | - set -euo pipefail - - manifest="dist/codex-package_SHA256SUMS" - tmp_manifest="$(mktemp)" - find dist -type f \ - \( -name 'codex-package-*.tar.gz' -o -name 'codex-app-server-package-*.tar.gz' \) \ - -print | - sort | - while IFS= read -r archive; do - sha256sum "$archive" | - awk -v name="$(basename "$archive")" '{ print $1 " " name }' - done > "$tmp_manifest" - - if [[ ! -s "$tmp_manifest" ]]; then - echo "No Codex package archives found for checksum manifest" - exit 1 - fi - - mv "$tmp_manifest" "$manifest" - cat "$manifest" - - name: Add config schema release asset run: | cp codex-rs/core/config.schema.json dist/config-schema.json @@ -1146,12 +1048,6 @@ jobs: set -euo pipefail version="${VERSION}" - if [[ "${SIGN_MACOS}" != "true" ]]; then - echo "should_publish=false" >> "$GITHUB_OUTPUT" - echo "npm_tag=" >> "$GITHUB_OUTPUT" - exit 0 - fi - if [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "should_publish=true" >> "$GITHUB_OUTPUT" echo "npm_tag=" >> "$GITHUB_OUTPUT" @@ -1163,130 +1059,55 @@ jobs: echo "npm_tag=" >> "$GITHUB_OUTPUT" fi - - name: Determine Python runtime publish settings - id: python_runtime_publish_settings - env: - VERSION: ${{ steps.release_name.outputs.name }} - run: | - set -euo pipefail - version="${VERSION}" - - if [[ "${SIGN_MACOS}" != "true" ]]; then - echo "should_publish=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - if [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "should_publish=true" >> "$GITHUB_OUTPUT" - elif [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+-alpha\.[0-9]+$ ]]; then - echo "should_publish=true" >> "$GITHUB_OUTPUT" - else - echo "should_publish=false" >> "$GITHUB_OUTPUT" - fi - - name: Setup pnpm - if: ${{ env.SIGN_MACOS == 'true' }} - uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5 + uses: pnpm/action-setup@v4 with: run_install: false - name: Setup Node.js for npm packaging - if: ${{ env.SIGN_MACOS == 'true' }} - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + uses: actions/setup-node@v6 with: node-version: 22 - name: Install dependencies - if: ${{ env.SIGN_MACOS == 'true' }} run: pnpm install --frozen-lockfile + # stage_npm_packages.py requires DotSlash when staging releases. + - uses: facebook/install-dotslash@v2 - name: Stage npm packages - if: ${{ env.SIGN_MACOS == 'true' }} + if: github.repository_owner == 'openai' env: GH_TOKEN: ${{ github.token }} - RELEASE_VERSION: ${{ steps.release_name.outputs.name }} run: | - workflow_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" ./scripts/stage_npm_packages.py \ - --release-version "$RELEASE_VERSION" \ - --workflow-url "$workflow_url" \ + --release-version "${{ steps.release_name.outputs.name }}" \ --package codex \ --package codex-responses-api-proxy \ --package codex-sdk - - name: Stage installer scripts - if: ${{ env.SIGN_MACOS == 'true' }} - run: | - cp scripts/install/install.sh dist/install.sh - cp scripts/install/install.ps1 dist/install.ps1 - - name: Create GitHub Release - uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1 + uses: softprops/action-gh-release@v2 with: name: ${{ steps.release_name.outputs.name }} tag_name: ${{ github.ref_name }} body_path: ${{ steps.release_notes.outputs.path }} files: dist/** - overwrite_files: true - make_latest: ${{ env.SIGN_MACOS == 'true' && !contains(steps.release_name.outputs.name, '-') }} # Mark as prerelease only when the version has a suffix after x.y.z # (e.g. -alpha, -beta). Otherwise publish a normal release. prerelease: ${{ contains(steps.release_name.outputs.name, '-') }} - - name: Clean up signed promotion handoff assets - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - - release_id="$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${GITHUB_REF_NAME}" --jq '.id')" - gh api --paginate "repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets" \ - --jq '.[] | [.id, .name] | @tsv' | - while IFS=$'\t' read -r asset_id asset_name; do - if [[ -z "$asset_id" || -z "$asset_name" ]]; then - continue - fi - - delete_asset=false - if [[ "$asset_name" == *unsigned* || "$asset_name" == "$SIGNED_MACOS_ASSET" ]]; then - delete_asset=true - fi - - if [[ "$delete_asset" == "true" ]]; then - echo "Deleting release asset ${asset_name}" - gh api -X DELETE "repos/${GITHUB_REPOSITORY}/releases/assets/${asset_id}" - fi - done - - - if: ${{ env.SIGN_MACOS == 'true' }} - uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 + - if: github.repository_owner == 'openai' + uses: facebook/dotslash-publish-release@v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: tag: ${{ github.ref_name }} config: .github/dotslash-config.json - - if: ${{ env.SIGN_MACOS == 'true' }} - uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag: ${{ github.ref_name }} - config: .github/dotslash-zsh-config.json - - - if: ${{ env.SIGN_MACOS == 'true' }} - uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag: ${{ github.ref_name }} - config: .github/dotslash-argument-comment-lint-config.json - - name: Trigger developers.openai.com deploy # Only trigger the deploy if the release is not a pre-release. # The deploy is used to update the developers.openai.com website with the new config schema json file. - if: ${{ env.SIGN_MACOS == 'true' && !contains(steps.release_name.outputs.name, '-') }} + if: ${{ !contains(steps.release_name.outputs.name, '-') && github.repository_owner == 'openai' }} continue-on-error: true env: DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL: ${{ secrets.DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL }} @@ -1301,15 +1122,7 @@ jobs: # npm docs: https://docs.npmjs.com/trusted-publishers publish-npm: # Publish to npm for stable releases and alpha pre-releases with numeric suffixes. - # promote_signed intentionally skips build jobs that are ancestors of release; - # include the !cancelled() status function so Actions does not apply its implicit - # success() check to the whole dependency chain before evaluating release outputs. - if: >- - ${{ - !cancelled() && - needs.release.result == 'success' && - needs.release.outputs.should_publish_npm == 'true' - }} + if: ${{ needs.release.outputs.should_publish_npm == 'true' && github.repository_owner == 'openai' }} name: publish-npm needs: release runs-on: ubuntu-latest @@ -1319,37 +1132,36 @@ jobs: steps: - name: Setup Node.js - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + uses: actions/setup-node@v6 with: - # Node 24 bundles npm >= 11.5.1, which trusted publishing requires. - node-version: 24 + node-version: 22 registry-url: "https://registry.npmjs.org" scope: "@openai" + # Trusted publishing requires npm CLI version 11.5.1 or later. + - name: Update npm + run: npm install -g npm@latest + - name: Download npm tarballs from release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_TAG: ${{ needs.release.outputs.tag }} - RELEASE_VERSION: ${{ needs.release.outputs.version }} run: | set -euo pipefail - version="$RELEASE_VERSION" - tag="$RELEASE_TAG" + version="${{ needs.release.outputs.version }}" + tag="${{ needs.release.outputs.tag }}" mkdir -p dist/npm - patterns=( - "codex-npm-${version}.tgz" - "codex-npm-linux-*-${version}.tgz" - "codex-npm-darwin-*-${version}.tgz" - "codex-npm-win32-*-${version}.tgz" - "codex-responses-api-proxy-npm-${version}.tgz" - "codex-sdk-npm-${version}.tgz" - ) - for pattern in "${patterns[@]}"; do - gh release download "$tag" \ - --repo "${GITHUB_REPOSITORY}" \ - --pattern "$pattern" \ - --dir dist/npm - done + gh release download "$tag" \ + --repo "${GITHUB_REPOSITORY}" \ + --pattern "codex-npm-${version}.tgz" \ + --dir dist/npm + gh release download "$tag" \ + --repo "${GITHUB_REPOSITORY}" \ + --pattern "codex-responses-api-proxy-npm-${version}.tgz" \ + --dir dist/npm + gh release download "$tag" \ + --repo "${GITHUB_REPOSITORY}" \ + --pattern "codex-sdk-npm-${version}.tgz" \ + --dir dist/npm # No NODE_AUTH_TOKEN needed because we use OIDC. - name: Publish to npm @@ -1358,193 +1170,23 @@ jobs: NPM_TAG: ${{ needs.release.outputs.npm_tag }} run: | set -euo pipefail - prefix="" + tag_args=() if [[ -n "${NPM_TAG}" ]]; then - prefix="${NPM_TAG}-" + tag_args+=(--tag "${NPM_TAG}") fi - root_tarball="dist/npm/codex-npm-${VERSION}.tgz" - sdk_tarball="dist/npm/codex-sdk-npm-${VERSION}.tgz" - # Keep this list in sync with CODEX_PLATFORM_PACKAGES in - # codex-cli/scripts/build_npm_package.py. The root wrapper advances - # @openai/codex@latest as soon as it publishes, so every platform - # package it aliases must already exist in the registry first. - platform_tarballs=( - "dist/npm/codex-npm-linux-x64-${VERSION}.tgz" - "dist/npm/codex-npm-linux-arm64-${VERSION}.tgz" - "dist/npm/codex-npm-darwin-x64-${VERSION}.tgz" - "dist/npm/codex-npm-darwin-arm64-${VERSION}.tgz" - "dist/npm/codex-npm-win32-x64-${VERSION}.tgz" - "dist/npm/codex-npm-win32-arm64-${VERSION}.tgz" - ) - - for required_tarball in "${platform_tarballs[@]}" "${root_tarball}"; do - if [[ ! -f "${required_tarball}" ]]; then - echo "Missing npm tarball: ${required_tarball}" - exit 1 - fi - done - - shopt -s nullglob - other_tarballs=() - for tarball in dist/npm/*-"${VERSION}".tgz; do - if [[ "${tarball}" == "${root_tarball}" || "${tarball}" == "${sdk_tarball}" ]]; then - continue - fi - - is_platform_tarball=false - for platform_tarball in "${platform_tarballs[@]}"; do - if [[ "${tarball}" == "${platform_tarball}" ]]; then - is_platform_tarball=true - break - fi - done - if [[ "${is_platform_tarball}" == true ]]; then - continue - fi - - other_tarballs+=("${tarball}") - done - - # Publish the platform packages before the root CLI wrapper. The root - # wrapper advances @openai/codex@latest, so it should only publish - # after the optional dependency versions it references exist. tarballs=( - "${platform_tarballs[@]}" - "${other_tarballs[@]}" - "${root_tarball}" + "codex-npm-${VERSION}.tgz" + "codex-responses-api-proxy-npm-${VERSION}.tgz" + "codex-sdk-npm-${VERSION}.tgz" ) - if [[ -f "${sdk_tarball}" ]]; then - tarballs+=("${sdk_tarball}") - fi for tarball in "${tarballs[@]}"; do - filename="$(basename "${tarball}")" - tag="" - - case "${filename}" in - codex-npm-linux-*-"${VERSION}".tgz|codex-npm-darwin-*-"${VERSION}".tgz|codex-npm-win32-*-"${VERSION}".tgz) - platform="${filename#codex-npm-}" - platform="${platform%-${VERSION}.tgz}" - tag="${prefix}${platform}" - ;; - codex-npm-"${VERSION}".tgz|codex-responses-api-proxy-npm-"${VERSION}".tgz|codex-sdk-npm-"${VERSION}".tgz) - tag="${NPM_TAG}" - ;; - *) - echo "Unexpected npm tarball: ${filename}" - exit 1 - ;; - esac - - publish_cmd=(npm publish "${GITHUB_WORKSPACE}/${tarball}") - if [[ -n "${tag}" ]]; then - publish_cmd+=(--tag "${tag}") - fi - - echo "+ ${publish_cmd[*]}" - set +e - publish_output="$("${publish_cmd[@]}" 2>&1)" - publish_status=$? - set -e - - echo "${publish_output}" - if [[ ${publish_status} -eq 0 ]]; then - continue - fi - - if grep -qiE "previously published|cannot publish over|version already exists" <<< "${publish_output}"; then - echo "Skipping already-published package version for ${filename}" - continue - fi - - exit "${publish_status}" + npm publish "${GITHUB_WORKSPACE}/dist/npm/${tarball}" "${tag_args[@]}" done - # Publish the platform-specific Python runtime wheels using PyPI trusted publishing. - # PyPI project configuration must trust this workflow and job. Keep this - # non-blocking while the Python runtime publishing path is new; failures still - # need release follow-up, but should not invalidate the Rust release itself. - publish-python-runtime: - # Publish to PyPI for stable releases and alpha pre-releases with numeric suffixes. - if: >- - ${{ - !cancelled() && - needs.release.result == 'success' && - needs.release.outputs.should_publish_python_runtime == 'true' - }} - name: publish-python-runtime - needs: release - runs-on: ubuntu-latest - continue-on-error: true - environment: pypi - permissions: - id-token: write # Required for PyPI trusted publishing. - contents: read - - steps: - - name: Download Python runtime wheels from release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_TAG: ${{ needs.release.outputs.tag }} - RELEASE_VERSION: ${{ needs.release.outputs.version }} - run: | - set -euo pipefail - python_version="$RELEASE_VERSION" - python_version="${python_version/-alpha./a}" - python_version="${python_version/-beta./b}" - python_version="${python_version/-rc./rc}" - - mkdir -p dist/python-runtime - gh release download "$RELEASE_TAG" \ - --repo "${GITHUB_REPOSITORY}" \ - --pattern "openai_codex_cli_bin-${python_version}-*.whl" \ - --dir dist/python-runtime - ls -lh dist/python-runtime - - - name: Publish Python runtime wheels to PyPI - uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 - with: - packages-dir: dist/python-runtime - skip-existing: true - - winget: - name: winget - needs: release - # Only publish stable/mainline releases to WinGet; pre-releases include a - # '-' in the semver string (e.g., 1.2.3-alpha.1). - if: >- - ${{ - !cancelled() && - needs.release.result == 'success' && - needs.release.outputs.sign_macos == 'true' && - !contains(needs.release.outputs.version, '-') - }} - # This job only invokes a GitHub Action to open/update the winget-pkgs PR; - # it does not execute Windows-only tooling, so Linux is sufficient. - runs-on: ubuntu-latest - permissions: - contents: read - - steps: - - name: Publish to WinGet - uses: vedantmgoyal9/winget-releaser@7bd472be23763def6e16bd06cc8b1cdfab0e2fd5 - with: - identifier: OpenAI.Codex - version: ${{ needs.release.outputs.version }} - release-tag: ${{ needs.release.outputs.tag }} - fork-user: openai-oss-forks - installers-regex: '^codex-(?:x86_64|aarch64)-pc-windows-msvc\.exe\.zip$' - token: ${{ secrets.WINGET_PUBLISH_PAT }} - update-branch: name: Update latest-alpha-cli branch - if: >- - ${{ - !cancelled() && - needs.release.result == 'success' && - needs.release.outputs.sign_macos == 'true' - }} permissions: contents: write needs: release diff --git a/.github/workflows/shell-tool-mcp.yml b/.github/workflows/shell-tool-mcp.yml new file mode 100644 index 000000000000..66a76aa4d938 --- /dev/null +++ b/.github/workflows/shell-tool-mcp.yml @@ -0,0 +1,461 @@ +name: shell-tool-mcp + +on: + workflow_call: + inputs: + release-version: + description: Version to publish (x.y.z or x.y.z-alpha.N). Defaults to GITHUB_REF_NAME when it starts with rust-v. + required: false + type: string + release-tag: + description: Tag name to use when downloading release artifacts (defaults to rust-v). + required: false + type: string + publish: + description: Whether to publish to npm when the version is releasable. + required: false + default: true + type: boolean + +env: + NODE_VERSION: 22 + +jobs: + metadata: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.compute.outputs.version }} + release_tag: ${{ steps.compute.outputs.release_tag }} + should_publish: ${{ steps.compute.outputs.should_publish }} + npm_tag: ${{ steps.compute.outputs.npm_tag }} + steps: + - name: Compute version and tags + id: compute + run: | + set -euo pipefail + + version="${{ inputs.release-version }}" + release_tag="${{ inputs.release-tag }}" + + if [[ -z "$version" ]]; then + if [[ -n "$release_tag" && "$release_tag" =~ ^rust-v.+ ]]; then + version="${release_tag#rust-v}" + elif [[ "${GITHUB_REF_NAME:-}" =~ ^rust-v.+ ]]; then + version="${GITHUB_REF_NAME#rust-v}" + release_tag="${GITHUB_REF_NAME}" + else + echo "release-version is required when GITHUB_REF_NAME is not a rust-v tag." + exit 1 + fi + fi + + if [[ -z "$release_tag" ]]; then + release_tag="rust-v${version}" + fi + + npm_tag="" + should_publish="false" + if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + should_publish="true" + elif [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+-alpha\.[0-9]+$ ]]; then + should_publish="true" + npm_tag="alpha" + fi + + echo "version=${version}" >> "$GITHUB_OUTPUT" + echo "release_tag=${release_tag}" >> "$GITHUB_OUTPUT" + echo "npm_tag=${npm_tag}" >> "$GITHUB_OUTPUT" + echo "should_publish=${should_publish}" >> "$GITHUB_OUTPUT" + + matrix-setup: + runs-on: ubuntu-latest + outputs: + rust-binaries: ${{ steps.compute.outputs.rust-binaries }} + bash-linux: ${{ steps.compute.outputs.bash-linux }} + bash-darwin: ${{ steps.compute.outputs.bash-darwin }} + steps: + - name: Compute matrices + id: compute + shell: bash + run: | + set -euo pipefail + is_openai="${{ github.repository_owner == 'openai' }}" + + # rust-binaries: always include x86_64-musl; conditionally include paid/fork runners + if [[ "$is_openai" == "true" ]]; then + rust='[ + {"runner":"macos-15-xlarge","target":"aarch64-apple-darwin"}, + {"runner":"macos-15-xlarge","target":"x86_64-apple-darwin"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","install_musl":true} + ]' + else + rust='[{"runner":"macos-latest","target":"aarch64-apple-darwin"}]' + fi + rust=$(echo "$rust" | jq -c '. + [{"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","install_musl":true}]') + echo "rust-binaries={\"include\":$rust}" >> "$GITHUB_OUTPUT" + + # bash-linux: always include x86_64 variants; add arm64 on openai + bash_linux='[ + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","variant":"ubuntu-24.04","image":"ubuntu:24.04"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","variant":"ubuntu-22.04","image":"ubuntu:22.04"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","variant":"debian-12","image":"debian:12"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","variant":"debian-11","image":"debian:11"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","variant":"centos-9","image":"quay.io/centos/centos:stream9"} + ]' + if [[ "$is_openai" == "true" ]]; then + bash_linux=$(echo "$bash_linux" | jq -c '. + [ + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"ubuntu-24.04","image":"arm64v8/ubuntu:24.04"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"ubuntu-22.04","image":"arm64v8/ubuntu:22.04"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"ubuntu-20.04","image":"arm64v8/ubuntu:20.04"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"debian-12","image":"arm64v8/debian:12"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"debian-11","image":"arm64v8/debian:11"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"centos-9","image":"quay.io/centos/centos:stream9"} + ]') + fi + echo "bash-linux={\"include\":$(echo "$bash_linux" | jq -c)}" >> "$GITHUB_OUTPUT" + + # bash-darwin: always include macos-14; add macos-15-xlarge on openai + bash_darwin='[{"runner":"macos-14","target":"aarch64-apple-darwin","variant":"macos-14"}]' + if [[ "$is_openai" == "true" ]]; then + bash_darwin=$(echo "$bash_darwin" | jq -c '. + [ + {"runner":"macos-15-xlarge","target":"aarch64-apple-darwin","variant":"macos-15"} + ]') + fi + echo "bash-darwin={\"include\":$(echo "$bash_darwin" | jq -c)}" >> "$GITHUB_OUTPUT" + + rust-binaries: + name: Build Rust - ${{ matrix.target }} + needs: [metadata, matrix-setup] + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + defaults: + run: + working-directory: codex-rs + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.matrix-setup.outputs.rust-binaries) }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Install UBSan runtime (musl) + if: ${{ matrix.install_musl }} + shell: bash + run: | + set -euo pipefail + if command -v apt-get >/dev/null 2>&1; then + sudo apt-get update -y + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y libubsan1 + fi + + - uses: dtolnay/rust-toolchain@1.93 + with: + targets: ${{ matrix.target }} + + - if: ${{ matrix.install_musl }} + name: Install Zig + uses: mlugg/setup-zig@v2 + with: + version: 0.14.0 + + - if: ${{ matrix.install_musl }} + name: Install musl build dependencies + env: + TARGET: ${{ matrix.target }} + run: bash "${GITHUB_WORKSPACE}/.github/scripts/install-musl-build-tools.sh" + + - if: ${{ matrix.install_musl }} + name: Configure rustc UBSan wrapper (musl host) + shell: bash + run: | + set -euo pipefail + ubsan="" + if command -v ldconfig >/dev/null 2>&1; then + ubsan="$(ldconfig -p | grep -m1 'libubsan\.so\.1' | sed -E 's/.*=> (.*)$/\1/')" + fi + wrapper_root="${RUNNER_TEMP:-/tmp}" + wrapper="${wrapper_root}/rustc-ubsan-wrapper" + cat > "${wrapper}" <> "$GITHUB_ENV" + echo "RUSTC_WORKSPACE_WRAPPER=" >> "$GITHUB_ENV" + + - if: ${{ matrix.install_musl }} + name: Clear sanitizer flags (musl) + shell: bash + run: | + set -euo pipefail + # Clear global Rust flags so host/proc-macro builds don't pull in UBSan. + echo "RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_ENCODED_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "RUSTDOCFLAGS=" >> "$GITHUB_ENV" + # Override any runner-level Cargo config rustflags as well. + echo "CARGO_BUILD_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_RUSTFLAGS=" >> "$GITHUB_ENV" + + sanitize_flags() { + local input="$1" + input="${input//-fsanitize=undefined/}" + input="${input//-fno-sanitize-recover=undefined/}" + input="${input//-fno-sanitize-trap=undefined/}" + echo "$input" + } + + cflags="$(sanitize_flags "${CFLAGS-}")" + cxxflags="$(sanitize_flags "${CXXFLAGS-}")" + echo "CFLAGS=${cflags}" >> "$GITHUB_ENV" + echo "CXXFLAGS=${cxxflags}" >> "$GITHUB_ENV" + + - name: Build exec server binaries + run: cargo build --release --target ${{ matrix.target }} --bin codex-exec-mcp-server --bin codex-execve-wrapper + + - name: Stage exec server binaries + run: | + dest="${GITHUB_WORKSPACE}/artifacts/vendor/${{ matrix.target }}" + mkdir -p "$dest" + cp "target/${{ matrix.target }}/release/codex-exec-mcp-server" "$dest/" + cp "target/${{ matrix.target }}/release/codex-execve-wrapper" "$dest/" + + - uses: actions/upload-artifact@v6 + with: + name: shell-tool-mcp-rust-${{ matrix.target }} + path: artifacts/** + if-no-files-found: error + + bash-linux: + name: Build Bash (Linux) - ${{ matrix.variant }} - ${{ matrix.target }} + needs: [metadata, matrix-setup] + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + container: + image: ${{ matrix.image }} + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.matrix-setup.outputs.bash-linux) }} + steps: + - name: Install build prerequisites + shell: bash + run: | + set -euo pipefail + if command -v apt-get >/dev/null 2>&1; then + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y git build-essential bison autoconf gettext + elif command -v dnf >/dev/null 2>&1; then + dnf install -y git gcc gcc-c++ make bison autoconf gettext + elif command -v yum >/dev/null 2>&1; then + yum install -y git gcc gcc-c++ make bison autoconf gettext + else + echo "Unsupported package manager in container" + exit 1 + fi + + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Build patched Bash + shell: bash + run: | + set -euo pipefail + git clone --depth 1 https://github.com/bolinfest/bash /tmp/bash + cd /tmp/bash + git fetch --depth 1 origin a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b + git checkout a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b + git apply "${GITHUB_WORKSPACE}/shell-tool-mcp/patches/bash-exec-wrapper.patch" + ./configure --without-bash-malloc + cores="$(command -v nproc >/dev/null 2>&1 && nproc || getconf _NPROCESSORS_ONLN)" + make -j"${cores}" + + dest="${GITHUB_WORKSPACE}/artifacts/vendor/${{ matrix.target }}/bash/${{ matrix.variant }}" + mkdir -p "$dest" + cp bash "$dest/bash" + + - uses: actions/upload-artifact@v6 + with: + name: shell-tool-mcp-bash-${{ matrix.target }}-${{ matrix.variant }} + path: artifacts/** + if-no-files-found: error + + bash-darwin: + name: Build Bash (macOS) - ${{ matrix.variant }} - ${{ matrix.target }} + needs: [metadata, matrix-setup] + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.matrix-setup.outputs.bash-darwin) }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Build patched Bash + shell: bash + run: | + set -euo pipefail + git clone --depth 1 https://github.com/bolinfest/bash /tmp/bash + cd /tmp/bash + git fetch --depth 1 origin a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b + git checkout a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b + git apply "${GITHUB_WORKSPACE}/shell-tool-mcp/patches/bash-exec-wrapper.patch" + ./configure --without-bash-malloc + cores="$(getconf _NPROCESSORS_ONLN)" + make -j"${cores}" + + dest="${GITHUB_WORKSPACE}/artifacts/vendor/${{ matrix.target }}/bash/${{ matrix.variant }}" + mkdir -p "$dest" + cp bash "$dest/bash" + + - uses: actions/upload-artifact@v6 + with: + name: shell-tool-mcp-bash-${{ matrix.target }}-${{ matrix.variant }} + path: artifacts/** + if-no-files-found: error + + package: + name: Package npm module + needs: + - metadata + - rust-binaries + - bash-linux + - bash-darwin + runs-on: ubuntu-latest + env: + PACKAGE_VERSION: ${{ needs.metadata.outputs.version }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Install JavaScript dependencies + run: pnpm install --frozen-lockfile + + - name: Build (shell-tool-mcp) + run: pnpm --filter @openai/codex-shell-tool-mcp run build + + - name: Download build artifacts + uses: actions/download-artifact@v7 + with: + path: artifacts + + - name: Assemble staging directory + id: staging + shell: bash + run: | + set -euo pipefail + staging="${STAGING_DIR}" + mkdir -p "$staging" "$staging/vendor" + cp shell-tool-mcp/README.md "$staging/" + cp shell-tool-mcp/package.json "$staging/" + cp -R shell-tool-mcp/bin "$staging/" + + found_vendor="false" + shopt -s nullglob + for vendor_dir in artifacts/*/vendor; do + rsync -av "$vendor_dir/" "$staging/vendor/" + found_vendor="true" + done + if [[ "$found_vendor" == "false" ]]; then + echo "No vendor payloads were downloaded." + exit 1 + fi + + node - <<'NODE' + import fs from "node:fs"; + import path from "node:path"; + + const stagingDir = process.env.STAGING_DIR; + const version = process.env.PACKAGE_VERSION; + const pkgPath = path.join(stagingDir, "package.json"); + const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")); + pkg.version = version; + fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n"); + NODE + + echo "dir=$staging" >> "$GITHUB_OUTPUT" + env: + STAGING_DIR: ${{ runner.temp }}/shell-tool-mcp + + - name: Ensure binaries are executable + run: | + set -euo pipefail + staging="${{ steps.staging.outputs.dir }}" + chmod +x \ + "$staging"/vendor/*/codex-exec-mcp-server \ + "$staging"/vendor/*/codex-execve-wrapper \ + "$staging"/vendor/*/bash/*/bash + + - name: Create npm tarball + shell: bash + run: | + set -euo pipefail + mkdir -p dist/npm + staging="${{ steps.staging.outputs.dir }}" + pack_info=$(cd "$staging" && npm pack --ignore-scripts --json --pack-destination "${GITHUB_WORKSPACE}/dist/npm") + filename=$(PACK_INFO="$pack_info" node -e 'const data = JSON.parse(process.env.PACK_INFO); console.log(data[0].filename);') + mv "dist/npm/${filename}" "dist/npm/codex-shell-tool-mcp-npm-${PACKAGE_VERSION}.tgz" + + - uses: actions/upload-artifact@v6 + with: + name: codex-shell-tool-mcp-npm + path: dist/npm/codex-shell-tool-mcp-npm-${{ env.PACKAGE_VERSION }}.tgz + if-no-files-found: error + + publish: + name: Publish npm package + needs: + - metadata + - package + if: ${{ inputs.publish && needs.metadata.outputs.should_publish == 'true' }} + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + steps: + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + registry-url: https://registry.npmjs.org + scope: "@openai" + + # Trusted publishing requires npm CLI version 11.5.1 or later. + - name: Update npm + run: npm install -g npm@latest + + - name: Download npm tarball + uses: actions/download-artifact@v7 + with: + name: codex-shell-tool-mcp-npm + path: dist/npm + + - name: Publish to npm + if: github.repository == 'openai/codex' + env: + NPM_TAG: ${{ needs.metadata.outputs.npm_tag }} + VERSION: ${{ needs.metadata.outputs.version }} + shell: bash + run: | + set -euo pipefail + tag_args=() + if [[ -n "${NPM_TAG}" ]]; then + tag_args+=(--tag "${NPM_TAG}") + fi + npm publish "dist/npm/codex-shell-tool-mcp-npm-${VERSION}.tgz" "${tag_args[@]}" diff --git a/.github/workflows/termux-release-checkpoint.yml b/.github/workflows/termux-release-checkpoint.yml new file mode 100644 index 000000000000..0347e6f1b3d6 --- /dev/null +++ b/.github/workflows/termux-release-checkpoint.yml @@ -0,0 +1,103 @@ +name: termux-release-checkpoint + +on: + workflow_dispatch: + inputs: + source_branch: + description: "Release branch to checkpoint from, for example release/0.123.0" + required: false + type: string + default: "" + source_sha: + description: "Specific source commit SHA to checkpoint; defaults to the source branch tip" + required: false + type: string + default: "" + destination_branch: + description: "Destination patch branch to receive the checkpoint PR" + required: false + type: string + default: "wallentx/termux-target" + reviewer: + description: "GitHub username to request as reviewer" + required: false + type: string + default: "wallentx" + +permissions: + actions: read + attestations: read + checks: read + contents: read + deployments: read + issues: read + discussions: read + packages: read + pages: read + pull-requests: read + repository-projects: read + statuses: read + +concurrency: + group: termux-release-checkpoint-${{ inputs.source_branch || github.ref_name }} + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + checkpoint: + runs-on: ubuntu-slim + permissions: + contents: write + issues: write + pull-requests: write + env: + DESTINATION_BRANCH: ${{ inputs.destination_branch || 'wallentx/termux-target' }} + REVIEWER: ${{ inputs.reviewer || 'wallentx' }} + REQUESTED_SOURCE_BRANCH: ${{ inputs.source_branch }} + REQUESTED_SOURCE_SHA: ${{ inputs.source_sha }} + TERMUX_AUTOMATION_DIR: ${{ github.workspace }}/.termux-release-automation + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Export GitHub App token for gh + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + run: echo "GH_TOKEN=${APP_TOKEN}" >> "${GITHUB_ENV}" + + - name: Checkout release branch + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ inputs.source_branch || github.ref_name }} + token: ${{ steps.app-token.outputs.token }} + + - name: Checkout automation helpers + uses: actions/checkout@v6 + with: + fetch-depth: 1 + ref: ${{ github.workflow_sha }} + path: .termux-release-automation + token: ${{ steps.app-token.outputs.token }} + + - name: 🧰 Actions Toolbox + uses: wallentx/gh-actions/composite/actions-toolbox@main + + - name: Validate GitHub CLI environment + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-validate-gh-env.sh" + + - name: Configure git + run: | + set -euo pipefail + source_branch="${REQUESTED_SOURCE_BRANCH:-${GITHUB_REF_NAME}}" + bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-configure-git.sh" --origin "${DESTINATION_BRANCH}" "${source_branch}" + + - name: Create checkpoint PR + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-create-checkpoint-pr.sh" diff --git a/.github/workflows/termux-release-deploy.yml b/.github/workflows/termux-release-deploy.yml new file mode 100644 index 000000000000..2071611dacc4 --- /dev/null +++ b/.github/workflows/termux-release-deploy.yml @@ -0,0 +1,243 @@ +name: termux-release-deploy + +on: + push: + branches: + - "release/**" + workflow_dispatch: + inputs: + release_branch: + description: "Release branch to deploy, for example release/0.124.0" + required: true + type: string + release_sha: + description: "Release branch commit SHA to deploy. Defaults to the branch head." + required: false + default: "" + type: string + pr_number: + description: "Merged release PR number to promote. Optional; normally discovered automatically." + required: false + default: "" + type: string + pr_head_sha: + description: "Merged release PR head SHA. Required only when pr_number is set." + required: false + default: "" + type: string + destination_branch: + description: "Destination patch branch to receive the checkpoint PR" + required: false + default: "wallentx/termux-target" + type: string + reviewer: + description: "GitHub username to request as reviewer on the checkpoint PR" + required: false + default: "wallentx" + type: string + +permissions: + actions: read + attestations: read + checks: read + contents: read + deployments: read + issues: read + discussions: read + packages: read + pages: read + pull-requests: read + repository-projects: read + statuses: read + +concurrency: + group: termux-release-deploy-${{ github.event_name == 'workflow_dispatch' && inputs.release_branch || github.ref_name }} + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + deploy: + runs-on: ubuntu-24.04 + if: ${{ github.event_name == 'workflow_dispatch' || !startsWith(github.event.head_commit.message, 'Seed Termux release automation') }} + permissions: + actions: read + contents: write + deployments: write + issues: write + pull-requests: write + env: + REQUESTED_RELEASE_BRANCH: ${{ inputs.release_branch }} + REQUESTED_RELEASE_SHA: ${{ inputs.release_sha }} + INPUT_PR_NUMBER: ${{ inputs.pr_number }} + INPUT_PR_HEAD_SHA: ${{ inputs.pr_head_sha }} + DESTINATION_BRANCH: ${{ inputs.destination_branch || 'wallentx/termux-target' }} + REVIEWER: ${{ inputs.reviewer || 'wallentx' }} + TERMUX_AUTOMATION_DIR: ${{ github.workspace }}/.termux-release-automation + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Export GitHub App token for gh + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + run: echo "GH_TOKEN=${APP_TOKEN}" >> "${GITHUB_ENV}" + + - name: Checkout release branch + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.release_branch || github.ref }} + token: ${{ steps.app-token.outputs.token }} + + - name: Checkout automation helpers + uses: actions/checkout@v6 + with: + fetch-depth: 1 + ref: ${{ github.workflow_sha }} + path: .termux-release-automation + token: ${{ steps.app-token.outputs.token }} + + - name: 🧰 Actions Toolbox + uses: wallentx/gh-actions/composite/actions-toolbox@main + + - name: Validate GitHub CLI environment + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-validate-gh-env.sh" + + - name: Configure git + run: | + set -euo pipefail + release_branch="${REQUESTED_RELEASE_BRANCH:-${GITHUB_REF_NAME}}" + bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-configure-git.sh" --origin "${DESTINATION_BRANCH}" "${release_branch}" + + - name: Resolve release ref + id: release-ref + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-resolve-release-ref.sh" + + - name: Read release metadata + id: metadata + env: + TERMUX_RELEASE_ACTION: deploy + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-read-release-metadata.sh" + + - name: Create deployment + if: steps.metadata.outputs.deploy == 'true' + id: deployment + env: + GH_TOKEN: ${{ github.token }} + RELEASE_SHA: ${{ steps.release-ref.outputs.sha }} + TERMUX_TAG: ${{ steps.metadata.outputs.termux_tag }} + run: | + set -euo pipefail + deployment_id="$( + gh api \ + -X POST \ + "repos/${GITHUB_REPOSITORY}/deployments" \ + -f ref="${RELEASE_SHA}" \ + -f environment="termux-release" \ + -F auto_merge=false \ + -F required_contexts[] \ + -f description="Termux release deployment for ${TERMUX_TAG}" \ + --jq '.id' + )" + echo "id=${deployment_id}" >> "$GITHUB_OUTPUT" + + - name: Mark deployment in progress + if: steps.metadata.outputs.deploy == 'true' + env: + GH_TOKEN: ${{ github.token }} + DEPLOYMENT_ID: ${{ steps.deployment.outputs.id }} + run: | + set -euo pipefail + log_url="${GH_WORKFLOW_URL:-${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}}" + gh api \ + -X POST \ + "repos/${GITHUB_REPOSITORY}/deployments/${DEPLOYMENT_ID}/statuses" \ + -f state="in_progress" \ + -f environment="termux-release" \ + -f log_url="${log_url}" \ + -F auto_inactive=false \ + -f description="Promoting Termux release artifact and preparing checkpoint PR" \ + >/dev/null + + - name: Locate merged pull request + if: steps.metadata.outputs.deploy == 'true' && steps.metadata.outputs.asset_exists != 'true' + id: pr + env: + RELEASE_BRANCH: ${{ steps.release-ref.outputs.branch }} + RELEASE_SHA: ${{ steps.release-ref.outputs.sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-find-release-pr.sh" + + - name: Download promoted PR artifact + if: steps.metadata.outputs.deploy == 'true' && steps.metadata.outputs.asset_exists != 'true' + env: + PR_ARTIFACT_NAME: ${{ steps.pr.outputs.artifact_name }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-download-release-artifact.sh" + + - name: Create or update mirrored Termux release + if: steps.metadata.outputs.deploy == 'true' && steps.metadata.outputs.asset_exists != 'true' + env: + UPSTREAM_TAG: ${{ steps.metadata.outputs.upstream_tag }} + UPSTREAM_REPO: ${{ steps.metadata.outputs.upstream_repo }} + UPSTREAM_NAME: ${{ steps.metadata.outputs.upstream_name }} + TERMUX_TAG: ${{ steps.metadata.outputs.termux_tag }} + UPSTREAM_PRERELEASE: ${{ steps.metadata.outputs.upstream_prerelease }} + UPSTREAM_HTML_URL: ${{ steps.metadata.outputs.upstream_html_url }} + RELEASE_TRAIN: ${{ steps.metadata.outputs.release_train }} + RELEASE_EXISTS: ${{ steps.metadata.outputs.release_exists }} + PR_NUMBER: ${{ steps.pr.outputs.number }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + RELEASE_SHA: ${{ steps.release-ref.outputs.sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-create-or-update-mirrored-release.sh" + + - name: Ensure checkpoint PR + if: steps.metadata.outputs.deploy == 'true' + id: checkpoint + env: + SOURCE_BRANCH: ${{ steps.release-ref.outputs.branch }} + SOURCE_SHA: ${{ steps.release-ref.outputs.sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-create-checkpoint-pr.sh" + + - name: Mark deployment success + if: steps.metadata.outputs.deploy == 'true' + env: + GH_TOKEN: ${{ github.token }} + DEPLOYMENT_ID: ${{ steps.deployment.outputs.id }} + TERMUX_TAG: ${{ steps.metadata.outputs.termux_tag }} + run: | + set -euo pipefail + log_url="${GH_WORKFLOW_URL:-${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}}" + gh api \ + -X POST \ + "repos/${GITHUB_REPOSITORY}/deployments/${DEPLOYMENT_ID}/statuses" \ + -f state="success" \ + -f environment="termux-release" \ + -f log_url="${log_url}" \ + -F auto_inactive=false \ + -f description="Termux release deployment completed for ${TERMUX_TAG}" \ + >/dev/null + + - name: Mark deployment failure + if: failure() && steps.deployment.outputs.id != '' + env: + GH_TOKEN: ${{ github.token }} + DEPLOYMENT_ID: ${{ steps.deployment.outputs.id }} + run: | + set -euo pipefail + log_url="${GH_WORKFLOW_URL:-${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}}" + gh api \ + -X POST \ + "repos/${GITHUB_REPOSITORY}/deployments/${DEPLOYMENT_ID}/statuses" \ + -f state="failure" \ + -f environment="termux-release" \ + -f log_url="${log_url}" \ + -F auto_inactive=false \ + -f description="Termux release deployment failed" \ + >/dev/null diff --git a/.github/workflows/termux-release-promote.yml b/.github/workflows/termux-release-promote.yml new file mode 100644 index 000000000000..22c277c21a70 --- /dev/null +++ b/.github/workflows/termux-release-promote.yml @@ -0,0 +1,135 @@ +name: termux-release-promote + +on: + workflow_dispatch: + inputs: + release_branch: + description: "Release branch to promote, for example release/0.122.0" + required: true + type: string + release_sha: + description: "Release branch commit SHA to promote. Defaults to the branch head." + required: false + default: "" + type: string + pr_number: + description: "Merged PR number to promote. Optional; normally discovered automatically." + required: false + default: "" + type: string + pr_head_sha: + description: "Merged PR head SHA. Required only when pr_number is set." + required: false + default: "" + type: string + +permissions: + actions: read + attestations: read + checks: read + contents: read + deployments: read + issues: read + discussions: read + packages: read + pages: read + pull-requests: read + repository-projects: read + statuses: read + +concurrency: + group: termux-release-promote-${{ github.event_name == 'workflow_dispatch' && inputs.release_branch || github.ref_name }} + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + promote: + runs-on: ubuntu-24.04 + permissions: + actions: read + contents: write + pull-requests: read + env: + TERMUX_AUTOMATION_DIR: ${{ github.workspace }}/.termux-release-automation + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Export GitHub App token for gh + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + run: echo "GH_TOKEN=${APP_TOKEN}" >> "${GITHUB_ENV}" + + - name: Checkout release branch + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.release_branch || github.ref }} + token: ${{ steps.app-token.outputs.token }} + + - name: Checkout automation helpers + uses: actions/checkout@v6 + with: + fetch-depth: 1 + ref: ${{ github.workflow_sha }} + path: .termux-release-automation + token: ${{ steps.app-token.outputs.token }} + + - name: 🧰 Actions Toolbox + uses: wallentx/gh-actions/composite/actions-toolbox@main + + - name: Validate GitHub CLI environment + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-validate-gh-env.sh" + + - name: Resolve release ref + id: release-ref + env: + INPUT_RELEASE_BRANCH: ${{ inputs.release_branch }} + INPUT_RELEASE_SHA: ${{ inputs.release_sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-resolve-release-ref.sh" + + - name: Read release metadata + id: metadata + env: + TERMUX_RELEASE_ACTION: promote + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-read-release-metadata.sh" + + - name: Locate merged pull request + if: steps.metadata.outputs.promote == 'true' + id: pr + env: + RELEASE_BRANCH: ${{ steps.release-ref.outputs.branch }} + RELEASE_SHA: ${{ steps.release-ref.outputs.sha }} + INPUT_PR_NUMBER: ${{ inputs.pr_number }} + INPUT_PR_HEAD_SHA: ${{ inputs.pr_head_sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-find-release-pr.sh" + + - name: Download promoted PR artifact + if: steps.metadata.outputs.promote == 'true' + env: + PR_ARTIFACT_NAME: ${{ steps.pr.outputs.artifact_name }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-download-release-artifact.sh" + + - name: Create or update mirrored Termux release + if: steps.metadata.outputs.promote == 'true' + env: + UPSTREAM_TAG: ${{ steps.metadata.outputs.upstream_tag }} + UPSTREAM_REPO: ${{ steps.metadata.outputs.upstream_repo }} + UPSTREAM_NAME: ${{ steps.metadata.outputs.upstream_name }} + TERMUX_TAG: ${{ steps.metadata.outputs.termux_tag }} + UPSTREAM_PRERELEASE: ${{ steps.metadata.outputs.upstream_prerelease }} + UPSTREAM_HTML_URL: ${{ steps.metadata.outputs.upstream_html_url }} + RELEASE_TRAIN: ${{ steps.metadata.outputs.release_train }} + RELEASE_EXISTS: ${{ steps.metadata.outputs.release_exists }} + PR_NUMBER: ${{ steps.pr.outputs.number }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + RELEASE_SHA: ${{ steps.release-ref.outputs.sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-create-or-update-mirrored-release.sh" diff --git a/scripts/termux-configure-git.sh b/scripts/termux-configure-git.sh new file mode 100755 index 000000000000..fae3abcbab48 --- /dev/null +++ b/scripts/termux-configure-git.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash + +# Configure the explicit bot identity used by release automation and fetch any +# refs requested by the calling workflow. + +set -euo pipefail + +git config user.name "github-actions[bot]" +git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + +while (($#)); do + case "$1" in + --origin) + shift + origin_refs=() + while (($#)) && [[ "$1" != --* ]]; do + origin_refs+=("$1") + shift + done + if ((${#origin_refs[@]})); then + git fetch --prune origin "${origin_refs[@]}" + fi + ;; + --upstream-tag) + if (($# < 3)); then + echo "--upstream-tag requires ." >&2 + exit 1 + fi + upstream_repo="$2" + upstream_tag="$3" + git remote add upstream "https://github.com/${upstream_repo}.git" 2>/dev/null || true + git fetch --prune --no-tags upstream "+refs/tags/${upstream_tag}:refs/tags/${upstream_tag}" + shift 3 + ;; + *) + echo "Unknown argument: $1" >&2 + exit 1 + ;; + esac +done diff --git a/scripts/termux-create-checkpoint-pr.sh b/scripts/termux-create-checkpoint-pr.sh new file mode 100755 index 000000000000..f315a5b91822 --- /dev/null +++ b/scripts/termux-create-checkpoint-pr.sh @@ -0,0 +1,275 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/termux-release-paths.sh +source "${script_dir}/termux-release-paths.sh" + +source_branch="${SOURCE_BRANCH:-${REQUESTED_SOURCE_BRANCH:-${GITHUB_REF_NAME}}}" +source_sha="${SOURCE_SHA:-${REQUESTED_SOURCE_SHA:-}}" +if [[ -z "${source_sha}" ]]; then + if [[ "${GITHUB_EVENT_NAME:-}" == "push" && "${source_branch}" == "${GITHUB_REF_NAME:-}" ]]; then + source_sha="${GITHUB_SHA}" + else + source_sha="$(git rev-parse "origin/${source_branch}")" + fi +fi + +if [[ -z "${DESTINATION_BRANCH:-}" ]]; then + echo "DESTINATION_BRANCH is required." >&2 + exit 1 +fi + +release_only_checkpoint_paths() { + printf '%s\n' "${TERMUX_RELEASE_BRANCH_SCRIPT_PATHS[@]}" +} + +resolve_source_version_conflicts() { + local path="$1" + local resolved_path + resolved_path="$(mktemp)" + + if ! awk ' + function normalize_versions(text) { + gsub(/version = "[^"]+"/, "version = \"\"", text) + return text + } + + BEGIN { + in_block = 0 + side = "" + ours = "" + theirs = "" + blocks = 0 + } + + /^<<<<<<< / { + if (in_block) { + exit 1 + } + in_block = 1 + side = "ours" + ours = "" + theirs = "" + blocks++ + next + } + + /^=======$/ && in_block { + side = "theirs" + next + } + + /^>>>>>>> / && in_block { + if (normalize_versions(ours) != normalize_versions(theirs)) { + exit 1 + } + printf "%s", theirs + in_block = 0 + side = "" + next + } + + { + if (!in_block) { + print + } else if (side == "ours") { + ours = ours $0 ORS + } else if (side == "theirs") { + theirs = theirs $0 ORS + } else { + exit 1 + } + } + + END { + if (in_block || blocks == 0) { + exit 1 + } + } + ' "${path}" > "${resolved_path}"; then + rm -f "${resolved_path}" + return 1 + fi + + cp "${resolved_path}" "${path}" + rm -f "${resolved_path}" +} + +short_sha="${source_sha:0:12}" +source_slug="${source_branch//\//_}" +dest_slug="${DESTINATION_BRANCH//\//_}" +checkpoint_branch="checkpoint/${dest_slug}_from_${source_slug}_${short_sha}" +pr_title="checkpoint: into ${DESTINATION_BRANCH} from ${source_branch} @ ${short_sha}" +merge_conflicted=false +conflict_summary="" + +existing_pr="$( + gh pr list \ + --repo "${GITHUB_REPOSITORY}" \ + --head "${checkpoint_branch}" \ + --state all \ + --json number,state,mergedAt,url \ + --jq '[.[] | select(.state == "OPEN" or .mergedAt != null)] | .[0] // empty' +)" +if [[ -n "${existing_pr}" ]]; then + existing_url="$(jq -r '.url' <<< "${existing_pr}")" + existing_state="$(jq -r '.state' <<< "${existing_pr}")" + echo "Checkpoint PR already exists for ${checkpoint_branch}: ${existing_url} (${existing_state})." + if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + echo "pr_url=${existing_url}" >> "${GITHUB_OUTPUT}" + fi + exit 0 +fi + +git checkout -B "${checkpoint_branch}" "origin/${DESTINATION_BRANCH}" + +if ! git merge --no-ff --no-edit "${source_sha}"; then + mapfile -t conflicted_paths < <(git diff --name-only --diff-filter=U) + for conflicted_path in "${conflicted_paths[@]}"; do + if termux_is_checkpoint_release_only_path "${conflicted_path}"; then + echo "Auto-resolving release-only checkpoint conflict in ${conflicted_path} by keeping ${DESTINATION_BRANCH}." + if git cat-file -e "HEAD:${conflicted_path}" 2>/dev/null; then + git checkout --ours -- "${conflicted_path}" + git add "${conflicted_path}" + else + git rm -f --ignore-unmatch "${conflicted_path}" + fi + fi + done + + mapfile -t remaining_conflicts < <(git diff --name-only --diff-filter=U) + if ((${#remaining_conflicts[@]})); then + cargo_version_conflicts=true + for remaining_conflict in "${remaining_conflicts[@]}"; do + case "${remaining_conflict}" in + codex-rs/Cargo.toml|codex-rs/Cargo.lock) + ;; + *) + cargo_version_conflicts=false + ;; + esac + done + + if [[ "${cargo_version_conflicts}" == "true" ]]; then + for remaining_conflict in "${remaining_conflicts[@]}"; do + if ! resolve_source_version_conflicts "${remaining_conflict}"; then + cargo_version_conflicts=false + break + fi + done + + if [[ "${cargo_version_conflicts}" == "true" ]]; then + echo "Auto-resolving recurring Cargo version checkpoint conflicts by keeping ${source_branch} versions." + git add -- "${remaining_conflicts[@]}" + fi + fi + fi + + mapfile -t remaining_conflicts < <(git diff --name-only --diff-filter=U) + if [[ "${#remaining_conflicts[@]}" -eq 0 ]]; then + git commit --no-edit + else + merge_conflicted=true + conflict_summary="$( + printf '%s\n' "${remaining_conflicts[@]}" | awk '{ print "- `" $0 "`" }' + )" + echo "Automatic checkpoint merge failed; creating a manual-resolution PR instead." >&2 + if git rev-parse -q --verify MERGE_HEAD >/dev/null; then + git merge --abort + fi + git checkout -B "${checkpoint_branch}" "${source_sha}" + fi +fi + +if git cat-file -e "origin/${DESTINATION_BRANCH}:.github" 2>/dev/null; then + git checkout "origin/${DESTINATION_BRANCH}" -- .github + mapfile -t added_github_paths < <( + git diff --name-only --diff-filter=A "origin/${DESTINATION_BRANCH}" -- .github + ) + if ((${#added_github_paths[@]})); then + git rm -f --ignore-unmatch -- "${added_github_paths[@]}" + fi +else + git rm -r --ignore-unmatch .github +fi + +while IFS= read -r release_only_path; do + if git cat-file -e "origin/${DESTINATION_BRANCH}:${release_only_path}" 2>/dev/null; then + git checkout "origin/${DESTINATION_BRANCH}" -- "${release_only_path}" + else + git rm -f --ignore-unmatch -- "${release_only_path}" + fi +done < <(release_only_checkpoint_paths) + +if ! git diff --quiet || ! git diff --cached --quiet; then + if [[ -e .github || -L .github ]] || git ls-files --error-unmatch -- .github >/dev/null 2>&1; then + git add -A .github + fi + while IFS= read -r release_only_path; do + if [[ -e "${release_only_path}" || -L "${release_only_path}" ]] || git ls-files --error-unmatch -- "${release_only_path}" >/dev/null 2>&1; then + git add -A -- "${release_only_path}" + fi + done < <(release_only_checkpoint_paths) + if [[ "${merge_conflicted}" == "true" ]]; then + git commit -m "checkpoint: prepare ${source_branch} for ${DESTINATION_BRANCH}" + else + git commit --amend --no-edit + fi +fi + +if git diff --quiet "origin/${DESTINATION_BRANCH}" HEAD; then + echo "Checkpoint merge produced no destination changes after release-only files were restored." + exit 0 +fi + +git push --force-with-lease origin "${checkpoint_branch}" + +remaining="$( + git log --first-parent --pretty=format:%H "${source_sha}..origin/${source_branch}" | wc -w +)" + +body_path="${RUNNER_TEMP}/termux-checkpoint-pr.md" +{ + echo "## Termux release checkpoint" + echo + echo "- Source branch: \`${source_branch}\`" + echo "- Source hash: \`${source_sha}\`" + echo "- Destination branch: \`${DESTINATION_BRANCH}\`" + echo "- Remaining first-parent commits on source: ${remaining}" + echo + echo "This PR carries release-train conflict fixes and follow-up changes back into the reusable Termux patch branch." + if [[ "${merge_conflicted}" == "true" ]]; then + echo + echo "## Merge conflicts" + echo + echo "GitHub Actions could not create the checkpoint merge commit automatically, so this PR was created from the source branch state for manual conflict resolution." + echo + echo "Conflicted paths from the failed merge attempt:" + if [[ -n "${conflict_summary}" ]]; then + printf '%s\n' "${conflict_summary}" + else + echo "- Conflict details unavailable" + fi + fi + echo + echo "Release-only workflow files and metadata under \`.github\` were restored to the destination branch versions before opening this PR." +} > "${body_path}" + +pr_url="$( + gh pr create \ + --repo "${GITHUB_REPOSITORY}" \ + --base "${DESTINATION_BRANCH}" \ + --head "${checkpoint_branch}" \ + --title "${pr_title}" \ + --body-file "${body_path}" +)" +gh pr edit "${pr_url}" --repo "${GITHUB_REPOSITORY}" --add-reviewer "${REVIEWER}" || true +gh label create checkpoint --repo "${GITHUB_REPOSITORY}" --color c5def5 --description "Checkpoint merge" --force +gh label create termux-release --repo "${GITHUB_REPOSITORY}" --color 0e8a16 --description "Termux release automation" --force +gh pr edit "${pr_url}" --repo "${GITHUB_REPOSITORY}" --add-label "checkpoint" --add-label "termux-release" + +if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + echo "pr_url=${pr_url}" >> "${GITHUB_OUTPUT}" +fi diff --git a/scripts/termux-create-or-update-mirrored-release.sh b/scripts/termux-create-or-update-mirrored-release.sh new file mode 100755 index 000000000000..5787e894582b --- /dev/null +++ b/scripts/termux-create-or-update-mirrored-release.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash + +# Create a mirrored Termux release or repair an existing release that is missing +# the Android tarball. + +set -euo pipefail + +: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" +: "${GH_TOKEN:?GH_TOKEN is required}" +: "${RUNNER_TEMP:?RUNNER_TEMP is required}" +: "${UPSTREAM_TAG:?UPSTREAM_TAG is required}" +: "${UPSTREAM_REPO:?UPSTREAM_REPO is required}" +: "${UPSTREAM_NAME?UPSTREAM_NAME is required}" +: "${TERMUX_TAG:?TERMUX_TAG is required}" +: "${UPSTREAM_PRERELEASE:?UPSTREAM_PRERELEASE is required}" +: "${UPSTREAM_HTML_URL?UPSTREAM_HTML_URL is required}" +: "${RELEASE_TRAIN?RELEASE_TRAIN is required}" +: "${RELEASE_EXISTS:?RELEASE_EXISTS is required}" +: "${PR_NUMBER:?PR_NUMBER is required}" +: "${HEAD_SHA:?HEAD_SHA is required}" +: "${RELEASE_SHA:?RELEASE_SHA is required}" + +promoted_dir="${PROMOTED_DIR:-promoted}" +asset_path="${promoted_dir}/codex-aarch64-linux-android.tar.gz" + +body_path="${RUNNER_TEMP}/release-body.md" +upstream_body_path="${RUNNER_TEMP}/upstream-release-body.md" +upstream_body_without_changelog_path="${RUNNER_TEMP}/upstream-release-body-without-changelog.md" +if gh release view "${UPSTREAM_TAG}" \ + --repo "${UPSTREAM_REPO}" \ + --json body \ + --jq '.body // ""' > "${upstream_body_path}"; then + awk ' + function heading_level(line, text) { + if (match(line, /^(#{1,6})[[:space:]]+(.+)$/, parts)) { + text = tolower(parts[2]) + sub(/[[:space:]]+#+[[:space:]]*$/, "", text) + gsub(/^[[:space:]]+|[[:space:]]+$/, "", text) + if (text == "changelog" || text == "change log") { + return length(parts[1]) + } + } + return 0 + } + + { + if (!skip) { + level = heading_level($0) + if (level > 0) { + skip = 1 + skip_level = level + next + } + print + next + } + + if (match($0, /^(#{1,6})[[:space:]]+/, parts) && length(parts[1]) <= skip_level) { + skip = 0 + print + } + } + ' "${upstream_body_path}" > "${upstream_body_without_changelog_path}" +else + echo "::warning title=Upstream release notes unavailable::Could not read ${UPSTREAM_REPO} release ${UPSTREAM_TAG}." + : > "${upstream_body_without_changelog_path}" +fi + +{ + echo "Termux Android build for ${UPSTREAM_TAG}." + echo + echo "- Upstream release: ${UPSTREAM_HTML_URL}" + echo "- Release train: \`${RELEASE_TRAIN}\`" + echo "- Promoted PR: #${PR_NUMBER}" + echo "- Promoted PR head SHA: \`${HEAD_SHA}\`" +} > "${body_path}" +if [[ -s "${upstream_body_without_changelog_path}" ]]; then + { + echo + echo "## Upstream release notes" + echo + cat "${upstream_body_without_changelog_path}" + } >> "${body_path}" +fi + +release_title="${UPSTREAM_NAME}" +if [[ -z "${release_title}" || "${release_title}" == "null" ]]; then + release_title="${TERMUX_TAG}" +fi + +if [[ "${RELEASE_EXISTS}" == "true" ]]; then + gh release upload \ + "${TERMUX_TAG}" \ + "${asset_path}#codex-termux" \ + --repo "${GITHUB_REPOSITORY}" \ + --clobber + exit 0 +fi + +release_args=( + gh release create "${TERMUX_TAG}" + --repo "${GITHUB_REPOSITORY}" + --target "${RELEASE_SHA}" + --title "${release_title}" + --notes-file "${body_path}" +) +if [[ "${UPSTREAM_PRERELEASE}" == "true" ]]; then + release_args+=(--prerelease) +fi +release_args+=("${asset_path}#codex-termux") +"${release_args[@]}" diff --git a/scripts/termux-download-release-artifact.sh b/scripts/termux-download-release-artifact.sh new file mode 100755 index 000000000000..2acdba9025a0 --- /dev/null +++ b/scripts/termux-download-release-artifact.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ -z "${HEAD_SHA:-}" ]]; then + echo "HEAD_SHA is required." >&2 + exit 1 +fi + +if [[ -z "${PR_ARTIFACT_NAME:-}" ]]; then + echo "PR_ARTIFACT_NAME is required." >&2 + exit 1 +fi + +promoted_dir="${PROMOTED_DIR:-promoted}" + +find_run_id() { + local event="$1" + gh run list \ + --repo "${GITHUB_REPOSITORY}" \ + --workflow rust-release.yml \ + --event "${event}" \ + --status success \ + --commit "${HEAD_SHA}" \ + --limit 1 \ + --json databaseId \ + --jq '.[0].databaseId // empty' +} + +find_artifact_run_id() { + local artifact_name="$1" + + gh api --paginate "repos/${GITHUB_REPOSITORY}/actions/artifacts?name=${artifact_name}" \ + | jq -rs \ + --arg artifact_name "${artifact_name}" \ + --arg head_sha "${HEAD_SHA}" \ + ' + [ + .[].artifacts[] + | select(.name == $artifact_name) + | select(.expired == false) + | select(.workflow_run.head_sha == $head_sha) + ] + | sort_by(.created_at) + | reverse + | .[0].workflow_run.id // empty + ' +} + +artifact_name="${PR_ARTIFACT_NAME}" +run_id="$(find_artifact_run_id "${artifact_name}")" +mkdir -p "${promoted_dir}" +if [[ -n "${run_id}" ]] && gh run download "${run_id}" \ + --repo "${GITHUB_REPOSITORY}" \ + --name "${artifact_name}" \ + --dir "${promoted_dir}"; then + : +else + artifact_name="aarch64-linux-android" + run_id="$(find_run_id workflow_dispatch)" + if [[ -z "${run_id}" ]]; then + echo "No successful rust-release run found for ${HEAD_SHA}" >&2 + exit 1 + fi + gh run download "${run_id}" \ + --repo "${GITHUB_REPOSITORY}" \ + --name "${artifact_name}" \ + --dir "${promoted_dir}" +fi + +ls -la "${promoted_dir}" +if [[ -f "${promoted_dir}/SHA256SUMS" ]]; then + (cd "${promoted_dir}" && sha256sum -c SHA256SUMS) +fi +if [[ ! -f "${promoted_dir}/codex-aarch64-linux-android.tar.gz" ]]; then + echo "Expected ${promoted_dir}/codex-aarch64-linux-android.tar.gz in the downloaded artifact." >&2 + exit 1 +fi diff --git a/scripts/termux-find-release-pr.sh b/scripts/termux-find-release-pr.sh new file mode 100755 index 000000000000..3e35fa42eabe --- /dev/null +++ b/scripts/termux-find-release-pr.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ -n "${INPUT_PR_NUMBER:-}" || -n "${INPUT_PR_HEAD_SHA:-}" ]]; then + if [[ -z "${INPUT_PR_NUMBER:-}" || -z "${INPUT_PR_HEAD_SHA:-}" ]]; then + echo "workflow_dispatch inputs pr_number and pr_head_sha must be provided together." >&2 + exit 1 + fi + + pr_number="${INPUT_PR_NUMBER}" + head_sha="${INPUT_PR_HEAD_SHA}" + head_ref="" +else + if [[ -z "${RELEASE_BRANCH:-}" || -z "${RELEASE_SHA:-}" ]]; then + echo "RELEASE_BRANCH and RELEASE_SHA are required." >&2 + exit 1 + fi + + pr_json="$( + gh pr list \ + --repo "${GITHUB_REPOSITORY}" \ + --base "${RELEASE_BRANCH}" \ + --state merged \ + --limit 100 \ + --json number,headRefOid,headRefName,mergeCommit \ + --jq "[.[] | select(.mergeCommit.oid == \"${RELEASE_SHA}\")] | sort_by(.number) | reverse | .[0] // empty" + )" + if [[ -z "${pr_json}" ]]; then + echo "Unable to find merged PR for ${RELEASE_SHA} into ${RELEASE_BRANCH}" >&2 + exit 1 + fi + + pr_number="$(jq -r '.number' <<< "${pr_json}")" + head_sha="$(jq -r '.headRefOid // .head.sha' <<< "${pr_json}")" + head_ref="$(jq -r '.headRefName // .head.ref' <<< "${pr_json}")" +fi + +artifact_name="termux-android-pr-${pr_number}-${head_sha}" +if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + { + echo "number=${pr_number}" + echo "head_sha=${head_sha}" + echo "head_ref=${head_ref}" + echo "artifact_name=${artifact_name}" + } >> "${GITHUB_OUTPUT}" +else + printf 'number=%s\nhead_sha=%s\nhead_ref=%s\nartifact_name=%s\n' \ + "${pr_number}" \ + "${head_sha}" \ + "${head_ref}" \ + "${artifact_name}" +fi diff --git a/scripts/termux-read-release-metadata.sh b/scripts/termux-read-release-metadata.sh new file mode 100755 index 000000000000..519a0635a368 --- /dev/null +++ b/scripts/termux-read-release-metadata.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash + +# Parse .github/termux-release.json and emit workflow outputs for deploy or +# promote jobs. TERMUX_RELEASE_ACTION must be "deploy" or "promote". + +set -euo pipefail + +: "${GITHUB_OUTPUT:?GITHUB_OUTPUT is required}" +: "${GH_TOKEN:?GH_TOKEN is required}" + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + +metadata=".github/termux-release.json" +action="${TERMUX_RELEASE_ACTION:-deploy}" +case "${action}" in + deploy) + missing_context="deployment" + ;; + promote) + missing_context="promotion" + ;; + *) + echo "TERMUX_RELEASE_ACTION must be deploy or promote." >&2 + exit 1 + ;; +esac + +if [[ ! -f "${metadata}" ]]; then + echo "No ${metadata}; this push is not a Termux release ${missing_context}." + echo "${action}=false" >> "${GITHUB_OUTPUT}" + exit 0 +fi + +upstream_tag="$(jq -r '.upstream_tag // empty' "${metadata}")" +upstream_name="$(jq -r '.upstream_name // .upstream_tag // empty' "${metadata}")" +termux_tag="$(jq -r '.termux_tag // empty' "${metadata}")" +upstream_version="${upstream_tag#rust-v}" +upstream_version="${upstream_version%-termux}" +upstream_prerelease=false +if [[ "${upstream_version}" == *-* ]]; then + upstream_prerelease=true +fi +upstream_html_url="$(jq -r '.upstream_html_url // ""' "${metadata}")" +upstream_repo="$(jq -r '.upstream_repo // "openai/codex"' "${metadata}")" +release_train="$(jq -r '.release_train // ""' "${metadata}")" +if [[ -z "${upstream_tag}" || -z "${termux_tag}" ]]; then + echo "Missing upstream_tag or termux_tag in ${metadata}" >&2 + exit 1 +fi + +release_state="$(TERMUX_TAG="${termux_tag}" "${script_dir}/termux-release-asset-state.sh")" +release_exists="$(awk -F= '$1 == "release_exists" { print $2 }' <<< "${release_state}")" +asset_exists="$(awk -F= '$1 == "asset_exists" { print $2 }' <<< "${release_state}")" + +if [[ "${action}" == "promote" ]]; then + if [[ "${asset_exists}" == "true" ]]; then + echo "${termux_tag} already exists with codex-aarch64-linux-android.tar.gz; skipping promotion." + echo "promote=false" >> "${GITHUB_OUTPUT}" + exit 0 + fi + if [[ "${release_exists}" == "true" ]]; then + echo "${termux_tag} exists but is missing codex-aarch64-linux-android.tar.gz; repairing promotion." + fi +fi + +{ + echo "${action}=true" + echo "upstream_tag=${upstream_tag}" + echo "upstream_name=${upstream_name}" + echo "termux_tag=${termux_tag}" + echo "upstream_prerelease=${upstream_prerelease}" + echo "upstream_html_url=${upstream_html_url}" + echo "upstream_repo=${upstream_repo}" + echo "release_train=${release_train}" + echo "release_exists=${release_exists}" + echo "asset_exists=${asset_exists}" +} >> "${GITHUB_OUTPUT}" diff --git a/scripts/termux-release-asset-state.sh b/scripts/termux-release-asset-state.sh new file mode 100755 index 000000000000..8694480b6a36 --- /dev/null +++ b/scripts/termux-release-asset-state.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash + +set -euo pipefail + +termux_tag="${1:-${TERMUX_TAG:-}}" +release_repo="${RELEASE_REPO:-${GITHUB_REPOSITORY:-}}" +asset_name="${ASSET_NAME:-codex-aarch64-linux-android.tar.gz}" + +if [[ -z "${termux_tag}" ]]; then + echo "TERMUX_TAG or tag argument is required." >&2 + exit 1 +fi + +if [[ -z "${release_repo}" ]]; then + echo "GITHUB_REPOSITORY or RELEASE_REPO is required." >&2 + exit 1 +fi + +release_exists=false +asset_exists=false + +if gh release view "${termux_tag}" --repo "${release_repo}" >/dev/null 2>&1; then + release_exists=true + release_asset_exists="$( + gh release view "${termux_tag}" \ + --repo "${release_repo}" \ + --json assets \ + | jq -r --arg asset_name "${asset_name}" \ + '.assets | map(.name) | any(. == $asset_name)' + )" + if [[ "${release_asset_exists}" == "true" ]]; then + asset_exists=true + fi +fi + +printf 'release_exists=%s\n' "${release_exists}" +printf 'asset_exists=%s\n' "${asset_exists}" diff --git a/scripts/termux-release-paths.sh b/scripts/termux-release-paths.sh new file mode 100755 index 000000000000..6990f323ec44 --- /dev/null +++ b/scripts/termux-release-paths.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash + +# Sourceable path lists for files owned by the Termux release automation. + +set -euo pipefail + +readonly -a TERMUX_RELEASE_WORKFLOW_PATHS=( + .github/workflows/rust-release.yml + .github/workflows/shell-tool-mcp.yml + .github/workflows/termux-release-checkpoint.yml + .github/workflows/termux-release-deploy.yml + .github/workflows/termux-release-promote.yml +) + +readonly -a TERMUX_RELEASE_BRANCH_SCRIPT_PATHS=( + scripts/termux-configure-git.sh + scripts/termux-create-checkpoint-pr.sh + scripts/termux-create-or-update-mirrored-release.sh + scripts/termux-download-release-artifact.sh + scripts/termux-find-release-pr.sh + scripts/termux-read-release-metadata.sh + scripts/termux-release-asset-state.sh + scripts/termux-release-paths.sh + scripts/termux-resolve-release-ref.sh + scripts/termux-validate-gh-env.sh +) + +readonly -a TERMUX_RELEASE_AUTOMATION_PATHS=( + "${TERMUX_RELEASE_WORKFLOW_PATHS[@]}" + "${TERMUX_RELEASE_BRANCH_SCRIPT_PATHS[@]}" +) + +readonly -a TERMUX_CHECKPOINT_RELEASE_ONLY_PATHS=( + "${TERMUX_RELEASE_AUTOMATION_PATHS[@]}" + .github/termux-release.json +) + +termux_path_in_list() { + local candidate="$1" + shift + local listed_path + + for listed_path in "$@"; do + [[ "${candidate}" != "${listed_path}" ]] || return 0 + done + return 1 +} + +termux_is_release_automation_path() { + termux_path_in_list "$1" "${TERMUX_RELEASE_AUTOMATION_PATHS[@]}" +} + +termux_is_checkpoint_release_only_path() { + termux_path_in_list "$1" "${TERMUX_CHECKPOINT_RELEASE_ONLY_PATHS[@]}" +} diff --git a/scripts/termux-resolve-release-ref.sh b/scripts/termux-resolve-release-ref.sh new file mode 100755 index 000000000000..194773d45bed --- /dev/null +++ b/scripts/termux-resolve-release-ref.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash + +# Resolve the release branch and SHA for deploy/promote workflows. + +set -euo pipefail + +: "${GITHUB_EVENT_NAME:?GITHUB_EVENT_NAME is required}" +: "${GITHUB_OUTPUT:?GITHUB_OUTPUT is required}" + +input_release_branch="${INPUT_RELEASE_BRANCH:-${REQUESTED_RELEASE_BRANCH:-}}" +input_release_sha="${INPUT_RELEASE_SHA:-${REQUESTED_RELEASE_SHA:-}}" + +if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then + if [[ -z "${input_release_branch}" ]]; then + echo "release_branch is required for workflow_dispatch." >&2 + exit 1 + fi + + release_branch="${input_release_branch}" + if [[ -n "${input_release_sha}" ]]; then + git checkout --detach "${input_release_sha}" + release_sha="${input_release_sha}" + else + release_sha="$(git rev-parse HEAD)" + fi +else + : "${GITHUB_REF_NAME:?GITHUB_REF_NAME is required}" + : "${GITHUB_SHA:?GITHUB_SHA is required}" + release_branch="${GITHUB_REF_NAME}" + release_sha="${GITHUB_SHA}" +fi + +{ + echo "branch=${release_branch}" + echo "sha=${release_sha}" +} >> "${GITHUB_OUTPUT}" diff --git a/scripts/termux-validate-gh-env.sh b/scripts/termux-validate-gh-env.sh new file mode 100755 index 000000000000..02581fdebceb --- /dev/null +++ b/scripts/termux-validate-gh-env.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash + +# Lightweight post-toolbox check for jobs that rely on authenticated gh calls. + +set -euo pipefail + +: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" +: "${GH_TOKEN:?GH_TOKEN is required}" + +command -v gh +gh auth status --hostname github.com + +printf 'GITHUB_REPOSITORY=%s\n' "${GITHUB_REPOSITORY}" +printf 'REPO=%s\n' "${REPO:-}" +printf 'GH_REPO_URL=%s\n' "${GH_REPO_URL:-}" +printf 'GH_WORKFLOW_URL=%s\n' "${GH_WORKFLOW_URL:-}" From 225559739cc92c147a3a66311e41d885d3c8a98d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 19:01:41 +0000 Subject: [PATCH 16/64] Prepare Termux rust-v0.133.0 --- .github/termux-release.json | 15 + .github/workflows/rust-release.yml | 1798 +++++++---------- .github/workflows/shell-tool-mcp.yml | 461 +++++ .../workflows/termux-release-checkpoint.yml | 103 + .github/workflows/termux-release-deploy.yml | 243 +++ .github/workflows/termux-release-promote.yml | 135 ++ codex-rs/Cargo.toml | 2 +- scripts/termux-configure-git.sh | 40 + scripts/termux-create-checkpoint-pr.sh | 275 +++ ...ermux-create-or-update-mirrored-release.sh | 111 + scripts/termux-download-release-artifact.sh | 78 + scripts/termux-find-release-pr.sh | 53 + scripts/termux-read-release-metadata.sh | 77 + scripts/termux-release-asset-state.sh | 37 + scripts/termux-release-paths.sh | 55 + scripts/termux-resolve-release-ref.sh | 36 + scripts/termux-validate-gh-env.sh | 16 + 17 files changed, 2460 insertions(+), 1075 deletions(-) create mode 100644 .github/termux-release.json create mode 100644 .github/workflows/shell-tool-mcp.yml create mode 100644 .github/workflows/termux-release-checkpoint.yml create mode 100644 .github/workflows/termux-release-deploy.yml create mode 100644 .github/workflows/termux-release-promote.yml create mode 100755 scripts/termux-configure-git.sh create mode 100755 scripts/termux-create-checkpoint-pr.sh create mode 100755 scripts/termux-create-or-update-mirrored-release.sh create mode 100755 scripts/termux-download-release-artifact.sh create mode 100755 scripts/termux-find-release-pr.sh create mode 100755 scripts/termux-read-release-metadata.sh create mode 100755 scripts/termux-release-asset-state.sh create mode 100755 scripts/termux-release-paths.sh create mode 100755 scripts/termux-resolve-release-ref.sh create mode 100755 scripts/termux-validate-gh-env.sh diff --git a/.github/termux-release.json b/.github/termux-release.json new file mode 100644 index 000000000000..ee27af17fe2f --- /dev/null +++ b/.github/termux-release.json @@ -0,0 +1,15 @@ +{ + "upstream_repo": "openai/codex", + "upstream_tag": "rust-v0.133.0", + "upstream_name": "0.133.0", + "upstream_html_url": "https://github.com/openai/codex/releases/tag/rust-v0.133.0", + "upstream_target": "main", + "upstream_release_id": "327109656", + "upstream_prerelease": false, + "release_train": "0.133.0", + "release_branch": "release/0.133.0", + "work_branch": "upstream/rust-v0.133.0", + "patch_branch": "wallentx/termux-target", + "patch_source_sha": "e9d18f72507bc0f66e761990996e27a6c1a9fdb1", + "termux_tag": "rust-v0.133.0-termux" +} diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index c55337ecfe6e..56d0977e6e67 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -4,46 +4,49 @@ # git tag -a rust-v0.1.0 -m "Release 0.1.0" # git push origin rust-v0.1.0 # ``` -# -# To use external macOS signing, manually dispatch `release_mode=build_unsigned`, -# sign the unsigned macOS artifacts in a secure enclave, upload the signed handoff -# archive as a GitHub Release asset, then manually dispatch -# `release_mode=promote_signed` with `unsigned_run_id` and `signed_macos_asset`. -# The signed handoff archive should contain target or artifact directories such -# as `aarch64-apple-darwin/` with signed binaries. name: rust-release on: - push: - tags: - - "rust-v*.*.*" + pull_request: + branches: + - "release/**" workflow_dispatch: inputs: - release_mode: - description: "build_unsigned creates unsigned macOS handoff artifacts; promote_signed finishes a release from signed macOS handoff artifacts." + source_ref: + description: "Branch, tag, or commit SHA to build (code only; keeps .github from dispatched ref)" required: false + type: string + default: "" + build_target: + description: "Target to build" + required: true type: choice - default: build_unsigned + default: all options: - - build_unsigned - - promote_signed - sign_macos: - description: "Deprecated compatibility input; use release_mode instead." - required: false - type: boolean - default: false - unsigned_run_id: - description: "For promote_signed: workflow run id from the build_unsigned run." - required: false - type: string - signed_macos_asset: - description: "For promote_signed: exact GitHub Release asset name containing signed macOS handoff artifacts." - required: false - type: string - signed_macos_sha256: - description: "For promote_signed: optional SHA-256 of signed_macos_asset." - required: false - type: string + - all + - aarch64-apple-darwin + - x86_64-apple-darwin + - x86_64-unknown-linux-musl + - x86_64-unknown-linux-gnu + - aarch64-unknown-linux-musl + - aarch64-unknown-linux-gnu + - aarch64-linux-android + - x86_64-pc-windows-msvc + - aarch64-pc-windows-msvc + +permissions: + actions: read + attestations: read + checks: read + contents: read + deployments: read + issues: read + discussions: read + packages: read + pages: read + pull-requests: read + repository-projects: read + statuses: read concurrency: group: ${{ github.workflow }} @@ -51,72 +54,26 @@ concurrency: jobs: tag-check: + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: actions/checkout@v6 + - name: 🧰 Actions Toolbox + # This is required for the GitHub CLI + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: wallentx/gh-actions/composite/actions-toolbox@main + - uses: dtolnay/rust-toolchain@1.92 - name: Validate tag matches Cargo.toml version shell: bash - env: - RELEASE_MODE: ${{ github.event_name == 'workflow_dispatch' && inputs.release_mode || 'signed' }} - REQUESTED_SIGN_MACOS: ${{ inputs.sign_macos }} - SIGNED_MACOS_ASSET: ${{ inputs.signed_macos_asset }} - UNSIGNED_RUN_ID: ${{ inputs.unsigned_run_id }} run: | set -euo pipefail echo "::group::Tag validation" - case "${RELEASE_MODE}" in - signed) - if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then - echo "❌ Manual rust-release runs must use release_mode=build_unsigned or release_mode=promote_signed" - exit 1 - fi - ;; - build_unsigned) - if [[ "${GITHUB_EVENT_NAME}" != "workflow_dispatch" ]]; then - echo "❌ release_mode=build_unsigned is only valid for manual runs" - exit 1 - fi - ;; - promote_signed) - if [[ "${GITHUB_EVENT_NAME}" != "workflow_dispatch" ]]; then - echo "❌ release_mode=promote_signed is only valid for manual runs" - exit 1 - fi - if [[ ! "${UNSIGNED_RUN_ID}" =~ ^[0-9]+$ ]]; then - echo "❌ release_mode=promote_signed requires unsigned_run_id to be a workflow run id" - exit 1 - fi - if [[ -z "${SIGNED_MACOS_ASSET}" ]]; then - echo "❌ release_mode=promote_signed requires signed_macos_asset" - exit 1 - fi - if [[ "${SIGNED_MACOS_ASSET}" == */* || "${SIGNED_MACOS_ASSET}" == *"*"* || "${SIGNED_MACOS_ASSET}" == *"?"* || "${SIGNED_MACOS_ASSET}" == *"["* ]]; then - echo "❌ signed_macos_asset must be an exact release asset name, not a path or glob" - exit 1 - fi - if [[ "${UNSIGNED_RUN_ID}" == "${GITHUB_RUN_ID}" ]]; then - echo "❌ unsigned_run_id must refer to the earlier build_unsigned run, not this run" - exit 1 - fi - ;; - *) - echo "❌ Unknown release_mode '${RELEASE_MODE}'" - exit 1 - ;; - esac - - if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${REQUESTED_SIGN_MACOS}" == "true" ]]; then - echo "::warning title=Deprecated sign_macos input ignored::Use release_mode=build_unsigned or release_mode=promote_signed instead." - fi - # 1. Must be a tag and match the regex [[ "${GITHUB_REF_TYPE}" == "tag" ]] \ || { echo "❌ Not a tag push"; exit 1; } - [[ "${GITHUB_REF_NAME}" =~ ^rust-v[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta)(\.[0-9]+)?)?$ ]] \ + [[ "${GITHUB_REF_NAME}" =~ ^rust-v[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta)(\.[0-9]+)?)?(-termux)?$ ]] \ || { echo "❌ Tag '${GITHUB_REF_NAME}' doesn't match expected format"; exit 1; } # 2. Extract versions @@ -131,115 +88,198 @@ jobs: echo "✅ Tag and Cargo.toml agree (${tag_ver})" echo "::endgroup::" - build: - if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} + select-build-matrix: + if: always() && (github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' || needs.tag-check.result == 'success') needs: tag-check - name: Build - ${{ matrix.runner }} - ${{ matrix.target }} - ${{ matrix.bundle }} - runs-on: ${{ matrix.runs_on || matrix.runner }} - # Release builds can take a long time, so leave some headroom to avoid - # having to restart the full workflow due to a timeout. - timeout-minutes: 90 - permissions: - contents: read - id-token: write + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.select.outputs.matrix }} + build_version: ${{ steps.select.outputs.build_version }} + steps: + - uses: actions/checkout@v6 + - name: 🧰 Actions Toolbox + # This is required for the GitHub CLI + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: wallentx/gh-actions/composite/actions-toolbox@main + - id: select + shell: bash + env: + REPO_OWNER: ${{ github.repository_owner }} + EVENT_NAME: ${{ github.event_name }} + BUILD_TARGET: ${{ inputs.build_target }} + run: | + set -euo pipefail + + matrix='[ + {"runner":"macos-15-xlarge","target":"aarch64-apple-darwin"}, + {"runner":"macos-15-xlarge","target":"x86_64-apple-darwin"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-gnu"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-gnu"}, + {"runner":"ubuntu-24.04","target":"aarch64-linux-android"}, + {"runner":"windows-latest","target":"x86_64-pc-windows-msvc"}, + {"runner":"windows-11-arm","target":"aarch64-pc-windows-msvc"} + ]' + + if [[ "${REPO_OWNER}" != "openai" ]]; then + matrix="$(jq -c '[.[] | select(.runner | startswith("macos") | not)]' <<< "${matrix}")" + fi + + if [[ "${EVENT_NAME}" == "pull_request" ]]; then + matrix="$(jq -c '[.[] | select(.target == "aarch64-linux-android")]' <<< "${matrix}")" + elif [[ "${EVENT_NAME}" == "workflow_dispatch" && -n "${BUILD_TARGET}" && "${BUILD_TARGET}" != "all" ]]; then + matrix="$(jq -c --arg build_target "${BUILD_TARGET}" '[.[] | select(.target == $build_target)]' <<< "${matrix}")" + fi + + if [[ "$(jq 'length' <<< "${matrix}")" -eq 0 ]]; then + echo "No build targets selected after applying owner/dispatch filters." >&2 + exit 1 + fi + + echo "matrix={\"include\":${matrix}}" >> "$GITHUB_OUTPUT" + + if [[ "${EVENT_NAME}" == "pull_request" ]]; then + metadata=".github/termux-release.json" + if [[ ! -f "${metadata}" ]]; then + echo "${metadata} is required for release train PR builds" >&2 + exit 1 + fi + upstream_tag="$(jq -r '.upstream_tag // empty' "${metadata}")" + if [[ -z "${upstream_tag}" || "${upstream_tag}" != rust-v* ]]; then + echo "Unable to determine upstream rust tag from ${metadata}" >&2 + exit 1 + fi + build_version="${upstream_tag#rust-v}" + echo "build_version=${build_version}" >> "$GITHUB_OUTPUT" + elif [[ "${EVENT_NAME}" == "workflow_dispatch" ]]; then + latest_release_tag="$( + GH_TOKEN="${{ github.token }}" gh api repos/openai/codex/releases/latest --jq '.tag_name' + )" + + if [[ -z "${latest_release_tag}" || "${latest_release_tag}" != rust-v* ]]; then + echo "Unable to determine latest stable release tag from openai/codex" >&2 + exit 1 + fi + + latest_release_version="${latest_release_tag#rust-v}" + build_version="${latest_release_version}-dev" + echo "build_version=${build_version}" >> "$GITHUB_OUTPUT" + fi + + build: + if: >- + always() && + (github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' || needs.tag-check.result == 'success') && + needs.select-build-matrix.result == 'success' + needs: + - tag-check + - select-build-matrix + name: Build - ${{ matrix.runner }} - ${{ matrix.target }} + runs-on: ${{ matrix.runner }} + permissions: write-all + timeout-minutes: 120 defaults: run: working-directory: codex-rs env: - # 2026-03-04: temporarily change releases to use thin LTO because - # Ubuntu ARM is timing out at 60 minutes. - CARGO_PROFILE_RELEASE_LTO: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'thin' }} - SIGN_MACOS: ${{ github.event_name != 'workflow_dispatch' }} + CODEX_BWRAP_ENABLE_FFI: ${{ contains(matrix.target, 'unknown-linux') && '1' || '0' }} + CODEX_BUILD_VERSION: ${{ needs.select-build-matrix.outputs.build_version }} + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" strategy: fail-fast: false - matrix: - include: - - runner: macos-15-xlarge - target: aarch64-apple-darwin - bundle: primary - artifact_name: aarch64-apple-darwin - binaries: "codex codex-responses-api-proxy" - build_dmg: "true" - - runner: macos-15-xlarge - target: aarch64-apple-darwin - bundle: app-server - artifact_name: aarch64-apple-darwin-app-server - binaries: "codex-app-server" - build_dmg: "false" - - runner: macos-15-xlarge - target: x86_64-apple-darwin - bundle: primary - artifact_name: x86_64-apple-darwin - binaries: "codex codex-responses-api-proxy" - build_dmg: "true" - - runner: macos-15-xlarge - target: x86_64-apple-darwin - bundle: app-server - artifact_name: x86_64-apple-darwin-app-server - binaries: "codex-app-server" - build_dmg: "false" - # Release artifacts intentionally ship MUSL-linked Linux binaries. - - runner: ubuntu-24.04 - target: x86_64-unknown-linux-musl - bundle: primary - artifact_name: x86_64-unknown-linux-musl - binaries: "codex codex-responses-api-proxy bwrap" - build_dmg: "false" - - runner: ubuntu-24.04 - target: x86_64-unknown-linux-musl - bundle: app-server - artifact_name: x86_64-unknown-linux-musl-app-server - binaries: "codex-app-server" - build_dmg: "false" - - runner: ubuntu-24.04-arm - target: aarch64-unknown-linux-musl - bundle: primary - artifact_name: aarch64-unknown-linux-musl - binaries: "codex codex-responses-api-proxy bwrap" - build_dmg: "false" - - runner: ubuntu-24.04-arm - target: aarch64-unknown-linux-musl - bundle: app-server - artifact_name: aarch64-unknown-linux-musl-app-server - binaries: "codex-app-server" - build_dmg: "false" + matrix: ${{ fromJson(needs.select-build-matrix.outputs.matrix) }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@v6 + - name: 🧰 Actions Toolbox + # This is required for the GitHub CLI + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: wallentx/gh-actions/composite/actions-toolbox@main + with: + verbose: true + - name: Checkout selected source ref + if: ${{ github.event_name == 'workflow_dispatch' && inputs.source_ref != '' }} + uses: actions/checkout@v6 with: - persist-credentials: false - - name: Print runner specs (Linux) - if: ${{ runner.os == 'Linux' }} + ref: ${{ inputs.source_ref }} + path: __source__ + fetch-depth: 1 + - name: Overlay selected source ref (excluding .github) + if: ${{ github.event_name == 'workflow_dispatch' && inputs.source_ref != '' }} + shell: bash + working-directory: ${{ github.workspace }} + run: | + set -euo pipefail + echo "Using source ref: ${{ inputs.source_ref }}" + + # Keep workflow/actions from the dispatch ref while replacing all other files. + find . -mindepth 1 -maxdepth 1 \ + ! -name .git \ + ! -name .github \ + ! -name __source__ \ + -exec rm -rf -- {} + + tar --exclude='.github' -C __source__ -cf - . | tar -xf - + rm -rf __source__ + + - name: Apply selected build version + if: ${{ github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' }} shell: bash + working-directory: ${{ github.workspace }} run: | set -euo pipefail - cpu_model="$(lscpu | awk -F: '/Model name/ {gsub(/^[ \t]+/, "", $2); print $2; exit}')" - total_ram="$(awk '/MemTotal/ {printf "%.1f GiB\n", $2 / 1024 / 1024}' /proc/meminfo)" - echo "Runner: ${RUNNER_NAME:-unknown}" - echo "OS: $(uname -a)" - echo "CPU model: ${cpu_model}" - echo "Logical CPUs: $(nproc)" - echo "Total RAM: ${total_ram}" - echo "Disk usage:" - df -h . - - name: Print runner specs (macOS) - if: ${{ runner.os == 'macOS' }} + if [[ -z "${CODEX_BUILD_VERSION}" ]]; then + echo "CODEX_BUILD_VERSION is empty for workflow_dispatch" >&2 + exit 1 + fi + echo "Using build version: ${CODEX_BUILD_VERSION}" + BUILD_VERSION="${CODEX_BUILD_VERSION}" perl -0777 -i -pe \ + 's/(\[workspace\.package\][^\[]*?version = ")([^"]+)(")/${1}$ENV{BUILD_VERSION}$3/s' \ + codex-rs/Cargo.toml + + grep -n "^\[workspace.package\]\|^version = " codex-rs/Cargo.toml | head -n 4 + + - name: Select Android NDK + if: ${{ matrix.target == 'aarch64-linux-android' }} shell: bash run: | set -euo pipefail - total_ram="$(sysctl -n hw.memsize | awk '{printf "%.1f GiB\n", $1 / 1024 / 1024 / 1024}')" - echo "Runner: ${RUNNER_NAME:-unknown}" - echo "OS: $(sw_vers -productName) $(sw_vers -productVersion)" - echo "Hardware model: $(sysctl -n hw.model)" - echo "CPU architecture: $(uname -m)" - echo "Logical CPUs: $(sysctl -n hw.logicalcpu)" - echo "Physical CPUs: $(sysctl -n hw.physicalcpu)" - echo "Total RAM: ${total_ram}" - echo "Disk usage:" - df -h . + + fallback_version="29.0.13113456" + ndk_root="${ANDROID_HOME}/ndk" + selected_ndk="" + + if [[ -d "${ndk_root}" ]]; then + while IFS= read -r candidate; do + toolchain="${candidate}/toolchains/llvm/prebuilt/linux-x86_64" + if [[ -x "${toolchain}/bin/aarch64-linux-android24-clang" ]]; then + selected_ndk="${candidate}" + break + fi + done < <(find "${ndk_root}" -mindepth 1 -maxdepth 1 -type d | sort -Vr) + fi + + if [[ -z "${selected_ndk}" ]]; then + echo "No usable preinstalled Android NDK found; installing ${fallback_version}" + yes | sudo "${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager" --licenses || true + sudo "${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager" "ndk;${fallback_version}" + selected_ndk="${ndk_root}/${fallback_version}" + fi + + if [[ ! -x "${selected_ndk}/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android24-clang" ]]; then + echo "Selected Android NDK does not contain a usable aarch64 API 24 clang: ${selected_ndk}" >&2 + exit 1 + fi + + echo "Selected Android NDK: ${selected_ndk}" + "${selected_ndk}/toolchains/llvm/prebuilt/linux-x86_64/bin/clang" --version + echo "ANDROID_NDK_HOME=${selected_ndk}" >> "$GITHUB_ENV" + echo "NDK_HOME=${selected_ndk}" >> "$GITHUB_ENV" - name: Install Linux bwrap build dependencies - if: ${{ runner.os == 'Linux' }} + if: ${{ runner.os == 'Linux' && matrix.target != 'aarch64-linux-android' }} shell: bash run: | set -euo pipefail @@ -254,9 +294,173 @@ jobs: sudo apt-get update -y sudo DEBIAN_FRONTEND=noninteractive apt-get install -y libubsan1 fi - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: dtolnay/rust-toolchain@1.93 with: targets: ${{ matrix.target }} + - name: Set up sccache (Android) + if: ${{ matrix.target == 'aarch64-linux-android' }} + uses: mozilla-actions/sccache-action@main + - name: Enable sccache (Android) + if: ${{ matrix.target == 'aarch64-linux-android' }} + shell: bash + run: | + set -euo pipefail + echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV" + echo "SCCACHE_GHA_ENABLED=true" >> "$GITHUB_ENV" + echo "SCCACHE_GHA_VERSION=android-release-${{ matrix.target }}" >> "$GITHUB_ENV" + echo "SCCACHE_CACHE_SIZE=5G" >> "$GITHUB_ENV" + - name: Ensure Rust target is installed + shell: bash + run: | + set -euo pipefail + rustup target add "${{ matrix.target }}" + - if: ${{ matrix.target == 'aarch64-linux-android' }} + name: Configure Android build environment + shell: bash + run: | + set -euo pipefail + ndk="${ANDROID_NDK_HOME}" + target="${{ matrix.target }}" + + # Set up the Android toolchain + toolchain="${ndk}/toolchains/llvm/prebuilt/linux-x86_64" + + # Use API level 24 to follow termux-build convention + api_level="24" + + # Configure Cargo to use the NDK linker + cargo_target_var="CARGO_TARGET_${target^^}_LINKER" + cargo_target_var="${cargo_target_var//-/_}" + echo "${cargo_target_var}=${toolchain}/bin/aarch64-linux-android${api_level}-clang" >> "$GITHUB_ENV" + + # Set CC and AR for the target + target_cc_var="CC_${target}" + target_cc_var="${target_cc_var//-/_}" + echo "${target_cc_var}=${toolchain}/bin/aarch64-linux-android${api_level}-clang" >> "$GITHUB_ENV" + + target_cxx_var="CXX_${target}" + target_cxx_var="${target_cxx_var//-/_}" + echo "${target_cxx_var}=${toolchain}/bin/aarch64-linux-android${api_level}-clang++" >> "$GITHUB_ENV" + + target_ar_var="AR_${target}" + target_ar_var="${target_ar_var//-/_}" + echo "${target_ar_var}=${toolchain}/bin/llvm-ar" >> "$GITHUB_ENV" + + # Add toolchain to PATH + echo "${toolchain}/bin" >> "$GITHUB_PATH" + + tls_align_source="${RUNNER_TEMP}/codex-android-tls-align.S" + tls_align_object="${RUNNER_TEMP}/codex-android-tls-align.o" + cat > "${tls_align_source}" <<'EOF' + .section .tdata.codex_android_tls_align,"awT",%progbits + .p2align 6 + .globl __codex_android_tls_align + .hidden __codex_android_tls_align + .type __codex_android_tls_align, %object + __codex_android_tls_align: + .byte 0 + .size __codex_android_tls_align, 1 + EOF + "${toolchain}/bin/aarch64-linux-android${api_level}-clang" -c "${tls_align_source}" -o "${tls_align_object}" + + v8_compat_source="${RUNNER_TEMP}/codex-android-v8-compat.c" + v8_compat_object="${RUNNER_TEMP}/codex-android-v8-compat.o" + cat > "${v8_compat_source}" <<'EOF' + #include + #include + + extern int posix_memalign(void **memptr, size_t alignment, size_t size); + extern double strtod(const char *nptr, char **endptr); + extern float strtof(const char *nptr, char **endptr); + + void *aligned_alloc(size_t alignment, size_t size) { + void *ptr = 0; + if (posix_memalign(&ptr, alignment, size) != 0) { + return 0; + } + return ptr; + } + + double strtod_l(const char *nptr, char **endptr, locale_t locale) { + (void)locale; + return strtod(nptr, endptr); + } + + float strtof_l(const char *nptr, char **endptr, locale_t locale) { + (void)locale; + return strtof(nptr, endptr); + } + EOF + "${toolchain}/bin/aarch64-linux-android${api_level}-clang" -c "${v8_compat_source}" -o "${v8_compat_object}" + + builtins_archive="$( + find "${toolchain}/lib/clang" \ + -path "*/lib/linux/libclang_rt.builtins-aarch64-android.a" \ + -print -quit + )" + if [[ -z "${builtins_archive}" ]]; then + echo "Could not find Android compiler-rt builtins archive under ${toolchain}/lib/clang" >&2 + exit 1 + fi + builtins_dir="$(dirname "${builtins_archive}")" + + # Provide compatibility symbols and C++ ABI needed by the Android rusty_v8 archive. + rustflags_var="CARGO_TARGET_${target^^}_RUSTFLAGS" + rustflags_var="${rustflags_var//-/_}" + echo "${rustflags_var}=-C link-arg=${tls_align_object} -C link-arg=${v8_compat_object} -C link-arg=-Wl,-u,__codex_android_tls_align -C link-arg=-L${builtins_dir} -C link-arg=-lclang_rt.builtins-aarch64-android -C link-arg=-lc++abi -C link-arg=-Wl,-z,max-page-size=65536" >> "$GITHUB_ENV" + + + # Configure for vendored OpenSSL build + echo "CARGO_BUILD_TARGET_APPLIES_TO_HOST=false" >> "$GITHUB_ENV" + echo "CARGO_BUILD_TARGET=${{ matrix.target }}" >> "$GITHUB_ENV" + + - if: ${{ matrix.target == 'aarch64-linux-android' }} + name: Configure Android rusty_v8 artifact overrides + env: + GH_TOKEN: ${{ github.token }} + TARGET: ${{ matrix.target }} + shell: bash + run: | + set -euo pipefail + binding_dir="${RUNNER_TEMP}/rusty_v8" + archive="${binding_dir}/librusty_v8_release_${TARGET}.a.gz" + binding_path="${binding_dir}/src_binding_release_${TARGET}.rs" + checksums_path="${binding_dir}/rusty_v8_release_${TARGET}.sha256" + artifact_repository="wallentx/codex-termux" + release_tag="rusty-v8-v147.4.0" + mkdir -p "${binding_dir}" + echo "Downloading Android rusty_v8 artifacts from ${artifact_repository}@${release_tag}" + gh release download "${release_tag}" \ + --repo "${artifact_repository}" \ + --dir "${binding_dir}" \ + --pattern "librusty_v8_release_${TARGET}.a.gz" \ + --pattern "src_binding_release_${TARGET}.rs" \ + --pattern "rusty_v8_release_${TARGET}.sha256" + (cd "${binding_dir}" && sha256sum -c "$(basename "${checksums_path}")") + echo "RUSTY_V8_ARCHIVE=${archive}" >> "$GITHUB_ENV" + echo "RUSTY_V8_SRC_BINDING_PATH=${binding_path}" >> "$GITHUB_ENV" + + - if: ${{ matrix.target == 'aarch64-linux-android' }} + name: Install termux-elf-cleaner + shell: bash + run: | + set -euo pipefail + version="v3.0.1" + src_dir="${RUNNER_TEMP}/termux-elf-cleaner-src" + build_dir="${RUNNER_TEMP}/termux-elf-cleaner-build" + bin_dir="${RUNNER_TEMP}/termux-elf-cleaner-bin" + + rm -rf "${src_dir}" "${build_dir}" "${bin_dir}" + mkdir -p "${src_dir}" "${build_dir}" "${bin_dir}" + + curl -fsSL "https://github.com/termux/termux-elf-cleaner/archive/refs/tags/${version}.tar.gz" \ + | tar -xzf - --strip-components=1 -C "${src_dir}" + + cmake -S "${src_dir}" -B "${build_dir}" -DCMAKE_BUILD_TYPE=Release + cmake --build "${build_dir}" --parallel + + install "${build_dir}/termux-elf-cleaner" "${bin_dir}/termux-elf-cleaner" + echo "${bin_dir}" >> "$GITHUB_PATH" - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} name: Use hermetic Cargo home (musl) @@ -269,12 +473,25 @@ jobs: echo "${cargo_home}/bin" >> "$GITHUB_PATH" : > "${cargo_home}/config.toml" + - uses: actions/cache@v5 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + ${{ github.workspace }}/.cargo-home/bin/ + ${{ github.workspace }}/.cargo-home/registry/index/ + ${{ github.workspace }}/.cargo-home/registry/cache/ + ${{ github.workspace }}/.cargo-home/git/db/ + ${{ github.workspace }}/codex-rs/target/ + key: cargo-${{ matrix.runner }}-${{ matrix.target }}-release-${{ hashFiles('**/Cargo.lock') }} + - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} name: Install Zig - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 + uses: mlugg/setup-zig@v2 with: version: 0.14.0 - use-cache: false - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} name: Install musl build tools @@ -310,12 +527,6 @@ jobs: shell: bash run: | set -euo pipefail - # Avoid problematic aws-lc jitter entropy code path on musl builders. - echo "AWS_LC_SYS_NO_JITTER_ENTROPY=1" >> "$GITHUB_ENV" - target_no_jitter="AWS_LC_SYS_NO_JITTER_ENTROPY_${{ matrix.target }}" - target_no_jitter="${target_no_jitter//-/_}" - echo "${target_no_jitter}=1" >> "$GITHUB_ENV" - # Clear global Rust flags so host/proc-macro builds don't pull in UBSan. echo "RUSTFLAGS=" >> "$GITHUB_ENV" echo "CARGO_ENCODED_RUSTFLAGS=" >> "$GITHUB_ENV" @@ -340,94 +551,70 @@ jobs: echo "CFLAGS=${cflags}" >> "$GITHUB_ENV" echo "CXXFLAGS=${cxxflags}" >> "$GITHUB_ENV" - - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl' }} - name: Configure musl rusty_v8 artifact overrides and verify checksums - uses: ./.github/actions/setup-rusty-v8-musl - with: - target: ${{ matrix.target }} - - - if: ${{ contains(matrix.target, 'linux') && matrix.bundle == 'primary' }} - name: Build bwrap and export digest + - name: Cargo build shell: bash run: | set -euo pipefail - target="${{ matrix.target }}" - cargo build --target "$target" --release --timings --bin bwrap - bwrap_path="target/${target}/release/bwrap" - if [[ ! -f "$bwrap_path" ]]; then - echo "bwrap binary ${bwrap_path} not found" - exit 1 + if [[ "${{ matrix.target }}" == 'aarch64-linux-android' ]]; then + export CARGO_BUILD_JOBS=4 + export CARGO_PROFILE_RELEASE_LTO=thin + export CARGO_PROFILE_RELEASE_CODEGEN_UNITS=8 + cargo build --target ${{ matrix.target }} --release --bin codex + exit 0 fi - digest="$(sha256sum "$bwrap_path" | awk '{print $1}')" - echo "CODEX_BWRAP_SHA256=${digest}" >> "$GITHUB_ENV" - echo "Built bwrap ${bwrap_path} with sha256:${digest}" - - - name: Cargo build + if [[ "${{ contains(matrix.target, 'windows') }}" == 'true' ]]; then + cargo build --target ${{ matrix.target }} --release --bin codex --bin codex-responses-api-proxy --bin codex-windows-sandbox-setup --bin codex-command-runner + else + cargo build --target ${{ matrix.target }} --release --bin codex --bin codex-responses-api-proxy + fi + - name: sccache stats (Android) + if: ${{ matrix.target == 'aarch64-linux-android' }} shell: bash - run: | - build_args=() - for binary in ${{ matrix.binaries }}; do - build_args+=(--bin "$binary") - done - echo "CARGO_PROFILE_RELEASE_LTO: ${CARGO_PROFILE_RELEASE_LTO}" - cargo build --target ${{ matrix.target }} --release --timings "${build_args[@]}" + run: ${SCCACHE_PATH:-sccache} --show-stats || true - - name: Upload Cargo timings - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: cargo-timings-rust-release-${{ matrix.target }}-${{ matrix.bundle }} - path: codex-rs/target/**/cargo-timings/cargo-timing.html - if-no-files-found: warn - - - if: ${{ runner.os == 'macOS' && env.SIGN_MACOS != 'true' }} - name: Stage unsigned macOS artifacts + - if: ${{ matrix.target == 'aarch64-linux-android' }} + name: Normalize Android ELF for Termux shell: bash run: | set -euo pipefail - - target="${{ matrix.target }}" - release_dir="target/${target}/release" - dest="unsigned-dist/${target}" - mkdir -p "$dest" - - for binary in ${{ matrix.binaries }}; do - binary_path="${release_dir}/${binary}" - unsigned_name="${binary}-${target}-unsigned" - unsigned_path="${dest}/${unsigned_name}" - if [[ ! -f "${binary_path}" ]]; then - echo "Binary ${binary_path} not found" - exit 1 + for binary in codex codex-responses-api-proxy; do + binary_path="target/${{ matrix.target }}/release/${binary}" + if [[ -f "${binary_path}" ]]; then + termux-elf-cleaner --api-level 24 "${binary_path}" + chmod +x "${binary_path}" + # if [[ "${binary}" == "codex" ]]; then + # # Patch PT_TLS (0x7) to PT_NULL (0x0) at offset 400 to bypass Bionic alignment checks. + # printf '\x00\x00\x00\x00' | dd of="${binary_path}" bs=1 seek=400 count=4 conv=notrunc + # fi fi - - cp "${binary_path}" "${unsigned_path}" - tar -C "$dest" -czf "${unsigned_path}.tar.gz" "${unsigned_name}" - zstd -T0 -19 --rm "${unsigned_path}" done - - if: ${{ runner.os == 'macOS' && env.SIGN_MACOS != 'true' }} - name: Upload unsigned macOS artifacts - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: ${{ matrix.artifact_name }}-unsigned - path: codex-rs/unsigned-dist/${{ matrix.target }}/* - if-no-files-found: error - - - if: ${{ contains(matrix.target, 'linux') }} + - if: ${{ contains(matrix.target, 'linux') && !contains(matrix.target, 'android') && github.repository_owner == 'openai' }} name: Cosign Linux artifacts uses: ./.github/actions/linux-code-sign with: target: ${{ matrix.target }} artifacts-dir: ${{ github.workspace }}/codex-rs/target/${{ matrix.target }}/release - binaries: ${{ matrix.binaries }} - - if: ${{ runner.os == 'macOS' && env.SIGN_MACOS == 'true' }} + - if: ${{ contains(matrix.target, 'windows') && github.repository_owner == 'openai' }} + name: Sign Windows binaries with Azure Trusted Signing + uses: ./.github/actions/windows-code-sign + with: + target: ${{ matrix.target }} + client-id: ${{ secrets.AZURE_TRUSTED_SIGNING_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TRUSTED_SIGNING_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_TRUSTED_SIGNING_SUBSCRIPTION_ID }} + endpoint: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} + account-name: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} + certificate-profile-name: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} + + - if: ${{ runner.os == 'macOS' && github.repository_owner == 'openai' }} name: MacOS code signing (binaries) uses: ./.github/actions/macos-code-sign with: target: ${{ matrix.target }} - binaries: ${{ matrix.binaries }} sign-binaries: "true" sign-dmg: "false" apple-certificate: ${{ secrets.APPLE_CERTIFICATE_P12 }} @@ -436,7 +623,7 @@ jobs: apple-notarization-key-id: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} apple-notarization-issuer-id: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} - - if: ${{ runner.os == 'macOS' && matrix.build_dmg == 'true' && env.SIGN_MACOS == 'true' }} + - if: ${{ runner.os == 'macOS' && github.repository_owner == 'openai' }} name: Build macOS dmg shell: bash run: | @@ -451,17 +638,23 @@ jobs: # The previous "MacOS code signing (binaries)" step signs + notarizes the # built artifacts in `${release_dir}`. This step packages *those same* # signed binaries into a dmg. + codex_binary_path="${release_dir}/codex" + proxy_binary_path="${release_dir}/codex-responses-api-proxy" + rm -rf "$dmg_root" mkdir -p "$dmg_root" - for binary in ${{ matrix.binaries }}; do - binary_path="${release_dir}/${binary}" - if [[ ! -f "${binary_path}" ]]; then - echo "Binary ${binary_path} not found" - exit 1 - fi - ditto "${binary_path}" "${dmg_root}/${binary}" - done + if [[ ! -f "$codex_binary_path" ]]; then + echo "Binary $codex_binary_path not found" + exit 1 + fi + if [[ ! -f "$proxy_binary_path" ]]; then + echo "Binary $proxy_binary_path not found" + exit 1 + fi + + ditto "$codex_binary_path" "${dmg_root}/codex" + ditto "$proxy_binary_path" "${dmg_root}/codex-responses-api-proxy" rm -f "$dmg_path" hdiutil create \ @@ -476,7 +669,7 @@ jobs: exit 1 fi - - if: ${{ runner.os == 'macOS' && matrix.build_dmg == 'true' && env.SIGN_MACOS == 'true' }} + - if: ${{ runner.os == 'macOS' && github.repository_owner == 'openai' }} name: MacOS code signing (dmg) uses: ./.github/actions/macos-code-sign with: @@ -490,121 +683,65 @@ jobs: apple-notarization-issuer-id: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} - name: Stage artifacts - if: ${{ runner.os != 'macOS' || env.SIGN_MACOS == 'true' }} shell: bash run: | dest="dist/${{ matrix.target }}" mkdir -p "$dest" - for binary in ${{ matrix.binaries }}; do - cp "target/${{ matrix.target }}/release/${binary}" "$dest/${binary}-${{ matrix.target }}" - if [[ "${{ matrix.target }}" == *linux* ]]; then - cp "target/${{ matrix.target }}/release/${binary}.sigstore" \ - "$dest/${binary}-${{ matrix.target }}.sigstore" + if [[ "${{ matrix.runner }}" == windows* ]]; then + cp target/${{ matrix.target }}/release/codex.exe "$dest/codex-${{ matrix.target }}.exe" + cp target/${{ matrix.target }}/release/codex-responses-api-proxy.exe "$dest/codex-responses-api-proxy-${{ matrix.target }}.exe" + cp target/${{ matrix.target }}/release/codex-windows-sandbox-setup.exe "$dest/codex-windows-sandbox-setup-${{ matrix.target }}.exe" + cp target/${{ matrix.target }}/release/codex-command-runner.exe "$dest/codex-command-runner-${{ matrix.target }}.exe" + else + # For Android, we want the binary to be named just 'codex' in the archive. + if [[ "${{ matrix.target }}" == "aarch64-linux-android" ]]; then + cp target/${{ matrix.target }}/release/codex "$dest/codex" + else + cp target/${{ matrix.target }}/release/codex "$dest/codex-${{ matrix.target }}" + fi + if [[ -f target/${{ matrix.target }}/release/codex-responses-api-proxy ]]; then + cp target/${{ matrix.target }}/release/codex-responses-api-proxy "$dest/codex-responses-api-proxy-${{ matrix.target }}" fi - done - - if [[ "${{ matrix.target }}" == *linux* && "${{ matrix.bundle }}" == "primary" ]]; then - bundle_root="${RUNNER_TEMP}/codex-${{ matrix.target }}-bundle" - rm -rf "$bundle_root" - mkdir -p "$bundle_root/codex-resources" - cp "$dest/codex-${{ matrix.target }}" "$bundle_root/codex" - cp "$dest/bwrap-${{ matrix.target }}" "$bundle_root/codex-resources/bwrap" - chmod 0755 "$bundle_root/codex" "$bundle_root/codex-resources/bwrap" - tar -C "$bundle_root" -cf - codex codex-resources/bwrap | - zstd -T0 -19 -o "$dest/codex-${{ matrix.target }}-bundle.tar.zst" fi - if [[ "${{ matrix.build_dmg }}" == "true" ]]; then - cp target/${{ matrix.target }}/release/codex-${{ matrix.target }}.dmg "$dest/codex-${{ matrix.target }}.dmg" + if [[ "${{ matrix.target }}" == *linux* && "${{ matrix.target }}" != *android* ]]; then + cp target/${{ matrix.target }}/release/codex.sigstore "$dest/codex-${{ matrix.target }}.sigstore" + cp target/${{ matrix.target }}/release/codex-responses-api-proxy.sigstore "$dest/codex-responses-api-proxy-${{ matrix.target }}.sigstore" fi - - name: Build Codex package archive - if: ${{ runner.os != 'macOS' || env.SIGN_MACOS == 'true' }} - shell: bash - env: - TARGET: ${{ matrix.target }} - BUNDLE: ${{ matrix.bundle }} - run: | - set -euo pipefail - bash "${GITHUB_WORKSPACE}/.github/scripts/build-codex-package-archive.sh" \ - --target "$TARGET" \ - --bundle "$BUNDLE" \ - --entrypoint-dir "target/${TARGET}/release" \ - --archive-dir "dist/${TARGET}" - - - name: Build Python runtime wheel - if: ${{ matrix.bundle == 'primary' && (runner.os != 'macOS' || env.SIGN_MACOS == 'true') }} - shell: bash - run: | - set -euo pipefail - - case "${{ matrix.target }}" in - aarch64-apple-darwin) - platform_tag="macosx_11_0_arm64" - ;; - x86_64-apple-darwin) - platform_tag="macosx_10_9_x86_64" - ;; - aarch64-unknown-linux-musl) - platform_tag="manylinux_2_17_aarch64" - ;; - x86_64-unknown-linux-musl) - platform_tag="manylinux_2_17_x86_64" - ;; - *) - echo "No Python runtime wheel platform tag for ${{ matrix.target }}" - exit 1 - ;; - esac - - python3 -m venv "${RUNNER_TEMP}/python-runtime-build-venv" - # Do not install into the runner's system Python; macOS runners mark - # the Homebrew Python as externally managed under PEP 668. - "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m pip install build - - stage_dir="${RUNNER_TEMP}/openai-codex-cli-bin-${{ matrix.target }}" - wheel_dir="${GITHUB_WORKSPACE}/python-runtime-dist/${{ matrix.target }}" - stage_runtime_args=( - "${GITHUB_WORKSPACE}/sdk/python/scripts/update_sdk_artifacts.py" - stage-runtime - "$stage_dir" - "${GITHUB_WORKSPACE}/codex-rs/target/${{ matrix.target }}/release/codex" - --codex-version "${GITHUB_REF_NAME}" - --platform-tag "$platform_tag" - ) - if [[ "${{ matrix.target }}" == *linux* ]]; then - # Keep bwrap in the runtime wheel so Linux sandbox fallback behavior - # matches the standalone release bundle on hosts without system bwrap. - stage_runtime_args+=( - --resource-binary - "${GITHUB_WORKSPACE}/codex-rs/target/${{ matrix.target }}/release/bwrap" - ) + if [[ "${{ matrix.target }}" == *apple-darwin ]]; then + cp target/${{ matrix.target }}/release/codex-${{ matrix.target }}.dmg "$dest/codex-${{ matrix.target }}.dmg" fi - python3 "${stage_runtime_args[@]}" - "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m build --wheel --outdir "$wheel_dir" "$stage_dir" - - name: Upload Python runtime wheel - if: ${{ matrix.bundle == 'primary' && (runner.os != 'macOS' || env.SIGN_MACOS == 'true') }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: python-runtime-wheel-${{ matrix.target }} - path: python-runtime-dist/${{ matrix.target }}/*.whl - if-no-files-found: error + - if: ${{ matrix.runner == 'windows-11-arm' }} + name: Install zstd + shell: powershell + run: choco install -y zstandard - name: Compress artifacts - if: ${{ runner.os != 'macOS' || env.SIGN_MACOS == 'true' }} shell: bash run: | # Path that contains the uncompressed binaries for the current # ${{ matrix.target }} dest="dist/${{ matrix.target }}" + repo_root=$PWD + + # We want to ship the raw Windows executables in the GitHub Release + # in addition to the compressed archives. Keep the originals for + # Windows targets; remove them elsewhere to limit the number of + # artifacts that end up in the GitHub Release. + keep_originals=false + if [[ "${{ matrix.runner }}" == windows* ]]; then + keep_originals=true + fi # For compatibility with environments that lack the `zstd` tool we - # additionally create a `.tar.gz` alongside every binary we publish. - # The end result is: + # additionally create a `.tar.gz` for all platforms and `.zip` for + # Windows alongside every single binary that we publish. The end result is: # codex-.zst (existing) # codex-.tar.gz (new) + # codex-.zip (only for Windows) # 1. Produce a .tar.gz for every file in the directory *before* we # run `zstd --rm`, because that flag deletes the original files. @@ -612,7 +749,7 @@ jobs: base="$(basename "$f")" # Skip files that are already archives (shouldn't happen, but be # safe). - if [[ "$base" == *.tar.gz || "$base" == *.tar.zst || "$base" == *.zip || "$base" == *.dmg ]]; then + if [[ "$base" == *.tar.gz || "$base" == *.zip || "$base" == *.dmg ]]; then continue fi @@ -622,344 +759,239 @@ jobs: fi # Create per-binary tar.gz - tar -C "$dest" -czf "$dest/${base}.tar.gz" "$base" - - # Also create .zst and remove the uncompressed binaries to keep - # non-Windows artifact directories small. - zstd -T0 -19 --rm "$dest/$base" - done - - - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - if: ${{ runner.os != 'macOS' || env.SIGN_MACOS == 'true' }} - with: - name: ${{ matrix.artifact_name }} - # Upload the per-binary .zst files, .tar.gz equivalents, and any - # prebuilt archives staged above. - path: | - codex-rs/dist/${{ matrix.target }}/* - - stage-signed-macos: - if: ${{ github.event_name == 'workflow_dispatch' && inputs.release_mode == 'promote_signed' }} - needs: tag-check - name: Stage signed macOS handoff - ${{ matrix.target }} - ${{ matrix.bundle }} - runs-on: macos-15-xlarge - timeout-minutes: 30 - permissions: - contents: read - defaults: - run: - working-directory: codex-rs + # For Android, we want the archive to have the target suffix even though the binary is just 'codex'. + archive_name="${base}" + if [[ "${{ matrix.target }}" == "aarch64-linux-android" && "${base}" == "codex" ]]; then + archive_name="codex-${{ matrix.target }}" + fi + tar -C "$dest" -czf "$dest/${archive_name}.tar.gz" "$base" + + # Create zip archive for Windows binaries + # Must run from inside the dest dir so 7z won't + # embed the directory path inside the zip. + if [[ "${{ matrix.runner }}" == windows* ]]; then + if [[ "$base" == "codex-${{ matrix.target }}.exe" ]]; then + # Bundle the sandbox helper binaries into the main codex zip so + # WinGet installs include the required helpers next to codex.exe. + # Fall back to the single-binary zip if the helpers are missing + # to avoid breaking releases. + bundle_dir="$(mktemp -d)" + runner_src="$dest/codex-command-runner-${{ matrix.target }}.exe" + setup_src="$dest/codex-windows-sandbox-setup-${{ matrix.target }}.exe" + if [[ -f "$runner_src" && -f "$setup_src" ]]; then + cp "$dest/$base" "$bundle_dir/$base" + cp "$runner_src" "$bundle_dir/codex-command-runner.exe" + cp "$setup_src" "$bundle_dir/codex-windows-sandbox-setup.exe" + # Use an absolute path so bundle zips land in the real dist + # dir even when 7z runs from a temp directory. + (cd "$bundle_dir" && 7z a "$repo_root/$dest/${base}.zip" .) + else + echo "warning: missing sandbox binaries; falling back to single-binary zip" + echo "warning: expected $runner_src and $setup_src" + (cd "$dest" && 7z a "${base}.zip" "$base") + fi + rm -rf "$bundle_dir" + else + (cd "$dest" && 7z a "${base}.zip" "$base") + fi + fi - strategy: - fail-fast: false - matrix: - include: - - target: aarch64-apple-darwin - bundle: primary - artifact_name: aarch64-apple-darwin - binaries: "codex codex-responses-api-proxy" - build_dmg: "false" - - target: aarch64-apple-darwin - bundle: app-server - artifact_name: aarch64-apple-darwin-app-server - binaries: "codex-app-server" - build_dmg: "false" - - target: x86_64-apple-darwin - bundle: primary - artifact_name: x86_64-apple-darwin - binaries: "codex codex-responses-api-proxy" - build_dmg: "false" - - target: x86_64-apple-darwin - bundle: app-server - artifact_name: x86_64-apple-darwin-app-server - binaries: "codex-app-server" - build_dmg: "false" + # Also create .zst (existing behaviour) *and* remove the original + # uncompressed binary to keep the directory small. + zstd_args=(-T0 -19) + if [[ "${keep_originals}" == false ]]; then + zstd_args+=(--rm) + fi - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false + if [[ "${archive_name}" != "${base}" ]]; then + zstd "${zstd_args[@]}" "$dest/$base" -o "$dest/${archive_name}.zst" + else + zstd "${zstd_args[@]}" "$dest/$base" + fi + done - - name: Download signed macOS handoff + - name: Add Termux release metadata + if: ${{ (github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch') && matrix.target == 'aarch64-linux-android' }} shell: bash - env: - GH_TOKEN: ${{ github.token }} - SIGNED_MACOS_ASSET: ${{ inputs.signed_macos_asset }} - SIGNED_MACOS_SHA256: ${{ inputs.signed_macos_sha256 }} run: | set -euo pipefail - - download_dir="${RUNNER_TEMP}/signed-macos-download" - handoff_dir="${RUNNER_TEMP}/signed-macos-handoff" - rm -rf "$download_dir" "$handoff_dir" - mkdir -p "$download_dir" "$handoff_dir" - - gh release download "$GITHUB_REF_NAME" \ - --repo "$GITHUB_REPOSITORY" \ - --pattern "$SIGNED_MACOS_ASSET" \ - --dir "$download_dir" - - asset_count="$(find "$download_dir" -maxdepth 1 -type f | wc -l | tr -d '[:space:]')" - if [[ "$asset_count" != "1" ]]; then - echo "Expected exactly one signed macOS handoff asset named ${SIGNED_MACOS_ASSET}; found ${asset_count}" - find "$download_dir" -maxdepth 1 -type f -print - exit 1 - fi - - asset_path="$(find "$download_dir" -maxdepth 1 -type f -print -quit)" - if [[ -n "${SIGNED_MACOS_SHA256}" ]]; then - expected_sha="$(printf '%s' "$SIGNED_MACOS_SHA256" | tr '[:upper:]' '[:lower:]')" - actual_sha="$(shasum -a 256 "$asset_path" | awk '{print $1}')" - if [[ "$actual_sha" != "$expected_sha" ]]; then - echo "signed_macos_sha256 mismatch for ${SIGNED_MACOS_ASSET}" - echo "expected: ${expected_sha}" - echo "actual: ${actual_sha}" - exit 1 - fi + if [[ ! -f "${GITHUB_WORKSPACE}/.github/termux-release.json" ]]; then + echo "No Termux release metadata found; skipping metadata attachment." + exit 0 fi + dest="dist/${{ matrix.target }}" + cp "${GITHUB_WORKSPACE}/.github/termux-release.json" "${dest}/termux-release.json" + ( + cd "${dest}" + sha256sum ./* > SHA256SUMS + ) - asset_name="$(basename "$asset_path")" - case "$asset_name" in - *.tar.zst) - zstd -dc "$asset_path" | tar -C "$handoff_dir" -xf - - ;; - *.tar.gz|*.tgz) - tar -C "$handoff_dir" -xzf "$asset_path" - ;; - *.zip) - ditto -x -k "$asset_path" "$handoff_dir" - ;; - *) - echo "Unsupported signed macOS handoff archive format: ${asset_name}" - exit 1 - ;; - esac - - echo "SIGNED_MACOS_HANDOFF_DIR=$handoff_dir" >> "$GITHUB_ENV" + - id: upload-artifact + uses: actions/upload-artifact@v6 + with: + name: ${{ github.event_name == 'pull_request' && format('termux-android-pr-{0}-{1}', github.event.pull_request.number, github.event.pull_request.head.sha) || matrix.target }} + # Upload the per-binary .zst files as well as the new .tar.gz + # equivalents we generated in the previous step. + path: | + codex-rs/dist/${{ matrix.target }}/* - - name: Stage signed macOS artifacts + - name: Publish Termux artifact notification + if: ${{ github.event_name == 'pull_request' && startsWith(github.base_ref, 'release/') && matrix.target == 'aarch64-linux-android' }} shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ARTIFACT_ID: ${{ steps.upload-artifact.outputs.artifact-id }} + ARTIFACT_NAME: ${{ github.event_name == 'pull_request' && format('termux-android-pr-{0}-{1}', github.event.pull_request.number, github.event.pull_request.head.sha) || matrix.target }} + PR_NUMBER: ${{ github.event.pull_request.number }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} run: | set -euo pipefail - - target="${{ matrix.target }}" - artifact_name="${{ matrix.artifact_name }}" - source_dir="${SIGNED_MACOS_HANDOFF_DIR}/${artifact_name}" - if [[ ! -d "$source_dir" && -d "${SIGNED_MACOS_HANDOFF_DIR}/dist/${artifact_name}" ]]; then - source_dir="${SIGNED_MACOS_HANDOFF_DIR}/dist/${artifact_name}" - fi - if [[ ! -d "$source_dir" && -d "${SIGNED_MACOS_HANDOFF_DIR}/${target}" ]]; then - source_dir="${SIGNED_MACOS_HANDOFF_DIR}/${target}" - fi - if [[ ! -d "$source_dir" && -d "${SIGNED_MACOS_HANDOFF_DIR}/dist/${target}" ]]; then - source_dir="${SIGNED_MACOS_HANDOFF_DIR}/dist/${target}" - fi - if [[ ! -d "$source_dir" ]]; then - echo "Signed macOS handoff is missing ${artifact_name}/" - echo "Expected either:" - echo " ${SIGNED_MACOS_HANDOFF_DIR}/${artifact_name}" - echo " ${SIGNED_MACOS_HANDOFF_DIR}/dist/${artifact_name}" - echo " ${SIGNED_MACOS_HANDOFF_DIR}/${target}" - echo " ${SIGNED_MACOS_HANDOFF_DIR}/dist/${target}" - find "$SIGNED_MACOS_HANDOFF_DIR" -maxdepth 3 -type f -print + if [[ -z "${ARTIFACT_ID}" ]]; then + echo "upload-artifact did not return an artifact id" >&2 exit 1 fi - dest="dist/${target}" - mkdir -p "$dest" - - for binary in ${{ matrix.binaries }}; do - source_path="${source_dir}/${binary}" - if [[ ! -f "$source_path" ]]; then - source_path="${source_dir}/${binary}-${target}" - fi - if [[ ! -f "$source_path" ]]; then - echo "Signed macOS handoff is missing ${binary} for ${artifact_name}" - exit 1 - fi - - release_path="${dest}/${binary}-${target}" - ditto "$source_path" "$release_path" - chmod 0755 "$release_path" - codesign --verify --strict --verbose=2 "$release_path" - done - - # DMG staging is disabled for signed promotion because we no longer - # distribute DMGs from this release path. Keep the branch here so the - # handoff can opt back in by flipping matrix.build_dmg if needed. - if [[ "${{ matrix.build_dmg }}" == "true" ]]; then - dmg_name="codex-${target}.dmg" - dmg_source="${source_dir}/${dmg_name}" - if [[ ! -f "$dmg_source" ]]; then - echo "Signed macOS handoff is missing ${dmg_name} for ${artifact_name}" - exit 1 - fi - - codesign --verify --strict --verbose=2 "$dmg_source" - xcrun stapler validate "$dmg_source" - cp "$dmg_source" "$dest/$dmg_name" + marker="" + artifact_url="https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/artifacts/${ARTIFACT_ID}" + run_url="${GH_WORKFLOW_URL:-https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}}" + body_path="${RUNNER_TEMP}/termux-artifact-comment.md" + { + echo "${marker}" + echo "Termux Android artifact ready for testing:" + echo + echo "- Artifact: [${ARTIFACT_NAME}](${artifact_url})" + echo "- Workflow run: [${GITHUB_RUN_ID}](${run_url})" + echo + echo "You must be signed in to GitHub with repository access to download Actions artifacts." + } > "${body_path}" + + existing_comment_id="$( + gh pr view "${PR_NUMBER}" \ + --repo "${GITHUB_REPOSITORY}" \ + --json comments \ + --jq ".comments | map(select(.author.is_bot == true and (.body | contains(\"${marker}\")))) | .[-1].id // \"\"" + )" + if [[ -n "${existing_comment_id}" ]]; then + gh api graphql \ + -f query=' + mutation($id: ID!, $body: String!) { + updateIssueComment(input: {id: $id, body: $body}) { + issueComment { + id + } + } + } + ' \ + -f id="${existing_comment_id}" \ + -f body="$(cat "${body_path}")" \ + >/dev/null + else + gh pr comment "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" --body-file "${body_path}" fi - - name: Build Python runtime wheel - if: ${{ matrix.bundle == 'primary' }} - shell: bash - run: | - set -euo pipefail - - case "${{ matrix.target }}" in - aarch64-apple-darwin) - platform_tag="macosx_11_0_arm64" - ;; - x86_64-apple-darwin) - platform_tag="macosx_10_9_x86_64" - ;; - *) - echo "No Python runtime wheel platform tag for ${{ matrix.target }}" - exit 1 - ;; - esac - - python3 -m venv "${RUNNER_TEMP}/python-runtime-build-venv" - "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m pip install build - - stage_dir="${RUNNER_TEMP}/openai-codex-cli-bin-${{ matrix.target }}" - wheel_dir="${GITHUB_WORKSPACE}/python-runtime-dist/${{ matrix.target }}" - python3 \ - "${GITHUB_WORKSPACE}/sdk/python/scripts/update_sdk_artifacts.py" \ - stage-runtime \ - "$stage_dir" \ - "${GITHUB_WORKSPACE}/codex-rs/dist/${{ matrix.target }}/codex-${{ matrix.target }}" \ - --codex-version "${GITHUB_REF_NAME}" \ - --platform-tag "$platform_tag" - "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m build --wheel --outdir "$wheel_dir" "$stage_dir" - - - name: Build Codex package archive - shell: bash - env: - TARGET: ${{ matrix.target }} - BUNDLE: ${{ matrix.bundle }} - run: | - set -euo pipefail - bash "${GITHUB_WORKSPACE}/.github/scripts/build-codex-package-archive.sh" \ - --target "$TARGET" \ - --bundle "$BUNDLE" \ - --entrypoint-dir "dist/${TARGET}" \ - --archive-dir "dist/${TARGET}" \ - --target-suffixed-entrypoint - - - name: Upload Python runtime wheel - if: ${{ matrix.bundle == 'primary' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: python-runtime-wheel-${{ matrix.target }} - path: python-runtime-dist/${{ matrix.target }}/*.whl - if-no-files-found: error - - - name: Compress artifacts - shell: bash - run: | - set -euo pipefail - - dest="dist/${{ matrix.target }}" - for f in "$dest"/*; do - base="$(basename "$f")" - if [[ "$base" == *.tar.gz || "$base" == *.tar.zst || "$base" == *.zip || "$base" == *.dmg ]]; then - continue + gh label create binary-ready \ + --repo "${GITHUB_REPOSITORY}" \ + --color 0e8a16 \ + --description "Android binary is ready for testing" \ + --force + gh pr edit "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" --add-label binary-ready + + if [[ -n "${SLACK_WEBHOOK_URL:-}" ]]; then + slack_payload_path="${RUNNER_TEMP}/termux-artifact-slack.json" + pr_url="https://github.com/${GITHUB_REPOSITORY}/pull/${PR_NUMBER}" + jq -n \ + --arg artifact_name "${ARTIFACT_NAME}" \ + --arg artifact_url "${artifact_url}" \ + --arg pr_number "${PR_NUMBER}" \ + --arg pr_url "${pr_url}" \ + --arg run_id "${GITHUB_RUN_ID}" \ + --arg run_url "${run_url}" \ + '{ + text: ("Termux Android artifact ready for testing: " + $artifact_url), + blocks: [ + { + type: "section", + text: { + type: "mrkdwn", + text: ("*Termux Android artifact ready for testing*\n" + $artifact_url) + } + }, + { + type: "section", + fields: [ + { + type: "mrkdwn", + text: ("*Artifact:*\n<" + $artifact_url + "|" + $artifact_name + ">") + }, + { + type: "mrkdwn", + text: ("*Pull request:*\n<" + $pr_url + "|#" + $pr_number + ">") + }, + { + type: "mrkdwn", + text: ("*Workflow run:*\n<" + $run_url + "|" + $run_id + ">") + } + ] + }, + { + type: "context", + elements: [ + { + type: "mrkdwn", + text: "You must be signed in to GitHub with repository access to download Actions artifacts." + } + ] + } + ] + }' > "${slack_payload_path}" + + if ! curl -fsS \ + -X POST \ + -H "Content-type: application/json" \ + --data @"${slack_payload_path}" \ + "${SLACK_WEBHOOK_URL}"; then + echo "::warning::Failed to send Slack artifact notification" fi + fi - tar -C "$dest" -czf "$dest/${base}.tar.gz" "$base" - zstd -T0 -19 --rm "$dest/$base" - done - - - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: ${{ matrix.artifact_name }} - path: | - codex-rs/dist/${{ matrix.target }}/* - - build-windows: - if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} - needs: tag-check - uses: ./.github/workflows/rust-release-windows.yml - with: - release-lto: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'fat' }} - secrets: inherit - - argument-comment-lint-release-assets: - if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} - name: argument-comment-lint release assets + shell-tool-mcp: + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && github.repository_owner == 'openai' + name: shell-tool-mcp needs: tag-check - uses: ./.github/workflows/rust-release-argument-comment-lint.yml + permissions: + contents: read + id-token: write + uses: ./.github/workflows/shell-tool-mcp.yml with: + release-tag: ${{ github.ref_name }} publish: true - - zsh-release-assets: - if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} - name: zsh release assets - needs: tag-check - uses: ./.github/workflows/rust-release-zsh.yml + secrets: inherit release: + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') needs: - - tag-check - build - - stage-signed-macos - - build-windows - - argument-comment-lint-release-assets - - zsh-release-assets - if: >- - ${{ - always() && - needs.tag-check.result == 'success' && - ( - ( - github.event_name == 'workflow_dispatch' && - inputs.release_mode == 'promote_signed' && - needs.stage-signed-macos.result == 'success' && - needs.build.result == 'skipped' && - needs.build-windows.result == 'skipped' && - needs.argument-comment-lint-release-assets.result == 'skipped' && - needs.zsh-release-assets.result == 'skipped' - ) || - ( - (github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed') && - needs.build.result == 'success' && - needs.stage-signed-macos.result == 'skipped' && - needs.build-windows.result == 'success' && - needs.argument-comment-lint-release-assets.result == 'success' && - needs.zsh-release-assets.result == 'success' - ) - ) - }} + - shell-tool-mcp name: release runs-on: ubuntu-latest permissions: contents: write actions: read - env: - RELEASE_MODE: ${{ github.event_name == 'workflow_dispatch' && inputs.release_mode || 'signed' }} - SIGN_MACOS: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode == 'promote_signed' }} - SIGNED_MACOS_ASSET: ${{ inputs.signed_macos_asset }} - UNSIGNED_RUN_ID: ${{ inputs.unsigned_run_id }} outputs: version: ${{ steps.release_name.outputs.name }} tag: ${{ github.ref_name }} - sign_macos: ${{ steps.release_mode.outputs.sign_macos }} should_publish_npm: ${{ steps.npm_publish_settings.outputs.should_publish }} npm_tag: ${{ steps.npm_publish_settings.outputs.npm_tag }} - should_publish_python_runtime: ${{ steps.python_runtime_publish_settings.outputs.should_publish }} steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false + uses: actions/checkout@v6 - - name: Define release mode - id: release_mode - run: | - echo "release_mode=${RELEASE_MODE}" >> "$GITHUB_OUTPUT" - echo "sign_macos=${SIGN_MACOS}" >> "$GITHUB_OUTPUT" + - name: 🧰 Actions Toolbox + # This is required for the GitHub CLI + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: wallentx/gh-actions/composite/actions-toolbox@main - name: Generate release notes from tag commit message id: release_notes @@ -981,138 +1013,18 @@ jobs: echo "path=${notes_path}" >> "${GITHUB_OUTPUT}" - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + - uses: actions/download-artifact@v7 with: path: dist - - name: Validate unsigned build run - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - - run_summary="$(gh run view "$UNSIGNED_RUN_ID" \ - --repo "$GITHUB_REPOSITORY" \ - --json conclusion,event,headBranch,headSha,status,workflowName,url \ - --jq '[.workflowName, .event, .headBranch, .headSha, .status, .conclusion, .url] | @tsv')" - IFS=$'\t' read -r workflow_name event head_branch head_sha status conclusion run_url <<< "$run_summary" - expected_head_sha="$(git rev-parse "${GITHUB_SHA}^{commit}")" - - if [[ "$workflow_name" != "$GITHUB_WORKFLOW" ]]; then - echo "unsigned_run_id ${UNSIGNED_RUN_ID} is for workflow '${workflow_name}', expected '${GITHUB_WORKFLOW}'" - echo "Run URL: ${run_url}" - exit 1 - fi - - if [[ "$event" != "workflow_dispatch" ]]; then - echo "unsigned_run_id ${UNSIGNED_RUN_ID} was triggered by '${event}', expected 'workflow_dispatch'" - echo "Run URL: ${run_url}" - exit 1 - fi - - if [[ "$head_branch" != "$GITHUB_REF_NAME" ]]; then - echo "unsigned_run_id ${UNSIGNED_RUN_ID} used ref '${head_branch}', expected '${GITHUB_REF_NAME}'" - echo "Run URL: ${run_url}" - exit 1 - fi - - if [[ "$head_sha" != "$expected_head_sha" ]]; then - echo "unsigned_run_id ${UNSIGNED_RUN_ID} used head SHA '${head_sha}', expected '${expected_head_sha}'" - echo "Run URL: ${run_url}" - exit 1 - fi - - if [[ "$status" != "completed" || "$conclusion" != "success" ]]; then - echo "unsigned_run_id ${UNSIGNED_RUN_ID} is ${status}/${conclusion}, expected completed/success" - echo "Run URL: ${run_url}" - exit 1 - fi - - - name: Download artifacts from unsigned build run - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - gh run download "$UNSIGNED_RUN_ID" \ - --repo "$GITHUB_REPOSITORY" \ - --dir dist - - - name: Remove unsigned macOS staging artifacts - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - run: | - set -euo pipefail - find dist -mindepth 1 -maxdepth 1 -type d \ - -name '*-apple-darwin*-unsigned' \ - -exec rm -rf {} + - - - name: Re-upload promoted Linux x64 artifacts - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: x86_64-unknown-linux-musl - path: dist/x86_64-unknown-linux-musl/* - if-no-files-found: error - - - name: Re-upload promoted Linux arm64 artifacts - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: aarch64-unknown-linux-musl - path: dist/aarch64-unknown-linux-musl/* - if-no-files-found: error - - - name: Re-upload promoted Windows x64 artifacts - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: x86_64-pc-windows-msvc - path: dist/x86_64-pc-windows-msvc/* - if-no-files-found: error - - - name: Re-upload promoted Windows arm64 artifacts - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: aarch64-pc-windows-msvc - path: dist/aarch64-pc-windows-msvc/* - if-no-files-found: error - - name: List run: ls -R dist/ - - name: Prune artifacts excluded from unsigned macOS release - if: ${{ env.SIGN_MACOS == 'false' }} - run: | - find dist -mindepth 1 -maxdepth 1 -type d \ - ! -name '*-apple-darwin*-unsigned' \ - ! -name 'aarch64-unknown-linux-musl' \ - ! -name 'aarch64-unknown-linux-musl-app-server' \ - ! -name 'x86_64-unknown-linux-musl' \ - ! -name 'x86_64-unknown-linux-musl-app-server' \ - ! -name 'aarch64-pc-windows-msvc' \ - ! -name 'x86_64-pc-windows-msvc' \ - -exec rm -rf {} + - - if ! find dist -type f -name '*-apple-darwin*-unsigned*' | grep -q .; then - echo "No unsigned macOS artifacts found in downloaded workflow artifacts." - exit 1 - fi - + # This is a temporary fix: we should modify shell-tool-mcp.yml so these + # files do not end up in dist/ in the first place. - name: Delete entries from dist/ that should not go in the release run: | - rm -rf dist/windows-binaries* - # cargo-timing.html appears under multiple target-specific directories. - # If included in files: dist/**, release upload races on duplicate - # asset names and can fail with 404s. - find dist -type f -name 'cargo-timing.html' -delete - # Keep package-builder sidecar archives as workflow artifacts only - # until distribution channels are ready to consume them. - find dist -type f \ - \( -name 'codex-package-*' -o -name 'codex-app-server-package-*' \) \ - -delete - find dist -type d -empty -delete + rm -rf dist/shell-tool-mcp* ls -R dist/ @@ -1136,12 +1048,6 @@ jobs: set -euo pipefail version="${VERSION}" - if [[ "${SIGN_MACOS}" != "true" ]]; then - echo "should_publish=false" >> "$GITHUB_OUTPUT" - echo "npm_tag=" >> "$GITHUB_OUTPUT" - exit 0 - fi - if [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "should_publish=true" >> "$GITHUB_OUTPUT" echo "npm_tag=" >> "$GITHUB_OUTPUT" @@ -1153,132 +1059,55 @@ jobs: echo "npm_tag=" >> "$GITHUB_OUTPUT" fi - - name: Determine Python runtime publish settings - id: python_runtime_publish_settings - env: - VERSION: ${{ steps.release_name.outputs.name }} - run: | - set -euo pipefail - version="${VERSION}" - - if [[ "${SIGN_MACOS}" != "true" ]]; then - echo "should_publish=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - if [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "should_publish=true" >> "$GITHUB_OUTPUT" - elif [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+-alpha\.[0-9]+$ ]]; then - echo "should_publish=true" >> "$GITHUB_OUTPUT" - else - echo "should_publish=false" >> "$GITHUB_OUTPUT" - fi - - name: Setup pnpm - if: ${{ env.SIGN_MACOS == 'true' }} - uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5 + uses: pnpm/action-setup@v4 with: run_install: false - name: Setup Node.js for npm packaging - if: ${{ env.SIGN_MACOS == 'true' }} - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + uses: actions/setup-node@v6 with: node-version: 22 - name: Install dependencies - if: ${{ env.SIGN_MACOS == 'true' }} run: pnpm install --frozen-lockfile # stage_npm_packages.py requires DotSlash when staging releases. - - uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2 + - uses: facebook/install-dotslash@v2 - name: Stage npm packages - if: ${{ env.SIGN_MACOS == 'true' }} + if: github.repository_owner == 'openai' env: GH_TOKEN: ${{ github.token }} - RELEASE_VERSION: ${{ steps.release_name.outputs.name }} run: | - workflow_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" ./scripts/stage_npm_packages.py \ - --release-version "$RELEASE_VERSION" \ - --workflow-url "$workflow_url" \ + --release-version "${{ steps.release_name.outputs.name }}" \ --package codex \ --package codex-responses-api-proxy \ --package codex-sdk - - name: Stage installer scripts - if: ${{ env.SIGN_MACOS == 'true' }} - run: | - cp scripts/install/install.sh dist/install.sh - cp scripts/install/install.ps1 dist/install.ps1 - - name: Create GitHub Release - uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1 + uses: softprops/action-gh-release@v2 with: name: ${{ steps.release_name.outputs.name }} tag_name: ${{ github.ref_name }} body_path: ${{ steps.release_notes.outputs.path }} files: dist/** - overwrite_files: true - make_latest: ${{ env.SIGN_MACOS == 'true' && !contains(steps.release_name.outputs.name, '-') }} # Mark as prerelease only when the version has a suffix after x.y.z # (e.g. -alpha, -beta). Otherwise publish a normal release. prerelease: ${{ contains(steps.release_name.outputs.name, '-') }} - - name: Clean up signed promotion handoff assets - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - - release_id="$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${GITHUB_REF_NAME}" --jq '.id')" - gh api --paginate "repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets" \ - --jq '.[] | [.id, .name] | @tsv' | - while IFS=$'\t' read -r asset_id asset_name; do - if [[ -z "$asset_id" || -z "$asset_name" ]]; then - continue - fi - - delete_asset=false - if [[ "$asset_name" == *unsigned* || "$asset_name" == "$SIGNED_MACOS_ASSET" ]]; then - delete_asset=true - fi - - if [[ "$delete_asset" == "true" ]]; then - echo "Deleting release asset ${asset_name}" - gh api -X DELETE "repos/${GITHUB_REPOSITORY}/releases/assets/${asset_id}" - fi - done - - - if: ${{ env.SIGN_MACOS == 'true' }} - uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 + - if: github.repository_owner == 'openai' + uses: facebook/dotslash-publish-release@v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: tag: ${{ github.ref_name }} config: .github/dotslash-config.json - - if: ${{ env.SIGN_MACOS == 'true' }} - uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag: ${{ github.ref_name }} - config: .github/dotslash-zsh-config.json - - - if: ${{ env.SIGN_MACOS == 'true' }} - uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag: ${{ github.ref_name }} - config: .github/dotslash-argument-comment-lint-config.json - - name: Trigger developers.openai.com deploy # Only trigger the deploy if the release is not a pre-release. # The deploy is used to update the developers.openai.com website with the new config schema json file. - if: ${{ env.SIGN_MACOS == 'true' && !contains(steps.release_name.outputs.name, '-') }} + if: ${{ !contains(steps.release_name.outputs.name, '-') && github.repository_owner == 'openai' }} continue-on-error: true env: DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL: ${{ secrets.DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL }} @@ -1293,15 +1122,7 @@ jobs: # npm docs: https://docs.npmjs.com/trusted-publishers publish-npm: # Publish to npm for stable releases and alpha pre-releases with numeric suffixes. - # promote_signed intentionally skips build jobs that are ancestors of release; - # include the !cancelled() status function so Actions does not apply its implicit - # success() check to the whole dependency chain before evaluating release outputs. - if: >- - ${{ - !cancelled() && - needs.release.result == 'success' && - needs.release.outputs.should_publish_npm == 'true' - }} + if: ${{ needs.release.outputs.should_publish_npm == 'true' && github.repository_owner == 'openai' }} name: publish-npm needs: release runs-on: ubuntu-latest @@ -1311,37 +1132,36 @@ jobs: steps: - name: Setup Node.js - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + uses: actions/setup-node@v6 with: - # Node 24 bundles npm >= 11.5.1, which trusted publishing requires. - node-version: 24 + node-version: 22 registry-url: "https://registry.npmjs.org" scope: "@openai" + # Trusted publishing requires npm CLI version 11.5.1 or later. + - name: Update npm + run: npm install -g npm@latest + - name: Download npm tarballs from release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_TAG: ${{ needs.release.outputs.tag }} - RELEASE_VERSION: ${{ needs.release.outputs.version }} run: | set -euo pipefail - version="$RELEASE_VERSION" - tag="$RELEASE_TAG" + version="${{ needs.release.outputs.version }}" + tag="${{ needs.release.outputs.tag }}" mkdir -p dist/npm - patterns=( - "codex-npm-${version}.tgz" - "codex-npm-linux-*-${version}.tgz" - "codex-npm-darwin-*-${version}.tgz" - "codex-npm-win32-*-${version}.tgz" - "codex-responses-api-proxy-npm-${version}.tgz" - "codex-sdk-npm-${version}.tgz" - ) - for pattern in "${patterns[@]}"; do - gh release download "$tag" \ - --repo "${GITHUB_REPOSITORY}" \ - --pattern "$pattern" \ - --dir dist/npm - done + gh release download "$tag" \ + --repo "${GITHUB_REPOSITORY}" \ + --pattern "codex-npm-${version}.tgz" \ + --dir dist/npm + gh release download "$tag" \ + --repo "${GITHUB_REPOSITORY}" \ + --pattern "codex-responses-api-proxy-npm-${version}.tgz" \ + --dir dist/npm + gh release download "$tag" \ + --repo "${GITHUB_REPOSITORY}" \ + --pattern "codex-sdk-npm-${version}.tgz" \ + --dir dist/npm # No NODE_AUTH_TOKEN needed because we use OIDC. - name: Publish to npm @@ -1350,193 +1170,23 @@ jobs: NPM_TAG: ${{ needs.release.outputs.npm_tag }} run: | set -euo pipefail - prefix="" + tag_args=() if [[ -n "${NPM_TAG}" ]]; then - prefix="${NPM_TAG}-" + tag_args+=(--tag "${NPM_TAG}") fi - root_tarball="dist/npm/codex-npm-${VERSION}.tgz" - sdk_tarball="dist/npm/codex-sdk-npm-${VERSION}.tgz" - # Keep this list in sync with CODEX_PLATFORM_PACKAGES in - # codex-cli/scripts/build_npm_package.py. The root wrapper advances - # @openai/codex@latest as soon as it publishes, so every platform - # package it aliases must already exist in the registry first. - platform_tarballs=( - "dist/npm/codex-npm-linux-x64-${VERSION}.tgz" - "dist/npm/codex-npm-linux-arm64-${VERSION}.tgz" - "dist/npm/codex-npm-darwin-x64-${VERSION}.tgz" - "dist/npm/codex-npm-darwin-arm64-${VERSION}.tgz" - "dist/npm/codex-npm-win32-x64-${VERSION}.tgz" - "dist/npm/codex-npm-win32-arm64-${VERSION}.tgz" - ) - - for required_tarball in "${platform_tarballs[@]}" "${root_tarball}"; do - if [[ ! -f "${required_tarball}" ]]; then - echo "Missing npm tarball: ${required_tarball}" - exit 1 - fi - done - - shopt -s nullglob - other_tarballs=() - for tarball in dist/npm/*-"${VERSION}".tgz; do - if [[ "${tarball}" == "${root_tarball}" || "${tarball}" == "${sdk_tarball}" ]]; then - continue - fi - - is_platform_tarball=false - for platform_tarball in "${platform_tarballs[@]}"; do - if [[ "${tarball}" == "${platform_tarball}" ]]; then - is_platform_tarball=true - break - fi - done - if [[ "${is_platform_tarball}" == true ]]; then - continue - fi - - other_tarballs+=("${tarball}") - done - - # Publish the platform packages before the root CLI wrapper. The root - # wrapper advances @openai/codex@latest, so it should only publish - # after the optional dependency versions it references exist. tarballs=( - "${platform_tarballs[@]}" - "${other_tarballs[@]}" - "${root_tarball}" + "codex-npm-${VERSION}.tgz" + "codex-responses-api-proxy-npm-${VERSION}.tgz" + "codex-sdk-npm-${VERSION}.tgz" ) - if [[ -f "${sdk_tarball}" ]]; then - tarballs+=("${sdk_tarball}") - fi for tarball in "${tarballs[@]}"; do - filename="$(basename "${tarball}")" - tag="" - - case "${filename}" in - codex-npm-linux-*-"${VERSION}".tgz|codex-npm-darwin-*-"${VERSION}".tgz|codex-npm-win32-*-"${VERSION}".tgz) - platform="${filename#codex-npm-}" - platform="${platform%-${VERSION}.tgz}" - tag="${prefix}${platform}" - ;; - codex-npm-"${VERSION}".tgz|codex-responses-api-proxy-npm-"${VERSION}".tgz|codex-sdk-npm-"${VERSION}".tgz) - tag="${NPM_TAG}" - ;; - *) - echo "Unexpected npm tarball: ${filename}" - exit 1 - ;; - esac - - publish_cmd=(npm publish "${GITHUB_WORKSPACE}/${tarball}") - if [[ -n "${tag}" ]]; then - publish_cmd+=(--tag "${tag}") - fi - - echo "+ ${publish_cmd[*]}" - set +e - publish_output="$("${publish_cmd[@]}" 2>&1)" - publish_status=$? - set -e - - echo "${publish_output}" - if [[ ${publish_status} -eq 0 ]]; then - continue - fi - - if grep -qiE "previously published|cannot publish over|version already exists" <<< "${publish_output}"; then - echo "Skipping already-published package version for ${filename}" - continue - fi - - exit "${publish_status}" + npm publish "${GITHUB_WORKSPACE}/dist/npm/${tarball}" "${tag_args[@]}" done - # Publish the platform-specific Python runtime wheels using PyPI trusted publishing. - # PyPI project configuration must trust this workflow and job. Keep this - # non-blocking while the Python runtime publishing path is new; failures still - # need release follow-up, but should not invalidate the Rust release itself. - publish-python-runtime: - # Publish to PyPI for stable releases and alpha pre-releases with numeric suffixes. - if: >- - ${{ - !cancelled() && - needs.release.result == 'success' && - needs.release.outputs.should_publish_python_runtime == 'true' - }} - name: publish-python-runtime - needs: release - runs-on: ubuntu-latest - continue-on-error: true - environment: pypi - permissions: - id-token: write # Required for PyPI trusted publishing. - contents: read - - steps: - - name: Download Python runtime wheels from release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_TAG: ${{ needs.release.outputs.tag }} - RELEASE_VERSION: ${{ needs.release.outputs.version }} - run: | - set -euo pipefail - python_version="$RELEASE_VERSION" - python_version="${python_version/-alpha./a}" - python_version="${python_version/-beta./b}" - python_version="${python_version/-rc./rc}" - - mkdir -p dist/python-runtime - gh release download "$RELEASE_TAG" \ - --repo "${GITHUB_REPOSITORY}" \ - --pattern "openai_codex_cli_bin-${python_version}-*.whl" \ - --dir dist/python-runtime - ls -lh dist/python-runtime - - - name: Publish Python runtime wheels to PyPI - uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 - with: - packages-dir: dist/python-runtime - skip-existing: true - - winget: - name: winget - needs: release - # Only publish stable/mainline releases to WinGet; pre-releases include a - # '-' in the semver string (e.g., 1.2.3-alpha.1). - if: >- - ${{ - !cancelled() && - needs.release.result == 'success' && - needs.release.outputs.sign_macos == 'true' && - !contains(needs.release.outputs.version, '-') - }} - # This job only invokes a GitHub Action to open/update the winget-pkgs PR; - # it does not execute Windows-only tooling, so Linux is sufficient. - runs-on: ubuntu-latest - permissions: - contents: read - - steps: - - name: Publish to WinGet - uses: vedantmgoyal9/winget-releaser@7bd472be23763def6e16bd06cc8b1cdfab0e2fd5 - with: - identifier: OpenAI.Codex - version: ${{ needs.release.outputs.version }} - release-tag: ${{ needs.release.outputs.tag }} - fork-user: openai-oss-forks - installers-regex: '^codex-(?:x86_64|aarch64)-pc-windows-msvc\.exe\.zip$' - token: ${{ secrets.WINGET_PUBLISH_PAT }} - update-branch: name: Update latest-alpha-cli branch - if: >- - ${{ - !cancelled() && - needs.release.result == 'success' && - needs.release.outputs.sign_macos == 'true' - }} permissions: contents: write needs: release diff --git a/.github/workflows/shell-tool-mcp.yml b/.github/workflows/shell-tool-mcp.yml new file mode 100644 index 000000000000..66a76aa4d938 --- /dev/null +++ b/.github/workflows/shell-tool-mcp.yml @@ -0,0 +1,461 @@ +name: shell-tool-mcp + +on: + workflow_call: + inputs: + release-version: + description: Version to publish (x.y.z or x.y.z-alpha.N). Defaults to GITHUB_REF_NAME when it starts with rust-v. + required: false + type: string + release-tag: + description: Tag name to use when downloading release artifacts (defaults to rust-v). + required: false + type: string + publish: + description: Whether to publish to npm when the version is releasable. + required: false + default: true + type: boolean + +env: + NODE_VERSION: 22 + +jobs: + metadata: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.compute.outputs.version }} + release_tag: ${{ steps.compute.outputs.release_tag }} + should_publish: ${{ steps.compute.outputs.should_publish }} + npm_tag: ${{ steps.compute.outputs.npm_tag }} + steps: + - name: Compute version and tags + id: compute + run: | + set -euo pipefail + + version="${{ inputs.release-version }}" + release_tag="${{ inputs.release-tag }}" + + if [[ -z "$version" ]]; then + if [[ -n "$release_tag" && "$release_tag" =~ ^rust-v.+ ]]; then + version="${release_tag#rust-v}" + elif [[ "${GITHUB_REF_NAME:-}" =~ ^rust-v.+ ]]; then + version="${GITHUB_REF_NAME#rust-v}" + release_tag="${GITHUB_REF_NAME}" + else + echo "release-version is required when GITHUB_REF_NAME is not a rust-v tag." + exit 1 + fi + fi + + if [[ -z "$release_tag" ]]; then + release_tag="rust-v${version}" + fi + + npm_tag="" + should_publish="false" + if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + should_publish="true" + elif [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+-alpha\.[0-9]+$ ]]; then + should_publish="true" + npm_tag="alpha" + fi + + echo "version=${version}" >> "$GITHUB_OUTPUT" + echo "release_tag=${release_tag}" >> "$GITHUB_OUTPUT" + echo "npm_tag=${npm_tag}" >> "$GITHUB_OUTPUT" + echo "should_publish=${should_publish}" >> "$GITHUB_OUTPUT" + + matrix-setup: + runs-on: ubuntu-latest + outputs: + rust-binaries: ${{ steps.compute.outputs.rust-binaries }} + bash-linux: ${{ steps.compute.outputs.bash-linux }} + bash-darwin: ${{ steps.compute.outputs.bash-darwin }} + steps: + - name: Compute matrices + id: compute + shell: bash + run: | + set -euo pipefail + is_openai="${{ github.repository_owner == 'openai' }}" + + # rust-binaries: always include x86_64-musl; conditionally include paid/fork runners + if [[ "$is_openai" == "true" ]]; then + rust='[ + {"runner":"macos-15-xlarge","target":"aarch64-apple-darwin"}, + {"runner":"macos-15-xlarge","target":"x86_64-apple-darwin"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","install_musl":true} + ]' + else + rust='[{"runner":"macos-latest","target":"aarch64-apple-darwin"}]' + fi + rust=$(echo "$rust" | jq -c '. + [{"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","install_musl":true}]') + echo "rust-binaries={\"include\":$rust}" >> "$GITHUB_OUTPUT" + + # bash-linux: always include x86_64 variants; add arm64 on openai + bash_linux='[ + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","variant":"ubuntu-24.04","image":"ubuntu:24.04"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","variant":"ubuntu-22.04","image":"ubuntu:22.04"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","variant":"debian-12","image":"debian:12"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","variant":"debian-11","image":"debian:11"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","variant":"centos-9","image":"quay.io/centos/centos:stream9"} + ]' + if [[ "$is_openai" == "true" ]]; then + bash_linux=$(echo "$bash_linux" | jq -c '. + [ + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"ubuntu-24.04","image":"arm64v8/ubuntu:24.04"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"ubuntu-22.04","image":"arm64v8/ubuntu:22.04"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"ubuntu-20.04","image":"arm64v8/ubuntu:20.04"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"debian-12","image":"arm64v8/debian:12"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"debian-11","image":"arm64v8/debian:11"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"centos-9","image":"quay.io/centos/centos:stream9"} + ]') + fi + echo "bash-linux={\"include\":$(echo "$bash_linux" | jq -c)}" >> "$GITHUB_OUTPUT" + + # bash-darwin: always include macos-14; add macos-15-xlarge on openai + bash_darwin='[{"runner":"macos-14","target":"aarch64-apple-darwin","variant":"macos-14"}]' + if [[ "$is_openai" == "true" ]]; then + bash_darwin=$(echo "$bash_darwin" | jq -c '. + [ + {"runner":"macos-15-xlarge","target":"aarch64-apple-darwin","variant":"macos-15"} + ]') + fi + echo "bash-darwin={\"include\":$(echo "$bash_darwin" | jq -c)}" >> "$GITHUB_OUTPUT" + + rust-binaries: + name: Build Rust - ${{ matrix.target }} + needs: [metadata, matrix-setup] + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + defaults: + run: + working-directory: codex-rs + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.matrix-setup.outputs.rust-binaries) }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Install UBSan runtime (musl) + if: ${{ matrix.install_musl }} + shell: bash + run: | + set -euo pipefail + if command -v apt-get >/dev/null 2>&1; then + sudo apt-get update -y + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y libubsan1 + fi + + - uses: dtolnay/rust-toolchain@1.93 + with: + targets: ${{ matrix.target }} + + - if: ${{ matrix.install_musl }} + name: Install Zig + uses: mlugg/setup-zig@v2 + with: + version: 0.14.0 + + - if: ${{ matrix.install_musl }} + name: Install musl build dependencies + env: + TARGET: ${{ matrix.target }} + run: bash "${GITHUB_WORKSPACE}/.github/scripts/install-musl-build-tools.sh" + + - if: ${{ matrix.install_musl }} + name: Configure rustc UBSan wrapper (musl host) + shell: bash + run: | + set -euo pipefail + ubsan="" + if command -v ldconfig >/dev/null 2>&1; then + ubsan="$(ldconfig -p | grep -m1 'libubsan\.so\.1' | sed -E 's/.*=> (.*)$/\1/')" + fi + wrapper_root="${RUNNER_TEMP:-/tmp}" + wrapper="${wrapper_root}/rustc-ubsan-wrapper" + cat > "${wrapper}" <> "$GITHUB_ENV" + echo "RUSTC_WORKSPACE_WRAPPER=" >> "$GITHUB_ENV" + + - if: ${{ matrix.install_musl }} + name: Clear sanitizer flags (musl) + shell: bash + run: | + set -euo pipefail + # Clear global Rust flags so host/proc-macro builds don't pull in UBSan. + echo "RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_ENCODED_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "RUSTDOCFLAGS=" >> "$GITHUB_ENV" + # Override any runner-level Cargo config rustflags as well. + echo "CARGO_BUILD_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_RUSTFLAGS=" >> "$GITHUB_ENV" + + sanitize_flags() { + local input="$1" + input="${input//-fsanitize=undefined/}" + input="${input//-fno-sanitize-recover=undefined/}" + input="${input//-fno-sanitize-trap=undefined/}" + echo "$input" + } + + cflags="$(sanitize_flags "${CFLAGS-}")" + cxxflags="$(sanitize_flags "${CXXFLAGS-}")" + echo "CFLAGS=${cflags}" >> "$GITHUB_ENV" + echo "CXXFLAGS=${cxxflags}" >> "$GITHUB_ENV" + + - name: Build exec server binaries + run: cargo build --release --target ${{ matrix.target }} --bin codex-exec-mcp-server --bin codex-execve-wrapper + + - name: Stage exec server binaries + run: | + dest="${GITHUB_WORKSPACE}/artifacts/vendor/${{ matrix.target }}" + mkdir -p "$dest" + cp "target/${{ matrix.target }}/release/codex-exec-mcp-server" "$dest/" + cp "target/${{ matrix.target }}/release/codex-execve-wrapper" "$dest/" + + - uses: actions/upload-artifact@v6 + with: + name: shell-tool-mcp-rust-${{ matrix.target }} + path: artifacts/** + if-no-files-found: error + + bash-linux: + name: Build Bash (Linux) - ${{ matrix.variant }} - ${{ matrix.target }} + needs: [metadata, matrix-setup] + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + container: + image: ${{ matrix.image }} + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.matrix-setup.outputs.bash-linux) }} + steps: + - name: Install build prerequisites + shell: bash + run: | + set -euo pipefail + if command -v apt-get >/dev/null 2>&1; then + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y git build-essential bison autoconf gettext + elif command -v dnf >/dev/null 2>&1; then + dnf install -y git gcc gcc-c++ make bison autoconf gettext + elif command -v yum >/dev/null 2>&1; then + yum install -y git gcc gcc-c++ make bison autoconf gettext + else + echo "Unsupported package manager in container" + exit 1 + fi + + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Build patched Bash + shell: bash + run: | + set -euo pipefail + git clone --depth 1 https://github.com/bolinfest/bash /tmp/bash + cd /tmp/bash + git fetch --depth 1 origin a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b + git checkout a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b + git apply "${GITHUB_WORKSPACE}/shell-tool-mcp/patches/bash-exec-wrapper.patch" + ./configure --without-bash-malloc + cores="$(command -v nproc >/dev/null 2>&1 && nproc || getconf _NPROCESSORS_ONLN)" + make -j"${cores}" + + dest="${GITHUB_WORKSPACE}/artifacts/vendor/${{ matrix.target }}/bash/${{ matrix.variant }}" + mkdir -p "$dest" + cp bash "$dest/bash" + + - uses: actions/upload-artifact@v6 + with: + name: shell-tool-mcp-bash-${{ matrix.target }}-${{ matrix.variant }} + path: artifacts/** + if-no-files-found: error + + bash-darwin: + name: Build Bash (macOS) - ${{ matrix.variant }} - ${{ matrix.target }} + needs: [metadata, matrix-setup] + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.matrix-setup.outputs.bash-darwin) }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Build patched Bash + shell: bash + run: | + set -euo pipefail + git clone --depth 1 https://github.com/bolinfest/bash /tmp/bash + cd /tmp/bash + git fetch --depth 1 origin a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b + git checkout a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b + git apply "${GITHUB_WORKSPACE}/shell-tool-mcp/patches/bash-exec-wrapper.patch" + ./configure --without-bash-malloc + cores="$(getconf _NPROCESSORS_ONLN)" + make -j"${cores}" + + dest="${GITHUB_WORKSPACE}/artifacts/vendor/${{ matrix.target }}/bash/${{ matrix.variant }}" + mkdir -p "$dest" + cp bash "$dest/bash" + + - uses: actions/upload-artifact@v6 + with: + name: shell-tool-mcp-bash-${{ matrix.target }}-${{ matrix.variant }} + path: artifacts/** + if-no-files-found: error + + package: + name: Package npm module + needs: + - metadata + - rust-binaries + - bash-linux + - bash-darwin + runs-on: ubuntu-latest + env: + PACKAGE_VERSION: ${{ needs.metadata.outputs.version }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Install JavaScript dependencies + run: pnpm install --frozen-lockfile + + - name: Build (shell-tool-mcp) + run: pnpm --filter @openai/codex-shell-tool-mcp run build + + - name: Download build artifacts + uses: actions/download-artifact@v7 + with: + path: artifacts + + - name: Assemble staging directory + id: staging + shell: bash + run: | + set -euo pipefail + staging="${STAGING_DIR}" + mkdir -p "$staging" "$staging/vendor" + cp shell-tool-mcp/README.md "$staging/" + cp shell-tool-mcp/package.json "$staging/" + cp -R shell-tool-mcp/bin "$staging/" + + found_vendor="false" + shopt -s nullglob + for vendor_dir in artifacts/*/vendor; do + rsync -av "$vendor_dir/" "$staging/vendor/" + found_vendor="true" + done + if [[ "$found_vendor" == "false" ]]; then + echo "No vendor payloads were downloaded." + exit 1 + fi + + node - <<'NODE' + import fs from "node:fs"; + import path from "node:path"; + + const stagingDir = process.env.STAGING_DIR; + const version = process.env.PACKAGE_VERSION; + const pkgPath = path.join(stagingDir, "package.json"); + const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")); + pkg.version = version; + fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n"); + NODE + + echo "dir=$staging" >> "$GITHUB_OUTPUT" + env: + STAGING_DIR: ${{ runner.temp }}/shell-tool-mcp + + - name: Ensure binaries are executable + run: | + set -euo pipefail + staging="${{ steps.staging.outputs.dir }}" + chmod +x \ + "$staging"/vendor/*/codex-exec-mcp-server \ + "$staging"/vendor/*/codex-execve-wrapper \ + "$staging"/vendor/*/bash/*/bash + + - name: Create npm tarball + shell: bash + run: | + set -euo pipefail + mkdir -p dist/npm + staging="${{ steps.staging.outputs.dir }}" + pack_info=$(cd "$staging" && npm pack --ignore-scripts --json --pack-destination "${GITHUB_WORKSPACE}/dist/npm") + filename=$(PACK_INFO="$pack_info" node -e 'const data = JSON.parse(process.env.PACK_INFO); console.log(data[0].filename);') + mv "dist/npm/${filename}" "dist/npm/codex-shell-tool-mcp-npm-${PACKAGE_VERSION}.tgz" + + - uses: actions/upload-artifact@v6 + with: + name: codex-shell-tool-mcp-npm + path: dist/npm/codex-shell-tool-mcp-npm-${{ env.PACKAGE_VERSION }}.tgz + if-no-files-found: error + + publish: + name: Publish npm package + needs: + - metadata + - package + if: ${{ inputs.publish && needs.metadata.outputs.should_publish == 'true' }} + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + steps: + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + registry-url: https://registry.npmjs.org + scope: "@openai" + + # Trusted publishing requires npm CLI version 11.5.1 or later. + - name: Update npm + run: npm install -g npm@latest + + - name: Download npm tarball + uses: actions/download-artifact@v7 + with: + name: codex-shell-tool-mcp-npm + path: dist/npm + + - name: Publish to npm + if: github.repository == 'openai/codex' + env: + NPM_TAG: ${{ needs.metadata.outputs.npm_tag }} + VERSION: ${{ needs.metadata.outputs.version }} + shell: bash + run: | + set -euo pipefail + tag_args=() + if [[ -n "${NPM_TAG}" ]]; then + tag_args+=(--tag "${NPM_TAG}") + fi + npm publish "dist/npm/codex-shell-tool-mcp-npm-${VERSION}.tgz" "${tag_args[@]}" diff --git a/.github/workflows/termux-release-checkpoint.yml b/.github/workflows/termux-release-checkpoint.yml new file mode 100644 index 000000000000..0347e6f1b3d6 --- /dev/null +++ b/.github/workflows/termux-release-checkpoint.yml @@ -0,0 +1,103 @@ +name: termux-release-checkpoint + +on: + workflow_dispatch: + inputs: + source_branch: + description: "Release branch to checkpoint from, for example release/0.123.0" + required: false + type: string + default: "" + source_sha: + description: "Specific source commit SHA to checkpoint; defaults to the source branch tip" + required: false + type: string + default: "" + destination_branch: + description: "Destination patch branch to receive the checkpoint PR" + required: false + type: string + default: "wallentx/termux-target" + reviewer: + description: "GitHub username to request as reviewer" + required: false + type: string + default: "wallentx" + +permissions: + actions: read + attestations: read + checks: read + contents: read + deployments: read + issues: read + discussions: read + packages: read + pages: read + pull-requests: read + repository-projects: read + statuses: read + +concurrency: + group: termux-release-checkpoint-${{ inputs.source_branch || github.ref_name }} + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + checkpoint: + runs-on: ubuntu-slim + permissions: + contents: write + issues: write + pull-requests: write + env: + DESTINATION_BRANCH: ${{ inputs.destination_branch || 'wallentx/termux-target' }} + REVIEWER: ${{ inputs.reviewer || 'wallentx' }} + REQUESTED_SOURCE_BRANCH: ${{ inputs.source_branch }} + REQUESTED_SOURCE_SHA: ${{ inputs.source_sha }} + TERMUX_AUTOMATION_DIR: ${{ github.workspace }}/.termux-release-automation + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Export GitHub App token for gh + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + run: echo "GH_TOKEN=${APP_TOKEN}" >> "${GITHUB_ENV}" + + - name: Checkout release branch + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ inputs.source_branch || github.ref_name }} + token: ${{ steps.app-token.outputs.token }} + + - name: Checkout automation helpers + uses: actions/checkout@v6 + with: + fetch-depth: 1 + ref: ${{ github.workflow_sha }} + path: .termux-release-automation + token: ${{ steps.app-token.outputs.token }} + + - name: 🧰 Actions Toolbox + uses: wallentx/gh-actions/composite/actions-toolbox@main + + - name: Validate GitHub CLI environment + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-validate-gh-env.sh" + + - name: Configure git + run: | + set -euo pipefail + source_branch="${REQUESTED_SOURCE_BRANCH:-${GITHUB_REF_NAME}}" + bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-configure-git.sh" --origin "${DESTINATION_BRANCH}" "${source_branch}" + + - name: Create checkpoint PR + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-create-checkpoint-pr.sh" diff --git a/.github/workflows/termux-release-deploy.yml b/.github/workflows/termux-release-deploy.yml new file mode 100644 index 000000000000..2071611dacc4 --- /dev/null +++ b/.github/workflows/termux-release-deploy.yml @@ -0,0 +1,243 @@ +name: termux-release-deploy + +on: + push: + branches: + - "release/**" + workflow_dispatch: + inputs: + release_branch: + description: "Release branch to deploy, for example release/0.124.0" + required: true + type: string + release_sha: + description: "Release branch commit SHA to deploy. Defaults to the branch head." + required: false + default: "" + type: string + pr_number: + description: "Merged release PR number to promote. Optional; normally discovered automatically." + required: false + default: "" + type: string + pr_head_sha: + description: "Merged release PR head SHA. Required only when pr_number is set." + required: false + default: "" + type: string + destination_branch: + description: "Destination patch branch to receive the checkpoint PR" + required: false + default: "wallentx/termux-target" + type: string + reviewer: + description: "GitHub username to request as reviewer on the checkpoint PR" + required: false + default: "wallentx" + type: string + +permissions: + actions: read + attestations: read + checks: read + contents: read + deployments: read + issues: read + discussions: read + packages: read + pages: read + pull-requests: read + repository-projects: read + statuses: read + +concurrency: + group: termux-release-deploy-${{ github.event_name == 'workflow_dispatch' && inputs.release_branch || github.ref_name }} + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + deploy: + runs-on: ubuntu-24.04 + if: ${{ github.event_name == 'workflow_dispatch' || !startsWith(github.event.head_commit.message, 'Seed Termux release automation') }} + permissions: + actions: read + contents: write + deployments: write + issues: write + pull-requests: write + env: + REQUESTED_RELEASE_BRANCH: ${{ inputs.release_branch }} + REQUESTED_RELEASE_SHA: ${{ inputs.release_sha }} + INPUT_PR_NUMBER: ${{ inputs.pr_number }} + INPUT_PR_HEAD_SHA: ${{ inputs.pr_head_sha }} + DESTINATION_BRANCH: ${{ inputs.destination_branch || 'wallentx/termux-target' }} + REVIEWER: ${{ inputs.reviewer || 'wallentx' }} + TERMUX_AUTOMATION_DIR: ${{ github.workspace }}/.termux-release-automation + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Export GitHub App token for gh + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + run: echo "GH_TOKEN=${APP_TOKEN}" >> "${GITHUB_ENV}" + + - name: Checkout release branch + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.release_branch || github.ref }} + token: ${{ steps.app-token.outputs.token }} + + - name: Checkout automation helpers + uses: actions/checkout@v6 + with: + fetch-depth: 1 + ref: ${{ github.workflow_sha }} + path: .termux-release-automation + token: ${{ steps.app-token.outputs.token }} + + - name: 🧰 Actions Toolbox + uses: wallentx/gh-actions/composite/actions-toolbox@main + + - name: Validate GitHub CLI environment + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-validate-gh-env.sh" + + - name: Configure git + run: | + set -euo pipefail + release_branch="${REQUESTED_RELEASE_BRANCH:-${GITHUB_REF_NAME}}" + bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-configure-git.sh" --origin "${DESTINATION_BRANCH}" "${release_branch}" + + - name: Resolve release ref + id: release-ref + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-resolve-release-ref.sh" + + - name: Read release metadata + id: metadata + env: + TERMUX_RELEASE_ACTION: deploy + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-read-release-metadata.sh" + + - name: Create deployment + if: steps.metadata.outputs.deploy == 'true' + id: deployment + env: + GH_TOKEN: ${{ github.token }} + RELEASE_SHA: ${{ steps.release-ref.outputs.sha }} + TERMUX_TAG: ${{ steps.metadata.outputs.termux_tag }} + run: | + set -euo pipefail + deployment_id="$( + gh api \ + -X POST \ + "repos/${GITHUB_REPOSITORY}/deployments" \ + -f ref="${RELEASE_SHA}" \ + -f environment="termux-release" \ + -F auto_merge=false \ + -F required_contexts[] \ + -f description="Termux release deployment for ${TERMUX_TAG}" \ + --jq '.id' + )" + echo "id=${deployment_id}" >> "$GITHUB_OUTPUT" + + - name: Mark deployment in progress + if: steps.metadata.outputs.deploy == 'true' + env: + GH_TOKEN: ${{ github.token }} + DEPLOYMENT_ID: ${{ steps.deployment.outputs.id }} + run: | + set -euo pipefail + log_url="${GH_WORKFLOW_URL:-${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}}" + gh api \ + -X POST \ + "repos/${GITHUB_REPOSITORY}/deployments/${DEPLOYMENT_ID}/statuses" \ + -f state="in_progress" \ + -f environment="termux-release" \ + -f log_url="${log_url}" \ + -F auto_inactive=false \ + -f description="Promoting Termux release artifact and preparing checkpoint PR" \ + >/dev/null + + - name: Locate merged pull request + if: steps.metadata.outputs.deploy == 'true' && steps.metadata.outputs.asset_exists != 'true' + id: pr + env: + RELEASE_BRANCH: ${{ steps.release-ref.outputs.branch }} + RELEASE_SHA: ${{ steps.release-ref.outputs.sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-find-release-pr.sh" + + - name: Download promoted PR artifact + if: steps.metadata.outputs.deploy == 'true' && steps.metadata.outputs.asset_exists != 'true' + env: + PR_ARTIFACT_NAME: ${{ steps.pr.outputs.artifact_name }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-download-release-artifact.sh" + + - name: Create or update mirrored Termux release + if: steps.metadata.outputs.deploy == 'true' && steps.metadata.outputs.asset_exists != 'true' + env: + UPSTREAM_TAG: ${{ steps.metadata.outputs.upstream_tag }} + UPSTREAM_REPO: ${{ steps.metadata.outputs.upstream_repo }} + UPSTREAM_NAME: ${{ steps.metadata.outputs.upstream_name }} + TERMUX_TAG: ${{ steps.metadata.outputs.termux_tag }} + UPSTREAM_PRERELEASE: ${{ steps.metadata.outputs.upstream_prerelease }} + UPSTREAM_HTML_URL: ${{ steps.metadata.outputs.upstream_html_url }} + RELEASE_TRAIN: ${{ steps.metadata.outputs.release_train }} + RELEASE_EXISTS: ${{ steps.metadata.outputs.release_exists }} + PR_NUMBER: ${{ steps.pr.outputs.number }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + RELEASE_SHA: ${{ steps.release-ref.outputs.sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-create-or-update-mirrored-release.sh" + + - name: Ensure checkpoint PR + if: steps.metadata.outputs.deploy == 'true' + id: checkpoint + env: + SOURCE_BRANCH: ${{ steps.release-ref.outputs.branch }} + SOURCE_SHA: ${{ steps.release-ref.outputs.sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-create-checkpoint-pr.sh" + + - name: Mark deployment success + if: steps.metadata.outputs.deploy == 'true' + env: + GH_TOKEN: ${{ github.token }} + DEPLOYMENT_ID: ${{ steps.deployment.outputs.id }} + TERMUX_TAG: ${{ steps.metadata.outputs.termux_tag }} + run: | + set -euo pipefail + log_url="${GH_WORKFLOW_URL:-${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}}" + gh api \ + -X POST \ + "repos/${GITHUB_REPOSITORY}/deployments/${DEPLOYMENT_ID}/statuses" \ + -f state="success" \ + -f environment="termux-release" \ + -f log_url="${log_url}" \ + -F auto_inactive=false \ + -f description="Termux release deployment completed for ${TERMUX_TAG}" \ + >/dev/null + + - name: Mark deployment failure + if: failure() && steps.deployment.outputs.id != '' + env: + GH_TOKEN: ${{ github.token }} + DEPLOYMENT_ID: ${{ steps.deployment.outputs.id }} + run: | + set -euo pipefail + log_url="${GH_WORKFLOW_URL:-${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}}" + gh api \ + -X POST \ + "repos/${GITHUB_REPOSITORY}/deployments/${DEPLOYMENT_ID}/statuses" \ + -f state="failure" \ + -f environment="termux-release" \ + -f log_url="${log_url}" \ + -F auto_inactive=false \ + -f description="Termux release deployment failed" \ + >/dev/null diff --git a/.github/workflows/termux-release-promote.yml b/.github/workflows/termux-release-promote.yml new file mode 100644 index 000000000000..22c277c21a70 --- /dev/null +++ b/.github/workflows/termux-release-promote.yml @@ -0,0 +1,135 @@ +name: termux-release-promote + +on: + workflow_dispatch: + inputs: + release_branch: + description: "Release branch to promote, for example release/0.122.0" + required: true + type: string + release_sha: + description: "Release branch commit SHA to promote. Defaults to the branch head." + required: false + default: "" + type: string + pr_number: + description: "Merged PR number to promote. Optional; normally discovered automatically." + required: false + default: "" + type: string + pr_head_sha: + description: "Merged PR head SHA. Required only when pr_number is set." + required: false + default: "" + type: string + +permissions: + actions: read + attestations: read + checks: read + contents: read + deployments: read + issues: read + discussions: read + packages: read + pages: read + pull-requests: read + repository-projects: read + statuses: read + +concurrency: + group: termux-release-promote-${{ github.event_name == 'workflow_dispatch' && inputs.release_branch || github.ref_name }} + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + promote: + runs-on: ubuntu-24.04 + permissions: + actions: read + contents: write + pull-requests: read + env: + TERMUX_AUTOMATION_DIR: ${{ github.workspace }}/.termux-release-automation + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Export GitHub App token for gh + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + run: echo "GH_TOKEN=${APP_TOKEN}" >> "${GITHUB_ENV}" + + - name: Checkout release branch + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.release_branch || github.ref }} + token: ${{ steps.app-token.outputs.token }} + + - name: Checkout automation helpers + uses: actions/checkout@v6 + with: + fetch-depth: 1 + ref: ${{ github.workflow_sha }} + path: .termux-release-automation + token: ${{ steps.app-token.outputs.token }} + + - name: 🧰 Actions Toolbox + uses: wallentx/gh-actions/composite/actions-toolbox@main + + - name: Validate GitHub CLI environment + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-validate-gh-env.sh" + + - name: Resolve release ref + id: release-ref + env: + INPUT_RELEASE_BRANCH: ${{ inputs.release_branch }} + INPUT_RELEASE_SHA: ${{ inputs.release_sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-resolve-release-ref.sh" + + - name: Read release metadata + id: metadata + env: + TERMUX_RELEASE_ACTION: promote + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-read-release-metadata.sh" + + - name: Locate merged pull request + if: steps.metadata.outputs.promote == 'true' + id: pr + env: + RELEASE_BRANCH: ${{ steps.release-ref.outputs.branch }} + RELEASE_SHA: ${{ steps.release-ref.outputs.sha }} + INPUT_PR_NUMBER: ${{ inputs.pr_number }} + INPUT_PR_HEAD_SHA: ${{ inputs.pr_head_sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-find-release-pr.sh" + + - name: Download promoted PR artifact + if: steps.metadata.outputs.promote == 'true' + env: + PR_ARTIFACT_NAME: ${{ steps.pr.outputs.artifact_name }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-download-release-artifact.sh" + + - name: Create or update mirrored Termux release + if: steps.metadata.outputs.promote == 'true' + env: + UPSTREAM_TAG: ${{ steps.metadata.outputs.upstream_tag }} + UPSTREAM_REPO: ${{ steps.metadata.outputs.upstream_repo }} + UPSTREAM_NAME: ${{ steps.metadata.outputs.upstream_name }} + TERMUX_TAG: ${{ steps.metadata.outputs.termux_tag }} + UPSTREAM_PRERELEASE: ${{ steps.metadata.outputs.upstream_prerelease }} + UPSTREAM_HTML_URL: ${{ steps.metadata.outputs.upstream_html_url }} + RELEASE_TRAIN: ${{ steps.metadata.outputs.release_train }} + RELEASE_EXISTS: ${{ steps.metadata.outputs.release_exists }} + PR_NUMBER: ${{ steps.pr.outputs.number }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + RELEASE_SHA: ${{ steps.release-ref.outputs.sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-create-or-update-mirrored-release.sh" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index ce3b943429e7..739f710f6111 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -118,7 +118,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.133.0-alpha.3" +version = "0.133.0" # Track the edition for all workspace crates in one place. Individual # crates can still override this value, but keeping it here means new # crates created with `cargo new -w ...` automatically inherit the 2024 diff --git a/scripts/termux-configure-git.sh b/scripts/termux-configure-git.sh new file mode 100755 index 000000000000..fae3abcbab48 --- /dev/null +++ b/scripts/termux-configure-git.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash + +# Configure the explicit bot identity used by release automation and fetch any +# refs requested by the calling workflow. + +set -euo pipefail + +git config user.name "github-actions[bot]" +git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + +while (($#)); do + case "$1" in + --origin) + shift + origin_refs=() + while (($#)) && [[ "$1" != --* ]]; do + origin_refs+=("$1") + shift + done + if ((${#origin_refs[@]})); then + git fetch --prune origin "${origin_refs[@]}" + fi + ;; + --upstream-tag) + if (($# < 3)); then + echo "--upstream-tag requires ." >&2 + exit 1 + fi + upstream_repo="$2" + upstream_tag="$3" + git remote add upstream "https://github.com/${upstream_repo}.git" 2>/dev/null || true + git fetch --prune --no-tags upstream "+refs/tags/${upstream_tag}:refs/tags/${upstream_tag}" + shift 3 + ;; + *) + echo "Unknown argument: $1" >&2 + exit 1 + ;; + esac +done diff --git a/scripts/termux-create-checkpoint-pr.sh b/scripts/termux-create-checkpoint-pr.sh new file mode 100755 index 000000000000..f315a5b91822 --- /dev/null +++ b/scripts/termux-create-checkpoint-pr.sh @@ -0,0 +1,275 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/termux-release-paths.sh +source "${script_dir}/termux-release-paths.sh" + +source_branch="${SOURCE_BRANCH:-${REQUESTED_SOURCE_BRANCH:-${GITHUB_REF_NAME}}}" +source_sha="${SOURCE_SHA:-${REQUESTED_SOURCE_SHA:-}}" +if [[ -z "${source_sha}" ]]; then + if [[ "${GITHUB_EVENT_NAME:-}" == "push" && "${source_branch}" == "${GITHUB_REF_NAME:-}" ]]; then + source_sha="${GITHUB_SHA}" + else + source_sha="$(git rev-parse "origin/${source_branch}")" + fi +fi + +if [[ -z "${DESTINATION_BRANCH:-}" ]]; then + echo "DESTINATION_BRANCH is required." >&2 + exit 1 +fi + +release_only_checkpoint_paths() { + printf '%s\n' "${TERMUX_RELEASE_BRANCH_SCRIPT_PATHS[@]}" +} + +resolve_source_version_conflicts() { + local path="$1" + local resolved_path + resolved_path="$(mktemp)" + + if ! awk ' + function normalize_versions(text) { + gsub(/version = "[^"]+"/, "version = \"\"", text) + return text + } + + BEGIN { + in_block = 0 + side = "" + ours = "" + theirs = "" + blocks = 0 + } + + /^<<<<<<< / { + if (in_block) { + exit 1 + } + in_block = 1 + side = "ours" + ours = "" + theirs = "" + blocks++ + next + } + + /^=======$/ && in_block { + side = "theirs" + next + } + + /^>>>>>>> / && in_block { + if (normalize_versions(ours) != normalize_versions(theirs)) { + exit 1 + } + printf "%s", theirs + in_block = 0 + side = "" + next + } + + { + if (!in_block) { + print + } else if (side == "ours") { + ours = ours $0 ORS + } else if (side == "theirs") { + theirs = theirs $0 ORS + } else { + exit 1 + } + } + + END { + if (in_block || blocks == 0) { + exit 1 + } + } + ' "${path}" > "${resolved_path}"; then + rm -f "${resolved_path}" + return 1 + fi + + cp "${resolved_path}" "${path}" + rm -f "${resolved_path}" +} + +short_sha="${source_sha:0:12}" +source_slug="${source_branch//\//_}" +dest_slug="${DESTINATION_BRANCH//\//_}" +checkpoint_branch="checkpoint/${dest_slug}_from_${source_slug}_${short_sha}" +pr_title="checkpoint: into ${DESTINATION_BRANCH} from ${source_branch} @ ${short_sha}" +merge_conflicted=false +conflict_summary="" + +existing_pr="$( + gh pr list \ + --repo "${GITHUB_REPOSITORY}" \ + --head "${checkpoint_branch}" \ + --state all \ + --json number,state,mergedAt,url \ + --jq '[.[] | select(.state == "OPEN" or .mergedAt != null)] | .[0] // empty' +)" +if [[ -n "${existing_pr}" ]]; then + existing_url="$(jq -r '.url' <<< "${existing_pr}")" + existing_state="$(jq -r '.state' <<< "${existing_pr}")" + echo "Checkpoint PR already exists for ${checkpoint_branch}: ${existing_url} (${existing_state})." + if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + echo "pr_url=${existing_url}" >> "${GITHUB_OUTPUT}" + fi + exit 0 +fi + +git checkout -B "${checkpoint_branch}" "origin/${DESTINATION_BRANCH}" + +if ! git merge --no-ff --no-edit "${source_sha}"; then + mapfile -t conflicted_paths < <(git diff --name-only --diff-filter=U) + for conflicted_path in "${conflicted_paths[@]}"; do + if termux_is_checkpoint_release_only_path "${conflicted_path}"; then + echo "Auto-resolving release-only checkpoint conflict in ${conflicted_path} by keeping ${DESTINATION_BRANCH}." + if git cat-file -e "HEAD:${conflicted_path}" 2>/dev/null; then + git checkout --ours -- "${conflicted_path}" + git add "${conflicted_path}" + else + git rm -f --ignore-unmatch "${conflicted_path}" + fi + fi + done + + mapfile -t remaining_conflicts < <(git diff --name-only --diff-filter=U) + if ((${#remaining_conflicts[@]})); then + cargo_version_conflicts=true + for remaining_conflict in "${remaining_conflicts[@]}"; do + case "${remaining_conflict}" in + codex-rs/Cargo.toml|codex-rs/Cargo.lock) + ;; + *) + cargo_version_conflicts=false + ;; + esac + done + + if [[ "${cargo_version_conflicts}" == "true" ]]; then + for remaining_conflict in "${remaining_conflicts[@]}"; do + if ! resolve_source_version_conflicts "${remaining_conflict}"; then + cargo_version_conflicts=false + break + fi + done + + if [[ "${cargo_version_conflicts}" == "true" ]]; then + echo "Auto-resolving recurring Cargo version checkpoint conflicts by keeping ${source_branch} versions." + git add -- "${remaining_conflicts[@]}" + fi + fi + fi + + mapfile -t remaining_conflicts < <(git diff --name-only --diff-filter=U) + if [[ "${#remaining_conflicts[@]}" -eq 0 ]]; then + git commit --no-edit + else + merge_conflicted=true + conflict_summary="$( + printf '%s\n' "${remaining_conflicts[@]}" | awk '{ print "- `" $0 "`" }' + )" + echo "Automatic checkpoint merge failed; creating a manual-resolution PR instead." >&2 + if git rev-parse -q --verify MERGE_HEAD >/dev/null; then + git merge --abort + fi + git checkout -B "${checkpoint_branch}" "${source_sha}" + fi +fi + +if git cat-file -e "origin/${DESTINATION_BRANCH}:.github" 2>/dev/null; then + git checkout "origin/${DESTINATION_BRANCH}" -- .github + mapfile -t added_github_paths < <( + git diff --name-only --diff-filter=A "origin/${DESTINATION_BRANCH}" -- .github + ) + if ((${#added_github_paths[@]})); then + git rm -f --ignore-unmatch -- "${added_github_paths[@]}" + fi +else + git rm -r --ignore-unmatch .github +fi + +while IFS= read -r release_only_path; do + if git cat-file -e "origin/${DESTINATION_BRANCH}:${release_only_path}" 2>/dev/null; then + git checkout "origin/${DESTINATION_BRANCH}" -- "${release_only_path}" + else + git rm -f --ignore-unmatch -- "${release_only_path}" + fi +done < <(release_only_checkpoint_paths) + +if ! git diff --quiet || ! git diff --cached --quiet; then + if [[ -e .github || -L .github ]] || git ls-files --error-unmatch -- .github >/dev/null 2>&1; then + git add -A .github + fi + while IFS= read -r release_only_path; do + if [[ -e "${release_only_path}" || -L "${release_only_path}" ]] || git ls-files --error-unmatch -- "${release_only_path}" >/dev/null 2>&1; then + git add -A -- "${release_only_path}" + fi + done < <(release_only_checkpoint_paths) + if [[ "${merge_conflicted}" == "true" ]]; then + git commit -m "checkpoint: prepare ${source_branch} for ${DESTINATION_BRANCH}" + else + git commit --amend --no-edit + fi +fi + +if git diff --quiet "origin/${DESTINATION_BRANCH}" HEAD; then + echo "Checkpoint merge produced no destination changes after release-only files were restored." + exit 0 +fi + +git push --force-with-lease origin "${checkpoint_branch}" + +remaining="$( + git log --first-parent --pretty=format:%H "${source_sha}..origin/${source_branch}" | wc -w +)" + +body_path="${RUNNER_TEMP}/termux-checkpoint-pr.md" +{ + echo "## Termux release checkpoint" + echo + echo "- Source branch: \`${source_branch}\`" + echo "- Source hash: \`${source_sha}\`" + echo "- Destination branch: \`${DESTINATION_BRANCH}\`" + echo "- Remaining first-parent commits on source: ${remaining}" + echo + echo "This PR carries release-train conflict fixes and follow-up changes back into the reusable Termux patch branch." + if [[ "${merge_conflicted}" == "true" ]]; then + echo + echo "## Merge conflicts" + echo + echo "GitHub Actions could not create the checkpoint merge commit automatically, so this PR was created from the source branch state for manual conflict resolution." + echo + echo "Conflicted paths from the failed merge attempt:" + if [[ -n "${conflict_summary}" ]]; then + printf '%s\n' "${conflict_summary}" + else + echo "- Conflict details unavailable" + fi + fi + echo + echo "Release-only workflow files and metadata under \`.github\` were restored to the destination branch versions before opening this PR." +} > "${body_path}" + +pr_url="$( + gh pr create \ + --repo "${GITHUB_REPOSITORY}" \ + --base "${DESTINATION_BRANCH}" \ + --head "${checkpoint_branch}" \ + --title "${pr_title}" \ + --body-file "${body_path}" +)" +gh pr edit "${pr_url}" --repo "${GITHUB_REPOSITORY}" --add-reviewer "${REVIEWER}" || true +gh label create checkpoint --repo "${GITHUB_REPOSITORY}" --color c5def5 --description "Checkpoint merge" --force +gh label create termux-release --repo "${GITHUB_REPOSITORY}" --color 0e8a16 --description "Termux release automation" --force +gh pr edit "${pr_url}" --repo "${GITHUB_REPOSITORY}" --add-label "checkpoint" --add-label "termux-release" + +if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + echo "pr_url=${pr_url}" >> "${GITHUB_OUTPUT}" +fi diff --git a/scripts/termux-create-or-update-mirrored-release.sh b/scripts/termux-create-or-update-mirrored-release.sh new file mode 100755 index 000000000000..5787e894582b --- /dev/null +++ b/scripts/termux-create-or-update-mirrored-release.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash + +# Create a mirrored Termux release or repair an existing release that is missing +# the Android tarball. + +set -euo pipefail + +: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" +: "${GH_TOKEN:?GH_TOKEN is required}" +: "${RUNNER_TEMP:?RUNNER_TEMP is required}" +: "${UPSTREAM_TAG:?UPSTREAM_TAG is required}" +: "${UPSTREAM_REPO:?UPSTREAM_REPO is required}" +: "${UPSTREAM_NAME?UPSTREAM_NAME is required}" +: "${TERMUX_TAG:?TERMUX_TAG is required}" +: "${UPSTREAM_PRERELEASE:?UPSTREAM_PRERELEASE is required}" +: "${UPSTREAM_HTML_URL?UPSTREAM_HTML_URL is required}" +: "${RELEASE_TRAIN?RELEASE_TRAIN is required}" +: "${RELEASE_EXISTS:?RELEASE_EXISTS is required}" +: "${PR_NUMBER:?PR_NUMBER is required}" +: "${HEAD_SHA:?HEAD_SHA is required}" +: "${RELEASE_SHA:?RELEASE_SHA is required}" + +promoted_dir="${PROMOTED_DIR:-promoted}" +asset_path="${promoted_dir}/codex-aarch64-linux-android.tar.gz" + +body_path="${RUNNER_TEMP}/release-body.md" +upstream_body_path="${RUNNER_TEMP}/upstream-release-body.md" +upstream_body_without_changelog_path="${RUNNER_TEMP}/upstream-release-body-without-changelog.md" +if gh release view "${UPSTREAM_TAG}" \ + --repo "${UPSTREAM_REPO}" \ + --json body \ + --jq '.body // ""' > "${upstream_body_path}"; then + awk ' + function heading_level(line, text) { + if (match(line, /^(#{1,6})[[:space:]]+(.+)$/, parts)) { + text = tolower(parts[2]) + sub(/[[:space:]]+#+[[:space:]]*$/, "", text) + gsub(/^[[:space:]]+|[[:space:]]+$/, "", text) + if (text == "changelog" || text == "change log") { + return length(parts[1]) + } + } + return 0 + } + + { + if (!skip) { + level = heading_level($0) + if (level > 0) { + skip = 1 + skip_level = level + next + } + print + next + } + + if (match($0, /^(#{1,6})[[:space:]]+/, parts) && length(parts[1]) <= skip_level) { + skip = 0 + print + } + } + ' "${upstream_body_path}" > "${upstream_body_without_changelog_path}" +else + echo "::warning title=Upstream release notes unavailable::Could not read ${UPSTREAM_REPO} release ${UPSTREAM_TAG}." + : > "${upstream_body_without_changelog_path}" +fi + +{ + echo "Termux Android build for ${UPSTREAM_TAG}." + echo + echo "- Upstream release: ${UPSTREAM_HTML_URL}" + echo "- Release train: \`${RELEASE_TRAIN}\`" + echo "- Promoted PR: #${PR_NUMBER}" + echo "- Promoted PR head SHA: \`${HEAD_SHA}\`" +} > "${body_path}" +if [[ -s "${upstream_body_without_changelog_path}" ]]; then + { + echo + echo "## Upstream release notes" + echo + cat "${upstream_body_without_changelog_path}" + } >> "${body_path}" +fi + +release_title="${UPSTREAM_NAME}" +if [[ -z "${release_title}" || "${release_title}" == "null" ]]; then + release_title="${TERMUX_TAG}" +fi + +if [[ "${RELEASE_EXISTS}" == "true" ]]; then + gh release upload \ + "${TERMUX_TAG}" \ + "${asset_path}#codex-termux" \ + --repo "${GITHUB_REPOSITORY}" \ + --clobber + exit 0 +fi + +release_args=( + gh release create "${TERMUX_TAG}" + --repo "${GITHUB_REPOSITORY}" + --target "${RELEASE_SHA}" + --title "${release_title}" + --notes-file "${body_path}" +) +if [[ "${UPSTREAM_PRERELEASE}" == "true" ]]; then + release_args+=(--prerelease) +fi +release_args+=("${asset_path}#codex-termux") +"${release_args[@]}" diff --git a/scripts/termux-download-release-artifact.sh b/scripts/termux-download-release-artifact.sh new file mode 100755 index 000000000000..2acdba9025a0 --- /dev/null +++ b/scripts/termux-download-release-artifact.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ -z "${HEAD_SHA:-}" ]]; then + echo "HEAD_SHA is required." >&2 + exit 1 +fi + +if [[ -z "${PR_ARTIFACT_NAME:-}" ]]; then + echo "PR_ARTIFACT_NAME is required." >&2 + exit 1 +fi + +promoted_dir="${PROMOTED_DIR:-promoted}" + +find_run_id() { + local event="$1" + gh run list \ + --repo "${GITHUB_REPOSITORY}" \ + --workflow rust-release.yml \ + --event "${event}" \ + --status success \ + --commit "${HEAD_SHA}" \ + --limit 1 \ + --json databaseId \ + --jq '.[0].databaseId // empty' +} + +find_artifact_run_id() { + local artifact_name="$1" + + gh api --paginate "repos/${GITHUB_REPOSITORY}/actions/artifacts?name=${artifact_name}" \ + | jq -rs \ + --arg artifact_name "${artifact_name}" \ + --arg head_sha "${HEAD_SHA}" \ + ' + [ + .[].artifacts[] + | select(.name == $artifact_name) + | select(.expired == false) + | select(.workflow_run.head_sha == $head_sha) + ] + | sort_by(.created_at) + | reverse + | .[0].workflow_run.id // empty + ' +} + +artifact_name="${PR_ARTIFACT_NAME}" +run_id="$(find_artifact_run_id "${artifact_name}")" +mkdir -p "${promoted_dir}" +if [[ -n "${run_id}" ]] && gh run download "${run_id}" \ + --repo "${GITHUB_REPOSITORY}" \ + --name "${artifact_name}" \ + --dir "${promoted_dir}"; then + : +else + artifact_name="aarch64-linux-android" + run_id="$(find_run_id workflow_dispatch)" + if [[ -z "${run_id}" ]]; then + echo "No successful rust-release run found for ${HEAD_SHA}" >&2 + exit 1 + fi + gh run download "${run_id}" \ + --repo "${GITHUB_REPOSITORY}" \ + --name "${artifact_name}" \ + --dir "${promoted_dir}" +fi + +ls -la "${promoted_dir}" +if [[ -f "${promoted_dir}/SHA256SUMS" ]]; then + (cd "${promoted_dir}" && sha256sum -c SHA256SUMS) +fi +if [[ ! -f "${promoted_dir}/codex-aarch64-linux-android.tar.gz" ]]; then + echo "Expected ${promoted_dir}/codex-aarch64-linux-android.tar.gz in the downloaded artifact." >&2 + exit 1 +fi diff --git a/scripts/termux-find-release-pr.sh b/scripts/termux-find-release-pr.sh new file mode 100755 index 000000000000..3e35fa42eabe --- /dev/null +++ b/scripts/termux-find-release-pr.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ -n "${INPUT_PR_NUMBER:-}" || -n "${INPUT_PR_HEAD_SHA:-}" ]]; then + if [[ -z "${INPUT_PR_NUMBER:-}" || -z "${INPUT_PR_HEAD_SHA:-}" ]]; then + echo "workflow_dispatch inputs pr_number and pr_head_sha must be provided together." >&2 + exit 1 + fi + + pr_number="${INPUT_PR_NUMBER}" + head_sha="${INPUT_PR_HEAD_SHA}" + head_ref="" +else + if [[ -z "${RELEASE_BRANCH:-}" || -z "${RELEASE_SHA:-}" ]]; then + echo "RELEASE_BRANCH and RELEASE_SHA are required." >&2 + exit 1 + fi + + pr_json="$( + gh pr list \ + --repo "${GITHUB_REPOSITORY}" \ + --base "${RELEASE_BRANCH}" \ + --state merged \ + --limit 100 \ + --json number,headRefOid,headRefName,mergeCommit \ + --jq "[.[] | select(.mergeCommit.oid == \"${RELEASE_SHA}\")] | sort_by(.number) | reverse | .[0] // empty" + )" + if [[ -z "${pr_json}" ]]; then + echo "Unable to find merged PR for ${RELEASE_SHA} into ${RELEASE_BRANCH}" >&2 + exit 1 + fi + + pr_number="$(jq -r '.number' <<< "${pr_json}")" + head_sha="$(jq -r '.headRefOid // .head.sha' <<< "${pr_json}")" + head_ref="$(jq -r '.headRefName // .head.ref' <<< "${pr_json}")" +fi + +artifact_name="termux-android-pr-${pr_number}-${head_sha}" +if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + { + echo "number=${pr_number}" + echo "head_sha=${head_sha}" + echo "head_ref=${head_ref}" + echo "artifact_name=${artifact_name}" + } >> "${GITHUB_OUTPUT}" +else + printf 'number=%s\nhead_sha=%s\nhead_ref=%s\nartifact_name=%s\n' \ + "${pr_number}" \ + "${head_sha}" \ + "${head_ref}" \ + "${artifact_name}" +fi diff --git a/scripts/termux-read-release-metadata.sh b/scripts/termux-read-release-metadata.sh new file mode 100755 index 000000000000..519a0635a368 --- /dev/null +++ b/scripts/termux-read-release-metadata.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash + +# Parse .github/termux-release.json and emit workflow outputs for deploy or +# promote jobs. TERMUX_RELEASE_ACTION must be "deploy" or "promote". + +set -euo pipefail + +: "${GITHUB_OUTPUT:?GITHUB_OUTPUT is required}" +: "${GH_TOKEN:?GH_TOKEN is required}" + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + +metadata=".github/termux-release.json" +action="${TERMUX_RELEASE_ACTION:-deploy}" +case "${action}" in + deploy) + missing_context="deployment" + ;; + promote) + missing_context="promotion" + ;; + *) + echo "TERMUX_RELEASE_ACTION must be deploy or promote." >&2 + exit 1 + ;; +esac + +if [[ ! -f "${metadata}" ]]; then + echo "No ${metadata}; this push is not a Termux release ${missing_context}." + echo "${action}=false" >> "${GITHUB_OUTPUT}" + exit 0 +fi + +upstream_tag="$(jq -r '.upstream_tag // empty' "${metadata}")" +upstream_name="$(jq -r '.upstream_name // .upstream_tag // empty' "${metadata}")" +termux_tag="$(jq -r '.termux_tag // empty' "${metadata}")" +upstream_version="${upstream_tag#rust-v}" +upstream_version="${upstream_version%-termux}" +upstream_prerelease=false +if [[ "${upstream_version}" == *-* ]]; then + upstream_prerelease=true +fi +upstream_html_url="$(jq -r '.upstream_html_url // ""' "${metadata}")" +upstream_repo="$(jq -r '.upstream_repo // "openai/codex"' "${metadata}")" +release_train="$(jq -r '.release_train // ""' "${metadata}")" +if [[ -z "${upstream_tag}" || -z "${termux_tag}" ]]; then + echo "Missing upstream_tag or termux_tag in ${metadata}" >&2 + exit 1 +fi + +release_state="$(TERMUX_TAG="${termux_tag}" "${script_dir}/termux-release-asset-state.sh")" +release_exists="$(awk -F= '$1 == "release_exists" { print $2 }' <<< "${release_state}")" +asset_exists="$(awk -F= '$1 == "asset_exists" { print $2 }' <<< "${release_state}")" + +if [[ "${action}" == "promote" ]]; then + if [[ "${asset_exists}" == "true" ]]; then + echo "${termux_tag} already exists with codex-aarch64-linux-android.tar.gz; skipping promotion." + echo "promote=false" >> "${GITHUB_OUTPUT}" + exit 0 + fi + if [[ "${release_exists}" == "true" ]]; then + echo "${termux_tag} exists but is missing codex-aarch64-linux-android.tar.gz; repairing promotion." + fi +fi + +{ + echo "${action}=true" + echo "upstream_tag=${upstream_tag}" + echo "upstream_name=${upstream_name}" + echo "termux_tag=${termux_tag}" + echo "upstream_prerelease=${upstream_prerelease}" + echo "upstream_html_url=${upstream_html_url}" + echo "upstream_repo=${upstream_repo}" + echo "release_train=${release_train}" + echo "release_exists=${release_exists}" + echo "asset_exists=${asset_exists}" +} >> "${GITHUB_OUTPUT}" diff --git a/scripts/termux-release-asset-state.sh b/scripts/termux-release-asset-state.sh new file mode 100755 index 000000000000..8694480b6a36 --- /dev/null +++ b/scripts/termux-release-asset-state.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash + +set -euo pipefail + +termux_tag="${1:-${TERMUX_TAG:-}}" +release_repo="${RELEASE_REPO:-${GITHUB_REPOSITORY:-}}" +asset_name="${ASSET_NAME:-codex-aarch64-linux-android.tar.gz}" + +if [[ -z "${termux_tag}" ]]; then + echo "TERMUX_TAG or tag argument is required." >&2 + exit 1 +fi + +if [[ -z "${release_repo}" ]]; then + echo "GITHUB_REPOSITORY or RELEASE_REPO is required." >&2 + exit 1 +fi + +release_exists=false +asset_exists=false + +if gh release view "${termux_tag}" --repo "${release_repo}" >/dev/null 2>&1; then + release_exists=true + release_asset_exists="$( + gh release view "${termux_tag}" \ + --repo "${release_repo}" \ + --json assets \ + | jq -r --arg asset_name "${asset_name}" \ + '.assets | map(.name) | any(. == $asset_name)' + )" + if [[ "${release_asset_exists}" == "true" ]]; then + asset_exists=true + fi +fi + +printf 'release_exists=%s\n' "${release_exists}" +printf 'asset_exists=%s\n' "${asset_exists}" diff --git a/scripts/termux-release-paths.sh b/scripts/termux-release-paths.sh new file mode 100755 index 000000000000..6990f323ec44 --- /dev/null +++ b/scripts/termux-release-paths.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash + +# Sourceable path lists for files owned by the Termux release automation. + +set -euo pipefail + +readonly -a TERMUX_RELEASE_WORKFLOW_PATHS=( + .github/workflows/rust-release.yml + .github/workflows/shell-tool-mcp.yml + .github/workflows/termux-release-checkpoint.yml + .github/workflows/termux-release-deploy.yml + .github/workflows/termux-release-promote.yml +) + +readonly -a TERMUX_RELEASE_BRANCH_SCRIPT_PATHS=( + scripts/termux-configure-git.sh + scripts/termux-create-checkpoint-pr.sh + scripts/termux-create-or-update-mirrored-release.sh + scripts/termux-download-release-artifact.sh + scripts/termux-find-release-pr.sh + scripts/termux-read-release-metadata.sh + scripts/termux-release-asset-state.sh + scripts/termux-release-paths.sh + scripts/termux-resolve-release-ref.sh + scripts/termux-validate-gh-env.sh +) + +readonly -a TERMUX_RELEASE_AUTOMATION_PATHS=( + "${TERMUX_RELEASE_WORKFLOW_PATHS[@]}" + "${TERMUX_RELEASE_BRANCH_SCRIPT_PATHS[@]}" +) + +readonly -a TERMUX_CHECKPOINT_RELEASE_ONLY_PATHS=( + "${TERMUX_RELEASE_AUTOMATION_PATHS[@]}" + .github/termux-release.json +) + +termux_path_in_list() { + local candidate="$1" + shift + local listed_path + + for listed_path in "$@"; do + [[ "${candidate}" != "${listed_path}" ]] || return 0 + done + return 1 +} + +termux_is_release_automation_path() { + termux_path_in_list "$1" "${TERMUX_RELEASE_AUTOMATION_PATHS[@]}" +} + +termux_is_checkpoint_release_only_path() { + termux_path_in_list "$1" "${TERMUX_CHECKPOINT_RELEASE_ONLY_PATHS[@]}" +} diff --git a/scripts/termux-resolve-release-ref.sh b/scripts/termux-resolve-release-ref.sh new file mode 100755 index 000000000000..194773d45bed --- /dev/null +++ b/scripts/termux-resolve-release-ref.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash + +# Resolve the release branch and SHA for deploy/promote workflows. + +set -euo pipefail + +: "${GITHUB_EVENT_NAME:?GITHUB_EVENT_NAME is required}" +: "${GITHUB_OUTPUT:?GITHUB_OUTPUT is required}" + +input_release_branch="${INPUT_RELEASE_BRANCH:-${REQUESTED_RELEASE_BRANCH:-}}" +input_release_sha="${INPUT_RELEASE_SHA:-${REQUESTED_RELEASE_SHA:-}}" + +if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then + if [[ -z "${input_release_branch}" ]]; then + echo "release_branch is required for workflow_dispatch." >&2 + exit 1 + fi + + release_branch="${input_release_branch}" + if [[ -n "${input_release_sha}" ]]; then + git checkout --detach "${input_release_sha}" + release_sha="${input_release_sha}" + else + release_sha="$(git rev-parse HEAD)" + fi +else + : "${GITHUB_REF_NAME:?GITHUB_REF_NAME is required}" + : "${GITHUB_SHA:?GITHUB_SHA is required}" + release_branch="${GITHUB_REF_NAME}" + release_sha="${GITHUB_SHA}" +fi + +{ + echo "branch=${release_branch}" + echo "sha=${release_sha}" +} >> "${GITHUB_OUTPUT}" diff --git a/scripts/termux-validate-gh-env.sh b/scripts/termux-validate-gh-env.sh new file mode 100755 index 000000000000..02581fdebceb --- /dev/null +++ b/scripts/termux-validate-gh-env.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash + +# Lightweight post-toolbox check for jobs that rely on authenticated gh calls. + +set -euo pipefail + +: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" +: "${GH_TOKEN:?GH_TOKEN is required}" + +command -v gh +gh auth status --hostname github.com + +printf 'GITHUB_REPOSITORY=%s\n' "${GITHUB_REPOSITORY}" +printf 'REPO=%s\n' "${REPO:-}" +printf 'GH_REPO_URL=%s\n' "${GH_REPO_URL:-}" +printf 'GH_WORKFLOW_URL=%s\n' "${GH_WORKFLOW_URL:-}" From b20e969f23bee39497d060431756283e17749e37 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 21 May 2026 13:43:48 -0700 Subject: [PATCH 17/64] npm: remove legacy package artifact synthesis (#23836) ## Why `rust-release` now publishes `codex-package-.tar.gz` as the canonical native package payload. npm staging should consume those archives directly instead of keeping legacy synthesis code that fetched `rg`, copied standalone binaries, and rebuilt an approximate package layout. That also means the package builder should not know the internal shape of `codex-package`. It should extract and copy the target payload wholesale so future layout changes stay localized to the archive producer. The release job stages `codex`, `codex-responses-api-proxy`, and `codex-sdk` together, so native artifact download should be filtered, observable, and shared across component installs. Since that native hydration is now only used by release staging, keeping a separate `install_native_deps.py` CLI adds an extra wrapper without a real caller. ## What Changed - Removed legacy `codex-package` synthesis and related compatibility flags from npm staging. - Folded the remaining native artifact hydration code into `scripts/stage_npm_packages.py` and deleted `codex-cli/scripts/install_native_deps.py`. - Made platform package staging copy the full extracted target directory instead of enumerating package entries. - Kept non-`codex-package` native components under their component directory name instead of using a legacy destination map. - Split native staging by component set while sharing one workflow-artifact cache across the invocation. - Changed workflow artifact download to select target artifacts by name, print sizes/progress, and reuse cached artifacts. - Removed the implicit `CI=true` default from `build_npm_package.py`; local CI-shaped runs should set that environment explicitly. - Kept `npm pack` cache/log output in its temporary directory so packing does not write to the user npm cache. ## Verification - `python3 -m py_compile scripts/stage_npm_packages.py codex-cli/scripts/build_npm_package.py` - `python3 -m unittest discover -s scripts/codex_package -p "test_*.py"` - `scripts/stage_npm_packages.py --help` - `codex-cli/scripts/build_npm_package.py --help` - Ran the release-shaped staging command from `rust-release.yml` against workflow run https://github.com/openai/codex/actions/runs/26240748758 with `CI=true` set locally to match GitHub Actions: ```sh CI=true python3 ./scripts/stage_npm_packages.py \ --release-version 0.133.0 \ --workflow-url https://github.com/openai/codex/actions/runs/26240748758 \ --package codex \ --package codex-responses-api-proxy \ --package codex-sdk ``` That completed successfully, downloaded only the six target artifacts once, reused the cache for `codex-responses-api-proxy`, and produced all nine npm tarballs. Generated tarballs and staging/artifact temp dirs were cleaned afterward. --- .github/workflows/ci.yml | 11 +- codex-cli/scripts/README.md | 10 +- codex-cli/scripts/build_npm_package.py | 93 +--- codex-cli/scripts/install_native_deps.py | 654 ----------------------- scripts/stage_npm_packages.py | 434 +++++++++++++-- 5 files changed, 401 insertions(+), 801 deletions(-) delete mode 100755 codex-cli/scripts/install_native_deps.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25dff134a77d..b1ee1395e104 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,9 +42,6 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - # stage_npm_packages.py requires DotSlash when staging releases. - - uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2 - - name: Stage npm package id: stage_npm_package env: @@ -55,17 +52,13 @@ jobs: # cross-platform native payload required by the npm package layout. # Passing the workflow URL directly avoids relying on old rust-v* # branches remaining discoverable via `gh run list --branch ...`. - CODEX_VERSION=0.125.0 - WORKFLOW_URL="https://github.com/openai/codex/actions/runs/26131514935" + CODEX_VERSION=0.133.0-alpha.4 + WORKFLOW_URL="https://github.com/openai/codex/actions/runs/26201494185" OUTPUT_DIR="${RUNNER_TEMP}" - # This reused workflow predates codex-package archive artifacts, so - # CI synthesizes the package layout from the older per-binary - # artifacts. Release staging must use real package archives. python3 ./scripts/stage_npm_packages.py \ --release-version "$CODEX_VERSION" \ --workflow-url "$WORKFLOW_URL" \ --package codex \ - --allow-legacy-codex-package \ --output-dir "$OUTPUT_DIR" PACK_OUTPUT="${OUTPUT_DIR}/codex-npm-${CODEX_VERSION}.tgz" echo "pack_output=$PACK_OUTPUT" >> "$GITHUB_OUTPUT" diff --git a/codex-cli/scripts/README.md b/codex-cli/scripts/README.md index ce58097db549..65923151a0c5 100644 --- a/codex-cli/scripts/README.md +++ b/codex-cli/scripts/README.md @@ -11,13 +11,13 @@ example, to stage the CLI, responses proxy, and SDK packages for version `0.6.0` --package codex-sdk ``` -This downloads the native package archive artifacts once, hydrates `vendor/` for each -package, and writes tarballs to `dist/npm/`. +This downloads the required native package archive artifacts, hydrates `vendor/` for +each package, and writes tarballs to `dist/npm/`. When `--package codex` is provided, the staging helper builds the lightweight `@openai/codex` meta package plus all platform-native `@openai/codex` variants that are later published under platform-specific dist-tags. -If you need to invoke `build_npm_package.py` directly, run -`codex-cli/scripts/install_native_deps.py --component codex-package` first and pass -`--vendor-src` pointing to the directory that contains the populated `vendor/` tree. +Direct `build_npm_package.py` invocations are still useful for package-specific +debugging, but native packages expect `--vendor-src` to point at a prehydrated +`vendor/` tree. Release packaging should use `scripts/stage_npm_packages.py`. diff --git a/codex-cli/scripts/build_npm_package.py b/codex-cli/scripts/build_npm_package.py index 261b9e0b614e..60f6ca7a9d5b 100755 --- a/codex-cli/scripts/build_npm_package.py +++ b/codex-cli/scripts/build_npm_package.py @@ -3,6 +3,7 @@ import argparse import json +import os import shutil import subprocess import sys @@ -16,7 +17,6 @@ CODEX_SDK_ROOT = REPO_ROOT / "sdk" / "typescript" CODEX_NPM_NAME = "@openai/codex" CODEX_PACKAGE_COMPONENT = "codex-package" -CODEX_PACKAGE_ENTRIES = ("codex-package.json", "bin", "codex-resources", "codex-path") # `npm_name` is the local optional-dependency alias consumed by `bin/codex.js`. # The underlying package published to npm is always `@openai/codex`. @@ -88,16 +88,6 @@ PACKAGE_CHOICES = tuple(PACKAGE_NATIVE_COMPONENTS) -COMPONENT_DEST_DIR: dict[str, str] = { - "bwrap": "codex-resources", - "codex": "codex", - "codex-responses-api-proxy": "codex-responses-api-proxy", - "codex-windows-sandbox-setup": "codex", - "codex-command-runner": "codex", - "rg": "path", -} - - def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Build or stage the Codex CLI npm package.") parser.add_argument( @@ -140,16 +130,6 @@ def parse_args() -> argparse.Namespace: type=Path, help="Directory containing pre-installed native binaries to bundle (vendor root).", ) - parser.add_argument( - "--allow-missing-native-component", - dest="allow_missing_native_components", - action="append", - default=[], - help=( - "Native component that may be absent from --vendor-src. Intended for CI " - "compatibility with older artifact workflows; releases should not use this." - ), - ) return parser.parse_args() @@ -190,7 +170,6 @@ def main() -> int: staging_dir, native_components, target_filter={target_filter} if target_filter else None, - allow_missing_components=set(args.allow_missing_native_components), ) if release_version: @@ -346,7 +325,7 @@ def compute_platform_package_version(version: str, platform_tag: str) -> str: def run_command(cmd: list[str], cwd: Path | None = None) -> None: - print("+", " ".join(cmd)) + print("+", " ".join(cmd), flush=True) subprocess.run(cmd, cwd=cwd, check=True) @@ -376,18 +355,12 @@ def copy_native_binaries( staging_dir: Path, components: list[str], target_filter: set[str] | None = None, - allow_missing_components: set[str] | None = None, ) -> None: vendor_src = vendor_src.resolve() if not vendor_src.exists(): raise RuntimeError(f"Vendor source directory not found: {vendor_src}") - components_set = { - component - for component in components - if component == CODEX_PACKAGE_COMPONENT or component in COMPONENT_DEST_DIR - } - allow_missing_components = allow_missing_components or set() + components_set = set(components) if not components_set: return @@ -410,34 +383,20 @@ def copy_native_binaries( dest_target_dir = vendor_dest / target_dir.name if CODEX_PACKAGE_COMPONENT in components_set: - validate_codex_package_dir(target_dir) if dest_target_dir.exists(): shutil.rmtree(dest_target_dir) - dest_target_dir.mkdir(parents=True, exist_ok=True) - for entry in CODEX_PACKAGE_ENTRIES: - src = target_dir / entry - dest = dest_target_dir / entry - if src.is_dir(): - shutil.copytree(src, dest) - else: - shutil.copy2(src, dest) + shutil.copytree(target_dir, dest_target_dir) else: dest_target_dir.mkdir(parents=True, exist_ok=True) - for component in components_set - {CODEX_PACKAGE_COMPONENT}: - dest_dir_name = COMPONENT_DEST_DIR.get(component) - if dest_dir_name is None: - continue - - src_component_dir = target_dir / dest_dir_name + for component in sorted(components_set - {CODEX_PACKAGE_COMPONENT}): + src_component_dir = target_dir / component if not src_component_dir.exists(): - if component in allow_missing_components: - continue raise RuntimeError( f"Missing native component '{component}' in vendor source: {src_component_dir}" ) - dest_component_dir = dest_target_dir / dest_dir_name + dest_component_dir = dest_target_dir / component if dest_component_dir.exists(): shutil.rmtree(dest_component_dir) shutil.copytree(src_component_dir, dest_component_dir) @@ -448,45 +407,23 @@ def copy_native_binaries( missing_list = ", ".join(missing_targets) raise RuntimeError(f"Missing target directories in vendor source: {missing_list}") - -def validate_codex_package_dir(package_dir: Path) -> None: - is_windows = "windows" in package_dir.name - required_files = [ - Path("codex-package.json"), - Path("bin") / ("codex.exe" if is_windows else "codex"), - Path("codex-path") / ("rg.exe" if is_windows else "rg"), - ] - - if "linux" in package_dir.name: - required_files.append(Path("codex-resources") / "bwrap") - - if is_windows: - required_files.extend( - [ - Path("codex-resources") / "codex-command-runner.exe", - Path("codex-resources") / "codex-windows-sandbox-setup.exe", - ] - ) - - missing_files = [ - str(relative_path) - for relative_path in required_files - if not (package_dir / relative_path).is_file() - ] - if missing_files: - missing = ", ".join(missing_files) - raise RuntimeError(f"Missing files in Codex package directory {package_dir}: {missing}") - - def run_npm_pack(staging_dir: Path, output_path: Path) -> Path: output_path = output_path.resolve() output_path.parent.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory(prefix="codex-npm-pack-") as pack_dir_str: pack_dir = Path(pack_dir_str) + npm_cache_dir = pack_dir / "npm-cache" + npm_logs_dir = pack_dir / "npm-logs" + npm_cache_dir.mkdir() + npm_logs_dir.mkdir() + env = os.environ.copy() + env["NPM_CONFIG_CACHE"] = str(npm_cache_dir) + env["NPM_CONFIG_LOGS_DIR"] = str(npm_logs_dir) stdout = subprocess.check_output( ["npm", "pack", "--json", "--pack-destination", str(pack_dir)], cwd=staging_dir, + env=env, text=True, ) try: diff --git a/codex-cli/scripts/install_native_deps.py b/codex-cli/scripts/install_native_deps.py deleted file mode 100755 index de157334cd07..000000000000 --- a/codex-cli/scripts/install_native_deps.py +++ /dev/null @@ -1,654 +0,0 @@ -#!/usr/bin/env python3 -"""Install Codex package archives and native helper binaries.""" - -import argparse -from contextlib import contextmanager -import json -import os -import shutil -import subprocess -import tarfile -import tempfile -import zipfile -from dataclasses import dataclass -from concurrent.futures import ThreadPoolExecutor, as_completed -from pathlib import Path -import sys -from typing import Iterable, Sequence -from urllib.parse import urlparse -from urllib.request import urlopen - -SCRIPT_DIR = Path(__file__).resolve().parent -CODEX_CLI_ROOT = SCRIPT_DIR.parent -REPO_ROOT = CODEX_CLI_ROOT.parent -DEFAULT_WORKFLOW_URL = "https://github.com/openai/codex/actions/runs/26131514935" # rust-v0.132.0 -VENDOR_DIR_NAME = "vendor" -RG_MANIFEST = REPO_ROOT / "scripts" / "codex_package" / "rg" -BINARY_TARGETS = ( - "x86_64-unknown-linux-musl", - "aarch64-unknown-linux-musl", - "x86_64-apple-darwin", - "aarch64-apple-darwin", - "x86_64-pc-windows-msvc", - "aarch64-pc-windows-msvc", -) -CODEX_PACKAGE_COMPONENT = "codex-package" - - -@dataclass(frozen=True) -class BinaryComponent: - artifact_prefix: str # matches the artifact filename prefix (e.g. codex-.zst) - dest_dir: str # directory under vendor// where the binary is installed - binary_basename: str # executable name inside dest_dir (before optional .exe) - targets: tuple[str, ...] | None = None # limit installation to specific targets - - -WINDOWS_TARGETS = tuple(target for target in BINARY_TARGETS if "windows" in target) -LINUX_TARGETS = tuple(target for target in BINARY_TARGETS if "linux" in target) - -BINARY_COMPONENTS = { - "bwrap": BinaryComponent( - artifact_prefix="bwrap", - dest_dir="codex-resources", - binary_basename="bwrap", - targets=LINUX_TARGETS, - ), - "codex": BinaryComponent( - artifact_prefix="codex", - dest_dir="codex", - binary_basename="codex", - ), - "codex-responses-api-proxy": BinaryComponent( - artifact_prefix="codex-responses-api-proxy", - dest_dir="codex-responses-api-proxy", - binary_basename="codex-responses-api-proxy", - ), - "codex-windows-sandbox-setup": BinaryComponent( - artifact_prefix="codex-windows-sandbox-setup", - dest_dir="codex", - binary_basename="codex-windows-sandbox-setup", - targets=WINDOWS_TARGETS, - ), - "codex-command-runner": BinaryComponent( - artifact_prefix="codex-command-runner", - dest_dir="codex", - binary_basename="codex-command-runner", - targets=WINDOWS_TARGETS, - ), -} - -RG_TARGET_PLATFORM_PAIRS: list[tuple[str, str]] = [ - ("x86_64-unknown-linux-musl", "linux-x86_64"), - ("aarch64-unknown-linux-musl", "linux-aarch64"), - ("x86_64-apple-darwin", "macos-x86_64"), - ("aarch64-apple-darwin", "macos-aarch64"), - ("x86_64-pc-windows-msvc", "windows-x86_64"), - ("aarch64-pc-windows-msvc", "windows-aarch64"), -] -RG_TARGET_TO_PLATFORM = {target: platform for target, platform in RG_TARGET_PLATFORM_PAIRS} -DEFAULT_RG_TARGETS = [target for target, _ in RG_TARGET_PLATFORM_PAIRS] - -# urllib.request.urlopen() defaults to no timeout (can hang indefinitely), which is painful in CI. -DOWNLOAD_TIMEOUT_SECS = 60 - - -def _gha_enabled() -> bool: - # GitHub Actions supports "workflow commands" (e.g. ::group:: / ::error::) that make logs - # much easier to scan: groups collapse noisy sections and error annotations surface the - # failure in the UI without changing the actual exception/traceback output. - return os.environ.get("GITHUB_ACTIONS") == "true" - - -def _gha_escape(value: str) -> str: - # Workflow commands require percent/newline escaping. - return value.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A") - - -def _gha_error(*, title: str, message: str) -> None: - # Emit a GitHub Actions error annotation. This does not replace stdout/stderr logs; it just - # adds a prominent summary line to the job UI so the root cause is easier to spot. - if not _gha_enabled(): - return - print( - f"::error title={_gha_escape(title)}::{_gha_escape(message)}", - flush=True, - ) - - -@contextmanager -def _gha_group(title: str): - # Wrap a block in a collapsible log group on GitHub Actions. Outside of GHA this is a no-op - # so local output remains unchanged. - if _gha_enabled(): - print(f"::group::{_gha_escape(title)}", flush=True) - try: - yield - finally: - if _gha_enabled(): - print("::endgroup::", flush=True) - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Install native Codex binaries.") - parser.add_argument( - "--workflow-url", - help=( - "GitHub Actions workflow URL that produced the artifacts. Defaults to a " - "known good run when omitted." - ), - ) - parser.add_argument( - "--component", - dest="components", - action="append", - choices=tuple([CODEX_PACKAGE_COMPONENT, *BINARY_COMPONENTS, "rg"]), - help=( - "Limit installation to the specified components." - " May be repeated. Defaults to codex-package and codex-responses-api-proxy." - ), - ) - parser.add_argument( - "--allow-legacy-codex-package", - action="store_true", - help=( - "Allow codex-package to be synthesized from legacy per-binary artifacts " - "when package archives are missing. Intended for CI compatibility only; " - "release staging should not use this. Automatically enabled for the " - "built-in default workflow." - ), - ) - parser.add_argument( - "root", - nargs="?", - type=Path, - help=( - "Directory containing package.json for the staged package. If omitted, the " - "repository checkout is used." - ), - ) - return parser.parse_args() - - -def main() -> int: - args = parse_args() - - codex_cli_root = (args.root or CODEX_CLI_ROOT).resolve() - vendor_dir = codex_cli_root / VENDOR_DIR_NAME - vendor_dir.mkdir(parents=True, exist_ok=True) - - components = args.components or [CODEX_PACKAGE_COMPONENT, "codex-responses-api-proxy"] - - workflow_override = (args.workflow_url or "").strip() - use_default_workflow = not workflow_override - workflow_url = workflow_override or DEFAULT_WORKFLOW_URL - - workflow_id = workflow_url.rstrip("/").split("/")[-1] - print(f"Downloading native artifacts from workflow {workflow_id}...") - - with _gha_group(f"Download native artifacts from workflow {workflow_id}"): - with tempfile.TemporaryDirectory(prefix="codex-native-artifacts-") as artifacts_dir_str: - artifacts_dir = Path(artifacts_dir_str) - _download_artifacts(workflow_id, artifacts_dir) - if CODEX_PACKAGE_COMPONENT in components: - try: - install_codex_package_archives(artifacts_dir, vendor_dir, BINARY_TARGETS) - except FileNotFoundError: - if not (args.allow_legacy_codex_package or use_default_workflow): - raise - install_legacy_codex_package_layouts( - artifacts_dir, - vendor_dir, - BINARY_TARGETS, - manifest_path=RG_MANIFEST, - ) - install_binary_components( - artifacts_dir, - vendor_dir, - [BINARY_COMPONENTS[name] for name in components if name in BINARY_COMPONENTS], - ) - - if "rg" in components: - with _gha_group("Fetch ripgrep binaries"): - print("Fetching ripgrep binaries...") - fetch_rg(vendor_dir, DEFAULT_RG_TARGETS, manifest_path=RG_MANIFEST) - - print(f"Installed native dependencies into {vendor_dir}") - return 0 - - -def install_codex_package_archives( - artifacts_dir: Path, - vendor_dir: Path, - targets: Sequence[str], -) -> None: - targets = list(targets) - if not targets: - return - - print("Installing Codex package archives for targets: " + ", ".join(targets)) - max_workers = min(len(targets), max(1, (os.cpu_count() or 1))) - with ThreadPoolExecutor(max_workers=max_workers) as executor: - futures = { - executor.submit( - _install_single_codex_package_archive, - artifacts_dir, - vendor_dir, - target, - ): target - for target in targets - } - for future in as_completed(futures): - installed_path = future.result() - print(f" installed {installed_path}") - - -def _install_single_codex_package_archive( - artifacts_dir: Path, - vendor_dir: Path, - target: str, -) -> Path: - artifact_subdir = artifact_dir_for_target(artifacts_dir, target) - archive_path = artifact_subdir / f"codex-package-{target}.tar.gz" - if not archive_path.exists(): - raise FileNotFoundError(f"Expected package archive not found: {archive_path}") - - dest_dir = vendor_dir / target - if dest_dir.exists(): - shutil.rmtree(dest_dir) - dest_dir.mkdir(parents=True, exist_ok=True) - - with tarfile.open(archive_path, "r:gz") as archive: - archive.extractall(dest_dir, filter="data") - - return dest_dir - - -def install_legacy_codex_package_layouts( - artifacts_dir: Path, - vendor_dir: Path, - targets: Sequence[str], - *, - manifest_path: Path, -) -> None: - targets = list(targets) - print( - "Synthesizing Codex package layouts from legacy artifacts for targets: " - + ", ".join(targets) - ) - with tempfile.TemporaryDirectory(prefix="codex-legacy-package-") as legacy_vendor_dir_str: - legacy_vendor_dir = Path(legacy_vendor_dir_str) - install_binary_components( - artifacts_dir, - legacy_vendor_dir, - [ - BINARY_COMPONENTS["codex"], - BINARY_COMPONENTS["bwrap"], - BINARY_COMPONENTS["codex-windows-sandbox-setup"], - BINARY_COMPONENTS["codex-command-runner"], - ], - ) - fetch_rg(legacy_vendor_dir, targets, manifest_path=manifest_path) - - for target in targets: - dest_dir = vendor_dir / target - if dest_dir.exists(): - shutil.rmtree(dest_dir) - _build_legacy_codex_package_layout(legacy_vendor_dir / target, dest_dir, target) - print(f" synthesized {dest_dir}") - - -def _build_legacy_codex_package_layout( - legacy_target_dir: Path, - package_dir: Path, - target: str, -) -> None: - is_windows = "windows" in target - exe_suffix = ".exe" if is_windows else "" - package_dir.mkdir(parents=True) - - bin_dir = package_dir / "bin" - resources_dir = package_dir / "codex-resources" - path_dir = package_dir / "codex-path" - bin_dir.mkdir() - resources_dir.mkdir() - path_dir.mkdir() - - shutil.copy2( - legacy_target_dir / "codex" / f"codex{exe_suffix}", - bin_dir / f"codex{exe_suffix}", - ) - shutil.copy2( - legacy_target_dir / "path" / f"rg{exe_suffix}", - path_dir / f"rg{exe_suffix}", - ) - - if is_windows: - for helper in [ - "codex-command-runner.exe", - "codex-windows-sandbox-setup.exe", - ]: - shutil.copy2(legacy_target_dir / "codex" / helper, resources_dir / helper) - elif "linux" in target: - shutil.copy2(legacy_target_dir / "codex-resources" / "bwrap", resources_dir / "bwrap") - - write_json( - package_dir / "codex-package.json", - { - "layoutVersion": 1, - "version": "unknown", - "target": target, - "variant": "codex", - "entrypoint": f"bin/codex{exe_suffix}", - "resourcesDir": "codex-resources", - "pathDir": "codex-path", - }, - ) - - -def fetch_rg( - vendor_dir: Path, - targets: Sequence[str] | None = None, - *, - manifest_path: Path, -) -> list[Path]: - """Download ripgrep binaries described by the DotSlash manifest.""" - - if targets is None: - targets = DEFAULT_RG_TARGETS - - if not manifest_path.exists(): - raise FileNotFoundError(f"DotSlash manifest not found: {manifest_path}") - - manifest = _load_manifest(manifest_path) - platforms = manifest.get("platforms", {}) - - vendor_dir.mkdir(parents=True, exist_ok=True) - - targets = list(targets) - if not targets: - return [] - - task_configs: list[tuple[str, str, dict]] = [] - for target in targets: - platform_key = RG_TARGET_TO_PLATFORM.get(target) - if platform_key is None: - raise ValueError(f"Unsupported ripgrep target '{target}'.") - - platform_info = platforms.get(platform_key) - if platform_info is None: - raise RuntimeError(f"Platform '{platform_key}' not found in manifest {manifest_path}.") - - task_configs.append((target, platform_key, platform_info)) - - results: dict[str, Path] = {} - max_workers = min(len(task_configs), max(1, (os.cpu_count() or 1))) - - print("Installing ripgrep binaries for targets: " + ", ".join(targets)) - - with ThreadPoolExecutor(max_workers=max_workers) as executor: - future_map = { - executor.submit( - _fetch_single_rg, - vendor_dir, - target, - platform_key, - platform_info, - manifest_path, - ): target - for target, platform_key, platform_info in task_configs - } - - for future in as_completed(future_map): - target = future_map[future] - try: - results[target] = future.result() - except Exception as exc: - _gha_error( - title="ripgrep install failed", - message=f"target={target} error={exc!r}", - ) - raise RuntimeError(f"Failed to install ripgrep for target {target}.") from exc - print(f" installed ripgrep for {target}") - - return [results[target] for target in targets] - - -def _download_artifacts(workflow_id: str, dest_dir: Path) -> None: - cmd = [ - "gh", - "run", - "download", - "--dir", - str(dest_dir), - "--repo", - "openai/codex", - workflow_id, - ] - subprocess.check_call(cmd) - - -def install_binary_components( - artifacts_dir: Path, - vendor_dir: Path, - selected_components: Sequence[BinaryComponent], -) -> None: - if not selected_components: - return - - for component in selected_components: - component_targets = list(component.targets or BINARY_TARGETS) - - print( - f"Installing {component.binary_basename} binaries for targets: " - + ", ".join(component_targets) - ) - max_workers = min(len(component_targets), max(1, (os.cpu_count() or 1))) - with ThreadPoolExecutor(max_workers=max_workers) as executor: - futures = { - executor.submit( - _install_single_binary, - artifacts_dir, - vendor_dir, - target, - component, - ): target - for target in component_targets - } - for future in as_completed(futures): - installed_path = future.result() - print(f" installed {installed_path}") - - -def _install_single_binary( - artifacts_dir: Path, - vendor_dir: Path, - target: str, - component: BinaryComponent, -) -> Path: - artifact_subdir = artifact_dir_for_target(artifacts_dir, target) - archive_path = legacy_binary_archive_path(artifact_subdir, component.artifact_prefix, target) - - dest_dir = vendor_dir / target / component.dest_dir - dest_dir.mkdir(parents=True, exist_ok=True) - - binary_name = ( - f"{component.binary_basename}.exe" if "windows" in target else component.binary_basename - ) - dest = dest_dir / binary_name - dest.unlink(missing_ok=True) - extract_archive(archive_path, "zst", None, dest) - if "windows" not in target: - dest.chmod(0o755) - return dest - - -def _archive_name_for_target(artifact_prefix: str, target: str) -> str: - if "windows" in target: - return f"{artifact_prefix}-{target}.exe.zst" - return f"{artifact_prefix}-{target}.zst" - - -def legacy_binary_archive_path(artifact_dir: Path, artifact_prefix: str, target: str) -> Path: - archive_names = [_archive_name_for_target(artifact_prefix, target)] - if artifact_dir.name == f"{target}-unsigned": - archive_names.append(_archive_name_for_target(artifact_prefix, f"{target}-unsigned")) - - for archive_name in archive_names: - archive_path = artifact_dir / archive_name - if archive_path.exists(): - return archive_path - - raise FileNotFoundError(f"Expected artifact not found: {artifact_dir / archive_names[0]}") - - -def artifact_dir_for_target(artifacts_dir: Path, target: str) -> Path: - for artifact_name in [target, f"{target}-unsigned"]: - artifact_dir = artifacts_dir / artifact_name - if artifact_dir.is_dir(): - return artifact_dir - - return artifacts_dir / target - - -def _fetch_single_rg( - vendor_dir: Path, - target: str, - platform_key: str, - platform_info: dict, - manifest_path: Path, -) -> Path: - providers = platform_info.get("providers", []) - if not providers: - raise RuntimeError(f"No providers listed for platform '{platform_key}' in {manifest_path}.") - - url = providers[0]["url"] - archive_format = platform_info.get("format", "zst") - archive_member = platform_info.get("path") - digest = platform_info.get("digest") - expected_size = platform_info.get("size") - - dest_dir = vendor_dir / target / "path" - dest_dir.mkdir(parents=True, exist_ok=True) - - is_windows = platform_key.startswith("win") - binary_name = "rg.exe" if is_windows else "rg" - dest = dest_dir / binary_name - - with tempfile.TemporaryDirectory() as tmp_dir_str: - tmp_dir = Path(tmp_dir_str) - archive_filename = os.path.basename(urlparse(url).path) - download_path = tmp_dir / archive_filename - print( - f" downloading ripgrep for {target} ({platform_key}) from {url}", - flush=True, - ) - try: - _download_file(url, download_path) - except Exception as exc: - _gha_error( - title="ripgrep download failed", - message=f"target={target} platform={platform_key} url={url} error={exc!r}", - ) - raise RuntimeError( - "Failed to download ripgrep " - f"(target={target}, platform={platform_key}, format={archive_format}, " - f"expected_size={expected_size!r}, digest={digest!r}, url={url}, dest={download_path})." - ) from exc - - dest.unlink(missing_ok=True) - try: - extract_archive(download_path, archive_format, archive_member, dest) - except Exception as exc: - raise RuntimeError( - "Failed to extract ripgrep " - f"(target={target}, platform={platform_key}, format={archive_format}, " - f"member={archive_member!r}, url={url}, archive={download_path})." - ) from exc - - if not is_windows: - dest.chmod(0o755) - - return dest - - -def _download_file(url: str, dest: Path) -> None: - dest.parent.mkdir(parents=True, exist_ok=True) - dest.unlink(missing_ok=True) - - with urlopen(url, timeout=DOWNLOAD_TIMEOUT_SECS) as response, open(dest, "wb") as out: - shutil.copyfileobj(response, out) - - -def extract_archive( - archive_path: Path, - archive_format: str, - archive_member: str | None, - dest: Path, -) -> None: - dest.parent.mkdir(parents=True, exist_ok=True) - - if archive_format == "zst": - output_path = archive_path.parent / dest.name - subprocess.check_call( - ["zstd", "-f", "-d", str(archive_path), "-o", str(output_path)] - ) - shutil.move(str(output_path), dest) - return - - if archive_format == "tar.gz": - if not archive_member: - raise RuntimeError("Missing 'path' for tar.gz archive in DotSlash manifest.") - with tarfile.open(archive_path, "r:gz") as tar: - try: - member = tar.getmember(archive_member) - except KeyError as exc: - raise RuntimeError( - f"Entry '{archive_member}' not found in archive {archive_path}." - ) from exc - tar.extract(member, path=archive_path.parent, filter="data") - extracted = archive_path.parent / archive_member - shutil.move(str(extracted), dest) - return - - if archive_format == "zip": - if not archive_member: - raise RuntimeError("Missing 'path' for zip archive in DotSlash manifest.") - with zipfile.ZipFile(archive_path) as archive: - try: - with archive.open(archive_member) as src, open(dest, "wb") as out: - shutil.copyfileobj(src, out) - except KeyError as exc: - raise RuntimeError( - f"Entry '{archive_member}' not found in archive {archive_path}." - ) from exc - return - - raise RuntimeError(f"Unsupported archive format '{archive_format}'.") - - -def _load_manifest(manifest_path: Path) -> dict: - cmd = ["dotslash", "--", "parse", str(manifest_path)] - stdout = subprocess.check_output(cmd, text=True) - try: - manifest = json.loads(stdout) - except json.JSONDecodeError as exc: - raise RuntimeError(f"Invalid DotSlash manifest output from {manifest_path}.") from exc - - if not isinstance(manifest, dict): - raise RuntimeError( - f"Unexpected DotSlash manifest structure for {manifest_path}: {type(manifest)!r}" - ) - - return manifest - - -def write_json(path: Path, value: object) -> None: - with open(path, "w", encoding="utf-8") as out: - json.dump(value, out, indent=2) - out.write("\n") - - -if __name__ == "__main__": - import sys - - sys.exit(main()) diff --git a/scripts/stage_npm_packages.py b/scripts/stage_npm_packages.py index 4eb69053ebcb..d0cfccf37f20 100755 --- a/scripts/stage_npm_packages.py +++ b/scripts/stage_npm_packages.py @@ -2,20 +2,32 @@ """Stage one or more Codex npm packages for release.""" import argparse +from concurrent.futures import ThreadPoolExecutor, as_completed +from contextlib import contextmanager +from dataclasses import dataclass import importlib.util import json import os import shutil import subprocess +import tarfile import tempfile from pathlib import Path +from typing import Sequence REPO_ROOT = Path(__file__).resolve().parent.parent BUILD_SCRIPT = REPO_ROOT / "codex-cli" / "scripts" / "build_npm_package.py" -INSTALL_NATIVE_DEPS = REPO_ROOT / "codex-cli" / "scripts" / "install_native_deps.py" WORKFLOW_NAME = ".github/workflows/rust-release.yml" GITHUB_REPO = "openai/codex" +BINARY_TARGETS = ( + "x86_64-unknown-linux-musl", + "aarch64-unknown-linux-musl", + "x86_64-apple-darwin", + "aarch64-apple-darwin", + "x86_64-pc-windows-msvc", + "aarch64-pc-windows-msvc", +) _SPEC = importlib.util.spec_from_file_location("codex_build_npm_package", BUILD_SCRIPT) if _SPEC is None or _SPEC.loader is None: @@ -25,6 +37,48 @@ PACKAGE_NATIVE_COMPONENTS = getattr(_BUILD_MODULE, "PACKAGE_NATIVE_COMPONENTS", {}) PACKAGE_EXPANSIONS = getattr(_BUILD_MODULE, "PACKAGE_EXPANSIONS", {}) CODEX_PLATFORM_PACKAGES = getattr(_BUILD_MODULE, "CODEX_PLATFORM_PACKAGES", {}) +CODEX_PACKAGE_COMPONENT = getattr(_BUILD_MODULE, "CODEX_PACKAGE_COMPONENT", "codex-package") + + +@dataclass(frozen=True) +class BinaryComponent: + artifact_prefix: str + dest_dir: str + binary_basename: str + + +@dataclass(frozen=True) +class WorkflowArtifact: + name: str + size_in_bytes: int + + +BINARY_COMPONENTS = { + "codex-responses-api-proxy": BinaryComponent( + artifact_prefix="codex-responses-api-proxy", + dest_dir="codex-responses-api-proxy", + binary_basename="codex-responses-api-proxy", + ), +} + + +def _gha_enabled() -> bool: + return os.environ.get("GITHUB_ACTIONS") == "true" + + +def _gha_escape(value: str) -> str: + return value.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A") + + +@contextmanager +def _gha_group(title: str): + if _gha_enabled(): + print(f"::group::{_gha_escape(title)}", flush=True) + try: + yield + finally: + if _gha_enabled(): + print("::endgroup::", flush=True) def parse_args() -> argparse.Namespace: @@ -56,33 +110,23 @@ def parse_args() -> argparse.Namespace: action="store_true", help="Retain temporary staging directories instead of deleting them.", ) - parser.add_argument( - "--allow-missing-native-component", - dest="allow_missing_native_components", - action="append", - default=[], - help=( - "Native component that may be absent from reused workflow artifacts. " - "Intended for CI compatibility only; release staging should not use this." - ), - ) - parser.add_argument( - "--allow-legacy-codex-package", - action="store_true", - help=( - "Allow codex-package layouts to be synthesized from legacy per-binary " - "workflow artifacts. Intended for CI compatibility only; release staging " - "should not use this." - ), - ) return parser.parse_args() -def collect_native_components(packages: list[str]) -> set[str]: - components: set[str] = set() +def native_components_for_package(package: str) -> tuple[str, ...]: + return tuple(sorted(PACKAGE_NATIVE_COMPONENTS.get(package, []))) + + +def collect_native_component_sets(packages: list[str]) -> list[tuple[str, ...]]: + component_sets: list[tuple[str, ...]] = [] + seen: set[tuple[str, ...]] = set() for package in packages: - components.update(PACKAGE_NATIVE_COMPONENTS.get(package, [])) - return components + components = native_components_for_package(package) + if not components or components in seen: + continue + seen.add(components) + component_sets.append(components) + return component_sets def expand_packages(packages: list[str]) -> list[str]: @@ -131,23 +175,280 @@ def install_native_components( workflow_url: str, components: set[str], vendor_root: Path, - *, - allow_legacy_codex_package: bool, + artifacts_dir: Path, ) -> None: if not components: return - cmd = [str(INSTALL_NATIVE_DEPS), "--workflow-url", workflow_url] - if allow_legacy_codex_package: - cmd.append("--allow-legacy-codex-package") - for component in sorted(components): - cmd.extend(["--component", component]) - cmd.append(str(vendor_root)) - run_command(cmd) + vendor_dir = vendor_root / "vendor" + vendor_dir.mkdir(parents=True, exist_ok=True) + + workflow_id = workflow_url.rstrip("/").split("/")[-1] + print(f"Downloading native artifacts from workflow {workflow_id}...", flush=True) + with _gha_group(f"Download native artifacts from workflow {workflow_id}"): + artifacts_dir.mkdir(parents=True, exist_ok=True) + install_from_workflow_artifacts( + workflow_id, + artifacts_dir, + sorted(components), + vendor_dir, + ) + print(f"Installed native dependencies into {vendor_dir}", flush=True) + + +def install_from_workflow_artifacts( + workflow_id: str, + artifacts_dir: Path, + components: Sequence[str], + vendor_dir: Path, +) -> None: + artifacts = select_target_artifacts(workflow_id, components) + download_artifacts(workflow_id, artifacts_dir, artifacts) + if CODEX_PACKAGE_COMPONENT in components: + install_codex_package_archives(artifacts_dir, vendor_dir, BINARY_TARGETS) + install_binary_components( + artifacts_dir, + vendor_dir, + [BINARY_COMPONENTS[name] for name in components if name in BINARY_COMPONENTS], + ) + + +def select_target_artifacts( + workflow_id: str, + components: Sequence[str], +) -> list[WorkflowArtifact]: + needs_target_artifacts = CODEX_PACKAGE_COMPONENT in components or any( + component in BINARY_COMPONENTS for component in components + ) + if not needs_target_artifacts: + return [] + + artifacts_by_name = { + artifact.name: artifact for artifact in list_workflow_artifacts(workflow_id) + } + selected_artifacts: list[WorkflowArtifact] = [] + for target in BINARY_TARGETS: + for artifact_name in [target, f"{target}-unsigned"]: + artifact = artifacts_by_name.get(artifact_name) + if artifact is not None: + selected_artifacts.append(artifact) + break + else: + raise FileNotFoundError( + f"Expected workflow artifact not found for target {target}" + ) + + return selected_artifacts + + +def list_workflow_artifacts(workflow_id: str) -> list[WorkflowArtifact]: + stdout = subprocess.check_output( + [ + "gh", + "api", + f"repos/{GITHUB_REPO}/actions/runs/{workflow_id}/artifacts", + "--paginate", + "--jq", + ".artifacts[] | [.name, .size_in_bytes] | @tsv", + ], + text=True, + ) + artifacts: list[WorkflowArtifact] = [] + for line in stdout.splitlines(): + name, size_in_bytes = line.split("\t", 1) + artifacts.append(WorkflowArtifact(name=name, size_in_bytes=int(size_in_bytes))) + return artifacts + + +def download_artifacts( + workflow_id: str, + dest_dir: Path, + artifacts: Sequence[WorkflowArtifact], +) -> None: + total_bytes = sum(artifact.size_in_bytes for artifact in artifacts) + print( + f"Downloading {len(artifacts)} artifacts ({format_bytes(total_bytes)})", + flush=True, + ) + for artifact in artifacts: + artifact_dir = dest_dir / artifact.name + if artifact_dir.is_dir() and any(artifact_dir.iterdir()): + print( + f" using cached {artifact.name} ({format_bytes(artifact.size_in_bytes)})", + flush=True, + ) + continue + + artifact_dir.mkdir(parents=True, exist_ok=True) + print( + f" downloading {artifact.name} ({format_bytes(artifact.size_in_bytes)})", + flush=True, + ) + subprocess.check_call( + [ + "gh", + "run", + "download", + "--name", + artifact.name, + "--dir", + str(artifact_dir), + "--repo", + GITHUB_REPO, + workflow_id, + ] + ) + + +def install_codex_package_archives( + artifacts_dir: Path, + vendor_dir: Path, + targets: Sequence[str], +) -> None: + if not targets: + return + + print( + "Installing Codex package archives for targets: " + ", ".join(targets), + flush=True, + ) + max_workers = min(len(targets), max(1, (os.cpu_count() or 1))) + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = { + executor.submit( + install_single_codex_package_archive, + artifacts_dir, + vendor_dir, + target, + ): target + for target in targets + } + for future in as_completed(futures): + installed_path = future.result() + print(f" installed {installed_path}", flush=True) + + +def install_single_codex_package_archive( + artifacts_dir: Path, + vendor_dir: Path, + target: str, +) -> Path: + artifact_subdir = artifact_dir_for_target(artifacts_dir, target) + archive_path = artifact_subdir / f"codex-package-{target}.tar.gz" + if not archive_path.exists(): + raise FileNotFoundError(f"Expected package archive not found: {archive_path}") + + dest_dir = vendor_dir / target + if dest_dir.exists(): + shutil.rmtree(dest_dir) + dest_dir.mkdir(parents=True, exist_ok=True) + + with tarfile.open(archive_path, "r:gz") as archive: + archive.extractall(dest_dir, filter="data") + + return dest_dir + + +def install_binary_components( + artifacts_dir: Path, + vendor_dir: Path, + selected_components: Sequence[BinaryComponent], +) -> None: + for component in selected_components: + component_targets = list(BINARY_TARGETS) + + print( + f"Installing {component.binary_basename} binaries for targets: " + + ", ".join(component_targets), + flush=True, + ) + max_workers = min(len(component_targets), max(1, (os.cpu_count() or 1))) + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = { + executor.submit( + install_single_binary, + artifacts_dir, + vendor_dir, + target, + component, + ): target + for target in component_targets + } + for future in as_completed(futures): + installed_path = future.result() + print(f" installed {installed_path}", flush=True) + + +def install_single_binary( + artifacts_dir: Path, + vendor_dir: Path, + target: str, + component: BinaryComponent, +) -> Path: + artifact_subdir = artifact_dir_for_target(artifacts_dir, target) + archive_path = binary_archive_path(artifact_subdir, component.artifact_prefix, target) + + dest_dir = vendor_dir / target / component.dest_dir + dest_dir.mkdir(parents=True, exist_ok=True) + + binary_name = ( + f"{component.binary_basename}.exe" if "windows" in target else component.binary_basename + ) + dest = dest_dir / binary_name + dest.unlink(missing_ok=True) + extract_zstd_archive(archive_path, dest) + if "windows" not in target: + dest.chmod(0o755) + return dest + + +def binary_archive_path(artifact_dir: Path, artifact_prefix: str, target: str) -> Path: + archive_names = [archive_name_for_target(artifact_prefix, target)] + if artifact_dir.name == f"{target}-unsigned": + archive_names.append(archive_name_for_target(artifact_prefix, f"{target}-unsigned")) + + for archive_name in archive_names: + archive_path = artifact_dir / archive_name + if archive_path.exists(): + return archive_path + + raise FileNotFoundError(f"Expected artifact not found: {artifact_dir / archive_names[0]}") + + +def archive_name_for_target(artifact_prefix: str, target: str) -> str: + if "windows" in target: + return f"{artifact_prefix}-{target}.exe.zst" + return f"{artifact_prefix}-{target}.zst" + + +def artifact_dir_for_target(artifacts_dir: Path, target: str) -> Path: + for artifact_name in [target, f"{target}-unsigned"]: + artifact_dir = artifacts_dir / artifact_name + if artifact_dir.is_dir(): + return artifact_dir + + return artifacts_dir / target + + +def extract_zstd_archive(archive_path: Path, dest: Path) -> None: + dest.parent.mkdir(parents=True, exist_ok=True) + + output_path = archive_path.parent / dest.name + subprocess.check_call(["zstd", "-f", "-d", str(archive_path), "-o", str(output_path)]) + shutil.move(str(output_path), dest) + + +def format_bytes(size_in_bytes: int) -> str: + value = float(size_in_bytes) + for unit in ["B", "KiB", "MiB"]: + if value < 1024: + return f"{value:.1f} {unit}" + value /= 1024 + return f"{value:.1f} GiB" def run_command(cmd: list[str]) -> None: - print("+", " ".join(cmd)) + print("+", " ".join(cmd), flush=True) subprocess.run(cmd, cwd=REPO_ROOT, check=True) @@ -167,36 +468,58 @@ def main() -> int: runner_temp = Path(os.environ.get("RUNNER_TEMP", tempfile.gettempdir())) packages = expand_packages(list(args.packages)) - native_components = collect_native_components(packages) - allow_missing_native_components = set(args.allow_missing_native_components) - native_components_to_install = native_components - allow_missing_native_components - - vendor_temp_root: Path | None = None - vendor_src: Path | None = None + native_component_sets = collect_native_component_sets(packages) + print("Expanded packages: " + ", ".join(packages), flush=True) + if native_component_sets: + component_sets = [ + "(" + ", ".join(components) + ")" for components in native_component_sets + ] + print( + "Native component sets: " + ", ".join(component_sets), + flush=True, + ) + + vendor_temp_roots: list[Path] = [] + vendor_src_by_components: dict[tuple[str, ...], Path] = {} + artifacts_temp_root: Path | None = None resolved_head_sha: str | None = None final_messages = [] try: - if native_components_to_install: + if native_component_sets: workflow_url, resolved_head_sha = resolve_workflow_url( args.release_version, args.workflow_url ) - vendor_temp_root = Path(tempfile.mkdtemp(prefix="npm-native-", dir=runner_temp)) - install_native_components( - workflow_url, - native_components_to_install, - vendor_temp_root, - allow_legacy_codex_package=args.allow_legacy_codex_package, + print(f"Using native artifacts from {workflow_url}", flush=True) + artifacts_temp_root = Path( + tempfile.mkdtemp(prefix="npm-native-artifacts-", dir=runner_temp) ) - vendor_src = vendor_temp_root / "vendor" + print(f"Caching downloaded artifacts in {artifacts_temp_root}", flush=True) + for components in native_component_sets: + vendor_temp_root = Path(tempfile.mkdtemp(prefix="npm-native-", dir=runner_temp)) + vendor_temp_roots.append(vendor_temp_root) + print( + "Installing native components " + + ", ".join(components) + + f" into {vendor_temp_root}", + flush=True, + ) + install_native_components( + workflow_url, + set(components), + vendor_temp_root, + artifacts_temp_root, + ) + vendor_src_by_components[components] = vendor_temp_root / "vendor" if resolved_head_sha: - print(f"should `git checkout {resolved_head_sha}`") + print(f"should `git checkout {resolved_head_sha}`", flush=True) for package in packages: staging_dir = Path(tempfile.mkdtemp(prefix=f"npm-stage-{package}-", dir=runner_temp)) pack_output = output_dir / tarball_name_for_package(package, args.release_version) + print(f"Staging {package} in {staging_dir}", flush=True) cmd = [ str(BUILD_SCRIPT), @@ -210,12 +533,10 @@ def main() -> int: str(pack_output), ] + vendor_src = vendor_src_by_components.get(native_components_for_package(package)) if vendor_src is not None: cmd.extend(["--vendor-src", str(vendor_src)]) - for component in sorted(allow_missing_native_components): - cmd.extend(["--allow-missing-native-component", component]) - try: run_command(cmd) finally: @@ -224,11 +545,14 @@ def main() -> int: final_messages.append(f"Staged {package} at {pack_output}") finally: - if vendor_temp_root is not None and not args.keep_staging_dirs: - shutil.rmtree(vendor_temp_root, ignore_errors=True) + if not args.keep_staging_dirs: + for vendor_temp_root in vendor_temp_roots: + shutil.rmtree(vendor_temp_root, ignore_errors=True) + if artifacts_temp_root is not None: + shutil.rmtree(artifacts_temp_root, ignore_errors=True) for msg in final_messages: - print(msg) + print(msg, flush=True) return 0 From 05cf2fc4ce82b4f894031522a7c42698e6e6addd Mon Sep 17 00:00:00 2001 From: Francis Chalissery Date: Thu, 21 May 2026 14:14:01 -0700 Subject: [PATCH 18/64] [codex] Make thread search case-insensitive (#23921) ## Summary - make rollout content search prefilter rollout files case-insensitively - keep the no-ripgrep fallback scan and visible snippet matcher aligned with that behavior - cover a lowercase `thread/search` query matching mixed-case conversation content ## Why The rollout-backed `thread/search` path used exact string matching in both its `rg` prefilter and semantic snippet generation. A content result could be missed solely because the query casing did not match the stored conversation text. ## Validation - `just fmt` - `cargo test -p codex-app-server thread_search_returns_content_matches` - `cargo test -p codex-rollout` - `just bazel-lock-update` - `just bazel-lock-check` - `cargo build -p codex-cli` - launched a local Electron dev instance with the rebuilt CLI binary --- codex-rs/Cargo.lock | 1 + .../app-server/tests/suite/v2/thread_list.rs | 4 +-- codex-rs/rollout/Cargo.toml | 1 + codex-rs/rollout/src/search.rs | 33 +++++++++++++------ 4 files changed, 27 insertions(+), 12 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 3fa4d8315b1c..7c9955323d4d 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3558,6 +3558,7 @@ dependencies = [ "codex-utils-path", "codex-utils-string", "pretty_assertions", + "regex", "serde", "serde_json", "tempfile", diff --git a/codex-rs/app-server/tests/suite/v2/thread_list.rs b/codex-rs/app-server/tests/suite/v2/thread_list.rs index bfb1d4f5e038..e064ff6e254a 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_list.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_list.rs @@ -686,7 +686,7 @@ async fn thread_search_returns_content_matches() -> Result<()> { codex_home.path(), "2025-01-02T12-00-00", "2025-01-02T12:00:00Z", - "needle suffix", + "mixed NEEDLE suffix", Some("mock_provider"), /*git_info*/ None, )?; @@ -718,7 +718,7 @@ async fn thread_search_returns_content_matches() -> Result<()> { .map(|result| result.thread.id.as_str()) .collect(); assert_eq!(ids, vec![newer_match, older_match]); - assert_eq!(data[0].snippet, "needle suffix"); + assert_eq!(data[0].snippet, "mixed NEEDLE suffix"); Ok(()) } diff --git a/codex-rs/rollout/Cargo.toml b/codex-rs/rollout/Cargo.toml index ef5a8dc22a8d..50e5a8594a1a 100644 --- a/codex-rs/rollout/Cargo.toml +++ b/codex-rs/rollout/Cargo.toml @@ -24,6 +24,7 @@ codex-protocol = { workspace = true } codex-state = { workspace = true } codex-utils-path = { workspace = true } codex-utils-string = { workspace = true } +regex = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } time = { workspace = true, features = [ diff --git a/codex-rs/rollout/src/search.rs b/codex-rs/rollout/src/search.rs index 1773f5afb38b..911e80552a3f 100644 --- a/codex-rs/rollout/src/search.rs +++ b/codex-rs/rollout/src/search.rs @@ -9,6 +9,8 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::RolloutLine; use codex_protocol::protocol::USER_MESSAGE_BEGIN; +use regex::Regex; +use regex::RegexBuilder; use tokio::io::AsyncBufReadExt; use tokio::process::Command; @@ -45,6 +47,7 @@ async fn ripgrep_rollout_paths( let output = match Command::new(rg_command) .arg("-l") .arg("--fixed-strings") + .arg("--ignore-case") .arg("--no-ignore") .arg("--glob") .arg("*.jsonl") @@ -88,6 +91,7 @@ async fn ripgrep_rollout_paths( async fn scan_rollout_paths(root: &Path, search_term: &str) -> io::Result> { let mut matches = HashSet::new(); let mut dirs = vec![root.to_path_buf()]; + let search_term = case_insensitive_literal_regex(search_term)?; while let Some(dir) = dirs.pop() { let mut entries = match tokio::fs::read_dir(dir).await { @@ -107,7 +111,7 @@ async fn scan_rollout_paths(root: &Path, search_term: &str) -> io::Result io::Result io::Result { +async fn rollout_contains(path: &Path, search_term: &Regex) -> io::Result { let file = tokio::fs::File::open(path).await?; let mut lines = tokio::io::BufReader::new(file).lines(); while let Some(line) = lines.next_line().await? { - if line.contains(search_term) { + if search_term.is_match(line.as_str()) { return Ok(true); } } @@ -133,10 +137,11 @@ pub async fn first_rollout_content_match_snippet( ) -> io::Result> { let file = tokio::fs::File::open(path).await?; let mut lines = tokio::io::BufReader::new(file).lines(); - let json_search_term = json_escaped_search_term(search_term)?; + let json_search_term = case_insensitive_literal_regex(json_escaped_search_term(search_term)?)?; + let search_term = case_insensitive_literal_regex(search_term)?; while let Some(line) = lines.next_line().await? { - if line.contains(json_search_term.as_str()) - && let Some(snippet) = content_match_snippet(line.as_str(), search_term) + if json_search_term.is_match(line.as_str()) + && let Some(snippet) = content_match_snippet(line.as_str(), &search_term) { return Ok(Some(snippet)); } @@ -149,7 +154,14 @@ fn json_escaped_search_term(search_term: &str) -> io::Result { Ok(serialized[1..serialized.len() - 1].to_string()) } -fn content_match_snippet(jsonl_line: &str, search_term: &str) -> Option { +fn case_insensitive_literal_regex(search_term: impl AsRef) -> io::Result { + RegexBuilder::new(regex::escape(search_term.as_ref()).as_str()) + .case_insensitive(true) + .build() + .map_err(io::Error::other) +} + +fn content_match_snippet(jsonl_line: &str, search_term: &Regex) -> Option { let rollout_line = serde_json::from_str::(jsonl_line.trim()).ok()?; let text = conversation_text_from_item(&rollout_line.item)?; excerpt_around_match(text.as_str(), search_term) @@ -206,10 +218,11 @@ fn strip_user_message_prefix(text: &str) -> &str { } } -fn excerpt_around_match(text: &str, search_term: &str) -> Option { +fn excerpt_around_match(text: &str, search_term: &Regex) -> Option { let normalized = normalize_preview_text(text); - let match_start = normalized.find(search_term)?; - let match_end = match_start.saturating_add(search_term.len()); + let matched = search_term.find(normalized.as_str())?; + let match_start = matched.start(); + let match_end = matched.end(); let excerpt_start = char_start_before(normalized.as_str(), match_start, MATCH_CONTEXT_BEFORE_CHARS); let excerpt_end = char_end_after(normalized.as_str(), match_end, MATCH_CONTEXT_AFTER_CHARS); From 58be470d159b40cbfd0dce92de18416b4472e16f Mon Sep 17 00:00:00 2001 From: Anton Panasenko Date: Thu, 21 May 2026 14:38:30 -0700 Subject: [PATCH 19/64] fix(remote-control): retry after auth recovery (#23775) ## Why When remote control hits an auth failure such as a revoked or reused refresh token, the websocket loop falls into reconnect backoff. If the user fixes auth while that loop is sleeping, remote control can stay offline until the old retry timer expires because nothing wakes the loop or resets its exhausted auth recovery state. ## What Changed Added an auth-change watch on `AuthManager` for refresh-relevant cached auth updates. The remote-control websocket loop now subscribes to that signal, resets `UnauthorizedRecovery` and reconnect backoff when auth changes, and retries immediately instead of waiting for the previous delay. Updated the remote-control transport test to verify that reloading auth with the now-available account id wakes enrollment before the prior retry delay. ## Verification `cargo test -p codex-app-server-transport remote_control_waits_for_account_id_before_enrolling` --- .../src/transport/remote_control/tests.rs | 7 +- .../src/transport/remote_control/websocket.rs | 146 ++++++++++++++++-- codex-rs/app-server/src/lib.rs | 3 +- codex-rs/login/src/auth/manager.rs | 18 +++ 4 files changed, 153 insertions(+), 21 deletions(-) diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests.rs index fb1512fedbd1..627907271352 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests.rs @@ -1339,7 +1339,7 @@ async fn remote_control_waits_for_account_id_before_enrolling() { installation_id: TEST_INSTALLATION_ID.to_string(), }, Some(state_db.clone()), - auth_manager, + auth_manager.clone(), transport_event_tx, shutdown_token.clone(), /*app_server_client_name_rx*/ None, @@ -1358,8 +1358,11 @@ async fn remote_control_waits_for_account_id_before_enrolling() { AuthCredentialsStoreMode::File, ) .expect("auth with account id should save"); + auth_manager.reload().await; - let enroll_request = accept_http_request(&listener).await; + let enroll_request = timeout(Duration::from_millis(100), accept_http_request(&listener)) + .await + .expect("auth change should wake remote control before the retry delay"); assert_eq!( enroll_request.request_line, "POST /backend-api/wham/remote/control/server/enroll HTTP/1.1" diff --git a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs index f117aec3b47d..74ef9c2301a1 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs @@ -226,6 +226,7 @@ pub(crate) struct RemoteControlWebsocket { reconnect_attempt: u64, enrollment: Option, auth_recovery: UnauthorizedRecovery, + auth_change_rx: watch::Receiver, client_tracker: Arc>, state: Arc>, server_event_rx: Arc>>, @@ -240,6 +241,12 @@ pub(crate) struct RemoteControlWebsocketConfig { pub(crate) server_name: String, } +pub(super) struct RemoteControlAuthContext<'a> { + auth_manager: &'a Arc, + auth_recovery: &'a mut UnauthorizedRecovery, + auth_change_rx: &'a mut watch::Receiver, +} + enum ConnectOutcome { Connected(Box>>), Disabled, @@ -321,6 +328,7 @@ impl RemoteControlWebsocket { ); let (outbound_buffer, used_rx) = BoundedOutboundBuffer::new(); let auth_recovery = auth_manager.unauthorized_recovery(); + let auth_change_rx = auth_manager.auth_change_receiver(); Self { remote_control_url: config.remote_control_url, @@ -334,6 +342,7 @@ impl RemoteControlWebsocket { reconnect_attempt: 0, enrollment: None, auth_recovery, + auth_change_rx, client_tracker: Arc::new(Mutex::new(client_tracker)), state: Arc::new(Mutex::new(WebsocketState { outbound_buffer, @@ -457,6 +466,11 @@ impl RemoteControlWebsocket { subscribe_cursor: subscribe_cursor.as_deref(), app_server_client_name, }; + let auth_context = RemoteControlAuthContext { + auth_manager: &self.auth_manager, + auth_recovery: &mut self.auth_recovery, + auth_change_rx: &mut self.auth_change_rx, + }; let connect_result = tokio::select! { _ = shutdown_token.cancelled() => return ConnectOutcome::Shutdown, changed = self.enabled_rx.wait_for(|enabled| !*enabled) => { @@ -468,8 +482,7 @@ impl RemoteControlWebsocket { connect_result = connect_remote_control_websocket( &remote_control_target, self.state_db.as_deref(), - &self.auth_manager, - &mut self.auth_recovery, + auth_context, &mut self.enrollment, connect_options, &self.status_publisher, @@ -517,6 +530,14 @@ impl RemoteControlWebsocket { } return ConnectOutcome::Disabled; } + changed = self.auth_change_rx.changed() => { + if changed.is_err() { + return ConnectOutcome::Shutdown; + } + self.auth_recovery = self.auth_manager.unauthorized_recovery(); + self.reconnect_attempt = 0; + info!("retrying app-server remote control websocket after auth changed"); + } _ = tokio::time::sleep(reconnect_delay) => {} } } @@ -1018,8 +1039,7 @@ pub(crate) async fn load_remote_control_auth( pub(super) async fn connect_remote_control_websocket( remote_control_target: &RemoteControlTarget, state_db: Option<&StateRuntime>, - auth_manager: &Arc, - auth_recovery: &mut UnauthorizedRecovery, + auth_context: RemoteControlAuthContext<'_>, enrollment: &mut Option, connect_options: RemoteControlConnectOptions<'_>, status_publisher: &RemoteControlStatusPublisher, @@ -1028,6 +1048,11 @@ pub(super) async fn connect_remote_control_websocket( tungstenite::http::Response<()>, )> { ensure_rustls_crypto_provider(); + let RemoteControlAuthContext { + auth_manager, + auth_recovery, + auth_change_rx, + } = auth_context; let Some(state_db) = state_db else { *enrollment = None; @@ -1098,7 +1123,7 @@ pub(super) async fn connect_remote_control_websocket( Ok(new_enrollment) => new_enrollment, Err(err) if err.kind() == ErrorKind::PermissionDenied - && recover_remote_control_auth(auth_recovery).await => + && recover_remote_control_auth(auth_recovery, auth_change_rx).await => { return Err(io::Error::other(format!( "{err}; retrying after auth recovery" @@ -1172,7 +1197,7 @@ pub(super) async fn connect_remote_control_websocket( tungstenite::Error::Http(response) if matches!(response.status().as_u16(), 401 | 403) => { - if recover_remote_control_auth(auth_recovery).await { + if recover_remote_control_auth(auth_recovery, auth_change_rx).await { return Err(io::Error::other(format!( "remote control websocket auth failed with HTTP {}; retrying after auth recovery", response.status() @@ -1191,15 +1216,25 @@ pub(super) async fn connect_remote_control_websocket( } } -async fn recover_remote_control_auth(auth_recovery: &mut UnauthorizedRecovery) -> bool { +async fn recover_remote_control_auth( + auth_recovery: &mut UnauthorizedRecovery, + auth_change_rx: &mut watch::Receiver, +) -> bool { if !auth_recovery.has_next() { return false; } let mode = auth_recovery.mode_name(); let step = auth_recovery.step_name(); + let auth_change_revision_before_recovery = *auth_change_rx.borrow(); match auth_recovery.next().await { Ok(step_result) => { + if step_result.auth_state_changed() == Some(true) { + mark_recovery_auth_change_seen( + auth_change_rx, + auth_change_revision_before_recovery, + ); + } info!( "remote control websocket auth recovery succeeded: mode={mode}, step={step}, auth_state_changed={:?}", step_result.auth_state_changed() @@ -1213,6 +1248,20 @@ async fn recover_remote_control_auth(auth_recovery: &mut UnauthorizedRecovery) - } } +fn mark_recovery_auth_change_seen( + auth_change_rx: &mut watch::Receiver, + auth_change_revision_before_recovery: u64, +) { + let auth_change_revision_after_recovery = *auth_change_rx.borrow(); + if auth_change_revision_after_recovery == auth_change_revision_before_recovery.wrapping_add(1) { + // Recovery updated the same watch that wakes the outer reconnect + // loop. Mark only that single revision seen; if more revisions + // arrived while recovery was in flight, leave them pending so the + // reconnect loop still reacts to the later external auth change. + auth_change_rx.borrow_and_update(); + } +} + fn format_remote_control_websocket_connect_error( websocket_url: &str, err: &tungstenite::Error, @@ -1290,6 +1339,37 @@ mod tests { (RemoteControlStatusPublisher::new(status_tx), status_rx) } + #[test] + fn mark_recovery_auth_change_seen_marks_only_recovery_revision_seen() { + let (auth_change_tx, mut auth_change_rx) = watch::channel(0u64); + let auth_change_revision_before_recovery = *auth_change_rx.borrow(); + auth_change_tx.send_modify(|revision| *revision += 1); + + mark_recovery_auth_change_seen(&mut auth_change_rx, auth_change_revision_before_recovery); + + assert!( + !auth_change_rx + .has_changed() + .expect("auth change watch should remain open") + ); + } + + #[test] + fn mark_recovery_auth_change_seen_preserves_racing_auth_change() { + let (auth_change_tx, mut auth_change_rx) = watch::channel(0u64); + let auth_change_revision_before_recovery = *auth_change_rx.borrow(); + auth_change_tx.send_modify(|revision| *revision += 1); + auth_change_tx.send_modify(|revision| *revision += 1); + + mark_recovery_auth_change_seen(&mut auth_change_rx, auth_change_revision_before_recovery); + + assert!( + auth_change_rx + .has_changed() + .expect("auth change watch should remain open") + ); + } + async fn remote_control_state_runtime(codex_home: &TempDir) -> Arc { StateRuntime::init(codex_home.path().to_path_buf(), "test-provider".to_string()) .await @@ -1375,6 +1455,7 @@ mod tests { let state_db = remote_control_state_runtime(&codex_home).await; let auth_manager = remote_control_auth_manager(); let mut auth_recovery = auth_manager.unauthorized_recovery(); + let mut auth_change_rx = auth_manager.auth_change_receiver(); let mut enrollment = Some(RemoteControlEnrollment { account_id: "account_id".to_string(), environment_id: "env_test".to_string(), @@ -1386,8 +1467,11 @@ mod tests { let err = match connect_remote_control_websocket( &remote_control_target, Some(state_db.as_ref()), - &auth_manager, - &mut auth_recovery, + RemoteControlAuthContext { + auth_manager: &auth_manager, + auth_recovery: &mut auth_recovery, + auth_change_rx: &mut auth_change_rx, + }, &mut enrollment, RemoteControlConnectOptions { installation_id: TEST_INSTALLATION_ID, @@ -1440,6 +1524,7 @@ mod tests { ) .await; let mut auth_recovery = auth_manager.unauthorized_recovery(); + let mut auth_change_rx = auth_manager.auth_change_receiver(); let mut enrollment = Some(RemoteControlEnrollment { account_id: "account_id".to_string(), environment_id: "env_test".to_string(), @@ -1466,8 +1551,11 @@ mod tests { let err = connect_remote_control_websocket( &remote_control_target, Some(state_db.as_ref()), - &auth_manager, - &mut auth_recovery, + RemoteControlAuthContext { + auth_manager: &auth_manager, + auth_recovery: &mut auth_recovery, + auth_change_rx: &mut auth_change_rx, + }, &mut enrollment, RemoteControlConnectOptions { installation_id: TEST_INSTALLATION_ID, @@ -1503,6 +1591,12 @@ mod tests { .expect("token should be readable"), "fresh-token" ); + assert!( + !auth_change_rx + .has_changed() + .expect("auth change watch should remain open"), + "recovery's own auth reload should not wake the reconnect loop" + ); } #[tokio::test] @@ -1538,6 +1632,7 @@ mod tests { ) .await; let mut auth_recovery = auth_manager.unauthorized_recovery(); + let mut auth_change_rx = auth_manager.auth_change_receiver(); let mut enrollment = None; let (status_publisher, status_rx) = remote_control_status_channel(); save_auth( @@ -1550,8 +1645,11 @@ mod tests { let err = connect_remote_control_websocket( &remote_control_target, Some(state_db.as_ref()), - &auth_manager, - &mut auth_recovery, + RemoteControlAuthContext { + auth_manager: &auth_manager, + auth_recovery: &mut auth_recovery, + auth_change_rx: &mut auth_change_rx, + }, &mut enrollment, RemoteControlConnectOptions { installation_id: TEST_INSTALLATION_ID, @@ -1585,6 +1683,12 @@ mod tests { .expect("token should be readable"), "fresh-token" ); + assert!( + !auth_change_rx + .has_changed() + .expect("auth change watch should remain open"), + "recovery's own auth reload should not wake the reconnect loop" + ); } #[tokio::test] @@ -1593,6 +1697,7 @@ mod tests { .expect("target should parse"); let auth_manager = remote_control_auth_manager(); let mut auth_recovery = auth_manager.unauthorized_recovery(); + let mut auth_change_rx = auth_manager.auth_change_receiver(); let mut enrollment = Some(RemoteControlEnrollment { account_id: "account_id".to_string(), environment_id: "env_test".to_string(), @@ -1604,8 +1709,11 @@ mod tests { let err = connect_remote_control_websocket( &remote_control_target, /*state_db*/ None, - &auth_manager, - &mut auth_recovery, + RemoteControlAuthContext { + auth_manager: &auth_manager, + auth_recovery: &mut auth_recovery, + auth_change_rx: &mut auth_change_rx, + }, &mut enrollment, RemoteControlConnectOptions { installation_id: TEST_INSTALLATION_ID, @@ -1637,6 +1745,7 @@ mod tests { ) .await; let mut auth_recovery = auth_manager.unauthorized_recovery(); + let mut auth_change_rx = auth_manager.auth_change_receiver(); let mut enrollment = Some(RemoteControlEnrollment { account_id: "account_id".to_string(), environment_id: "env_test".to_string(), @@ -1653,8 +1762,11 @@ mod tests { let err = connect_remote_control_websocket( &remote_control_target, Some(state_db.as_ref()), - &auth_manager, - &mut auth_recovery, + RemoteControlAuthContext { + auth_manager: &auth_manager, + auth_recovery: &mut auth_recovery, + auth_change_rx: &mut auth_change_rx, + }, &mut enrollment, RemoteControlConnectOptions { installation_id: TEST_INSTALLATION_ID, diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index ef4106aa143d..80305d2d9fc7 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -786,8 +786,7 @@ pub async fn run_main_with_transport_options( }); let processor_handle = tokio::spawn({ - let auth_manager = - AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false).await; + let auth_manager = Arc::clone(&auth_manager); let analytics_events_client = analytics_events_client_from_config(Arc::clone(&auth_manager), &config); let outgoing_message_sender = Arc::new(OutgoingMessageSender::new( diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index a2e4e8e0d866..d94c48165dce 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -15,6 +15,7 @@ use std::sync::RwLock; use std::sync::atomic::AtomicU64; use std::sync::atomic::Ordering; use tokio::sync::Semaphore; +use tokio::sync::watch; use codex_agent_identity::decode_agent_identity_jwt; use codex_agent_identity::fetch_agent_identity_jwks; @@ -1252,6 +1253,7 @@ impl UnauthorizedRecovery { pub struct AuthManager { codex_home: PathBuf, inner: RwLock, + auth_change_tx: watch::Sender, enable_codex_api_key_env: bool, auth_credentials_store_mode: AuthCredentialsStoreMode, forced_chatgpt_workspace_id: RwLock>>, @@ -1320,12 +1322,14 @@ impl AuthManager { .await .ok() .flatten(); + let (auth_change_tx, _auth_change_rx) = watch::channel(0); Self { codex_home, inner: RwLock::new(CachedAuth { auth: managed_auth, permanent_refresh_failure: None, }), + auth_change_tx, enable_codex_api_key_env, auth_credentials_store_mode, forced_chatgpt_workspace_id: RwLock::new(None), @@ -1341,10 +1345,12 @@ impl AuthManager { auth: Some(auth), permanent_refresh_failure: None, }; + let (auth_change_tx, _auth_change_rx) = watch::channel(0); Arc::new(Self { codex_home: PathBuf::from("non-existent"), inner: RwLock::new(cached), + auth_change_tx, enable_codex_api_key_env: false, auth_credentials_store_mode: AuthCredentialsStoreMode::File, forced_chatgpt_workspace_id: RwLock::new(None), @@ -1360,9 +1366,11 @@ impl AuthManager { auth: Some(auth), permanent_refresh_failure: None, }; + let (auth_change_tx, _auth_change_rx) = watch::channel(0); Arc::new(Self { codex_home, inner: RwLock::new(cached), + auth_change_tx, enable_codex_api_key_env: false, auth_credentials_store_mode: AuthCredentialsStoreMode::File, forced_chatgpt_workspace_id: RwLock::new(None), @@ -1373,12 +1381,14 @@ impl AuthManager { } pub fn external_bearer_only(config: ModelProviderAuthInfo) -> Arc { + let (auth_change_tx, _auth_change_rx) = watch::channel(0); Arc::new(Self { codex_home: PathBuf::from("non-existent"), inner: RwLock::new(CachedAuth { auth: None, permanent_refresh_failure: None, }), + auth_change_tx, enable_codex_api_key_env: false, auth_credentials_store_mode: AuthCredentialsStoreMode::File, forced_chatgpt_workspace_id: RwLock::new(None), @@ -1395,6 +1405,11 @@ impl AuthManager { self.inner.read().ok().and_then(|c| c.auth.clone()) } + /// Subscribes to cached auth changes that can affect request recovery. + pub fn auth_change_receiver(&self) -> watch::Receiver { + self.auth_change_tx.subscribe() + } + pub fn refresh_failure_for_auth(&self, auth: &CodexAuth) -> Option { self.inner.read().ok().and_then(|cached| { cached @@ -1537,6 +1552,9 @@ impl AuthManager { } tracing::info!("Reloaded auth, changed: {changed}"); guard.auth = new_auth; + if auth_changed_for_refresh { + self.auth_change_tx.send_modify(|revision| *revision += 1); + } changed } else { false From 16d85e270817de21e3a6afe6162b44c13df1d1c0 Mon Sep 17 00:00:00 2001 From: Abhinav Date: Thu, 21 May 2026 14:54:01 -0700 Subject: [PATCH 20/64] Add subagent identity to hook inputs (#22882) # What When a normal hook fires inside a thread-spawned subagent, Codex now includes these optional top-level fields in the hook input: - `agent_id`: the child thread id - `agent_type`: the subagent role Root-agent hook inputs omit these fields. `SubagentStart` and `SubagentStop` keep their existing required `agent_id` and `agent_type` fields because those events are inherently subagent-scoped. This does not change matcher behavior. Tool hooks still match on tool name, compact hooks still match on trigger, and `UserPromptSubmit` still ignores matchers. Only `SubagentStart` and `SubagentStop` match on `agent_type`. --- codex-rs/core/src/hook_runtime.rs | 44 ++++-- .../tests/suite/subagent_notifications.rs | 57 +++++++- ...rmission-request.command.input.schema.json | 6 + .../post-compact.command.input.schema.json | 6 + .../post-tool-use.command.input.schema.json | 6 + .../pre-compact.command.input.schema.json | 6 + .../pre-tool-use.command.input.schema.json | 6 + ...er-prompt-submit.command.input.schema.json | 6 + codex-rs/hooks/src/engine/mod_tests.rs | 7 + codex-rs/hooks/src/events/common.rs | 7 + codex-rs/hooks/src/events/compact.rs | 11 ++ .../hooks/src/events/permission_request.rs | 5 + codex-rs/hooks/src/events/post_tool_use.rs | 6 + codex-rs/hooks/src/events/pre_tool_use.rs | 6 + .../hooks/src/events/user_prompt_submit.rs | 5 + codex-rs/hooks/src/lib.rs | 1 + codex-rs/hooks/src/schema.rs | 131 ++++++++++++++++++ 17 files changed, 302 insertions(+), 14 deletions(-) diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index 047299f48eb9..6452e9d5ad65 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -16,6 +16,7 @@ use codex_hooks::SessionStartOutcome; use codex_hooks::StartHookTarget; use codex_hooks::StopHookTarget; use codex_hooks::StopOutcome; +use codex_hooks::SubagentHookContext; use codex_hooks::UserPromptSubmitOutcome; use codex_hooks::UserPromptSubmitRequest; use codex_otel::HOOK_RUN_DURATION_METRIC; @@ -111,13 +112,11 @@ pub(crate) async fn run_pending_session_start_hooks( codex_hooks::SessionStartSource::Startup ) => { - let agent_type = agent_role - .clone() - .unwrap_or_else(|| crate::agent::role::DEFAULT_ROLE_NAME.to_string()); + let context = subagent_hook_context(sess, agent_role); StartHookTarget::SubagentStart { turn_id: turn_context.sub_id.clone(), - agent_id: sess.thread_id().to_string(), - agent_type, + agent_id: context.agent_id, + agent_type: context.agent_type, } } SessionSource::SubAgent(_) => return false, @@ -168,6 +167,7 @@ pub(crate) async fn run_pre_tool_use_hooks( let request = PreToolUseRequest { session_id: sess.session_id().into(), turn_id: turn_context.sub_id.clone(), + subagent: thread_spawn_subagent_hook_context(sess, turn_context), #[allow(deprecated)] cwd: turn_context.cwd.clone(), transcript_path: sess.hook_transcript_path().await, @@ -228,6 +228,7 @@ pub(crate) async fn run_permission_request_hooks( let request = PermissionRequestRequest { session_id: sess.session_id().into(), turn_id: turn_context.sub_id.clone(), + subagent: thread_spawn_subagent_hook_context(sess, turn_context), #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), transcript_path: sess.hook_transcript_path().await, @@ -269,6 +270,7 @@ pub(crate) async fn run_post_tool_use_hooks( let request = PostToolUseRequest { session_id: sess.session_id().into(), turn_id: turn_context.sub_id.clone(), + subagent: thread_spawn_subagent_hook_context(sess, turn_context), #[allow(deprecated)] cwd: turn_context.cwd.clone(), transcript_path: sess.hook_transcript_path().await, @@ -303,9 +305,7 @@ pub(crate) async fn run_turn_stop_hooks( parent_thread_id, .. }) => { - let agent_type = agent_role - .clone() - .unwrap_or_else(|| crate::agent::role::DEFAULT_ROLE_NAME.to_string()); + let context = subagent_hook_context(sess, agent_role); let agent_transcript_path = sess.hook_transcript_path().await; let parent_transcript_path = match sess .services @@ -329,8 +329,8 @@ pub(crate) async fn run_turn_stop_hooks( }; ( StopHookTarget::SubagentStop { - agent_id: sess.thread_id().to_string(), - agent_type, + agent_id: context.agent_id, + agent_type: context.agent_type, agent_transcript_path, }, parent_transcript_path, @@ -369,6 +369,7 @@ pub(crate) async fn run_pre_compact_hooks( let request = codex_hooks::PreCompactRequest { session_id: sess.session_id().into(), turn_id: turn_context.sub_id.clone(), + subagent: thread_spawn_subagent_hook_context(sess, turn_context), #[allow(deprecated)] cwd: turn_context.cwd.clone(), transcript_path: sess.hook_transcript_path().await, @@ -407,6 +408,7 @@ pub(crate) async fn run_post_compact_hooks( let request = codex_hooks::PostCompactRequest { session_id: sess.session_id().into(), turn_id: turn_context.sub_id.clone(), + subagent: thread_spawn_subagent_hook_context(sess, turn_context), #[allow(deprecated)] cwd: turn_context.cwd.clone(), transcript_path: sess.hook_transcript_path().await, @@ -502,6 +504,7 @@ pub(crate) async fn inspect_pending_input( let request = UserPromptSubmitRequest { session_id: sess.session_id().into(), turn_id: turn_context.sub_id.clone(), + subagent: thread_spawn_subagent_hook_context(sess, turn_context), #[allow(deprecated)] cwd: turn_context.cwd.clone(), transcript_path: sess.hook_transcript_path().await, @@ -728,6 +731,27 @@ fn hook_permission_mode(turn_context: &TurnContext) -> String { .to_string() } +fn thread_spawn_subagent_hook_context( + sess: &Arc, + turn_context: &TurnContext, +) -> Option { + match &turn_context.session_source { + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { agent_role, .. }) => { + Some(subagent_hook_context(sess, agent_role)) + } + _ => None, + } +} + +fn subagent_hook_context(sess: &Arc, agent_role: &Option) -> SubagentHookContext { + SubagentHookContext { + agent_id: sess.thread_id().to_string(), + agent_type: agent_role + .clone() + .unwrap_or_else(|| crate::agent::role::DEFAULT_ROLE_NAME.to_string()), + } +} + fn compaction_trigger_label(value: CompactionTrigger) -> &'static str { match value { CompactionTrigger::Manual => "manual", diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index d16c58a10e0a..d3c07a115394 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -151,6 +151,21 @@ print(json.dumps({{"hookSpecificOutput": {{"hookEventName": "SubagentStart", "ad start_log_path = start_log_path.display(), ); + let user_prompt_submit_script_path = home.join("user_prompt_submit_hook.py"); + let user_prompt_submit_log_path = home.join("user_prompt_submit_hook_log.jsonl"); + let user_prompt_submit_script = format!( + r#"import json +from pathlib import Path +import sys + +log_path = Path(r"{user_prompt_submit_log_path}") +payload = json.load(sys.stdin) +with log_path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(payload) + "\n") +"#, + user_prompt_submit_log_path = user_prompt_submit_log_path.display(), + ); + let subagent_stop_script_path = home.join("subagent_stop_hook.py"); let subagent_stop_log_path = home.join("subagent_stop_hook_log.jsonl"); let prompts_json = serde_json::to_string(stop_prompts)?; @@ -212,6 +227,12 @@ print(json.dumps({{"systemMessage": "root stop complete"}})) "command": format!("python3 {}", start_script_path.display()), }] }], + "UserPromptSubmit": [{ + "hooks": [{ + "type": "command", + "command": format!("python3 {}", user_prompt_submit_script_path.display()), + }] + }], "SubagentStop": [{ "matcher": subagent_stop_matcher, "hooks": [{ @@ -230,6 +251,7 @@ print(json.dumps({{"systemMessage": "root stop complete"}})) fs::write(&session_start_script_path, session_start_script)?; fs::write(&start_script_path, start_script)?; + fs::write(&user_prompt_submit_script_path, user_prompt_submit_script)?; fs::write(&subagent_stop_script_path, subagent_stop_script)?; fs::write(&stop_script_path, stop_script)?; fs::write(home.join("hooks.json"), hooks.to_string())?; @@ -504,7 +526,9 @@ async fn subagent_start_replaces_session_start_and_injects_context() -> Result<( let test = test_codex() .with_pre_build_hook(|home| { - if let Err(error) = write_subagent_lifecycle_hooks(home, &[], "worker") { + if let Err(error) = + write_subagent_lifecycle_hooks(home, /*stop_prompts*/ &[], "worker") + { panic!("failed to write subagent hook fixture: {error}"); } }) @@ -535,6 +559,29 @@ async fn subagent_start_replaces_session_start_and_injects_context() -> Result<( Some(spawned_id.as_str()) ); + let user_prompt_submit_inputs = wait_for_hook_log( + test.codex_home_path(), + "user_prompt_submit_hook_log.jsonl", + /*expected_len*/ 2, + ) + .await?; + let parent_prompt_input = user_prompt_submit_inputs + .iter() + .find(|input| input["prompt"].as_str() == Some(TURN_1_PROMPT)) + .expect("parent prompt submit hook input should be logged"); + assert_eq!(parent_prompt_input.get("agent_id"), None); + assert_eq!(parent_prompt_input.get("agent_type"), None); + + let child_prompt_input = user_prompt_submit_inputs + .iter() + .find(|input| input["prompt"].as_str() == Some(CHILD_PROMPT)) + .expect("child prompt submit hook input should be logged"); + assert_eq!( + child_prompt_input["agent_id"].as_str(), + Some(spawned_id.as_str()) + ); + assert_eq!(child_prompt_input["agent_type"].as_str(), Some("worker")); + let session_start_inputs = wait_for_hook_log( test.codex_home_path(), "session_start_hook_log.jsonl", @@ -626,9 +673,11 @@ async fn subagent_stop_replaces_stop_and_skips_internal_subagents() -> Result<() let test = test_codex() .with_pre_build_hook(|home| { - if let Err(error) = - write_subagent_lifecycle_hooks(home, &[SUBAGENT_STOP_CONTINUATION], "") - { + if let Err(error) = write_subagent_lifecycle_hooks( + home, + /*stop_prompts*/ &[SUBAGENT_STOP_CONTINUATION], + "", + ) { panic!("failed to write subagent hook fixture: {error}"); } }) diff --git a/codex-rs/hooks/schema/generated/permission-request.command.input.schema.json b/codex-rs/hooks/schema/generated/permission-request.command.input.schema.json index 55b3843c0b89..9ee8996db169 100644 --- a/codex-rs/hooks/schema/generated/permission-request.command.input.schema.json +++ b/codex-rs/hooks/schema/generated/permission-request.command.input.schema.json @@ -10,6 +10,12 @@ } }, "properties": { + "agent_id": { + "type": "string" + }, + "agent_type": { + "type": "string" + }, "cwd": { "type": "string" }, diff --git a/codex-rs/hooks/schema/generated/post-compact.command.input.schema.json b/codex-rs/hooks/schema/generated/post-compact.command.input.schema.json index e80ed092b77d..3131f3a776be 100644 --- a/codex-rs/hooks/schema/generated/post-compact.command.input.schema.json +++ b/codex-rs/hooks/schema/generated/post-compact.command.input.schema.json @@ -10,6 +10,12 @@ } }, "properties": { + "agent_id": { + "type": "string" + }, + "agent_type": { + "type": "string" + }, "cwd": { "type": "string" }, diff --git a/codex-rs/hooks/schema/generated/post-tool-use.command.input.schema.json b/codex-rs/hooks/schema/generated/post-tool-use.command.input.schema.json index 1ec5fb3082df..f92af1dd3249 100644 --- a/codex-rs/hooks/schema/generated/post-tool-use.command.input.schema.json +++ b/codex-rs/hooks/schema/generated/post-tool-use.command.input.schema.json @@ -10,6 +10,12 @@ } }, "properties": { + "agent_id": { + "type": "string" + }, + "agent_type": { + "type": "string" + }, "cwd": { "type": "string" }, diff --git a/codex-rs/hooks/schema/generated/pre-compact.command.input.schema.json b/codex-rs/hooks/schema/generated/pre-compact.command.input.schema.json index 816fae23c8dd..54e9a8b7f4f7 100644 --- a/codex-rs/hooks/schema/generated/pre-compact.command.input.schema.json +++ b/codex-rs/hooks/schema/generated/pre-compact.command.input.schema.json @@ -10,6 +10,12 @@ } }, "properties": { + "agent_id": { + "type": "string" + }, + "agent_type": { + "type": "string" + }, "cwd": { "type": "string" }, diff --git a/codex-rs/hooks/schema/generated/pre-tool-use.command.input.schema.json b/codex-rs/hooks/schema/generated/pre-tool-use.command.input.schema.json index f9cde0102028..48dd4c571010 100644 --- a/codex-rs/hooks/schema/generated/pre-tool-use.command.input.schema.json +++ b/codex-rs/hooks/schema/generated/pre-tool-use.command.input.schema.json @@ -10,6 +10,12 @@ } }, "properties": { + "agent_id": { + "type": "string" + }, + "agent_type": { + "type": "string" + }, "cwd": { "type": "string" }, diff --git a/codex-rs/hooks/schema/generated/user-prompt-submit.command.input.schema.json b/codex-rs/hooks/schema/generated/user-prompt-submit.command.input.schema.json index be5e16fc5071..6a10a9f75c19 100644 --- a/codex-rs/hooks/schema/generated/user-prompt-submit.command.input.schema.json +++ b/codex-rs/hooks/schema/generated/user-prompt-submit.command.input.schema.json @@ -10,6 +10,12 @@ } }, "properties": { + "agent_id": { + "type": "string" + }, + "agent_type": { + "type": "string" + }, "cwd": { "type": "string" }, diff --git a/codex-rs/hooks/src/engine/mod_tests.rs b/codex-rs/hooks/src/engine/mod_tests.rs index c3b75716264b..9b642503a068 100644 --- a/codex-rs/hooks/src/engine/mod_tests.rs +++ b/codex-rs/hooks/src/engine/mod_tests.rs @@ -224,6 +224,7 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle: let preview = engine.preview_pre_tool_use(&PreToolUseRequest { session_id: ThreadId::new(), turn_id: "turn-1".to_string(), + subagent: None, cwd: cwd.clone(), transcript_path: None, model: "gpt-test".to_string(), @@ -240,6 +241,7 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle: .run_pre_tool_use(PreToolUseRequest { session_id: ThreadId::new(), turn_id: "turn-1".to_string(), + subagent: None, cwd, transcript_path: None, model: "gpt-test".to_string(), @@ -311,6 +313,7 @@ async fn requirements_managed_hooks_execute_windows_command_override() { .run_pre_tool_use(PreToolUseRequest { session_id: ThreadId::new(), turn_id: "turn-1".to_string(), + subagent: None, cwd: cwd(), transcript_path: None, model: "gpt-test".to_string(), @@ -696,6 +699,7 @@ fn requirements_managed_hooks_load_when_managed_dir_is_missing() { let preview = engine.preview_pre_tool_use(&PreToolUseRequest { session_id: ThreadId::new(), turn_id: "turn-1".to_string(), + subagent: None, cwd, transcript_path: None, model: "gpt-test".to_string(), @@ -1101,6 +1105,7 @@ fn discovers_hooks_from_json_and_toml_in_the_same_layer() { let preview = engine.preview_pre_tool_use(&PreToolUseRequest { session_id: ThreadId::new(), turn_id: "turn-1".to_string(), + subagent: None, cwd, transcript_path: None, model: "gpt-test".to_string(), @@ -1186,6 +1191,7 @@ print(json.dumps({ let preview = engine.preview_pre_tool_use(&PreToolUseRequest { session_id: ThreadId::new(), turn_id: "turn-1".to_string(), + subagent: None, cwd: cwd(), transcript_path: None, model: "gpt-test".to_string(), @@ -1217,6 +1223,7 @@ print(json.dumps({ .run_pre_tool_use(PreToolUseRequest { session_id: ThreadId::new(), turn_id: "turn-1".to_string(), + subagent: None, cwd: cwd(), transcript_path: None, model: "gpt-test".to_string(), diff --git a/codex-rs/hooks/src/events/common.rs b/codex-rs/hooks/src/events/common.rs index c97129ebef11..997eac139f4c 100644 --- a/codex-rs/hooks/src/events/common.rs +++ b/codex-rs/hooks/src/events/common.rs @@ -8,6 +8,13 @@ use codex_protocol::protocol::HookRunSummary; use crate::engine::ConfiguredHandler; use crate::engine::dispatcher; +/// Identifies a thread-spawned subagent when a normal hook runs inside it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SubagentHookContext { + pub agent_id: String, + pub agent_type: String, +} + pub(crate) fn join_text_chunks(chunks: Vec) -> Option { if chunks.is_empty() { None diff --git a/codex-rs/hooks/src/events/compact.rs b/codex-rs/hooks/src/events/compact.rs index 469fdda232f7..cb3080219a56 100644 --- a/codex-rs/hooks/src/events/compact.rs +++ b/codex-rs/hooks/src/events/compact.rs @@ -17,11 +17,13 @@ use crate::engine::dispatcher; use crate::engine::output_parser; use crate::schema::PostCompactCommandInput; use crate::schema::PreCompactCommandInput; +use crate::schema::SubagentCommandInputFields; #[derive(Debug, Clone)] pub struct PreCompactRequest { pub session_id: ThreadId, pub turn_id: String, + pub subagent: Option, pub cwd: AbsolutePathBuf, pub transcript_path: Option, pub model: String, @@ -32,6 +34,7 @@ pub struct PreCompactRequest { pub struct PostCompactRequest { pub session_id: ThreadId, pub turn_id: String, + pub subagent: Option, pub cwd: AbsolutePathBuf, pub transcript_path: Option, pub model: String, @@ -120,9 +123,12 @@ pub(crate) async fn run_pre( } fn pre_command_input_json(request: &PreCompactRequest) -> Result { + let subagent = SubagentCommandInputFields::from(request.subagent.as_ref()); serde_json::to_string(&PreCompactCommandInput { session_id: request.session_id.to_string(), turn_id: request.turn_id.clone(), + agent_id: subagent.agent_id, + agent_type: subagent.agent_type, transcript_path: crate::schema::NullableString::from_path(request.transcript_path.clone()), cwd: request.cwd.display().to_string(), hook_event_name: "PreCompact".to_string(), @@ -199,9 +205,12 @@ pub(crate) async fn run_post( } fn post_command_input_json(request: &PostCompactRequest) -> Result { + let subagent = SubagentCommandInputFields::from(request.subagent.as_ref()); serde_json::to_string(&PostCompactCommandInput { session_id: request.session_id.to_string(), turn_id: request.turn_id.clone(), + agent_id: subagent.agent_id, + agent_type: subagent.agent_type, transcript_path: crate::schema::NullableString::from_path(request.transcript_path.clone()), cwd: request.cwd.display().to_string(), hook_event_name: "PostCompact".to_string(), @@ -563,6 +572,7 @@ mod tests { session_id: ThreadId::from_string("00000000-0000-4000-8000-000000000001") .expect("valid thread id"), turn_id: "turn-1".to_string(), + subagent: None, cwd: test_path_buf("/tmp").abs(), transcript_path: None, model: "gpt-test".to_string(), @@ -575,6 +585,7 @@ mod tests { session_id: ThreadId::from_string("00000000-0000-4000-8000-000000000002") .expect("valid thread id"), turn_id: "turn-1".to_string(), + subagent: None, cwd: test_path_buf("/tmp").abs(), transcript_path: None, model: "gpt-test".to_string(), diff --git a/codex-rs/hooks/src/events/permission_request.rs b/codex-rs/hooks/src/events/permission_request.rs index 79d06082369a..db7970f02dab 100644 --- a/codex-rs/hooks/src/events/permission_request.rs +++ b/codex-rs/hooks/src/events/permission_request.rs @@ -22,6 +22,7 @@ use crate::engine::command_runner::CommandRunResult; use crate::engine::dispatcher; use crate::engine::output_parser; use crate::schema::PermissionRequestCommandInput; +use crate::schema::SubagentCommandInputFields; use codex_protocol::ThreadId; use codex_protocol::protocol::HookCompletedEvent; use codex_protocol::protocol::HookEventName; @@ -35,6 +36,7 @@ use serde_json::Value; pub struct PermissionRequestRequest { pub session_id: ThreadId, pub turn_id: String, + pub subagent: Option, pub cwd: PathBuf, pub transcript_path: Option, pub model: String, @@ -168,9 +170,12 @@ fn resolve_permission_request_decision<'a>( } fn build_command_input(request: &PermissionRequestRequest) -> PermissionRequestCommandInput { + let subagent = SubagentCommandInputFields::from(request.subagent.as_ref()); PermissionRequestCommandInput { session_id: request.session_id.to_string(), turn_id: request.turn_id.clone(), + agent_id: subagent.agent_id, + agent_type: subagent.agent_type, transcript_path: crate::schema::NullableString::from_path(request.transcript_path.clone()), cwd: request.cwd.display().to_string(), hook_event_name: "PermissionRequest".to_string(), diff --git a/codex-rs/hooks/src/events/post_tool_use.rs b/codex-rs/hooks/src/events/post_tool_use.rs index 801c5f09e9c2..f096de011098 100644 --- a/codex-rs/hooks/src/events/post_tool_use.rs +++ b/codex-rs/hooks/src/events/post_tool_use.rs @@ -17,11 +17,13 @@ use crate::engine::command_runner::CommandRunResult; use crate::engine::dispatcher; use crate::engine::output_parser; use crate::schema::PostToolUseCommandInput; +use crate::schema::SubagentCommandInputFields; #[derive(Debug, Clone)] pub struct PostToolUseRequest { pub session_id: ThreadId, pub turn_id: String, + pub subagent: Option, pub cwd: AbsolutePathBuf, pub transcript_path: Option, pub model: String, @@ -148,9 +150,12 @@ pub(crate) async fn run( /// events across processes. Shell-like tools pass `{ "command": ... }` as /// `tool_input`; MCP tools pass their resolved JSON arguments. fn command_input_json(request: &PostToolUseRequest) -> Result { + let subagent = SubagentCommandInputFields::from(request.subagent.as_ref()); serde_json::to_string(&PostToolUseCommandInput { session_id: request.session_id.to_string(), turn_id: request.turn_id.clone(), + agent_id: subagent.agent_id, + agent_type: subagent.agent_type, transcript_path: crate::schema::NullableString::from_path(request.transcript_path.clone()), cwd: request.cwd.display().to_string(), hook_event_name: "PostToolUse".to_string(), @@ -571,6 +576,7 @@ mod tests { super::PostToolUseRequest { session_id: ThreadId::new(), turn_id: "turn-1".to_string(), + subagent: None, cwd: test_path_buf("/tmp").abs(), transcript_path: None, model: "gpt-test".to_string(), diff --git a/codex-rs/hooks/src/events/pre_tool_use.rs b/codex-rs/hooks/src/events/pre_tool_use.rs index b21daf063b86..b3579aba824e 100644 --- a/codex-rs/hooks/src/events/pre_tool_use.rs +++ b/codex-rs/hooks/src/events/pre_tool_use.rs @@ -17,11 +17,13 @@ use crate::engine::command_runner::CommandRunResult; use crate::engine::dispatcher; use crate::engine::output_parser; use crate::schema::PreToolUseCommandInput; +use crate::schema::SubagentCommandInputFields; #[derive(Debug, Clone)] pub struct PreToolUseRequest { pub session_id: ThreadId, pub turn_id: String, + pub subagent: Option, pub cwd: AbsolutePathBuf, pub transcript_path: Option, pub model: String, @@ -166,9 +168,12 @@ fn latest_updated_input( /// stable. Shell-like tools pass `{ "command": ... }` as `tool_input`; MCP /// tools pass their resolved JSON arguments. fn command_input_json(request: &PreToolUseRequest) -> Result { + let subagent = SubagentCommandInputFields::from(request.subagent.as_ref()); serde_json::to_string(&PreToolUseCommandInput { session_id: request.session_id.to_string(), turn_id: request.turn_id.clone(), + agent_id: subagent.agent_id, + agent_type: subagent.agent_type, transcript_path: crate::schema::NullableString::from_path(request.transcript_path.clone()), cwd: request.cwd.display().to_string(), hook_event_name: "PreToolUse".to_string(), @@ -763,6 +768,7 @@ mod tests { super::PreToolUseRequest { session_id: ThreadId::new(), turn_id: "turn-1".to_string(), + subagent: None, cwd: test_path_buf("/tmp").abs(), transcript_path: None, model: "gpt-test".to_string(), diff --git a/codex-rs/hooks/src/events/user_prompt_submit.rs b/codex-rs/hooks/src/events/user_prompt_submit.rs index eb152a1f48e8..2934bd352397 100644 --- a/codex-rs/hooks/src/events/user_prompt_submit.rs +++ b/codex-rs/hooks/src/events/user_prompt_submit.rs @@ -16,12 +16,14 @@ use crate::engine::command_runner::CommandRunResult; use crate::engine::dispatcher; use crate::engine::output_parser; use crate::schema::NullableString; +use crate::schema::SubagentCommandInputFields; use crate::schema::UserPromptSubmitCommandInput; #[derive(Debug, Clone)] pub struct UserPromptSubmitRequest { pub session_id: ThreadId, pub turn_id: String, + pub subagent: Option, pub cwd: AbsolutePathBuf, pub transcript_path: Option, pub model: String, @@ -77,9 +79,12 @@ pub(crate) async fn run( }; } + let subagent = SubagentCommandInputFields::from(request.subagent.as_ref()); let input_json = match serde_json::to_string(&UserPromptSubmitCommandInput { session_id: request.session_id.to_string(), turn_id: request.turn_id.clone(), + agent_id: subagent.agent_id, + agent_type: subagent.agent_type, transcript_path: NullableString::from_path(request.transcript_path.clone()), cwd: request.cwd.display().to_string(), hook_event_name: "UserPromptSubmit".to_string(), diff --git a/codex-rs/hooks/src/lib.rs b/codex-rs/hooks/src/lib.rs index 70ee3b454053..11300802d86e 100644 --- a/codex-rs/hooks/src/lib.rs +++ b/codex-rs/hooks/src/lib.rs @@ -14,6 +14,7 @@ pub use config_rules::hook_states_from_stack; pub use declarations::PluginHookDeclaration; pub use declarations::plugin_hook_declarations; pub use engine::HookListEntry; +pub use events::common::SubagentHookContext; /// Hook event names as they appear in hooks JSON and config files. pub const HOOK_EVENT_NAMES: [&str; 10] = [ "PreToolUse", diff --git a/codex-rs/hooks/src/schema.rs b/codex-rs/hooks/src/schema.rs index bbed57d36d22..3f17986dec27 100644 --- a/codex-rs/hooks/src/schema.rs +++ b/codex-rs/hooks/src/schema.rs @@ -12,6 +12,8 @@ use serde_json::Value; use std::path::Path; use std::path::PathBuf; +use crate::events::common::SubagentHookContext; + const GENERATED_DIR: &str = "generated"; const POST_TOOL_USE_INPUT_FIXTURE: &str = "post-tool-use.command.input.schema.json"; const POST_TOOL_USE_OUTPUT_FIXTURE: &str = "post-tool-use.command.output.schema.json"; @@ -61,6 +63,24 @@ impl JsonSchema for NullableString { } } +#[derive(Debug, Clone, Default)] +pub(crate) struct SubagentCommandInputFields { + pub agent_id: Option, + pub agent_type: Option, +} + +impl From> for SubagentCommandInputFields { + fn from(value: Option<&SubagentHookContext>) -> Self { + match value { + Some(context) => Self { + agent_id: Some(context.agent_id.clone()), + agent_type: Some(context.agent_type.clone()), + }, + None => Self::default(), + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] #[serde(deny_unknown_fields)] @@ -251,6 +271,10 @@ pub(crate) struct PreToolUseCommandInput { pub session_id: String, /// Codex extension: expose the active turn id to internal turn-scoped hooks. pub turn_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_type: Option, pub transcript_path: NullableString, pub cwd: String, #[schemars(schema_with = "pre_tool_use_hook_event_name_schema")] @@ -270,6 +294,10 @@ pub(crate) struct PermissionRequestCommandInput { pub session_id: String, /// Codex extension: expose the active turn id to internal turn-scoped hooks. pub turn_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_type: Option, pub transcript_path: NullableString, pub cwd: String, #[schemars(schema_with = "permission_request_hook_event_name_schema")] @@ -288,6 +316,10 @@ pub(crate) struct PostToolUseCommandInput { pub session_id: String, /// Codex extension: expose the active turn id to internal turn-scoped hooks. pub turn_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_type: Option, pub transcript_path: NullableString, pub cwd: String, #[schemars(schema_with = "post_tool_use_hook_event_name_schema")] @@ -308,6 +340,10 @@ pub(crate) struct PreCompactCommandInput { pub session_id: String, /// Codex extension: expose the active turn id to internal turn-scoped hooks. pub turn_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_type: Option, pub transcript_path: NullableString, pub cwd: String, #[schemars(schema_with = "pre_compact_hook_event_name_schema")] @@ -324,6 +360,10 @@ pub(crate) struct PostCompactCommandInput { pub session_id: String, /// Codex extension: expose the active turn id to internal turn-scoped hooks. pub turn_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_type: Option, pub transcript_path: NullableString, pub cwd: String, #[schemars(schema_with = "post_compact_hook_event_name_schema")] @@ -486,6 +526,10 @@ pub(crate) struct UserPromptSubmitCommandInput { pub session_id: String, /// Codex extension: expose the active turn id to internal turn-scoped hooks. pub turn_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_type: Option, pub transcript_path: NullableString, pub cwd: String, #[schemars(schema_with = "user_prompt_submit_hook_event_name_schema")] @@ -761,6 +805,7 @@ fn default_continue() -> bool { #[cfg(test)] mod tests { + use super::NullableString; use super::PERMISSION_REQUEST_INPUT_FIXTURE; use super::PERMISSION_REQUEST_OUTPUT_FIXTURE; use super::POST_COMPACT_INPUT_FIXTURE; @@ -785,6 +830,7 @@ mod tests { use super::SUBAGENT_STOP_INPUT_FIXTURE; use super::SUBAGENT_STOP_OUTPUT_FIXTURE; use super::StopCommandInput; + use super::SubagentCommandInputFields; use super::SubagentStartCommandInput; use super::SubagentStopCommandInput; use super::USER_PROMPT_SUBMIT_INPUT_FIXTURE; @@ -792,8 +838,10 @@ mod tests { use super::UserPromptSubmitCommandInput; use super::schema_json; use super::write_schema_fixtures; + use crate::events::common::SubagentHookContext; use pretty_assertions::assert_eq; use serde_json::Value; + use serde_json::json; use tempfile::TempDir; fn expected_fixture(name: &str) -> &'static str { @@ -968,4 +1016,87 @@ mod tests { ); } } + + #[test] + fn subagent_context_fields_are_optional_for_hooks_that_run_inside_subagents() { + let schemas = [ + schema_json::().expect("serialize pre tool use input schema"), + schema_json::() + .expect("serialize permission request input schema"), + schema_json::().expect("serialize post tool use input schema"), + schema_json::().expect("serialize pre compact input schema"), + schema_json::().expect("serialize post compact input schema"), + schema_json::() + .expect("serialize user prompt submit input schema"), + ]; + + for schema in schemas { + let schema: Value = serde_json::from_slice(&schema).expect("parse hook input schema"); + assert_eq!(schema["properties"]["agent_id"]["type"], "string"); + assert_eq!(schema["properties"]["agent_type"]["type"], "string"); + let required = schema["required"] + .as_array() + .expect("schema required fields"); + assert!(!required.contains(&Value::String("agent_id".to_string()))); + assert!(!required.contains(&Value::String("agent_type".to_string()))); + } + } + + #[test] + fn subagent_context_fields_serialize_flat_and_omit_when_absent() { + let subagent = SubagentCommandInputFields::from(Some(&SubagentHookContext { + agent_id: "agent-1".to_string(), + agent_type: "worker".to_string(), + })); + let input = PreToolUseCommandInput { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + agent_id: subagent.agent_id, + agent_type: subagent.agent_type, + transcript_path: NullableString::from_path(/*path*/ None), + cwd: "/tmp".to_string(), + hook_event_name: "PreToolUse".to_string(), + model: "gpt-test".to_string(), + permission_mode: "default".to_string(), + tool_name: "Bash".to_string(), + tool_input: json!({ "command": "echo hello" }), + tool_use_id: "tool-1".to_string(), + }; + + assert_eq!( + serde_json::to_value(input).expect("serialize subagent hook input"), + json!({ + "session_id": "session-1", + "turn_id": "turn-1", + "agent_id": "agent-1", + "agent_type": "worker", + "transcript_path": null, + "cwd": "/tmp", + "hook_event_name": "PreToolUse", + "model": "gpt-test", + "permission_mode": "default", + "tool_name": "Bash", + "tool_input": { "command": "echo hello" }, + "tool_use_id": "tool-1", + }) + ); + + let root_input = PreToolUseCommandInput { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + agent_id: None, + agent_type: None, + transcript_path: NullableString::from_path(/*path*/ None), + cwd: "/tmp".to_string(), + hook_event_name: "PreToolUse".to_string(), + model: "gpt-test".to_string(), + permission_mode: "default".to_string(), + tool_name: "Bash".to_string(), + tool_input: json!({ "command": "echo hello" }), + tool_use_id: "tool-1".to_string(), + }; + let root_input = serde_json::to_value(root_input).expect("serialize root hook input"); + assert_eq!(root_input.get("agent_id"), None); + assert_eq!(root_input.get("agent_type"), None); + } } From e8378c7f0c65f5fa38096fe7c9a19ebac259e58b Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 21 May 2026 16:03:11 -0700 Subject: [PATCH 21/64] [3 of 4] tui: route feature and memory toggles through app server (#22915) ## Why Experimental feature toggles and memory settings can update several related config values in one interaction. Keeping those writes local in a remote TUI session is especially dangerous because the UI can diverge from the app-server config while also leaving behind partially stale supporting keys. This is **[3 of 4]** in a stacked series that moves TUI-owned config mutations onto app-server APIs. ## What changed - Routed feature flag persistence through app-server batch writes, including the supporting reviewer and permission updates used by guardian approval. - Routed Windows sandbox mode persistence and legacy Windows feature cleanup through app-server writes. - Routed memory settings through app-server batch writes and updated the TUI tests to exercise the embedded app-server path. ## Config keys affected - `features.` - `profiles..features.` - `approval_policy` - `sandbox_mode` - `approvals_reviewer` - `windows.sandbox` - `features.experimental_windows_sandbox` - `features.elevated_windows_sandbox` - `features.enable_experimental_windows_sandbox` - Profile-scoped Windows legacy feature variants under `profiles..features.*` - `memories.use_memories` - `memories.generate_memories` - Profile-scoped memory variants under `profiles..memories.*` ## Suggested manual validation - Connect the TUI to a remote app server, toggle guardian approval on and off, and confirm the remote config updates `features.guardian_approval`, reviewer state, approval policy, and sandbox mode coherently. - Toggle a default-false experimental feature at the root level, disable it again, and confirm the key clears instead of lingering as an unnecessary explicit `false`. - Change memory settings and confirm the remote config updates both memory keys while the running TUI reflects the new state. - On Windows, switch sandbox mode through the TUI and confirm `windows.sandbox` is updated while the legacy Windows feature keys are cleared. ## Stack 1. [#22913](https://github.com/openai/codex/pull/22913) `[1 of 4]` primary settings writes 2. [#22914](https://github.com/openai/codex/pull/22914) `[2 of 4]` app and skill enablement 3. [#22915](https://github.com/openai/codex/pull/22915) `[3 of 4]` feature and memory toggles 4. [#22916](https://github.com/openai/codex/pull/22916) `[4 of 4]` startup and onboarding bookkeeping --- codex-rs/tui/src/app.rs | 8 +- codex-rs/tui/src/app/config_persistence.rs | 568 ++++++++++++++++++--- codex-rs/tui/src/app/event_dispatch.rs | 33 +- codex-rs/tui/src/app/tests.rs | 31 +- codex-rs/tui/src/config_update.rs | 79 ++- 5 files changed, 609 insertions(+), 110 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 8d1797e72478..c00974967829 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -50,7 +50,6 @@ use crate::legacy_core::config::Config; use crate::legacy_core::config::ConfigBuilder; use crate::legacy_core::config::ConfigOverrides; use crate::legacy_core::config::PermissionProfileSnapshot; -use crate::legacy_core::config::edit::ConfigEdit; use crate::legacy_core::config::edit::ConfigEditsBuilder; #[cfg(target_os = "windows")] use crate::legacy_core::windows_sandbox::WindowsSandboxLevelExt; @@ -91,6 +90,7 @@ use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; use codex_app_server_protocol::ConfigBatchWriteParams; use codex_app_server_protocol::ConfigLayerSource; +use codex_app_server_protocol::ConfigReadResponse; use codex_app_server_protocol::ConfigValueWriteParams; use codex_app_server_protocol::ConfigWriteResponse; use codex_app_server_protocol::FeedbackUploadParams; @@ -113,6 +113,7 @@ use codex_app_server_protocol::PluginReadResponse; use codex_app_server_protocol::PluginUninstallParams; use codex_app_server_protocol::PluginUninstallResponse; use codex_app_server_protocol::RateLimitSnapshot; +use codex_app_server_protocol::SandboxMode as AppServerSandboxMode; use codex_app_server_protocol::SendAddCreditsNudgeEmailParams; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; @@ -127,12 +128,17 @@ use codex_app_server_protocol::ThreadStartSource; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnError as AppServerTurnError; use codex_app_server_protocol::TurnStatus; +use codex_app_server_protocol::WriteStatus; use codex_config::ConfigLayerStackOrdering; use codex_config::LoaderOverrides; use codex_config::types::ApprovalsReviewer; +use codex_config::types::MemoriesToml; use codex_config::types::ModelAvailabilityNuxConfig; +#[cfg(target_os = "windows")] +use codex_config::types::WindowsToml; use codex_exec_server::EnvironmentManager; use codex_features::Feature; +use codex_features::FeaturesToml; use codex_model_provider::create_model_provider; use codex_model_provider_info::ModelProviderInfo; use codex_models_manager::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG; diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index d4fdebad89df..4baea7fb04ee 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -198,6 +198,28 @@ impl App { } } + pub(super) async fn read_effective_config_after_overridden_write( + &mut self, + app_server: &mut AppServerSession, + setting: &str, + ) -> Option { + let cwd = self.chat_widget.config_ref().cwd.display().to_string(); + match crate::config_update::read_effective_config(app_server.request_handle(), cwd).await { + Ok(response) => Some(response), + Err(err) => { + tracing::warn!( + error = %err, + setting, + "failed to refresh effective config after an overridden write" + ); + self.chat_widget.add_error_message(format!( + "{setting} were saved, but Codex could not refresh the effective config: {err}" + )); + None + } + } + } + pub(super) async fn rebuild_config_for_resume_or_fallback( &mut self, current_cwd: &Path, @@ -311,7 +333,11 @@ impl App { Some(permission_profile) } - pub(super) async fn update_feature_flags(&mut self, updates: Vec<(Feature, bool)>) { + pub(super) async fn update_feature_flags( + &mut self, + app_server: &mut AppServerSession, + updates: Vec<(Feature, bool)>, + ) { if updates.is_empty() { return; } @@ -319,13 +345,6 @@ impl App { let auto_review_preset = auto_review_mode(); let mut next_config = self.config.clone(); let active_profile = self.active_profile.clone(); - let scoped_segments = |key: &str| { - if let Some(profile) = active_profile.as_deref() { - vec!["profiles".to_string(), profile.to_string(), key.to_string()] - } else { - vec![key.to_string()] - } - }; let windows_sandbox_changed = updates.iter().any(|(feature, _)| { matches!( feature, @@ -358,8 +377,7 @@ impl App { (root_blocks_disable, profile_configured) }; let mut permissions_history_label: Option<&'static str> = None; - let mut builder = ConfigEditsBuilder::for_config(&self.config) - .with_profile(self.active_profile.as_deref()); + let mut config_edits = Vec::new(); for (feature, enabled) in updates { let feature_key = feature.key(); @@ -394,18 +412,24 @@ impl App { // experiment's matching `/permissions` mode until the user // changes it explicitly. feature_config.approvals_reviewer = auto_review_preset.approvals_reviewer; - feature_edits.push(ConfigEdit::SetPath { - segments: scoped_segments("approvals_reviewer"), - value: auto_review_preset.approvals_reviewer.to_string().into(), - }); + feature_edits.push(crate::config_update::replace_config_value( + crate::config_update::profile_scoped_key_path( + active_profile.as_deref(), + "approvals_reviewer", + ), + serde_json::json!(auto_review_preset.approvals_reviewer.to_string()), + )); if previous_approvals_reviewer != auto_review_preset.approvals_reviewer { permissions_history_label = Some("Auto-review"); } } else if !effective_enabled { if profile_approvals_reviewer_configured || self.active_profile.is_none() { - feature_edits.push(ConfigEdit::ClearPath { - segments: scoped_segments("approvals_reviewer"), - }); + feature_edits.push(crate::config_update::clear_config_value( + crate::config_update::profile_scoped_key_path( + active_profile.as_deref(), + "approvals_reviewer", + ), + )); } feature_config.approvals_reviewer = ApprovalsReviewer::User; if previous_approvals_reviewer != ApprovalsReviewer::User { @@ -438,14 +462,20 @@ impl App { continue; }; feature_edits.extend([ - ConfigEdit::SetPath { - segments: scoped_segments("approval_policy"), - value: "on-request".into(), - }, - ConfigEdit::SetPath { - segments: scoped_segments("sandbox_mode"), - value: "workspace-write".into(), - }, + crate::config_update::replace_config_value( + crate::config_update::profile_scoped_key_path( + active_profile.as_deref(), + "approval_policy", + ), + serde_json::json!("on-request"), + ), + crate::config_update::replace_config_value( + crate::config_update::profile_scoped_key_path( + active_profile.as_deref(), + "sandbox_mode", + ), + serde_json::json!("workspace-write"), + ), ]); approval_policy_override = Some(auto_review_preset.approval_policy); permission_profile_override = Some(permission_profile); @@ -454,18 +484,60 @@ impl App { } next_config = feature_config; feature_updates_to_apply.push((feature, effective_enabled)); - builder = builder - .with_edits(feature_edits) - .set_feature_enabled(feature_key, effective_enabled); + config_edits.extend(feature_edits); + config_edits.push(crate::config_update::build_feature_enabled_edit( + active_profile.as_deref(), + feature_key, + effective_enabled, + )); } // Persist first so the live session does not diverge from disk if the // config edit fails. Runtime/UI state is patched below only after the // durable config update succeeds. - if let Err(err) = builder.apply().await { - tracing::error!(error = %err, "failed to persist feature flags"); - self.chat_widget - .add_error_message(format!("Failed to update experimental features: {err}")); + let write_response = match crate::config_update::write_config_batch( + app_server.request_handle(), + config_edits, + ) + .await + { + Ok(response) => response, + Err(err) => { + tracing::error!(error = %err, "failed to persist feature flags"); + self.chat_widget + .add_error_message(format!("Failed to update experimental features: {err}")); + return; + } + }; + if write_response.status == WriteStatus::OkOverridden { + let message = overridden_write_message(&write_response); + tracing::warn!( + message, + "feature flag config write was overridden by effective config" + ); + self.chat_widget.add_error_message(format!( + "Experimental feature changes were saved but not applied: {message}" + )); + if let Some(effective_config) = self + .read_effective_config_after_overridden_write( + app_server, + "Experimental feature changes", + ) + .await + { + self.sync_feature_state_from_effective_config( + &effective_config, + &feature_updates_to_apply, + ); + self.sync_auto_review_runtime_state_from_effective_config( + &effective_config, + &feature_updates_to_apply, + ) + .await; + if windows_sandbox_changed { + self.propagate_windows_sandbox_turn_context(); + } + } return; } @@ -550,26 +622,7 @@ impl App { } if windows_sandbox_changed { - #[cfg(target_os = "windows")] - { - let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config); - self.app_event_tx - .send(AppEvent::CodexOp(AppCommand::override_turn_context( - /*cwd*/ None, - /*approval_policy*/ None, - /*approvals_reviewer*/ None, - /*permission_profile*/ None, - /*active_permission_profile*/ None, - #[cfg(target_os = "windows")] - Some(windows_sandbox_level), - /*model*/ None, - /*effort*/ None, - /*summary*/ None, - /*service_tier*/ None, - /*collaboration_mode*/ None, - /*personality*/ None, - ))); - } + self.propagate_windows_sandbox_turn_context(); } if let Some(label) = permissions_history_label { @@ -582,42 +635,43 @@ impl App { pub(super) async fn update_memory_settings( &mut self, + app_server: &mut AppServerSession, use_memories: bool, generate_memories: bool, ) -> bool { - let active_profile = self.active_profile.clone(); - let scoped_memory_segments = |key: &str| { - if let Some(profile) = active_profile.as_deref() { - vec![ - "profiles".to_string(), - profile.to_string(), - "memories".to_string(), - key.to_string(), - ] - } else { - vec!["memories".to_string(), key.to_string()] + let edits = + crate::config_update::build_memory_settings_edits(use_memories, generate_memories); + + let write_response = match crate::config_update::write_config_batch( + app_server.request_handle(), + edits, + ) + .await + { + Ok(response) => response, + Err(err) => { + tracing::error!(error = %err, "failed to persist memory settings"); + self.chat_widget + .add_error_message(format!("Failed to save memory settings: {err}")); + return false; } }; - let edits = [ - ConfigEdit::SetPath { - segments: scoped_memory_segments("use_memories"), - value: use_memories.into(), - }, - ConfigEdit::SetPath { - segments: scoped_memory_segments("generate_memories"), - value: generate_memories.into(), - }, - ]; - - if let Err(err) = ConfigEditsBuilder::for_config(&self.config) - .with_edits(edits) - .apply() - .await - { - tracing::error!(error = %err, "failed to persist memory settings"); - self.chat_widget - .add_error_message(format!("Failed to save memory settings: {err}")); - return false; + if write_response.status == WriteStatus::OkOverridden { + let message = overridden_write_message(&write_response); + tracing::warn!( + message, + "memory settings config write was overridden by effective config" + ); + self.chat_widget.add_error_message(format!( + "Memory setting changes were saved but not applied: {message}" + )); + let Some(effective_config) = self + .read_effective_config_after_overridden_write(app_server, "Memory setting changes") + .await + else { + return false; + }; + return self.sync_memory_state_from_effective_config(&effective_config); } self.config.memories.use_memories = use_memories; @@ -635,12 +689,13 @@ impl App { ) { let previous_generate_memories = self.config.memories.generate_memories; if !self - .update_memory_settings(use_memories, generate_memories) + .update_memory_settings(app_server, use_memories, generate_memories) .await { return; } + let generate_memories = self.config.memories.generate_memories; if previous_generate_memories == generate_memories { return; } @@ -754,6 +809,311 @@ impl App { Personality::Pragmatic => "Pragmatic", } } + + fn sync_feature_state_from_effective_config( + &mut self, + effective_config: &ConfigReadResponse, + feature_updates: &[(Feature, bool)], + ) { + let active_profile = self.active_profile.clone(); + let active_profile = active_profile.as_deref(); + for (feature, _) in feature_updates { + let enabled = + feature_enabled_from_effective_config(effective_config, active_profile, *feature); + if let Err(err) = self.config.features.set_enabled(*feature, enabled) { + tracing::warn!( + error = %err, + feature = feature.key(), + "failed to sync effective feature state after an overridden write" + ); + continue; + } + self.chat_widget.set_feature_enabled(*feature, enabled); + } + + if feature_updates + .iter() + .any(|(feature, _)| *feature == Feature::GuardianApproval) + && !self.config.features.enabled(Feature::GuardianApproval) + { + self.set_approvals_reviewer_in_app_and_widget(ApprovalsReviewer::User); + return; + } + + if let Some(reviewer) = + approvals_reviewer_from_effective_config(effective_config, active_profile) + { + self.set_approvals_reviewer_in_app_and_widget(reviewer); + } + if let Some(policy) = + approval_policy_from_effective_config(effective_config, active_profile) + { + if let Err(err) = self + .config + .permissions + .approval_policy + .set(policy.to_core()) + { + tracing::warn!( + error = %err, + "failed to sync effective approval policy after an overridden write" + ); + self.chat_widget.add_error_message(format!( + "Failed to refresh overridden Auto-review settings: {err}" + )); + } else { + self.chat_widget.set_approval_policy(policy); + } + } + } + + async fn sync_auto_review_runtime_state_from_effective_config( + &mut self, + effective_config: &ConfigReadResponse, + feature_updates: &[(Feature, bool)], + ) { + if !feature_updates + .iter() + .any(|(feature, _)| *feature == Feature::GuardianApproval) + || !self.config.features.enabled(Feature::GuardianApproval) + || sandbox_mode_from_effective_config(effective_config, self.active_profile.as_deref()) + != Some(AppServerSandboxMode::WorkspaceWrite) + { + return; + } + + let auto_review_preset = auto_review_mode(); + let mut config = self.config.clone(); + let Some(permission_profile) = self.try_set_builtin_active_permission_profile_on_config( + &mut config, + auto_review_preset.active_permission_profile.clone(), + "Failed to refresh overridden Auto-review settings", + "failed to sync overridden Auto-review permission profile", + ) else { + return; + }; + self.config = config; + if let Err(err) = self + .chat_widget + .set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::active( + permission_profile.clone(), + auto_review_preset.active_permission_profile.clone(), + )) + { + tracing::warn!( + error = %err, + "failed to sync overridden Auto-review permission profile on chat config" + ); + self.chat_widget.add_error_message(format!( + "Failed to refresh overridden Auto-review settings: {err}" + )); + return; + } + + self.runtime_permission_profile_override = Some(permission_profile); + self.sync_active_thread_permission_settings_to_cached_session() + .await; + + let approval_policy = AskForApproval::from(self.config.permissions.approval_policy.value()); + let op = AppCommand::override_turn_context( + /*cwd*/ None, + Some(approval_policy), + Some(self.config.approvals_reviewer), + /*permission_profile*/ None, + Some(auto_review_preset.active_permission_profile), + /*windows_sandbox_level*/ None, + /*model*/ None, + /*effort*/ None, + /*summary*/ None, + /*service_tier*/ None, + /*collaboration_mode*/ None, + /*personality*/ None, + ); + let replay_state_op = + ThreadEventStore::op_can_change_pending_replay_state(&op).then(|| op.clone()); + let submitted = self.chat_widget.submit_op(op); + if submitted && let Some(op) = replay_state_op.as_ref() { + self.note_active_thread_outbound_op(op).await; + self.refresh_pending_thread_approvals().await; + } + } + + fn sync_memory_state_from_effective_config( + &mut self, + effective_config: &ConfigReadResponse, + ) -> bool { + let Some(memories) = memories_from_effective_config(effective_config) else { + tracing::warn!( + "config/read omitted memories after an overridden memory settings write" + ); + return false; + }; + let use_memories = memories + .use_memories + .unwrap_or(self.config.memories.use_memories); + let generate_memories = memories + .generate_memories + .unwrap_or(self.config.memories.generate_memories); + self.config.memories.use_memories = use_memories; + self.config.memories.generate_memories = generate_memories; + self.chat_widget + .set_memory_settings(use_memories, generate_memories); + true + } + + #[cfg(target_os = "windows")] + pub(super) async fn sync_windows_sandbox_after_overridden_write( + &mut self, + app_server: &mut AppServerSession, + write_response: &ConfigWriteResponse, + ) { + let message = overridden_write_message(write_response); + tracing::warn!( + message, + "Windows sandbox config write was overridden by effective config" + ); + self.chat_widget.add_error_message(format!( + "Windows sandbox changes were saved but not applied: {message}" + )); + let Some(effective_config) = self + .read_effective_config_after_overridden_write(app_server, "Windows sandbox changes") + .await + else { + return; + }; + let Some(mode) = windows_sandbox_mode_from_effective_config( + &effective_config, + self.active_profile.as_deref(), + ) else { + return; + }; + self.config.permissions.windows_sandbox_mode = Some(mode); + self.chat_widget.set_windows_sandbox_mode(Some(mode)); + self.propagate_windows_sandbox_turn_context(); + } + + fn propagate_windows_sandbox_turn_context(&self) { + #[cfg(target_os = "windows")] + { + let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config); + self.app_event_tx + .send(AppEvent::CodexOp(AppCommand::override_turn_context( + /*cwd*/ None, + /*approval_policy*/ None, + /*approvals_reviewer*/ None, + /*permission_profile*/ None, + /*active_permission_profile*/ None, + Some(windows_sandbox_level), + /*model*/ None, + /*effort*/ None, + /*summary*/ None, + /*service_tier*/ None, + /*collaboration_mode*/ None, + /*personality*/ None, + ))); + } + } +} + +fn overridden_write_message(write_response: &ConfigWriteResponse) -> &str { + write_response + .overridden_metadata + .as_ref() + .map(|metadata| metadata.message.as_str()) + .unwrap_or("the effective config is overridden by a higher-priority layer") +} + +fn feature_enabled_from_effective_config( + effective_config: &ConfigReadResponse, + active_profile: Option<&str>, + feature: Feature, +) -> bool { + let profile_features = active_profile + .and_then(|profile| effective_config.config.profiles.get(profile)) + .and_then(|profile| profile.additional.get("features")) + .and_then(features_toml_from_json); + let root_features = effective_config + .config + .additional + .get("features") + .and_then(features_toml_from_json); + profile_features + .as_ref() + .and_then(|features| features.entries().get(feature.key()).copied()) + .or_else(|| { + root_features + .as_ref() + .and_then(|features| features.entries().get(feature.key()).copied()) + }) + .unwrap_or_else(|| feature.default_enabled()) +} + +fn approvals_reviewer_from_effective_config( + effective_config: &ConfigReadResponse, + active_profile: Option<&str>, +) -> Option { + active_profile + .and_then(|profile| effective_config.config.profiles.get(profile)) + .and_then(|profile| profile.approvals_reviewer) + .or(effective_config.config.approvals_reviewer) + .map(codex_app_server_protocol::ApprovalsReviewer::to_core) +} + +fn approval_policy_from_effective_config( + effective_config: &ConfigReadResponse, + active_profile: Option<&str>, +) -> Option { + active_profile + .and_then(|profile| effective_config.config.profiles.get(profile)) + .and_then(|profile| profile.approval_policy) + .or(effective_config.config.approval_policy) +} + +fn sandbox_mode_from_effective_config( + effective_config: &ConfigReadResponse, + active_profile: Option<&str>, +) -> Option { + active_profile + .and_then(|profile| effective_config.config.profiles.get(profile)) + .and_then(|profile| profile.additional.get("sandbox_mode")) + .and_then(|mode| serde_json::from_value(mode.clone()).ok()) + .or(effective_config.config.sandbox_mode) +} + +fn memories_from_effective_config(effective_config: &ConfigReadResponse) -> Option { + effective_config + .config + .additional + .get("memories") + .and_then(|memories| serde_json::from_value(memories.clone()).ok()) +} + +fn features_toml_from_json(value: &serde_json::Value) -> Option { + serde_json::from_value(value.clone()).ok() +} + +#[cfg(target_os = "windows")] +fn windows_sandbox_mode_from_effective_config( + effective_config: &ConfigReadResponse, + active_profile: Option<&str>, +) -> Option { + let profile_windows = active_profile + .and_then(|profile| effective_config.config.profiles.get(profile)) + .and_then(|profile| profile.additional.get("windows")) + .and_then(windows_toml_from_json); + let root_windows = effective_config + .config + .additional + .get("windows") + .and_then(windows_toml_from_json); + profile_windows + .and_then(|windows| windows.sandbox) + .or_else(|| root_windows.and_then(|windows| windows.sandbox)) +} + +#[cfg(target_os = "windows")] +fn windows_toml_from_json(value: &serde_json::Value) -> Option { + serde_json::from_value(value.clone()).ok() } #[cfg(test)] @@ -902,6 +1262,46 @@ terminal_resize_reflow_max_rows = 9000 Ok(()) } + #[tokio::test] + async fn overridden_disabled_guardian_does_not_apply_auto_review_companions() -> Result<()> { + let mut app = make_test_app().await; + let original_policy = app.config.permissions.approval_policy.value(); + let effective_config: ConfigReadResponse = serde_json::from_value(serde_json::json!({ + "config": { + "approval_policy": AskForApproval::OnRequest, + "approvals_reviewer": codex_app_server_protocol::ApprovalsReviewer::AutoReview, + "sandbox_mode": AppServerSandboxMode::WorkspaceWrite, + "features": { + "guardian_approval": false, + }, + }, + "origins": {}, + }))?; + + app.sync_feature_state_from_effective_config( + &effective_config, + &[(Feature::GuardianApproval, /*enabled*/ true)], + ); + + assert!(!app.config.features.enabled(Feature::GuardianApproval)); + assert!( + !app.chat_widget + .config_ref() + .features + .enabled(Feature::GuardianApproval) + ); + assert_eq!(app.config.approvals_reviewer, ApprovalsReviewer::User); + assert_eq!( + app.chat_widget.config_ref().approvals_reviewer, + ApprovalsReviewer::User + ); + assert_eq!( + app.config.permissions.approval_policy.value(), + original_policy + ); + Ok(()) + } + #[tokio::test] async fn rebuild_config_for_resume_or_fallback_uses_current_config_on_same_cwd_error() -> Result<()> { diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index ccc538b3d534..d103bb738d71 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -1160,16 +1160,21 @@ impl App { } let profile = self.active_profile.as_deref(); let elevated_enabled = matches!(mode, WindowsSandboxEnableMode::Elevated); - let builder = ConfigEditsBuilder::for_config(&self.config) - .with_profile(profile) - .set_windows_sandbox_mode(if elevated_enabled { - "elevated" - } else { - "unelevated" - }) - .clear_legacy_windows_sandbox_keys(); - match builder.apply().await { - Ok(()) => { + let edits = crate::config_update::build_windows_sandbox_mode_edits( + profile, + elevated_enabled, + ); + match crate::config_update::write_config_batch( + app_server.request_handle(), + edits, + ) + .await + { + Ok(response) if response.status == WriteStatus::OkOverridden => { + self.sync_windows_sandbox_after_overridden_write(app_server, &response) + .await; + } + Ok(_) => { if elevated_enabled { self.config.set_windows_sandbox_enabled(/*value*/ false); self.config @@ -1305,7 +1310,7 @@ impl App { ) .await { - Ok(()) => { + Ok(_) => { let effort_label = effort .map(|selected_effort| selected_effort.to_string()) .unwrap_or_else(|| "default".to_string()); @@ -1386,7 +1391,7 @@ impl App { ) .await { - Ok(()) => { + Ok(_) => { let label = Self::personality_label(personality); let mut message = format!("Personality set to {label}"); if let Some(profile) = profile { @@ -1426,7 +1431,7 @@ impl App { match crate::config_update::write_config_batch(app_server.request_handle(), edits) .await { - Ok(()) => { + Ok(_) => { let mut message = if let Some(service_tier) = service_tier { format!("Service tier set to {service_tier}") } else { @@ -1620,7 +1625,7 @@ impl App { } } AppEvent::UpdateFeatureFlags { updates } => { - self.update_feature_flags(updates).await; + self.update_feature_flags(app_server, updates).await; } AppEvent::UpdateMemorySettings { use_memories, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index c0050db28582..c12d44dd9913 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -1719,8 +1719,9 @@ async fn update_feature_flags_enabling_guardian_selects_auto_review() -> Result< let codex_home = tempdir()?; app.config.codex_home = codex_home.path().to_path_buf().abs(); let auto_review = auto_review_mode(); + let mut app_server = start_config_write_test_app_server(&app).await?; - app.update_feature_flags(vec![(Feature::GuardianApproval, true)]) + app.update_feature_flags(&mut app_server, vec![(Feature::GuardianApproval, true)]) .await; assert!(app.config.features.enabled(Feature::GuardianApproval)); @@ -1809,6 +1810,7 @@ async fn update_feature_flags_enabling_guardian_selects_auto_review() -> Result< assert!(config.contains("approvals_reviewer = \"guardian_subagent\"")); assert!(config.contains("approval_policy = \"on-request\"")); assert!(config.contains("sandbox_mode = \"workspace-write\"")); + app_server.shutdown().await?; Ok(()) } @@ -1847,8 +1849,9 @@ async fn update_feature_flags_disabling_guardian_clears_review_policy_and_restor .set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::legacy( PermissionProfile::workspace_write(), ))?; + let mut app_server = start_config_write_test_app_server(&app).await?; - app.update_feature_flags(vec![(Feature::GuardianApproval, false)]) + app.update_feature_flags(&mut app_server, vec![(Feature::GuardianApproval, false)]) .await; assert!(!app.config.features.enabled(Feature::GuardianApproval)); @@ -1902,6 +1905,7 @@ async fn update_feature_flags_disabling_guardian_clears_review_policy_and_restor assert!(!config.contains("approvals_reviewer =")); assert!(config.contains("approval_policy = \"on-request\"")); assert!(config.contains("sandbox_mode = \"workspace-write\"")); + app_server.shutdown().await?; Ok(()) } @@ -1923,8 +1927,9 @@ async fn update_feature_flags_enabling_guardian_overrides_explicit_manual_review app.config.approvals_reviewer = ApprovalsReviewer::User; app.chat_widget .set_approvals_reviewer(ApprovalsReviewer::User); + let mut app_server = start_config_write_test_app_server(&app).await?; - app.update_feature_flags(vec![(Feature::GuardianApproval, true)]) + app.update_feature_flags(&mut app_server, vec![(Feature::GuardianApproval, true)]) .await; assert!(app.config.features.enabled(Feature::GuardianApproval)); @@ -1970,6 +1975,7 @@ async fn update_feature_flags_enabling_guardian_overrides_explicit_manual_review assert!(config.contains("guardian_approval = true")); assert!(config.contains("approval_policy = \"on-request\"")); assert!(config.contains("sandbox_mode = \"workspace-write\"")); + app_server.shutdown().await?; Ok(()) } @@ -1995,8 +2001,9 @@ async fn update_feature_flags_disabling_guardian_clears_manual_review_policy_wit app.config.approvals_reviewer = ApprovalsReviewer::User; app.chat_widget .set_approvals_reviewer(ApprovalsReviewer::User); + let mut app_server = start_config_write_test_app_server(&app).await?; - app.update_feature_flags(vec![(Feature::GuardianApproval, false)]) + app.update_feature_flags(&mut app_server, vec![(Feature::GuardianApproval, false)]) .await; assert!(!app.config.features.enabled(Feature::GuardianApproval)); @@ -2030,6 +2037,7 @@ async fn update_feature_flags_disabling_guardian_clears_manual_review_policy_wit let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; assert!(!config.contains("guardian_approval = true")); assert!(!config.contains("approvals_reviewer =")); + app_server.shutdown().await?; Ok(()) } @@ -2052,8 +2060,9 @@ async fn update_feature_flags_enabling_guardian_in_profile_sets_profile_auto_rev app.config.approvals_reviewer = ApprovalsReviewer::User; app.chat_widget .set_approvals_reviewer(ApprovalsReviewer::User); + let mut app_server = start_config_write_test_app_server(&app).await?; - app.update_feature_flags(vec![(Feature::GuardianApproval, true)]) + app.update_feature_flags(&mut app_server, vec![(Feature::GuardianApproval, true)]) .await; assert!(app.config.features.enabled(Feature::GuardianApproval)); @@ -2102,6 +2111,7 @@ async fn update_feature_flags_enabling_guardian_in_profile_sets_profile_auto_rev profile_config.get("approvals_reviewer"), Some(&TomlValue::String("guardian_subagent".to_string())) ); + app_server.shutdown().await?; Ok(()) } @@ -2137,8 +2147,9 @@ guardian_approval = true app.config.approvals_reviewer = ApprovalsReviewer::AutoReview; app.chat_widget .set_approvals_reviewer(ApprovalsReviewer::AutoReview); + let mut app_server = start_config_write_test_app_server(&app).await?; - app.update_feature_flags(vec![(Feature::GuardianApproval, false)]) + app.update_feature_flags(&mut app_server, vec![(Feature::GuardianApproval, false)]) .await; assert!(!app.config.features.enabled(Feature::GuardianApproval)); @@ -2191,6 +2202,7 @@ guardian_approval = true .and_then(|table| table.get("approvals_reviewer")), Some(&TomlValue::String("user".to_string())) ); + app_server.shutdown().await?; Ok(()) } @@ -2217,8 +2229,9 @@ async fn update_feature_flags_disabling_guardian_in_profile_keeps_inherited_non_ app.config.approvals_reviewer = ApprovalsReviewer::AutoReview; app.chat_widget .set_approvals_reviewer(ApprovalsReviewer::AutoReview); + let mut app_server = start_config_write_test_app_server(&app).await?; - app.update_feature_flags(vec![(Feature::GuardianApproval, false)]) + app.update_feature_flags(&mut app_server, vec![(Feature::GuardianApproval, false)]) .await; assert!(app.config.features.enabled(Feature::GuardianApproval)); @@ -2257,6 +2270,7 @@ async fn update_feature_flags_disabling_guardian_in_profile_keeps_inherited_non_ .and_then(|table| table.get("approvals_reviewer")), Some(&TomlValue::String("guardian_subagent".to_string())) ); + app_server.shutdown().await?; Ok(()) } @@ -5758,3 +5772,6 @@ async fn side_backtrack_rejection_reports_unavailable_message_snapshot() { rendered ); } +async fn start_config_write_test_app_server(app: &App) -> Result { + Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await +} diff --git a/codex-rs/tui/src/config_update.rs b/codex-rs/tui/src/config_update.rs index f3dc0dc5fc27..c18ea44ce938 100644 --- a/codex-rs/tui/src/config_update.rs +++ b/codex-rs/tui/src/config_update.rs @@ -8,11 +8,14 @@ use codex_app_server_client::AppServerRequestHandle; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ConfigBatchWriteParams; use codex_app_server_protocol::ConfigEdit; +use codex_app_server_protocol::ConfigReadParams; +use codex_app_server_protocol::ConfigReadResponse; use codex_app_server_protocol::ConfigWriteResponse; use codex_app_server_protocol::MergeStrategy; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SkillsConfigWriteParams; use codex_app_server_protocol::SkillsConfigWriteResponse; +use codex_features::FEATURES; use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; use codex_utils_absolute_path::AbsolutePathBuf; use color_eyre::eyre::Result; @@ -94,12 +97,64 @@ pub(crate) fn build_service_tier_selection_edits( vec![service_tier_edit] } +#[cfg(target_os = "windows")] +pub(crate) fn build_windows_sandbox_mode_edits( + profile: Option<&str>, + elevated_enabled: bool, +) -> Vec { + let feature_key_path = + |feature: &str| profile_scoped_key_path(profile, &format!("features.{feature}")); + vec![ + replace_config_value( + profile_scoped_key_path(profile, "windows.sandbox"), + serde_json::json!(if elevated_enabled { + "elevated" + } else { + "unelevated" + }), + ), + clear_config_value(feature_key_path("experimental_windows_sandbox")), + clear_config_value(feature_key_path("elevated_windows_sandbox")), + clear_config_value(feature_key_path("enable_experimental_windows_sandbox")), + ] +} + +pub(crate) fn build_feature_enabled_edit( + profile: Option<&str>, + feature_key: &str, + enabled: bool, +) -> ConfigEdit { + let key_path = profile_scoped_key_path(profile, &format!("features.{feature_key}")); + let is_default_false_feature = FEATURES + .iter() + .find(|spec| spec.key == feature_key) + .is_some_and(|spec| !spec.default_enabled); + if enabled || profile.is_some() || !is_default_false_feature { + replace_config_value(key_path, serde_json::json!(enabled)) + } else { + clear_config_value(key_path) + } +} + +pub(crate) fn build_memory_settings_edits( + use_memories: bool, + generate_memories: bool, +) -> Vec { + vec![ + replace_config_value("memories.use_memories", serde_json::json!(use_memories)), + replace_config_value( + "memories.generate_memories", + serde_json::json!(generate_memories), + ), + ] +} + pub(crate) async fn write_config_batch( request_handle: AppServerRequestHandle, edits: Vec, -) -> Result<()> { +) -> Result { let request_id = RequestId::String(format!("tui-config-write-{}", Uuid::new_v4())); - let _: ConfigWriteResponse = request_handle + request_handle .request_typed(ClientRequest::ConfigBatchWrite { request_id, params: ConfigBatchWriteParams { @@ -110,8 +165,24 @@ pub(crate) async fn write_config_batch( }, }) .await - .wrap_err("config/batchWrite failed in TUI")?; - Ok(()) + .wrap_err("config/batchWrite failed in TUI") +} + +pub(crate) async fn read_effective_config( + request_handle: AppServerRequestHandle, + cwd: String, +) -> Result { + let request_id = RequestId::String(format!("tui-config-read-{}", Uuid::new_v4())); + request_handle + .request_typed(ClientRequest::ConfigRead { + request_id, + params: ConfigReadParams { + include_layers: false, + cwd: Some(cwd), + }, + }) + .await + .wrap_err("config/read failed in TUI") } pub(crate) async fn write_skill_enabled( From 247e22a9f6c46b6c77283e295d7dc621415c273f Mon Sep 17 00:00:00 2001 From: xl-openai Date: Thu, 21 May 2026 16:11:59 -0700 Subject: [PATCH 22/64] fix: Allow plugin skills to share plugin-level icon assets (#23776) Thread the plugin root through plugin skill loading so skill interface icons can reference shared plugin assets, such as ../../assets/logo.svg. --- codex-rs/core-plugins/src/loader.rs | 1 + codex-rs/core-skills/src/loader.rs | 84 ++++++++++++++-- codex-rs/core-skills/src/loader_tests.rs | 116 ++++++++++++++++++++++ codex-rs/core-skills/src/manager_tests.rs | 33 +++--- codex-rs/plugin/src/load_outcome.rs | 2 + codex-rs/utils/plugins/src/lib.rs | 1 + 6 files changed, 215 insertions(+), 22 deletions(-) diff --git a/codex-rs/core-plugins/src/loader.rs b/codex-rs/core-plugins/src/loader.rs index be6c8aca62ca..9e0ef7c471ec 100644 --- a/codex-rs/core-plugins/src/loader.rs +++ b/codex-rs/core-plugins/src/loader.rs @@ -655,6 +655,7 @@ pub async fn load_plugin_skills( scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), plugin_id: Some(plugin_id.as_key()), + plugin_root: Some(plugin_root.clone()), }) .collect::>(); let outcome = load_skills_from_roots(roots).await; diff --git a/codex-rs/core-skills/src/loader.rs b/codex-rs/core-skills/src/loader.rs index 2473f7108cf9..00d1cbba1421 100644 --- a/codex-rs/core-skills/src/loader.rs +++ b/codex-rs/core-skills/src/loader.rs @@ -30,6 +30,7 @@ use std::error::Error; use std::fmt; use std::io; use std::path::Component; +use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use toml::Value as TomlValue; @@ -154,6 +155,7 @@ pub struct SkillRoot { pub scope: SkillScope, pub file_system: Arc, pub plugin_id: Option, + pub plugin_root: Option, } pub async fn load_skills_from_roots(roots: I) -> SkillLoadOutcome @@ -174,6 +176,7 @@ where &root_path, root.scope, root.plugin_id.as_deref(), + root.plugin_root.as_ref(), &mut outcome, ) .await; @@ -258,6 +261,7 @@ async fn skill_roots_with_home_dir( scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), plugin_id: Some(root.plugin_id), + plugin_root: Some(root.plugin_root), })); roots.extend(repo_agents_skill_roots(fs, config_layer_stack, cwd).await); dedupe_skill_roots_by_path(&mut roots); @@ -287,6 +291,7 @@ fn skill_roots_from_layer_stack_inner( scope: SkillScope::Repo, file_system: Arc::clone(repo_fs), plugin_id: None, + plugin_root: None, }); } } @@ -298,6 +303,7 @@ fn skill_roots_from_layer_stack_inner( scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), plugin_id: None, + plugin_root: None, }); // `$HOME/.agents/skills` (user-installed skills). @@ -307,6 +313,7 @@ fn skill_roots_from_layer_stack_inner( scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), plugin_id: None, + plugin_root: None, }); } @@ -317,6 +324,7 @@ fn skill_roots_from_layer_stack_inner( scope: SkillScope::System, file_system: Arc::clone(&LOCAL_FS), plugin_id: None, + plugin_root: None, }); } ConfigLayerSource::System { .. } => { @@ -327,6 +335,7 @@ fn skill_roots_from_layer_stack_inner( scope: SkillScope::Admin, file_system: Arc::clone(&LOCAL_FS), plugin_id: None, + plugin_root: None, }); } ConfigLayerSource::Mdm { .. } @@ -359,6 +368,7 @@ async fn repo_agents_skill_roots( scope: SkillScope::Repo, file_system: Arc::clone(&fs), plugin_id: None, + plugin_root: None, }), Ok(_) => {} Err(err) if err.kind() == io::ErrorKind::NotFound => {} @@ -458,9 +468,11 @@ async fn discover_skills_under_root( root: &AbsolutePathBuf, scope: SkillScope, plugin_id: Option<&str>, + plugin_root: Option<&AbsolutePathBuf>, outcome: &mut SkillLoadOutcome, ) { let root = canonicalize_for_skill_identity(root); + let plugin_root = plugin_root.map(canonicalize_for_skill_identity); match fs.get_metadata(&root, /*sandbox*/ None).await { Ok(metadata) if metadata.is_directory => {} @@ -570,7 +582,7 @@ async fn discover_skills_under_root( } if metadata.is_file && file_name == SKILLS_FILENAME { - match parse_skill_file(fs, &path, scope, plugin_id).await { + match parse_skill_file(fs, &path, scope, plugin_id, plugin_root.as_ref()).await { Ok(skill) => { outcome.skills.push(skill); } @@ -601,6 +613,7 @@ async fn parse_skill_file( path: &AbsolutePathBuf, scope: SkillScope, plugin_id: Option<&str>, + plugin_root: Option<&AbsolutePathBuf>, ) -> Result { let contents = fs .read_file_text(path, /*sandbox*/ None) @@ -634,7 +647,7 @@ async fn parse_skill_file( interface, dependencies, policy, - } = load_skill_metadata(fs, path).await; + } = load_skill_metadata(fs, path, plugin_root).await; validate_len(&name, MAX_NAME_LEN, "name")?; validate_len(&description, MAX_DESCRIPTION_LEN, "description")?; @@ -687,6 +700,7 @@ async fn namespaced_skill_name( async fn load_skill_metadata( fs: &dyn ExecutorFileSystem, skill_path: &AbsolutePathBuf, + plugin_root: Option<&AbsolutePathBuf>, ) -> LoadedSkillMetadata { // Fail open: optional metadata should not block loading SKILL.md. let Some(skill_dir) = skill_path.parent() else { @@ -744,7 +758,7 @@ async fn load_skill_metadata( policy, } = parsed; LoadedSkillMetadata { - interface: resolve_interface(interface, &skill_dir), + interface: resolve_interface(interface, &skill_dir, plugin_root), dependencies: resolve_dependencies(dependencies), policy: resolve_policy(policy), } @@ -753,6 +767,7 @@ async fn load_skill_metadata( fn resolve_interface( interface: Option, skill_dir: &AbsolutePathBuf, + plugin_root: Option<&AbsolutePathBuf>, ) -> Option { let interface = interface?; let interface = SkillInterface { @@ -766,8 +781,18 @@ fn resolve_interface( MAX_SHORT_DESCRIPTION_LEN, "interface.short_description", ), - icon_small: resolve_asset_path(skill_dir, "interface.icon_small", interface.icon_small), - icon_large: resolve_asset_path(skill_dir, "interface.icon_large", interface.icon_large), + icon_small: resolve_asset_path( + skill_dir, + plugin_root, + "interface.icon_small", + interface.icon_small, + ), + icon_large: resolve_asset_path( + skill_dir, + plugin_root, + "interface.icon_large", + interface.icon_large, + ), brand_color: resolve_color_str(interface.brand_color, "interface.brand_color"), default_prompt: resolve_str( interface.default_prompt, @@ -845,10 +870,12 @@ fn resolve_dependency_tool(tool: DependencyTool) -> Option fn resolve_asset_path( skill_dir: &AbsolutePathBuf, + plugin_root: Option<&AbsolutePathBuf>, field: &'static str, path: Option, ) -> Option { - // Icons must be relative paths under the skill's assets/ directory; otherwise return None. + // Icons must stay under the skill's assets directory. Plugin skills may + // also share icons from the plugin-level assets directory. let path = path?; if path.as_os_str().is_empty() { return None; @@ -869,8 +896,7 @@ fn resolve_asset_path( Component::CurDir => {} Component::Normal(component) => normalized.push(component), Component::ParentDir => { - tracing::warn!("ignoring {field}: icon path must not contain '..'"); - return None; + return resolve_plugin_shared_asset_path(skill_dir, plugin_root, field, &path); } _ => { tracing::warn!("ignoring {field}: icon path must be under assets/"); @@ -891,6 +917,48 @@ fn resolve_asset_path( Some(skill_dir.join(normalized)) } +fn resolve_plugin_shared_asset_path( + skill_dir: &AbsolutePathBuf, + plugin_root: Option<&AbsolutePathBuf>, + field: &'static str, + path: &Path, +) -> Option { + let Some(plugin_root) = plugin_root else { + tracing::warn!("ignoring {field}: icon path must not contain '..'"); + return None; + }; + + let plugin_assets_dir = lexically_normalize(plugin_root.join("assets").as_path()); + let resolved = lexically_normalize(skill_dir.join(path).as_path()); + if !resolved.starts_with(&plugin_assets_dir) { + tracing::warn!("ignoring {field}: icon path with '..' must resolve under plugin assets/"); + return None; + } + + AbsolutePathBuf::try_from(resolved) + .map_err(|err| { + tracing::warn!("ignoring {field}: icon path must resolve to an absolute path: {err}"); + err + }) + .ok() +} + +fn lexically_normalize(path: &Path) -> PathBuf { + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + normalized.pop(); + } + Component::Prefix(_) | Component::RootDir | Component::Normal(_) => { + normalized.push(component.as_os_str()); + } + } + } + normalized +} + fn sanitize_single_line(raw: &str) -> String { raw.split_whitespace().collect::>().join(" ") } diff --git a/codex-rs/core-skills/src/loader_tests.rs b/codex-rs/core-skills/src/loader_tests.rs index a1d03dead2e7..84867ca66bfd 100644 --- a/codex-rs/core-skills/src/loader_tests.rs +++ b/codex-rs/core-skills/src/loader_tests.rs @@ -822,6 +822,116 @@ async fn drops_interface_when_icons_are_invalid() { ); } +#[tokio::test] +async fn loads_plugin_skill_interface_icons_from_shared_plugin_assets() { + let root = tempfile::tempdir().expect("tempdir"); + let plugin_root = root.path().join("plugins/twilio-developer-kit"); + let skill_path = write_skill_at( + &plugin_root.join("skills"), + "twilio-send-message", + "send-message", + "send messages", + ); + let skill_dir = skill_path.parent().expect("skill dir"); + fs::create_dir_all(plugin_root.join("assets")).unwrap(); + fs::write(plugin_root.join("assets/logo.svg"), "").unwrap(); + write_skill_interface_at( + skill_dir, + r##" +interface: + icon_small: "../../assets/logo.svg" + icon_large: "../../assets/logo.svg" +"##, + ); + + let plugin_root_abs = plugin_root.abs(); + let outcome = load_skills_from_roots([SkillRoot { + path: plugin_root.join("skills").abs(), + scope: SkillScope::User, + file_system: Arc::clone(&LOCAL_FS), + plugin_id: Some("twilio-developer-kit@test".to_string()), + plugin_root: Some(plugin_root_abs.clone()), + }]) + .await; + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + let expected_icon_path = normalized(&plugin_root.join("assets/logo.svg")); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "send-message".to_string(), + description: "send messages".to_string(), + short_description: None, + interface: Some(SkillInterface { + display_name: None, + short_description: None, + icon_small: Some(expected_icon_path.clone()), + icon_large: Some(expected_icon_path), + brand_color: None, + default_prompt: None, + }), + dependencies: None, + policy: None, + path_to_skills_md: normalized(&skill_path), + scope: SkillScope::User, + plugin_id: Some("twilio-developer-kit@test".to_string()), + }] + ); +} + +#[tokio::test] +async fn drops_plugin_skill_interface_icons_that_escape_shared_plugin_assets() { + let root = tempfile::tempdir().expect("tempdir"); + let plugin_root = root.path().join("plugins/twilio-developer-kit"); + let skill_path = write_skill_at( + &plugin_root.join("skills"), + "twilio-send-message", + "send-message", + "send messages", + ); + let skill_dir = skill_path.parent().expect("skill dir"); + write_skill_interface_at( + skill_dir, + r##" +interface: + icon_small: "../../other/logo.svg" +"##, + ); + + let outcome = load_skills_from_roots([SkillRoot { + path: plugin_root.join("skills").abs(), + scope: SkillScope::User, + file_system: Arc::clone(&LOCAL_FS), + plugin_id: Some("twilio-developer-kit@test".to_string()), + plugin_root: Some(plugin_root.abs()), + }]) + .await; + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "send-message".to_string(), + description: "send messages".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + path_to_skills_md: normalized(&skill_path), + scope: SkillScope::User, + plugin_id: Some("twilio-developer-kit@test".to_string()), + }] + ); +} + #[cfg(unix)] fn symlink_dir(target: &Path, link: &Path) { std::os::unix::fs::symlink(target, link).unwrap(); @@ -943,6 +1053,7 @@ async fn loads_skills_via_symlinked_subdir_for_admin_scope() { scope: SkillScope::Admin, file_system: Arc::clone(&LOCAL_FS), plugin_id: None, + plugin_root: None, }]) .await; @@ -1024,6 +1135,7 @@ async fn system_scope_ignores_symlinked_subdir() { scope: SkillScope::System, file_system: Arc::clone(&LOCAL_FS), plugin_id: None, + plugin_root: None, }]) .await; assert!( @@ -1057,6 +1169,7 @@ async fn respects_max_scan_depth_for_user_scope() { scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), plugin_id: None, + plugin_root: None, }]) .await; @@ -1163,6 +1276,7 @@ async fn namespaces_plugin_skills_using_plugin_name() { scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), plugin_id: Some("sample@test".to_string()), + plugin_root: Some(plugin_root.abs()), }]) .await; @@ -1485,12 +1599,14 @@ async fn deduplicates_by_path_preferring_first_root() { scope: SkillScope::Repo, file_system: Arc::clone(&LOCAL_FS), plugin_id: None, + plugin_root: None, }, SkillRoot { path: root.path().abs(), scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), plugin_id: None, + plugin_root: None, }, ]) .await; diff --git a/codex-rs/core-skills/src/manager_tests.rs b/codex-rs/core-skills/src/manager_tests.rs index 15b06b393a48..4afa83e21ef7 100644 --- a/codex-rs/core-skills/src/manager_tests.rs +++ b/codex-rs/core-skills/src/manager_tests.rs @@ -15,6 +15,7 @@ use codex_utils_plugins::PluginSkillRoot; use pretty_assertions::assert_eq; use std::collections::HashSet; use std::fs; +use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use tempfile::TempDir; @@ -54,6 +55,21 @@ fn write_plugin_skill( skill_path } +fn plugin_skill_root_for_skill_path(skill_path: &Path, plugin_id: &str) -> PluginSkillRoot { + let skills_root = skill_path + .parent() + .and_then(Path::parent) + .expect("plugin skill should live under a skills root"); + let plugin_root = skills_root + .parent() + .expect("plugin skills root should live under a plugin root"); + PluginSkillRoot { + path: skills_root.abs(), + plugin_id: plugin_id.to_string(), + plugin_root: plugin_root.abs(), + } +} + fn test_skill(name: &str, path: PathBuf) -> SkillMetadata { SkillMetadata { name: name.to_string(), @@ -146,18 +162,11 @@ async fn skills_for_config_with_stack( skills_manager: &SkillsManager, cwd: &TempDir, config_layer_stack: &ConfigLayerStack, - effective_skill_roots: &[AbsolutePathBuf], + effective_skill_roots: &[PluginSkillRoot], ) -> SkillLoadOutcome { let skills_input = SkillsLoadInput::new( cwd.path().abs(), - effective_skill_roots - .iter() - .cloned() - .map(|path| PluginSkillRoot { - path, - plugin_id: "test-plugin@test".to_string(), - }) - .collect(), + effective_skill_roots.to_vec(), config_layer_stack.clone(), bundled_skills_enabled_from_stack(config_layer_stack), ); @@ -228,11 +237,7 @@ async fn skills_for_config_disables_plugin_skills_by_name() { &codex_home, &name_toggle_config("sample:sample-search", /*enabled*/ false), ); - let plugin_skill_root = skill_path - .parent() - .and_then(std::path::Path::parent) - .expect("plugin skill should live under a skills root") - .abs(); + let plugin_skill_root = plugin_skill_root_for_skill_path(&skill_path, "test-plugin@test"); let skills_manager = SkillsManager::new( codex_home.path().abs(), /*bundled_skills_enabled*/ true, diff --git a/codex-rs/plugin/src/load_outcome.rs b/codex-rs/plugin/src/load_outcome.rs index c76697366f01..2588ee0a7f94 100644 --- a/codex-rs/plugin/src/load_outcome.rs +++ b/codex-rs/plugin/src/load_outcome.rs @@ -126,6 +126,7 @@ impl PluginLoadOutcome { skill_roots.push(PluginSkillRoot { path: path.clone(), plugin_id: plugin.config_name.clone(), + plugin_root: plugin.root.clone(), }); } } @@ -245,6 +246,7 @@ mod tests { vec![PluginSkillRoot { path: shared_root, plugin_id: "zeta@test".to_string(), + plugin_root: test_path("zeta@test"), }] ); } diff --git a/codex-rs/utils/plugins/src/lib.rs b/codex-rs/utils/plugins/src/lib.rs index dec24d99d856..38bf68040344 100644 --- a/codex-rs/utils/plugins/src/lib.rs +++ b/codex-rs/utils/plugins/src/lib.rs @@ -14,4 +14,5 @@ pub use plugin_namespace::plugin_namespace_for_skill_path; pub struct PluginSkillRoot { pub path: AbsolutePathBuf, pub plugin_id: String, + pub plugin_root: AbsolutePathBuf, } From 5381240f57fe326b13bc81325f3c61596592fc7a Mon Sep 17 00:00:00 2001 From: CHARLESPALEN-OAI Date: Thu, 21 May 2026 19:19:26 -0400 Subject: [PATCH 23/64] Add Bedrock Mantle GovCloud region (#23860) ## Summary - Add us-gov-west-1 to the Bedrock Mantle supported region list - Cover the GovCloud endpoint URL in the existing base_url unit test ## Test - cargo test -p codex-model-provider --- codex-rs/model-provider/src/amazon_bedrock/mantle.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/codex-rs/model-provider/src/amazon_bedrock/mantle.rs b/codex-rs/model-provider/src/amazon_bedrock/mantle.rs index 7881845e4575..e5b497d355c2 100644 --- a/codex-rs/model-provider/src/amazon_bedrock/mantle.rs +++ b/codex-rs/model-provider/src/amazon_bedrock/mantle.rs @@ -7,10 +7,11 @@ use super::auth::BedrockAuthMethod; use super::auth::resolve_auth_method; const BEDROCK_MANTLE_SERVICE_NAME: &str = "bedrock-mantle"; -const BEDROCK_MANTLE_SUPPORTED_REGIONS: [&str; 12] = [ +const BEDROCK_MANTLE_SUPPORTED_REGIONS: [&str; 13] = [ "us-east-2", "us-east-1", "us-west-2", + "us-gov-west-1", "ap-southeast-3", "ap-south-1", "ap-northeast-1", @@ -72,6 +73,10 @@ mod tests { base_url("ap-northeast-1").expect("supported region"), "https://bedrock-mantle.ap-northeast-1.api.aws/openai/v1" ); + assert_eq!( + base_url("us-gov-west-1").expect("supported region"), + "https://bedrock-mantle.us-gov-west-1.api.aws/openai/v1" + ); } #[test] From 5a6e90599434137b677b7224d995932183844646 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 21 May 2026 16:52:36 -0700 Subject: [PATCH 24/64] Fix auto-review permission profile override (#23956) ## Summary The auto-review runtime sync path was assigning a raw `PermissionProfile` into `runtime_permission_profile_override`, whose field now expects `RuntimePermissionProfileOverride`. That broke the TUI Bazel build. This changes the assignment to store `RuntimePermissionProfileOverride::from_config(&self.config)`, matching the other runtime override paths and preserving the active profile and network metadata with the permission profile. --- codex-rs/tui/src/app/config_persistence.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index 4baea7fb04ee..1cba3c85a0c4 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -910,7 +910,8 @@ impl App { return; } - self.runtime_permission_profile_override = Some(permission_profile); + self.runtime_permission_profile_override = + Some(RuntimePermissionProfileOverride::from_config(&self.config)); self.sync_active_thread_permission_settings_to_cached_session() .await; From 0cec50814895fe257be9b325eca8243648455f8f Mon Sep 17 00:00:00 2001 From: Celia Chen Date: Thu, 21 May 2026 17:32:14 -0700 Subject: [PATCH 25/64] feat: support local refs and defs in tool input schemas (#23357) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Why Some connector tool input schemas use local JSON Schema references and definition tables to avoid duplicating large nested shapes. Codex previously lowered these schemas into the supported subset in a way that could discard `$ref`-only schema objects and lose the corresponding definitions, which made non-strict tool registration less faithful than the original connector schema. This keeps the existing minimal-lowering policy: Codex still does not raw-pass through arbitrary JSON Schema, but it now preserves local reference structure that fits the Responses-compatible subset and prunes definition entries that cannot be reached by following `$ref`s from the root schema after sanitization, including refs found transitively inside other reachable definitions. The pruning matters because Responses parses definition tables even when entries are unused, so keeping dead definitions wastes prompt tokens. # What changed - Added `$ref`, `$defs`, and legacy `definitions` fields to the tool `JsonSchema` representation. - Updated `parse_tool_input_schema` lowering so `$ref`-only schema objects survive sanitization instead of becoming `{}`. - Sanitized definition tables recursively and dropped malformed definition tables so non-strict registration degrades gracefully. - Added reachability pruning for root definition tables by starting from refs outside definition tables, then following refs inside reachable definitions. - Added JSON Pointer decoding for local definition refs such as `#/$defs/Foo~1Bar`. # Verification ran local golden-schema probes against representative connector schemas to validate behavior on real generated schemas: | Golden schema | Before bytes | After bytes | `$defs` before -> after | `$ref` before -> after | Result | |---|---:|---:|---:|---:|---| | `google_calendar/create_space` | 7111 | 4526 | 7 -> 7 | 7 -> 7 | all definitions preserved because all are reachable | | `figma/apply_file_variable_changes` | 4609 | 999 | 8 -> 5 | 8 -> 5 | unused defs pruned after unsupported `oneOf` shapes lower away | | `snowflake/list_catalog_integrations` | 1380 | 404 | 3 -> 0 | 0 -> 0 | all defs pruned because none are referenced | | `dropbox/create_shared_link` | 8894 | 1836 | 14 -> 4 | 9 -> 4 | only defs reachable from the root schema after sanitization are retained, including transitively through other retained defs | Token increase across golden schema due to this change: Screenshot 2026-05-19 at 1 47 04 PM --- MODULE.bazel.lock | 1 + codex-rs/Cargo.lock | 8 + codex-rs/Cargo.toml | 1 + codex-rs/tools/Cargo.toml | 2 + codex-rs/tools/src/json_schema.rs | 221 +++++++++- codex-rs/tools/src/json_schema_tests.rs | 535 ++++++++++++++++++++++++ 6 files changed, 767 insertions(+), 1 deletion(-) diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 02d7c76a9111..1392b5a5681a 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -1150,6 +1150,7 @@ "jni_0.21.1": "{\"dependencies\":[{\"name\":\"cesu8\",\"req\":\"^1.1.0\"},{\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"name\":\"combine\",\"req\":\"^4.1.0\"},{\"name\":\"java-locator\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"jni-sys\",\"req\":\"^0.3.0\"},{\"name\":\"libloading\",\"optional\":true,\"req\":\"^0.7\"},{\"name\":\"log\",\"req\":\"^0.4.4\"},{\"name\":\"thiserror\",\"req\":\"^1.0.20\"},{\"kind\":\"dev\",\"name\":\"assert_matches\",\"req\":\"^1.5.0\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rusty-fork\",\"req\":\"^0.3.0\"},{\"kind\":\"build\",\"name\":\"walkdir\",\"req\":\"^2\"},{\"features\":[\"Win32_Globalization\"],\"name\":\"windows-sys\",\"req\":\"^0.45.0\",\"target\":\"cfg(windows)\"},{\"kind\":\"dev\",\"name\":\"bytemuck\",\"req\":\"^1.13.0\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[],\"invocation\":[\"java-locator\",\"libloading\"]}}", "jobserver_0.1.34": "{\"dependencies\":[{\"features\":[\"std\"],\"name\":\"getrandom\",\"req\":\"^0.3.2\",\"target\":\"cfg(windows)\"},{\"name\":\"libc\",\"req\":\"^0.2.171\",\"target\":\"cfg(unix)\"},{\"features\":[\"fs\"],\"kind\":\"dev\",\"name\":\"nix\",\"req\":\"^0.28.0\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.10.1\"}],\"features\":{}}", "js-sys_0.3.85": "{\"dependencies\":[{\"default_features\":false,\"name\":\"once_cell\",\"req\":\"^1.12\"},{\"default_features\":false,\"name\":\"wasm-bindgen\",\"req\":\"=0.2.108\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"wasm-bindgen/std\"]}}", + "jsonptr_0.7.1": "{\"dependencies\":[{\"features\":[\"fancy\"],\"name\":\"miette\",\"optional\":true,\"req\":\"^7.4.0\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"},{\"kind\":\"dev\",\"name\":\"quickcheck_macros\",\"req\":\"^1.0.0\"},{\"features\":[\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.203\"},{\"features\":[\"alloc\"],\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0.119\"},{\"name\":\"syn\",\"optional\":true,\"req\":\"^1.0.109\",\"target\":\"cfg(any())\"},{\"name\":\"toml\",\"optional\":true,\"req\":\"^0.8\"}],\"features\":{\"assign\":[],\"default\":[\"std\",\"serde\",\"json\",\"resolve\",\"assign\",\"delete\"],\"delete\":[\"resolve\"],\"json\":[\"dep:serde_json\",\"serde\"],\"miette\":[\"dep:miette\",\"std\"],\"resolve\":[],\"std\":[\"serde/std\",\"serde_json?/std\"],\"toml\":[\"dep:toml\",\"serde\",\"std\"]}}", "jsonwebtoken_9.3.1": "{\"dependencies\":[{\"name\":\"base64\",\"req\":\"^0.22\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\")))))\"},{\"name\":\"js-sys\",\"req\":\"^0.3\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"pem\",\"optional\":true,\"req\":\"^3\"},{\"features\":[\"std\"],\"name\":\"ring\",\"req\":\"^0.17.4\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"std\",\"wasm32_unknown_unknown_js\"],\"name\":\"ring\",\"req\":\"^0.17.4\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"simple_asn1\",\"optional\":true,\"req\":\"^0.6\"},{\"features\":[\"wasm-bindgen\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\")))))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.1\"}],\"features\":{\"default\":[\"use_pem\"],\"use_pem\":[\"pem\",\"simple_asn1\"]}}", "keyring_3.6.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"base64\",\"req\":\"^0.22\"},{\"name\":\"byteorder\",\"optional\":true,\"req\":\"^1.2\",\"target\":\"cfg(target_os = \\\"windows\\\")\"},{\"features\":[\"derive\",\"wrap_help\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^4\"},{\"name\":\"dbus-secret-service\",\"optional\":true,\"req\":\"^4.0.0-rc.1\",\"target\":\"cfg(target_os = \\\"openbsd\\\")\"},{\"name\":\"dbus-secret-service\",\"optional\":true,\"req\":\"^4.0.0-rc.2\",\"target\":\"cfg(target_os = \\\"linux\\\")\"},{\"name\":\"dbus-secret-service\",\"optional\":true,\"req\":\"^4.0.1\",\"target\":\"cfg(target_os = \\\"freebsd\\\")\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11.5\"},{\"kind\":\"dev\",\"name\":\"fastrand\",\"req\":\"^2\"},{\"features\":[\"std\"],\"name\":\"linux-keyutils\",\"optional\":true,\"req\":\"^0.2\",\"target\":\"cfg(target_os = \\\"linux\\\")\"},{\"name\":\"log\",\"req\":\"^0.4.22\"},{\"name\":\"openssl\",\"optional\":true,\"req\":\"^0.10.66\"},{\"kind\":\"dev\",\"name\":\"rpassword\",\"req\":\"^7\"},{\"kind\":\"dev\",\"name\":\"rprompt\",\"req\":\"^2\"},{\"name\":\"secret-service\",\"optional\":true,\"req\":\"^4\",\"target\":\"cfg(target_os = \\\"freebsd\\\")\"},{\"name\":\"secret-service\",\"optional\":true,\"req\":\"^4\",\"target\":\"cfg(target_os = \\\"linux\\\")\"},{\"name\":\"secret-service\",\"optional\":true,\"req\":\"^4\",\"target\":\"cfg(target_os = \\\"openbsd\\\")\"},{\"name\":\"security-framework\",\"optional\":true,\"req\":\"^2\",\"target\":\"cfg(target_os = \\\"ios\\\")\"},{\"name\":\"security-framework\",\"optional\":true,\"req\":\"^3\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"kind\":\"dev\",\"name\":\"whoami\",\"req\":\"^1.5\"},{\"features\":[\"Win32_Foundation\",\"Win32_Security_Credentials\"],\"name\":\"windows-sys\",\"optional\":true,\"req\":\"^0.60\",\"target\":\"cfg(target_os = \\\"windows\\\")\"},{\"name\":\"zbus\",\"optional\":true,\"req\":\"^4\",\"target\":\"cfg(target_os = \\\"freebsd\\\")\"},{\"name\":\"zbus\",\"optional\":true,\"req\":\"^4\",\"target\":\"cfg(target_os = \\\"linux\\\")\"},{\"name\":\"zbus\",\"optional\":true,\"req\":\"^4\",\"target\":\"cfg(target_os = \\\"openbsd\\\")\"},{\"name\":\"zeroize\",\"req\":\"^1.8.1\",\"target\":\"cfg(target_os = \\\"windows\\\")\"}],\"features\":{\"apple-native\":[\"dep:security-framework\"],\"async-io\":[\"zbus?/async-io\"],\"async-secret-service\":[\"dep:secret-service\",\"dep:zbus\"],\"crypto-openssl\":[\"dbus-secret-service?/crypto-openssl\",\"secret-service?/crypto-openssl\"],\"crypto-rust\":[\"dbus-secret-service?/crypto-rust\",\"secret-service?/crypto-rust\"],\"linux-native\":[\"dep:linux-keyutils\"],\"linux-native-async-persistent\":[\"linux-native\",\"async-secret-service\"],\"linux-native-sync-persistent\":[\"linux-native\",\"sync-secret-service\"],\"sync-secret-service\":[\"dep:dbus-secret-service\"],\"tokio\":[\"zbus?/tokio\"],\"vendored\":[\"dbus-secret-service?/vendored\",\"openssl?/vendored\"],\"windows-native\":[\"dep:windows-sys\",\"dep:byteorder\"]}}", "kqueue-sys_1.0.4": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^1.2.1\"},{\"name\":\"libc\",\"req\":\"^0.2.74\"}],\"features\":{}}", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 7c9955323d4d..4acaeb7c2775 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3773,12 +3773,14 @@ dependencies = [ "codex-utils-output-truncation", "codex-utils-pty", "codex-utils-string", + "jsonptr", "pretty_assertions", "rmcp", "serde", "serde_json", "thiserror 2.0.18", "tracing", + "urlencoding", ] [[package]] @@ -8125,6 +8127,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonptr" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5a3cc660ba5d72bce0b3bb295bf20847ccbb40fd423f3f05b61273672e561fe" + [[package]] name = "jsonwebtoken" version = "9.3.1" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index ec38a87cff97..df169504f081 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -301,6 +301,7 @@ indexmap = "2.12.0" insta = "1.46.3" inventory = "0.3.19" itertools = "0.14.0" +jsonptr = { version = "0.7.1", default-features = false } jsonwebtoken = "9.3.1" keyring = { version = "3.6", default-features = false } landlock = "0.4.4" diff --git a/codex-rs/tools/Cargo.toml b/codex-rs/tools/Cargo.toml index 334ce795803f..7d02f1bf3602 100644 --- a/codex-rs/tools/Cargo.toml +++ b/codex-rs/tools/Cargo.toml @@ -17,6 +17,7 @@ codex-utils-absolute-path = { workspace = true } codex-utils-output-truncation = { workspace = true } codex-utils-pty = { workspace = true } codex-utils-string = { workspace = true } +jsonptr = { workspace = true } rmcp = { workspace = true, default-features = false, features = [ "base64", "macros", @@ -27,6 +28,7 @@ serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } +urlencoding = { workspace = true } [dev-dependencies] pretty_assertions = { workspace = true } diff --git a/codex-rs/tools/src/json_schema.rs b/codex-rs/tools/src/json_schema.rs index ecd880cc7aa9..83733ffb7b48 100644 --- a/codex-rs/tools/src/json_schema.rs +++ b/codex-rs/tools/src/json_schema.rs @@ -3,6 +3,10 @@ use serde::Serialize; use serde_json::Value as JsonValue; use serde_json::json; use std::collections::BTreeMap; +use std::collections::BTreeSet; + +const DEFINITION_TABLE_KEYS: [&str; 2] = ["$defs", "definitions"]; +const SCHEMA_CHILD_KEYS: [&str; 2] = ["items", "anyOf"]; /// Primitive JSON Schema type names we support in tool definitions. /// @@ -33,6 +37,8 @@ pub enum JsonSchemaType { /// Generic JSON-Schema subset needed for our tool definitions. #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] pub struct JsonSchema { + #[serde(rename = "$ref", skip_serializing_if = "Option::is_none")] + pub schema_ref: Option, #[serde(rename = "type", skip_serializing_if = "Option::is_none")] pub schema_type: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -52,6 +58,10 @@ pub struct JsonSchema { pub additional_properties: Option, #[serde(rename = "anyOf", skip_serializing_if = "Option::is_none")] pub any_of: Option>, + #[serde(rename = "$defs", skip_serializing_if = "Option::is_none")] + pub defs: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub definitions: Option>, } impl JsonSchema { @@ -149,6 +159,7 @@ impl From for AdditionalProperties { pub fn parse_tool_input_schema(input_schema: &JsonValue) -> Result { let mut input_schema = input_schema.clone(); sanitize_json_schema(&mut input_schema); + prune_unreachable_definitions(&mut input_schema); let schema: JsonSchema = serde_json::from_value(input_schema)?; if matches!( schema.schema_type, @@ -159,10 +170,55 @@ pub fn parse_tool_input_schema(input_schema: &JsonValue) -> Result, + definition_traversal: DefinitionTraversal, + visitor: &mut impl FnMut(&JsonValue), +) { + if let Some(properties) = map.get("properties") + && let Some(properties_map) = properties.as_object() + { + for value in properties_map.values() { + visitor(value); + } + } + + for key in SCHEMA_CHILD_KEYS { + if let Some(value) = map.get(key) { + visitor(value); + } + } + + if let Some(additional_properties) = map.get("additionalProperties") + && !matches!(additional_properties, JsonValue::Bool(_)) + { + visitor(additional_properties); + } + + if definition_traversal == DefinitionTraversal::Include { + for key in DEFINITION_TABLE_KEYS { + if let Some(definitions) = map.get(key) + && let Some(definitions_map) = definitions.as_object() + { + for value in definitions_map.values() { + visitor(value); + } + } + } + } +} + /// Sanitize a JSON Schema (as serde_json::Value) so it can fit our limited /// schema representation. This function: /// - Ensures every typed schema object has a `"type"` when required. /// - Preserves explicit `anyOf`. +/// - Preserves `$ref` and reachable local `$defs` / `definitions`. /// - Collapses `const` into single-value `enum`. /// - Fills required child fields for object/array schema types, including /// nullable unions, with permissive defaults when absent. @@ -200,6 +256,9 @@ fn sanitize_json_schema(value: &mut JsonValue) { if let Some(value) = map.get_mut("anyOf") { sanitize_json_schema(value); } + for table in DEFINITION_TABLE_KEYS { + sanitize_schema_table(map, table); + } if let Some(const_value) = map.remove("const") { map.insert("enum".to_string(), JsonValue::Array(vec![const_value])); @@ -207,7 +266,7 @@ fn sanitize_json_schema(value: &mut JsonValue) { let mut schema_types = normalized_schema_types(map); - if schema_types.is_empty() && map.contains_key("anyOf") { + if schema_types.is_empty() && (map.contains_key("$ref") || map.contains_key("anyOf")) { return; } @@ -241,6 +300,29 @@ fn sanitize_json_schema(value: &mut JsonValue) { } } +/// Sanitize a schema definition table before deserializing into `JsonSchema`. +/// +/// Definition tables must be objects. Codex keeps valid definition tables and +/// recursively applies the same compatibility lowering used for inline schemas, +/// but drops malformed tables so `strict: false` tool registration degrades +/// gracefully instead of failing on an unreachable or invalid definition table. +fn sanitize_schema_table(map: &mut serde_json::Map, key: &str) { + let should_remove = match map.get_mut(key) { + Some(JsonValue::Object(definitions)) => { + for definition in definitions.values_mut() { + sanitize_json_schema(definition); + } + false + } + Some(_) => true, + None => false, + }; + + if should_remove { + map.remove(key); + } +} + fn ensure_default_children_for_schema_types( map: &mut serde_json::Map, schema_types: &[JsonSchemaPrimitiveType], @@ -257,6 +339,143 @@ fn ensure_default_children_for_schema_types( } } +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +struct DefinitionPointer { + table: &'static str, + name: String, +} + +/// Prune unused root definition entries to avoid sending tokens for definitions +/// the tool schema never references. +fn prune_unreachable_definitions(value: &mut JsonValue) { + let reachable = collect_reachable_definitions(value); + let JsonValue::Object(map) = value else { + return; + }; + + for table in DEFINITION_TABLE_KEYS { + prune_schema_table(map, table, &reachable); + } +} + +fn prune_schema_table( + map: &mut serde_json::Map, + table: &'static str, + reachable: &BTreeSet, +) { + let Some(JsonValue::Object(definitions)) = map.get_mut(table) else { + return; + }; + + definitions.retain(|name, _| { + reachable.contains(&DefinitionPointer { + table, + name: name.clone(), + }) + }); + + if definitions.is_empty() { + map.remove(table); + } +} + +fn collect_reachable_definitions(value: &JsonValue) -> BTreeSet { + let mut reachable = BTreeSet::new(); + let mut pending = Vec::new(); + + collect_refs_outside_definitions(value, &mut pending); + + while let Some(pointer) = pending.pop() { + if !reachable.insert(pointer.clone()) { + continue; + } + + if let Some(definition) = definition_for_pointer(value, &pointer) { + collect_refs(definition, &mut pending); + } + } + + reachable +} + +fn collect_refs_outside_definitions(value: &JsonValue, refs: &mut Vec) { + match value { + JsonValue::Array(values) => { + for value in values { + collect_refs_outside_definitions(value, refs); + } + } + JsonValue::Object(map) => { + collect_ref_from_map(map, refs); + for_each_schema_child(map, DefinitionTraversal::Skip, &mut |value| { + collect_refs_outside_definitions(value, refs); + }); + } + _ => {} + } +} + +fn collect_refs(value: &JsonValue, refs: &mut Vec) { + match value { + JsonValue::Array(values) => { + for value in values { + collect_refs(value, refs); + } + } + JsonValue::Object(map) => { + collect_ref_from_map(map, refs); + for value in map.values() { + collect_refs(value, refs); + } + } + _ => {} + } +} + +fn collect_ref_from_map( + map: &serde_json::Map, + refs: &mut Vec, +) { + if let Some(JsonValue::String(schema_ref)) = map.get("$ref") + && let Some(pointer) = parse_local_definition_ref(schema_ref) + { + refs.push(pointer); + } +} + +fn definition_for_pointer<'a>( + value: &'a JsonValue, + pointer: &DefinitionPointer, +) -> Option<&'a JsonValue> { + let JsonValue::Object(map) = value else { + return None; + }; + + map.get(pointer.table) + .and_then(JsonValue::as_object) + .and_then(|definitions| definitions.get(&pointer.name)) +} + +fn parse_local_definition_ref(schema_ref: &str) -> Option { + let fragment = schema_ref.strip_prefix('#')?; + let pointer = urlencoding::decode(fragment).ok()?; + let pointer = jsonptr::Pointer::parse(pointer.as_ref()).ok()?; + + let (table_token, pointer) = pointer.split_front()?; + let table = table_token.decoded(); + let table = DEFINITION_TABLE_KEYS + .into_iter() + .find(|candidate| table.as_ref() == *candidate)?; + + // Responses API non-strict mode accepts nested local refs such as + // `#/$defs/User/properties/name`, so keep the parent definition reachable. + let (name, _) = pointer.split_front()?; + Some(DefinitionPointer { + table, + name: name.decoded().into_owned(), + }) +} + fn normalized_schema_types( map: &serde_json::Map, ) -> Vec { diff --git a/codex-rs/tools/src/json_schema_tests.rs b/codex-rs/tools/src/json_schema_tests.rs index 5daaf048d09c..90ddcc4529c2 100644 --- a/codex-rs/tools/src/json_schema_tests.rs +++ b/codex-rs/tools/src/json_schema_tests.rs @@ -848,3 +848,538 @@ fn parse_tool_input_schema_preserves_string_enum_constraints() { ) ); } + +#[test] +fn parse_tool_input_schema_preserves_refs_and_prunes_unreachable_defs() { + // Example schema shape: + // { + // "type": "object", + // "properties": { "user": { "$ref": "#/$defs/User" } }, + // "$defs": { + // "User": { "type": "object", "properties": { "name": { "type": "string" } } }, + // "Unused": { "type": "string" } + // } + // } + // + // Expected normalization behavior: + // - Local `$ref` is preserved as a schema hint. + // - Reachable `$defs` entries stay attached to the root schema. + // - Unreachable `$defs` entries are pruned. + let schema = parse_tool_input_schema(&serde_json::json!({ + "type": "object", + "properties": { + "user": {"$ref": "#/$defs/User"} + }, + "$defs": { + "User": { + "type": "object", + "properties": { + "name": {"type": "string"} + } + }, + "Unused": {"type": "string"} + } + })) + .expect("parse schema"); + + assert_eq!( + schema, + JsonSchema { + schema_type: Some(JsonSchemaType::Single(JsonSchemaPrimitiveType::Object)), + properties: Some(BTreeMap::from([( + "user".to_string(), + JsonSchema { + schema_ref: Some("#/$defs/User".to_string()), + ..Default::default() + }, + )])), + defs: Some(BTreeMap::from([( + "User".to_string(), + JsonSchema::object( + BTreeMap::from([( + "name".to_string(), + JsonSchema::string(/*description*/ None), + )]), + /*required*/ None, + /*additional_properties*/ None, + ), + )])), + ..Default::default() + } + ); +} + +#[test] +fn parse_tool_input_schema_preserves_refs_from_properties_named_def_tables() { + // Example schema shape: + // { + // "type": "object", + // "properties": { + // "$defs": { "$ref": "#/$defs/User" } + // }, + // "$defs": { "User": { "type": "string" }, "Unused": { "type": "boolean" } } + // } + // + // Expected normalization behavior: + // - A property named like the `$defs` keyword is treated as a user field + // while traversing `properties`. + // - Refs from that property schema still mark root definitions reachable. + // - Unreferenced root definitions are still pruned. + let schema = parse_tool_input_schema(&serde_json::json!({ + "type": "object", + "properties": { + "$defs": {"$ref": "#/$defs/User"} + }, + "$defs": { + "User": {"type": "string"}, + "Unused": {"type": "boolean"} + } + })) + .expect("parse schema"); + + assert_eq!( + schema, + JsonSchema { + schema_type: Some(JsonSchemaType::Single(JsonSchemaPrimitiveType::Object)), + properties: Some(BTreeMap::from([( + "$defs".to_string(), + JsonSchema { + schema_ref: Some("#/$defs/User".to_string()), + ..Default::default() + }, + )])), + defs: Some(BTreeMap::from([( + "User".to_string(), + JsonSchema::string(/*description*/ None), + )])), + ..Default::default() + } + ); +} + +#[test] +fn parse_tool_input_schema_collects_refs_from_schema_child_keywords() { + let schema = parse_tool_input_schema(&serde_json::json!({ + "type": "object", + "properties": { + "items_holder": { + "type": "array", + "items": {"$ref": "#/$defs/Item"} + }, + "map_holder": { + "type": "object", + "additionalProperties": {"$ref": "#/$defs/Extra"} + }, + "choice": { + "anyOf": [ + {"$ref": "#/$defs/Choice"}, + {"type": "string"} + ] + } + }, + "$defs": { + "Choice": {"type": "boolean"}, + "Extra": {"type": "number"}, + "Item": {"type": "string"}, + "Unused": {"type": "null"} + } + })) + .expect("parse schema"); + + assert_eq!( + serde_json::to_value(schema).expect("serialize schema"), + serde_json::json!({ + "type": "object", + "properties": { + "choice": { + "anyOf": [ + {"$ref": "#/$defs/Choice"}, + {"type": "string"} + ] + }, + "items_holder": { + "type": "array", + "items": {"$ref": "#/$defs/Item"} + }, + "map_holder": { + "type": "object", + "properties": {}, + "additionalProperties": {"$ref": "#/$defs/Extra"} + } + }, + "$defs": { + "Choice": {"type": "boolean"}, + "Extra": {"type": "number"}, + "Item": {"type": "string"} + } + }) + ); +} + +#[test] +fn parse_tool_input_schema_handles_cyclic_local_refs() { + // Example schema shape: + // { + // "type": "object", + // "properties": { "node": { "$ref": "#/$defs/Node" } }, + // "$defs": { + // "Node": { + // "type": "object", + // "properties": { "next": { "$ref": "#/$defs/Node" } } + // } + // } + // } + // + // Expected normalization behavior: + // - Recursive refs are preserved. + // - Pruning traversal terminates after visiting each local target once. + // - Responses API handles this recursive local-ref shape correctly. + let schema = parse_tool_input_schema(&serde_json::json!({ + "type": "object", + "properties": { + "node": {"$ref": "#/$defs/Node"} + }, + "$defs": { + "Node": { + "type": "object", + "properties": { + "next": {"$ref": "#/$defs/Node"} + } + } + } + })) + .expect("parse schema"); + + assert_eq!( + schema, + JsonSchema { + schema_type: Some(JsonSchemaType::Single(JsonSchemaPrimitiveType::Object)), + properties: Some(BTreeMap::from([( + "node".to_string(), + JsonSchema { + schema_ref: Some("#/$defs/Node".to_string()), + ..Default::default() + }, + )])), + defs: Some(BTreeMap::from([( + "Node".to_string(), + JsonSchema::object( + BTreeMap::from([( + "next".to_string(), + JsonSchema { + schema_ref: Some("#/$defs/Node".to_string()), + ..Default::default() + }, + )]), + /*required*/ None, + /*additional_properties*/ None, + ), + )])), + ..Default::default() + } + ); +} + +#[test] +fn parse_tool_input_schema_preserves_legacy_definitions() { + // Example schema shape: + // { + // "type": "object", + // "properties": { "user": { "$ref": "#/definitions/User" } }, + // "definitions": { + // "User": { "type": "object", "properties": { "profile": { "$ref": "#/definitions/Profile" } } }, + // "Profile": { "type": "object", "properties": { "name": { "type": "string" } } } + // } + // } + // + // Expected normalization behavior: + // - Codex preserves legacy `definitions`. + // - Reachability follows refs through the legacy definition table. + // - Unreachable legacy definition entries are pruned. + let schema = parse_tool_input_schema(&serde_json::json!({ + "type": "object", + "properties": { + "user": {"$ref": "#/definitions/User"} + }, + "definitions": { + "User": { + "type": "object", + "properties": { + "profile": {"$ref": "#/definitions/Profile"} + } + }, + "Profile": { + "type": "object", + "properties": { + "name": {"type": "string"} + } + }, + "Unused": {"type": "string"} + } + })) + .expect("parse schema"); + + assert_eq!( + schema, + JsonSchema { + schema_type: Some(JsonSchemaType::Single(JsonSchemaPrimitiveType::Object)), + properties: Some(BTreeMap::from([( + "user".to_string(), + JsonSchema { + schema_ref: Some("#/definitions/User".to_string()), + ..Default::default() + }, + )])), + definitions: Some(BTreeMap::from([ + ( + "Profile".to_string(), + JsonSchema::object( + BTreeMap::from([( + "name".to_string(), + JsonSchema::string(/*description*/ None), + )]), + /*required*/ None, + /*additional_properties*/ None, + ), + ), + ( + "User".to_string(), + JsonSchema::object( + BTreeMap::from([( + "profile".to_string(), + JsonSchema { + schema_ref: Some("#/definitions/Profile".to_string()), + ..Default::default() + }, + )]), + /*required*/ None, + /*additional_properties*/ None, + ), + ), + ])), + ..Default::default() + } + ); +} + +#[test] +fn parse_tool_input_schema_preserves_unresolved_and_external_refs() { + // Example schema shape: + // { + // "type": "object", + // "properties": { + // "missing": { "$ref": "#/$defs/Missing" }, + // "remote": { "$ref": "https://example.com/schema.json" } + // }, + // "$defs": { "Unused": { "type": "string" } } + // } + // + // Expected normalization behavior: + // - Unresolved local refs and external refs are preserved. + // - Unreachable local definitions are still pruned. + // - Responses API handles these refs correctly during downstream validation. + let schema = parse_tool_input_schema(&serde_json::json!({ + "type": "object", + "properties": { + "missing": {"$ref": "#/$defs/Missing"}, + "remote": {"$ref": "https://example.com/schema.json"} + }, + "$defs": { + "Unused": {"type": "string"} + } + })) + .expect("parse schema"); + + assert_eq!( + schema, + JsonSchema { + schema_type: Some(JsonSchemaType::Single(JsonSchemaPrimitiveType::Object)), + properties: Some(BTreeMap::from([ + ( + "missing".to_string(), + JsonSchema { + schema_ref: Some("#/$defs/Missing".to_string()), + ..Default::default() + }, + ), + ( + "remote".to_string(), + JsonSchema { + schema_ref: Some("https://example.com/schema.json".to_string()), + ..Default::default() + }, + ), + ])), + ..Default::default() + } + ); +} + +#[test] +fn parse_tool_input_schema_preserves_nested_defs_ref_parent() { + // Example schema shape: + // { + // "type": "object", + // "properties": { "name": { "$ref": "#/$defs/User/properties/name" } }, + // "$defs": { + // "User": { "type": "object", "properties": { "name": { "type": "string" } } }, + // "name": { "type": "string" }, + // "Unused": { "type": "boolean" } + // } + // } + // + // Expected normalization behavior: + // - The nested JSON Pointer ref remains unchanged. + // - The parent root definition is retained so the local ref does not dangle. + // - Unreferenced root definitions are still pruned. + let schema = parse_tool_input_schema(&serde_json::json!({ + "type": "object", + "properties": { + "name": {"$ref": "#/$defs/User/properties/name"} + }, + "$defs": { + "User": { + "type": "object", + "properties": { + "name": {"type": "string"} + } + }, + "name": {"type": "string"}, + "Unused": {"type": "boolean"} + } + })) + .expect("parse schema"); + + assert_eq!( + schema, + JsonSchema { + schema_type: Some(JsonSchemaType::Single(JsonSchemaPrimitiveType::Object)), + properties: Some(BTreeMap::from([( + "name".to_string(), + JsonSchema { + schema_ref: Some("#/$defs/User/properties/name".to_string()), + ..Default::default() + }, + )])), + defs: Some(BTreeMap::from([( + "User".to_string(), + JsonSchema::object( + BTreeMap::from([( + "name".to_string(), + JsonSchema::string(/*description*/ None), + )]), + /*required*/ None, + /*additional_properties*/ None, + ), + )])), + ..Default::default() + } + ); +} + +#[test] +fn parse_tool_input_schema_preserves_percent_encoded_definition_refs() { + // Example schema shape: + // { + // "type": "object", + // "properties": { + // "user": { "$ref": "#/$defs/User%20Name" }, + // "profile": { "$ref": "#/%24defs/Profile%7E0Name" } + // }, + // "$defs": { + // "User Name": { "type": "string" }, + // "Profile~Name": { "type": "string" }, + // "Unused": { "type": "boolean" } + // } + // } + // + // Expected normalization behavior: + // - URI fragment percent encoding is decoded before JSON Pointer `~` + // escaping, per RFC 6901 section 6. + // - The original `$ref` strings are preserved, but their definition + // targets are recognized as reachable and retained. + let schema = parse_tool_input_schema(&serde_json::json!({ + "type": "object", + "properties": { + "user": {"$ref": "#/$defs/User%20Name"}, + "profile": {"$ref": "#/%24defs/Profile%7E0Name"} + }, + "$defs": { + "User Name": {"type": "string"}, + "Profile~Name": {"type": "string"}, + "Unused": {"type": "boolean"} + } + })) + .expect("parse schema"); + + assert_eq!( + schema, + JsonSchema { + schema_type: Some(JsonSchemaType::Single(JsonSchemaPrimitiveType::Object)), + properties: Some(BTreeMap::from([ + ( + "profile".to_string(), + JsonSchema { + schema_ref: Some("#/%24defs/Profile%7E0Name".to_string()), + ..Default::default() + }, + ), + ( + "user".to_string(), + JsonSchema { + schema_ref: Some("#/$defs/User%20Name".to_string()), + ..Default::default() + }, + ), + ])), + defs: Some(BTreeMap::from([ + ( + "Profile~Name".to_string(), + JsonSchema::string(/*description*/ None), + ), + ( + "User Name".to_string(), + JsonSchema::string(/*description*/ None), + ), + ])), + ..Default::default() + } + ); +} + +#[test] +fn parse_tool_input_schema_drops_malformed_definition_tables() { + // Example schema shape: + // { + // "type": "object", + // "properties": { "user": { "$ref": "#/$defs/User" } }, + // "$defs": ["not", "an", "object"] + // } + // + // Expected normalization behavior: + // - Malformed `$defs` tables are dropped instead of rejecting the schema. + // - The unresolved local ref remains visible to the model. + let schema = parse_tool_input_schema(&serde_json::json!({ + "type": "object", + "properties": { + "user": {"$ref": "#/$defs/User"} + }, + "$defs": ["not", "an", "object"] + })) + .expect("parse schema"); + + assert_eq!( + schema, + JsonSchema { + schema_type: Some(JsonSchemaType::Single(JsonSchemaPrimitiveType::Object)), + properties: Some(BTreeMap::from([( + "user".to_string(), + JsonSchema { + schema_ref: Some("#/$defs/User".to_string()), + ..Default::default() + }, + )])), + ..Default::default() + } + ); +} From 7e802b22f13e2714efd2fb2a6e396319958d6506 Mon Sep 17 00:00:00 2001 From: sayan-oai Date: Thu, 21 May 2026 18:11:47 -0700 Subject: [PATCH 26/64] Expose conversation history to extension tools (#23963) ## Why Extension tools that need conversation context should be able to read it from the live tool invocation instead of reaching into thread persistence themselves. ## What changed - Add a `ConversationHistory` snapshot to extension `ToolCall`s and populate it from the current raw in-memory response history. - Expose all history items at this boundary so each extension can filter and bound the subset it needs before consuming or forwarding it. - Cover the adapter and registry dispatch paths and update existing extension tests that construct `ToolCall` literals. ## Test plan - `cargo test -p codex-tools` - `cargo test -p codex-extension-api` - `cargo test -p codex-goal-extension` - `cargo test -p codex-memories-extension` - `cargo test -p codex-core passes_turn_fields_to_extension_call` - `cargo test -p codex-core extension_tool_executors_are_model_visible_and_dispatchable` --- codex-rs/core/src/context_manager/history.rs | 5 ++++ .../src/tools/handlers/extension_tools.rs | 25 +++++++++++++++++-- codex-rs/core/src/tools/router_tests.rs | 14 +++++++++++ codex-rs/ext/extension-api/src/lib.rs | 1 + .../ext/goal/tests/goal_extension_backend.rs | 1 + codex-rs/ext/memories/src/tests.rs | 4 +++ codex-rs/tools/src/lib.rs | 1 + codex-rs/tools/src/tool_call.rs | 21 ++++++++++++++++ 8 files changed, 70 insertions(+), 2 deletions(-) diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index 6a0553548860..e5f6e4132dae 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -126,6 +126,11 @@ impl ContextManager { &self.items } + /// Returns raw items in the history and consumes the snapshot. + pub(crate) fn into_raw_items(self) -> Vec { + self.items + } + pub(crate) fn history_version(&self) -> u64 { self.history_version } diff --git a/codex-rs/core/src/tools/handlers/extension_tools.rs b/codex-rs/core/src/tools/handlers/extension_tools.rs index 764e66f0cec1..470b32beaea7 100644 --- a/codex-rs/core/src/tools/handlers/extension_tools.rs +++ b/codex-rs/core/src/tools/handlers/extension_tools.rs @@ -1,5 +1,6 @@ use std::sync::Arc; +use codex_tools::ConversationHistory; use codex_tools::ToolCall as ExtensionToolCall; use codex_tools::ToolName; use codex_tools::ToolSpec; @@ -53,7 +54,7 @@ impl ToolExecutor for ExtensionToolAdapter { &self, invocation: ToolInvocation, ) -> Result, FunctionCallError> { - self.0.handle(to_extension_call(&invocation)).await + self.0.handle(to_extension_call(&invocation).await).await } } @@ -86,12 +87,15 @@ impl CoreToolRuntime for ExtensionToolAdapter { } } -fn to_extension_call(invocation: &ToolInvocation) -> ExtensionToolCall { +async fn to_extension_call(invocation: &ToolInvocation) -> ExtensionToolCall { + let conversation_history = + ConversationHistory::new(invocation.session.clone_history().await.into_raw_items()); ExtensionToolCall { turn_id: invocation.turn.sub_id.clone(), call_id: invocation.call_id.clone(), tool_name: invocation.tool_name.clone(), truncation_policy: invocation.turn.truncation_policy, + conversation_history, payload: invocation.payload.clone(), } } @@ -108,6 +112,8 @@ fn extension_tool_hook_input(arguments: &str) -> Value { mod tests { use std::sync::Arc; + use codex_protocol::models::ContentItem; + use codex_protocol::models::ResponseItem; use pretty_assertions::assert_eq; use serde_json::json; use tokio::sync::Mutex; @@ -236,6 +242,17 @@ mod tests { let (session, turn) = crate::session::tests::make_session_and_context().await; let turn_id = turn.sub_id.clone(); let truncation_policy = turn.truncation_policy; + let history_item = ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "extension history".to_string(), + }], + phase: None, + }; + session + .record_into_history(std::slice::from_ref(&history_item), &turn) + .await; let invocation = ToolInvocation { session: session.into(), turn: turn.into(), @@ -261,6 +278,10 @@ mod tests { codex_tools::ToolName::plain("extension_echo") ); assert_eq!(captured_call.truncation_policy, truncation_policy); + assert_eq!( + captured_call.conversation_history.items(), + std::slice::from_ref(&history_item) + ); match captured_call.payload { ToolPayload::Function { arguments } => { assert_eq!(arguments, json!({ "message": "hello" }).to_string()); diff --git a/codex-rs/core/src/tools/router_tests.rs b/codex-rs/core/src/tools/router_tests.rs index 375faba248e9..9d685e53b6e5 100644 --- a/codex-rs/core/src/tools/router_tests.rs +++ b/codex-rs/core/src/tools/router_tests.rs @@ -11,6 +11,7 @@ use codex_extension_api::ResponsesApiTool; use codex_extension_api::ToolCall as ExtensionToolCall; use codex_extension_api::ToolExecutor; use codex_protocol::dynamic_tools::DynamicToolSpec; +use codex_protocol::models::ContentItem; use codex_protocol::models::FunctionCallOutputBody; use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; @@ -81,6 +82,7 @@ impl ToolExecutor for ExtensionEchoExecutor { Ok(Box::new(codex_tools::JsonToolOutput::new(json!({ "arguments": arguments, "callId": call.call_id, + "conversationHistory": call.conversation_history.items(), "ok": true, })))) } @@ -327,6 +329,17 @@ fn mcp_tool_info( async fn extension_tool_executors_are_model_visible_and_dispatchable() -> anyhow::Result<()> { let (mut session, turn) = make_session_and_context().await; session.services.extensions = extension_tool_test_registry(); + let history_item = ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "extension history".to_string(), + }], + phase: None, + }; + session + .record_into_history(std::slice::from_ref(&history_item), &turn) + .await; let router = ToolRouter::from_turn_context( &turn, @@ -384,6 +397,7 @@ async fn extension_tool_executors_are_model_visible_and_dispatchable() -> anyhow json!({ "arguments": { "message": "hello" }, "callId": "call-extension", + "conversationHistory": [history_item], "ok": true, }) ); diff --git a/codex-rs/ext/extension-api/src/lib.rs b/codex-rs/ext/extension-api/src/lib.rs index 06cbc6f88989..a7c5c87e5207 100644 --- a/codex-rs/ext/extension-api/src/lib.rs +++ b/codex-rs/ext/extension-api/src/lib.rs @@ -10,6 +10,7 @@ pub use capabilities::NoopExtensionEventSink; pub use capabilities::NoopResponseItemInjector; pub use capabilities::ResponseItemInjectionFuture; pub use capabilities::ResponseItemInjector; +pub use codex_tools::ConversationHistory; pub use codex_tools::FunctionCallError; pub use codex_tools::JsonToolOutput; pub use codex_tools::ResponsesApiTool; diff --git a/codex-rs/ext/goal/tests/goal_extension_backend.rs b/codex-rs/ext/goal/tests/goal_extension_backend.rs index 28e55064fd67..cdeacbebe6cd 100644 --- a/codex-rs/ext/goal/tests/goal_extension_backend.rs +++ b/codex-rs/ext/goal/tests/goal_extension_backend.rs @@ -625,6 +625,7 @@ fn tool_call(tool_name: &str, call_id: &str, arguments: serde_json::Value) -> To call_id: call_id.to_string(), tool_name: codex_extension_api::ToolName::plain(tool_name), truncation_policy: TruncationPolicy::Bytes(1024), + conversation_history: codex_extension_api::ConversationHistory::default(), payload: ToolPayload::Function { arguments: arguments.to_string(), }, diff --git a/codex-rs/ext/memories/src/tests.rs b/codex-rs/ext/memories/src/tests.rs index e88d7d8db912..c2e90e6520c4 100644 --- a/codex-rs/ext/memories/src/tests.rs +++ b/codex-rs/ext/memories/src/tests.rs @@ -139,6 +139,7 @@ async fn read_tool_reads_memory_file() { call_id: "call-1".to_string(), tool_name: memory_tool_name(crate::READ_TOOL_NAME), truncation_policy: TruncationPolicy::Bytes(1024), + conversation_history: codex_extension_api::ConversationHistory::default(), payload: payload.clone(), }) .await @@ -183,6 +184,7 @@ async fn search_tool_accepts_multiple_queries() { call_id: "call-1".to_string(), tool_name: memory_tool_name(crate::SEARCH_TOOL_NAME), truncation_policy: TruncationPolicy::Bytes(1024), + conversation_history: codex_extension_api::ConversationHistory::default(), payload: payload.clone(), }) .await @@ -253,6 +255,7 @@ async fn search_tool_accepts_windowed_all_match_mode() { call_id: "call-1".to_string(), tool_name: memory_tool_name(crate::SEARCH_TOOL_NAME), truncation_policy: TruncationPolicy::Bytes(1024), + conversation_history: codex_extension_api::ConversationHistory::default(), payload: payload.clone(), }) .await @@ -303,6 +306,7 @@ async fn search_tool_rejects_legacy_single_query() { call_id: "call-1".to_string(), tool_name: memory_tool_name(crate::SEARCH_TOOL_NAME), truncation_policy: TruncationPolicy::Bytes(1024), + conversation_history: codex_extension_api::ConversationHistory::default(), payload, }) .await; diff --git a/codex-rs/tools/src/lib.rs b/codex-rs/tools/src/lib.rs index 7b64776dca53..c141bfb37aa1 100644 --- a/codex-rs/tools/src/lib.rs +++ b/codex-rs/tools/src/lib.rs @@ -57,6 +57,7 @@ pub use responses_api::dynamic_tool_to_responses_api_tool; pub use responses_api::mcp_tool_to_deferred_responses_api_tool; pub use responses_api::mcp_tool_to_responses_api_tool; pub use responses_api::tool_definition_to_responses_api_tool; +pub use tool_call::ConversationHistory; pub use tool_call::ToolCall; pub use tool_config::ShellCommandBackendConfig; pub use tool_config::ToolEnvironmentMode; diff --git a/codex-rs/tools/src/tool_call.rs b/codex-rs/tools/src/tool_call.rs index f92c92f97997..32d428648fcc 100644 --- a/codex-rs/tools/src/tool_call.rs +++ b/codex-rs/tools/src/tool_call.rs @@ -1,7 +1,27 @@ use crate::FunctionCallError; use crate::ToolName; use crate::ToolPayload; +use codex_protocol::models::ResponseItem; use codex_utils_output_truncation::TruncationPolicy; +use std::sync::Arc; + +/// Raw response history snapshot available when an extension tool is invoked. +#[derive(Clone, Debug, Default)] +pub struct ConversationHistory { + items: Arc<[ResponseItem]>, +} + +impl ConversationHistory { + pub fn new(items: Vec) -> Self { + Self { + items: items.into(), + } + } + + pub fn items(&self) -> &[ResponseItem] { + &self.items + } +} // TODO: this is temporary and will disappear in the next PR (as we make codex-extension-api generic on Invocation. #[derive(Clone, Debug)] @@ -10,6 +30,7 @@ pub struct ToolCall { pub call_id: String, pub tool_name: ToolName, pub truncation_policy: TruncationPolicy, + pub conversation_history: ConversationHistory, pub payload: ToolPayload, } From 464ab40dfa1fd5058ea52512c29f38d2e4f6b204 Mon Sep 17 00:00:00 2001 From: Celia Chen Date: Thu, 21 May 2026 18:26:17 -0700 Subject: [PATCH 27/64] feat: best-effort compact large tool schemas (#23904) ## Why The `dev/cc/ref-def` branch preserves richer JSON Schema detail for connector tools, including `$defs` and nested shapes. That improves fidelity, but it pushes the largest connector schemas well past the intended tool-schema budget. This PR adds a best-effort compaction pass for unusually large tool input schemas so the p99 and max tails stay small while ordinary schemas are left alone. ## What Changed - Added best-effort large-schema compaction in `codex-rs/tools/src/json_schema.rs` after schema sanitization and definition pruning. - Compaction runs as a waterfall only while the compact JSON budget proxy is exceeded: 1. Strip schema `description` metadata. 2. Drop root `$defs` / `definitions`. 3. Collapse deep nested complex schema objects to `{}`. - Kept top-level argument names and immediate schema shape where possible. ## Corpus Results Scope: 2,025 schemas under `golden_schemas`, all parsed successfully. Token count is `o200k_base` over compact JSON from `parse_tool_input_schema`. | Percentile | Before `origin/main` `4dbca61e20` | After branch `dev/cc/ref-def` `f9bf071758` | After this PR | |---|---:|---:|---:| | p0 | 9 | 9 | 9 | | p10 | 59 | 63 | 63 | | p25 | 81 | 86 | 86 | | p50 | 114 | 127 | 125 | | p75 | 174 | 205 | 202 | | p90 | 295 | 335 | 322 | | p95 | 391 | 526 | 422 | | p99 | 794 | 1,303 | 689 | | max | 2,836 | 3,337 | 887 | After this PR, `0 / 2,025` schemas are over 1k tokens. ### Compaction Savings These are cumulative waterfall stages over the same corpus. Later passes only run for schemas that are still over the compact JSON budget proxy. | Stage | Total tokens | Step savings | Schemas changed by step | |---|---:|---:|---:| | No compaction | 391,862 | - | - | | Strip schema `description` metadata | 350,961 | 40,901 | 66 | | Drop root `$defs` / `definitions` | 340,683 | 10,278 | 13 | | Collapse deep complex schemas to `{}` | 335,875 | 4,808 | 6 | --- codex-rs/tools/src/json_schema.rs | 166 ++++++++++ codex-rs/tools/src/json_schema_tests.rs | 387 ++++++++++++++++++++++++ 2 files changed, 553 insertions(+) diff --git a/codex-rs/tools/src/json_schema.rs b/codex-rs/tools/src/json_schema.rs index 83733ffb7b48..02936d52d114 100644 --- a/codex-rs/tools/src/json_schema.rs +++ b/codex-rs/tools/src/json_schema.rs @@ -160,6 +160,7 @@ pub fn parse_tool_input_schema(input_schema: &JsonValue) -> Result Result bool { + compact_normalized_schema_len(value) <= MAX_COMPACT_TOOL_SCHEMA_BYTES +} + +fn compact_normalized_schema_len(value: &JsonValue) -> usize { + serde_json::from_value::(value.clone()) + .and_then(|schema| serde_json::to_vec(&schema)) + .map(|json| json.len()) + .unwrap_or(0) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum DefinitionTraversal { Include, @@ -214,6 +256,130 @@ fn for_each_schema_child( } } +fn strip_schema_descriptions(value: &mut JsonValue) { + match value { + JsonValue::Array(values) => { + for value in values { + strip_schema_descriptions(value); + } + } + JsonValue::Object(map) => { + map.remove("description"); + for_each_schema_child_mut(map, DefinitionTraversal::Include, &mut |value| { + strip_schema_descriptions(value); + }); + } + _ => {} + } +} + +fn for_each_schema_child_mut( + map: &mut serde_json::Map, + definition_traversal: DefinitionTraversal, + visitor: &mut impl FnMut(&mut JsonValue), +) { + if let Some(properties) = map.get_mut("properties") + && let Some(properties_map) = properties.as_object_mut() + { + for value in properties_map.values_mut() { + visitor(value); + } + } + + for key in SCHEMA_CHILD_KEYS { + if let Some(value) = map.get_mut(key) { + visitor(value); + } + } + + if let Some(additional_properties) = map.get_mut("additionalProperties") + && !matches!(additional_properties, JsonValue::Bool(_)) + { + visitor(additional_properties); + } + + if definition_traversal == DefinitionTraversal::Include { + for key in DEFINITION_TABLE_KEYS { + if let Some(definitions) = map.get_mut(key) + && let Some(definitions_map) = definitions.as_object_mut() + { + for value in definitions_map.values_mut() { + visitor(value); + } + } + } + } +} + +/// Replace local definition refs with empty schemas before dropping root +/// definition tables, so downstream behavior does not depend on how a schema +/// parser handles refs to missing definitions. +fn drop_schema_definitions(value: &mut JsonValue) { + rewrite_definition_refs_to_empty_schemas(value); + + let JsonValue::Object(map) = value else { + return; + }; + + for key in DEFINITION_TABLE_KEYS { + map.remove(key); + } +} + +fn rewrite_definition_refs_to_empty_schemas(value: &mut JsonValue) { + match value { + JsonValue::Array(values) => { + for value in values { + rewrite_definition_refs_to_empty_schemas(value); + } + } + JsonValue::Object(map) => { + if map + .get("$ref") + .and_then(JsonValue::as_str) + .and_then(parse_local_definition_ref) + .is_some() + { + *value = json!({}); + return; + } + + for_each_schema_child_mut(map, DefinitionTraversal::Skip, &mut |value| { + rewrite_definition_refs_to_empty_schemas(value); + }); + } + _ => {} + } +} + +fn collapse_deep_schema_objects(value: &mut JsonValue, depth: usize) { + match value { + JsonValue::Array(values) => { + for value in values { + collapse_deep_schema_objects(value, depth); + } + } + JsonValue::Object(map) => { + if depth >= MAX_COMPACT_TOOL_SCHEMA_DEPTH && is_complex_schema_object(map) { + *value = json!({}); + return; + } + + for_each_schema_child_mut(map, DefinitionTraversal::Skip, &mut |value| { + collapse_deep_schema_objects(value, depth + 1); + }); + } + _ => {} + } +} + +fn is_complex_schema_object(map: &serde_json::Map) -> bool { + SCHEMA_CHILD_KEYS.iter().any(|key| map.contains_key(*key)) + || map.contains_key("properties") + || map.contains_key("additionalProperties") + || map.contains_key("$ref") +} + /// Sanitize a JSON Schema (as serde_json::Value) so it can fit our limited /// schema representation. This function: /// - Ensures every typed schema object has a `"type"` when required. diff --git a/codex-rs/tools/src/json_schema_tests.rs b/codex-rs/tools/src/json_schema_tests.rs index 90ddcc4529c2..52b30138c117 100644 --- a/codex-rs/tools/src/json_schema_tests.rs +++ b/codex-rs/tools/src/json_schema_tests.rs @@ -779,6 +779,393 @@ fn parse_tool_input_schema_preserves_explicit_enum_type_union() { ); } +fn many_string_properties(count: usize) -> serde_json::Map { + (0..count) + .map(|index| { + ( + format!("field_{index:03}"), + serde_json::json!({ "type": "string" }), + ) + }) + .collect() +} + +#[test] +fn parse_large_tool_input_schema_stops_after_descriptions_when_under_budget() { + let schema = parse_tool_input_schema(&serde_json::json!({ + "type": "object", + "description": "x".repeat(4_500), + "properties": { + "metadata": { + "$ref": "#/$defs/metadata" + } + }, + "$defs": { + "metadata": { + "type": "string", + "description": "Metadata value" + } + } + })) + .expect("parse schema"); + + assert_eq!( + serde_json::to_value(schema).expect("serialize schema"), + serde_json::json!({ + "type": "object", + "properties": { + "metadata": { + "$ref": "#/$defs/metadata" + } + }, + "$defs": { + "metadata": { + "type": "string" + } + } + }) + ); +} + +#[test] +fn parse_large_tool_input_schema_ignores_dropped_metadata_for_budget() { + let schema = parse_tool_input_schema(&serde_json::json!({ + "type": "object", + "properties": { + "event": { + "type": "object", + "title": "Calendar event", + "properties": { + "recurrence": { + "type": "object", + "examples": [ + { + "payload": "x".repeat(4_500) + } + ], + "properties": { + "pattern": { + "type": "string", + "title": "Recurrence pattern" + } + } + } + } + } + } + })) + .expect("parse schema"); + + assert_eq!( + serde_json::to_value(schema).expect("serialize schema"), + serde_json::json!({ + "type": "object", + "properties": { + "event": { + "type": "object", + "properties": { + "recurrence": { + "type": "object", + "properties": { + "pattern": { + "type": "string" + } + } + } + } + } + } + }) + ); +} + +#[test] +fn parse_large_tool_input_schema_stops_after_dropping_root_definitions_when_under_budget() { + let schema = parse_tool_input_schema(&serde_json::json!({ + "type": "object", + "description": "x".repeat(4_500), + "properties": { + "event": { + "type": "object", + "description": "Calendar event", + "properties": { + "recurrence": { + "type": "object", + "description": "Recurrence settings", + "properties": { + "pattern": { + "type": "string", + "description": "Recurrence pattern" + } + } + } + } + }, + "metadata": { + "$ref": "#/$defs/metadata" + } + }, + "$defs": { + "metadata": { + "type": "object", + "description": "metadata object", + "properties": many_string_properties(/*count*/ 300) + } + } + })) + .expect("parse schema"); + + assert_eq!( + serde_json::to_value(schema).expect("serialize schema"), + serde_json::json!({ + "type": "object", + "properties": { + "event": { + "type": "object", + "properties": { + "recurrence": { + "type": "object", + "properties": { + "pattern": { + "type": "string" + } + } + } + } + }, + "metadata": {} + } + }) + ); +} + +#[test] +fn parse_large_tool_input_schema_strips_descriptions_without_removing_description_property() { + let schema = parse_tool_input_schema(&serde_json::json!({ + "type": "object", + "description": "x".repeat(4_500), + "properties": { + "description": { + "type": "string", + "description": "User-facing description value" + }, + "metadata": { + "type": "object", + "description": "Metadata object", + "properties": { + "label": { + "type": "string", + "description": "Metadata label" + } + } + }, + "tags": { + "type": "array", + "description": "Tag list", + "items": { + "type": "string", + "description": "Tag value" + } + }, + "extras": { + "type": "object", + "additionalProperties": { + "type": "string", + "description": "Extra value" + } + }, + "choice": { + "description": "Choice value", + "anyOf": [ + { + "type": "string", + "description": "String choice" + }, + { + "type": "number", + "description": "Number choice" + } + ] + } + } + })) + .expect("parse schema"); + + assert_eq!( + serde_json::to_value(schema).expect("serialize schema"), + serde_json::json!({ + "type": "object", + "properties": { + "choice": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + "description": { + "type": "string" + }, + "extras": { + "type": "object", + "properties": {}, + "additionalProperties": { + "type": "string" + } + }, + "metadata": { + "type": "object", + "properties": { + "label": { + "type": "string" + } + } + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + } + } + }) + ); +} + +#[test] +fn parse_large_tool_input_schema_preserves_object_enum_literal_descriptions() { + let schema = parse_tool_input_schema(&serde_json::json!({ + "type": "object", + "description": "x".repeat(4_500), + "properties": { + "choice": { + "enum": [ + { + "description": "first literal", + "id": 1 + }, + { + "description": "second literal", + "id": 2 + } + ] + } + } + })) + .expect("parse schema"); + + assert_eq!( + serde_json::to_value(schema).expect("serialize schema"), + serde_json::json!({ + "type": "object", + "properties": { + "choice": { + "type": "string", + "enum": [ + { + "description": "first literal", + "id": 1 + }, + { + "description": "second literal", + "id": 2 + } + ] + } + } + }) + ); +} + +#[test] +fn collapse_deep_schema_objects_traverses_schema_children() { + let mut schema = serde_json::json!({ + "type": "object", + "properties": { + "object_parent": { + "type": "object", + "properties": { + "complex": { + "type": "object", + "properties": { + "leaf": { "type": "string" } + } + }, + "scalar": { + "type": "string" + } + } + }, + "array_parent": { + "type": "array", + "items": { + "type": "object", + "properties": { + "leaf": { "type": "string" } + } + } + }, + "map_parent": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "leaf": { "type": "string" } + } + } + }, + "union_parent": { + "anyOf": [ + { + "type": "object", + "properties": { + "leaf": { "type": "string" } + } + }, + { "type": "string" } + ] + } + } + }); + + super::collapse_deep_schema_objects(&mut schema, /*depth*/ 0); + + assert_eq!( + schema, + serde_json::json!({ + "type": "object", + "properties": { + "object_parent": { + "type": "object", + "properties": { + "complex": {}, + "scalar": { + "type": "string" + } + } + }, + "array_parent": { + "type": "array", + "items": {} + }, + "map_parent": { + "type": "object", + "additionalProperties": {} + }, + "union_parent": { + "anyOf": [ + {}, + { "type": "string" } + ] + } + } + }) + ); +} + #[test] fn parse_tool_input_schema_preserves_string_enum_constraints() { // Example schema shape: From c83ba22359f4140e44fc43500d2bedbb882d7211 Mon Sep 17 00:00:00 2001 From: anp-oai Date: Thu, 21 May 2026 20:40:34 -0700 Subject: [PATCH 28/64] Allow parallel MCP tool calls when annotated readOnly (#23750) ## Summary - Treat MCP tools with `readOnlyHint: true` as parallel-safe even when `supports_parallel_tool_calls` is unset or `false`. - Keep server-level `supports_parallel_tool_calls` as an additive override for non-read-only tools. - Add focused unit coverage for the MCP handler eligibility decision. - Update RMCP integration coverage to keep the serial baseline on a mutable tool, verify read-only concurrency without server opt-in, and preserve the server opt-in concurrency path separately. ## Testing - `just fmt` - `cargo test -p codex-core --lib tools::handlers::mcp::tests::` - `cargo test -p codex-core --test all stdio_mcp_read_only_tool_calls_run_concurrently_without_server_opt_in` - `cargo test -p codex-core --test all stdio_mcp_parallel_tool_calls_opt_in_runs_concurrently` - `cargo test -p codex-rmcp-client` --- codex-rs/core/src/tools/handlers/mcp.rs | 47 +++++++ codex-rs/core/tests/suite/rmcp_client.rs | 126 +++++++++++++++++- .../rmcp-client/src/bin/test_stdio_server.rs | 11 ++ 3 files changed, 181 insertions(+), 3 deletions(-) diff --git a/codex-rs/core/src/tools/handlers/mcp.rs b/codex-rs/core/src/tools/handlers/mcp.rs index a6f3cf4ced32..5f6261323d41 100644 --- a/codex-rs/core/src/tools/handlers/mcp.rs +++ b/codex-rs/core/src/tools/handlers/mcp.rs @@ -49,7 +49,16 @@ impl ToolExecutor for McpHandler { } fn supports_parallel_tool_calls(&self) -> bool { + // Correctly implemented MCP servers should tolerate parallel calls to + // tools that advertise themselves as read-only. self.tool_info.supports_parallel_tool_calls + || self + .tool_info + .tool + .annotations + .as_ref() + .and_then(|annotations| annotations.read_only_hint) + .unwrap_or(false) } async fn handle( @@ -443,6 +452,44 @@ mod tests { assert_eq!(mcp_hook_tool_input(" "), json!({})); } + #[test] + fn mcp_read_only_hint_supports_parallel_calls_without_server_opt_in() { + let mut read_only_info = tool_info("foo", "mcp__foo__", "read"); + read_only_info.tool.annotations = Some(rmcp::model::ToolAnnotations::new().read_only(true)); + + assert!( + McpHandler::new(read_only_info) + .expect("MCP tool spec should build") + .supports_parallel_tool_calls() + ); + } + + #[test] + fn mcp_parallel_calls_require_read_only_hint_or_server_opt_in() { + let missing_hint_info = tool_info("foo", "mcp__foo__", "unannotated"); + assert!( + !McpHandler::new(missing_hint_info) + .expect("MCP tool spec should build") + .supports_parallel_tool_calls() + ); + + let mut writable_info = tool_info("foo", "mcp__foo__", "write"); + writable_info.tool.annotations = Some(rmcp::model::ToolAnnotations::new().read_only(false)); + assert!( + !McpHandler::new(writable_info) + .expect("MCP tool spec should build") + .supports_parallel_tool_calls() + ); + + let mut server_opt_in_info = tool_info("foo", "mcp__foo__", "server_opt_in"); + server_opt_in_info.supports_parallel_tool_calls = true; + assert!( + McpHandler::new(server_opt_in_info) + .expect("MCP tool spec should build") + .supports_parallel_tool_calls() + ); + } + fn tool_info(server_name: &str, callable_namespace: &str, tool_name: &str) -> ToolInfo { ToolInfo { server_name: server_name.to_string(), diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 94222deab8b2..864fb1e07a05 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -101,10 +101,28 @@ fn read_only_user_turn_with_model( fixture: &TestCodex, text: impl Into, model: String, +) -> Op { + user_turn_with_permission_profile(fixture, text, model, PermissionProfile::read_only()) +} + +fn auto_approved_user_turn(fixture: &TestCodex, text: impl Into) -> Op { + user_turn_with_permission_profile( + fixture, + text, + fixture.session_configured.model.clone(), + PermissionProfile::Disabled, + ) +} + +fn user_turn_with_permission_profile( + fixture: &TestCodex, + text: impl Into, + model: String, + permission_profile: PermissionProfile, ) -> Op { let cwd = fixture.cwd.path().to_path_buf(); let (sandbox_policy, permission_profile) = - turn_permission_fields(PermissionProfile::read_only(), cwd.as_path()); + turn_permission_fields(permission_profile, cwd.as_path()); Op::UserInput { items: vec![UserInput::Text { text: text.into(), @@ -840,7 +858,10 @@ async fn stdio_mcp_parallel_tool_calls_default_false_runs_serially() -> anyhow:: .await?; fixture .codex - .submit(read_only_user_turn( + // Keep this baseline on the mutable sync tool so read-only hints do not + // make the call parallel-safe. Bypass read-only turn permissions so + // approval behavior does not block the scheduling assertion. + .submit(auto_approved_user_turn( &fixture, "call the rmcp sync tool twice", )) @@ -899,6 +920,102 @@ async fn stdio_mcp_parallel_tool_calls_default_false_runs_serially() -> anyhow:: Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn stdio_mcp_read_only_tool_calls_run_concurrently_without_server_opt_in() +-> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + + let first_call_id = "sync-read-only-1"; + let second_call_id = "sync-read-only-2"; + let server_name = "rmcp"; + let namespace = format!("mcp__{server_name}__"); + // The stdio MCP test server holds each sync call at this barrier until both + // calls arrive. A serial scheduler times out inside the server instead of + // returning the structured `{ "result": "ok" }` result asserted below. + let args = json!({ + "sleep_after_ms": 100, + "barrier": { + "id": "stdio-mcp-read-only-tool-calls", + "participants": 2, + "timeout_ms": 1_000 + } + }) + .to_string(); + + mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_function_call_with_namespace( + first_call_id, + &namespace, + "sync_readonly", + &args, + ), + responses::ev_function_call_with_namespace( + second_call_id, + &namespace, + "sync_readonly", + &args, + ), + responses::ev_completed("resp-1"), + ]), + ) + .await; + let final_mock = mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_assistant_message("msg-1", "rmcp sync tools completed successfully."), + responses::ev_completed("resp-2"), + ]), + ) + .await; + + let rmcp_test_server_bin = remote_aware_stdio_server_bin()?; + + let fixture = test_codex() + .with_config(move |config| { + insert_mcp_server( + config, + server_name, + stdio_transport(rmcp_test_server_bin, /*env*/ None, Vec::new()), + TestMcpServerOptions { + environment_id: remote_aware_environment_id(), + tool_timeout_sec: Some(Duration::from_secs(2)), + ..Default::default() + }, + ); + }) + .build_with_remote_env(&server) + .await?; + fixture + .codex + .submit(read_only_user_turn( + &fixture, + "call the rmcp sync_readonly tool twice", + )) + .await?; + + wait_for_event(&fixture.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let request = final_mock.single_request(); + for call_id in [first_call_id, second_call_id] { + let output_text = request + .function_call_output_text(call_id) + .expect("function_call_output present for rmcp sync call"); + let wrapped_payload = split_wall_time_wrapped_output(&output_text); + let output_json: Value = serde_json::from_str(wrapped_payload) + .expect("wrapped MCP output should preserve structured JSON"); + assert_eq!(output_json, json!({ "result": "ok" })); + } + + server.verify().await; + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn stdio_mcp_parallel_tool_calls_opt_in_runs_concurrently() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); @@ -957,7 +1074,10 @@ async fn stdio_mcp_parallel_tool_calls_opt_in_runs_concurrently() -> anyhow::Res .await?; fixture .codex - .submit(read_only_user_turn( + // Exercise the server opt-in with the mutable sync tool rather than the + // read-only sync_readonly tool. Bypass read-only turn permissions so + // approval behavior does not block the scheduling assertion. + .submit(auto_approved_user_turn( &fixture, "call the rmcp sync tool twice", )) diff --git a/codex-rs/rmcp-client/src/bin/test_stdio_server.rs b/codex-rs/rmcp-client/src/bin/test_stdio_server.rs index 7add4d05f5af..50657ab182be 100644 --- a/codex-rs/rmcp-client/src/bin/test_stdio_server.rs +++ b/codex-rs/rmcp-client/src/bin/test_stdio_server.rs @@ -70,6 +70,7 @@ impl TestToolServer { Self::echo_dash_tool(), Self::cwd_tool(), Self::sync_tool(), + Self::sync_readonly_tool(), Self::image_tool(), Self::image_scenario_tool(), sandbox_meta_tool, @@ -205,6 +206,12 @@ impl TestToolServer { })) .expect("sync tool output schema should deserialize"); tool.output_schema = Some(Arc::new(output_schema)); + tool + } + + fn sync_readonly_tool() -> Tool { + let mut tool = Self::sync_tool(); + tool.name = Cow::Borrowed("sync_readonly"); tool.annotations = Some(ToolAnnotations::new().read_only(true)); tool } @@ -551,6 +558,10 @@ impl ServerHandler for TestToolServer { let args = Self::parse_call_args::(&request, "sync")?; Self::sync_result(args).await } + "sync_readonly" => { + let args = Self::parse_call_args::(&request, "sync_readonly")?; + Self::sync_result(args).await + } other => Err(McpError::invalid_params( format!("unknown tool: {other}"), None, From b14f11d3d2ca048bdae1872ef66087a2ce3f6b0c Mon Sep 17 00:00:00 2001 From: rreichel3-oai Date: Fri, 22 May 2026 01:27:25 -0400 Subject: [PATCH 29/64] [codex] Enable Node env proxy for managed network proxy (#23905) ## Summary - set `NODE_USE_ENV_PROXY=1` when Codex applies managed network proxy environment overrides - keep the Node opt-in in the proxy environment key set used by shell/runtime env handling - cover the new env var in the focused network proxy env test ## Why Codex already sets HTTP proxy environment variables for child processes when the managed network proxy is active. Node's built-in network behavior needs the `NODE_USE_ENV_PROXY` opt-in to honor those env vars, so Node-based skill scripts can otherwise skip the managed proxy path and fail under restricted network access. ## Validation - `just fmt` in `codex-rs` - `cargo test -p codex-network-proxy` in `codex-rs` --- codex-rs/network-proxy/src/proxy.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/codex-rs/network-proxy/src/proxy.rs b/codex-rs/network-proxy/src/proxy.rs index 2b76b27b8f63..1de1ed61796d 100644 --- a/codex-rs/network-proxy/src/proxy.rs +++ b/codex-rs/network-proxy/src/proxy.rs @@ -366,12 +366,14 @@ pub const ALL_PROXY_ENV_KEYS: &[&str] = &["ALL_PROXY", "all_proxy"]; pub const PROXY_ACTIVE_ENV_KEY: &str = "CODEX_NETWORK_PROXY_ACTIVE"; pub const ALLOW_LOCAL_BINDING_ENV_KEY: &str = "CODEX_NETWORK_ALLOW_LOCAL_BINDING"; const ELECTRON_GET_USE_PROXY_ENV_KEY: &str = "ELECTRON_GET_USE_PROXY"; +const NODE_USE_ENV_PROXY_ENV_KEY: &str = "NODE_USE_ENV_PROXY"; #[cfg(any(target_os = "macos", test))] const GIT_SSH_COMMAND_ENV_KEY: &str = "GIT_SSH_COMMAND"; pub const PROXY_ENV_KEYS: &[&str] = &[ PROXY_ACTIVE_ENV_KEY, ALLOW_LOCAL_BINDING_ENV_KEY, ELECTRON_GET_USE_PROXY_ENV_KEY, + NODE_USE_ENV_PROXY_ENV_KEY, "HTTP_PROXY", "HTTPS_PROXY", "http_proxy", @@ -525,6 +527,8 @@ fn apply_proxy_env_overrides( ELECTRON_GET_USE_PROXY_ENV_KEY.to_string(), "true".to_string(), ); + // Node.js built-in HTTP clients only honor proxy environment variables when this is enabled. + env.insert(NODE_USE_ENV_PROXY_ENV_KEY.to_string(), "1".to_string()); // Keep HTTP_PROXY/HTTPS_PROXY as HTTP endpoints. A lot of clients break if // those vars contain SOCKS URLs. We only switch ALL_PROXY here. @@ -1016,6 +1020,7 @@ mod tests { env.get(ELECTRON_GET_USE_PROXY_ENV_KEY), Some(&"true".to_string()) ); + assert_eq!(env.get(NODE_USE_ENV_PROXY_ENV_KEY), Some(&"1".to_string())); #[cfg(target_os = "macos")] assert_eq!( env.get(GIT_SSH_COMMAND_ENV_KEY), From ed80e5f5583d85e6f61d6839842c50b5c0630d1d Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 22 May 2026 10:40:33 +0200 Subject: [PATCH 30/64] mcp: surface profile migration guidance under --profile (#23890) ## Why `codex --profile mcp ...` should reach the same profile-v2 migration guard as runtime commands. Otherwise legacy `[profiles.]` users see the generic command-scope rejection instead of the existing guidance to move settings into `$CODEX_HOME/.config.toml`. ## What - Allow `codex mcp` through the `--profile` subcommand gate. - Pass profile loader overrides into the MCP entry point only to validate profile-v2 migration when a profile is present. - Keep MCP add/remove/list/get/login/logout behavior otherwise unchanged; this does not add profile-scoped MCP server management. - Cover the legacy profile migration error for `codex --profile work mcp list`. ## Testing - `cargo test -p codex-cli` --- codex-rs/cli/src/main.rs | 13 +++++++++++-- codex-rs/cli/src/mcp_cmd.rs | 24 +++++++++++++++++++++++- codex-rs/cli/tests/mcp_add_remove.rs | 22 ++++++++++++++++++++++ 3 files changed, 56 insertions(+), 3 deletions(-) diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 4382dee29224..929d1b86542f 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -918,7 +918,9 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { )?; // Propagate any root-level config overrides (e.g. `-c key=value`). prepend_config_flags(&mut mcp_cli.config_overrides, root_config_overrides.clone()); - mcp_cli.run().await?; + let loader_overrides = + loader_overrides_for_profile(interactive.config_profile_v2.as_ref())?; + mcp_cli.run(loader_overrides).await?; } Some(Subcommand::Plugin(plugin_cli)) => { reject_remote_mode_for_subcommand( @@ -1459,11 +1461,12 @@ fn profile_v2_for_subcommand<'a>( | Subcommand::Review(_) | Subcommand::Resume(_) | Subcommand::Fork(_) + | Subcommand::Mcp(_) | Subcommand::Debug(DebugCommand { subcommand: DebugSubcommand::PromptInput(_), }) => Ok(Some(profile_v2)), _ => anyhow::bail!( - "--profile only applies to runtime commands: `codex`, `codex exec`, `codex review`, `codex resume`, `codex fork`, and `codex debug prompt-input`." + "--profile only applies to runtime commands and `codex mcp`: `codex`, `codex exec`, `codex review`, `codex resume`, `codex fork`, `codex mcp`, and `codex debug prompt-input`." ), } } @@ -2264,6 +2267,12 @@ mod tests { .as_deref(), Some("work") ); + assert_eq!( + profile_v2_for_args(&["codex", "--profile", "work", "mcp", "list"]) + .expect("mcp supports profile-v2") + .as_deref(), + Some("work") + ); } #[test] diff --git a/codex-rs/cli/src/mcp_cmd.rs b/codex-rs/cli/src/mcp_cmd.rs index f52d24160ba9..3104262c23fd 100644 --- a/codex-rs/cli/src/mcp_cmd.rs +++ b/codex-rs/cli/src/mcp_cmd.rs @@ -11,6 +11,8 @@ use codex_config::types::McpServerConfig; use codex_config::types::McpServerTransportConfig; use codex_core::McpManager; use codex_core::config::Config; +use codex_core::config::ConfigBuilder; +use codex_core::config::LoaderOverrides; use codex_core::config::edit::ConfigEditsBuilder; use codex_core::config::find_codex_home; use codex_core::config::load_global_mcp_servers; @@ -157,12 +159,16 @@ pub struct LogoutArgs { } impl McpCli { - pub async fn run(self) -> Result<()> { + pub async fn run(self, loader_overrides: LoaderOverrides) -> Result<()> { let McpCli { config_overrides, subcommand, } = self; + if loader_overrides.user_config_profile.is_some() { + validate_profile_v2_migration(&config_overrides, loader_overrides).await?; + } + match subcommand { McpSubcommand::List(args) => { run_list(&config_overrides, args).await?; @@ -239,6 +245,22 @@ async fn perform_oauth_login_retry_without_scopes( } } +async fn validate_profile_v2_migration( + config_overrides: &CliConfigOverrides, + loader_overrides: LoaderOverrides, +) -> Result<()> { + let overrides = config_overrides + .parse_overrides() + .map_err(anyhow::Error::msg)?; + ConfigBuilder::default() + .cli_overrides(overrides) + .loader_overrides(loader_overrides) + .build() + .await + .context("failed to load configuration")?; + Ok(()) +} + async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Result<()> { // Validate any provided overrides even though they are not currently applied. let overrides = config_overrides diff --git a/codex-rs/cli/tests/mcp_add_remove.rs b/codex-rs/cli/tests/mcp_add_remove.rs index 15afaf0828f4..ecfbed1264d5 100644 --- a/codex-rs/cli/tests/mcp_add_remove.rs +++ b/codex-rs/cli/tests/mcp_add_remove.rs @@ -68,6 +68,28 @@ async fn add_and_remove_server_updates_global_config() -> Result<()> { Ok(()) } +#[tokio::test] +async fn profile_mcp_reports_legacy_profile_migration() -> Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join("config.toml"), + r#"[profiles.work] +model = "gpt-5" +"#, + )?; + + let mut list_cmd = codex_command(codex_home.path())?; + list_cmd + .args(["--profile", "work", "mcp", "list"]) + .assert() + .failure() + .stderr(contains("--profile `work` cannot be used")) + .stderr(contains("[profiles.work]")) + .stderr(contains("work.config.toml")); + + Ok(()) +} + #[tokio::test] async fn add_with_env_preserves_key_order_and_values() -> Result<()> { let codex_home = TempDir::new()?; From fd72e993842d573e119d345af3069272f037fb32 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 22 May 2026 12:13:52 +0200 Subject: [PATCH 31/64] config: remove legacy profile v1 resolution (#24051) ## Why [#23883](https://github.com/openai/codex/pull/23883) moved user-facing `--profile` selection onto profile v2, and [#23886](https://github.com/openai/codex/pull/23886) removed the old CLI `config_profile` override path. Core still had a second legacy path: `profile = "..."` could select `[profiles.*]` values while runtime config was built. Keeping that resolver alive preserves the old precedence model and profile-carrying surfaces even though profile selection now points at `$CODEX_HOME/.config.toml`. ## What - Reject legacy top-level `profile = "..."` config while loading runtime config, with an error that points callers at `--profile ` and `.config.toml` in the [core load path](https://github.com/openai/codex/blob/3d923366eca10a29143623124c6c6e538f058269/codex-rs/core/src/config/mod.rs#L2524-L2531). - Remove the remaining profile-v1 merge points from runtime config resolution, including features, permissions, model/provider selection, web search, Windows sandbox settings, TUI settings, role reloads, and OSS provider lookup. - Drop the leftover profile override surface from [`ConfigOverrides`](https://github.com/openai/codex/blob/3d923366eca10a29143623124c6c6e538f058269/codex-rs/core/src/config/mod.rs#L2118-L2148) and from the MCP server `codex` tool schema. - Prune profile-precedence tests that only exercised the removed resolver and replace them with rejection coverage for the legacy selector. ## Testing - Not run in this metadata pass. - Added [`legacy_profile_selection_is_rejected`](https://github.com/openai/codex/blob/3d923366eca10a29143623124c6c6e538f058269/codex-rs/core/src/config/config_tests.rs#L7942-L7965) coverage for the new runtime guard. --- codex-rs/cli/src/debug_sandbox.rs | 42 - codex-rs/core/src/agent/role.rs | 76 +- codex-rs/core/src/agent/role_tests.rs | 298 ----- codex-rs/core/src/config/config_tests.rs | 1083 +----------------- codex-rs/core/src/config/mod.rs | 240 +--- codex-rs/core/src/windows_sandbox.rs | 36 +- codex-rs/core/src/windows_sandbox_tests.rs | 78 +- codex-rs/exec/src/lib.rs | 7 +- codex-rs/mcp-server/src/codex_tool_config.rs | 10 - codex-rs/tui/src/lib.rs | 6 +- 10 files changed, 110 insertions(+), 1766 deletions(-) diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index bc9d6172f249..e83d90e8ffe8 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -979,48 +979,6 @@ mod tests { Ok(()) } - #[tokio::test] - async fn explicit_permission_profile_overrides_active_profile_sandbox_mode() - -> anyhow::Result<()> { - let codex_home = TempDir::new()?; - std::fs::write( - codex_home.path().join("config.toml"), - "profile = \"legacy\"\n\ - \n\ - [profiles.legacy]\n\ - sandbox_mode = \"danger-full-access\"\n", - )?; - - let config = load_debug_sandbox_config_with_codex_home( - Vec::new(), - /*codex_linux_sandbox_exe*/ None, - DebugSandboxConfigOptions { - permissions_profile: Some(":workspace".to_string()), - cwd: None, - managed_requirements_mode: ManagedRequirementsMode::Ignore, - }, - Some(codex_home.path().to_path_buf()), - /*strict_config*/ false, - ) - .await?; - - let actual = config - .permissions - .permission_profile() - .file_system_sandbox_policy(); - let expected = codex_protocol::models::PermissionProfile::workspace_write() - .file_system_sandbox_policy(); - assert!( - expected - .entries - .iter() - .all(|entry| actual.entries.contains(entry)), - "explicit workspace profile should preserve the built-in workspace rules" - ); - - Ok(()) - } - #[tokio::test] async fn debug_sandbox_honors_explicit_named_permission_profile() -> anyhow::Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core/src/agent/role.rs b/codex-rs/core/src/agent/role.rs index a38930c00e8b..886d4dd90496 100644 --- a/codex-rs/core/src/agent/role.rs +++ b/codex-rs/core/src/agent/role.rs @@ -71,14 +71,12 @@ async fn apply_role_to_config_inner( { return Ok(()); } - let (preserve_current_profile, preserve_current_provider) = - preservation_policy(config, &role_layer_toml); + let preserve_current_provider = role_layer_toml.get("model_provider").is_none(); let preserve_current_service_tier = role_layer_toml.get("service_tier").is_none(); *config = reload::build_next_config( config, role_layer_toml, - preserve_current_profile, preserve_current_provider, preserve_current_service_tier, ) @@ -130,48 +128,19 @@ pub(crate) fn resolve_role_config<'a>( .or_else(|| built_in::configs().get(role_name)) } -fn preservation_policy(config: &Config, role_layer_toml: &TomlValue) -> (bool, bool) { - let role_selects_provider = role_layer_toml.get("model_provider").is_some(); - let role_selects_profile = role_layer_toml.get("profile").is_some(); - let role_updates_active_profile_provider = config - .active_profile - .as_ref() - .and_then(|active_profile| { - role_layer_toml - .get("profiles") - .and_then(TomlValue::as_table) - .and_then(|profiles| profiles.get(active_profile)) - .and_then(TomlValue::as_table) - .map(|profile| profile.contains_key("model_provider")) - }) - .unwrap_or(false); - let preserve_current_profile = !role_selects_provider && !role_selects_profile; - let preserve_current_provider = - preserve_current_profile && !role_updates_active_profile_provider; - (preserve_current_profile, preserve_current_provider) -} - mod reload { use super::*; pub(super) async fn build_next_config( config: &Config, role_layer_toml: TomlValue, - preserve_current_profile: bool, preserve_current_provider: bool, preserve_current_service_tier: bool, ) -> anyhow::Result { - let active_profile_name = preserve_current_profile - .then_some(config.active_profile.as_deref()) - .flatten(); - let config_layer_stack = - build_config_layer_stack(config, &role_layer_toml, active_profile_name)?; - let mut merged_config = deserialize_effective_config(config, &config_layer_stack)?; - if preserve_current_profile { - merged_config.profile = None; - } + let config_layer_stack = build_config_layer_stack(config, &role_layer_toml)?; + let merged_config = deserialize_effective_config(config, &config_layer_stack)?; - let mut next_config = Config::load_config_with_layer_stack( + let next_config = Config::load_config_with_layer_stack( LOCAL_FS.as_ref(), merged_config, reload_overrides( @@ -183,23 +152,14 @@ mod reload { config_layer_stack, ) .await?; - if preserve_current_profile { - next_config.active_profile = config.active_profile.clone(); - } Ok(next_config) } fn build_config_layer_stack( config: &Config, role_layer_toml: &TomlValue, - active_profile_name: Option<&str>, ) -> anyhow::Result { let mut layers = existing_layers(config); - if let Some(resolved_profile_layer) = - resolved_profile_layer(config, &layers, role_layer_toml, active_profile_name)? - { - insert_layer(&mut layers, resolved_profile_layer); - } insert_layer(&mut layers, role_layer(role_layer_toml.clone())); Ok(ConfigLayerStack::new( layers, @@ -208,34 +168,6 @@ mod reload { )?) } - fn resolved_profile_layer( - config: &Config, - existing_layers: &[ConfigLayerEntry], - role_layer_toml: &TomlValue, - active_profile_name: Option<&str>, - ) -> anyhow::Result> { - let Some(active_profile_name) = active_profile_name else { - return Ok(None); - }; - - let mut layers = existing_layers.to_vec(); - insert_layer(&mut layers, role_layer(role_layer_toml.clone())); - let merged_config = deserialize_effective_config( - config, - &ConfigLayerStack::new( - layers, - config.config_layer_stack.requirements().clone(), - config.config_layer_stack.requirements_toml().clone(), - )?, - )?; - let resolved_profile = - merged_config.get_config_profile(Some(active_profile_name.to_string()))?; - Ok(Some(ConfigLayerEntry::new( - ConfigLayerSource::SessionFlags, - TomlValue::try_from(resolved_profile)?, - ))) - } - fn deserialize_effective_config( config: &Config, config_layer_stack: &ConfigLayerStack, diff --git a/codex-rs/core/src/agent/role_tests.rs b/codex-rs/core/src/agent/role_tests.rs index 828fa5e5913a..5461323a366b 100644 --- a/codex-rs/core/src/agent/role_tests.rs +++ b/codex-rs/core/src/agent/role_tests.rs @@ -1,13 +1,10 @@ use super::*; use crate::SkillsManager; -use crate::config::CONFIG_TOML_FILE; use crate::config::ConfigBuilder; use crate::skills_load_input_from_config; use codex_config::ConfigLayerStackOrdering; use codex_core_plugins::PluginsManager; -use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::ServiceTier; -use codex_protocol::config_types::Verbosity; use codex_protocol::openai_models::ReasoningEffort; use codex_utils_absolute_path::test_support::PathExt; use pretty_assertions::assert_eq; @@ -275,301 +272,6 @@ async fn apply_role_preserves_existing_service_tier_without_override() { ); } -#[tokio::test] -async fn apply_role_preserves_active_profile_and_model_provider() { - let home = TempDir::new().expect("create temp dir"); - tokio::fs::write( - home.path().join(CONFIG_TOML_FILE), - r#" -[model_providers.test-provider] -name = "Test Provider" -base_url = "https://example.com/v1" -env_key = "TEST_PROVIDER_API_KEY" -wire_api = "responses" - -[profiles.test-profile] -model_provider = "test-provider" -"#, - ) - .await - .expect("write config.toml"); - let mut config = ConfigBuilder::default() - .codex_home(home.path().to_path_buf()) - .harness_overrides(ConfigOverrides { - config_profile: Some("test-profile".to_string()), - ..Default::default() - }) - .fallback_cwd(Some(home.path().to_path_buf())) - .build() - .await - .expect("load config"); - let role_path = write_role_config( - &home, - "empty-role.toml", - "developer_instructions = \"Stay focused\"", - ) - .await; - config.agent_roles.insert( - "custom".to_string(), - AgentRoleConfig { - description: None, - config_file: Some(role_path), - nickname_candidates: None, - }, - ); - - apply_role_to_config(&mut config, Some("custom")) - .await - .expect("custom role should apply"); - - assert_eq!(config.active_profile.as_deref(), Some("test-profile")); - assert_eq!(config.model_provider_id, "test-provider"); - assert_eq!(config.model_provider.name, "Test Provider"); -} - -#[tokio::test] -async fn apply_role_top_level_profile_settings_override_preserved_profile() { - let home = TempDir::new().expect("create temp dir"); - tokio::fs::write( - home.path().join(CONFIG_TOML_FILE), - r#" -[profiles.base-profile] -model = "profile-model" -model_reasoning_effort = "low" -model_reasoning_summary = "concise" -model_verbosity = "low" -"#, - ) - .await - .expect("write config.toml"); - let mut config = ConfigBuilder::default() - .codex_home(home.path().to_path_buf()) - .harness_overrides(ConfigOverrides { - config_profile: Some("base-profile".to_string()), - ..Default::default() - }) - .fallback_cwd(Some(home.path().to_path_buf())) - .build() - .await - .expect("load config"); - let role_path = write_role_config( - &home, - "top-level-profile-settings-role.toml", - r#"developer_instructions = "Stay focused" -model = "role-model" -model_reasoning_effort = "high" -model_reasoning_summary = "detailed" -model_verbosity = "high" -"#, - ) - .await; - config.agent_roles.insert( - "custom".to_string(), - AgentRoleConfig { - description: None, - config_file: Some(role_path), - nickname_candidates: None, - }, - ); - - apply_role_to_config(&mut config, Some("custom")) - .await - .expect("custom role should apply"); - - assert_eq!(config.active_profile.as_deref(), Some("base-profile")); - assert_eq!(config.model.as_deref(), Some("role-model")); - assert_eq!(config.model_reasoning_effort, Some(ReasoningEffort::High)); - assert_eq!( - config.model_reasoning_summary, - Some(ReasoningSummary::Detailed) - ); - assert_eq!(config.model_verbosity, Some(Verbosity::High)); -} - -#[tokio::test] -async fn apply_role_uses_role_profile_instead_of_current_profile() { - let home = TempDir::new().expect("create temp dir"); - tokio::fs::write( - home.path().join(CONFIG_TOML_FILE), - r#" -[model_providers.base-provider] -name = "Base Provider" -base_url = "https://base.example.com/v1" -env_key = "BASE_PROVIDER_API_KEY" -wire_api = "responses" - -[model_providers.role-provider] -name = "Role Provider" -base_url = "https://role.example.com/v1" -env_key = "ROLE_PROVIDER_API_KEY" -wire_api = "responses" - -[profiles.base-profile] -model_provider = "base-provider" - -[profiles.role-profile] -model_provider = "role-provider" -"#, - ) - .await - .expect("write config.toml"); - let mut config = ConfigBuilder::default() - .codex_home(home.path().to_path_buf()) - .harness_overrides(ConfigOverrides { - config_profile: Some("base-profile".to_string()), - ..Default::default() - }) - .fallback_cwd(Some(home.path().to_path_buf())) - .build() - .await - .expect("load config"); - let role_path = write_role_config( - &home, - "profile-role.toml", - "developer_instructions = \"Stay focused\"\nprofile = \"role-profile\"", - ) - .await; - config.agent_roles.insert( - "custom".to_string(), - AgentRoleConfig { - description: None, - config_file: Some(role_path), - nickname_candidates: None, - }, - ); - - apply_role_to_config(&mut config, Some("custom")) - .await - .expect("custom role should apply"); - - assert_eq!(config.active_profile.as_deref(), Some("role-profile")); - assert_eq!(config.model_provider_id, "role-provider"); - assert_eq!(config.model_provider.name, "Role Provider"); -} - -#[tokio::test] -async fn apply_role_uses_role_model_provider_instead_of_current_profile_provider() { - let home = TempDir::new().expect("create temp dir"); - tokio::fs::write( - home.path().join(CONFIG_TOML_FILE), - r#" -[model_providers.base-provider] -name = "Base Provider" -base_url = "https://base.example.com/v1" -env_key = "BASE_PROVIDER_API_KEY" -wire_api = "responses" - -[model_providers.role-provider] -name = "Role Provider" -base_url = "https://role.example.com/v1" -env_key = "ROLE_PROVIDER_API_KEY" -wire_api = "responses" - -[profiles.base-profile] -model_provider = "base-provider" -"#, - ) - .await - .expect("write config.toml"); - let mut config = ConfigBuilder::default() - .codex_home(home.path().to_path_buf()) - .harness_overrides(ConfigOverrides { - config_profile: Some("base-profile".to_string()), - ..Default::default() - }) - .fallback_cwd(Some(home.path().to_path_buf())) - .build() - .await - .expect("load config"); - let role_path = write_role_config( - &home, - "provider-role.toml", - "developer_instructions = \"Stay focused\"\nmodel_provider = \"role-provider\"", - ) - .await; - config.agent_roles.insert( - "custom".to_string(), - AgentRoleConfig { - description: None, - config_file: Some(role_path), - nickname_candidates: None, - }, - ); - - apply_role_to_config(&mut config, Some("custom")) - .await - .expect("custom role should apply"); - - assert_eq!(config.active_profile, None); - assert_eq!(config.model_provider_id, "role-provider"); - assert_eq!(config.model_provider.name, "Role Provider"); -} - -#[tokio::test] -async fn apply_role_uses_active_profile_model_provider_update() { - let home = TempDir::new().expect("create temp dir"); - tokio::fs::write( - home.path().join(CONFIG_TOML_FILE), - r#" -[model_providers.base-provider] -name = "Base Provider" -base_url = "https://base.example.com/v1" -env_key = "BASE_PROVIDER_API_KEY" -wire_api = "responses" - -[model_providers.role-provider] -name = "Role Provider" -base_url = "https://role.example.com/v1" -env_key = "ROLE_PROVIDER_API_KEY" -wire_api = "responses" - -[profiles.base-profile] -model_provider = "base-provider" -model_reasoning_effort = "low" -"#, - ) - .await - .expect("write config.toml"); - let mut config = ConfigBuilder::default() - .codex_home(home.path().to_path_buf()) - .harness_overrides(ConfigOverrides { - config_profile: Some("base-profile".to_string()), - ..Default::default() - }) - .fallback_cwd(Some(home.path().to_path_buf())) - .build() - .await - .expect("load config"); - let role_path = write_role_config( - &home, - "profile-edit-role.toml", - r#"developer_instructions = "Stay focused" - -[profiles.base-profile] -model_provider = "role-provider" -model_reasoning_effort = "high" -"#, - ) - .await; - config.agent_roles.insert( - "custom".to_string(), - AgentRoleConfig { - description: None, - config_file: Some(role_path), - nickname_candidates: None, - }, - ); - - apply_role_to_config(&mut config, Some("custom")) - .await - .expect("custom role should apply"); - - assert_eq!(config.active_profile.as_deref(), Some("base-profile")); - assert_eq!(config.model_provider_id, "role-provider"); - assert_eq!(config.model_provider.name, "Role Provider"); - assert_eq!(config.model_reasoning_effort, Some(ReasoningEffort::High)); -} - #[tokio::test] #[cfg(not(windows))] async fn apply_role_does_not_materialize_default_sandbox_workspace_write_fields() { diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 9f40aec674ce..016a835e2f59 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -1,6 +1,5 @@ use crate::agents_md::DEFAULT_AGENTS_MD_FILENAME; use crate::agents_md::LOCAL_AGENTS_MD_FILENAME; -use crate::config::ThreadStoreConfig; use crate::config::edit::ConfigEdit; use crate::config::edit::ConfigEditsBuilder; use crate::config::edit::apply_blocking; @@ -14,7 +13,6 @@ use codex_config::config_toml::AgentsToml; use codex_config::config_toml::AutoReviewToml; use codex_config::config_toml::ConfigToml; use codex_config::config_toml::ProjectConfig; -use codex_config::config_toml::RealtimeAudioConfig; use codex_config::config_toml::RealtimeConfig; use codex_config::config_toml::RealtimeToml; use codex_config::config_toml::RealtimeTransport; @@ -50,7 +48,6 @@ use codex_config::types::Notice; use codex_config::types::NotificationCondition; use codex_config::types::NotificationMethod; use codex_config::types::Notifications; -use codex_config::types::OtelConfig; use codex_config::types::OtelConfigToml; use codex_config::types::OtelExporterKind; use codex_config::types::SandboxWorkspaceWrite; @@ -111,18 +108,6 @@ use std::path::Path; use std::time::Duration; use tempfile::TempDir; -fn active_permission_profile_state( - permission_profile: PermissionProfile, - profile_id: impl Into, -) -> PermissionProfileState { - PermissionProfileState::from_constrained_active_profile( - Constrained::allow_any(permission_profile), - Some(ActivePermissionProfile::new(profile_id)), - Vec::new(), - ) - .expect("active permission profile state should be valid") -} - fn stdio_mcp(command: &str) -> McpServerConfig { McpServerConfig { transport: McpServerTransportConfig::Stdio { @@ -1408,47 +1393,6 @@ async fn network_proxy_feature_uses_profile_network_proxy_settings() -> std::io: Ok(()) } -#[tokio::test] -async fn profile_network_proxy_disable_ignores_base_feature_config() -> std::io::Result<()> { - let codex_home = TempDir::new()?; - let cwd = TempDir::new()?; - let config = Config::load_from_base_config_with_overrides( - ConfigToml { - features: Some( - toml::from_str( - r#" -[network_proxy] -enabled = true -proxy_url = "http://127.0.0.1:43128" -"#, - ) - .expect("valid base features"), - ), - profiles: HashMap::from([( - "no_proxy".to_string(), - ConfigProfile { - features: Some( - toml::from_str("network_proxy = false").expect("valid profile features"), - ), - ..Default::default() - }, - )]), - profile: Some("no_proxy".to_string()), - ..Default::default() - }, - ConfigOverrides { - cwd: Some(cwd.path().to_path_buf()), - ..Default::default() - }, - codex_home.abs(), - ) - .await?; - - assert!(!config.features.enabled(Feature::NetworkProxy)); - assert!(config.permissions.network.is_none()); - Ok(()) -} - #[tokio::test] async fn disabled_network_proxy_feature_does_not_start_profile_proxy_policy() -> std::io::Result<()> { @@ -3459,31 +3403,6 @@ async fn runtime_config_resolves_session_picker_view_default_and_override() { cfg.tui_session_picker_view, SessionPickerViewMode::Comfortable ); - - let cfg_toml = toml::from_str::( - r#"profile = "work" - -[tui] -session_picker_view = "dense" - -[profiles.work.tui] -session_picker_view = "comfortable" -"#, - ) - .expect("parse profile scoped tui config"); - - let cfg = Config::load_from_base_config_with_overrides( - cfg_toml, - ConfigOverrides::default(), - tempdir().expect("tempdir").abs(), - ) - .await - .expect("load profile override config"); - - assert_eq!( - cfg.tui_session_picker_view, - SessionPickerViewMode::Comfortable - ); } #[tokio::test] @@ -4763,16 +4682,14 @@ async fn feedback_enabled_defaults_to_true() -> std::io::Result<()> { #[test] fn web_search_mode_defaults_to_none_if_unset() { let cfg = ConfigToml::default(); - let profile = ConfigProfile::default(); let features = Features::with_defaults(); - assert_eq!(resolve_web_search_mode(&cfg, &profile, &features), None); + assert_eq!(resolve_web_search_mode(&cfg, &features), None); } #[test] -fn web_search_mode_prefers_profile_over_legacy_flags() { - let cfg = ConfigToml::default(); - let profile = ConfigProfile { +fn web_search_mode_prefers_config_over_legacy_flags() { + let cfg = ConfigToml { web_search: Some(WebSearchMode::Live), ..Default::default() }; @@ -4780,7 +4697,7 @@ fn web_search_mode_prefers_profile_over_legacy_flags() { features.enable(Feature::WebSearchCached); assert_eq!( - resolve_web_search_mode(&cfg, &profile, &features), + resolve_web_search_mode(&cfg, &features), Some(WebSearchMode::Live) ); } @@ -4791,12 +4708,11 @@ fn web_search_mode_disabled_overrides_legacy_request() { web_search: Some(WebSearchMode::Disabled), ..Default::default() }; - let profile = ConfigProfile::default(); let mut features = Features::with_defaults(); features.enable(Feature::WebSearchRequest); assert_eq!( - resolve_web_search_mode(&cfg, &profile, &features), + resolve_web_search_mode(&cfg, &features), Some(WebSearchMode::Disabled) ); } @@ -4856,14 +4772,6 @@ async fn project_profiles_are_ignored() -> std::io::Result<()> { codex_home.path().join(CONFIG_TOML_FILE), format!( r#" -profile = "global" - -[profiles.global] -model = "gpt-global" - -[profiles.project] -model = "gpt-project" - [projects."{workspace_key}"] trust_level = "trusted" "#, @@ -4890,8 +4798,8 @@ model = "gpt-project-local" .build() .await?; - assert_eq!(config.active_profile.as_deref(), Some("global")); - assert_eq!(config.model.as_deref(), Some("gpt-global")); + assert_eq!(config.active_profile, None); + assert_eq!(config.model, None); assert!( config.startup_warnings.iter().any(|warning| { warning.contains("profile") @@ -4908,7 +4816,7 @@ model = "gpt-project-local" } #[tokio::test] -async fn profile_sandbox_mode_overrides_base() -> std::io::Result<()> { +async fn unselected_profile_sandbox_mode_is_ignored() -> std::io::Result<()> { let codex_home = TempDir::new()?; let mut profiles = HashMap::new(); profiles.insert( @@ -4920,7 +4828,6 @@ async fn profile_sandbox_mode_overrides_base() -> std::io::Result<()> { ); let cfg = ConfigToml { profiles, - profile: Some("work".to_string()), sandbox_mode: Some(SandboxMode::ReadOnly), ..Default::default() }; @@ -4932,50 +4839,10 @@ async fn profile_sandbox_mode_overrides_base() -> std::io::Result<()> { ) .await?; - assert!(matches!( - &config.legacy_sandbox_policy(), - &SandboxPolicy::DangerFullAccess - )); - - Ok(()) -} - -#[tokio::test] -async fn cli_override_takes_precedence_over_profile_sandbox_mode() -> std::io::Result<()> { - let codex_home = TempDir::new()?; - let mut profiles = HashMap::new(); - profiles.insert( - "work".to_string(), - ConfigProfile { - sandbox_mode: Some(SandboxMode::DangerFullAccess), - ..Default::default() - }, + assert_eq!( + config.legacy_sandbox_policy(), + SandboxPolicy::new_read_only_policy() ); - let cfg = ConfigToml { - profiles, - profile: Some("work".to_string()), - ..Default::default() - }; - - let overrides = ConfigOverrides { - sandbox_mode: Some(SandboxMode::WorkspaceWrite), - ..Default::default() - }; - - let config = - Config::load_from_base_config_with_overrides(cfg, overrides, codex_home.abs()).await?; - - if cfg!(target_os = "windows") { - assert!(matches!( - &config.legacy_sandbox_policy(), - SandboxPolicy::ReadOnly { .. } - )); - } else { - assert!(matches!( - &config.legacy_sandbox_policy(), - SandboxPolicy::WorkspaceWrite { .. } - )); - } Ok(()) } @@ -6610,16 +6477,9 @@ struct PrecedenceTestFixture { cwd: TempDir, codex_home: TempDir, cfg: ConfigToml, - model_provider_map: HashMap, - openai_provider: ModelProviderInfo, - openai_custom_provider: ModelProviderInfo, } impl PrecedenceTestFixture { - fn cwd(&self) -> AbsolutePathBuf { - self.cwd.abs() - } - fn cwd_path(&self) -> PathBuf { self.cwd.path().to_path_buf() } @@ -8019,10 +7879,6 @@ fn create_test_fixture() -> std::io::Result { model = "o3" approval_policy = "untrusted" -# Can be used to determine which profile to use if not specified by -# `ConfigOverrides`. -profile = "gpt3" - [analytics] enabled = true @@ -8076,207 +7932,34 @@ model_verbosity = "high" let codex_home_temp_dir = TempDir::new().unwrap(); - let openai_custom_provider = ModelProviderInfo { - name: "OpenAI custom".to_string(), - base_url: Some("https://api.openai.com/v1".to_string()), - env_key: Some("OPENAI_API_KEY".to_string()), - wire_api: WireApi::Responses, - env_key_instructions: None, - experimental_bearer_token: None, - auth: None, - aws: None, - query_params: None, - http_headers: None, - env_http_headers: None, - request_max_retries: Some(4), - stream_max_retries: Some(10), - stream_idle_timeout_ms: Some(300_000), - websocket_connect_timeout_ms: Some(15_000), - requires_openai_auth: false, - supports_websockets: false, - }; - let model_provider_map = { - let mut model_provider_map = - built_in_model_providers(/* openai_base_url */ /*openai_base_url*/ None); - model_provider_map.insert("openai-custom".to_string(), openai_custom_provider.clone()); - model_provider_map - }; - - let openai_provider = model_provider_map - .get("openai") - .expect("openai provider should exist") - .clone(); - Ok(PrecedenceTestFixture { cwd: cwd_temp_dir, codex_home: codex_home_temp_dir, cfg, - model_provider_map, - openai_provider, - openai_custom_provider, }) } -/// Users can specify config values at multiple levels that have the -/// following precedence: -/// -/// 1. custom command-line argument, e.g. `--model o3` -/// 2. as part of a profile, where the `--profile` is specified via a CLI -/// (or in the config file itself) -/// 3. as an entry in `config.toml`, e.g. `model = "o3"` -/// 4. the default value for a required field defined in code. -/// -/// Note that profiles are the recommended way to specify a group of -/// configuration options together. #[tokio::test] -async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { - let fixture = create_test_fixture()?; +async fn legacy_profile_selection_is_rejected() -> std::io::Result<()> { + let mut fixture = create_test_fixture()?; + fixture.cfg.profile = Some("gpt3".to_string()); - let o3_profile_overrides = ConfigOverrides { - config_profile: Some("o3".to_string()), - cwd: Some(fixture.cwd_path()), - ..Default::default() - }; - let o3_profile_config: Config = Config::load_from_base_config_with_overrides( + let err = Config::load_from_base_config_with_overrides( fixture.cfg.clone(), - o3_profile_overrides, + ConfigOverrides { + cwd: Some(fixture.cwd_path()), + ..Default::default() + }, fixture.codex_home(), ) - .await?; - assert_eq!( - Config { - model: Some("o3".to_string()), - review_model: None, - model_context_window: None, - model_auto_compact_token_limit: None, - model_auto_compact_token_limit_scope: AutoCompactTokenLimitScope::Total, - service_tier: None, - model_provider_id: "openai".to_string(), - model_provider: fixture.openai_provider.clone(), - permissions: Permissions { - approval_policy: Constrained::allow_any(AskForApproval::Never), - permission_profile_state: active_permission_profile_state( - PermissionProfile::read_only(), - BUILT_IN_PERMISSION_PROFILE_READ_ONLY, - ), - workspace_roots: vec![fixture.cwd()], - network: None, - allow_login_shell: true, - shell_environment_policy: ShellEnvironmentPolicy::default(), - windows_sandbox_mode: None, - windows_sandbox_private_desktop: true, - }, - explicit_permission_profile_mode: false, - custom_permission_profile_ids: Vec::new(), - approvals_reviewer: ApprovalsReviewer::User, - enforce_residency: Constrained::allow_any(/*initial_value*/ None), - user_instructions: None, - notify: None, - cwd: fixture.cwd(), - workspace_roots: vec![fixture.cwd()], - workspace_roots_explicit: false, - cli_auth_credentials_store_mode: Default::default(), - mcp_servers: Constrained::allow_any(HashMap::new()), - mcp_oauth_credentials_store_mode: resolve_mcp_oauth_credentials_store_mode( - Default::default(), - LOCAL_DEV_BUILD_VERSION, - ), - mcp_oauth_callback_port: None, - mcp_oauth_callback_url: None, - model_providers: fixture.model_provider_map.clone(), - project_doc_max_bytes: AGENTS_MD_MAX_BYTES, - project_doc_fallback_filenames: Vec::new(), - tool_output_token_limit: None, - agent_max_threads: DEFAULT_AGENT_MAX_THREADS, - agent_max_depth: DEFAULT_AGENT_MAX_DEPTH, - agent_roles: BTreeMap::new(), - memories: MemoriesConfig::default(), - agent_job_max_runtime_seconds: DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS, - agent_interrupt_message_enabled: true, - codex_home: fixture.codex_home(), - sqlite_home: fixture.codex_home().to_path_buf(), - log_dir: fixture.codex_home().join("log").to_path_buf(), - config_lock_export_dir: None, - config_lock_allow_codex_version_mismatch: false, - config_lock_save_fields_resolved_from_model_catalog: true, - config_lock_toml: None, - config_layer_stack: Default::default(), - startup_warnings: Vec::new(), - history: History::default(), - ephemeral: false, - bypass_hook_trust: false, - file_opener: UriBasedFileOpener::VsCode, - codex_self_exe: None, - codex_linux_sandbox_exe: None, - main_execve_wrapper_exe: None, - zsh_path: None, - hide_agent_reasoning: false, - show_raw_agent_reasoning: false, - model_reasoning_effort: Some(ReasoningEffort::High), - plan_mode_reasoning_effort: None, - model_reasoning_summary: Some(ReasoningSummary::Detailed), - model_supports_reasoning_summaries: None, - model_catalog: None, - model_verbosity: None, - personality: Some(Personality::Pragmatic), - chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), - apps_mcp_path_override: None, - apps_mcp_product_sku: None, - realtime_audio: RealtimeAudioConfig::default(), - experimental_realtime_start_instructions: None, - experimental_realtime_ws_base_url: None, - experimental_realtime_ws_model: None, - realtime: RealtimeConfig::default(), - experimental_realtime_ws_backend_prompt: None, - experimental_realtime_ws_startup_context: None, - experimental_thread_config_endpoint: None, - experimental_thread_store: ThreadStoreConfig::Local, - base_instructions: None, - developer_instructions: None, - guardian_policy_config: None, - include_permissions_instructions: true, - include_apps_instructions: true, - include_collaboration_mode_instructions: true, - include_skill_instructions: true, - include_environment_context: true, - compact_prompt: None, - forced_chatgpt_workspace_id: None, - forced_login_method: None, - web_search_mode: Constrained::allow_any(WebSearchMode::Cached), - web_search_config: None, - use_experimental_unified_exec_tool: !cfg!(windows), - background_terminal_max_timeout: DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS, - ghost_snapshot: GhostSnapshotConfig::default(), - multi_agent_v2: MultiAgentV2Config::default(), - features: Features::with_defaults().into(), - suppress_unstable_features_warning: false, - active_profile: Some("o3".to_string()), - active_project: ProjectConfig { trust_level: None }, - notices: Default::default(), - check_for_update_on_startup: true, - disable_paste_burst: false, - tui_notifications: Default::default(), - animations: true, - show_tooltips: true, - tui_vim_mode_default: false, - tui_raw_output_mode: false, - tui_keymap: TuiKeymap::default(), - model_availability_nux: ModelAvailabilityNuxConfig::default(), - terminal_resize_reflow: TerminalResizeReflowConfig::default(), - analytics_enabled: Some(true), - feedback_enabled: true, - tool_suggest: ToolSuggestConfig::default(), - tui_alternate_screen: AltScreenMode::Auto, - tui_status_line: None, - tui_status_line_use_colors: true, - tui_terminal_title: None, - tui_theme: None, - tui_pet: None, - tui_pet_anchor: TuiPetAnchor::Composer, - tui_session_picker_view: SessionPickerViewMode::Dense, - otel: OtelConfig::default(), - }, - o3_profile_config + .await + .expect_err("legacy profile selection should be rejected"); + + assert_eq!(err.kind(), ErrorKind::InvalidData); + assert!( + err.to_string() + .contains("legacy `profile = \"gpt3\"` config is no longer supported"), + "unexpected error: {err}" ); Ok(()) } @@ -8608,480 +8291,6 @@ async fn fast_default_opt_out_notice_config_is_respected() -> std::io::Result<() Ok(()) } -#[tokio::test] -async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { - let fixture = create_test_fixture()?; - - let gpt3_profile_overrides = ConfigOverrides { - config_profile: Some("gpt3".to_string()), - cwd: Some(fixture.cwd_path()), - ..Default::default() - }; - let gpt3_profile_config = Config::load_from_base_config_with_overrides( - fixture.cfg.clone(), - gpt3_profile_overrides, - fixture.codex_home(), - ) - .await?; - let expected_gpt3_profile_config = Config { - model: Some("gpt-3.5-turbo".to_string()), - review_model: None, - model_context_window: None, - model_auto_compact_token_limit: None, - model_auto_compact_token_limit_scope: AutoCompactTokenLimitScope::Total, - service_tier: None, - model_provider_id: "openai-custom".to_string(), - model_provider: fixture.openai_custom_provider.clone(), - permissions: Permissions { - approval_policy: Constrained::allow_any(AskForApproval::UnlessTrusted), - permission_profile_state: active_permission_profile_state( - PermissionProfile::read_only(), - BUILT_IN_PERMISSION_PROFILE_READ_ONLY, - ), - workspace_roots: vec![fixture.cwd()], - network: None, - allow_login_shell: true, - shell_environment_policy: ShellEnvironmentPolicy::default(), - windows_sandbox_mode: None, - windows_sandbox_private_desktop: true, - }, - explicit_permission_profile_mode: false, - custom_permission_profile_ids: Vec::new(), - approvals_reviewer: ApprovalsReviewer::User, - enforce_residency: Constrained::allow_any(/*initial_value*/ None), - user_instructions: None, - notify: None, - cwd: fixture.cwd(), - workspace_roots: vec![fixture.cwd()], - workspace_roots_explicit: false, - cli_auth_credentials_store_mode: Default::default(), - mcp_servers: Constrained::allow_any(HashMap::new()), - mcp_oauth_credentials_store_mode: resolve_mcp_oauth_credentials_store_mode( - Default::default(), - LOCAL_DEV_BUILD_VERSION, - ), - mcp_oauth_callback_port: None, - mcp_oauth_callback_url: None, - model_providers: fixture.model_provider_map.clone(), - project_doc_max_bytes: AGENTS_MD_MAX_BYTES, - project_doc_fallback_filenames: Vec::new(), - tool_output_token_limit: None, - agent_max_threads: DEFAULT_AGENT_MAX_THREADS, - agent_max_depth: DEFAULT_AGENT_MAX_DEPTH, - agent_roles: BTreeMap::new(), - memories: MemoriesConfig::default(), - agent_job_max_runtime_seconds: DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS, - agent_interrupt_message_enabled: true, - codex_home: fixture.codex_home(), - sqlite_home: fixture.codex_home().to_path_buf(), - log_dir: fixture.codex_home().join("log").to_path_buf(), - config_lock_export_dir: None, - config_lock_allow_codex_version_mismatch: false, - config_lock_save_fields_resolved_from_model_catalog: true, - config_lock_toml: None, - config_layer_stack: Default::default(), - startup_warnings: Vec::new(), - history: History::default(), - ephemeral: false, - bypass_hook_trust: false, - file_opener: UriBasedFileOpener::VsCode, - codex_self_exe: None, - codex_linux_sandbox_exe: None, - main_execve_wrapper_exe: None, - zsh_path: None, - hide_agent_reasoning: false, - show_raw_agent_reasoning: false, - model_reasoning_effort: None, - plan_mode_reasoning_effort: None, - model_reasoning_summary: None, - model_supports_reasoning_summaries: None, - model_catalog: None, - model_verbosity: None, - personality: Some(Personality::Pragmatic), - chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), - apps_mcp_path_override: None, - apps_mcp_product_sku: None, - realtime_audio: RealtimeAudioConfig::default(), - experimental_realtime_start_instructions: None, - experimental_realtime_ws_base_url: None, - experimental_realtime_ws_model: None, - realtime: RealtimeConfig::default(), - experimental_realtime_ws_backend_prompt: None, - experimental_realtime_ws_startup_context: None, - experimental_thread_config_endpoint: None, - experimental_thread_store: ThreadStoreConfig::Local, - base_instructions: None, - developer_instructions: None, - guardian_policy_config: None, - include_permissions_instructions: true, - include_apps_instructions: true, - include_collaboration_mode_instructions: true, - include_skill_instructions: true, - include_environment_context: true, - compact_prompt: None, - forced_chatgpt_workspace_id: None, - forced_login_method: None, - web_search_mode: Constrained::allow_any(WebSearchMode::Cached), - web_search_config: None, - use_experimental_unified_exec_tool: !cfg!(windows), - background_terminal_max_timeout: DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS, - ghost_snapshot: GhostSnapshotConfig::default(), - multi_agent_v2: MultiAgentV2Config::default(), - features: Features::with_defaults().into(), - suppress_unstable_features_warning: false, - active_profile: Some("gpt3".to_string()), - active_project: ProjectConfig { trust_level: None }, - notices: Default::default(), - check_for_update_on_startup: true, - disable_paste_burst: false, - tui_notifications: Default::default(), - animations: true, - show_tooltips: true, - tui_vim_mode_default: false, - tui_raw_output_mode: false, - tui_keymap: TuiKeymap::default(), - model_availability_nux: ModelAvailabilityNuxConfig::default(), - terminal_resize_reflow: TerminalResizeReflowConfig::default(), - analytics_enabled: Some(true), - feedback_enabled: true, - tool_suggest: ToolSuggestConfig::default(), - tui_alternate_screen: AltScreenMode::Auto, - tui_status_line: None, - tui_status_line_use_colors: true, - tui_terminal_title: None, - tui_theme: None, - tui_pet: None, - tui_pet_anchor: TuiPetAnchor::Composer, - tui_session_picker_view: SessionPickerViewMode::Dense, - otel: OtelConfig::default(), - }; - - assert_eq!(expected_gpt3_profile_config, gpt3_profile_config); - - // Verify that loading without specifying a profile in ConfigOverrides - // uses the default profile from the config file (which is "gpt3"). - let default_profile_overrides = ConfigOverrides { - cwd: Some(fixture.cwd_path()), - ..Default::default() - }; - - let default_profile_config = Config::load_from_base_config_with_overrides( - fixture.cfg.clone(), - default_profile_overrides, - fixture.codex_home(), - ) - .await?; - - assert_eq!(expected_gpt3_profile_config, default_profile_config); - Ok(()) -} - -#[tokio::test] -async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { - let fixture = create_test_fixture()?; - - let zdr_profile_overrides = ConfigOverrides { - config_profile: Some("zdr".to_string()), - cwd: Some(fixture.cwd_path()), - ..Default::default() - }; - let zdr_profile_config = Config::load_from_base_config_with_overrides( - fixture.cfg.clone(), - zdr_profile_overrides, - fixture.codex_home(), - ) - .await?; - let expected_zdr_profile_config = Config { - model: Some("o3".to_string()), - review_model: None, - model_context_window: None, - model_auto_compact_token_limit: None, - model_auto_compact_token_limit_scope: AutoCompactTokenLimitScope::Total, - service_tier: None, - model_provider_id: "openai".to_string(), - model_provider: fixture.openai_provider.clone(), - permissions: Permissions { - approval_policy: Constrained::allow_any(AskForApproval::OnFailure), - permission_profile_state: active_permission_profile_state( - PermissionProfile::read_only(), - BUILT_IN_PERMISSION_PROFILE_READ_ONLY, - ), - workspace_roots: vec![fixture.cwd()], - network: None, - allow_login_shell: true, - shell_environment_policy: ShellEnvironmentPolicy::default(), - windows_sandbox_mode: None, - windows_sandbox_private_desktop: true, - }, - explicit_permission_profile_mode: false, - custom_permission_profile_ids: Vec::new(), - approvals_reviewer: ApprovalsReviewer::User, - enforce_residency: Constrained::allow_any(/*initial_value*/ None), - user_instructions: None, - notify: None, - cwd: fixture.cwd(), - workspace_roots: vec![fixture.cwd()], - workspace_roots_explicit: false, - cli_auth_credentials_store_mode: Default::default(), - mcp_servers: Constrained::allow_any(HashMap::new()), - mcp_oauth_credentials_store_mode: resolve_mcp_oauth_credentials_store_mode( - Default::default(), - LOCAL_DEV_BUILD_VERSION, - ), - mcp_oauth_callback_port: None, - mcp_oauth_callback_url: None, - model_providers: fixture.model_provider_map.clone(), - project_doc_max_bytes: AGENTS_MD_MAX_BYTES, - project_doc_fallback_filenames: Vec::new(), - tool_output_token_limit: None, - agent_max_threads: DEFAULT_AGENT_MAX_THREADS, - agent_max_depth: DEFAULT_AGENT_MAX_DEPTH, - agent_roles: BTreeMap::new(), - memories: MemoriesConfig::default(), - agent_job_max_runtime_seconds: DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS, - agent_interrupt_message_enabled: true, - codex_home: fixture.codex_home(), - sqlite_home: fixture.codex_home().to_path_buf(), - log_dir: fixture.codex_home().join("log").to_path_buf(), - config_lock_export_dir: None, - config_lock_allow_codex_version_mismatch: false, - config_lock_save_fields_resolved_from_model_catalog: true, - config_lock_toml: None, - config_layer_stack: Default::default(), - startup_warnings: Vec::new(), - history: History::default(), - ephemeral: false, - bypass_hook_trust: false, - file_opener: UriBasedFileOpener::VsCode, - codex_self_exe: None, - codex_linux_sandbox_exe: None, - main_execve_wrapper_exe: None, - zsh_path: None, - hide_agent_reasoning: false, - show_raw_agent_reasoning: false, - model_reasoning_effort: None, - plan_mode_reasoning_effort: None, - model_reasoning_summary: None, - model_supports_reasoning_summaries: None, - model_catalog: None, - model_verbosity: None, - personality: Some(Personality::Pragmatic), - chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), - apps_mcp_path_override: None, - apps_mcp_product_sku: None, - realtime_audio: RealtimeAudioConfig::default(), - experimental_realtime_start_instructions: None, - experimental_realtime_ws_base_url: None, - experimental_realtime_ws_model: None, - realtime: RealtimeConfig::default(), - experimental_realtime_ws_backend_prompt: None, - experimental_realtime_ws_startup_context: None, - experimental_thread_config_endpoint: None, - experimental_thread_store: ThreadStoreConfig::Local, - base_instructions: None, - developer_instructions: None, - guardian_policy_config: None, - include_permissions_instructions: true, - include_apps_instructions: true, - include_collaboration_mode_instructions: true, - include_skill_instructions: true, - include_environment_context: true, - compact_prompt: None, - forced_chatgpt_workspace_id: None, - forced_login_method: None, - web_search_mode: Constrained::allow_any(WebSearchMode::Cached), - web_search_config: None, - use_experimental_unified_exec_tool: !cfg!(windows), - background_terminal_max_timeout: DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS, - ghost_snapshot: GhostSnapshotConfig::default(), - multi_agent_v2: MultiAgentV2Config::default(), - features: Features::with_defaults().into(), - suppress_unstable_features_warning: false, - active_profile: Some("zdr".to_string()), - active_project: ProjectConfig { trust_level: None }, - notices: Default::default(), - check_for_update_on_startup: true, - disable_paste_burst: false, - tui_notifications: Default::default(), - animations: true, - show_tooltips: true, - tui_vim_mode_default: false, - tui_raw_output_mode: false, - tui_keymap: TuiKeymap::default(), - model_availability_nux: ModelAvailabilityNuxConfig::default(), - terminal_resize_reflow: TerminalResizeReflowConfig::default(), - analytics_enabled: Some(false), - feedback_enabled: true, - tool_suggest: ToolSuggestConfig::default(), - tui_alternate_screen: AltScreenMode::Auto, - tui_status_line: None, - tui_status_line_use_colors: true, - tui_terminal_title: None, - tui_theme: None, - tui_pet: None, - tui_pet_anchor: TuiPetAnchor::Composer, - tui_session_picker_view: SessionPickerViewMode::Dense, - otel: OtelConfig::default(), - }; - - assert_eq!(expected_zdr_profile_config, zdr_profile_config); - - Ok(()) -} - -#[tokio::test] -async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { - let fixture = create_test_fixture()?; - - let gpt5_profile_overrides = ConfigOverrides { - config_profile: Some("gpt5".to_string()), - cwd: Some(fixture.cwd_path()), - ..Default::default() - }; - let gpt5_profile_config = Config::load_from_base_config_with_overrides( - fixture.cfg.clone(), - gpt5_profile_overrides, - fixture.codex_home(), - ) - .await?; - let expected_gpt5_profile_config = Config { - model: Some("gpt-5.4".to_string()), - review_model: None, - model_context_window: None, - model_auto_compact_token_limit: None, - model_auto_compact_token_limit_scope: AutoCompactTokenLimitScope::Total, - service_tier: None, - model_provider_id: "openai".to_string(), - model_provider: fixture.openai_provider.clone(), - permissions: Permissions { - approval_policy: Constrained::allow_any(AskForApproval::OnFailure), - permission_profile_state: active_permission_profile_state( - PermissionProfile::read_only(), - BUILT_IN_PERMISSION_PROFILE_READ_ONLY, - ), - workspace_roots: vec![fixture.cwd()], - network: None, - allow_login_shell: true, - shell_environment_policy: ShellEnvironmentPolicy::default(), - windows_sandbox_mode: None, - windows_sandbox_private_desktop: true, - }, - explicit_permission_profile_mode: false, - custom_permission_profile_ids: Vec::new(), - approvals_reviewer: ApprovalsReviewer::User, - enforce_residency: Constrained::allow_any(/*initial_value*/ None), - user_instructions: None, - notify: None, - cwd: fixture.cwd(), - workspace_roots: vec![fixture.cwd()], - workspace_roots_explicit: false, - cli_auth_credentials_store_mode: Default::default(), - mcp_servers: Constrained::allow_any(HashMap::new()), - mcp_oauth_credentials_store_mode: resolve_mcp_oauth_credentials_store_mode( - Default::default(), - LOCAL_DEV_BUILD_VERSION, - ), - mcp_oauth_callback_port: None, - mcp_oauth_callback_url: None, - model_providers: fixture.model_provider_map.clone(), - project_doc_max_bytes: AGENTS_MD_MAX_BYTES, - project_doc_fallback_filenames: Vec::new(), - tool_output_token_limit: None, - agent_max_threads: DEFAULT_AGENT_MAX_THREADS, - agent_max_depth: DEFAULT_AGENT_MAX_DEPTH, - agent_roles: BTreeMap::new(), - memories: MemoriesConfig::default(), - agent_job_max_runtime_seconds: DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS, - agent_interrupt_message_enabled: true, - codex_home: fixture.codex_home(), - sqlite_home: fixture.codex_home().to_path_buf(), - log_dir: fixture.codex_home().join("log").to_path_buf(), - config_lock_export_dir: None, - config_lock_allow_codex_version_mismatch: false, - config_lock_save_fields_resolved_from_model_catalog: true, - config_lock_toml: None, - config_layer_stack: Default::default(), - startup_warnings: Vec::new(), - history: History::default(), - ephemeral: false, - bypass_hook_trust: false, - file_opener: UriBasedFileOpener::VsCode, - codex_self_exe: None, - codex_linux_sandbox_exe: None, - main_execve_wrapper_exe: None, - zsh_path: None, - hide_agent_reasoning: false, - show_raw_agent_reasoning: false, - model_reasoning_effort: Some(ReasoningEffort::High), - plan_mode_reasoning_effort: None, - model_reasoning_summary: Some(ReasoningSummary::Detailed), - model_supports_reasoning_summaries: None, - model_catalog: None, - model_verbosity: Some(Verbosity::High), - personality: Some(Personality::Pragmatic), - chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), - apps_mcp_path_override: None, - apps_mcp_product_sku: None, - realtime_audio: RealtimeAudioConfig::default(), - experimental_realtime_start_instructions: None, - experimental_realtime_ws_base_url: None, - experimental_realtime_ws_model: None, - realtime: RealtimeConfig::default(), - experimental_realtime_ws_backend_prompt: None, - experimental_realtime_ws_startup_context: None, - experimental_thread_config_endpoint: None, - experimental_thread_store: ThreadStoreConfig::Local, - base_instructions: None, - developer_instructions: None, - guardian_policy_config: None, - include_permissions_instructions: true, - include_apps_instructions: true, - include_collaboration_mode_instructions: true, - include_skill_instructions: true, - include_environment_context: true, - compact_prompt: None, - forced_chatgpt_workspace_id: None, - forced_login_method: None, - web_search_mode: Constrained::allow_any(WebSearchMode::Cached), - web_search_config: None, - use_experimental_unified_exec_tool: !cfg!(windows), - background_terminal_max_timeout: DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS, - ghost_snapshot: GhostSnapshotConfig::default(), - multi_agent_v2: MultiAgentV2Config::default(), - features: Features::with_defaults().into(), - suppress_unstable_features_warning: false, - active_profile: Some("gpt5".to_string()), - active_project: ProjectConfig { trust_level: None }, - notices: Default::default(), - check_for_update_on_startup: true, - disable_paste_burst: false, - tui_notifications: Default::default(), - animations: true, - show_tooltips: true, - tui_vim_mode_default: false, - tui_raw_output_mode: false, - tui_keymap: TuiKeymap::default(), - model_availability_nux: ModelAvailabilityNuxConfig::default(), - terminal_resize_reflow: TerminalResizeReflowConfig::default(), - analytics_enabled: Some(true), - feedback_enabled: true, - tool_suggest: ToolSuggestConfig::default(), - tui_alternate_screen: AltScreenMode::Auto, - tui_status_line: None, - tui_status_line_use_colors: true, - tui_terminal_title: None, - tui_theme: None, - tui_pet: None, - tui_pet_anchor: TuiPetAnchor::Composer, - tui_session_picker_view: SessionPickerViewMode::Dense, - otel: OtelConfig::default(), - }; - - assert_eq!(expected_gpt5_profile_config, gpt5_profile_config); - - Ok(()) -} - #[tokio::test] async fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset() -> anyhow::Result<()> { @@ -9507,35 +8716,10 @@ async fn derive_sandbox_policy_preserves_windows_downgrade_for_unsupported_fallb #[test] fn test_resolve_oss_provider_explicit_override() { let config_toml = ConfigToml::default(); - let result = resolve_oss_provider( - Some("custom-provider"), - &config_toml, - /*config_profile*/ None, - ); + let result = resolve_oss_provider(Some("custom-provider"), &config_toml); assert_eq!(result, Some("custom-provider".to_string())); } -#[test] -fn test_resolve_oss_provider_from_profile() { - let mut profiles = std::collections::HashMap::new(); - let profile = ConfigProfile { - oss_provider: Some("profile-provider".to_string()), - ..Default::default() - }; - profiles.insert("test-profile".to_string(), profile); - let config_toml = ConfigToml { - profiles, - ..Default::default() - }; - - let result = resolve_oss_provider( - /*explicit_provider*/ None, - &config_toml, - Some("test-profile".to_string()), - ); - assert_eq!(result, Some("profile-provider".to_string())); -} - #[test] fn test_resolve_oss_provider_from_global_config() { let config_toml = ConfigToml { @@ -9543,63 +8727,25 @@ fn test_resolve_oss_provider_from_global_config() { ..Default::default() }; - let result = resolve_oss_provider( - /*explicit_provider*/ None, - &config_toml, - /*config_profile*/ None, - ); - assert_eq!(result, Some("global-provider".to_string())); -} - -#[test] -fn test_resolve_oss_provider_profile_fallback_to_global() { - let mut profiles = std::collections::HashMap::new(); - let profile = ConfigProfile::default(); // No oss_provider set - profiles.insert("test-profile".to_string(), profile); - let config_toml = ConfigToml { - oss_provider: Some("global-provider".to_string()), - profiles, - ..Default::default() - }; - - let result = resolve_oss_provider( - /*explicit_provider*/ None, - &config_toml, - Some("test-profile".to_string()), - ); + let result = resolve_oss_provider(/*explicit_provider*/ None, &config_toml); assert_eq!(result, Some("global-provider".to_string())); } #[test] fn test_resolve_oss_provider_none_when_not_configured() { let config_toml = ConfigToml::default(); - let result = resolve_oss_provider( - /*explicit_provider*/ None, - &config_toml, - /*config_profile*/ None, - ); + let result = resolve_oss_provider(/*explicit_provider*/ None, &config_toml); assert_eq!(result, None); } #[test] -fn test_resolve_oss_provider_explicit_overrides_all() { - let mut profiles = std::collections::HashMap::new(); - let profile = ConfigProfile { - oss_provider: Some("profile-provider".to_string()), - ..Default::default() - }; - profiles.insert("test-profile".to_string(), profile); +fn test_resolve_oss_provider_explicit_overrides_global() { let config_toml = ConfigToml { oss_provider: Some("global-provider".to_string()), - profiles, ..Default::default() }; - let result = resolve_oss_provider( - Some("explicit-provider"), - &config_toml, - Some("test-profile".to_string()), - ); + let result = resolve_oss_provider(Some("explicit-provider"), &config_toml); assert_eq!(result, Some("explicit-provider".to_string())); } @@ -10430,8 +9576,7 @@ async fn approvals_reviewer_defaults_to_manual_only_without_guardian_feature() - } #[tokio::test] -async fn prompt_instruction_blocks_can_be_disabled_from_config_and_profiles() -> std::io::Result<()> -{ +async fn prompt_instruction_blocks_can_be_disabled_from_config() -> std::io::Result<()> { let codex_home = TempDir::new()?; std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), @@ -10439,15 +9584,9 @@ async fn prompt_instruction_blocks_can_be_disabled_from_config_and_profiles() -> include_apps_instructions = false include_collaboration_mode_instructions = false include_environment_context = false -profile = "chatty" [skills] include_instructions = false - -[profiles.chatty] -include_permissions_instructions = true -include_collaboration_mode_instructions = true -include_environment_context = true "#, )?; @@ -10457,11 +9596,11 @@ include_environment_context = true .build() .await?; - assert!(config.include_permissions_instructions); + assert!(!config.include_permissions_instructions); assert!(!config.include_apps_instructions); - assert!(config.include_collaboration_mode_instructions); + assert!(!config.include_collaboration_mode_instructions); assert!(!config.include_skill_instructions); - assert!(config.include_environment_context); + assert!(!config.include_environment_context); Ok(()) } @@ -10506,29 +9645,6 @@ async fn approvals_reviewer_can_be_set_in_config_without_guardian_approval() -> Ok(()) } -#[tokio::test] -async fn approvals_reviewer_can_be_set_in_profile_without_guardian_approval() -> std::io::Result<()> -{ - let codex_home = TempDir::new()?; - std::fs::write( - codex_home.path().join(CONFIG_TOML_FILE), - r#"profile = "guardian" - -[profiles.guardian] -approvals_reviewer = "guardian_subagent" -"#, - )?; - - let config = ConfigBuilder::without_managed_config_for_tests() - .codex_home(codex_home.path().to_path_buf()) - .fallback_cwd(Some(codex_home.path().to_path_buf())) - .build() - .await?; - - assert_eq!(config.approvals_reviewer, ApprovalsReviewer::AutoReview); - Ok(()) -} - #[tokio::test] async fn requirements_disallowing_default_approvals_reviewer_falls_back_to_required_default() -> std::io::Result<()> { @@ -10583,35 +9699,6 @@ async fn root_approvals_reviewer_falls_back_when_disallowed_by_requirements() -> Ok(()) } -#[tokio::test] -async fn profile_approvals_reviewer_falls_back_when_disallowed_by_requirements() --> std::io::Result<()> { - let codex_home = TempDir::new()?; - std::fs::write( - codex_home.path().join(CONFIG_TOML_FILE), - r#"profile = "default" - -[profiles.default] -approvals_reviewer = "user" -"#, - )?; - - let config = ConfigBuilder::without_managed_config_for_tests() - .codex_home(codex_home.path().to_path_buf()) - .fallback_cwd(Some(codex_home.path().to_path_buf())) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - allowed_approvals_reviewers: Some(vec![ApprovalsReviewer::AutoReview]), - ..Default::default() - })) - })) - .build() - .await?; - - assert_eq!(config.approvals_reviewer, ApprovalsReviewer::AutoReview); - Ok(()) -} - #[tokio::test] async fn approvals_reviewer_preserves_valid_user_choice_when_allowed_by_requirements() -> std::io::Result<()> { @@ -10676,36 +9763,6 @@ smart_approvals = true Ok(()) } -#[tokio::test] -async fn smart_approvals_alias_is_ignored_in_profiles() -> std::io::Result<()> { - let codex_home = TempDir::new()?; - std::fs::write( - codex_home.path().join(CONFIG_TOML_FILE), - r#"profile = "guardian" - -[profiles.guardian.features] -smart_approvals = true -"#, - )?; - - let config = ConfigBuilder::without_managed_config_for_tests() - .codex_home(codex_home.path().to_path_buf()) - .fallback_cwd(Some(codex_home.path().to_path_buf())) - .build() - .await?; - - assert!(config.features.enabled(Feature::GuardianApproval)); - assert_eq!(config.approvals_reviewer, ApprovalsReviewer::User); - - let serialized = tokio::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).await?; - assert!(serialized.contains("[profiles.guardian.features]")); - assert!(serialized.contains("smart_approvals = true")); - assert!(!serialized.contains("guardian_approval")); - assert!(!serialized.contains("approvals_reviewer")); - - Ok(()) -} - #[tokio::test] async fn multi_agent_v2_config_from_feature_table() -> std::io::Result<()> { let codex_home = TempDir::new()?; @@ -10762,74 +9819,6 @@ non_code_mode_only = true Ok(()) } -#[tokio::test] -async fn profile_multi_agent_v2_config_overrides_base() -> std::io::Result<()> { - let codex_home = TempDir::new()?; - std::fs::write( - codex_home.path().join(CONFIG_TOML_FILE), - r#"profile = "no_hint" - -[features.multi_agent_v2] -max_concurrent_threads_per_session = 4 -min_wait_timeout_ms = 3000 -max_wait_timeout_ms = 120000 -default_wait_timeout_ms = 30000 -usage_hint_enabled = true -usage_hint_text = "base hint" -root_agent_usage_hint_text = "base root hint" -subagent_usage_hint_text = "base subagent hint" -tool_namespace = "base_agents" -hide_spawn_agent_metadata = true -non_code_mode_only = false - -[profiles.no_hint.features.multi_agent_v2] -max_concurrent_threads_per_session = 6 -min_wait_timeout_ms = 1500 -max_wait_timeout_ms = 90000 -default_wait_timeout_ms = 15000 -usage_hint_enabled = false -usage_hint_text = "profile hint" -root_agent_usage_hint_text = "profile root hint" -subagent_usage_hint_text = "profile subagent hint" -tool_namespace = "profile_agents" -hide_spawn_agent_metadata = false -non_code_mode_only = true -"#, - )?; - - let config = ConfigBuilder::without_managed_config_for_tests() - .codex_home(codex_home.path().to_path_buf()) - .fallback_cwd(Some(codex_home.path().to_path_buf())) - .build() - .await?; - - assert_eq!(config.multi_agent_v2.max_concurrent_threads_per_session, 6); - assert_eq!(config.multi_agent_v2.min_wait_timeout_ms, 1500); - assert_eq!(config.multi_agent_v2.max_wait_timeout_ms, 90000); - assert_eq!(config.multi_agent_v2.default_wait_timeout_ms, 15000); - assert!(!config.multi_agent_v2.usage_hint_enabled); - assert_eq!( - config.multi_agent_v2.usage_hint_text.as_deref(), - Some("profile hint") - ); - assert_eq!( - config.multi_agent_v2.root_agent_usage_hint_text.as_deref(), - Some("profile root hint") - ); - assert_eq!( - config.multi_agent_v2.subagent_usage_hint_text.as_deref(), - Some("profile subagent hint") - ); - assert_eq!( - config.multi_agent_v2.tool_namespace.as_deref(), - Some("profile_agents") - ); - assert!(!config.multi_agent_v2.hide_spawn_agent_metadata); - assert!(config.multi_agent_v2.non_code_mode_only); - - Ok(()) -} - #[tokio::test] async fn multi_agent_v2_default_session_thread_cap_counts_root() -> std::io::Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 2392af0e5887..3b65ceddcdbd 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -34,7 +34,6 @@ use codex_config::config_toml::validate_model_providers; use codex_config::loader::load_config_layers_state; use codex_config::loader::project_trust_key; use codex_config::permissions_toml::PermissionsToml; -use codex_config::profile_toml::ConfigProfile; use codex_config::sandbox_mode_requirement_for_permission_profile; use codex_config::types::ApprovalsReviewer; use codex_config::types::AuthCredentialsStoreMode; @@ -2033,7 +2032,6 @@ fn resolve_permission_config_syntax( config_layer_stack: &ConfigLayerStack, cfg: &ConfigToml, sandbox_mode_override: Option, - profile_sandbox_mode: Option, ) -> Option { if sandbox_mode_override.is_some() { return Some(PermissionConfigSyntax::Legacy); @@ -2058,10 +2056,6 @@ fn resolve_permission_config_syntax( return Some(PermissionConfigSyntax::Profiles); } - if profile_sandbox_mode.is_some() { - return Some(PermissionConfigSyntax::Legacy); - } - let mut selection = None; for layer in config_layer_stack.get_layers( ConfigLayerStackOrdering::LowestPrecedenceFirst, @@ -2134,7 +2128,6 @@ pub struct ConfigOverrides { pub default_permissions: Option, pub model_provider: Option, pub service_tier: Option>, - pub config_profile: Option, pub codex_self_exe: Option, pub codex_linux_sandbox_exe: Option, pub main_execve_wrapper_exe: Option, @@ -2159,41 +2152,23 @@ fn dedupe_absolute_paths(paths: &mut Vec) { paths.retain(|path| seen.insert(path.clone())); } -/// Resolves the OSS provider from CLI override, profile config, or global config. +/// Resolves the OSS provider from CLI override or global config. /// Returns `None` if no provider is configured at any level. pub fn resolve_oss_provider( explicit_provider: Option<&str>, config_toml: &ConfigToml, - config_profile: Option, ) -> Option { if let Some(provider) = explicit_provider { // Explicit provider specified (e.g., via --local-provider) Some(provider.to_string()) } else { - // Check profile config first, then global config - let profile = config_toml.get_config_profile(config_profile).ok(); - if let Some(profile) = &profile { - // Check if profile has an oss provider - if let Some(profile_oss_provider) = &profile.oss_provider { - Some(profile_oss_provider.clone()) - } - // If not then check if the toml has an oss provider - else { - config_toml.oss_provider.clone() - } - } else { - config_toml.oss_provider.clone() - } + config_toml.oss_provider.clone() } } /// Resolve the web search mode from explicit config and feature flags. -fn resolve_web_search_mode( - config_toml: &ConfigToml, - config_profile: &ConfigProfile, - features: &Features, -) -> Option { - if let Some(mode) = config_profile.web_search.or(config_toml.web_search) { +fn resolve_web_search_mode(config_toml: &ConfigToml, features: &Features) -> Option { + if let Some(mode) = config_toml.web_search { return Some(mode); } if features.enabled(Feature::WebSearchCached) { @@ -2205,82 +2180,55 @@ fn resolve_web_search_mode( None } -fn resolve_web_search_config( - config_toml: &ConfigToml, - config_profile: &ConfigProfile, -) -> Option { - let base = config_toml - .tools - .as_ref() - .and_then(|tools| tools.web_search.as_ref()); - let profile = config_profile +fn resolve_web_search_config(config_toml: &ConfigToml) -> Option { + config_toml .tools .as_ref() - .and_then(|tools| tools.web_search.as_ref()); - - match (base, profile) { - (None, None) => None, - (Some(base), None) => Some(base.clone().into()), - (None, Some(profile)) => Some(profile.clone().into()), - (Some(base), Some(profile)) => Some(base.merge(profile).into()), - } + .and_then(|tools| tools.web_search.as_ref()) + .cloned() + .map(Into::into) } -fn resolve_multi_agent_v2_config( - config_toml: &ConfigToml, - config_profile: &ConfigProfile, -) -> MultiAgentV2Config { +fn resolve_multi_agent_v2_config(config_toml: &ConfigToml) -> MultiAgentV2Config { let base = multi_agent_v2_toml_config(config_toml.features.as_ref()); - let profile = multi_agent_v2_toml_config(config_profile.features.as_ref()); let default = MultiAgentV2Config::default(); - let max_concurrent_threads_per_session = profile + let max_concurrent_threads_per_session = base .and_then(|config| config.max_concurrent_threads_per_session) - .or_else(|| base.and_then(|config| config.max_concurrent_threads_per_session)) .unwrap_or(default.max_concurrent_threads_per_session); - let min_wait_timeout_ms = profile + let min_wait_timeout_ms = base .and_then(|config| config.min_wait_timeout_ms) - .or_else(|| base.and_then(|config| config.min_wait_timeout_ms)) .unwrap_or(default.min_wait_timeout_ms); - let max_wait_timeout_ms = profile + let max_wait_timeout_ms = base .and_then(|config| config.max_wait_timeout_ms) - .or_else(|| base.and_then(|config| config.max_wait_timeout_ms)) .unwrap_or(default.max_wait_timeout_ms); - let default_wait_timeout_ms = profile + let default_wait_timeout_ms = base .and_then(|config| config.default_wait_timeout_ms) - .or_else(|| base.and_then(|config| config.default_wait_timeout_ms)) .unwrap_or(default.default_wait_timeout_ms); - let usage_hint_enabled = profile + let usage_hint_enabled = base .and_then(|config| config.usage_hint_enabled) - .or_else(|| base.and_then(|config| config.usage_hint_enabled)) .unwrap_or(default.usage_hint_enabled); - let usage_hint_text = profile + let usage_hint_text = base .and_then(|config| config.usage_hint_text.as_ref()) - .or_else(|| base.and_then(|config| config.usage_hint_text.as_ref())) .cloned() .or(default.usage_hint_text); - let root_agent_usage_hint_text = profile + let root_agent_usage_hint_text = base .and_then(|config| config.root_agent_usage_hint_text.as_ref()) - .or_else(|| base.and_then(|config| config.root_agent_usage_hint_text.as_ref())) .cloned() .or(default.root_agent_usage_hint_text); - let subagent_usage_hint_text = profile + let subagent_usage_hint_text = base .and_then(|config| config.subagent_usage_hint_text.as_ref()) - .or_else(|| base.and_then(|config| config.subagent_usage_hint_text.as_ref())) .cloned() .or(default.subagent_usage_hint_text); - let tool_namespace = profile + let tool_namespace = base .and_then(|config| config.tool_namespace.as_ref()) - .or_else(|| base.and_then(|config| config.tool_namespace.as_ref())) .cloned() .or(default.tool_namespace); - let hide_spawn_agent_metadata = profile + let hide_spawn_agent_metadata = base .and_then(|config| config.hide_spawn_agent_metadata) - .or_else(|| base.and_then(|config| config.hide_spawn_agent_metadata)) .unwrap_or(default.hide_spawn_agent_metadata); - let non_code_mode_only = profile + let non_code_mode_only = base .and_then(|config| config.non_code_mode_only) - .or_else(|| base.and_then(|config| config.non_code_mode_only)) .unwrap_or(default.non_code_mode_only); MultiAgentV2Config { @@ -2531,7 +2479,6 @@ impl Config { default_permissions: default_permissions_override, model_provider, service_tier: service_tier_override, - config_profile: config_profile_key, codex_self_exe, codex_linux_sandbox_exe, main_execve_wrapper_exe, @@ -2574,24 +2521,15 @@ impl Config { "`permission_profile` and `default_permissions` overrides cannot both be set", )); } + if let Some(profile) = cfg.profile.as_deref() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!( + "legacy `profile = \"{profile}\"` config is no longer supported; use `--profile {profile}` with `{profile}.config.toml` instead" + ), + )); + } - let active_profile_name = config_profile_key - .as_ref() - .or(cfg.profile.as_ref()) - .cloned(); - let config_profile = match active_profile_name.as_ref() { - Some(key) => cfg - .profiles - .get(key) - .ok_or_else(|| { - std::io::Error::new( - std::io::ErrorKind::NotFound, - format!("config profile `{key}` not found"), - ) - })? - .clone(), - None => ConfigProfile::default(), - }; let tool_suggest = resolve_tool_suggest_config(&cfg, &config_layer_stack); let feature_overrides = FeatureOverrides { web_search_request: override_tools_web_search_request, @@ -2603,9 +2541,7 @@ impl Config { experimental_use_unified_exec_tool: cfg.experimental_use_unified_exec_tool, }, FeatureConfigSource { - features: config_profile.features.as_ref(), - experimental_use_unified_exec_tool: config_profile - .experimental_use_unified_exec_tool, + ..Default::default() }, feature_overrides, ); @@ -2615,9 +2551,8 @@ impl Config { &mut startup_warnings, )?; let enable_network_proxy = features.enabled(Feature::NetworkProxy); - let windows_sandbox_mode = resolve_windows_sandbox_mode(&cfg, &config_profile); - let windows_sandbox_private_desktop = - resolve_windows_sandbox_private_desktop(&cfg, &config_profile); + let windows_sandbox_mode = resolve_windows_sandbox_mode(&cfg); + let windows_sandbox_private_desktop = resolve_windows_sandbox_private_desktop(&cfg); let resolved_cwd = AbsolutePathBuf::try_from(normalize_for_native_workdir({ use std::env; @@ -2651,7 +2586,6 @@ impl Config { &config_layer_stack, &cfg, sandbox_mode, - config_profile.sandbox_mode, ); let requirements_toml = config_layer_stack.requirements_toml(); let effective_permission_selection = resolve_effective_permission_selection( @@ -2908,7 +2842,7 @@ impl Config { let mut permission_profile = cfg .derive_permission_profile( sandbox_mode, - config_profile.sandbox_mode, + /*profile_sandbox_mode*/ None, windows_sandbox_level, Some(&active_project), Some(&constrained_permission_profile), @@ -2965,20 +2899,11 @@ impl Config { network_proxy, ); } - if let Some(network_proxy) = network_proxy_toml_config(config_profile.features.as_ref()) - { - apply_network_proxy_feature_config( - &mut configured_network_proxy_config, - network_proxy, - ); - } configured_network_proxy_config.network.enabled = true; } - let approval_policy_was_explicit = approval_policy_override.is_some() - || config_profile.approval_policy.is_some() - || cfg.approval_policy.is_some(); + let approval_policy_was_explicit = + approval_policy_override.is_some() || cfg.approval_policy.is_some(); let mut approval_policy = approval_policy_override - .or(config_profile.approval_policy) .or(cfg.approval_policy) .unwrap_or_else(|| { if active_project.is_trusted() { @@ -2998,11 +2923,9 @@ impl Config { ); approval_policy = constrained_approval_policy.value(); } - let approvals_reviewer_was_explicit = approvals_reviewer_override.is_some() - || config_profile.approvals_reviewer.is_some() - || cfg.approvals_reviewer.is_some(); + let approvals_reviewer_was_explicit = + approvals_reviewer_override.is_some() || cfg.approvals_reviewer.is_some(); let mut approvals_reviewer = approvals_reviewer_override - .or(config_profile.approvals_reviewer) .or(cfg.approvals_reviewer) .unwrap_or(ApprovalsReviewer::User); if !approvals_reviewer_was_explicit @@ -3014,16 +2937,13 @@ impl Config { ); approvals_reviewer = constrained_approvals_reviewer.value(); } - let web_search_mode = resolve_web_search_mode(&cfg, &config_profile, &features) - .unwrap_or(WebSearchMode::Cached); - let web_search_config = resolve_web_search_config(&cfg, &config_profile); - let multi_agent_v2 = resolve_multi_agent_v2_config(&cfg, &config_profile); + let web_search_mode = + resolve_web_search_mode(&cfg, &features).unwrap_or(WebSearchMode::Cached); + let web_search_config = resolve_web_search_config(&cfg); + let multi_agent_v2 = resolve_multi_agent_v2_config(&cfg); let apps_mcp_path_override = if features.enabled(Feature::AppsMcpPathOverride) { let base = apps_mcp_path_override_toml_config(cfg.features.as_ref()); - let profile = apps_mcp_path_override_toml_config(config_profile.features.as_ref()); - profile - .and_then(|config| config.path.as_ref()) - .or_else(|| base.and_then(|config| config.path.as_ref())) + base.and_then(|config| config.path.as_ref()) .cloned() .or_else(|| Some("/ps/mcp".to_string())) } else { @@ -3045,7 +2965,6 @@ impl Config { .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidData, message))?; let model_provider_id = model_provider - .or(config_profile.model_provider) .or(cfg.model_provider) .unwrap_or_else(|| "openai".to_string()); let model_provider = model_providers @@ -3207,12 +3126,12 @@ impl Config { let forced_login_method = cfg.forced_login_method; - let model = model.or(config_profile.model).or(cfg.model); + let model = model.or(cfg.model); let notices = cfg.notice.unwrap_or_default(); let service_tier = match service_tier_override { Some(Some(service_tier)) => Some(service_tier), Some(None) => Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string()), - None => config_profile.service_tier.or(cfg.service_tier), + None => cfg.service_tier, }; let service_tier = service_tier.and_then(|service_tier| { match ServiceTier::from_request_value(&service_tier) { @@ -3236,10 +3155,7 @@ impl Config { // Load base instructions override from a file if specified. If the // path is relative, resolve it against the effective cwd so the // behaviour matches other path-like config values. - let model_instructions_path = config_profile - .model_instructions_file - .as_ref() - .or(cfg.model_instructions_file.as_ref()); + let model_instructions_path = cfg.model_instructions_file.as_ref(); let file_base_instructions = Self::try_read_non_empty_file( fs, model_instructions_path, @@ -3250,27 +3166,16 @@ impl Config { .or(file_base_instructions) .or(cfg.instructions.clone()); let developer_instructions = developer_instructions.or(cfg.developer_instructions); - let include_permissions_instructions = config_profile - .include_permissions_instructions - .or(cfg.include_permissions_instructions) - .unwrap_or(true); - let include_apps_instructions = config_profile - .include_apps_instructions - .or(cfg.include_apps_instructions) - .unwrap_or(true); - let include_collaboration_mode_instructions = config_profile - .include_collaboration_mode_instructions - .or(cfg.include_collaboration_mode_instructions) - .unwrap_or(true); + let include_permissions_instructions = cfg.include_permissions_instructions.unwrap_or(true); + let include_apps_instructions = cfg.include_apps_instructions.unwrap_or(true); + let include_collaboration_mode_instructions = + cfg.include_collaboration_mode_instructions.unwrap_or(true); let include_skill_instructions = cfg .skills .as_ref() .and_then(|skills| skills.include_instructions) .unwrap_or(true); - let include_environment_context = config_profile - .include_environment_context - .or(cfg.include_environment_context) - .unwrap_or(true); + let include_environment_context = cfg.include_environment_context.unwrap_or(true); let guardian_policy_config = guardian_policy_config_from_requirements(config_layer_stack.requirements_toml()) .or_else(|| { @@ -3281,7 +3186,6 @@ impl Config { )) }); let personality = personality - .or(config_profile.personality) .or(cfg.personality) .or_else(|| { features @@ -3289,10 +3193,7 @@ impl Config { .then_some(Personality::Pragmatic) }); - let experimental_compact_prompt_path = config_profile - .experimental_compact_prompt_file - .as_ref() - .or(cfg.experimental_compact_prompt_file.as_ref()); + let experimental_compact_prompt_path = cfg.experimental_compact_prompt_file.as_ref(); let file_compact_prompt = Self::try_read_non_empty_file( fs, experimental_compact_prompt_path, @@ -3300,19 +3201,12 @@ impl Config { ) .await?; let compact_prompt = compact_prompt.or(file_compact_prompt); - let zsh_path = zsh_path_override - .or(config_profile.zsh_path.map(Into::into)) - .or(cfg.zsh_path.map(Into::into)); + let zsh_path = zsh_path_override.or(cfg.zsh_path.map(Into::into)); let review_model = override_review_model.or(cfg.review_model); let check_for_update_on_startup = cfg.check_for_update_on_startup.unwrap_or(true); - let model_catalog = load_model_catalog( - config_profile - .model_catalog_json - .clone() - .or(cfg.model_catalog_json.clone()), - )?; + let model_catalog = load_model_catalog(cfg.model_catalog_json.clone())?; let log_dir = cfg .log_dir @@ -3558,21 +3452,14 @@ impl Config { .or(show_raw_agent_reasoning) .unwrap_or(false), guardian_policy_config, - model_reasoning_effort: config_profile - .model_reasoning_effort - .or(cfg.model_reasoning_effort), - plan_mode_reasoning_effort: config_profile - .plan_mode_reasoning_effort - .or(cfg.plan_mode_reasoning_effort), - model_reasoning_summary: config_profile - .model_reasoning_summary - .or(cfg.model_reasoning_summary), + model_reasoning_effort: cfg.model_reasoning_effort, + plan_mode_reasoning_effort: cfg.plan_mode_reasoning_effort, + model_reasoning_summary: cfg.model_reasoning_summary, model_supports_reasoning_summaries: cfg.model_supports_reasoning_summaries, model_catalog, - model_verbosity: config_profile.model_verbosity.or(cfg.model_verbosity), - chatgpt_base_url: config_profile + model_verbosity: cfg.model_verbosity, + chatgpt_base_url: cfg .chatgpt_base_url - .or(cfg.chatgpt_base_url) .unwrap_or("https://chatgpt.com/backend-api/".to_string()), apps_mcp_path_override, apps_mcp_product_sku: cfg.apps_mcp_product_sku.clone(), @@ -3612,16 +3499,12 @@ impl Config { suppress_unstable_features_warning: cfg .suppress_unstable_features_warning .unwrap_or(false), - active_profile: active_profile_name, + active_profile: None, active_project, notices, check_for_update_on_startup, disable_paste_burst: cfg.disable_paste_burst.unwrap_or(false), - analytics_enabled: config_profile - .analytics - .as_ref() - .and_then(|a| a.enabled) - .or(cfg.analytics.as_ref().and_then(|a| a.enabled)), + analytics_enabled: cfg.analytics.as_ref().and_then(|a| a.enabled), feedback_enabled: cfg .feedback .as_ref() @@ -3669,11 +3552,10 @@ impl Config { .as_ref() .map(|t| t.pet_anchor) .unwrap_or_default(), - tui_session_picker_view: config_profile + tui_session_picker_view: cfg .tui .as_ref() .and_then(|t| t.session_picker_view) - .or_else(|| cfg.tui.as_ref().and_then(|t| t.session_picker_view)) .unwrap_or_default(), terminal_resize_reflow, tui_keymap: cfg diff --git a/codex-rs/core/src/windows_sandbox.rs b/codex-rs/core/src/windows_sandbox.rs index 2c87c885ad6a..4166868ff7a3 100644 --- a/codex-rs/core/src/windows_sandbox.rs +++ b/codex-rs/core/src/windows_sandbox.rs @@ -1,7 +1,6 @@ use crate::config::Config; use crate::config::edit::ConfigEditsBuilder; use codex_config::config_toml::ConfigToml; -use codex_config::profile_toml::ConfigProfile; use codex_config::types::WindowsSandboxModeToml; use codex_features::Feature; use codex_features::Features; @@ -56,47 +55,20 @@ pub fn windows_sandbox_level_from_features(features: &Features) -> WindowsSandbo WindowsSandboxLevel::from_features(features) } -pub fn resolve_windows_sandbox_mode( - cfg: &ConfigToml, - profile: &ConfigProfile, -) -> Option { - if let Some(mode) = legacy_windows_sandbox_mode(profile.features.as_ref()) { - return Some(mode); - } - if legacy_windows_sandbox_keys_present(profile.features.as_ref()) { - return None; - } - - profile - .windows +pub fn resolve_windows_sandbox_mode(cfg: &ConfigToml) -> Option { + cfg.windows .as_ref() .and_then(|windows| windows.sandbox) - .or_else(|| cfg.windows.as_ref().and_then(|windows| windows.sandbox)) .or_else(|| legacy_windows_sandbox_mode(cfg.features.as_ref())) } -pub fn resolve_windows_sandbox_private_desktop(cfg: &ConfigToml, profile: &ConfigProfile) -> bool { - profile - .windows +pub fn resolve_windows_sandbox_private_desktop(cfg: &ConfigToml) -> bool { + cfg.windows .as_ref() .and_then(|windows| windows.sandbox_private_desktop) - .or_else(|| { - cfg.windows - .as_ref() - .and_then(|windows| windows.sandbox_private_desktop) - }) .unwrap_or(true) } -fn legacy_windows_sandbox_keys_present(features: Option<&FeaturesToml>) -> bool { - let Some(entries) = features.map(FeaturesToml::entries) else { - return false; - }; - entries.contains_key(Feature::WindowsSandboxElevated.key()) - || entries.contains_key(Feature::WindowsSandbox.key()) - || entries.contains_key("enable_experimental_windows_sandbox") -} - pub fn legacy_windows_sandbox_mode( features: Option<&FeaturesToml>, ) -> Option { diff --git a/codex-rs/core/src/windows_sandbox_tests.rs b/codex-rs/core/src/windows_sandbox_tests.rs index 27612c640a5c..5a66c8c96a29 100644 --- a/codex-rs/core/src/windows_sandbox_tests.rs +++ b/codex-rs/core/src/windows_sandbox_tests.rs @@ -78,29 +78,6 @@ fn legacy_mode_supports_alias_key() { ); } -#[test] -fn resolve_windows_sandbox_mode_prefers_profile_windows() { - let cfg = ConfigToml { - windows: Some(WindowsToml { - sandbox: Some(WindowsSandboxModeToml::Unelevated), - ..Default::default() - }), - ..Default::default() - }; - let profile = ConfigProfile { - windows: Some(WindowsToml { - sandbox: Some(WindowsSandboxModeToml::Elevated), - ..Default::default() - }), - ..Default::default() - }; - - assert_eq!( - resolve_windows_sandbox_mode(&cfg, &profile), - Some(WindowsSandboxModeToml::Elevated) - ); -} - #[test] fn resolve_windows_sandbox_mode_falls_back_to_legacy_keys() { let mut entries = BTreeMap::new(); @@ -114,61 +91,15 @@ fn resolve_windows_sandbox_mode_falls_back_to_legacy_keys() { }; assert_eq!( - resolve_windows_sandbox_mode(&cfg, &ConfigProfile::default()), + resolve_windows_sandbox_mode(&cfg), Some(WindowsSandboxModeToml::Unelevated) ); } -#[test] -fn resolve_windows_sandbox_mode_profile_legacy_false_blocks_top_level_legacy_true() { - let mut profile_entries = BTreeMap::new(); - profile_entries.insert( - "experimental_windows_sandbox".to_string(), - /*value*/ false, - ); - let profile = ConfigProfile { - features: Some(FeaturesToml::from(profile_entries)), - ..Default::default() - }; - - let mut cfg_entries = BTreeMap::new(); - cfg_entries.insert( - "experimental_windows_sandbox".to_string(), - /*value*/ true, - ); - let cfg = ConfigToml { - features: Some(FeaturesToml::from(cfg_entries)), - ..Default::default() - }; - - assert_eq!(resolve_windows_sandbox_mode(&cfg, &profile), None); -} - -#[test] -fn resolve_windows_sandbox_private_desktop_prefers_profile_windows() { - let cfg = ConfigToml { - windows: Some(WindowsToml { - sandbox: Some(WindowsSandboxModeToml::Unelevated), - sandbox_private_desktop: Some(false), - }), - ..Default::default() - }; - let profile = ConfigProfile { - windows: Some(WindowsToml { - sandbox: Some(WindowsSandboxModeToml::Elevated), - sandbox_private_desktop: Some(true), - }), - ..Default::default() - }; - - assert!(resolve_windows_sandbox_private_desktop(&cfg, &profile)); -} - #[test] fn resolve_windows_sandbox_private_desktop_defaults_to_true() { assert!(resolve_windows_sandbox_private_desktop( - &ConfigToml::default(), - &ConfigProfile::default() + &ConfigToml::default() )); } @@ -182,8 +113,5 @@ fn resolve_windows_sandbox_private_desktop_respects_explicit_cfg_value() { ..Default::default() }; - assert!(!resolve_windows_sandbox_private_desktop( - &cfg, - &ConfigProfile::default() - )); + assert!(!resolve_windows_sandbox_private_desktop(&cfg)); } diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 8ca5ba8a9a79..25b2b0e14395 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -375,11 +375,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result let run_cloud_requirements = cloud_requirements.clone(); let model_provider = if oss { - let resolved = resolve_oss_provider( - oss_provider.as_deref(), - &config_toml, - /*config_profile*/ None, - ); + let resolved = resolve_oss_provider(oss_provider.as_deref(), &config_toml); if let Some(provider) = resolved { Some(provider) @@ -418,7 +414,6 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result workspace_roots: None, model_provider: model_provider.clone(), service_tier: None, - config_profile: None, codex_self_exe: arg0_paths.codex_self_exe.clone(), codex_linux_sandbox_exe: arg0_paths.codex_linux_sandbox_exe.clone(), main_execve_wrapper_exe: arg0_paths.main_execve_wrapper_exe.clone(), diff --git a/codex-rs/mcp-server/src/codex_tool_config.rs b/codex-rs/mcp-server/src/codex_tool_config.rs index 2f9f35427756..d91d261fb8c8 100644 --- a/codex-rs/mcp-server/src/codex_tool_config.rs +++ b/codex-rs/mcp-server/src/codex_tool_config.rs @@ -29,10 +29,6 @@ pub struct CodexToolCallParam { #[serde(default, skip_serializing_if = "Option::is_none")] pub model: Option, - /// Configuration profile from config.toml to specify default options. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub profile: Option, - /// Working directory for the session. If relative, it is resolved against /// the server process's current working directory. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -160,7 +156,6 @@ impl CodexToolCallParam { let Self { prompt, model, - profile, cwd, approval_policy, sandbox, @@ -173,7 +168,6 @@ impl CodexToolCallParam { // Build the `ConfigOverrides` recognized by codex-core. let overrides = ConfigOverrides { model, - config_profile: profile, cwd: cwd.map(PathBuf::from), approval_policy: approval_policy.map(Into::into), sandbox_mode: sandbox.map(Into::into), @@ -345,10 +339,6 @@ mod tests { "description": "Optional override for the model name (e.g. 'gpt-5.2', 'gpt-5.2-codex').", "type": "string" }, - "profile": { - "description": "Configuration profile from config.toml to specify default options.", - "type": "string" - }, "prompt": { "description": "The *initial user prompt* to start the Codex conversation.", "type": "string" diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 92f4e9971211..1833e056f7e4 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -994,11 +994,7 @@ pub async fn run_main( .await; let model_provider_override = if cli.oss { - let resolved = resolve_oss_provider( - cli.oss_provider.as_deref(), - &config_toml, - /*config_profile*/ None, - ); + let resolved = resolve_oss_provider(cli.oss_provider.as_deref(), &config_toml); if let Some(provider) = resolved { Some(provider) From 2c6605ab359f339001fd0da8462a94fbea947551 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 22 May 2026 12:50:42 +0200 Subject: [PATCH 32/64] config: remove legacy profile write paths (#24055) ## Why [#23883](https://github.com/openai/codex/pull/23883) moved the user-facing `--profile` flag onto profile v2 and [#23886](https://github.com/openai/codex/pull/23886) removed CLI forwarding for the legacy profile-v1 path. Core and TUI config persistence still carried `active_profile` and `ConfigEditsBuilder::with_profile`, which let later writes continue targeting legacy `[profiles.]` tables after profile selection moved to profile-v2 config files. ## What - Remove legacy profile routing from [`ConfigEditsBuilder`](https://github.com/openai/codex/blob/4b38e9c22e762261d7f7eef49d8a21792e241a06/codex-rs/core/src/config/edit.rs#L1064-L1294), so core config edits no longer carry `with_profile` or infer `[profiles.*]` write targets from a `profile` key. - Drop `active_profile` plumbing from runtime `Config`, TUI startup/state, app-server config override forwarding, and Windows sandbox setup persistence. - Make app-server-backed TUI config edits use unscoped model, service-tier, feature, Auto-review, plan-mode, and Windows sandbox paths through [`tui/src/config_update.rs`](https://github.com/openai/codex/blob/4b38e9c22e762261d7f7eef49d8a21792e241a06/codex-rs/tui/src/config_update.rs#L43-L112). - Update config edit coverage so legacy `profile` state stays untouched by direct model writes, and remove tests whose only contract was the deleted profile-scoped persistence path. ## Testing - Not run locally. --- .../windows_sandbox_processor.rs | 1 - codex-rs/core/src/config/config_tests.rs | 206 --------------- codex-rs/core/src/config/edit.rs | 171 +++---------- codex-rs/core/src/config/edit_tests.rs | 125 ++-------- codex-rs/core/src/config/mod.rs | 4 - codex-rs/core/src/session/session.rs | 2 +- codex-rs/core/src/windows_sandbox.rs | 3 - codex-rs/exec/src/lib.rs | 12 +- codex-rs/thread-manager-sample/src/main.rs | 1 - codex-rs/tui/src/app.rs | 3 - codex-rs/tui/src/app/config_persistence.rs | 120 ++------- codex-rs/tui/src/app/event_dispatch.rs | 96 ++----- codex-rs/tui/src/app/test_support.rs | 1 - codex-rs/tui/src/app/tests.rs | 235 ------------------ codex-rs/tui/src/app_server_session.rs | 1 - codex-rs/tui/src/config_update.rs | 59 +---- codex-rs/tui/src/lib.rs | 2 - codex-rs/tui/src/resume_picker.rs | 39 --- 18 files changed, 107 insertions(+), 974 deletions(-) diff --git a/codex-rs/app-server/src/request_processors/windows_sandbox_processor.rs b/codex-rs/app-server/src/request_processors/windows_sandbox_processor.rs index 2392cc807842..0b639b7d5c06 100644 --- a/codex-rs/app-server/src/request_processors/windows_sandbox_processor.rs +++ b/codex-rs/app-server/src/request_processors/windows_sandbox_processor.rs @@ -83,7 +83,6 @@ impl WindowsSandboxRequestProcessor { command_cwd, env_map: std::env::vars().collect(), codex_home: config.codex_home.to_path_buf(), - active_profile: config.active_profile.clone(), }; codex_core::windows_sandbox::run_windows_sandbox_setup(setup_request).await } diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 016a835e2f59..89310277ab66 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -4798,7 +4798,6 @@ model = "gpt-project-local" .build() .await?; - assert_eq!(config.active_profile, None); assert_eq!(config.model, None); assert!( config.startup_warnings.iter().any(|warning| { @@ -5032,7 +5031,6 @@ async fn replace_mcp_servers_round_trips_entries() -> anyhow::Result<()> { apply_blocking( codex_home.path(), - /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; @@ -5063,7 +5061,6 @@ async fn replace_mcp_servers_round_trips_entries() -> anyhow::Result<()> { let empty = BTreeMap::new(); apply_blocking( codex_home.path(), - /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(empty.clone())], )?; let loaded = load_global_mcp_servers(codex_home.path()).await?; @@ -5379,7 +5376,6 @@ async fn replace_mcp_servers_serializes_env_sorted() -> anyhow::Result<()> { apply_blocking( codex_home.path(), - /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; @@ -5456,7 +5452,6 @@ async fn replace_mcp_servers_serializes_env_vars() -> anyhow::Result<()> { apply_blocking( codex_home.path(), - /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; @@ -5518,7 +5513,6 @@ async fn replace_mcp_servers_serializes_sourced_env_vars() -> anyhow::Result<()> apply_blocking( codex_home.path(), - /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; @@ -5570,7 +5564,6 @@ async fn replace_mcp_servers_serializes_cwd() -> anyhow::Result<()> { apply_blocking( codex_home.path(), - /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; @@ -5625,7 +5618,6 @@ async fn replace_mcp_servers_streamable_http_serializes_bearer_token() -> anyhow apply_blocking( codex_home.path(), - /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; @@ -5695,7 +5687,6 @@ async fn replace_mcp_servers_streamable_http_serializes_custom_headers() -> anyh )]); apply_blocking( codex_home.path(), - /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; @@ -5779,7 +5770,6 @@ async fn replace_mcp_servers_streamable_http_removes_optional_sections() -> anyh apply_blocking( codex_home.path(), - /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; let serialized_with_optional = std::fs::read_to_string(&config_path)?; @@ -5814,7 +5804,6 @@ async fn replace_mcp_servers_streamable_http_removes_optional_sections() -> anyh ); apply_blocking( codex_home.path(), - /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; @@ -5913,7 +5902,6 @@ async fn replace_mcp_servers_streamable_http_isolates_headers_between_servers() apply_blocking( codex_home.path(), - /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; @@ -6001,7 +5989,6 @@ async fn replace_mcp_servers_serializes_disabled_flag() -> anyhow::Result<()> { apply_blocking( codex_home.path(), - /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; @@ -6052,7 +6039,6 @@ async fn replace_mcp_servers_serializes_required_flag() -> anyhow::Result<()> { apply_blocking( codex_home.path(), - /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; @@ -6103,7 +6089,6 @@ async fn replace_mcp_servers_serializes_tool_filters() -> anyhow::Result<()> { apply_blocking( codex_home.path(), - /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; @@ -6160,7 +6145,6 @@ async fn replace_mcp_servers_streamable_http_serializes_oauth_resource() -> anyh apply_blocking( codex_home.path(), - /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; @@ -6283,196 +6267,6 @@ model = "gpt-4.1" Ok(()) } -#[tokio::test] -async fn set_model_updates_profile() -> anyhow::Result<()> { - let codex_home = TempDir::new()?; - - ConfigEditsBuilder::new(codex_home.path()) - .with_profile(Some("dev")) - .set_model(Some("gpt-5.4"), Some(ReasoningEffort::Medium)) - .apply() - .await?; - - let serialized = tokio::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).await?; - let parsed: ConfigToml = toml::from_str(&serialized)?; - let profile = parsed - .profiles - .get("dev") - .expect("profile should be created"); - - assert_eq!(profile.model.as_deref(), Some("gpt-5.4")); - assert_eq!( - profile.model_reasoning_effort, - Some(ReasoningEffort::Medium) - ); - - Ok(()) -} - -#[tokio::test] -async fn set_model_updates_existing_profile() -> anyhow::Result<()> { - let codex_home = TempDir::new()?; - let config_path = codex_home.path().join(CONFIG_TOML_FILE); - - tokio::fs::write( - &config_path, - r#" -[profiles.dev] -model = "gpt-4" -model_reasoning_effort = "medium" - -[profiles.prod] -model = "gpt-5.4" -"#, - ) - .await?; - - ConfigEditsBuilder::new(codex_home.path()) - .with_profile(Some("dev")) - .set_model(Some("o4-high"), Some(ReasoningEffort::Medium)) - .apply() - .await?; - - let serialized = tokio::fs::read_to_string(config_path).await?; - let parsed: ConfigToml = toml::from_str(&serialized)?; - - let dev_profile = parsed - .profiles - .get("dev") - .expect("dev profile should survive updates"); - assert_eq!(dev_profile.model.as_deref(), Some("o4-high")); - assert_eq!( - dev_profile.model_reasoning_effort, - Some(ReasoningEffort::Medium) - ); - - assert_eq!( - parsed - .profiles - .get("prod") - .and_then(|profile| profile.model.as_deref()), - Some("gpt-5.4"), - ); - - Ok(()) -} - -#[tokio::test] -async fn set_feature_enabled_updates_profile() -> anyhow::Result<()> { - let codex_home = TempDir::new()?; - - ConfigEditsBuilder::new(codex_home.path()) - .with_profile(Some("dev")) - .set_feature_enabled("guardian_approval", /*enabled*/ true) - .apply() - .await?; - - let serialized = tokio::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).await?; - let parsed: ConfigToml = toml::from_str(&serialized)?; - let profile = parsed - .profiles - .get("dev") - .expect("profile should be created"); - - assert_eq!( - profile - .features - .as_ref() - .and_then(|features| features.entries().get("guardian_approval").copied()), - Some(true), - ); - assert_eq!( - parsed - .features - .as_ref() - .and_then(|features| features.entries().get("guardian_approval").copied()), - None, - ); - - Ok(()) -} - -#[tokio::test] -async fn set_feature_enabled_persists_feature_disable_in_profile() -> anyhow::Result<()> { - let codex_home = TempDir::new()?; - - ConfigEditsBuilder::new(codex_home.path()) - .with_profile(Some("dev")) - .set_feature_enabled("guardian_approval", /*enabled*/ true) - .apply() - .await?; - - ConfigEditsBuilder::new(codex_home.path()) - .with_profile(Some("dev")) - .set_feature_enabled("guardian_approval", /*enabled*/ false) - .apply() - .await?; - - let serialized = tokio::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).await?; - let parsed: ConfigToml = toml::from_str(&serialized)?; - let profile = parsed - .profiles - .get("dev") - .expect("profile should be created"); - - assert_eq!( - profile - .features - .as_ref() - .and_then(|features| features.entries().get("guardian_approval").copied()), - Some(false), - ); - assert_eq!( - parsed - .features - .as_ref() - .and_then(|features| features.entries().get("guardian_approval").copied()), - None, - ); - - Ok(()) -} - -#[tokio::test] -async fn set_feature_enabled_profile_disable_overrides_root_enable() -> anyhow::Result<()> { - let codex_home = TempDir::new()?; - - ConfigEditsBuilder::new(codex_home.path()) - .set_feature_enabled("guardian_approval", /*enabled*/ true) - .apply() - .await?; - - ConfigEditsBuilder::new(codex_home.path()) - .with_profile(Some("dev")) - .set_feature_enabled("guardian_approval", /*enabled*/ false) - .apply() - .await?; - - let serialized = tokio::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).await?; - let parsed: ConfigToml = toml::from_str(&serialized)?; - let profile = parsed - .profiles - .get("dev") - .expect("profile should be created"); - - assert_eq!( - parsed - .features - .as_ref() - .and_then(|features| features.entries().get("guardian_approval").copied()), - Some(true), - ); - assert_eq!( - profile - .features - .as_ref() - .and_then(|features| features.entries().get("guardian_approval").copied()), - Some(false), - ); - - Ok(()) -} - struct PrecedenceTestFixture { cwd: TempDir, codex_home: TempDir, diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index 1b5cd879c45c..ca54a7c3f577 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -513,13 +513,6 @@ mod document_helpers { struct ConfigDocument { doc: DocumentMut, - profile: Option, -} - -#[derive(Copy, Clone)] -enum Scope { - Global, - Profile, } #[derive(Copy, Clone)] @@ -529,25 +522,25 @@ enum TraversalMode { } impl ConfigDocument { - fn new(doc: DocumentMut, profile: Option) -> Self { - Self { doc, profile } + fn new(doc: DocumentMut) -> Self { + Self { doc } } fn apply(&mut self, edit: &ConfigEdit) -> anyhow::Result { match edit { ConfigEdit::SetModel { model, effort } => Ok({ let mut mutated = false; - mutated |= self.write_profile_value( + mutated |= self.write_optional_value( &["model"], model.as_ref().map(|model_value| value(model_value.clone())), ); - mutated |= self.write_profile_value( + mutated |= self.write_optional_value( &["model_reasoning_effort"], effort.map(|effort| value(effort.to_string())), ); mutated }), - ConfigEdit::SetServiceTier { service_tier } => Ok(self.write_profile_value( + ConfigEdit::SetServiceTier { service_tier } => Ok(self.write_optional_value( &["service_tier"], service_tier.as_ref().map(|service_tier| { // Keep the legacy config spelling stable. Runtime values use @@ -560,35 +553,30 @@ impl ConfigDocument { value(config_value) }), )), - ConfigEdit::SetModelPersonality { personality } => Ok(self.write_profile_value( + ConfigEdit::SetModelPersonality { personality } => Ok(self.write_optional_value( &["personality"], personality.map(|personality| value(personality.to_string())), )), ConfigEdit::SetNoticeHideFullAccessWarning(acknowledged) => Ok(self.write_value( - Scope::Global, &[NOTICE_TABLE_KEY, "hide_full_access_warning"], value(*acknowledged), )), ConfigEdit::SetNoticeHideWorldWritableWarning(acknowledged) => Ok(self.write_value( - Scope::Global, &[NOTICE_TABLE_KEY, "hide_world_writable_warning"], value(*acknowledged), )), ConfigEdit::SetNoticeHideRateLimitModelNudge(acknowledged) => Ok(self.write_value( - Scope::Global, &[NOTICE_TABLE_KEY, "hide_rate_limit_model_nudge"], value(*acknowledged), )), ConfigEdit::SetNoticeHideModelMigrationPrompt(migration_config, acknowledged) => { Ok(self.write_value( - Scope::Global, &[NOTICE_TABLE_KEY, migration_config.as_str()], value(*acknowledged), )) } ConfigEdit::SetNoticeHideExternalConfigMigrationPromptHome(acknowledged) => Ok(self .write_value( - Scope::Global, &[ NOTICE_TABLE_KEY, "external_config_migration_prompts", @@ -598,7 +586,6 @@ impl ConfigDocument { )), ConfigEdit::SetNoticeExternalConfigMigrationPromptHomeLastPromptedAt(timestamp) => { Ok(self.write_value( - Scope::Global, &[ NOTICE_TABLE_KEY, "external_config_migration_prompts", @@ -611,7 +598,6 @@ impl ConfigDocument { project, acknowledged, ) => Ok(self.write_value( - Scope::Global, &[ NOTICE_TABLE_KEY, "external_config_migration_prompts", @@ -624,7 +610,6 @@ impl ConfigDocument { project, timestamp, ) => Ok(self.write_value( - Scope::Global, &[ NOTICE_TABLE_KEY, "external_config_migration_prompts", @@ -634,7 +619,6 @@ impl ConfigDocument { value(*timestamp), )), ConfigEdit::RecordModelMigrationSeen { from, to } => Ok(self.write_value( - Scope::Global, &[NOTICE_TABLE_KEY, "model_migrations", from.as_str()], value(to.clone()), )), @@ -663,20 +647,26 @@ impl ConfigDocument { } } - fn write_profile_value(&mut self, segments: &[&str], value: Option) -> bool { + fn write_optional_value(&mut self, segments: &[&str], value: Option) -> bool { match value { - Some(item) => self.write_value(Scope::Profile, segments, item), - None => self.clear(Scope::Profile, segments), + Some(item) => self.write_value(segments, item), + None => self.clear(segments), } } - fn write_value(&mut self, scope: Scope, segments: &[&str], value: TomlItem) -> bool { - let resolved = self.scoped_segments(scope, segments); + fn write_value(&mut self, segments: &[&str], value: TomlItem) -> bool { + let resolved = segments + .iter() + .map(|segment| (*segment).to_string()) + .collect::>(); self.insert(&resolved, value) } - fn clear(&mut self, scope: Scope, segments: &[&str]) -> bool { - let resolved = self.scoped_segments(scope, segments); + fn clear(&mut self, segments: &[&str]) -> bool { + let resolved = segments + .iter() + .map(|segment| (*segment).to_string()) + .collect::>(); self.remove(&resolved) } @@ -709,7 +699,6 @@ impl ConfigDocument { .filter(|disabled_tool| seen.insert(disabled_tool.clone())) .collect::>(); self.write_value( - Scope::Global, &["tool_suggest", "disabled_tools"], document_helpers::tool_suggest_disabled_tools_value(&disabled_tools), ) @@ -721,7 +710,7 @@ impl ConfigDocument { fn replace_mcp_servers(&mut self, servers: &BTreeMap) -> bool { if servers.is_empty() { - return self.clear(Scope::Global, &["mcp_servers"]); + return self.clear(&["mcp_servers"]); } let root = self.doc.as_table_mut(); @@ -883,26 +872,6 @@ impl ConfigDocument { mutated } - fn scoped_segments(&self, scope: Scope, segments: &[&str]) -> Vec { - let resolved: Vec = segments - .iter() - .map(|segment| (*segment).to_string()) - .collect(); - - if matches!(scope, Scope::Profile) - && resolved.first().is_none_or(|segment| segment != "profiles") - && let Some(profile) = self.profile.as_deref() - { - let mut scoped = Vec::with_capacity(resolved.len() + 2); - scoped.push("profiles".to_string()); - scoped.push(profile.to_string()); - scoped.extend(resolved); - return scoped; - } - - resolved - } - fn insert(&mut self, segments: &[String], value: TomlItem) -> bool { let Some((last, parents)) = segments.split_last() else { return false; @@ -1030,18 +999,13 @@ fn write_skill_config_selector(table: &mut TomlTable, selector: &SkillConfigSele } /// Persist edits using a blocking strategy. -pub fn apply_blocking( - codex_home: &Path, - profile: Option<&str>, - edits: &[ConfigEdit], -) -> anyhow::Result<()> { +pub fn apply_blocking(codex_home: &Path, edits: &[ConfigEdit]) -> anyhow::Result<()> { let config_path = codex_home.join(CONFIG_TOML_FILE); - apply_blocking_to_resolved_file(&config_path, profile, edits) + apply_blocking_to_resolved_file(&config_path, edits) } fn apply_blocking_to_resolved_file( resolved_config_file: &Path, - legacy_profile: Option<&str>, edits: &[ConfigEdit], ) -> anyhow::Result<()> { if edits.is_empty() { @@ -1064,13 +1028,7 @@ fn apply_blocking_to_resolved_file( serialized.parse::()? }; - let profile = legacy_profile.map(ToOwned::to_owned).or_else(|| { - doc.get("profile") - .and_then(|item| item.as_str()) - .map(ToOwned::to_owned) - }); - - let mut document = ConfigDocument::new(doc, profile); + let mut document = ConfigDocument::new(doc); let mut mutated = false; for edit in edits { @@ -1093,29 +1051,18 @@ fn apply_blocking_to_resolved_file( /// Persist edits asynchronously by offloading the blocking writer. /// -/// `profile` selects a legacy `[profiles.]` section inside -/// `$CODEX_HOME/config.toml`; profile-v2 callers should resolve their target -/// file before constructing a [ConfigEditsBuilder]. -pub async fn apply( - codex_home: &Path, - profile: Option<&str>, - edits: Vec, -) -> anyhow::Result<()> { +pub async fn apply(codex_home: &Path, edits: Vec) -> anyhow::Result<()> { let codex_home = codex_home.to_path_buf(); let config_path = codex_home.join(CONFIG_TOML_FILE); - let profile = profile.map(ToOwned::to_owned); - task::spawn_blocking(move || { - apply_blocking_to_resolved_file(&config_path, profile.as_deref(), &edits) - }) - .await - .context("config persistence task panicked")? + task::spawn_blocking(move || apply_blocking_to_resolved_file(&config_path, &edits)) + .await + .context("config persistence task panicked")? } /// Fluent builder to batch config edits and apply them atomically. #[derive(Default)] pub struct ConfigEditsBuilder { config_path: PathBuf, - profile: Option, edits: Vec, } @@ -1136,16 +1083,10 @@ impl ConfigEditsBuilder { pub fn for_config_path(config_path: &Path) -> Self { Self { config_path: config_path.to_path_buf(), - profile: None, edits: Vec::new(), } } - pub fn with_profile(mut self, profile: Option<&str>) -> Self { - self.profile = profile.map(ToOwned::to_owned); - self - } - pub fn set_model(mut self, model: Option<&str>, effort: Option) -> Self { self.edits.push(ConfigEdit::SetModel { model: model.map(ToOwned::to_owned), @@ -1248,27 +1189,16 @@ impl ConfigEditsBuilder { /// Enable or disable a feature flag by key under the `[features]` table. /// - /// Disabling a default-false feature clears the root-scoped key instead of + /// Disabling a default-false feature clears the key instead of /// persisting `false`, so the config does not pin the feature once it - /// graduates to globally enabled. Profile-scoped disables still persist - /// `false` so they can override an inherited root enable. + /// graduates to globally enabled. pub fn set_feature_enabled(mut self, key: &str, enabled: bool) -> Self { - let profile_scoped = self.profile.is_some(); - let segments = if let Some(profile) = self.profile.as_ref() { - vec![ - "profiles".to_string(), - profile.clone(), - "features".to_string(), - key.to_string(), - ] - } else { - vec!["features".to_string(), key.to_string()] - }; + let segments = vec!["features".to_string(), key.to_string()]; let is_default_false_feature = FEATURES .iter() .find(|spec| spec.key == key) .is_some_and(|spec| !spec.default_enabled); - if enabled || profile_scoped || !is_default_false_feature { + if enabled || !is_default_false_feature { self.edits.push(ConfigEdit::SetPath { segments, value: value(enabled), @@ -1280,18 +1210,8 @@ impl ConfigEditsBuilder { } pub fn set_windows_sandbox_mode(mut self, mode: &str) -> Self { - let segments = if let Some(profile) = self.profile.as_ref() { - vec![ - "profiles".to_string(), - profile.clone(), - "windows".to_string(), - "sandbox".to_string(), - ] - } else { - vec!["windows".to_string(), "sandbox".to_string()] - }; self.edits.push(ConfigEdit::SetPath { - segments, + segments: vec!["windows".to_string(), "sandbox".to_string()], value: value(mode), }); self @@ -1339,34 +1259,15 @@ impl ConfigEditsBuilder { "elevated_windows_sandbox", "enable_experimental_windows_sandbox", ] { - let mut segments = vec!["features".to_string(), key.to_string()]; - if let Some(profile) = self.profile.as_ref() { - segments = vec![ - "profiles".to_string(), - profile.clone(), - "features".to_string(), - key.to_string(), - ]; - } + let segments = vec!["features".to_string(), key.to_string()]; self.edits.push(ConfigEdit::ClearPath { segments }); } self } pub fn set_session_picker_view(mut self, mode: SessionPickerViewMode) -> Self { - let segments = if let Some(profile) = self.profile.as_ref() { - vec![ - "profiles".to_string(), - profile.clone(), - "tui".to_string(), - "session_picker_view".to_string(), - ] - } else { - vec!["tui".to_string(), "session_picker_view".to_string()] - }; - self.edits.push(ConfigEdit::SetPath { - segments, + segments: vec!["tui".to_string(), "session_picker_view".to_string()], value: value(mode.to_string()), }); self @@ -1382,13 +1283,13 @@ impl ConfigEditsBuilder { /// Apply edits on a blocking thread. pub fn apply_blocking(self) -> anyhow::Result<()> { - apply_blocking_to_resolved_file(&self.config_path, self.profile.as_deref(), &self.edits) + apply_blocking_to_resolved_file(&self.config_path, &self.edits) } /// Apply edits asynchronously via a blocking offload. pub async fn apply(self) -> anyhow::Result<()> { task::spawn_blocking(move || { - apply_blocking_to_resolved_file(&self.config_path, self.profile.as_deref(), &self.edits) + apply_blocking_to_resolved_file(&self.config_path, &self.edits) }) .await .context("config persistence task panicked")? diff --git a/codex-rs/core/src/config/edit_tests.rs b/codex-rs/core/src/config/edit_tests.rs index 740d819aed2d..dce192831b6d 100644 --- a/codex-rs/core/src/config/edit_tests.rs +++ b/codex-rs/core/src/config/edit_tests.rs @@ -20,7 +20,6 @@ fn blocking_set_model_top_level() { apply_blocking( codex_home, - /*profile*/ None, &[ConfigEdit::SetModel { model: Some("gpt-5.4".to_string()), effort: Some(ReasoningEffort::High), @@ -111,24 +110,6 @@ session_picker_view = "dense" assert_eq!(contents, expected); } -#[test] -fn session_picker_view_builder_respects_active_profile() { - let tmp = tempdir().expect("tmpdir"); - let codex_home = tmp.path(); - - ConfigEditsBuilder::new(codex_home) - .with_profile(Some("work")) - .set_session_picker_view(SessionPickerViewMode::Dense) - .apply_blocking() - .expect("persist"); - - let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); - let expected = r#"[profiles.work.tui] -session_picker_view = "dense" -"#; - assert_eq!(contents, expected); -} - #[test] fn keymap_binding_edit_writes_root_action_binding() { let tmp = tempdir().expect("tmpdir"); @@ -380,7 +361,7 @@ enabled = false } #[test] -fn blocking_set_model_preserves_inline_table_contents() { +fn blocking_set_model_ignores_inline_legacy_profile_contents() { let tmp = tempdir().expect("tmpdir"); let codex_home = tmp.path(); @@ -396,7 +377,6 @@ profiles = { fast = { model = "gpt-4o", sandbox_mode = "strict" } } apply_blocking( codex_home, - /*profile*/ None, &[ConfigEdit::SetModel { model: Some("o4-mini".to_string()), effort: None, @@ -407,7 +387,12 @@ profiles = { fast = { model = "gpt-4o", sandbox_mode = "strict" } } let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let value: TomlValue = toml::from_str(&raw).expect("parse config"); - // Ensure sandbox_mode is preserved under profiles.fast and model updated. + assert_eq!( + value.get("model").and_then(TomlValue::as_str), + Some("o4-mini") + ); + + // Legacy profile values stay untouched when root settings are updated. let profiles_tbl = value .get("profiles") .and_then(|v| v.as_table()) @@ -422,7 +407,7 @@ profiles = { fast = { model = "gpt-4o", sandbox_mode = "strict" } } ); assert_eq!( fast_tbl.get("model").and_then(|v| v.as_str()), - Some("o4-mini") + Some("gpt-4o") ); } @@ -441,7 +426,6 @@ fn blocking_set_model_writes_through_symlink_chain() { apply_blocking( codex_home, - /*profile*/ None, &[ConfigEdit::SetModel { model: Some("gpt-5.4".to_string()), effort: Some(ReasoningEffort::High), @@ -474,7 +458,6 @@ fn blocking_set_model_replaces_symlink_on_cycle() { apply_blocking( codex_home, - /*profile*/ None, &[ConfigEdit::SetModel { model: Some("gpt-5.4".to_string()), effort: None, @@ -513,7 +496,6 @@ network_access = false apply_blocking( codex_home, - /*profile*/ None, &[ ConfigEdit::SetPath { segments: vec![ @@ -553,7 +535,7 @@ network_access = true } #[test] -fn blocking_clear_model_removes_inline_table_entry() { +fn blocking_clear_model_does_not_follow_legacy_active_profile() { let tmp = tempdir().expect("tmpdir"); let codex_home = tmp.path(); @@ -568,7 +550,6 @@ profiles = { fast = { model = "gpt-4o", sandbox_mode = "strict" } } apply_blocking( codex_home, - /*profile*/ None, &[ConfigEdit::SetModel { model: None, effort: Some(ReasoningEffort::High), @@ -579,15 +560,14 @@ profiles = { fast = { model = "gpt-4o", sandbox_mode = "strict" } } let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let expected = r#"profile = "fast" -[profiles.fast] -sandbox_mode = "strict" +profiles = { fast = { model = "gpt-4o", sandbox_mode = "strict" } } model_reasoning_effort = "high" "#; assert_eq!(contents, expected); } #[test] -fn blocking_set_model_scopes_to_active_profile() { +fn blocking_set_model_does_not_follow_legacy_active_profile() { let tmp = tempdir().expect("tmpdir"); let codex_home = tmp.path(); std::fs::write( @@ -602,7 +582,6 @@ model_reasoning_effort = "low" apply_blocking( codex_home, - /*profile*/ None, &[ConfigEdit::SetModel { model: Some("o5-preview".to_string()), effort: Some(ReasoningEffort::Minimal), @@ -612,39 +591,11 @@ model_reasoning_effort = "low" let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let expected = r#"profile = "team" - -[profiles.team] -model_reasoning_effort = "minimal" model = "o5-preview" -"#; - assert_eq!(contents, expected); -} - -#[test] -fn blocking_set_model_with_explicit_profile() { - let tmp = tempdir().expect("tmpdir"); - let codex_home = tmp.path(); - std::fs::write( - codex_home.join(CONFIG_TOML_FILE), - r#"[profiles."team a"] -model = "gpt-5.4" -"#, - ) - .expect("seed"); - - apply_blocking( - codex_home, - Some("team a"), - &[ConfigEdit::SetModel { - model: Some("o4-mini".to_string()), - effort: None, - }], - ) - .expect("persist"); +model_reasoning_effort = "minimal" - let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); - let expected = r#"[profiles."team a"] -model = "o4-mini" +[profiles.team] +model_reasoning_effort = "low" "#; assert_eq!(contents, expected); } @@ -666,7 +617,6 @@ existing = "value" apply_blocking( codex_home, - /*profile*/ None, &[ConfigEdit::SetNoticeHideFullAccessWarning(true)], ) .expect("persist"); @@ -696,7 +646,6 @@ existing = "value" apply_blocking( codex_home, - /*profile*/ None, &[ConfigEdit::SetNoticeHideRateLimitModelNudge(true)], ) .expect("persist"); @@ -722,7 +671,6 @@ existing = "value" .expect("seed"); apply_blocking( codex_home, - /*profile*/ None, &[ConfigEdit::SetNoticeHideModelMigrationPrompt( "hide_gpt5_1_migration_prompt".to_string(), true, @@ -751,7 +699,6 @@ existing = "value" .expect("seed"); apply_blocking( codex_home, - /*profile*/ None, &[ConfigEdit::SetNoticeHideModelMigrationPrompt( "hide_gpt-5.1-codex-max_migration_prompt".to_string(), true, @@ -780,7 +727,6 @@ existing = "value" .expect("seed"); apply_blocking( codex_home, - /*profile*/ None, &[ConfigEdit::RecordModelMigrationSeen { from: "gpt-5.2".to_string(), to: "gpt-5.4".to_string(), @@ -811,7 +757,6 @@ existing = "value" .expect("seed"); apply_blocking( codex_home, - /*profile*/ None, &[ConfigEdit::SetNoticeHideExternalConfigMigrationPromptHome( true, )], @@ -841,7 +786,6 @@ existing = "value" .expect("seed"); apply_blocking( codex_home, - /*profile*/ None, &[ ConfigEdit::SetNoticeHideExternalConfigMigrationPromptProject( "/Users/alexsong/code/skills".to_string(), @@ -874,7 +818,6 @@ existing = "value" .expect("seed"); apply_blocking( codex_home, - /*profile*/ None, &[ConfigEdit::SetNoticeExternalConfigMigrationPromptHomeLastPromptedAt(1_760_000_000)], ) .expect("persist"); @@ -902,7 +845,6 @@ existing = "value" .expect("seed"); apply_blocking( codex_home, - /*profile*/ None, &[ ConfigEdit::SetNoticeExternalConfigMigrationPromptProjectLastPromptedAt( "/Users/alexsong/code/skills".to_string(), @@ -996,7 +938,6 @@ fn blocking_replace_mcp_servers_round_trips() { apply_blocking( codex_home, - /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(servers.clone())], ) .expect("persist"); @@ -1069,12 +1010,7 @@ fn blocking_replace_mcp_servers_serializes_tool_approval_overrides() { }, ); - apply_blocking( - codex_home, - /*profile*/ None, - &[ConfigEdit::ReplaceMcpServers(servers)], - ) - .expect("persist"); + apply_blocking(codex_home, &[ConfigEdit::ReplaceMcpServers(servers)]).expect("persist"); let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let expected = "\ @@ -1129,12 +1065,7 @@ foo = { command = "cmd" } }, ); - apply_blocking( - codex_home, - /*profile*/ None, - &[ConfigEdit::ReplaceMcpServers(servers)], - ) - .expect("persist"); + apply_blocking(codex_home, &[ConfigEdit::ReplaceMcpServers(servers)]).expect("persist"); let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let expected = r#"[mcp_servers] @@ -1184,12 +1115,7 @@ foo = { command = "cmd" } # keep me }, ); - apply_blocking( - codex_home, - /*profile*/ None, - &[ConfigEdit::ReplaceMcpServers(servers)], - ) - .expect("persist"); + apply_blocking(codex_home, &[ConfigEdit::ReplaceMcpServers(servers)]).expect("persist"); let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let expected = r#"[mcp_servers] @@ -1238,12 +1164,7 @@ foo = { command = "cmd", args = ["--flag"] } # keep me }, ); - apply_blocking( - codex_home, - /*profile*/ None, - &[ConfigEdit::ReplaceMcpServers(servers)], - ) - .expect("persist"); + apply_blocking(codex_home, &[ConfigEdit::ReplaceMcpServers(servers)]).expect("persist"); let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let expected = r#"[mcp_servers] @@ -1293,12 +1214,7 @@ foo = { command = "cmd" } }, ); - apply_blocking( - codex_home, - /*profile*/ None, - &[ConfigEdit::ReplaceMcpServers(servers)], - ) - .expect("persist"); + apply_blocking(codex_home, &[ConfigEdit::ReplaceMcpServers(servers)]).expect("persist"); let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let expected = r#"[mcp_servers] @@ -1315,7 +1231,6 @@ fn blocking_clear_path_noop_when_missing() { apply_blocking( codex_home, - /*profile*/ None, &[ConfigEdit::ClearPath { segments: vec!["missing".to_string()], }], @@ -1336,7 +1251,6 @@ fn blocking_set_path_updates_notifications() { let item = value(false); apply_blocking( codex_home, - /*profile*/ None, &[ConfigEdit::SetPath { segments: vec!["tui".to_string(), "notifications".to_string()], value: item, @@ -1518,7 +1432,6 @@ fn replace_mcp_servers_blocking_clears_table_when_empty() { apply_blocking( codex_home, - /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(BTreeMap::new())], ) .expect("persist"); diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 3b65ceddcdbd..a70492ef05fb 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -958,9 +958,6 @@ pub struct Config { /// When `true`, suppress warnings about unstable (under development) features. pub suppress_unstable_features_warning: bool, - /// The active profile name used to derive this `Config` (if any). - pub active_profile: Option, - /// The currently active project config, resolved by checking if cwd: /// is (1) part of a git repo, (2) a git worktree, or (3) just using the cwd pub active_project: ProjectConfig, @@ -3499,7 +3496,6 @@ impl Config { suppress_unstable_features_warning: cfg .suppress_unstable_features_warning .unwrap_or(false), - active_profile: None, active_project, notices, check_for_update_on_startup, diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 8ed010172e76..a16b0855eced 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -816,7 +816,7 @@ impl Session { .permissions .legacy_sandbox_policy(session_configuration.cwd.as_path()), mcp_servers.keys().map(String::as_str).collect(), - config.active_profile.clone(), + /*active_profile*/ None, ); let use_zsh_fork_shell = config.features.enabled(Feature::ShellZshFork); diff --git a/codex-rs/core/src/windows_sandbox.rs b/codex-rs/core/src/windows_sandbox.rs index 4166868ff7a3..1494ce262eef 100644 --- a/codex-rs/core/src/windows_sandbox.rs +++ b/codex-rs/core/src/windows_sandbox.rs @@ -252,7 +252,6 @@ pub struct WindowsSandboxSetupRequest { pub command_cwd: PathBuf, pub env_map: HashMap, pub codex_home: PathBuf, - pub active_profile: Option, } pub async fn run_windows_sandbox_setup(request: WindowsSandboxSetupRequest) -> anyhow::Result<()> { @@ -291,7 +290,6 @@ async fn run_windows_sandbox_setup_and_persist( let command_cwd = request.command_cwd; let env_map = request.env_map; let codex_home = request.codex_home; - let active_profile = request.active_profile; let setup_codex_home = codex_home.clone(); let setup_result = tokio::task::spawn_blocking(move || -> anyhow::Result<()> { @@ -325,7 +323,6 @@ async fn run_windows_sandbox_setup_and_persist( setup_result?; ConfigEditsBuilder::new(codex_home.as_path()) - .with_profile(active_profile.as_deref()) .set_windows_sandbox_mode(windows_sandbox_setup_mode_tag(mode)) .clear_legacy_windows_sandbox_keys() .apply() diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 25b2b0e14395..52590b56cca4 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -134,7 +134,6 @@ pub use exec_events::TurnStartedEvent; pub use exec_events::Usage; pub use exec_events::WebSearchItem; use serde_json::Value; -use std::collections::HashMap; use std::io::IsTerminal; use std::io::Read; use std::path::Path; @@ -967,7 +966,7 @@ fn thread_start_params_from_config(config: &Config) -> ThreadStartParams { approvals_reviewer: approvals_reviewer_override_from_config(config), sandbox: sandbox.flatten(), permissions, - config: config_request_overrides_from_config(config), + config: None, ephemeral: Some(config.ephemeral), thread_source: Some(ThreadSource::User), ..ThreadStartParams::default() @@ -998,7 +997,7 @@ fn thread_resume_params_from_config(config: &Config, thread_id: String) -> Threa approvals_reviewer: approvals_reviewer_override_from_config(config), sandbox: sandbox.flatten(), permissions, - config: config_request_overrides_from_config(config), + config: None, ..ThreadResumeParams::default() } } @@ -1039,13 +1038,6 @@ fn sandbox_mode_from_permission_profile( } } -fn config_request_overrides_from_config(config: &Config) -> Option> { - config - .active_profile - .as_ref() - .map(|profile| HashMap::from([("profile".to_string(), Value::String(profile.clone()))])) -} - fn approvals_reviewer_override_from_config( config: &Config, ) -> Option { diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index 9fdd6db90fb1..634327d007dc 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -271,7 +271,6 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R multi_agent_v2: MultiAgentV2Config::default(), features: Default::default(), suppress_unstable_features_warning: false, - active_profile: None, active_project: ProjectConfig { trust_level: None }, notices: Notice::default(), check_for_update_on_startup: false, diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index c00974967829..3c3d15086f85 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -486,7 +486,6 @@ pub(crate) struct App { /// Config is stored here so we can recreate ChatWidgets as needed. pub(crate) config: Config, pub(crate) state_db: Option, - pub(crate) active_profile: Option, cli_kv_overrides: Vec<(String, TomlValue)>, harness_overrides: ConfigOverrides, loader_overrides: LoaderOverrides, @@ -693,7 +692,6 @@ impl App { cli_kv_overrides: Vec<(String, TomlValue)>, harness_overrides: ConfigOverrides, loader_overrides: LoaderOverrides, - active_profile: Option, initial_prompt: Option, initial_images: Vec, session_selection: SessionSelection, @@ -971,7 +969,6 @@ See the Codex keymap documentation for supported actions and examples." workspace_command_runner: Some(workspace_command_runner), config, state_db, - active_profile, cli_kv_overrides, harness_overrides, loader_overrides, diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index 1cba3c85a0c4..e83662c2a25c 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -344,7 +344,6 @@ impl App { let auto_review_preset = auto_review_mode(); let mut next_config = self.config.clone(); - let active_profile = self.active_profile.clone(); let windows_sandbox_changed = updates.iter().any(|(feature, _)| { matches!( feature, @@ -356,42 +355,12 @@ impl App { let mut permission_profile_override = None; let mut active_permission_profile_override = None; let mut feature_updates_to_apply = Vec::with_capacity(updates.len()); - // Auto-Review owns `approvals_reviewer`, but disabling the feature - // from inside a profile should not silently clear a value configured at - // the root scope. - let (root_approvals_reviewer_blocks_profile_disable, profile_approvals_reviewer_configured) = { - let effective_config = next_config.config_layer_stack.effective_config(); - let root_blocks_disable = effective_config - .as_table() - .and_then(|table| table.get("approvals_reviewer")) - .is_some_and(|value| value != &TomlValue::String("user".to_string())); - let profile_configured = active_profile.as_deref().is_some_and(|profile| { - effective_config - .as_table() - .and_then(|table| table.get("profiles")) - .and_then(TomlValue::as_table) - .and_then(|profiles| profiles.get(profile)) - .and_then(TomlValue::as_table) - .is_some_and(|profile_config| profile_config.contains_key("approvals_reviewer")) - }); - (root_blocks_disable, profile_configured) - }; let mut permissions_history_label: Option<&'static str> = None; let mut config_edits = Vec::new(); for (feature, enabled) in updates { let feature_key = feature.key(); let mut feature_edits = Vec::new(); - if feature == Feature::GuardianApproval - && !enabled - && self.active_profile.is_some() - && root_approvals_reviewer_blocks_profile_disable - { - self.chat_widget.add_error_message( - "Cannot disable Auto-review in this profile because `approvals_reviewer` is configured outside the active profile.".to_string(), - ); - continue; - } let mut feature_config = next_config.clone(); if let Err(err) = feature_config.features.set_enabled(feature, enabled) { tracing::error!( @@ -413,24 +382,16 @@ impl App { // changes it explicitly. feature_config.approvals_reviewer = auto_review_preset.approvals_reviewer; feature_edits.push(crate::config_update::replace_config_value( - crate::config_update::profile_scoped_key_path( - active_profile.as_deref(), - "approvals_reviewer", - ), + "approvals_reviewer", serde_json::json!(auto_review_preset.approvals_reviewer.to_string()), )); if previous_approvals_reviewer != auto_review_preset.approvals_reviewer { permissions_history_label = Some("Auto-review"); } } else if !effective_enabled { - if profile_approvals_reviewer_configured || self.active_profile.is_none() { - feature_edits.push(crate::config_update::clear_config_value( - crate::config_update::profile_scoped_key_path( - active_profile.as_deref(), - "approvals_reviewer", - ), - )); - } + feature_edits.push(crate::config_update::clear_config_value( + "approvals_reviewer", + )); feature_config.approvals_reviewer = ApprovalsReviewer::User; if previous_approvals_reviewer != ApprovalsReviewer::User { permissions_history_label = Some("Default"); @@ -463,17 +424,11 @@ impl App { }; feature_edits.extend([ crate::config_update::replace_config_value( - crate::config_update::profile_scoped_key_path( - active_profile.as_deref(), - "approval_policy", - ), + "approval_policy", serde_json::json!("on-request"), ), crate::config_update::replace_config_value( - crate::config_update::profile_scoped_key_path( - active_profile.as_deref(), - "sandbox_mode", - ), + "sandbox_mode", serde_json::json!("workspace-write"), ), ]); @@ -486,7 +441,6 @@ impl App { feature_updates_to_apply.push((feature, effective_enabled)); config_edits.extend(feature_edits); config_edits.push(crate::config_update::build_feature_enabled_edit( - active_profile.as_deref(), feature_key, effective_enabled, )); @@ -815,11 +769,8 @@ impl App { effective_config: &ConfigReadResponse, feature_updates: &[(Feature, bool)], ) { - let active_profile = self.active_profile.clone(); - let active_profile = active_profile.as_deref(); for (feature, _) in feature_updates { - let enabled = - feature_enabled_from_effective_config(effective_config, active_profile, *feature); + let enabled = feature_enabled_from_effective_config(effective_config, *feature); if let Err(err) = self.config.features.set_enabled(*feature, enabled) { tracing::warn!( error = %err, @@ -840,14 +791,10 @@ impl App { return; } - if let Some(reviewer) = - approvals_reviewer_from_effective_config(effective_config, active_profile) - { + if let Some(reviewer) = approvals_reviewer_from_effective_config(effective_config) { self.set_approvals_reviewer_in_app_and_widget(reviewer); } - if let Some(policy) = - approval_policy_from_effective_config(effective_config, active_profile) - { + if let Some(policy) = approval_policy_from_effective_config(effective_config) { if let Err(err) = self .config .permissions @@ -876,7 +823,7 @@ impl App { .iter() .any(|(feature, _)| *feature == Feature::GuardianApproval) || !self.config.features.enabled(Feature::GuardianApproval) - || sandbox_mode_from_effective_config(effective_config, self.active_profile.as_deref()) + || sandbox_mode_from_effective_config(effective_config) != Some(AppServerSandboxMode::WorkspaceWrite) { return; @@ -982,10 +929,7 @@ impl App { else { return; }; - let Some(mode) = windows_sandbox_mode_from_effective_config( - &effective_config, - self.active_profile.as_deref(), - ) else { + let Some(mode) = windows_sandbox_mode_from_effective_config(&effective_config) else { return; }; self.config.permissions.windows_sandbox_mode = Some(mode); @@ -1026,59 +970,38 @@ fn overridden_write_message(write_response: &ConfigWriteResponse) -> &str { fn feature_enabled_from_effective_config( effective_config: &ConfigReadResponse, - active_profile: Option<&str>, feature: Feature, ) -> bool { - let profile_features = active_profile - .and_then(|profile| effective_config.config.profiles.get(profile)) - .and_then(|profile| profile.additional.get("features")) - .and_then(features_toml_from_json); let root_features = effective_config .config .additional .get("features") .and_then(features_toml_from_json); - profile_features + root_features .as_ref() .and_then(|features| features.entries().get(feature.key()).copied()) - .or_else(|| { - root_features - .as_ref() - .and_then(|features| features.entries().get(feature.key()).copied()) - }) .unwrap_or_else(|| feature.default_enabled()) } fn approvals_reviewer_from_effective_config( effective_config: &ConfigReadResponse, - active_profile: Option<&str>, ) -> Option { - active_profile - .and_then(|profile| effective_config.config.profiles.get(profile)) - .and_then(|profile| profile.approvals_reviewer) - .or(effective_config.config.approvals_reviewer) + effective_config + .config + .approvals_reviewer .map(codex_app_server_protocol::ApprovalsReviewer::to_core) } fn approval_policy_from_effective_config( effective_config: &ConfigReadResponse, - active_profile: Option<&str>, ) -> Option { - active_profile - .and_then(|profile| effective_config.config.profiles.get(profile)) - .and_then(|profile| profile.approval_policy) - .or(effective_config.config.approval_policy) + effective_config.config.approval_policy } fn sandbox_mode_from_effective_config( effective_config: &ConfigReadResponse, - active_profile: Option<&str>, ) -> Option { - active_profile - .and_then(|profile| effective_config.config.profiles.get(profile)) - .and_then(|profile| profile.additional.get("sandbox_mode")) - .and_then(|mode| serde_json::from_value(mode.clone()).ok()) - .or(effective_config.config.sandbox_mode) + effective_config.config.sandbox_mode } fn memories_from_effective_config(effective_config: &ConfigReadResponse) -> Option { @@ -1096,20 +1019,13 @@ fn features_toml_from_json(value: &serde_json::Value) -> Option { #[cfg(target_os = "windows")] fn windows_sandbox_mode_from_effective_config( effective_config: &ConfigReadResponse, - active_profile: Option<&str>, ) -> Option { - let profile_windows = active_profile - .and_then(|profile| effective_config.config.profiles.get(profile)) - .and_then(|profile| profile.additional.get("windows")) - .and_then(windows_toml_from_json); let root_windows = effective_config .config .additional .get("windows") .and_then(windows_toml_from_json); - profile_windows - .and_then(|windows| windows.sandbox) - .or_else(|| root_windows.and_then(|windows| windows.sandbox)) + root_windows.and_then(|windows| windows.sandbox) } #[cfg(target_os = "windows")] diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index d103bb738d71..e150dbccd21b 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -1158,12 +1158,9 @@ impl App { &[("result", "success")], ); } - let profile = self.active_profile.as_deref(); let elevated_enabled = matches!(mode, WindowsSandboxEnableMode::Elevated); - let edits = crate::config_update::build_windows_sandbox_mode_edits( - profile, - elevated_enabled, - ); + let edits = + crate::config_update::build_windows_sandbox_mode_edits(elevated_enabled); match crate::config_update::write_config_batch( app_server.request_handle(), edits, @@ -1299,14 +1296,9 @@ impl App { } } AppEvent::PersistModelSelection { model, effort } => { - let profile = self.active_profile.as_deref(); match crate::config_update::write_config_batch( app_server.request_handle(), - crate::config_update::build_model_selection_edits( - profile, - model.as_str(), - effort, - ), + crate::config_update::build_model_selection_edits(model.as_str(), effort), ) .await { @@ -1320,11 +1312,6 @@ impl App { message.push(' '); message.push_str(label); } - if let Some(profile) = profile { - message.push_str(" for "); - message.push_str(profile); - message.push_str(" profile"); - } self.chat_widget.add_info_message(message, /*hint*/ None); } Err(err) => { @@ -1332,14 +1319,8 @@ impl App { error = %err, "failed to persist model selection" ); - if let Some(profile) = profile { - self.chat_widget.add_error_message(format!( - "Failed to save model for profile `{profile}`: {err}" - )); - } else { - self.chat_widget - .add_error_message(format!("Failed to save default model: {err}")); - } + self.chat_widget + .add_error_message(format!("Failed to save default model: {err}")); } } } @@ -1381,11 +1362,10 @@ impl App { self.chat_widget.on_plugin_mentions_loaded(plugins); } AppEvent::PersistPersonalitySelection { personality } => { - let profile = self.active_profile.as_deref(); match crate::config_update::write_config_batch( app_server.request_handle(), vec![crate::config_update::replace_config_value( - crate::config_update::profile_scoped_key_path(profile, "personality"), + "personality", serde_json::json!(personality.to_string()), )], ) @@ -1393,12 +1373,7 @@ impl App { { Ok(_) => { let label = Self::personality_label(personality); - let mut message = format!("Personality set to {label}"); - if let Some(profile) = profile { - message.push_str(" for "); - message.push_str(profile); - message.push_str(" profile"); - } + let message = format!("Personality set to {label}"); self.chat_widget.add_info_message(message, /*hint*/ None); } Err(err) => { @@ -1406,15 +1381,9 @@ impl App { error = %err, "failed to persist personality selection" ); - if let Some(profile) = profile { - self.chat_widget.add_error_message(format!( - "Failed to save personality for profile `{profile}`: {err}" - )); - } else { - self.chat_widget.add_error_message(format!( - "Failed to save default personality: {err}" - )); - } + self.chat_widget.add_error_message(format!( + "Failed to save default personality: {err}" + )); } } } @@ -1423,38 +1392,25 @@ impl App { self.config.service_tier = service_tier.clone(); self.sync_active_thread_service_tier_to_cached_session() .await; - let profile = self.active_profile.as_deref(); let edits = crate::config_update::build_service_tier_selection_edits( - profile, service_tier.as_deref(), ); match crate::config_update::write_config_batch(app_server.request_handle(), edits) .await { Ok(_) => { - let mut message = if let Some(service_tier) = service_tier { + let message = if let Some(service_tier) = service_tier { format!("Service tier set to {service_tier}") } else { "Service tier cleared".to_string() }; - if let Some(profile) = profile { - message.push_str(" for "); - message.push_str(profile); - message.push_str(" profile"); - } self.chat_widget.add_info_message(message, /*hint*/ None); } Err(err) => { tracing::error!(error = %err, "failed to persist service tier selection"); - if let Some(profile) = profile { - self.chat_widget.add_error_message(format!( - "Failed to save service tier for profile `{profile}`: {err}" - )); - } else { - self.chat_widget.add_error_message(format!( - "Failed to save default service tier: {err}" - )); - } + self.chat_widget.add_error_message(format!( + "Failed to save default service tier: {err}" + )); } } } @@ -1603,14 +1559,10 @@ impl App { self.chat_widget.set_approvals_reviewer(policy); self.sync_active_thread_permission_settings_to_cached_session() .await; - let profile = self.active_profile.as_deref(); if let Err(err) = crate::config_update::write_config_batch( app_server.request_handle(), vec![crate::config_update::replace_config_value( - crate::config_update::profile_scoped_key_path( - profile, - "approvals_reviewer", - ), + "approvals_reviewer", serde_json::json!(policy.to_string()), )], ) @@ -1706,11 +1658,7 @@ impl App { } } AppEvent::PersistPlanModeReasoningEffort(effort) => { - let profile = self.active_profile.as_deref(); - let key_path = crate::config_update::profile_scoped_key_path( - profile, - "plan_mode_reasoning_effort", - ); + let key_path = "plan_mode_reasoning_effort"; let edit = if let Some(effort) = effort { crate::config_update::replace_config_value( key_path, @@ -1729,15 +1677,9 @@ impl App { error = %err, "failed to persist plan mode reasoning effort" ); - if let Some(profile) = profile { - self.chat_widget.add_error_message(format!( - "Failed to save Plan mode reasoning effort for profile `{profile}`: {err}" - )); - } else { - self.chat_widget.add_error_message(format!( - "Failed to save Plan mode reasoning effort: {err}" - )); - } + self.chat_widget.add_error_message(format!( + "Failed to save Plan mode reasoning effort: {err}" + )); } } AppEvent::PersistModelMigrationPromptAcknowledged { diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index 10c234a903a9..9bbe0c60b478 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -22,7 +22,6 @@ pub(super) async fn make_test_app() -> App { workspace_command_runner: None, config, state_db: None, - active_profile: None, cli_kv_overrides: Vec::new(), harness_overrides: ConfigOverrides::default(), loader_overrides: LoaderOverrides::without_managed_config_for_tests(), diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index c12d44dd9913..04c8bbf9a060 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -2041,239 +2041,6 @@ async fn update_feature_flags_disabling_guardian_clears_manual_review_policy_wit Ok(()) } -#[tokio::test] -async fn update_feature_flags_enabling_guardian_in_profile_sets_profile_auto_review_policy() --> Result<()> { - let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await; - let codex_home = tempdir()?; - app.config.codex_home = codex_home.path().to_path_buf().abs(); - let auto_review = auto_review_mode(); - app.active_profile = Some("guardian".to_string()); - let config_toml_path = codex_home.path().join("config.toml").abs(); - let config_toml = "profile = \"guardian\"\napprovals_reviewer = \"user\"\n"; - std::fs::write(config_toml_path.as_path(), config_toml)?; - let user_config = toml::from_str::(config_toml)?; - app.config.config_layer_stack = app - .config - .config_layer_stack - .with_user_config(&config_toml_path, user_config); - app.config.approvals_reviewer = ApprovalsReviewer::User; - app.chat_widget - .set_approvals_reviewer(ApprovalsReviewer::User); - let mut app_server = start_config_write_test_app_server(&app).await?; - - app.update_feature_flags(&mut app_server, vec![(Feature::GuardianApproval, true)]) - .await; - - assert!(app.config.features.enabled(Feature::GuardianApproval)); - assert_eq!( - app.config.approvals_reviewer, - auto_review.approvals_reviewer - ); - assert_eq!( - app.chat_widget.config_ref().approvals_reviewer, - auto_review.approvals_reviewer - ); - assert_eq!( - op_rx.try_recv(), - Ok(Op::OverrideTurnContext { - cwd: None, - approval_policy: Some(auto_review.approval_policy), - approvals_reviewer: Some(auto_review.approvals_reviewer), - permission_profile: Some(auto_review.permission_profile()), - active_permission_profile: Some(auto_review.active_permission_profile.clone()), - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, - }) - ); - - let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; - let config_value = toml::from_str::(&config)?; - let profile_config = config_value - .as_table() - .and_then(|table| table.get("profiles")) - .and_then(TomlValue::as_table) - .and_then(|profiles| profiles.get("guardian")) - .and_then(TomlValue::as_table) - .expect("guardian profile should exist"); - assert_eq!( - config_value - .as_table() - .and_then(|table| table.get("approvals_reviewer")), - Some(&TomlValue::String("user".to_string())) - ); - assert_eq!( - profile_config.get("approvals_reviewer"), - Some(&TomlValue::String("guardian_subagent".to_string())) - ); - app_server.shutdown().await?; - Ok(()) -} - -#[tokio::test] -async fn update_feature_flags_disabling_guardian_in_profile_allows_inherited_user_reviewer() --> Result<()> { - let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; - let codex_home = tempdir()?; - app.config.codex_home = codex_home.path().to_path_buf().abs(); - app.active_profile = Some("guardian".to_string()); - let config_toml_path = codex_home.path().join("config.toml").abs(); - let config_toml = r#" -profile = "guardian" -approvals_reviewer = "user" - -[profiles.guardian] -approvals_reviewer = "guardian_subagent" - -[profiles.guardian.features] -guardian_approval = true -"#; - std::fs::write(config_toml_path.as_path(), config_toml)?; - let user_config = toml::from_str::(config_toml)?; - app.config.config_layer_stack = app - .config - .config_layer_stack - .with_user_config(&config_toml_path, user_config); - app.config - .features - .set_enabled(Feature::GuardianApproval, /*enabled*/ true)?; - app.chat_widget - .set_feature_enabled(Feature::GuardianApproval, /*enabled*/ true); - app.config.approvals_reviewer = ApprovalsReviewer::AutoReview; - app.chat_widget - .set_approvals_reviewer(ApprovalsReviewer::AutoReview); - let mut app_server = start_config_write_test_app_server(&app).await?; - - app.update_feature_flags(&mut app_server, vec![(Feature::GuardianApproval, false)]) - .await; - - assert!(!app.config.features.enabled(Feature::GuardianApproval)); - assert!( - !app.chat_widget - .config_ref() - .features - .enabled(Feature::GuardianApproval) - ); - assert_eq!(app.config.approvals_reviewer, ApprovalsReviewer::User); - assert_eq!( - app.chat_widget.config_ref().approvals_reviewer, - ApprovalsReviewer::User - ); - assert_eq!( - op_rx.try_recv(), - Ok(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - approvals_reviewer: Some(ApprovalsReviewer::User), - permission_profile: None, - active_permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, - }) - ); - let cell = match app_event_rx.try_recv() { - Ok(AppEvent::InsertHistoryCell(cell)) => cell, - other => panic!("expected InsertHistoryCell event, got {other:?}"), - }; - let rendered = cell - .display_lines(/*width*/ 120) - .into_iter() - .map(|line| line.to_string()) - .collect::>() - .join("\n"); - assert!(rendered.contains("Permissions updated to Default")); - - let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; - assert!(!config.contains("guardian_approval = true")); - assert!(!config.contains("guardian_subagent")); - assert_eq!( - toml::from_str::(&config)? - .as_table() - .and_then(|table| table.get("approvals_reviewer")), - Some(&TomlValue::String("user".to_string())) - ); - app_server.shutdown().await?; - Ok(()) -} - -#[tokio::test] -async fn update_feature_flags_disabling_guardian_in_profile_keeps_inherited_non_user_reviewer_enabled() --> Result<()> { - let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; - let codex_home = tempdir()?; - app.config.codex_home = codex_home.path().to_path_buf().abs(); - app.active_profile = Some("guardian".to_string()); - let config_toml_path = codex_home.path().join("config.toml").abs(); - let config_toml = "profile = \"guardian\"\napprovals_reviewer = \"guardian_subagent\"\n\n[features]\nguardian_approval = true\n"; - std::fs::write(config_toml_path.as_path(), config_toml)?; - let user_config = toml::from_str::(config_toml)?; - app.config.config_layer_stack = app - .config - .config_layer_stack - .with_user_config(&config_toml_path, user_config); - app.config - .features - .set_enabled(Feature::GuardianApproval, /*enabled*/ true)?; - app.chat_widget - .set_feature_enabled(Feature::GuardianApproval, /*enabled*/ true); - app.config.approvals_reviewer = ApprovalsReviewer::AutoReview; - app.chat_widget - .set_approvals_reviewer(ApprovalsReviewer::AutoReview); - let mut app_server = start_config_write_test_app_server(&app).await?; - - app.update_feature_flags(&mut app_server, vec![(Feature::GuardianApproval, false)]) - .await; - - assert!(app.config.features.enabled(Feature::GuardianApproval)); - assert!( - app.chat_widget - .config_ref() - .features - .enabled(Feature::GuardianApproval) - ); - assert_eq!(app.config.approvals_reviewer, ApprovalsReviewer::AutoReview); - assert_eq!( - app.chat_widget.config_ref().approvals_reviewer, - ApprovalsReviewer::AutoReview - ); - assert!( - op_rx.try_recv().is_err(), - "disabling an inherited non-user reviewer should not patch the active session" - ); - let app_events = std::iter::from_fn(|| app_event_rx.try_recv().ok()).collect::>(); - assert!( - !app_events.iter().any(|event| match event { - AppEvent::InsertHistoryCell(cell) => cell - .display_lines(/*width*/ 120) - .iter() - .any(|line| line.to_string().contains("Permissions updated to")), - _ => false, - }), - "blocking disable with inherited guardian review should not emit a permissions history update: {app_events:?}" - ); - - let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; - assert!(config.contains("guardian_approval = true")); - assert_eq!( - toml::from_str::(&config)? - .as_table() - .and_then(|table| table.get("approvals_reviewer")), - Some(&TomlValue::String("guardian_subagent".to_string())) - ); - app_server.shutdown().await?; - Ok(()) -} - #[tokio::test] async fn open_agent_picker_allows_existing_agent_threads_when_feature_is_disabled() -> Result<()> { let (mut app, mut app_event_rx, _op_rx) = Box::pin(make_test_app_with_channels()).await; @@ -3979,7 +3746,6 @@ async fn make_test_app() -> App { workspace_command_runner: None, config, state_db: None, - active_profile: None, cli_kv_overrides: Vec::new(), harness_overrides: ConfigOverrides::default(), loader_overrides: LoaderOverrides::without_managed_config_for_tests(), @@ -4043,7 +3809,6 @@ async fn make_test_app_with_channels() -> ( workspace_command_runner: None, config, state_db: None, - active_profile: None, cli_kv_overrides: Vec::new(), harness_overrides: ConfigOverrides::default(), loader_overrides: LoaderOverrides::without_managed_config_for_tests(), diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 4597c6091388..59d56ca56eb1 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -1224,7 +1224,6 @@ fn config_request_overrides_from_config( overrides.insert(key.to_string(), serde_json::Value::String(value)); } }; - insert("profile", config.active_profile.clone()); insert( "model_reasoning_effort", config diff --git a/codex-rs/tui/src/config_update.rs b/codex-rs/tui/src/config_update.rs index c18ea44ce938..9d73bb1a2adb 100644 --- a/codex-rs/tui/src/config_update.rs +++ b/codex-rs/tui/src/config_update.rs @@ -35,49 +35,33 @@ pub(crate) fn clear_config_value(key_path: impl Into) -> ConfigEdit { replace_config_value(key_path, JsonValue::Null) } -pub(crate) fn profile_scoped_key_path(profile: Option<&str>, key_path: &str) -> String { - if let Some(profile) = profile { - let profile = serde_json::Value::String(profile.to_string()).to_string(); - format!("profiles.{profile}.{key_path}") - } else { - key_path.to_string() - } -} - pub(crate) fn app_scoped_key_path(app_id: &str, key_path: &str) -> String { let app_id = serde_json::Value::String(app_id.to_string()).to_string(); format!("apps.{app_id}.{key_path}") } pub(crate) fn build_model_selection_edits( - profile: Option<&str>, model: &str, effort: Option, ) -> Vec { let effort_edit = effort.map_or_else( - || clear_config_value(profile_scoped_key_path(profile, "model_reasoning_effort")), + || clear_config_value("model_reasoning_effort"), |effort| { replace_config_value( - profile_scoped_key_path(profile, "model_reasoning_effort"), + "model_reasoning_effort", serde_json::json!(effort.to_string()), ) }, ); vec![ - replace_config_value( - profile_scoped_key_path(profile, "model"), - serde_json::json!(model), - ), + replace_config_value("model", serde_json::json!(model)), effort_edit, ] } -pub(crate) fn build_service_tier_selection_edits( - profile: Option<&str>, - service_tier: Option<&str>, -) -> Vec { +pub(crate) fn build_service_tier_selection_edits(service_tier: Option<&str>) -> Vec { let service_tier_edit = service_tier.map_or_else( - || clear_config_value(profile_scoped_key_path(profile, "service_tier")), + || clear_config_value("service_tier"), |service_tier| { let config_value = if service_tier == SERVICE_TIER_DEFAULT_REQUEST_VALUE { SERVICE_TIER_DEFAULT_REQUEST_VALUE @@ -88,25 +72,18 @@ pub(crate) fn build_service_tier_selection_edits( None => service_tier, } }; - replace_config_value( - profile_scoped_key_path(profile, "service_tier"), - serde_json::json!(config_value), - ) + replace_config_value("service_tier", serde_json::json!(config_value)) }, ); vec![service_tier_edit] } #[cfg(target_os = "windows")] -pub(crate) fn build_windows_sandbox_mode_edits( - profile: Option<&str>, - elevated_enabled: bool, -) -> Vec { - let feature_key_path = - |feature: &str| profile_scoped_key_path(profile, &format!("features.{feature}")); +pub(crate) fn build_windows_sandbox_mode_edits(elevated_enabled: bool) -> Vec { + let feature_key_path = |feature: &str| format!("features.{feature}"); vec![ replace_config_value( - profile_scoped_key_path(profile, "windows.sandbox"), + "windows.sandbox", serde_json::json!(if elevated_enabled { "elevated" } else { @@ -119,17 +96,13 @@ pub(crate) fn build_windows_sandbox_mode_edits( ] } -pub(crate) fn build_feature_enabled_edit( - profile: Option<&str>, - feature_key: &str, - enabled: bool, -) -> ConfigEdit { - let key_path = profile_scoped_key_path(profile, &format!("features.{feature_key}")); +pub(crate) fn build_feature_enabled_edit(feature_key: &str, enabled: bool) -> ConfigEdit { + let key_path = format!("features.{feature_key}"); let is_default_false_feature = FEATURES .iter() .find(|spec| spec.key == feature_key) .is_some_and(|spec| !spec.default_enabled); - if enabled || profile.is_some() || !is_default_false_feature { + if enabled || !is_default_false_feature { replace_config_value(key_path, serde_json::json!(enabled)) } else { clear_config_value(key_path) @@ -210,14 +183,6 @@ mod tests { use super::*; use pretty_assertions::assert_eq; - #[test] - fn profile_scoped_key_path_quotes_dotted_profile_names() { - assert_eq!( - profile_scoped_key_path(Some("team.prod"), "model"), - "profiles.\"team.prod\".model" - ); - } - #[test] fn app_scoped_key_path_quotes_dotted_app_ids() { assert_eq!( diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 1833e056f7e4..539c7bd4614d 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -1658,7 +1658,6 @@ async fn run_ratatui_app( } set_default_client_residency_requirement(config.enforce_residency.value()); - let active_profile = config.active_profile.clone(); let should_show_trust_screen = should_show_trust_screen(&config); let should_prompt_windows_sandbox_nux_at_startup = cfg!(target_os = "windows") && trust_decision_was_made @@ -1716,7 +1715,6 @@ async fn run_ratatui_app( cli_kv_overrides.clone(), overrides.clone(), loader_overrides.clone(), - active_profile, prompt, images, session_selection, diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 3dbc2c3da606..fb021a0fece9 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -272,7 +272,6 @@ struct PickerPage { #[derive(Clone)] struct SessionPickerViewPersistence { codex_home: PathBuf, - active_profile: Option, } struct SessionPickerRunOptions { @@ -369,7 +368,6 @@ async fn run_resume_picker_with_launch_context( initial_density: SessionListDensity::from(config.tui_session_picker_view), view_persistence: Some(SessionPickerViewPersistence { codex_home: config.codex_home.to_path_buf(), - active_profile: config.active_profile.clone(), }), pager_keymap: runtime_keymap.pager, list_keymap: runtime_keymap.list, @@ -415,7 +413,6 @@ pub async fn run_fork_picker_with_app_server( initial_density: SessionListDensity::from(config.tui_session_picker_view), view_persistence: Some(SessionPickerViewPersistence { codex_home: config.codex_home.to_path_buf(), - active_profile: config.active_profile.clone(), }), pager_keymap: runtime_keymap.pager, list_keymap: runtime_keymap.list, @@ -1679,7 +1676,6 @@ impl PickerState { }; ConfigEditsBuilder::new(&persistence.codex_home) - .with_profile(persistence.active_profile.as_deref()) .set_session_picker_view(SessionPickerViewMode::from(self.density)) .apply() .await @@ -4444,7 +4440,6 @@ mod tests { ); state.view_persistence = Some(SessionPickerViewPersistence { codex_home: tmp.path().to_path_buf(), - active_profile: None, }); state @@ -4463,39 +4458,6 @@ session_picker_view = "dense" ); } - #[tokio::test] - async fn ctrl_o_persists_density_preference_for_active_profile() { - let tmp = tempdir().expect("tmpdir"); - let loader = page_only_loader(|_| {}); - let mut state = PickerState::new( - FrameRequester::test_dummy(), - loader, - ProviderFilter::MatchDefault(String::from("openai")), - /*show_all*/ true, - /*filter_cwd*/ None, - SessionPickerAction::Resume, - ); - state.view_persistence = Some(SessionPickerViewPersistence { - codex_home: tmp.path().to_path_buf(), - active_profile: Some(String::from("work")), - }); - - state - .handle_key(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL)) - .await - .unwrap(); - - assert_eq!(state.density, SessionListDensity::Dense); - let contents = - std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).expect("read config"); - assert_eq!( - contents, - r#"[profiles.work.tui] -session_picker_view = "dense" -"# - ); - } - #[tokio::test] async fn ctrl_o_keeps_toggled_density_when_persistence_fails() { let tmp = tempdir().expect("tmpdir"); @@ -4512,7 +4474,6 @@ session_picker_view = "dense" ); state.view_persistence = Some(SessionPickerViewPersistence { codex_home: codex_home_file, - active_profile: None, }); state From 5865ec45e596e67c0a1279c82d3a02e50dcaef1b Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 22 May 2026 13:06:40 +0200 Subject: [PATCH 33/64] Avoid config snapshots in live agent subtree traversal (#24057) ## Why `/feedback` asks `ThreadManager` for the selected agent subtree before it uploads logs. The previous live subtree path reconstructed parent-child links by iterating every loaded thread and awaiting each thread config snapshot, so unrelated loaded-thread state could stall feedback subtree enumeration. The loaded-thread set already belongs to [`ThreadManagerState`](https://github.com/openai/codex/blob/50e6644c9425df2dcbfe52f65fd60bd7f15a8ea2/codex-rs/core/src/thread_manager.rs). Reading thread-spawn parents from the captured `CodexThread` session sources at that boundary keeps unload and resume behavior manager-owned while avoiding per-session config inspection. ## What Changed - expose parent-child thread-spawn edges for loaded, non-internal threads from `ThreadManagerState` - build the live child map from those edges while keeping agent metadata lookup and ordering in `AgentControl` - add regression coverage for live subtree enumeration when no state DB is available ## Validation - `git diff --check` - local Rust tests not run per request --- codex-rs/core/src/agent/control.rs | 16 ++----- codex-rs/core/src/agent/control_tests.rs | 58 ++++++++++++++++++++++++ codex-rs/core/src/thread_manager.rs | 21 +++++++++ 3 files changed, 83 insertions(+), 12 deletions(-) diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 42981a5d0a21..a9a90be54aa0 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -1165,24 +1165,16 @@ impl AgentControl { let state = self.upgrade()?; let mut children_by_parent = HashMap::>::new(); - for thread_id in state.list_thread_ids().await { - let Ok(thread) = state.get_thread(thread_id).await else { - continue; - }; - let snapshot = thread.config_snapshot().await; - let Some(parent_thread_id) = thread_spawn_parent_thread_id(&snapshot.session_source) - else { - continue; - }; + for (parent_thread_id, child_thread_id) in state.list_live_thread_spawn_edges().await { children_by_parent .entry(parent_thread_id) .or_default() .push(( - thread_id, + child_thread_id, self.state - .agent_metadata_for_thread(thread_id) + .agent_metadata_for_thread(child_thread_id) .unwrap_or(AgentMetadata { - agent_id: Some(thread_id), + agent_id: Some(child_thread_id), ..Default::default() }), )); diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 7ff50ffef837..54114f05d127 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -2123,6 +2123,64 @@ async fn list_agent_subtree_thread_ids_includes_anonymous_and_closed_descendants ); } +#[tokio::test] +async fn list_agent_subtree_thread_ids_includes_live_descendants_without_state_db() { + let (_home, config) = test_config().await; + let manager = ThreadManager::with_models_provider_home_and_state_for_tests( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + config.codex_home.to_path_buf(), + std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), + /*state_db*/ None, + ); + let control = manager.agent_control(); + let parent_thread_id = manager + .start_thread(config.clone()) + .await + .expect("parent should start") + .thread_id; + + let child_thread_id = control + .spawn_agent( + config.clone(), + text_input("hello child"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: Some("explorer".to_string()), + })), + ) + .await + .expect("child spawn should succeed"); + let grandchild_thread_id = control + .spawn_agent( + config, + text_input("hello grandchild"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: child_thread_id, + depth: 2, + agent_path: None, + agent_nickname: None, + agent_role: Some("worker".to_string()), + })), + ) + .await + .expect("grandchild spawn should succeed"); + + let mut subtree_thread_ids = manager + .list_agent_subtree_thread_ids(parent_thread_id) + .await + .expect("live subtree should load"); + subtree_thread_ids.sort_by_key(ToString::to_string); + let mut expected_subtree_thread_ids = + vec![parent_thread_id, child_thread_id, grandchild_thread_id]; + expected_subtree_thread_ids.sort_by_key(ToString::to_string); + + assert_eq!(subtree_thread_ids, expected_subtree_thread_ids); +} + #[tokio::test] async fn shutdown_agent_tree_closes_live_descendants() { let harness = AgentControlHarness::new().await; diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index e5b4fcf6a23f..232f12ca7115 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -928,6 +928,27 @@ impl ThreadManagerState { .collect() } + /// List parent-child edges for currently loaded thread-spawn agents. + pub(crate) async fn list_live_thread_spawn_edges(&self) -> Vec<(ThreadId, ThreadId)> { + self.threads + .read() + .await + .iter() + .filter_map(|(thread_id, thread)| { + if thread.session_source.is_internal() { + return None; + } + match &thread.session_source { + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + .. + }) => Some((*parent_thread_id, *thread_id)), + _ => None, + } + }) + .collect() + } + /// Fetch a thread by ID or return ThreadNotFound. pub(crate) async fn get_thread(&self, thread_id: ThreadId) -> CodexResult> { let threads = self.threads.read().await; From 47476e8a8a58d9953f6b5f38ee0c9d0864a8724e Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 22 May 2026 13:14:44 +0200 Subject: [PATCH 34/64] otel: drop legacy profile usage telemetry (#24061) ## Summary - drop the dead legacy profile usage metric and active-profile conversation-start fields - update role comments so they describe provider and service-tier preservation without legacy config-profile wording - pair the code cleanup with the file-backed profile docs update in openai/developers-website#1476 ## Testing - `just fmt` - `cargo test -p codex-otel` - `cargo test -p codex-core` *(fails: existing stack overflow in `mcp_tool_call::tests::guardian_mode_mcp_denial_returns_rationale_message`)* - `cargo test -p codex-core --lib mcp_tool_call::tests::guardian_mode_mcp_denial_returns_rationale_message` *(fails with the same stack overflow)* --- codex-rs/core/src/agent/role.rs | 16 +++++++--------- codex-rs/core/src/session/session.rs | 1 - codex-rs/otel/src/events/session_telemetry.rs | 7 ------- codex-rs/otel/src/metrics/names.rs | 1 - .../tests/suite/otel_export_routing_policy.rs | 1 - 5 files changed, 7 insertions(+), 19 deletions(-) diff --git a/codex-rs/core/src/agent/role.rs b/codex-rs/core/src/agent/role.rs index 886d4dd90496..6d49c0557394 100644 --- a/codex-rs/core/src/agent/role.rs +++ b/codex-rs/core/src/agent/role.rs @@ -2,9 +2,9 @@ //! //! Roles are selected at spawn time and are loaded with the same config machinery as //! `config.toml`. This module resolves built-in and user-defined role files, inserts the role as a -//! high-precedence layer, and preserves the caller's current profile/provider unless the role -//! explicitly takes ownership of model selection. It does not decide when to spawn a sub-agent or -//! which role to use; the multi-agent tool handler owns that orchestration. +//! high-precedence layer, and preserves the caller's current provider and service tier unless the +//! role layer sets them. It does not decide when to spawn a sub-agent or which role to use; the +//! multi-agent tool handler owns that orchestration. use crate::config::AgentRoleConfig; use crate::config::Config; @@ -29,14 +29,12 @@ use toml::Value as TomlValue; pub const DEFAULT_ROLE_NAME: &str = "default"; const AGENT_TYPE_UNAVAILABLE_ERROR: &str = "agent type is currently not available"; -/// Applies a named role layer to `config` while preserving caller-owned model selection. +/// Applies a named role layer to `config` while preserving caller-owned provider settings. /// /// The role layer is inserted at session-flag precedence so it can override persisted config, but -/// the caller's current `profile` and `model_provider` remain sticky runtime choices unless the -/// role explicitly sets `profile`, explicitly sets `model_provider`, or rewrites the active -/// profile's `model_provider` in place. Rebuilding the config without those overrides would make a -/// spawned agent silently fall back to the default provider, which is the bug this preservation -/// logic avoids. +/// the caller's current `model_provider` and `service_tier` remain sticky runtime choices unless +/// the role explicitly sets the corresponding top-level config key. Rebuilding the config without +/// those overrides would make a spawned agent silently fall back to default settings. pub(crate) async fn apply_role_to_config( config: &mut Config, role_name: Option<&str>, diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index a16b0855eced..6ef8aad91e35 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -816,7 +816,6 @@ impl Session { .permissions .legacy_sandbox_policy(session_configuration.cwd.as_path()), mcp_servers.keys().map(String::as_str).collect(), - /*active_profile*/ None, ); let use_zsh_fork_shell = config.features.enabled(Feature::ShellZshFork); diff --git a/codex-rs/otel/src/events/session_telemetry.rs b/codex-rs/otel/src/events/session_telemetry.rs index 6a77125e70ea..1da6497eb077 100644 --- a/codex-rs/otel/src/events/session_telemetry.rs +++ b/codex-rs/otel/src/events/session_telemetry.rs @@ -10,7 +10,6 @@ use crate::metrics::MetricsConfig; use crate::metrics::MetricsError; use crate::metrics::PLUGIN_INSTALL_ELICITATION_SENT_METRIC; use crate::metrics::PLUGIN_INSTALL_SUGGESTION_METRIC; -use crate::metrics::PROFILE_USAGE_METRIC; use crate::metrics::RESPONSES_API_ENGINE_IAPI_TBT_DURATION_METRIC; use crate::metrics::RESPONSES_API_ENGINE_IAPI_TTFT_DURATION_METRIC; use crate::metrics::RESPONSES_API_ENGINE_SERVICE_TBT_DURATION_METRIC; @@ -447,11 +446,7 @@ impl SessionTelemetry { approval_policy: AskForApproval, sandbox_policy: SandboxPolicy, mcp_servers: Vec<&str>, - active_profile: Option, ) { - if active_profile.is_some() { - self.counter(PROFILE_USAGE_METRIC, /*inc*/ 1, &[]); - } log_and_trace_event!( self, common: { @@ -472,11 +467,9 @@ impl SessionTelemetry { }, log: { mcp_servers = mcp_servers.join(", "), - active_profile = active_profile, }, trace: { mcp_server_count = mcp_servers.len() as i64, - active_profile_present = active_profile.is_some(), }, ); } diff --git a/codex-rs/otel/src/metrics/names.rs b/codex-rs/otel/src/metrics/names.rs index 429db8116eb1..817545c87d23 100644 --- a/codex-rs/otel/src/metrics/names.rs +++ b/codex-rs/otel/src/metrics/names.rs @@ -36,7 +36,6 @@ pub const GOAL_USAGE_LIMITED_METRIC: &str = "codex.goal.usage_limited"; pub const GOAL_BLOCKED_METRIC: &str = "codex.goal.blocked"; pub const GOAL_TOKEN_COUNT_METRIC: &str = "codex.goal.token_count"; pub const GOAL_DURATION_SECONDS_METRIC: &str = "codex.goal.duration_s"; -pub const PROFILE_USAGE_METRIC: &str = "codex.profile.usage"; pub const PLUGIN_INSTALL_ELICITATION_SENT_METRIC: &str = "codex.plugins.install_elicitation.sent"; pub const PLUGIN_INSTALL_SUGGESTION_METRIC: &str = "codex.plugins.install_suggestion"; pub const CURATED_PLUGINS_STARTUP_SYNC_METRIC: &str = "codex.plugins.startup_sync"; diff --git a/codex-rs/otel/tests/suite/otel_export_routing_policy.rs b/codex-rs/otel/tests/suite/otel_export_routing_policy.rs index aba22a8e80f2..582d9792c55f 100644 --- a/codex-rs/otel/tests/suite/otel_export_routing_policy.rs +++ b/codex-rs/otel/tests/suite/otel_export_routing_policy.rs @@ -510,7 +510,6 @@ fn otel_export_routing_policy_routes_api_request_auth_observability() { AskForApproval::Never, SandboxPolicy::DangerFullAccess, Vec::new(), - /*active_profile*/ None, ); manager.record_api_request( /*attempt*/ 1, From 932f72c225889102257493f57460251016cbfdc2 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 22 May 2026 13:19:47 +0200 Subject: [PATCH 35/64] fix: reject legacy profile selectors (#24059) ## Why `--profile` now selects `.config.toml`, so the legacy `profile` selector should not be reintroduced through config write or MCP tool paths. A matching legacy selector in base user config also needs the same migration guard as a matching legacy `[profiles.]` table so profile loading fails with one clear migration error instead of mixing the old and new profile models. ## What - reject non-null app-server config writes to the top-level legacy `profile` selector - make `--profile ` reject base user config that still selects the same legacy `profile = ""` value, alongside the existing matching legacy profile-table guard - reject removed MCP `codex` tool fields such as `profile` by denying unknown tool-call parameters and exposing that restriction in the generated schema - add regression coverage for the app-server write paths, config loader guard, and MCP tool input/schema behavior ## Verification - targeted regression tests cover the new app-server, config loader, and MCP rejection paths --- .../app-server/src/config_manager_service.rs | 7 ++ .../src/config_manager_service_tests.rs | 74 +++++++++++++++++++ codex-rs/config/src/loader/mod.rs | 33 +++++---- codex-rs/config/src/loader/tests.rs | 55 ++++++++++++++ codex-rs/mcp-server/src/codex_tool_config.rs | 27 ++++++- 5 files changed, 180 insertions(+), 16 deletions(-) diff --git a/codex-rs/app-server/src/config_manager_service.rs b/codex-rs/app-server/src/config_manager_service.rs index 4255b83e62a3..2f3cc5ef97ed 100644 --- a/codex-rs/app-server/src/config_manager_service.rs +++ b/codex-rs/app-server/src/config_manager_service.rs @@ -238,6 +238,13 @@ impl ConfigManager { let segments = parse_key_path(&key_path).map_err(|message| { ConfigManagerError::write(ConfigWriteErrorCode::ConfigValidationError, message) })?; + if matches!(segments.as_slice(), [segment] if segment == "profile") && !value.is_null() + { + return Err(ConfigManagerError::write( + ConfigWriteErrorCode::ConfigValidationError, + "`profile` is a legacy config selector and can no longer be written; use `--profile ` with `.config.toml` instead", + )); + } let original_value = value_at_path(&user_config, &segments).cloned(); let parsed_value = parse_value(value).map_err(|message| { ConfigManagerError::write(ConfigWriteErrorCode::ConfigValidationError, message) diff --git a/codex-rs/app-server/src/config_manager_service_tests.rs b/codex-rs/app-server/src/config_manager_service_tests.rs index c1a081e023ce..be35a1977eda 100644 --- a/codex-rs/app-server/src/config_manager_service_tests.rs +++ b/codex-rs/app-server/src/config_manager_service_tests.rs @@ -130,6 +130,80 @@ async fn clear_missing_nested_config_is_noop() -> Result<()> { Ok(()) } +#[tokio::test] +async fn write_value_rejects_legacy_profile_selector() -> Result<()> { + let tmp = tempdir().expect("tempdir"); + let path = tmp.path().join(CONFIG_TOML_FILE); + std::fs::write(&path, "model = \"gpt-main\"\n")?; + + let service = ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf()); + let error = service + .write_value(ConfigValueWriteParams { + file_path: Some(path.display().to_string()), + key_path: "profile".to_string(), + value: serde_json::json!("work"), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await + .expect_err("legacy profile selector write should fail"); + + assert_eq!( + error.write_error_code(), + Some(ConfigWriteErrorCode::ConfigValidationError) + ); + assert!( + error + .to_string() + .contains("`profile` is a legacy config selector"), + "{error}" + ); + assert_eq!(std::fs::read_to_string(&path)?, "model = \"gpt-main\"\n"); + Ok(()) +} + +#[tokio::test] +async fn batch_write_rejects_legacy_profile_selector() -> Result<()> { + let tmp = tempdir().expect("tempdir"); + let path = tmp.path().join(CONFIG_TOML_FILE); + std::fs::write(&path, "model = \"gpt-main\"\n")?; + + let service = ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf()); + let error = service + .batch_write(ConfigBatchWriteParams { + edits: vec![ + codex_app_server_protocol::ConfigEdit { + key_path: "model".to_string(), + value: serde_json::json!("gpt-work"), + merge_strategy: MergeStrategy::Replace, + }, + codex_app_server_protocol::ConfigEdit { + key_path: "profile".to_string(), + value: serde_json::json!("work"), + merge_strategy: MergeStrategy::Replace, + }, + ], + file_path: Some(path.display().to_string()), + expected_version: None, + reload_user_config: false, + }) + .await + .expect_err("legacy profile selector batch write should fail"); + + assert_eq!( + error.write_error_code(), + Some(ConfigWriteErrorCode::ConfigValidationError) + ); + assert!( + error + .to_string() + .contains("`profile` is a legacy config selector"), + "{error}" + ); + assert_eq!(std::fs::read_to_string(&path)?, "model = \"gpt-main\"\n"); + Ok(()) +} + #[tokio::test] async fn write_value_supports_nested_app_paths() -> Result<()> { let tmp = tempdir().expect("tempdir"); diff --git a/codex-rs/config/src/loader/mod.rs b/codex-rs/config/src/loader/mod.rs index 528dedaba841..e8972ce5ad66 100644 --- a/codex-rs/config/src/loader/mod.rs +++ b/codex-rs/config/src/loader/mod.rs @@ -225,21 +225,26 @@ pub async fn load_config_layers_state( ) .await?; if let Some(active_user_profile) = active_user_profile.as_ref() - && base_user_layer.config.as_table().is_some_and(|config| { - config - .get("profiles") - .and_then(TomlValue::as_table) - .is_some_and(|profiles| profiles.contains_key(active_user_profile.as_str())) - }) + && let Some(base_user_config) = base_user_layer.config.as_table() { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - format!( - "--profile `{active_user_profile}` cannot be used while {} contains legacy `[profiles.{active_user_profile}]` config; move those settings into {} or remove `[profiles.{active_user_profile}]`. See https://developers.openai.com/codex/config-advanced#profiles for more information.", - base_user_file.as_path().display(), - active_user_file.as_path().display() - ), - )); + let legacy_profile_is_selected = base_user_config + .get("profile") + .and_then(TomlValue::as_str) + .is_some_and(|profile| profile == active_user_profile.as_str()); + let legacy_profile_table_exists = base_user_config + .get("profiles") + .and_then(TomlValue::as_table) + .is_some_and(|profiles| profiles.contains_key(active_user_profile.as_str())); + if legacy_profile_is_selected || legacy_profile_table_exists { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "--profile `{active_user_profile}` cannot be used while {} contains legacy `profile = \"{active_user_profile}\"` or `[profiles.{active_user_profile}]` config; move those settings into {} and remove the legacy profile selector/table. See https://developers.openai.com/codex/config-advanced#profiles for more information.", + base_user_file.as_path().display(), + active_user_file.as_path().display() + ), + )); + } } layers.push(base_user_layer); diff --git a/codex-rs/config/src/loader/tests.rs b/codex-rs/config/src/loader/tests.rs index 812a86e259be..2c87e1381d10 100644 --- a/codex-rs/config/src/loader/tests.rs +++ b/codex-rs/config/src/loader/tests.rs @@ -137,6 +137,61 @@ model = "gpt-work" ); } +#[tokio::test] +async fn profile_v2_rejects_matching_legacy_profile_selector_in_base_user_config() { + let tmp = tempdir().expect("tempdir"); + let selected_config = tmp.path().join("work.config.toml"); + + std::fs::write( + tmp.path().join(CONFIG_TOML_FILE), + r#" +profile = "work" +model = "gpt-main" +"#, + ) + .expect("write default user config"); + std::fs::write(&selected_config, r#"model = "gpt-work-v2""#) + .expect("write selected user config"); + + let mut overrides = LoaderOverrides::without_managed_config_for_tests(); + overrides.user_config_path = Some(AbsolutePathBuf::resolve_path_against_base( + "work.config.toml", + tmp.path(), + )); + overrides.user_config_profile = Some("work".parse().expect("profile-v2 name")); + + let err = load_config_layers_state( + &TestFileSystem, + tmp.path(), + /*cwd*/ None, + &[], + overrides, + CloudRequirementsLoader::default(), + &crate::NoopThreadConfigLoader, + ) + .await + .expect_err("profile-v2 should reject a matching legacy profile selector"); + + assert_eq!( + err.kind(), + io::ErrorKind::InvalidData, + "a matching legacy profile selector should be a hard config error" + ); + let message = err.to_string(); + assert!( + message.contains("--profile `work` cannot be used"), + "unexpected error message: {message}" + ); + assert!( + message.contains("profile = \"work\""), + "unexpected error message: {message}" + ); + assert!( + message.contains("work.config.toml"), + "unexpected error message: {message}" + ); +} + #[tokio::test] async fn profile_v2_allows_unrelated_legacy_profiles_in_base_user_config() { let tmp = tempdir().expect("tempdir"); diff --git a/codex-rs/mcp-server/src/codex_tool_config.rs b/codex-rs/mcp-server/src/codex_tool_config.rs index d91d261fb8c8..9c9a3da53cbc 100644 --- a/codex-rs/mcp-server/src/codex_tool_config.rs +++ b/codex-rs/mcp-server/src/codex_tool_config.rs @@ -20,7 +20,8 @@ use std::sync::Arc; /// Client-supplied configuration for a `codex` tool-call. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)] -#[serde(rename_all = "kebab-case")] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +#[schemars(deny_unknown_fields)] pub struct CodexToolCallParam { /// The *initial user prompt* to start the Codex conversation. pub prompt: String, @@ -271,7 +272,14 @@ fn create_tool_input_schema( // in case any `$ref` leaks into the generated schema (even though we try // to inline subschemas). let mut input_schema = JsonObject::new(); - for key in ["properties", "required", "type", "$defs", "definitions"] { + for key in [ + "additionalProperties", + "properties", + "required", + "type", + "$defs", + "definitions", + ] { if let Some(value) = schema_object.remove(key) { input_schema.insert(key.to_string(), value); } @@ -303,6 +311,7 @@ mod tests { let expected_tool_json = serde_json::json!({ "description": "Run a Codex session. Accepts configuration parameters matching the Codex Config struct.", "inputSchema": { + "additionalProperties": false, "properties": { "approval-policy": { "description": "Approval policy for shell commands generated by the model: `untrusted`, `on-failure`, `on-request`, `never`.", @@ -379,6 +388,20 @@ mod tests { assert_eq!(expected_tool_json, tool_json); } + #[test] + fn codex_tool_call_param_rejects_removed_profile_field() { + let err = serde_json::from_value::(serde_json::json!({ + "prompt": "hello", + "profile": "work" + })) + .expect_err("removed profile field should fail"); + + assert!( + err.to_string().contains("unknown field `profile`"), + "unexpected error: {err}" + ); + } + #[test] fn verify_codex_tool_reply_json_schema() { let tool = create_tool_for_codex_tool_call_reply_param(); From 014f19af5f68e819e987a71f0d33bdfe5ee20a59 Mon Sep 17 00:00:00 2001 From: Channing Conger Date: Fri, 22 May 2026 09:42:08 -0700 Subject: [PATCH 36/64] ci: Use codex produced v8 artifacts for release builds (#23934) Updates our build script to pull down the artifacts like we do in CI for building v8 into our targets. This changes the flow so that we now pre-install rusty v8 assets for all of our release targets from pre-built in workflow. Secondarily if running it locally we now optionally pull the assets down on python run assuming the user hasn't set the proper values, it then provides them. Sorry for the miss here. --- .../action.yml | 23 +-- .github/workflows/rust-ci-full.yml | 6 +- .github/workflows/rust-release.yml | 5 +- scripts/codex_package/README.md | 7 + scripts/codex_package/cargo.py | 14 +- scripts/codex_package/v8.py | 173 ++++++++++++++++++ third_party/v8/README.md | 10 +- 7 files changed, 212 insertions(+), 26 deletions(-) rename .github/actions/{setup-rusty-v8-musl => setup-rusty-v8}/action.yml (71%) create mode 100644 scripts/codex_package/v8.py diff --git a/.github/actions/setup-rusty-v8-musl/action.yml b/.github/actions/setup-rusty-v8/action.yml similarity index 71% rename from .github/actions/setup-rusty-v8-musl/action.yml rename to .github/actions/setup-rusty-v8/action.yml index fbec1feb4636..d9c4484657c6 100644 --- a/.github/actions/setup-rusty-v8-musl/action.yml +++ b/.github/actions/setup-rusty-v8/action.yml @@ -1,29 +1,20 @@ -name: setup-rusty-v8-musl -description: Download and verify musl rusty_v8 artifacts for Cargo builds. +name: setup-rusty-v8 +description: Download and verify Codex-built rusty_v8 artifacts for Cargo builds. inputs: target: - description: Rust musl target triple. + description: Rust target triple with Codex-built V8 release artifacts. required: true runs: using: composite steps: - - name: Configure musl rusty_v8 artifact overrides and verify checksums + - name: Configure rusty_v8 artifact overrides and verify checksums shell: bash env: TARGET: ${{ inputs.target }} run: | set -euo pipefail - case "${TARGET}" in - x86_64-unknown-linux-musl|aarch64-unknown-linux-musl) - ;; - *) - echo "Unsupported musl rusty_v8 target: ${TARGET}" >&2 - exit 1 - ;; - esac - version="$(python3 "${GITHUB_WORKSPACE}/.github/scripts/rusty_v8_bazel.py" resolved-v8-crate-version)" release_tag="rusty-v8-v${version}" base_url="https://github.com/openai/codex/releases/download/${release_tag}" @@ -42,6 +33,10 @@ runs: exit 1 fi - (cd "${binding_dir}" && sha256sum -c "${checksums_path}") + if command -v sha256sum >/dev/null 2>&1; then + (cd "${binding_dir}" && sha256sum -c "${checksums_path}") + else + (cd "${binding_dir}" && shasum -a 256 -c "${checksums_path}") + fi echo "RUSTY_V8_ARCHIVE=${archive_path}" >> "${GITHUB_ENV}" echo "RUSTY_V8_SRC_BINDING_PATH=${binding_path}" >> "${GITHUB_ENV}" diff --git a/.github/workflows/rust-ci-full.yml b/.github/workflows/rust-ci-full.yml index 08e0709e1702..b65c2f5d8fc9 100644 --- a/.github/workflows/rust-ci-full.yml +++ b/.github/workflows/rust-ci-full.yml @@ -436,9 +436,9 @@ jobs: echo "CFLAGS=${cflags}" >> "$GITHUB_ENV" echo "CXXFLAGS=${cxxflags}" >> "$GITHUB_ENV" - - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl' }} - name: Configure musl rusty_v8 artifact overrides and verify checksums - uses: ./.github/actions/setup-rusty-v8-musl + - if: ${{ !contains(matrix.target, 'windows') }} + name: Configure rusty_v8 artifact overrides and verify checksums + uses: ./.github/actions/setup-rusty-v8 with: target: ${{ matrix.target }} diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 4f10efa9dcf5..2cd11e66fe0f 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -340,9 +340,8 @@ jobs: echo "CFLAGS=${cflags}" >> "$GITHUB_ENV" echo "CXXFLAGS=${cxxflags}" >> "$GITHUB_ENV" - - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl' }} - name: Configure musl rusty_v8 artifact overrides and verify checksums - uses: ./.github/actions/setup-rusty-v8-musl + - name: Configure rusty_v8 artifact overrides and verify checksums + uses: ./.github/actions/setup-rusty-v8 with: target: ${{ matrix.target }} diff --git a/scripts/codex_package/README.md b/scripts/codex_package/README.md index 8a1b392b20ff..af070e2c4404 100644 --- a/scripts/codex_package/README.md +++ b/scripts/codex_package/README.md @@ -55,6 +55,13 @@ corresponding resource flags: `--bwrap-bin` for Linux packages, and Windows packages. This keeps package archive creation as a pure staging step after signing instead of rebuilding resources. +When the builder source-builds an entrypoint for a Darwin or Linux target, it +downloads and verifies the matching Codex-built V8 release pair before invoking +Cargo and sets `RUSTY_V8_ARCHIVE` plus `RUSTY_V8_SRC_BINDING_PATH` for that +build. Windows targets keep Cargo's release-build MSVC artifact path. Explicit +overrides remain authoritative when both variables are already set. Set +`V8_FROM_SOURCE=1` to leave the build with the `v8` crate source-build path. + `rg` is not built from this repository, so the builder fetches it from the DotSlash manifest at `scripts/codex_package/rg`. Downloaded archives are cached under `$TMPDIR/codex-package/-rg` and are reused only after the recorded diff --git a/scripts/codex_package/cargo.py b/scripts/codex_package/cargo.py index f7fbf0f9ad8a..f2238dce5337 100644 --- a/scripts/codex_package/cargo.py +++ b/scripts/codex_package/cargo.py @@ -8,6 +8,7 @@ from .targets import REPO_ROOT from .targets import PackageVariant from .targets import TargetSpec +from .v8 import resolve_codex_v8_cargo_env CODEX_RS_ROOT = REPO_ROOT / "codex-rs" @@ -60,8 +61,19 @@ def build_source_binaries( for binary in binaries: cmd.extend(["--bin", binary]) + cargo_env = None + if entrypoint_bin is None: + codex_v8_env = resolve_codex_v8_cargo_env(spec) + if codex_v8_env: + cargo_env = {**os.environ, **codex_v8_env} + print("+", " ".join(cmd)) - subprocess.run(cmd, cwd=CODEX_RS_ROOT, check=True) + subprocess.run( + cmd, + cwd=CODEX_RS_ROOT, + check=True, + env=cargo_env, + ) output_dir = cargo_profile_output_dir(spec, profile) outputs = SourceBuildOutputs( diff --git a/scripts/codex_package/v8.py b/scripts/codex_package/v8.py new file mode 100644 index 000000000000..43d0dcb6117f --- /dev/null +++ b/scripts/codex_package/v8.py @@ -0,0 +1,173 @@ +"""Codex-built V8 artifact overrides for package Cargo builds.""" + +from __future__ import annotations + +import hashlib +import os +import shutil +import tempfile +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path +from urllib.request import urlopen + +from .targets import REPO_ROOT +from .targets import TargetSpec + + +DOWNLOAD_TIMEOUT_SECS = 120 + + +@dataclass(frozen=True) +class RustyV8ArtifactPair: + archive: Path + binding: Path + + +def resolve_codex_v8_cargo_env( + spec: TargetSpec, + *, + environ: Mapping[str, str] | None = None, + cache_root: Path | None = None, +) -> dict[str, str]: + if spec.is_windows: + return {} + + environ = os.environ if environ is None else environ + if environ.get("V8_FROM_SOURCE") in {"true", "1", "yes"}: + return {} + + archive_override = environ.get("RUSTY_V8_ARCHIVE") + binding_override = environ.get("RUSTY_V8_SRC_BINDING_PATH") + if archive_override and binding_override: + return {} + if archive_override or binding_override: + raise RuntimeError( + "Cargo package builds need RUSTY_V8_ARCHIVE and " + "RUSTY_V8_SRC_BINDING_PATH set together." + ) + + artifacts = fetch_codex_v8_artifacts(spec, cache_root=cache_root) + return { + "RUSTY_V8_ARCHIVE": str(artifacts.archive), + "RUSTY_V8_SRC_BINDING_PATH": str(artifacts.binding), + } + + +def fetch_codex_v8_artifacts( + spec: TargetSpec, + *, + version: str | None = None, + cache_root: Path | None = None, +) -> RustyV8ArtifactPair: + if spec.is_windows: + raise RuntimeError(f"No Codex-built V8 release artifacts for target: {spec.target}") + + version = version or resolved_v8_crate_version() + release_url = ( + "https://github.com/openai/codex/releases/download/" + f"rusty-v8-v{version}" + ) + target = spec.target + cache_dir = (cache_root or default_cache_root()) / f"rusty-v8-{version}-{target}" + archive = cache_dir / f"librusty_v8_release_{target}.a.gz" + binding = cache_dir / f"src_binding_release_{target}.rs" + checksums = cache_dir / f"rusty_v8_release_{target}.sha256" + + download_file(f"{release_url}/{checksums.name}", checksums) + expected_checksums = load_checksums(checksums, {archive.name, binding.name}) + for artifact in [archive, binding]: + ensure_valid_artifact( + artifact, + expected_checksums[artifact.name], + f"{release_url}/{artifact.name}", + ) + + return RustyV8ArtifactPair(archive=archive, binding=binding) + + +def resolved_v8_crate_version() -> str: + import tomllib + + cargo_lock = tomllib.loads((REPO_ROOT / "codex-rs" / "Cargo.lock").read_text()) + versions = sorted( + { + package["version"] + for package in cargo_lock["package"] + if package["name"] == "v8" + } + ) + if len(versions) != 1: + raise RuntimeError(f"Expected exactly one resolved v8 version, found: {versions}") + return versions[0] + + +def default_cache_root() -> Path: + return Path(tempfile.gettempdir()) / "codex-package" + + +def load_checksums(checksums_path: Path, artifact_names: set[str]) -> dict[str, str]: + checksums: dict[str, str] = {} + lines = checksums_path.read_text(encoding="utf-8").splitlines() + if len(lines) != len(artifact_names): + raise RuntimeError( + f"Expected {len(artifact_names)} V8 checksums in {checksums_path}, " + f"found {len(lines)}." + ) + + for line in lines: + parts = line.split(maxsplit=1) + if len(parts) != 2: + raise RuntimeError(f"Invalid V8 checksum line in {checksums_path}: {line!r}") + + digest, artifact_name = parts[0], parts[1].strip() + if len(digest) != 64 or any(char not in "0123456789abcdef" for char in digest): + raise RuntimeError(f"Invalid V8 checksum digest in {checksums_path}: {digest}") + if artifact_name not in artifact_names: + raise RuntimeError( + f"Unexpected V8 checksum artifact in {checksums_path}: {artifact_name}" + ) + checksums[artifact_name] = digest + + if checksums.keys() != artifact_names: + raise RuntimeError( + f"V8 checksum manifest {checksums_path} does not cover {artifact_names}." + ) + return checksums + + +def ensure_valid_artifact(artifact: Path, checksum: str, url: str) -> None: + if has_checksum(artifact, checksum): + return + + artifact.unlink(missing_ok=True) + download_file(url, artifact) + if has_checksum(artifact, checksum): + return + + artifact.unlink(missing_ok=True) + raise RuntimeError(f"Codex-built V8 artifact {artifact} failed checksum validation.") + + +def has_checksum(path: Path, expected: str) -> bool: + if not path.is_file(): + return False + + digest = hashlib.sha256() + with path.open("rb") as artifact: + for chunk in iter(lambda: artifact.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() == expected + + +def download_file(url: str, dest: Path) -> None: + dest.parent.mkdir(parents=True, exist_ok=True) + temp_path = dest.with_suffix(f"{dest.suffix}.tmp") + temp_path.unlink(missing_ok=True) + try: + with urlopen(url, timeout=DOWNLOAD_TIMEOUT_SECS) as response: + with temp_path.open("wb") as output: + shutil.copyfileobj(response, output) + temp_path.replace(dest) + finally: + temp_path.unlink(missing_ok=True) diff --git a/third_party/v8/README.md b/third_party/v8/README.md index 336d03c74b8e..8b512ecb19dc 100644 --- a/third_party/v8/README.md +++ b/third_party/v8/README.md @@ -100,11 +100,11 @@ hermetic Windows C++ platform is `windows-gnullvm`/`x86_64-w64-windows-gnu`, so it cannot truthfully reproduce upstream's `*-pc-windows-msvc` archives until we add a real MSVC-targeting C++ toolchain to the Bazel graph. -Cargo musl builds use `RUSTY_V8_ARCHIVE` plus a downloaded -`RUSTY_V8_SRC_BINDING_PATH` to point at those `openai/codex` release assets -directly. We do not use `RUSTY_V8_MIRROR` for musl because the upstream `v8` -crate hardcodes a `v` tag layout, while our musl artifacts are -published under `rusty-v8-v`. +Release and CI Cargo builds for Darwin and Linux use `RUSTY_V8_ARCHIVE` plus a +downloaded `RUSTY_V8_SRC_BINDING_PATH` to point at those `openai/codex` release +assets directly. We do not use `RUSTY_V8_MIRROR` because the upstream `v8` crate +hardcodes a `v` tag layout, while our artifacts are published +under `rusty-v8-v`. Do not mix artifacts across crate versions. The archive and binding must match the exact resolved `v8` crate version in `codex-rs/Cargo.lock`. From cff960896c41799b34d8507c9df50ce8dfee1011 Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Fri, 22 May 2026 09:52:53 -0700 Subject: [PATCH 37/64] fix(app-server): fix optional bool annotations (#24099) `#[serde(default)]` wasn't sufficient for our generated TS types to reflect that clients didn't have to set them. We also need `skip_serializing_if = "std::ops::Not::not"`. This is already a rule in our agents.md file. --- .../schema/json/ClientRequest.json | 6 +----- .../json/codex_app_server_protocol.schemas.json | 6 +----- .../codex_app_server_protocol.v2.schemas.json | 6 +----- .../schema/json/v2/ConfigReadParams.json | 1 - .../schema/json/v2/FeedbackUploadParams.json | 3 +-- .../schema/json/v2/GetAccountParams.json | 1 - .../schema/json/v2/ThreadReadParams.json | 1 - .../schema/typescript/v2/ConfigReadParams.ts | 2 +- .../schema/typescript/v2/FeedbackUploadParams.ts | 2 +- .../schema/typescript/v2/GetAccountParams.ts | 2 +- .../schema/typescript/v2/ThreadReadParams.ts | 2 +- .../app-server-protocol/src/protocol/common.rs | 16 +++++++++++++++- .../src/protocol/v2/account.rs | 2 +- .../src/protocol/v2/config.rs | 2 +- .../src/protocol/v2/feedback.rs | 1 + .../src/protocol/v2/thread.rs | 10 +++++----- 16 files changed, 31 insertions(+), 32 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 97b5f698c27c..7595527c2125 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -423,7 +423,6 @@ ] }, "includeLayers": { - "default": false, "type": "boolean" } }, @@ -721,8 +720,7 @@ } }, "required": [ - "classification", - "includeLogs" + "classification" ], "type": "object" }, @@ -1034,7 +1032,6 @@ "GetAccountParams": { "properties": { "refreshToken": { - "default": false, "description": "When `true`, requests a proactive token refresh before returning.\n\nIn managed auth mode this triggers the normal refresh-token flow. In external auth mode this flag is ignored. Clients should refresh tokens themselves and call `account/login/start` with `chatgptAuthTokens`.", "type": "boolean" } @@ -3424,7 +3421,6 @@ "ThreadReadParams": { "properties": { "includeTurns": { - "default": false, "description": "When true, include turns and their items from rollout history.", "type": "boolean" }, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 155afbb29b0c..c0fbcc1653df 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -7691,7 +7691,6 @@ ] }, "includeLayers": { - "default": false, "type": "boolean" } }, @@ -8593,8 +8592,7 @@ } }, "required": [ - "classification", - "includeLogs" + "classification" ], "title": "FeedbackUploadParams", "type": "object" @@ -9370,7 +9368,6 @@ "$schema": "http://json-schema.org/draft-07/schema#", "properties": { "refreshToken": { - "default": false, "description": "When `true`, requests a proactive token refresh before returning.\n\nIn managed auth mode this triggers the normal refresh-token flow. In external auth mode this flag is ignored. Clients should refresh tokens themselves and call `account/login/start` with `chatgptAuthTokens`.", "type": "boolean" } @@ -16965,7 +16962,6 @@ "$schema": "http://json-schema.org/draft-07/schema#", "properties": { "includeTurns": { - "default": false, "description": "When true, include turns and their items from rollout history.", "type": "boolean" }, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index a636cf1de38e..c27890931d35 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -4060,7 +4060,6 @@ ] }, "includeLayers": { - "default": false, "type": "boolean" } }, @@ -4962,8 +4961,7 @@ } }, "required": [ - "classification", - "includeLogs" + "classification" ], "title": "FeedbackUploadParams", "type": "object" @@ -5850,7 +5848,6 @@ "$schema": "http://json-schema.org/draft-07/schema#", "properties": { "refreshToken": { - "default": false, "description": "When `true`, requests a proactive token refresh before returning.\n\nIn managed auth mode this triggers the normal refresh-token flow. In external auth mode this flag is ignored. Clients should refresh tokens themselves and call `account/login/start` with `chatgptAuthTokens`.", "type": "boolean" } @@ -14789,7 +14786,6 @@ "$schema": "http://json-schema.org/draft-07/schema#", "properties": { "includeTurns": { - "default": false, "description": "When true, include turns and their items from rollout history.", "type": "boolean" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadParams.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadParams.json index b173d2ba953d..db38089a5d2d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadParams.json @@ -9,7 +9,6 @@ ] }, "includeLayers": { - "default": false, "type": "boolean" } }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/FeedbackUploadParams.json b/codex-rs/app-server-protocol/schema/json/v2/FeedbackUploadParams.json index 07c20986067f..3bf0c6219459 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/FeedbackUploadParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/FeedbackUploadParams.json @@ -39,8 +39,7 @@ } }, "required": [ - "classification", - "includeLogs" + "classification" ], "title": "FeedbackUploadParams", "type": "object" diff --git a/codex-rs/app-server-protocol/schema/json/v2/GetAccountParams.json b/codex-rs/app-server-protocol/schema/json/v2/GetAccountParams.json index ca18a451e948..445e90c101fc 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/GetAccountParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/GetAccountParams.json @@ -2,7 +2,6 @@ "$schema": "http://json-schema.org/draft-07/schema#", "properties": { "refreshToken": { - "default": false, "description": "When `true`, requests a proactive token refresh before returning.\n\nIn managed auth mode this triggers the normal refresh-token flow. In external auth mode this flag is ignored. Clients should refresh tokens themselves and call `account/login/start` with `chatgptAuthTokens`.", "type": "boolean" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadParams.json index f5e5503cc0b0..5fb1bcc17b08 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadParams.json @@ -2,7 +2,6 @@ "$schema": "http://json-schema.org/draft-07/schema#", "properties": { "includeTurns": { - "default": false, "description": "When true, include turns and their items from rollout history.", "type": "boolean" }, diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ConfigReadParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigReadParams.ts index 1fd418d1820d..7acf72c8506b 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ConfigReadParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigReadParams.ts @@ -2,7 +2,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type ConfigReadParams = { includeLayers: boolean, +export type ConfigReadParams = { includeLayers?: boolean, /** * Optional working directory to resolve project config layers. If specified, * return the effective config as seen from that directory (i.e., including any diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/FeedbackUploadParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/FeedbackUploadParams.ts index 86d9de2f0da6..2afabd6e4ada 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/FeedbackUploadParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/FeedbackUploadParams.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type FeedbackUploadParams = { classification: string, reason?: string | null, threadId?: string | null, includeLogs: boolean, extraLogFiles?: Array | null, tags?: { [key in string]?: string } | null, }; +export type FeedbackUploadParams = { classification: string, reason?: string | null, threadId?: string | null, includeLogs?: boolean, extraLogFiles?: Array | null, tags?: { [key in string]?: string } | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/GetAccountParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/GetAccountParams.ts index a5c5c25f6647..9e82ef5e13a9 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/GetAccountParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/GetAccountParams.ts @@ -10,4 +10,4 @@ export type GetAccountParams = { * external auth mode this flag is ignored. Clients should refresh tokens * themselves and call `account/login/start` with `chatgptAuthTokens`. */ -refreshToken: boolean, }; +refreshToken?: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadReadParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadReadParams.ts index 64169d2bf665..c26e89648195 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadReadParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadReadParams.ts @@ -6,4 +6,4 @@ export type ThreadReadParams = { threadId: string, /** * When true, include turns and their items from rollout history. */ -includeTurns: boolean, }; +includeTurns?: boolean, }; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index dd1971116867..a8bec6b40255 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -2547,8 +2547,22 @@ mod tests { json!({ "method": "account/read", "id": 6, + "params": {} + }), + serde_json::to_value(&request)?, + ); + let request = ClientRequest::GetAccount { + request_id: RequestId::Integer(7), + params: v2::GetAccountParams { + refresh_token: true, + }, + }; + assert_eq!( + json!({ + "method": "account/read", + "id": 7, "params": { - "refreshToken": false + "refreshToken": true } }), serde_json::to_value(&request)?, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/account.rs b/codex-rs/app-server-protocol/src/protocol/v2/account.rs index efb4a26f603e..796c93d55505 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/account.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/account.rs @@ -224,7 +224,7 @@ pub struct GetAccountParams { /// In managed auth mode this triggers the normal refresh-token flow. In /// external auth mode this flag is ignored. Clients should refresh tokens /// themselves and call `account/login/start` with `chatgptAuthTokens`. - #[serde(default)] + #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub refresh_token: bool, } diff --git a/codex-rs/app-server-protocol/src/protocol/v2/config.rs b/codex-rs/app-server-protocol/src/protocol/v2/config.rs index 16f9bf154b18..c30106b1d43b 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -357,7 +357,7 @@ pub enum ConfigWriteErrorCode { #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct ConfigReadParams { - #[serde(default)] + #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub include_layers: bool, /// Optional working directory to resolve project config layers. If specified, /// return the effective config as seen from that directory (i.e., including any diff --git a/codex-rs/app-server-protocol/src/protocol/v2/feedback.rs b/codex-rs/app-server-protocol/src/protocol/v2/feedback.rs index aaf966a4bfc6..fdddb3aaa7ae 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/feedback.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/feedback.rs @@ -14,6 +14,7 @@ pub struct FeedbackUploadParams { pub reason: Option, #[ts(optional = nullable)] pub thread_id: Option, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub include_logs: bool, #[ts(optional = nullable)] pub extra_log_files: Option>, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs index 01d8bc2a6da3..4971d5f4b8a7 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -162,13 +162,13 @@ pub struct ThreadStartParams { /// If true, opt into emitting raw Responses API items on the event stream. /// This is for internal use only (e.g. Codex Cloud). #[experimental("thread/start.experimentalRawEvents")] - #[serde(default)] + #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub experimental_raw_events: bool, /// Deprecated and ignored by app-server. Kept only so older clients can /// continue sending the field while rollout persistence always uses the /// limited history policy. #[experimental("thread/start.persistFullHistory")] - #[serde(default)] + #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub persist_extended_history: bool, } @@ -401,7 +401,7 @@ pub struct ThreadResumeParams { /// continue sending the field while rollout persistence always uses the /// limited history policy. #[experimental("thread/resume.persistFullHistory")] - #[serde(default)] + #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub persist_extended_history: bool, } @@ -518,7 +518,7 @@ pub struct ThreadForkParams { /// continue sending the field while rollout persistence always uses the /// limited history policy. #[experimental("thread/fork.persistFullHistory")] - #[serde(default)] + #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub persist_extended_history: bool, } @@ -1132,7 +1132,7 @@ pub enum ThreadActiveFlag { pub struct ThreadReadParams { pub thread_id: String, /// When true, include turns and their items from rollout history. - #[serde(default)] + #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub include_turns: bool, } From d53e68954acee2eb50303970498ffebddff393ed Mon Sep 17 00:00:00 2001 From: anp-oai Date: Fri, 22 May 2026 09:58:14 -0700 Subject: [PATCH 38/64] Prefer `just test` over `cargo test` in docs (#23910) `cargo test` for the core and other crates fails on a fresh macOS checkout without the right stack size variable. This change encourages using the just test command that sets the environment up correctly. As a bonus, this should encourage agents to get more benefit out of nextest's parallel execution. --- AGENTS.md | 11 ++++++----- codex-rs/app-server/README.md | 2 +- codex-rs/core/tests/suite/live_cli.rs | 3 ++- codex-rs/utils/pty/README.md | 2 +- docs/contributing.md | 2 +- docs/install.md | 8 +++----- justfile | 7 +++---- scripts/test-remote-env.sh | 2 +- 8 files changed, 18 insertions(+), 19 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c13fdea641a8..9906d3039a2f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,12 +52,13 @@ In the codex-rs folder where the rust code lives: the new implementation so the invariants stay close to the code that owns them. - Avoid adding new standalone methods to `codex-rs/tui/src/chatwidget.rs` unless the change is trivial; prefer new modules/files and keep `chatwidget.rs` focused on orchestration. -- When running Rust commands (e.g. `just fix` or `cargo test`) be patient with the command and never try to kill them using the PID. Rust lock can make the execution slow, this is expected. +- When running Rust commands (e.g. `just fix` or `just test`) be patient with the command and never try to kill them using the PID. Rust lock can make the execution slow, this is expected. Run `just fmt` (in `codex-rs` directory) automatically after you have finished making Rust code changes; do not ask for approval to run it. Additionally, run the tests: -1. Run the test for the specific project that was changed. For example, if changes were made in `codex-rs/tui`, run `cargo test -p codex-tui`. -2. Once those pass, if any changes were made in common, core, or protocol, run the complete test suite with `cargo test` (or `just test` if `cargo-nextest` is installed). Avoid `--all-features` for routine local runs because it expands the build matrix and can significantly increase `target/` disk usage; use it only when you specifically need full feature coverage. project-specific or individual tests can be run without asking the user, but do ask the user before running the complete test suite. +1. Do not run `cargo test` directly. Use `just test` so test execution follows the repo defaults. +2. Run the test for the specific project that was changed. For example, if changes were made in `codex-rs/tui`, run `just test -p codex-tui`. +3. Once those pass, if any changes were made in common, core, or protocol, run the complete test suite with `just test`. Avoid `--all-features` for routine local runs because it expands the build matrix and can significantly increase `target/` disk usage; use it only when you specifically need full feature coverage. project-specific or individual tests can be run without asking the user, but do ask the user before running the complete test suite. Before finalizing a large change to `codex-rs`, run `just fix -p ` (in `codex-rs` directory) to fix any linter issues in the code. Prefer scoping with `-p` to avoid slow workspace‑wide Clippy builds; only run `just fix` without `-p` if you changed shared crates. Do not re-run tests after running `fix` or `fmt`. @@ -120,7 +121,7 @@ is easy to review and future diffs stay visual. When UI or text output changes intentionally, update the snapshots as follows: - Run tests to generate any updated snapshots: - - `cargo test -p codex-tui` + - `just test -p codex-tui` - Check what’s pending: - `cargo insta pending-snapshots -p codex-tui` - Review changes by reading the generated `*.snap.new` files directly in the repo, or preview a specific file: @@ -214,6 +215,6 @@ These guidelines apply to app-server protocol work in `codex-rs`, especially: - Regenerate schema fixtures when API shapes change: `just write-app-server-schema` (and `just write-app-server-schema --experimental` when experimental API fixtures are affected). -- Validate with `cargo test -p codex-app-server-protocol`. +- Validate with `just test -p codex-app-server-protocol`. - Avoid boilerplate tests that only assert experimental field markers for individual request fields in `common.rs`; rely on schema generation/tests and behavioral coverage instead. diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 2ceffc86fec4..71b068c93f7c 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1950,5 +1950,5 @@ For server-initiated request payloads, annotate the field the same way so schema 5. Verify the protocol crate: ```bash - cargo test -p codex-app-server-protocol + just test -p codex-app-server-protocol ``` diff --git a/codex-rs/core/tests/suite/live_cli.rs b/codex-rs/core/tests/suite/live_cli.rs index 5e2c0415ea7f..6273cd15e44b 100644 --- a/codex-rs/core/tests/suite/live_cli.rs +++ b/codex-rs/core/tests/suite/live_cli.rs @@ -2,7 +2,8 @@ //! Optional smoke tests that hit the real OpenAI /v1/responses endpoint. They are `#[ignore]` by //! default so CI stays deterministic and free. Developers can run them locally with -//! `cargo test --test live_cli -- --ignored` provided they set a valid `OPENAI_API_KEY`. +//! `just test -p codex-core --test all --run-ignored only live_cli` provided they set a valid +//! `OPENAI_API_KEY`. use assert_cmd::prelude::*; use predicates::prelude::*; diff --git a/codex-rs/utils/pty/README.md b/codex-rs/utils/pty/README.md index e70d7bc6afa9..7b9df30d0a56 100644 --- a/codex-rs/utils/pty/README.md +++ b/codex-rs/utils/pty/README.md @@ -60,5 +60,5 @@ Use `spawn_pipe_process_no_stdin` to force stdin closed (commands that read stdi Unit tests live in `src/lib.rs` and cover both backends (PTY Python REPL and pipe-based stdin roundtrip). Run with: ``` -cargo test -p codex-utils-pty -- --nocapture +just test -p codex-utils-pty --no-capture ``` diff --git a/docs/contributing.md b/docs/contributing.md index 19b31073e944..aeae1f10d3cd 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -54,7 +54,7 @@ When a change updates model catalogs or model metadata (`/models` payloads, pres - Fill in the PR template (or include similar information) - **What? Why? How?** - Include a link to a bug report or enhancement request in the issue tracker -- Run **all** checks locally. Use the root `just` helpers so you stay consistent with the rest of the workspace: `just fmt`, `just fix -p ` for the crate you touched, and the relevant tests (e.g., `cargo test -p codex-tui` or `just test` if you need a full sweep). CI failures that could have been caught locally slow down the process. +- Run **all** checks locally. Use the root `just` helpers so you stay consistent with the rest of the workspace: `just fmt`, `just fix -p ` for the crate you touched, and the relevant tests (e.g., `just test -p codex-tui` or `just test` if you need a full sweep). CI failures that could have been caught locally slow down the process. - Make sure your branch is up-to-date with `main` and that you have resolved merge conflicts. - Mark the PR as **Ready for review** only when you believe it is in a merge-able state. diff --git a/docs/install.md b/docs/install.md index 0991e7d16c93..7c762c4c5050 100644 --- a/docs/install.md +++ b/docs/install.md @@ -26,7 +26,7 @@ rustup component add rustfmt rustup component add clippy # Install helper tools used by the workspace justfile: cargo install --locked just -# Optional: install nextest for the `just test` helper +# Install nextest for the `just test` helper. cargo install --locked cargo-nextest # Build Codex. @@ -40,13 +40,11 @@ just fmt just fix -p # Run the relevant tests (project-specific is fastest), for example: -cargo test -p codex-tui -# If you have cargo-nextest installed, `just test` runs the test suite via nextest: +just test -p codex-tui +# `just test` runs the test suite via nextest: just test # Avoid `--all-features` for routine local runs because it increases build # time and `target/` disk usage by compiling additional feature combinations. -# If you specifically want full feature coverage, use: -cargo test --all-features ``` ## Tracing / verbose logging diff --git a/justfile b/justfile index ab2fbc63629a..907cd71f6db7 100644 --- a/justfile +++ b/justfile @@ -46,14 +46,13 @@ install: rustup show active-toolchain cargo fetch -# Run `cargo nextest` since it's faster than `cargo test`, though including -# --no-fail-fast is important to ensure all tests are run. +# Run nextest with --no-fail-fast so all tests are run. # # Run `cargo install --locked cargo-nextest` if you don't have it installed. # Prefer this for routine local runs. Workspace crate features are banned, so # there should be no need to add `--all-features`. -test: - RUST_MIN_STACK={{ rust_min_stack }} cargo nextest run --no-fail-fast +test *args: + RUST_MIN_STACK={{ rust_min_stack }} cargo nextest run --no-fail-fast "$@" # Build and run Codex from source using Bazel. # Note we have to use the combination of `[no-cd]` and `--run_under="cd $PWD &&"` diff --git a/scripts/test-remote-env.sh b/scripts/test-remote-env.sh index 96743616a211..584a0f6f291a 100755 --- a/scripts/test-remote-env.sh +++ b/scripts/test-remote-env.sh @@ -5,7 +5,7 @@ # Usage (source-only): # source scripts/test-remote-env.sh # cd codex-rs -# cargo test -p codex-core --test all remote_env_connects_creates_temp_dir_and_runs_sample_script +# just test -p codex-core --test all remote_test_env_can_connect_and_use_filesystem # codex_remote_env_cleanup SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" From dac98cb6356f7aa10404c60fcb9d8f8c51d246e0 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 22 May 2026 10:14:14 -0700 Subject: [PATCH 39/64] retry remote compaction v2 requests (#23951) ## Why Remote compaction v2 sends a normal `/responses` request with a compaction trigger. It should follow the retry semantics used by normal Responses streaming calls for transient stream/request failures, while keeping a smaller per-transport retry budget because compact attempts can run much longer than normal turns. ## What changed - Add a v2 compaction retry loop that uses `stream_max_retries`, matching normal Responses turn retry mechanics. - Cap the compact v2 retry budget at 2 retries per transport with `min(stream_max_retries, 2)`. - Retry retryable request-open and post-open stream collection failures through the same loop. - Use the existing 200ms exponential backoff and requested retry delay handling used by normal turn retries. - Emit the same `Reconnecting... n/max` stream-error notification pattern. - Fall back from WebSockets to HTTPS after the compact v2 stream retry budget is exhausted, then reset the retry counter for HTTPS. - Keep final remote-compaction failure logging after retries/fallback are exhausted. - Treat compact stream EOF before `response.completed` as a retryable stream failure. - Add compact v2 regression coverage with `request_max_retries = 0` and `stream_max_retries = 2`, covering both request-open failure and opened-stream EOF in one end-to-end test. ## Tests - `just fmt` - `cargo test -p codex-core remote_compact_v2` - `just fix -p codex-core` --- codex-rs/core/src/compact_remote_v2.rs | 133 ++++++++++++++++---- codex-rs/core/tests/suite/compact_remote.rs | 112 +++++++++++++++++ 2 files changed, 219 insertions(+), 26 deletions(-) diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index 101317a0ee74..251d37b35c1f 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -19,6 +19,7 @@ use crate::hook_runtime::run_pre_compact_hooks; use crate::session::session::Session; use crate::session::turn::built_tools; use crate::session::turn_context::TurnContext; +use crate::util::backoff; use codex_analytics::CompactionImplementation; use codex_analytics::CompactionPhase; use codex_analytics::CompactionReason; @@ -34,18 +35,22 @@ use codex_protocol::protocol::CompactedItem; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::TruncationPolicy; use codex_protocol::protocol::TurnStartedEvent; +use codex_protocol::protocol::WarningEvent; use codex_rollout_trace::CompactionCheckpointTracePayload; use codex_rollout_trace::InferenceTraceContext; use codex_utils_output_truncation::approx_token_count; use codex_utils_output_truncation::truncate_text; use futures::StreamExt; -use futures::TryFutureExt; use tokio_util::sync::CancellationToken; use tracing::info; +use tracing::warn; // Mirror the current /responses/compact retained-message default while the // server-side path remains the reference implementation. const RETAINED_MESSAGE_TOKEN_BUDGET: usize = 64_000; +// Compact attempts can run much longer than normal turns, so keep the per-transport +// retry budget smaller than the general Responses stream retry budget. +const MAX_REMOTE_COMPACTION_V2_STREAM_RETRIES: u64 = 2; pub(crate) async fn run_inline_remote_auto_compact_task( sess: Arc, @@ -277,31 +282,106 @@ async fn run_remote_compaction_request_v2( prompt: &Prompt, turn_metadata_header: Option<&str>, ) -> CodexResult<(ResponseItem, String)> { - let stream = client_session - .stream( - prompt, - &turn_context.model_info, - &turn_context.session_telemetry, - turn_context.reasoning_effort, - turn_context.reasoning_summary, - turn_context.config.service_tier.clone(), - turn_metadata_header, - &InferenceTraceContext::disabled(), - ) - .or_else(|err| async { - let total_usage_breakdown = sess.get_total_token_usage_breakdown().await; - let compact_request_log_data = - build_compact_request_log_data(&prompt.input, &prompt.base_instructions.text); - log_remote_compact_failure( - turn_context, - &compact_request_log_data, - total_usage_breakdown, - &err, - ); + let max_retries = turn_context + .provider + .info() + .stream_max_retries() + .min(MAX_REMOTE_COMPACTION_V2_STREAM_RETRIES); + let mut retries = 0; + loop { + let result = match client_session + .stream( + prompt, + &turn_context.model_info, + &turn_context.session_telemetry, + turn_context.reasoning_effort, + turn_context.reasoning_summary, + turn_context.config.service_tier.clone(), + turn_metadata_header, + &InferenceTraceContext::disabled(), + ) + .await + { + Ok(stream) => collect_compaction_output(stream).await, + Err(err) => Err(err), + }; + + match result { + Ok(compaction_output) => return Ok(compaction_output), + Err(err) if !err.is_retryable() => { + log_remote_compaction_request_failure(sess, turn_context, prompt, &err).await; + return Err(err); + } Err(err) - }) - .await?; - collect_compaction_output(stream).await + if retries >= max_retries + && client_session.try_switch_fallback_transport( + &turn_context.session_telemetry, + &turn_context.model_info, + ) => + { + sess.send_event( + turn_context, + EventMsg::Warning(WarningEvent { + message: format!( + "Falling back from WebSockets to HTTPS transport. {err:#}" + ), + }), + ) + .await; + retries = 0; + } + Err(err) if retries < max_retries => { + retries += 1; + let delay = match &err { + CodexErr::Stream(_, requested_delay) => { + requested_delay.unwrap_or_else(|| backoff(retries)) + } + _ => backoff(retries), + }; + warn!( + turn_id = %turn_context.sub_id, + retries, + max_retries, + compact_error = %err, + "remote compaction v2 stream failed; retrying request after delay" + ); + + let report_error = retries > 1 + || cfg!(debug_assertions) + || !sess.services.model_client.responses_websocket_enabled(); + if report_error { + sess.notify_stream_error( + turn_context, + format!("Reconnecting... {retries}/{max_retries}"), + err, + ) + .await; + } + tokio::time::sleep(delay).await; + } + Err(err) => { + log_remote_compaction_request_failure(sess, turn_context, prompt, &err).await; + return Err(err); + } + } + } +} + +async fn log_remote_compaction_request_failure( + sess: &Session, + turn_context: &TurnContext, + prompt: &Prompt, + err: &CodexErr, +) { + let total_usage_breakdown = sess.get_total_token_usage_breakdown().await; + let compact_request_log_data = + build_compact_request_log_data(&prompt.input, &prompt.base_instructions.text); + log_remote_compact_failure( + turn_context, + &compact_request_log_data, + total_usage_breakdown, + err, + ); } async fn collect_compaction_output( @@ -331,8 +411,9 @@ async fn collect_compaction_output( } let Some(response_id) = completed_response_id else { - return Err(CodexErr::Fatal( + return Err(CodexErr::Stream( "remote compaction v2 stream closed before response.completed".to_string(), + None, )); }; diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index d868abbfee32..b614c09d3a52 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -816,6 +816,118 @@ async fn remote_compact_v2_reuses_compaction_trigger_for_followups() -> Result<( Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_compact_v2_retries_failures_with_stream_retry_budget() -> Result<()> { + skip_if_no_network!(Ok(())); + + let harness = TestCodexHarness::with_builder( + test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config(|config| { + let _ = config.features.enable(Feature::RemoteCompactionV2); + config.model_provider.request_max_retries = Some(0); + config.model_provider.stream_max_retries = Some(2); + }), + ) + .await?; + let codex = harness.test().codex.clone(); + + let responses_mock = responses::mount_response_sequence( + harness.server(), + vec![ + responses::sse_response(responses::sse(vec![ + responses::ev_assistant_message("m1", "FIRST_REMOTE_REPLY"), + responses::ev_completed("resp-1"), + ])), + ResponseTemplate::new(500).set_body_string("first compact open failed"), + responses::sse_response(responses::sse(vec![serde_json::json!({ + "type": "response.output_item.done", + "item": { + "type": "compaction", + "encrypted_content": "FAILED_COMPACT_SUMMARY", + } + })])), + responses::sse_response(responses::sse(vec![ + serde_json::json!({ + "type": "response.output_item.done", + "item": { + "type": "compaction", + "encrypted_content": "RETRIED_COMPACT_SUMMARY", + } + }), + responses::ev_completed("resp-compact-retry"), + ])), + responses::sse_response(responses::sse(vec![ + responses::ev_assistant_message("m2", "AFTER_COMPACT_REPLY"), + responses::ev_completed("resp-2"), + ])), + ], + ) + .await; + + codex + .submit(Op::UserInput { + environments: None, + items: vec![UserInput::Text { + text: "hello remote compact".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + thread_settings: Default::default(), + }) + .await?; + wait_for_turn_complete(&codex).await; + + codex.submit(Op::Compact).await?; + wait_for_turn_complete(&codex).await; + + codex + .submit(Op::UserInput { + environments: None, + items: vec![UserInput::Text { + text: "after compact".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + thread_settings: Default::default(), + }) + .await?; + wait_for_turn_complete(&codex).await; + + let response_requests = responses_mock.requests(); + assert_eq!( + 5, + response_requests.len(), + "expected initial turn, failed open, failed stream, compact retry, and follow-up turn" + ); + + for compact_request in &response_requests[1..=3] { + assert_eq!("/v1/responses", compact_request.path()); + assert!( + compact_request + .body_json() + .to_string() + .contains("\"type\":\"compaction_trigger\""), + "expected v2 compaction request to include the compaction_trigger item" + ); + } + + let follow_up_request = response_requests.last().expect("follow-up request missing"); + let follow_up_body = follow_up_request.body_json().to_string(); + assert!( + follow_up_body.contains("RETRIED_COMPACT_SUMMARY"), + "expected follow-up request to include the retried compaction payload" + ); + assert!( + !follow_up_body.contains("FAILED_COMPACT_SUMMARY"), + "expected failed compaction attempt output to be discarded" + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn remote_compact_v2_accepts_additional_output_items_before_compaction() -> Result<()> { skip_if_no_network!(Ok(())); From f55f864b9f2b2b97d0006a69802c554866c1c7f2 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 22 May 2026 19:19:51 +0200 Subject: [PATCH 40/64] tui: make `codex-tui.log` opt-in (#24081) ## Why The TUI currently creates a shared plaintext `codex-tui.log` under the default log directory. That append-only file can keep growing across runs even though the TUI already records diagnostics in bounded local stores. Make the plaintext file log an explicit troubleshooting choice instead of a default side effect. This is possible because logs are also stored in the DB with proper rotation ## What changed - Only install the TUI file logging layer when `log_dir` is explicitly set. - Remove the prior `codex-tui.log` at startup before an opt-in file layer is created. - Clarify the `log_dir` config/schema text and `docs/install.md` example so users opt in with `codex -c log_dir=...` when they need a plaintext log. --- codex-rs/config/src/config_toml.rs | 3 +- codex-rs/core/config.schema.json | 2 +- codex-rs/tui/src/lib.rs | 94 ++++++++++++++++++------------ codex-rs/tui/src/session_log.rs | 4 ++ docs/install.md | 7 ++- 5 files changed, 67 insertions(+), 43 deletions(-) diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index a4d384b5f39e..beece2f172bc 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -319,7 +319,8 @@ pub struct ConfigToml { /// Defaults to `$CODEX_SQLITE_HOME` when set. Otherwise uses `$CODEX_HOME`. pub sqlite_home: Option, - /// Directory where Codex writes log files, for example `codex-tui.log`. + /// Directory where Codex writes log files. Setting this value explicitly + /// also enables the TUI text log in this directory. /// Defaults to `$CODEX_HOME/log`. pub log_dir: Option, diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 6f85785f2eee..7d95eec379a2 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -4654,7 +4654,7 @@ "$ref": "#/definitions/AbsolutePathBuf" } ], - "description": "Directory where Codex writes log files, for example `codex-tui.log`. Defaults to `$CODEX_HOME/log`." + "description": "Directory where Codex writes log files. Setting this value explicitly also enables the TUI text log in this directory. Defaults to `$CODEX_HOME/log`." }, "marketplaces": { "additionalProperties": { diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 539c7bd4614d..7348e3b452ff 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -278,6 +278,8 @@ pub use public_widgets::composer_input::ComposerAction; pub use public_widgets::composer_input::ComposerInput; // (tests access modules directly within the crate) +const TUI_LOG_FILE_NAME: &str = "codex-tui.log"; + #[cfg(unix)] const AUTO_CONNECT_DAEMON_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(50); @@ -349,6 +351,13 @@ async fn init_state_db_for_app_server_target( } } +// TODO(jif) delete after 22/11/2026. +fn remove_legacy_tui_log_file(codex_home: &Path) { + // Shared append-only TUI logs could grow without bound. Existing processes + // may still hold the file open, so startup cleanup is best effort. + let _ = std::fs::remove_file(codex_home.join("log").join(TUI_LOG_FILE_NAME)); +} + fn remote_addr_has_explicit_port(addr: &str, parsed: &Url) -> bool { let Some(host) = parsed.host_str() else { return false; @@ -1055,6 +1064,8 @@ pub async fn run_main( ) .await; + remove_legacy_tui_log_file(config.codex_home.as_path()); + let otel_originator = originator().value; let otel = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { crate::legacy_core::otel_init::build_provider( @@ -1166,47 +1177,40 @@ pub async fn run_main( } } - let log_dir = config.log_dir.clone(); - std::fs::create_dir_all(&log_dir)?; - // Open (or create) your log file, appending to it. - let mut log_file_opts = OpenOptions::new(); - log_file_opts.create(true).append(true); - - // Ensure the file is only readable and writable by the current user. - // Doing the equivalent to `chmod 600` on Windows is quite a bit more code - // and requires the Windows API crates, so we can reconsider that when - // Codex CLI is officially supported on Windows. - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - log_file_opts.mode(0o600); - } - - let log_file = log_file_opts.open(log_dir.join("codex-tui.log"))?; + let (tui_file_layer, _tui_file_log_guard) = if config_toml.log_dir.is_some() { + let log_dir = config.log_dir.clone(); + std::fs::create_dir_all(&log_dir)?; + let mut log_file_opts = OpenOptions::new(); + log_file_opts.create(true).append(true); - // Wrap file in non‑blocking writer. - let (non_blocking, _guard) = non_blocking(log_file); + // Ensure the file is only readable and writable by the current user. + // Doing the equivalent to `chmod 600` on Windows is quite a bit more + // code and requires the Windows API crates. + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + log_file_opts.mode(0o600); + } - // use RUST_LOG env var, default to info for codex crates. - let env_filter = || { - EnvFilter::try_from_default_env().unwrap_or_else(|_| { + let log_file = log_file_opts.open(log_dir.join(TUI_LOG_FILE_NAME))?; + let (non_blocking, guard) = non_blocking(log_file); + let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| { EnvFilter::new("codex_core=info,codex_tui=info,codex_rmcp_client=info") - }) + }); + let file_layer = tracing_subscriber::fmt::layer() + .with_writer(non_blocking) + .with_target(true) + .with_ansi(false) + .with_span_events( + tracing_subscriber::fmt::format::FmtSpan::NEW + | tracing_subscriber::fmt::format::FmtSpan::CLOSE, + ) + .with_filter(env_filter); + (Some(file_layer), Some(guard)) + } else { + (None, None) }; - let file_layer = tracing_subscriber::fmt::layer() - .with_writer(non_blocking) - // `with_target(true)` is the default, but we previously disabled it for file output. - // Keep it enabled so we can selectively enable targets via `RUST_LOG=...` and then - // grep for a specific module/target while troubleshooting. - .with_target(true) - .with_ansi(false) - .with_span_events( - tracing_subscriber::fmt::format::FmtSpan::NEW - | tracing_subscriber::fmt::format::FmtSpan::CLOSE, - ) - .with_filter(env_filter()); - let feedback = codex_feedback::CodexFeedback::new(); let feedback_layer = feedback.logger_layer(); let feedback_metadata_layer = feedback.metadata_layer(); @@ -1236,7 +1240,7 @@ pub async fn run_main( .map(|layer| layer.with_filter(Targets::new().with_default(Level::TRACE))); let _ = tracing_subscriber::registry() - .with(file_layer) + .with(tui_file_layer) .with(feedback_layer) .with(feedback_metadata_layer) .with(log_db_layer) @@ -1915,6 +1919,20 @@ mod tests { .await } + #[test] + fn startup_removes_legacy_tui_log_file() -> std::io::Result<()> { + let temp_dir = TempDir::new()?; + let legacy_log_dir = temp_dir.path().join("log"); + std::fs::create_dir_all(&legacy_log_dir)?; + let legacy_log = legacy_log_dir.join(TUI_LOG_FILE_NAME); + std::fs::write(&legacy_log, "legacy log")?; + + remove_legacy_tui_log_file(temp_dir.path()); + + assert!(!legacy_log.exists()); + Ok(()) + } + async fn start_test_embedded_app_server( config: Config, ) -> color_eyre::Result { @@ -2364,7 +2382,7 @@ mod tests { let updated_at = chrono::DateTime::parse_from_rfc3339(meta_rfc3339)?.with_timezone(&chrono::Utc); let times = std::fs::FileTimes::new().set_modified(updated_at.into()); - OpenOptions::new() + std::fs::OpenOptions::new() .append(true) .open(rollout_path)? .set_times(times)?; diff --git a/codex-rs/tui/src/session_log.rs b/codex-rs/tui/src/session_log.rs index 4acf2180ec54..369e19d7fd4d 100644 --- a/codex-rs/tui/src/session_log.rs +++ b/codex-rs/tui/src/session_log.rs @@ -30,6 +30,10 @@ impl SessionLogger { let mut opts = OpenOptions::new(); opts.create(true).truncate(true).write(true); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; diff --git a/docs/install.md b/docs/install.md index 7c762c4c5050..2cd2cbe5cd6f 100644 --- a/docs/install.md +++ b/docs/install.md @@ -51,12 +51,13 @@ just test Codex is written in Rust, so it honors the `RUST_LOG` environment variable to configure its logging behavior. -The TUI defaults to `RUST_LOG=codex_core=info,codex_tui=info,codex_rmcp_client=info` and log messages are written to `~/.codex/log/codex-tui.log` by default. For a single run, you can override the log directory with `-c log_dir=...` (for example, `-c log_dir=./.codex-log`). +The TUI records diagnostics in bounded local stores by default. Set `log_dir` explicitly to enable a plaintext TUI log for a run: ```bash -tail -F ~/.codex/log/codex-tui.log +codex -c log_dir=./.codex-log +tail -F ./.codex-log/codex-tui.log ``` -By comparison, the non-interactive mode (`codex exec`) defaults to `RUST_LOG=error`, but messages are printed inline, so there is no need to monitor a separate file. +The non-interactive mode (`codex exec`) defaults to `RUST_LOG=error`, but messages are printed inline, so there is no need to monitor a separate file. See the Rust documentation on [`RUST_LOG`](https://docs.rs/env_logger/latest/env_logger/#enabling-logging) for more information on the configuration options. From c0b16cfc6b653fe62a49ddb8982ea879dff87751 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Fri, 22 May 2026 10:23:59 -0700 Subject: [PATCH 41/64] cli: infer host sandbox backend (#24102) ## Why `codex sandbox` previously required an OS subcommand like `linux`, `macos`, or `windows`, even though the command can only run the sandbox backend available on the current host. That made the CLI imply a cross-OS choice that does not exist. ## What changed - Collapse `codex sandbox ` into `codex sandbox [COMMAND]...` by wiring the `sandbox` parser directly to the host-specific backend args with `cfg`. - Keep the existing backend runners for Seatbelt, Linux sandbox, and Windows restricted token. - Rename the public Windows debug sandbox runner to `run_command_under_windows_sandbox` for clarity. - Update the Rust sandbox docs and related README references to describe host OS selection and avoid pointing readers at legacy `sandbox_mode` config. ## Arg0 compatibility The `codex-linux-sandbox` helper path is still handled before normal CLI parsing. `arg0_dispatch()` checks whether the executable basename is `codex-linux-sandbox` and directly calls `codex_linux_sandbox::run_main()`, so removing the `sandbox linux` parser branch does not affect the arg0 helper flow. ## Verification - `cargo test -p codex-cli` - `cargo test -p codex-arg0` - `just fix -p codex-cli` --- codex-rs/README.md | 22 ++--- codex-rs/cli/src/debug_sandbox.rs | 5 +- codex-rs/cli/src/lib.rs | 6 +- codex-rs/cli/src/main.rs | 136 ++++++++++++------------------ codex-rs/core/README.md | 2 +- codex-rs/linux-sandbox/README.md | 2 +- 6 files changed, 69 insertions(+), 104 deletions(-) diff --git a/codex-rs/README.md b/codex-rs/README.md index d219061a350e..e315f7fd9b2e 100644 --- a/codex-rs/README.md +++ b/codex-rs/README.md @@ -55,26 +55,17 @@ Use `codex exec --ephemeral ...` to run without persisting session rollout files ### Experimenting with the Codex Sandbox -To test to see what happens when a command is run under the sandbox provided by Codex, we provide the following subcommands in Codex CLI: +To test to see what happens when a command is run under the sandbox provided by Codex, use the `sandbox` subcommand in Codex CLI: ``` -# macOS -codex sandbox macos [--log-denials] [COMMAND]... +# Uses the sandbox implementation for the current host OS: +# Seatbelt on macOS, the Linux sandbox on Linux, and Windows restricted token on Windows. +codex sandbox [COMMAND]... -# Linux -codex sandbox linux [COMMAND]... - -# Windows -codex sandbox windows [COMMAND]... - -# Legacy aliases -codex debug seatbelt [--log-denials] [COMMAND]... -codex debug landlock [COMMAND]... +# macOS-only diagnostic option +codex sandbox --log-denials [COMMAND]... ``` -To try a writable legacy sandbox mode with these commands, pass an explicit config override such -as `-c 'sandbox_mode="workspace-write"'`. - ### Selecting a sandbox policy via `--sandbox` The Rust CLI exposes a dedicated `--sandbox` (`-s`) flag that lets you pick the sandbox policy **without** having to reach for the generic `-c/--config` option: @@ -90,7 +81,6 @@ codex --sandbox workspace-write codex --sandbox danger-full-access ``` -The same setting can be persisted in `~/.codex/config.toml` via the top-level `sandbox_mode = "MODE"` key, e.g. `sandbox_mode = "workspace-write"`. In `workspace-write`, Codex also includes `~/.codex/memories` in its writable roots so memory maintenance does not require an extra approval. ## Code Organization diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index e83d90e8ffe8..4833ff2c6dcc 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -110,7 +110,7 @@ pub async fn run_command_under_landlock( .await } -pub async fn run_command_under_windows( +pub async fn run_command_under_windows_sandbox( command: WindowsCommand, codex_linux_sandbox_exe: Option, ) -> anyhow::Result<()> { @@ -672,8 +672,7 @@ async fn load_debug_sandbox_config_with_codex_home( // For legacy configs, `codex sandbox` historically defaulted to read-only // instead of inheriting ambient `sandbox_mode` settings from user/system // config. Keep that behavior unless this invocation explicitly passes a - // legacy `sandbox_mode` CLI override, which is now the documented writable - // replacement for the removed `--full-auto` flag. + // legacy `sandbox_mode` CLI override for compatibility with older callers. let uses_legacy_sandbox_mode_override = cli_overrides_use_legacy_sandbox_mode(&cli_overrides); let config = build_debug_sandbox_config( cli_overrides.clone(), diff --git a/codex-rs/cli/src/lib.rs b/codex-rs/cli/src/lib.rs index 5bea8ce78dc2..d9e1efe980ab 100644 --- a/codex-rs/cli/src/lib.rs +++ b/codex-rs/cli/src/lib.rs @@ -9,7 +9,7 @@ use std::path::PathBuf; pub use debug_sandbox::run_command_under_landlock; pub use debug_sandbox::run_command_under_seatbelt; -pub use debug_sandbox::run_command_under_windows; +pub use debug_sandbox::run_command_under_windows_sandbox; pub use login::read_access_token_from_stdin; pub use login::read_api_key_from_stdin; pub use login::run_login_status; @@ -20,8 +20,8 @@ pub use login::run_login_with_device_code; pub use login::run_login_with_device_code_fallback_to_browser; pub use login::run_logout; -// TODO: Deduplicate these shared sandbox options if we remove the explicit -// `codex sandbox ` platform subcommands. +// These command structs share common sandbox options, but remain separate +// because each host backend has a slightly different option surface. #[derive(Debug, Parser)] pub struct SeatbeltCommand { /// Named permissions profile to apply from the active configuration stack. diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 929d1b86542f..aefc022221e4 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -10,9 +10,6 @@ use codex_arg0::Arg0DispatchPaths; use codex_arg0::arg0_dispatch_or_else; use codex_chatgpt::apply_command::ApplyCommand; use codex_chatgpt::apply_command::run_apply_command; -use codex_cli::LandlockCommand; -use codex_cli::SeatbeltCommand; -use codex_cli::WindowsCommand; use codex_cli::read_access_token_from_stdin; use codex_cli::read_api_key_from_stdin; use codex_cli::run_login_status; @@ -160,7 +157,7 @@ enum Subcommand { Doctor(DoctorCommand), /// Run commands within a Codex-provided sandbox. - Sandbox(SandboxArgs), + Sandbox(HostSandboxArgs), /// Debugging tools. Debug(DebugCommand), @@ -343,24 +340,25 @@ struct ForkCommand { config_overrides: TuiCli, } -#[derive(Debug, Parser)] -struct SandboxArgs { - #[command(subcommand)] - cmd: SandboxCommand, -} +#[cfg(target_os = "macos")] +type HostSandboxArgs = codex_cli::SeatbeltCommand; +#[cfg(target_os = "linux")] +type HostSandboxArgs = codex_cli::LandlockCommand; +#[cfg(target_os = "windows")] +type HostSandboxArgs = codex_cli::WindowsCommand; -#[derive(Debug, clap::Subcommand)] -enum SandboxCommand { - /// Run a command under Seatbelt (macOS only). - #[clap(visible_alias = "seatbelt")] - Macos(SeatbeltCommand), +#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] +type HostSandboxArgs = UnsupportedSandboxArgs; - /// Run a command under the Linux sandbox (bubblewrap by default). - #[clap(visible_alias = "landlock")] - Linux(LandlockCommand), +#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] +#[derive(Debug, Parser)] +struct UnsupportedSandboxArgs { + #[clap(skip)] + pub config_overrides: CliConfigOverrides, - /// Run a command under Windows restricted token (Windows only). - Windows(WindowsCommand), + /// Full command args to run under the host sandbox. + #[arg(trailing_var_arg = true)] + pub command: Vec, } #[derive(Debug, Parser)] @@ -1238,56 +1236,37 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { codex_cloud_tasks::run_main(cloud_cli, arg0_paths.codex_linux_sandbox_exe.clone()) .await?; } - Some(Subcommand::Sandbox(sandbox_args)) => match sandbox_args.cmd { - SandboxCommand::Macos(mut seatbelt_cli) => { - reject_remote_mode_for_subcommand( - root_remote.as_deref(), - root_remote_auth_token_env.as_deref(), - "sandbox macos", - )?; - prepend_config_flags( - &mut seatbelt_cli.config_overrides, - root_config_overrides.clone(), - ); - codex_cli::run_command_under_seatbelt( - seatbelt_cli, - arg0_paths.codex_linux_sandbox_exe.clone(), - ) - .await?; - } - SandboxCommand::Linux(mut landlock_cli) => { - reject_remote_mode_for_subcommand( - root_remote.as_deref(), - root_remote_auth_token_env.as_deref(), - "sandbox linux", - )?; - prepend_config_flags( - &mut landlock_cli.config_overrides, - root_config_overrides.clone(), - ); - codex_cli::run_command_under_landlock( - landlock_cli, - arg0_paths.codex_linux_sandbox_exe.clone(), - ) - .await?; - } - SandboxCommand::Windows(mut windows_cli) => { - reject_remote_mode_for_subcommand( - root_remote.as_deref(), - root_remote_auth_token_env.as_deref(), - "sandbox windows", - )?; - prepend_config_flags( - &mut windows_cli.config_overrides, - root_config_overrides.clone(), - ); - codex_cli::run_command_under_windows( - windows_cli, - arg0_paths.codex_linux_sandbox_exe.clone(), - ) - .await?; - } - }, + Some(Subcommand::Sandbox(mut sandbox_cli)) => { + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "sandbox", + )?; + prepend_config_flags( + &mut sandbox_cli.config_overrides, + root_config_overrides.clone(), + ); + #[cfg(target_os = "macos")] + codex_cli::run_command_under_seatbelt( + sandbox_cli, + arg0_paths.codex_linux_sandbox_exe.clone(), + ) + .await?; + #[cfg(target_os = "linux")] + codex_cli::run_command_under_landlock( + sandbox_cli, + arg0_paths.codex_linux_sandbox_exe.clone(), + ) + .await?; + #[cfg(target_os = "windows")] + codex_cli::run_command_under_windows_sandbox( + sandbox_cli, + arg0_paths.codex_linux_sandbox_exe.clone(), + ) + .await?; + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + anyhow::bail!("`codex sandbox` is not supported on this operating system"); + } Some(Subcommand::Debug(DebugCommand { subcommand })) => match subcommand { DebugSubcommand::Models(cmd) => { reject_remote_mode_for_subcommand( @@ -2500,12 +2479,12 @@ mod tests { assert!(matches!(cli.subcommand, Some(Subcommand::Update))); } + #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] #[test] - fn sandbox_macos_parses_permissions_profile() { + fn sandbox_parses_permissions_profile() { let cli = MultitoolCli::try_parse_from([ "codex", "sandbox", - "macos", "--permissions-profile", ":workspace", "--", @@ -2513,20 +2492,18 @@ mod tests { ]) .expect("parse"); - let Some(Subcommand::Sandbox(SandboxArgs { - cmd: SandboxCommand::Macos(command), - })) = cli.subcommand - else { - panic!("expected sandbox macos command"); + let Some(Subcommand::Sandbox(command)) = cli.subcommand else { + panic!("expected sandbox command"); }; assert_eq!(command.permissions_profile.as_deref(), Some(":workspace")); assert_eq!(command.command, vec!["echo"]); } + #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] #[test] - fn sandbox_macos_rejects_explicit_profile_controls_without_profile() { - let err = MultitoolCli::try_parse_from(["codex", "sandbox", "macos", "-C", "/tmp"]) + fn sandbox_rejects_explicit_profile_controls_without_profile() { + let err = MultitoolCli::try_parse_from(["codex", "sandbox", "-C", "/tmp"]) .expect_err("parse should fail"); assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument); @@ -2579,8 +2556,7 @@ mod tests { #[test] fn sandbox_full_auto_no_longer_parses() { - let result = - MultitoolCli::try_parse_from(["codex", "sandbox", "linux", "--full-auto", "--"]); + let result = MultitoolCli::try_parse_from(["codex", "sandbox", "--full-auto", "--"]); assert!(result.is_err()); } diff --git a/codex-rs/core/README.md b/codex-rs/core/README.md index 3283ba2c3e4b..57b9e53f6b51 100644 --- a/codex-rs/core/README.md +++ b/codex-rs/core/README.md @@ -22,7 +22,7 @@ Seatbelt also keeps the legacy default preferences read access ### Linux -Expects the binary containing `codex-core` to run the equivalent of `codex sandbox linux` (legacy alias: `codex debug landlock`) when `arg0` is `codex-linux-sandbox`. See the `codex-arg0` crate for details. +Expects the binary containing `codex-core` to run the equivalent of `codex sandbox` when `arg0` is `codex-linux-sandbox`. See the `codex-arg0` crate for details. Legacy `SandboxPolicy` / `sandbox_mode` configs are still supported on Linux. They can continue to use the legacy Landlock path when the split filesystem diff --git a/codex-rs/linux-sandbox/README.md b/codex-rs/linux-sandbox/README.md index 4fc65c749922..07c679647088 100644 --- a/codex-rs/linux-sandbox/README.md +++ b/codex-rs/linux-sandbox/README.md @@ -94,4 +94,4 @@ commands that would enter the bubblewrap path. you can skip this in restrictive container environments with `--no-proc`. **Notes** -- The CLI surface still uses legacy names like `codex debug landlock`. +- The CLI surface is `codex sandbox`; the host OS selects the sandbox backend. From 162a6e746b7b4ef6024ccc819bf8ceaaa5f802f6 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 22 May 2026 19:41:39 +0200 Subject: [PATCH 42/64] app-server: drop legacy profile config surface (#24067) ## Why Legacy `[profiles.]` config tables and the legacy `profile` selector are being retired in favor of profile files selected with `--profile `. After #23886 removed the CLI-side legacy profile plumbing, the app-server config surface still exposed those fields and still carried conversion code for the old protocol shape. ## What changed - Remove `profile`, `profiles`, and `ProfileV2` from the app-server config protocol/schema output so `config/read` no longer returns legacy profile config. - Drop the old v1 `UserSavedConfig` profile conversion path from `config`. - Reject new app-server config writes under `profiles.*` with the same migration direction used for `profile`, while still allowing callers to clear existing legacy profile tables. - Refresh app-server config coverage and the experimental API README example around the remaining `Config` nesting path. ## Verification - Added config-manager coverage that `config/read` omits legacy profile config, `profiles.*` writes are rejected, and existing legacy profile tables can still be cleared. - Updated the v2 config RPC test to cover the rejected `profiles.*` batch-write path. --- .../schema/json/ServerNotification.json | 2 +- .../codex_app_server_protocol.schemas.json | 114 --------------- .../codex_app_server_protocol.v2.schemas.json | 114 --------------- .../schema/json/v2/ConfigReadResponse.json | 114 --------------- .../schema/typescript/v2/Config.ts | 3 +- .../schema/typescript/v2/ProfileV2.ts | 18 --- .../schema/typescript/v2/index.ts | 1 - codex-rs/app-server-protocol/src/lib.rs | 1 - .../app-server-protocol/src/protocol/v1.rs | 14 -- .../src/protocol/v2/config.rs | 28 ---- .../src/protocol/v2/tests.rs | 138 ------------------ codex-rs/app-server/README.md | 2 +- .../app-server/src/config_manager_service.rs | 23 ++- .../src/config_manager_service_tests.rs | 78 ++++------ .../app-server/tests/suite/v2/config_rpc.rs | 35 ++--- codex-rs/config/src/config_toml.rs | 45 ------ codex-rs/config/src/profile_toml.rs | 14 -- 17 files changed, 67 insertions(+), 677 deletions(-) delete mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ProfileV2.ts diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index dfb999cf314f..90899cb15278 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -6546,4 +6546,4 @@ } ], "title": "ServerNotification" -} +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index c0fbcc1653df..0de1933ed664 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -7361,19 +7361,6 @@ } ] }, - "profile": { - "type": [ - "string", - "null" - ] - }, - "profiles": { - "additionalProperties": { - "$ref": "#/definitions/v2/ProfileV2" - }, - "default": {}, - "type": "object" - }, "review_model": { "type": [ "string", @@ -13170,107 +13157,6 @@ ], "type": "object" }, - "ProfileV2": { - "additionalProperties": true, - "properties": { - "approval_policy": { - "anyOf": [ - { - "$ref": "#/definitions/v2/AskForApproval" - }, - { - "type": "null" - } - ] - }, - "approvals_reviewer": { - "anyOf": [ - { - "$ref": "#/definitions/v2/ApprovalsReviewer" - }, - { - "type": "null" - } - ], - "description": "[UNSTABLE] Optional profile-level override for where approval requests are routed for review. If omitted, the enclosing config default is used." - }, - "chatgpt_base_url": { - "type": [ - "string", - "null" - ] - }, - "model": { - "type": [ - "string", - "null" - ] - }, - "model_provider": { - "type": [ - "string", - "null" - ] - }, - "model_reasoning_effort": { - "anyOf": [ - { - "$ref": "#/definitions/v2/ReasoningEffort" - }, - { - "type": "null" - } - ] - }, - "model_reasoning_summary": { - "anyOf": [ - { - "$ref": "#/definitions/v2/ReasoningSummary" - }, - { - "type": "null" - } - ] - }, - "model_verbosity": { - "anyOf": [ - { - "$ref": "#/definitions/v2/Verbosity" - }, - { - "type": "null" - } - ] - }, - "service_tier": { - "type": [ - "string", - "null" - ] - }, - "tools": { - "anyOf": [ - { - "$ref": "#/definitions/v2/ToolsV2" - }, - { - "type": "null" - } - ] - }, - "web_search": { - "anyOf": [ - { - "$ref": "#/definitions/v2/WebSearchMode" - }, - { - "type": "null" - } - ] - } - }, - "type": "object" - }, "RateLimitReachedType": { "enum": [ "rate_limit_reached", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index c27890931d35..633a72245062 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -3730,19 +3730,6 @@ } ] }, - "profile": { - "type": [ - "string", - "null" - ] - }, - "profiles": { - "additionalProperties": { - "$ref": "#/definitions/ProfileV2" - }, - "default": {}, - "type": "object" - }, "review_model": { "type": [ "string", @@ -9699,107 +9686,6 @@ ], "type": "object" }, - "ProfileV2": { - "additionalProperties": true, - "properties": { - "approval_policy": { - "anyOf": [ - { - "$ref": "#/definitions/AskForApproval" - }, - { - "type": "null" - } - ] - }, - "approvals_reviewer": { - "anyOf": [ - { - "$ref": "#/definitions/ApprovalsReviewer" - }, - { - "type": "null" - } - ], - "description": "[UNSTABLE] Optional profile-level override for where approval requests are routed for review. If omitted, the enclosing config default is used." - }, - "chatgpt_base_url": { - "type": [ - "string", - "null" - ] - }, - "model": { - "type": [ - "string", - "null" - ] - }, - "model_provider": { - "type": [ - "string", - "null" - ] - }, - "model_reasoning_effort": { - "anyOf": [ - { - "$ref": "#/definitions/ReasoningEffort" - }, - { - "type": "null" - } - ] - }, - "model_reasoning_summary": { - "anyOf": [ - { - "$ref": "#/definitions/ReasoningSummary" - }, - { - "type": "null" - } - ] - }, - "model_verbosity": { - "anyOf": [ - { - "$ref": "#/definitions/Verbosity" - }, - { - "type": "null" - } - ] - }, - "service_tier": { - "type": [ - "string", - "null" - ] - }, - "tools": { - "anyOf": [ - { - "$ref": "#/definitions/ToolsV2" - }, - { - "type": "null" - } - ] - }, - "web_search": { - "anyOf": [ - { - "$ref": "#/definitions/WebSearchMode" - }, - { - "type": "null" - } - ] - } - }, - "type": "object" - }, "RateLimitReachedType": { "enum": [ "rate_limit_reached", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json index 7595f7fd0093..4a104b3bd519 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json @@ -352,19 +352,6 @@ } ] }, - "profile": { - "type": [ - "string", - "null" - ] - }, - "profiles": { - "additionalProperties": { - "$ref": "#/definitions/ProfileV2" - }, - "default": {}, - "type": "object" - }, "review_model": { "type": [ "string", @@ -642,107 +629,6 @@ ], "type": "string" }, - "ProfileV2": { - "additionalProperties": true, - "properties": { - "approval_policy": { - "anyOf": [ - { - "$ref": "#/definitions/AskForApproval" - }, - { - "type": "null" - } - ] - }, - "approvals_reviewer": { - "anyOf": [ - { - "$ref": "#/definitions/ApprovalsReviewer" - }, - { - "type": "null" - } - ], - "description": "[UNSTABLE] Optional profile-level override for where approval requests are routed for review. If omitted, the enclosing config default is used." - }, - "chatgpt_base_url": { - "type": [ - "string", - "null" - ] - }, - "model": { - "type": [ - "string", - "null" - ] - }, - "model_provider": { - "type": [ - "string", - "null" - ] - }, - "model_reasoning_effort": { - "anyOf": [ - { - "$ref": "#/definitions/ReasoningEffort" - }, - { - "type": "null" - } - ] - }, - "model_reasoning_summary": { - "anyOf": [ - { - "$ref": "#/definitions/ReasoningSummary" - }, - { - "type": "null" - } - ] - }, - "model_verbosity": { - "anyOf": [ - { - "$ref": "#/definitions/Verbosity" - }, - { - "type": "null" - } - ] - }, - "service_tier": { - "type": [ - "string", - "null" - ] - }, - "tools": { - "anyOf": [ - { - "$ref": "#/definitions/ToolsV2" - }, - { - "type": "null" - } - ] - }, - "web_search": { - "anyOf": [ - { - "$ref": "#/definitions/WebSearchMode" - }, - { - "type": "null" - } - ] - } - }, - "type": "object" - }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts index 29eae9877419..cc15fb4e720b 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts @@ -12,7 +12,6 @@ import type { AnalyticsConfig } from "./AnalyticsConfig"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; import type { ForcedChatgptWorkspaceIds } from "./ForcedChatgptWorkspaceIds"; -import type { ProfileV2 } from "./ProfileV2"; import type { SandboxMode } from "./SandboxMode"; import type { SandboxWorkspaceWrite } from "./SandboxWorkspaceWrite"; import type { ToolsV2 } from "./ToolsV2"; @@ -21,4 +20,4 @@ export type Config = {model: string | null, review_model: string | null, model_c * [UNSTABLE] Optional default for where approval requests are routed for * review. */ -approvals_reviewer: ApprovalsReviewer | null, sandbox_mode: SandboxMode | null, sandbox_workspace_write: SandboxWorkspaceWrite | null, forced_chatgpt_workspace_id: ForcedChatgptWorkspaceIds | null, forced_login_method: ForcedLoginMethod | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, profile: string | null, profiles: { [key in string]?: ProfileV2 }, instructions: string | null, developer_instructions: string | null, compact_prompt: string | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, service_tier: string | null, analytics: AnalyticsConfig | null, desktop: { [key in string]?: JsonValue } | null} & ({ [key in string]?: number | string | boolean | Array | { [key in string]?: JsonValue } | null }); +approvals_reviewer: ApprovalsReviewer | null, sandbox_mode: SandboxMode | null, sandbox_workspace_write: SandboxWorkspaceWrite | null, forced_chatgpt_workspace_id: ForcedChatgptWorkspaceIds | null, forced_login_method: ForcedLoginMethod | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, instructions: string | null, developer_instructions: string | null, compact_prompt: string | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, service_tier: string | null, analytics: AnalyticsConfig | null, desktop: { [key in string]?: JsonValue } | null} & ({ [key in string]?: number | string | boolean | Array | { [key in string]?: JsonValue } | null }); diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ProfileV2.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ProfileV2.ts deleted file mode 100644 index d05038701c83..000000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ProfileV2.ts +++ /dev/null @@ -1,18 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ReasoningEffort } from "../ReasoningEffort"; -import type { ReasoningSummary } from "../ReasoningSummary"; -import type { Verbosity } from "../Verbosity"; -import type { WebSearchMode } from "../WebSearchMode"; -import type { JsonValue } from "../serde_json/JsonValue"; -import type { ApprovalsReviewer } from "./ApprovalsReviewer"; -import type { AskForApproval } from "./AskForApproval"; -import type { ToolsV2 } from "./ToolsV2"; - -export type ProfileV2 = {model: string | null, model_provider: string | null, approval_policy: AskForApproval | null, /** - * [UNSTABLE] Optional profile-level override for where approval requests - * are routed for review. If omitted, the enclosing config default is - * used. - */ -approvals_reviewer: ApprovalsReviewer | null, service_tier: string | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, chatgpt_base_url: string | null} & ({ [key in string]?: number | string | boolean | Array | { [key in string]?: JsonValue } | null }); diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index d5ae15e8e279..5b4f2ed2831a 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -308,7 +308,6 @@ export type { ProcessExitedNotification } from "./ProcessExitedNotification"; export type { ProcessOutputDeltaNotification } from "./ProcessOutputDeltaNotification"; export type { ProcessOutputStream } from "./ProcessOutputStream"; export type { ProcessTerminalSize } from "./ProcessTerminalSize"; -export type { ProfileV2 } from "./ProfileV2"; export type { RateLimitReachedType } from "./RateLimitReachedType"; export type { RateLimitSnapshot } from "./RateLimitSnapshot"; export type { RateLimitWindow } from "./RateLimitWindow"; diff --git a/codex-rs/app-server-protocol/src/lib.rs b/codex-rs/app-server-protocol/src/lib.rs index 2fcf54f4bee8..f6d7670e10a2 100644 --- a/codex-rs/app-server-protocol/src/lib.rs +++ b/codex-rs/app-server-protocol/src/lib.rs @@ -36,7 +36,6 @@ pub use protocol::v1::InitializeParams; pub use protocol::v1::InitializeResponse; pub use protocol::v1::InterruptConversationResponse; pub use protocol::v1::LoginApiKeyParams; -pub use protocol::v1::Profile; pub use protocol::v1::SandboxSettings; pub use protocol::v1::Tools; pub use protocol::v1::UserSavedConfig; diff --git a/codex-rs/app-server-protocol/src/protocol/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs index 3c45c20b8fc2..f83674d4c37d 100644 --- a/codex-rs/app-server-protocol/src/protocol/v1.rs +++ b/codex-rs/app-server-protocol/src/protocol/v1.rs @@ -209,20 +209,6 @@ pub struct UserSavedConfig { pub model_reasoning_summary: Option, pub model_verbosity: Option, pub tools: Option, - pub profile: Option, - pub profiles: HashMap, -} - -#[derive(Deserialize, Debug, Clone, PartialEq, Serialize, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -pub struct Profile { - pub model: Option, - pub model_provider: Option, - pub approval_policy: Option, - pub model_reasoning_effort: Option, - pub model_reasoning_summary: Option, - pub model_verbosity: Option, - pub chatgpt_base_url: Option, } #[derive(Deserialize, Debug, Clone, PartialEq, Serialize, JsonSchema, TS)] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/config.rs b/codex-rs/app-server-protocol/src/protocol/v2/config.rs index c30106b1d43b..25f62f2c2418 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -133,30 +133,6 @@ pub struct ToolsV2 { pub web_search: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] -#[serde(rename_all = "snake_case")] -#[ts(export_to = "v2/")] -pub struct ProfileV2 { - pub model: Option, - pub model_provider: Option, - #[experimental(nested)] - pub approval_policy: Option, - /// [UNSTABLE] Optional profile-level override for where approval requests - /// are routed for review. If omitted, the enclosing config default is - /// used. - #[experimental("config/read.approvalsReviewer")] - pub approvals_reviewer: Option, - pub service_tier: Option, - pub model_reasoning_effort: Option, - pub model_reasoning_summary: Option, - pub model_verbosity: Option, - pub web_search: Option, - pub tools: Option, - pub chatgpt_base_url: Option, - #[serde(default, flatten)] - pub additional: HashMap, -} - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] #[ts(export_to = "v2/")] @@ -266,10 +242,6 @@ pub struct Config { pub forced_login_method: Option, pub web_search: Option, pub tools: Option, - pub profile: Option, - #[experimental(nested)] - #[serde(default)] - pub profiles: HashMap, pub instructions: Option, pub developer_instructions: Option, pub compact_prompt: Option, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index bbe6bec3f2d5..7112e9eb1396 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -1505,32 +1505,6 @@ fn ask_for_approval_granular_is_marked_experimental() { ); } -#[test] -fn profile_v2_granular_approval_policy_is_marked_experimental() { - let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&ProfileV2 { - model: None, - model_provider: None, - approval_policy: Some(AskForApproval::Granular { - sandbox_approval: true, - rules: false, - skill_approval: false, - request_permissions: true, - mcp_elicitations: false, - }), - approvals_reviewer: None, - service_tier: None, - model_reasoning_effort: None, - model_reasoning_summary: None, - model_verbosity: None, - web_search: None, - tools: None, - chatgpt_base_url: None, - additional: HashMap::new(), - }); - - assert_eq!(reason, Some("askForApproval.granular")); -} - #[test] fn config_granular_approval_policy_is_marked_experimental() { let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&Config { @@ -1554,8 +1528,6 @@ fn config_granular_approval_policy_is_marked_experimental() { forced_login_method: None, web_search: None, tools: None, - profile: None, - profiles: HashMap::new(), instructions: None, developer_instructions: None, compact_prompt: None, @@ -1589,116 +1561,6 @@ fn config_approvals_reviewer_is_marked_experimental() { forced_login_method: None, web_search: None, tools: None, - profile: None, - profiles: HashMap::new(), - instructions: None, - developer_instructions: None, - compact_prompt: None, - model_reasoning_effort: None, - model_reasoning_summary: None, - model_verbosity: None, - service_tier: None, - analytics: None, - apps: None, - desktop: None, - additional: HashMap::new(), - }); - - assert_eq!(reason, Some("config/read.approvalsReviewer")); -} - -#[test] -fn config_nested_profile_granular_approval_policy_is_marked_experimental() { - let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&Config { - model: None, - review_model: None, - model_context_window: None, - model_auto_compact_token_limit: None, - model_auto_compact_token_limit_scope: None, - model_provider: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_mode: None, - sandbox_workspace_write: None, - forced_chatgpt_workspace_id: None, - forced_login_method: None, - web_search: None, - tools: None, - profile: None, - profiles: HashMap::from([( - "default".to_string(), - ProfileV2 { - model: None, - model_provider: None, - approval_policy: Some(AskForApproval::Granular { - sandbox_approval: true, - rules: false, - skill_approval: false, - request_permissions: false, - mcp_elicitations: true, - }), - approvals_reviewer: None, - service_tier: None, - model_reasoning_effort: None, - model_reasoning_summary: None, - model_verbosity: None, - web_search: None, - tools: None, - chatgpt_base_url: None, - additional: HashMap::new(), - }, - )]), - instructions: None, - developer_instructions: None, - compact_prompt: None, - model_reasoning_effort: None, - model_reasoning_summary: None, - model_verbosity: None, - service_tier: None, - analytics: None, - apps: None, - desktop: None, - additional: HashMap::new(), - }); - - assert_eq!(reason, Some("askForApproval.granular")); -} - -#[test] -fn config_nested_profile_approvals_reviewer_is_marked_experimental() { - let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&Config { - model: None, - review_model: None, - model_context_window: None, - model_auto_compact_token_limit: None, - model_auto_compact_token_limit_scope: None, - model_provider: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_mode: None, - sandbox_workspace_write: None, - forced_chatgpt_workspace_id: None, - forced_login_method: None, - web_search: None, - tools: None, - profile: None, - profiles: HashMap::from([( - "default".to_string(), - ProfileV2 { - model: None, - model_provider: None, - approval_policy: None, - approvals_reviewer: Some(ApprovalsReviewer::AutoReview), - service_tier: None, - model_reasoning_effort: None, - model_reasoning_summary: None, - model_verbosity: None, - web_search: None, - tools: None, - chatgpt_base_url: None, - additional: HashMap::new(), - }, - )]), instructions: None, developer_instructions: None, compact_prompt: None, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 71b068c93f7c..dcc29530736c 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1931,7 +1931,7 @@ reason up through the containing type: ```rust #[derive(ExperimentalApi)] -struct ProfileV2 { +struct Config { #[experimental(nested)] approval_policy: Option, } diff --git a/codex-rs/app-server/src/config_manager_service.rs b/codex-rs/app-server/src/config_manager_service.rs index 2f3cc5ef97ed..d2465d32dd8e 100644 --- a/codex-rs/app-server/src/config_manager_service.rs +++ b/codex-rs/app-server/src/config_manager_service.rs @@ -125,7 +125,6 @@ impl ConfigManager { }; let effective = layers.effective_config(); - let effective_config_toml: ConfigToml = effective .try_into() .map_err(|err| ConfigManagerError::toml("invalid configuration", err))?; @@ -238,12 +237,22 @@ impl ConfigManager { let segments = parse_key_path(&key_path).map_err(|message| { ConfigManagerError::write(ConfigWriteErrorCode::ConfigValidationError, message) })?; - if matches!(segments.as_slice(), [segment] if segment == "profile") && !value.is_null() - { - return Err(ConfigManagerError::write( - ConfigWriteErrorCode::ConfigValidationError, - "`profile` is a legacy config selector and can no longer be written; use `--profile ` with `.config.toml` instead", - )); + if !value.is_null() { + match segments.as_slice() { + [segment] if segment == "profile" => { + return Err(ConfigManagerError::write( + ConfigWriteErrorCode::ConfigValidationError, + "`profile` is a legacy config selector and can no longer be written; use `--profile ` with `.config.toml` instead", + )); + } + [segment, ..] if segment == "profiles" => { + return Err(ConfigManagerError::write( + ConfigWriteErrorCode::ConfigValidationError, + "`profiles` contains legacy config profile tables and can no longer be written; use `--profile ` with `.config.toml` instead", + )); + } + _ => {} + } } let original_value = value_at_path(&user_config, &segments).cloned(); let parsed_value = parse_value(value).map_err(|message| { diff --git a/codex-rs/app-server/src/config_manager_service_tests.rs b/codex-rs/app-server/src/config_manager_service_tests.rs index be35a1977eda..5c6e3de5a100 100644 --- a/codex-rs/app-server/src/config_manager_service_tests.rs +++ b/codex-rs/app-server/src/config_manager_service_tests.rs @@ -162,6 +162,38 @@ async fn write_value_rejects_legacy_profile_selector() -> Result<()> { Ok(()) } +#[tokio::test] +async fn write_value_rejects_legacy_profile_table() -> Result<()> { + let tmp = tempdir().expect("tempdir"); + let path = tmp.path().join(CONFIG_TOML_FILE); + std::fs::write(&path, "")?; + + let service = ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf()); + let error = service + .write_value(ConfigValueWriteParams { + file_path: Some(path.display().to_string()), + key_path: "profiles.work.model".to_string(), + value: serde_json::json!("gpt-work"), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await + .expect_err("legacy profile table write should fail"); + + assert_eq!( + error.write_error_code(), + Some(ConfigWriteErrorCode::ConfigValidationError) + ); + assert!( + error + .to_string() + .contains("`profiles` contains legacy config profile tables"), + "{error}" + ); + assert_eq!(std::fs::read_to_string(&path)?, ""); + Ok(()) +} + #[tokio::test] async fn batch_write_rejects_legacy_profile_selector() -> Result<()> { let tmp = tempdir().expect("tempdir"); @@ -712,52 +744,6 @@ async fn write_value_rejects_feature_requirement_conflict() { ); } -#[tokio::test] -async fn write_value_rejects_profile_feature_requirement_conflict() { - let tmp = tempdir().expect("tempdir"); - std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "").unwrap(); - - let service = ConfigManager::new_for_tests( - tmp.path().to_path_buf(), - vec![], - LoaderOverrides::without_managed_config_for_tests(), - CloudRequirementsLoader::new(async { - Ok(Some(ConfigRequirementsToml { - feature_requirements: Some(FeatureRequirementsToml { - entries: BTreeMap::from([("personality".to_string(), true)]), - }), - ..Default::default() - })) - }), - ); - - let error = service - .write_value(ConfigValueWriteParams { - file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()), - key_path: "profiles.enterprise.features.personality".to_string(), - value: serde_json::json!(false), - merge_strategy: MergeStrategy::Replace, - expected_version: None, - }) - .await - .expect_err("conflicting profile feature write should fail"); - - assert_eq!( - error.write_error_code(), - Some(ConfigWriteErrorCode::ConfigValidationError) - ); - assert!( - error.to_string().contains( - "invalid value for `features`: `profiles.enterprise.features.personality=false`" - ), - "{error}" - ); - assert_eq!( - std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap(), - "" - ); -} - #[tokio::test] async fn read_reports_managed_overrides_user_and_session_flags() { let tmp = tempdir().expect("tempdir"); diff --git a/codex-rs/app-server/tests/suite/v2/config_rpc.rs b/codex-rs/app-server/tests/suite/v2/config_rpc.rs index 56c4d0b7d140..0bfd8ec876e6 100644 --- a/codex-rs/app-server/tests/suite/v2/config_rpc.rs +++ b/codex-rs/app-server/tests/suite/v2/config_rpc.rs @@ -894,19 +894,14 @@ async fn config_batch_write_applies_multiple_edits() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn config_batch_write_preserves_dotted_profile_names() -> Result<()> { +async fn config_batch_write_rejects_legacy_profile_tables() -> Result<()> { let tmp_dir = TempDir::new()?; let codex_home = tmp_dir.path().canonicalize()?; write_config( &tmp_dir, r#" -profile = "team.prod" - [profiles."team.prod"] model = "gpt-5.3-spark" - -[profiles.team.prod] -model = "should-stay-put" "#, )?; @@ -932,28 +927,30 @@ model = "should-stay-put" reload_user_config: false, }) .await?; - let batch_resp: JSONRPCResponse = timeout( + let err: JSONRPCError = timeout( DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(batch_id)), + mcp.read_stream_until_error_message(RequestId::Integer(batch_id)), ) .await??; - let batch_write: ConfigWriteResponse = to_response(batch_resp)?; - assert_eq!(batch_write.status, WriteStatus::Ok); + let code = err + .error + .data + .as_ref() + .and_then(|data| data.get("config_write_error_code")) + .and_then(|value| value.as_str()); + assert_eq!(code, Some("configValidationError")); + assert!( + err.error.message.contains("`profiles`"), + "unexpected error: {err:?}" + ); let config: toml::Value = toml::from_str(&std::fs::read_to_string(codex_home.join("config.toml"))?)?; assert_eq!( config["profiles"]["team.prod"]["model"].as_str(), - Some("gpt-5.5") - ); - assert_eq!( - config["profiles"]["team"]["prod"]["model"].as_str(), - Some("should-stay-put") - ); - assert_eq!( - config["items"]["sample@catalog"]["enabled"].as_bool(), - Some(true) + Some("gpt-5.3-spark") ); + assert_eq!(config.get("items"), None); Ok(()) } diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index beece2f172bc..b0e2a0d45c6f 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -27,9 +27,6 @@ use crate::types::ToolSuggestConfig; use crate::types::Tui; use crate::types::UriBasedFileOpener; use crate::types::WindowsToml; -use codex_app_server_protocol::ForcedChatgptWorkspaceIds as ApiForcedChatgptWorkspaceIds; -use codex_app_server_protocol::Tools; -use codex_app_server_protocol::UserSavedConfig; use codex_features::FeaturesToml; use codex_model_provider_info::AMAZON_BEDROCK_PROVIDER_ID; use codex_model_provider_info::LEGACY_OLLAMA_CHAT_PROVIDER_ID; @@ -105,13 +102,6 @@ impl ForcedChatgptWorkspaceIds { Self::Multiple(values) => values, } } - - pub fn into_api(self) -> ApiForcedChatgptWorkspaceIds { - match self { - Self::Single(value) => ApiForcedChatgptWorkspaceIds::Single(value), - Self::Multiple(values) => ApiForcedChatgptWorkspaceIds::Multiple(values), - } - } } impl<'de> Deserialize<'de> for ForcedChatgptWorkspaceIds { @@ -553,33 +543,6 @@ pub struct AutoReviewToml { pub policy: Option, } -impl From for UserSavedConfig { - fn from(config_toml: ConfigToml) -> Self { - let profiles = config_toml - .profiles - .into_iter() - .map(|(k, v)| (k, v.into())) - .collect(); - - Self { - approval_policy: config_toml.approval_policy, - sandbox_mode: config_toml.sandbox_mode, - sandbox_settings: config_toml.sandbox_workspace_write.map(From::from), - forced_chatgpt_workspace_id: config_toml - .forced_chatgpt_workspace_id - .map(ForcedChatgptWorkspaceIds::into_api), - forced_login_method: config_toml.forced_login_method, - model: config_toml.model, - model_reasoning_effort: config_toml.model_reasoning_effort, - model_reasoning_summary: config_toml.model_reasoning_summary, - model_verbosity: config_toml.model_verbosity, - tools: config_toml.tools.map(From::from), - profile: config_toml.profile, - profiles, - } - } -} - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct ProjectConfig { @@ -730,14 +693,6 @@ pub struct AgentRoleToml { pub nickname_candidates: Option>, } -impl From for Tools { - fn from(tools_toml: ToolsToml) -> Self { - Self { - web_search: tools_toml.web_search.is_some().then_some(true), - } - } -} - #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct GhostSnapshotToml { diff --git a/codex-rs/config/src/profile_toml.rs b/codex-rs/config/src/profile_toml.rs index 5f4c8d62f910..e7cddd3d679f 100644 --- a/codex-rs/config/src/profile_toml.rs +++ b/codex-rs/config/src/profile_toml.rs @@ -81,17 +81,3 @@ pub struct ProfileTui { #[serde(default)] pub session_picker_view: Option, } - -impl From for codex_app_server_protocol::Profile { - fn from(config_profile: ConfigProfile) -> Self { - Self { - model: config_profile.model, - model_provider: config_profile.model_provider, - approval_policy: config_profile.approval_policy, - model_reasoning_effort: config_profile.model_reasoning_effort, - model_reasoning_summary: config_profile.model_reasoning_summary, - model_verbosity: config_profile.model_verbosity, - chatgpt_base_url: config_profile.chatgpt_base_url, - } - } -} From 865ca936dbeaf8f5dd771853f9da478f969d3ec1 Mon Sep 17 00:00:00 2001 From: adams-oai Date: Fri, 22 May 2026 11:33:44 -0700 Subject: [PATCH 43/64] Add new enterprise requirement gate (#23736) Add new enterprise requirement gate. Validation: - `cargo test -p codex-config --lib` - `cargo test -p codex-app-server-protocol --lib` - `cargo test -p codex-tui --lib debug_config` - `cargo test -p codex-app-server --lib` *(fails: stack overflow in `in_process::tests::in_process_start_initializes_and_handles_typed_v2_request`; reproduces when run alone)* --- .../codex_app_server_protocol.schemas.json | 6 +++ .../codex_app_server_protocol.v2.schemas.json | 6 +++ .../v2/ConfigRequirementsReadResponse.json | 6 +++ .../typescript/v2/ConfigRequirements.ts | 2 +- .../src/protocol/v2/config.rs | 1 + .../src/protocol/v2/tests.rs | 1 + .../request_processors/config_processor.rs | 12 +++++ codex-rs/cloud-requirements/src/lib.rs | 16 +++++++ codex-rs/config/src/config_requirements.rs | 44 +++++++++++++++++++ .../core/src/config/config_loader_tests.rs | 3 ++ codex-rs/core/src/config/config_tests.rs | 2 + codex-rs/core/src/config/mod.rs | 1 + codex-rs/tui/src/debug_config.rs | 18 ++++++++ 13 files changed, 117 insertions(+), 1 deletion(-) diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 0de1933ed664..a7384050362c 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -7715,6 +7715,12 @@ }, "ConfigRequirements": { "properties": { + "allowAppshots": { + "type": [ + "boolean", + "null" + ] + }, "allowManagedHooksOnly": { "type": [ "boolean", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 633a72245062..ac565328f9dc 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -4084,6 +4084,12 @@ }, "ConfigRequirements": { "properties": { + "allowAppshots": { + "type": [ + "boolean", + "null" + ] + }, "allowManagedHooksOnly": { "type": [ "boolean", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json index d25ca854af91..63a209cc7795 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json @@ -73,6 +73,12 @@ }, "ConfigRequirements": { "properties": { + "allowAppshots": { + "type": [ + "boolean", + "null" + ] + }, "allowManagedHooksOnly": { "type": [ "boolean", diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts index 8653da78cded..c5f3895866a6 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts @@ -7,4 +7,4 @@ import type { ComputerUseRequirements } from "./ComputerUseRequirements"; import type { ResidencyRequirement } from "./ResidencyRequirement"; import type { SandboxMode } from "./SandboxMode"; -export type ConfigRequirements = {allowedApprovalPolicies: Array | null, allowedSandboxModes: Array | null, allowedPermissions: Array | null, allowedWebSearchModes: Array | null, allowManagedHooksOnly: boolean | null, computerUse: ComputerUseRequirements | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null}; +export type ConfigRequirements = {allowedApprovalPolicies: Array | null, allowedSandboxModes: Array | null, allowedPermissions: Array | null, allowedWebSearchModes: Array | null, allowManagedHooksOnly: boolean | null, allowAppshots: boolean | null, computerUse: ComputerUseRequirements | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null}; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/config.rs b/codex-rs/app-server-protocol/src/protocol/v2/config.rs index 25f62f2c2418..fffe21381130 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -361,6 +361,7 @@ pub struct ConfigRequirements { pub allowed_permissions: Option>, pub allowed_web_search_modes: Option>, pub allow_managed_hooks_only: Option, + pub allow_appshots: Option, pub computer_use: Option, pub feature_requirements: Option>, #[experimental("configRequirements/read.hooks")] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 7112e9eb1396..8f6824f397b4 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -1593,6 +1593,7 @@ fn config_requirements_granular_allowed_approval_policy_is_marked_experimental() allowed_permissions: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, feature_requirements: None, hooks: None, diff --git a/codex-rs/app-server/src/request_processors/config_processor.rs b/codex-rs/app-server/src/request_processors/config_processor.rs index 8f50a3c216a5..72948270a674 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -431,6 +431,7 @@ fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigR normalized }), allow_managed_hooks_only: requirements.allow_managed_hooks_only, + allow_appshots: requirements.allow_appshots, computer_use: requirements .computer_use .map(map_computer_use_requirements_to_api), @@ -658,6 +659,17 @@ mod tests { assert_eq!(mapped.hooks, None); } + #[test] + fn requirements_api_includes_allow_appshots() { + let mapped = map_requirements_toml_to_api(ConfigRequirementsToml { + allow_appshots: Some(false), + ..ConfigRequirementsToml::default() + }); + + assert_eq!(mapped.allow_appshots, Some(false)); + assert_eq!(mapped.hooks, None); + } + #[test] fn requirements_api_includes_computer_use_requirements() { let mapped = map_requirements_toml_to_api(ConfigRequirementsToml { diff --git a/codex-rs/cloud-requirements/src/lib.rs b/codex-rs/cloud-requirements/src/lib.rs index 27649ad4c1b4..19fbd8cd1d93 100644 --- a/codex-rs/cloud-requirements/src/lib.rs +++ b/codex-rs/cloud-requirements/src/lib.rs @@ -1220,6 +1220,7 @@ mod tests { remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, guardian_policy_config: None, feature_requirements: None, @@ -1305,6 +1306,7 @@ mod tests { remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, guardian_policy_config: None, feature_requirements: None, @@ -1341,6 +1343,7 @@ mod tests { remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, guardian_policy_config: None, feature_requirements: None, @@ -1394,6 +1397,7 @@ mod tests { remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, guardian_policy_config: None, feature_requirements: None, @@ -1576,6 +1580,7 @@ command = "sample-mcp" remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, guardian_policy_config: None, feature_requirements: None, @@ -1659,6 +1664,7 @@ command = "sample-mcp" remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, guardian_policy_config: None, feature_requirements: None, @@ -1740,6 +1746,7 @@ command = "sample-mcp" remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, guardian_policy_config: None, feature_requirements: None, @@ -1949,6 +1956,7 @@ command = "sample-mcp" remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, guardian_policy_config: None, feature_requirements: None, @@ -1992,6 +2000,7 @@ command = "sample-mcp" remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, guardian_policy_config: None, feature_requirements: None, @@ -2055,6 +2064,7 @@ command = "sample-mcp" remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, guardian_policy_config: None, feature_requirements: None, @@ -2114,6 +2124,7 @@ command = "sample-mcp" remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, guardian_policy_config: None, feature_requirements: None, @@ -2175,6 +2186,7 @@ command = "sample-mcp" remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, guardian_policy_config: None, feature_requirements: None, @@ -2237,6 +2249,7 @@ command = "sample-mcp" remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, guardian_policy_config: None, feature_requirements: None, @@ -2303,6 +2316,7 @@ command = "sample-mcp" remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, guardian_policy_config: None, feature_requirements: None, @@ -2395,6 +2409,7 @@ command = "sample-mcp" remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, guardian_policy_config: None, feature_requirements: None, @@ -2433,6 +2448,7 @@ command = "sample-mcp" remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, guardian_policy_config: None, feature_requirements: None, diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index d209c161999e..93d16d3ccfa2 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -89,6 +89,7 @@ pub struct ConfigRequirements { pub permission_profile: ConstrainedWithSource, pub web_search_mode: ConstrainedWithSource, pub allow_managed_hooks_only: Option>, + pub allow_appshots: Option>, pub computer_use: Option>, pub feature_requirements: Option>, pub managed_hooks: Option>, @@ -124,6 +125,7 @@ impl Default for ConfigRequirements { /*source*/ None, ), allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, feature_requirements: None, managed_hooks: None, @@ -750,6 +752,7 @@ pub struct ConfigRequirementsToml { pub remote_sandbox_config: Option>, pub allowed_web_search_modes: Option>, pub allow_managed_hooks_only: Option, + pub allow_appshots: Option, pub computer_use: Option, #[serde(rename = "features", alias = "feature_requirements")] pub feature_requirements: Option, @@ -801,6 +804,7 @@ pub struct ConfigRequirementsWithSources { pub allowed_permissions: Option>>, pub allowed_web_search_modes: Option>>, pub allow_managed_hooks_only: Option>, + pub allow_appshots: Option>, pub computer_use: Option>, pub feature_requirements: Option>, pub hooks: Option>, @@ -840,6 +844,7 @@ impl ConfigRequirementsWithSources { remote_sandbox_config: _, allowed_web_search_modes: _, allow_managed_hooks_only: _, + allow_appshots: _, computer_use: _, feature_requirements: _, hooks: _, @@ -872,6 +877,7 @@ impl ConfigRequirementsWithSources { allowed_permissions, allowed_web_search_modes, allow_managed_hooks_only, + allow_appshots, computer_use, feature_requirements, hooks, @@ -902,6 +908,7 @@ impl ConfigRequirementsWithSources { allowed_permissions, allowed_web_search_modes, allow_managed_hooks_only, + allow_appshots, computer_use, feature_requirements, hooks, @@ -922,6 +929,7 @@ impl ConfigRequirementsWithSources { remote_sandbox_config: None, allowed_web_search_modes: allowed_web_search_modes.map(|sourced| sourced.value), allow_managed_hooks_only: allow_managed_hooks_only.map(|sourced| sourced.value), + allow_appshots: allow_appshots.map(|sourced| sourced.value), computer_use: computer_use.map(|sourced| sourced.value), feature_requirements: feature_requirements.map(|sourced| sourced.value), hooks: hooks.map(|sourced| sourced.value), @@ -1008,6 +1016,7 @@ impl ConfigRequirementsToml { && self.remote_sandbox_config.is_none() && self.allowed_web_search_modes.is_none() && self.allow_managed_hooks_only.is_none() + && self.allow_appshots.is_none() && self .computer_use .as_ref() @@ -1054,6 +1063,7 @@ impl TryFrom for ConfigRequirements { allowed_permissions: _, allowed_web_search_modes, allow_managed_hooks_only, + allow_appshots, computer_use, feature_requirements, hooks, @@ -1291,6 +1301,7 @@ impl TryFrom for ConfigRequirements { permission_profile, web_search_mode, allow_managed_hooks_only, + allow_appshots, computer_use, feature_requirements, managed_hooks, @@ -1361,6 +1372,7 @@ mod tests { remote_sandbox_config: _, allowed_web_search_modes, allow_managed_hooks_only, + allow_appshots, computer_use, feature_requirements, hooks, @@ -1386,6 +1398,8 @@ mod tests { .map(|value| Sourced::new(value, RequirementSource::Unknown)), allow_managed_hooks_only: allow_managed_hooks_only .map(|value| Sourced::new(value, RequirementSource::Unknown)), + allow_appshots: allow_appshots + .map(|value| Sourced::new(value, RequirementSource::Unknown)), computer_use: computer_use.map(|value| Sourced::new(value, RequirementSource::Unknown)), feature_requirements: feature_requirements .map(|value| Sourced::new(value, RequirementSource::Unknown)), @@ -1466,6 +1480,19 @@ mod tests { Ok(()) } + #[test] + fn deserialize_allow_appshots() -> Result<()> { + let requirements: ConfigRequirementsToml = from_str( + r#" + allow_appshots = true + "#, + )?; + + assert_eq!(requirements.allow_appshots, Some(true)); + assert!(!requirements.is_empty()); + Ok(()) + } + #[test] fn filesystem_requirements_table_cannot_define_a_permission_profile() { let err = from_str::( @@ -1484,6 +1511,19 @@ mod tests { ); } + #[test] + fn allow_appshots_false_is_still_configured() -> Result<()> { + let requirements: ConfigRequirementsToml = from_str( + r#" + allow_appshots = false + "#, + )?; + + assert_eq!(requirements.allow_appshots, Some(false)); + assert!(!requirements.is_empty()); + Ok(()) + } + #[test] fn deserialize_computer_use_requirements() -> Result<()> { let requirements: ConfigRequirementsToml = from_str( @@ -1539,6 +1579,7 @@ mod tests { remote_sandbox_config: None, allowed_web_search_modes: Some(allowed_web_search_modes.clone()), allow_managed_hooks_only: Some(true), + allow_appshots: Some(false), computer_use: Some(computer_use.clone()), feature_requirements: Some(feature_requirements.clone()), hooks: None, @@ -1578,6 +1619,7 @@ mod tests { /*value*/ true, enforce_source.clone(), )), + allow_appshots: Some(Sourced::new(/*value*/ false, enforce_source.clone(),)), computer_use: Some(Sourced::new(computer_use, enforce_source.clone())), feature_requirements: Some(Sourced::new( feature_requirements, @@ -1623,6 +1665,7 @@ mod tests { allowed_permissions: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, feature_requirements: None, hooks: None, @@ -1673,6 +1716,7 @@ mod tests { allowed_permissions: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, feature_requirements: None, hooks: None, diff --git a/codex-rs/core/src/config/config_loader_tests.rs b/codex-rs/core/src/config/config_loader_tests.rs index db671e1d3a94..2682e6ef41fb 100644 --- a/codex-rs/core/src/config/config_loader_tests.rs +++ b/codex-rs/core/src/config/config_loader_tests.rs @@ -1101,6 +1101,7 @@ allowed_approval_policies = ["on-request"] remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, feature_requirements: None, hooks: None, @@ -1161,6 +1162,7 @@ allowed_approval_policies = ["on-request"] remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, feature_requirements: None, hooks: None, @@ -1370,6 +1372,7 @@ async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()> remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, feature_requirements: None, hooks: None, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 89310277ab66..09f17e201d33 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -8098,6 +8098,7 @@ async fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset() remote_sandbox_config: None, allowed_web_search_modes: Some(vec![codex_config::WebSearchModeRequirement::Cached]), allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, feature_requirements: None, hooks: None, @@ -8820,6 +8821,7 @@ async fn explicit_sandbox_mode_falls_back_when_disallowed_by_requirements() -> s remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, feature_requirements: None, hooks: None, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index a70492ef05fb..7044b9c6765c 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -2440,6 +2440,7 @@ impl Config { permission_profile: mut constrained_permission_profile, web_search_mode: mut constrained_web_search_mode, allow_managed_hooks_only: _, + allow_appshots: _, computer_use: _, feature_requirements, managed_hooks: _, diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index e89b97ed5417..4c40e0445521 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -156,6 +156,17 @@ fn render_debug_config_lines(stack: &ConfigLayerStack) -> Vec> { )); } + if let Some(allow_appshots) = requirements_toml.allow_appshots { + requirement_lines.push(requirement_line( + "allow_appshots", + allow_appshots.to_string(), + requirements + .allow_appshots + .as_ref() + .map(|sourced| &sourced.source), + )); + } + if requirements_toml.guardian_policy_config.is_some() { requirement_lines.push(requirement_line( "guardian_policy_config", @@ -662,6 +673,10 @@ mod tests { /*value*/ true, RequirementSource::CloudRequirements, )), + allow_appshots: Some(Sourced::new( + /*value*/ false, + RequirementSource::CloudRequirements, + )), feature_requirements: Some(Sourced::new( FeatureRequirementsToml { entries: BTreeMap::from([("guardian_approval".to_string(), true)]), @@ -701,6 +716,7 @@ mod tests { remote_sandbox_config: None, allowed_web_search_modes: Some(vec![WebSearchModeRequirement::Cached]), allow_managed_hooks_only: Some(true), + allow_appshots: Some(false), computer_use: None, guardian_policy_config: Some("Use the managed guardian policy.".to_string()), feature_requirements: Some(FeatureRequirementsToml { @@ -763,6 +779,7 @@ mod tests { ) ); assert!(rendered.contains("allow_managed_hooks_only: true (source: cloud requirements)")); + assert!(rendered.contains("allow_appshots: false (source: cloud requirements)")); assert!( rendered.contains("guardian_policy_config: configured (source: cloud requirements)") ); @@ -917,6 +934,7 @@ approval_policy = "never" remote_sandbox_config: None, allowed_web_search_modes: Some(Vec::new()), allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, guardian_policy_config: None, feature_requirements: None, From 5b1b6a20ddb39e3179fa6da930b9eeec5bee892b Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Fri, 22 May 2026 11:37:01 -0700 Subject: [PATCH 44/64] [codex] Use rolling files for Windows sandbox logs (#24117) ## Why Windows sandbox diagnostics currently append to a single `sandbox.log` under `CODEX_HOME/.sandbox`. That file never rolls over, which makes it hard to safely include sandbox diagnostics in future feedback reports without risking unbounded growth. ## What changed - Replaced direct append-open sandbox logging with `tracing_appender::rolling::RollingFileAppender`. - Configured sandbox logs to rotate daily using names like `sandbox.YYYY-MM-DD.log`. - Added a conservative `MAX_LOG_FILES` cap of 90 retained matching log files. - Routed the Windows sandbox setup helper through the same rolling writer. - Added helpers for resolving the current daily sandbox log path so future feedback upload work can use the same filename logic. - Updated tests and test diagnostics to read the dated daily log file. This intentionally does not include sandbox logs in `/feedback` yet; scrubbing and attachment behavior can happen in a follow-up. ## Testing - `cargo fmt -p codex-windows-sandbox` - `cargo check -p codex-windows-sandbox` - `cargo test -p codex-windows-sandbox` - `cargo test -p codex-windows-sandbox logging::tests` - `cargo clippy -p codex-windows-sandbox --all-targets -- -D warnings` --- codex-rs/Cargo.lock | 1 + codex-rs/windows-sandbox-rs/Cargo.toml | 1 + .../src/bin/setup_main/win.rs | 39 ++++------ .../src/bin/setup_main/win/firewall.rs | 19 +++-- .../src/bin/setup_main/win/sandbox_users.rs | 12 +-- .../bin/setup_main/win/setup_runtime_bin.rs | 4 +- codex-rs/windows-sandbox-rs/src/lib.rs | 6 +- codex-rs/windows-sandbox-rs/src/logging.rs | 74 ++++++++++++++++--- .../src/unified_exec/tests.rs | 2 +- 9 files changed, 109 insertions(+), 49 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 4acaeb7c2775..38ce92e46b30 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -4146,6 +4146,7 @@ dependencies = [ "serde_json", "tempfile", "tokio", + "tracing-appender", "windows 0.58.0", "windows-sys 0.52.0", ] diff --git a/codex-rs/windows-sandbox-rs/Cargo.toml b/codex-rs/windows-sandbox-rs/Cargo.toml index 3b00b40ae845..53c81f0b03f7 100644 --- a/codex-rs/windows-sandbox-rs/Cargo.toml +++ b/codex-rs/windows-sandbox-rs/Cargo.toml @@ -37,6 +37,7 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" tempfile = "3" tokio = { workspace = true, features = ["sync", "rt"] } +tracing-appender = { workspace = true } windows = { version = "0.58", features = [ "Win32_Foundation", "Win32_NetworkManagement_WindowsFirewall", diff --git a/codex-rs/windows-sandbox-rs/src/bin/setup_main/win.rs b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win.rs index f0475bc39fdb..9b178fdca9d3 100644 --- a/codex-rs/windows-sandbox-rs/src/bin/setup_main/win.rs +++ b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win.rs @@ -6,7 +6,6 @@ use anyhow::Result; use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64; use codex_otel::StatsigMetricsSettings; -use codex_windows_sandbox::LOG_FILE_NAME; use codex_windows_sandbox::SETUP_VERSION; use codex_windows_sandbox::SetupErrorCode; use codex_windows_sandbox::SetupErrorReport; @@ -21,6 +20,7 @@ use codex_windows_sandbox::hide_newly_created_users; use codex_windows_sandbox::install_wfp_filters; use codex_windows_sandbox::is_command_cwd_root; use codex_windows_sandbox::log_note; +use codex_windows_sandbox::log_writer; use codex_windows_sandbox::path_mask_allows; use codex_windows_sandbox::sandbox_bin_dir; use codex_windows_sandbox::sandbox_dir; @@ -36,7 +36,6 @@ use serde::Serialize; use std::collections::HashSet; use std::ffi::OsStr; use std::ffi::c_void; -use std::fs::File; use std::io::Write; use std::os::windows::process::CommandExt; use std::path::Path; @@ -109,7 +108,7 @@ enum SetupMode { ReadAclsOnly, } -fn log_line(log: &mut File, msg: &str) -> Result<()> { +fn log_line(log: &mut dyn Write, msg: &str) -> Result<()> { let ts = chrono::Utc::now().to_rfc3339(); writeln!(log, "[{ts}] {msg}").map_err(|err| { anyhow::Error::new(SetupFailure::new( @@ -156,7 +155,7 @@ fn workspace_write_cap_sids_for_path( Ok(sid_strs) } -fn spawn_read_acl_helper(payload: &Payload, _log: &mut File) -> Result<()> { +fn spawn_read_acl_helper(payload: &Payload, _log: &mut dyn Write) -> Result<()> { let mut read_payload = payload.clone(); read_payload.mode = SetupMode::ReadAclsOnly; read_payload.refresh_only = true; @@ -182,7 +181,7 @@ struct ReadAclSubjects<'a> { fn apply_read_acls( read_roots: &[PathBuf], subjects: &ReadAclSubjects<'_>, - log: &mut File, + log: &mut dyn Write, refresh_errors: &mut Vec, access_mask: u32, access_label: &str, @@ -259,7 +258,7 @@ fn read_mask_allows_or_log( read_mask: u32, access_label: &str, refresh_errors: &mut Vec, - log: &mut File, + log: &mut dyn Write, ) -> Result { match path_mask_allows(root, psids, read_mask, /*require_all_bits*/ true) { Ok(has) => Ok(has), @@ -294,7 +293,7 @@ fn lock_sandbox_dir( sandbox_group_access_mode: i32, sandbox_group_mask: u32, real_user_mask: u32, - _log: &mut File, + _log: &mut dyn Write, ) -> Result<()> { std::fs::create_dir_all(dir)?; let system_sid = resolve_sid("SYSTEM")?; @@ -391,8 +390,7 @@ pub fn main() -> Result<()> { if let Ok(codex_home) = std::env::var("CODEX_HOME") { let sbx_dir = sandbox_dir(Path::new(&codex_home)); let _ = std::fs::create_dir_all(&sbx_dir); - let log_path = sbx_dir.join(LOG_FILE_NAME); - if let Ok(mut f) = File::options().create(true).append(true).open(&log_path) { + if let Some(mut f) = log_writer(&sbx_dir) { let _ = writeln!( f, "[{}] top-level error: {}", @@ -442,17 +440,12 @@ fn real_main() -> Result<()> { format!("failed to create sandbox dir {}: {err}", sbx_dir.display()), )) })?; - let log_path = sbx_dir.join(LOG_FILE_NAME); - let mut log = File::options() - .create(true) - .append(true) - .open(&log_path) - .map_err(|err| { - anyhow::Error::new(SetupFailure::new( - SetupErrorCode::HelperLogFailed, - format!("open log {} failed: {err}", log_path.display()), - )) - })?; + let mut log = log_writer(&sbx_dir).ok_or_else(|| { + anyhow::Error::new(SetupFailure::new( + SetupErrorCode::HelperLogFailed, + format!("open log in {} failed", sbx_dir.display()), + )) + })?; let result = run_setup(&payload, &mut log, &sbx_dir); if let Err(err) = &result { let _ = log_line(&mut log, &format!("setup error: {err:?}")); @@ -480,14 +473,14 @@ fn real_main() -> Result<()> { result } -fn run_setup(payload: &Payload, log: &mut File, sbx_dir: &Path) -> Result<()> { +fn run_setup(payload: &Payload, log: &mut dyn Write, sbx_dir: &Path) -> Result<()> { match payload.mode { SetupMode::ReadAclsOnly => run_read_acl_only(payload, log), SetupMode::Full => run_setup_full(payload, log, sbx_dir), } } -fn run_read_acl_only(payload: &Payload, log: &mut File) -> Result<()> { +fn run_read_acl_only(payload: &Payload, log: &mut dyn Write) -> Result<()> { let _read_acl_guard = match acquire_read_acl_mutex()? { Some(guard) => guard, None => { @@ -550,7 +543,7 @@ fn run_read_acl_only(payload: &Payload, log: &mut File) -> Result<()> { Ok(()) } -fn run_setup_full(payload: &Payload, log: &mut File, sbx_dir: &Path) -> Result<()> { +fn run_setup_full(payload: &Payload, log: &mut dyn Write, sbx_dir: &Path) -> Result<()> { let refresh_only = payload.refresh_only; if !refresh_only { let provision_result = provision_sandbox_users( diff --git a/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/firewall.rs b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/firewall.rs index 0a839508536d..bf2476dd123c 100644 --- a/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/firewall.rs +++ b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/firewall.rs @@ -1,5 +1,4 @@ use anyhow::Result; -use std::fs::File; use std::io::Write; use windows::Win32::Foundation::S_OK; @@ -57,7 +56,7 @@ pub fn ensure_offline_proxy_allowlist( offline_sid: &str, proxy_ports: &[u16], allow_local_binding: bool, - log: &mut File, + log: &mut dyn Write, ) -> Result<()> { let local_user_spec = format!("O:LSD:(A;;CC;;;{offline_sid})"); @@ -154,7 +153,7 @@ pub fn ensure_offline_proxy_allowlist( result } -pub fn ensure_offline_outbound_block(offline_sid: &str, log: &mut File) -> Result<()> { +pub fn ensure_offline_outbound_block(offline_sid: &str, log: &mut dyn Write) -> Result<()> { let local_user_spec = format!("O:LSD:(A;;CC;;;{offline_sid})"); let hr = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) }; @@ -206,7 +205,11 @@ pub fn ensure_offline_outbound_block(offline_sid: &str, log: &mut File) -> Resul result } -fn remove_rule_if_present(rules: &INetFwRules, internal_name: &str, log: &mut File) -> Result<()> { +fn remove_rule_if_present( + rules: &INetFwRules, + internal_name: &str, + log: &mut dyn Write, +) -> Result<()> { let name = BSTR::from(internal_name); if unsafe { rules.Item(&name) }.is_ok() { unsafe { rules.Remove(&name) }.map_err(|err| { @@ -266,7 +269,11 @@ fn validate_local_policy_modify_result( ))) } -fn ensure_block_rule(rules: &INetFwRules, spec: &BlockRuleSpec<'_>, log: &mut File) -> Result<()> { +fn ensure_block_rule( + rules: &INetFwRules, + spec: &BlockRuleSpec<'_>, + log: &mut dyn Write, +) -> Result<()> { let name = BSTR::from(spec.internal_name); let rule: INetFwRule3 = match unsafe { rules.Item(&name) } { Ok(existing) => existing.cast().map_err(|err| { @@ -453,7 +460,7 @@ fn port_range_string(start: u32, end: u32) -> String { } } -fn log_line(log: &mut File, msg: &str) -> Result<()> { +fn log_line(log: &mut dyn Write, msg: &str) -> Result<()> { let ts = chrono::Utc::now().to_rfc3339(); writeln!(log, "[{ts}] {msg}")?; Ok(()) diff --git a/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/sandbox_users.rs b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/sandbox_users.rs index de76b2413b36..7f21f185ee14 100644 --- a/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/sandbox_users.rs +++ b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/sandbox_users.rs @@ -7,7 +7,7 @@ use rand::rngs::SmallRng; use serde::Serialize; use std::ffi::OsStr; use std::ffi::c_void; -use std::fs::File; +use std::io::Write; use std::path::Path; use std::path::PathBuf; use windows_sys::Win32::Foundation::ERROR_INSUFFICIENT_BUFFER; @@ -49,7 +49,7 @@ const SID_AUTHENTICATED_USERS: &str = "S-1-5-11"; const SID_EVERYONE: &str = "S-1-1-0"; const SID_SYSTEM: &str = "S-1-5-18"; -pub fn ensure_sandbox_users_group(log: &mut File) -> Result<()> { +pub fn ensure_sandbox_users_group(log: &mut dyn Write) -> Result<()> { ensure_local_group(SANDBOX_USERS_GROUP, SANDBOX_USERS_GROUP_COMMENT, log) } @@ -63,7 +63,7 @@ pub fn provision_sandbox_users( online_username: &str, proxy_ports: &[u16], allow_local_binding: bool, - log: &mut File, + log: &mut dyn Write, ) -> Result<()> { ensure_sandbox_users_group(log)?; super::log_line( @@ -86,13 +86,13 @@ pub fn provision_sandbox_users( Ok(()) } -pub fn ensure_sandbox_user(username: &str, password: &str, log: &mut File) -> Result<()> { +pub fn ensure_sandbox_user(username: &str, password: &str, log: &mut dyn Write) -> Result<()> { ensure_local_user(username, password, log)?; ensure_local_group_member(SANDBOX_USERS_GROUP, username)?; Ok(()) } -pub fn ensure_local_user(name: &str, password: &str, log: &mut File) -> Result<()> { +pub fn ensure_local_user(name: &str, password: &str, log: &mut dyn Write) -> Result<()> { let name_w = to_wide(OsStr::new(name)); let pwd_w = to_wide(OsStr::new(password)); unsafe { @@ -156,7 +156,7 @@ pub fn ensure_local_user(name: &str, password: &str, log: &mut File) -> Result<( Ok(()) } -pub fn ensure_local_group(name: &str, comment: &str, log: &mut File) -> Result<()> { +pub fn ensure_local_group(name: &str, comment: &str, log: &mut dyn Write) -> Result<()> { const ERROR_ALIAS_EXISTS: u32 = 1379; const NERR_GROUP_EXISTS: u32 = 2223; diff --git a/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/setup_runtime_bin.rs b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/setup_runtime_bin.rs index be8b0c67e784..47cacf72e0cf 100644 --- a/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/setup_runtime_bin.rs +++ b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/setup_runtime_bin.rs @@ -1,5 +1,5 @@ use std::ffi::c_void; -use std::fs::File; +use std::io::Write; use std::path::PathBuf; use anyhow::Result; @@ -13,7 +13,7 @@ use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_READ; pub(super) fn ensure_codex_app_runtime_bin_readable( sandbox_group_psid: *mut c_void, refresh_errors: &mut Vec, - log: &mut File, + log: &mut dyn Write, ) -> Result<()> { let local_app_data = std::env::var_os("LOCALAPPDATA") .map(PathBuf::from) diff --git a/codex-rs/windows-sandbox-rs/src/lib.rs b/codex-rs/windows-sandbox-rs/src/lib.rs index 43b3f7195528..ca9d6c16f301 100644 --- a/codex-rs/windows-sandbox-rs/src/lib.rs +++ b/codex-rs/windows-sandbox-rs/src/lib.rs @@ -183,10 +183,14 @@ pub use ipc_framed::read_frame; #[cfg(target_os = "windows")] pub use ipc_framed::write_frame; #[cfg(target_os = "windows")] -pub use logging::LOG_FILE_NAME; +pub use logging::current_log_file_path; +#[cfg(target_os = "windows")] +pub use logging::log_file_path_for_utc_date; #[cfg(target_os = "windows")] pub use logging::log_note; #[cfg(target_os = "windows")] +pub use logging::log_writer; +#[cfg(target_os = "windows")] pub use path_normalization::canonicalize_path; #[cfg(target_os = "windows")] pub use policy::SandboxPolicy; diff --git a/codex-rs/windows-sandbox-rs/src/logging.rs b/codex-rs/windows-sandbox-rs/src/logging.rs index 1c0d695f3244..f5e839970995 100644 --- a/codex-rs/windows-sandbox-rs/src/logging.rs +++ b/codex-rs/windows-sandbox-rs/src/logging.rs @@ -1,13 +1,16 @@ -use std::fs::OpenOptions; use std::io::Write; use std::path::Path; use std::path::PathBuf; use std::sync::OnceLock; use codex_utils_string::take_bytes_at_char_boundary; +use tracing_appender::rolling::RollingFileAppender; +use tracing_appender::rolling::Rotation; const LOG_COMMAND_PREVIEW_LIMIT: usize = 200; -pub const LOG_FILE_NAME: &str = "sandbox.log"; +pub const LOG_FILE_PREFIX: &str = "sandbox"; +pub const LOG_FILE_SUFFIX: &str = "log"; +pub const MAX_LOG_FILES: usize = 90; fn exe_label() -> &'static str { static LABEL: OnceLock = OnceLock::new(); @@ -28,18 +31,35 @@ fn preview(command: &[String]) -> String { } } -fn log_file_path(base_dir: &Path) -> Option { - if base_dir.is_dir() { - Some(base_dir.join(LOG_FILE_NAME)) - } else { - None +pub fn log_file_path_for_utc_date(base_dir: &Path, date: chrono::NaiveDate) -> PathBuf { + base_dir.join(format!( + "{LOG_FILE_PREFIX}.{}.{}", + date.format("%Y-%m-%d"), + LOG_FILE_SUFFIX + )) +} + +pub fn current_log_file_path(base_dir: &Path) -> PathBuf { + log_file_path_for_utc_date(base_dir, chrono::Utc::now().date_naive()) +} + +pub fn log_writer(base_dir: &Path) -> Option { + if !base_dir.is_dir() { + return None; } + + RollingFileAppender::builder() + .rotation(Rotation::DAILY) + .filename_prefix(LOG_FILE_PREFIX) + .filename_suffix(LOG_FILE_SUFFIX) + .max_log_files(MAX_LOG_FILES) + .build(base_dir) + .ok() } fn append_line(line: &str, base_dir: Option<&Path>) { if let Some(dir) = base_dir - && let Some(path) = log_file_path(dir) - && let Ok(mut f) = OpenOptions::new().create(true).append(true).open(path) + && let Some(mut f) = log_writer(dir) { let _ = writeln!(f, "{line}"); } @@ -68,7 +88,7 @@ pub fn debug_log(msg: &str, base_dir: Option<&Path>) { } } -// Unconditional note logging to sandbox.log +// Unconditional note logging to the daily sandbox log. pub fn log_note(msg: &str, base_dir: Option<&Path>) { let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S%.3f"); append_line(&format!("[{ts} {}] {}", exe_label(), msg), base_dir); @@ -88,4 +108,38 @@ mod tests { let previewed = result.unwrap(); assert!(previewed.len() <= LOG_COMMAND_PREVIEW_LIMIT); } + + #[test] + fn log_note_writes_to_daily_rolling_log() { + let tempdir = tempfile::tempdir().expect("tempdir"); + + log_note("hello daily log", Some(tempdir.path())); + + let entries = std::fs::read_dir(tempdir.path()) + .expect("read log dir") + .collect::, _>>() + .expect("read entries"); + assert_eq!(entries.len(), 1); + + let log_path = entries[0].path(); + let filename = log_path + .file_name() + .and_then(|name| name.to_str()) + .expect("utf-8 filename"); + assert!(filename.starts_with("sandbox.")); + assert!(filename.ends_with(".log")); + + let log = std::fs::read_to_string(log_path).expect("read log"); + assert!(log.contains("hello daily log")); + } + + #[test] + fn log_file_path_for_utc_date_matches_rolling_appender_name() { + let date = chrono::NaiveDate::from_ymd_opt(2026, 5, 21).expect("valid date"); + + assert_eq!( + log_file_path_for_utc_date(Path::new("logs"), date), + PathBuf::from("logs").join("sandbox.2026-05-21.log") + ); + } } diff --git a/codex-rs/windows-sandbox-rs/src/unified_exec/tests.rs b/codex-rs/windows-sandbox-rs/src/unified_exec/tests.rs index 9cd3d6d96d2f..e2830ad8a1c7 100644 --- a/codex-rs/windows-sandbox-rs/src/unified_exec/tests.rs +++ b/codex-rs/windows-sandbox-rs/src/unified_exec/tests.rs @@ -70,7 +70,7 @@ fn sandbox_home(name: &str) -> TempDir { } fn sandbox_log(codex_home: &Path) -> String { - let log_path = codex_home.join(".sandbox").join("sandbox.log"); + let log_path = crate::current_log_file_path(&codex_home.join(".sandbox")); fs::read_to_string(&log_path) .unwrap_or_else(|err| format!("failed to read {}: {err}", log_path.display())) } From 75b7e06621ac38c2b26a73832e797d49a7307f2b Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Fri, 22 May 2026 11:39:08 -0700 Subject: [PATCH 45/64] docs: update README.md to mention curl-based installer (#24106) Now that users can install via `curl` (or `irm`), we should tell them about it so they no longer need to use `npm`! Note that on one Windows machine I tested on, when I ran: ``` irm https://chatgpt.com/codex/install.ps1 | iex ``` I got this error: ``` iex : The property 'OSArchitecture' cannot be found on this object. Verify that the property exists. At line:1 char:45 + irm https://chatgpt.com/codex/install.ps1 | iex + ~~~ + CategoryInfo : NotSpecified: (:) [Invoke-Expression], PropertyNotFoundException + FullyQualifiedErrorId : PropertyNotFoundStrict,Microsoft.PowerShell.Commands.InvokeExpressionCommand ``` so we'll recommend the following that works from both `cmd.exe` and PowerShell: ``` powershell -ExecutionPolicy ByPass -c "irm https://chatgpt.com/codex/install.ps1 | iex" ``` This PR makes a slight update to `codex-rs/tui/src/update_action.rs` to match. --- README.md | 15 +++++++++++++-- codex-rs/tui/src/update_action.rs | 14 ++++++++++++-- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 5cc7fd4953cb..77c8d2199cac 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,3 @@ -

npm i -g @openai/codex
or brew install --cask codex

Codex CLI is a coding agent from OpenAI that runs locally on your computer.

Codex CLI splash @@ -14,7 +13,19 @@ If you want Codex in your code editor (VS Code, Cursor, Windsurf), ( "powershell", - &["-c", "irm https://chatgpt.com/codex/install.ps1|iex"], + &[ + "-ExecutionPolicy", + "Bypass", + "-c", + "irm https://chatgpt.com/codex/install.ps1 | iex", + ], ), } } @@ -142,7 +147,12 @@ mod tests { UpdateAction::StandaloneWindows.command_args(), ( "powershell", - &["-c", "irm https://chatgpt.com/codex/install.ps1|iex"][..], + &[ + "-ExecutionPolicy", + "Bypass", + "-c", + "irm https://chatgpt.com/codex/install.ps1 | iex" + ][..], ) ); } From acd851e89f31a74c2140f167dcb922dc08becc44 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Fri, 22 May 2026 16:20:09 -0300 Subject: [PATCH 46/64] fix(tui): restore Windows VT before TUI renders (#24082) ## Why Older Git for Windows versions can leave the Windows console output mode without virtual terminal processing after Codex runs git metadata commands in a repository. When the TUI later emits ANSI control sequences for redraws, restore, or image rendering, Windows Terminal can show raw escape bytes or leave the prompt/status area corrupted. This is a targeted mitigation for the repo-conditioned Windows rendering corruption reported in #23888 and related reports #23512 and #23628. Updating Git avoids the trigger for affected users, but Codex should also reassert the terminal mode before it writes TUI control sequences. | Before | After | |---|---| | CleanShot 2026-05-22 at 11 23 21 | CleanShot 2026-05-22 at 11 23
58 | ## What Changed - Re-enable Windows virtual terminal processing for stdout and stderr before TUI mode setup, restore, redraw, resume, and pet image render paths. - Treat invalid, null, or non-console handles as no-ops so redirected or non-console output is unaffected. - Keep the helper as a no-op on non-Windows platforms. ## How to Test 1. On Windows Terminal with a Git 2.28.0 for Windows install, start Codex inside a valid Git repository. 2. Start a new Codex CLI session. 3. Confirm the prompt, working indicator, and bottom status line remain readable instead of showing raw ANSI escape sequences. 4. Repeat outside a Git repository to confirm the ordinary non-repo startup path is unchanged. Targeted tests: - Not run locally; the behavior depends on Windows console mode APIs and the current worktree is on macOS. --- codex-rs/tui/src/tui.rs | 72 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 32123b787c61..47a6aeb6ee12 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -166,6 +166,8 @@ mod tests { } pub fn set_modes() -> Result<()> { + ensure_virtual_terminal_processing()?; + execute!(stdout(), EnableBracketedPaste)?; enable_raw_mode()?; @@ -239,12 +241,16 @@ fn restore_common( raw_mode_restore: RawModeRestore, keyboard_restore: KeyboardRestore, ) -> Result<()> { + let mut first_error = ensure_virtual_terminal_processing().err(); + match keyboard_restore { KeyboardRestore::PopStack => keyboard_modes::restore_keyboard_enhancement_stack(), KeyboardRestore::ResetAfterExit => keyboard_modes::reset_keyboard_reporting_after_exit(), } - let mut first_error = execute!(stdout(), DisableBracketedPaste).err(); + if let Err(err) = execute!(stdout(), DisableBracketedPaste) { + first_error.get_or_insert(err); + } let _ = execute!(stdout(), DisableFocusChange); if matches!(raw_mode_restore, RawModeRestore::Disable) && let Err(err) = disable_raw_mode() @@ -797,6 +803,8 @@ impl Tui { // the synchronized update, to avoid racing with the event reader. let mut pending_viewport_area = self.pending_viewport_area()?; + ensure_virtual_terminal_processing()?; + stdout().sync_update(|_| { #[cfg(unix)] if let Some(prepared) = prepared_resume.take() { @@ -854,6 +862,10 @@ impl Tui { &mut self, request: Option, ) -> std::result::Result<(), crate::pets::PetImageRenderError> { + if let Err(err) = ensure_virtual_terminal_processing() { + return Err(crate::pets::PetImageRenderError::Terminal(err)); + } + let terminal = &mut self.terminal; let state = &mut self.ambient_pet_image_state; stdout().sync_update(|_| { @@ -869,6 +881,10 @@ impl Tui { &mut self, request: Option, ) -> std::result::Result<(), crate::pets::PetImageRenderError> { + if let Err(err) = ensure_virtual_terminal_processing() { + return Err(crate::pets::PetImageRenderError::Terminal(err)); + } + let terminal = &mut self.terminal; let state = &mut self.pet_picker_preview_image_state; stdout().sync_update(|_| { @@ -887,6 +903,10 @@ impl Tui { pub fn clear_ambient_pet_image( &mut self, ) -> std::result::Result<(), crate::pets::PetImageRenderError> { + if let Err(err) = ensure_virtual_terminal_processing() { + return Err(crate::pets::PetImageRenderError::Terminal(err)); + } + crate::pets::render_ambient_pet_image( self.terminal.backend_mut(), &mut self.ambient_pet_image_state, @@ -911,6 +931,8 @@ impl Tui { .suspend_context .prepare_resume_action(&mut self.terminal, &mut self.alt_saved_viewport); + ensure_virtual_terminal_processing()?; + stdout().sync_update(|_| { #[cfg(unix)] if let Some(prepared) = prepared_resume.take() { @@ -968,3 +990,51 @@ impl Tui { Ok(None) } } + +#[cfg(windows)] +fn ensure_virtual_terminal_processing() -> Result<()> { + use windows_sys::Win32::Foundation::HANDLE; + use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; + use windows_sys::Win32::System::Console::ENABLE_PROCESSED_OUTPUT; + use windows_sys::Win32::System::Console::ENABLE_VIRTUAL_TERMINAL_PROCESSING; + use windows_sys::Win32::System::Console::GetConsoleMode; + use windows_sys::Win32::System::Console::GetStdHandle; + use windows_sys::Win32::System::Console::STD_ERROR_HANDLE; + use windows_sys::Win32::System::Console::STD_OUTPUT_HANDLE; + use windows_sys::Win32::System::Console::SetConsoleMode; + + fn enable_for_handle(handle: HANDLE) -> Result<()> { + if handle == INVALID_HANDLE_VALUE || handle == 0 { + return Ok(()); + } + + let mut mode = 0; + if unsafe { GetConsoleMode(handle, &mut mode) } == 0 { + return Ok(()); + } + + let requested = ENABLE_PROCESSED_OUTPUT | ENABLE_VIRTUAL_TERMINAL_PROCESSING; + if mode & requested == requested { + return Ok(()); + } + + if unsafe { SetConsoleMode(handle, mode | requested) } == 0 { + return Err(std::io::Error::last_os_error()); + } + + Ok(()) + } + + let stdout_handle = unsafe { GetStdHandle(STD_OUTPUT_HANDLE) }; + enable_for_handle(stdout_handle)?; + + let stderr_handle = unsafe { GetStdHandle(STD_ERROR_HANDLE) }; + enable_for_handle(stderr_handle)?; + + Ok(()) +} + +#[cfg(not(windows))] +fn ensure_virtual_terminal_processing() -> Result<()> { + Ok(()) +} From 36a71a88bf9c647fd039fbb32e64b647817623e6 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Fri, 22 May 2026 13:00:53 -0700 Subject: [PATCH 47/64] cli: support --profile for codex sandbox (#24110) ## Why `codex sandbox` now always runs the host sandbox backend, so it should accept the same profile selection mechanism as the rest of the runtime CLI surface. Without `--profile`, sandbox debugging can exercise only the default config stack unless users manually translate profile config into ad hoc `-c` overrides. Supporting `--profile` lets sandbox invocations load `$CODEX_HOME/.config.toml`, including permission profile configuration, before resolving the sandbox policy for the command being run. ## What Changed - Added `--profile NAME` / `-p NAME` to the host-specific `codex sandbox` argument structs as `config_profile`. - Allowed root-level `codex --profile NAME sandbox ...` and made a sandbox-local `codex sandbox --profile NAME ...` override the root selection. - Threaded `LoaderOverrides` through sandbox config loading so selected config profile files participate in permission resolution before the legacy read-only fallback. - Documented the new sandbox flag in `codex-rs/README.md`. ## Verification - Added parser coverage for `codex sandbox --profile`. - Added sandbox config-loader coverage that verifies selected config profile loader overrides select the profile config rather than falling back to read-only. - Ran `cargo test -p codex-cli`. --- codex-rs/README.md | 4 + codex-rs/cli/src/debug_sandbox.rs | 132 ++++++++++++++++++++++++++++-- codex-rs/cli/src/lib.rs | 13 +++ codex-rs/cli/src/main.rs | 41 +++++++++- 4 files changed, 179 insertions(+), 11 deletions(-) diff --git a/codex-rs/README.md b/codex-rs/README.md index e315f7fd9b2e..18bffd9f6424 100644 --- a/codex-rs/README.md +++ b/codex-rs/README.md @@ -66,6 +66,10 @@ codex sandbox [COMMAND]... codex sandbox --log-denials [COMMAND]... ``` +`codex sandbox` also accepts `--profile NAME` (`-p NAME`) to layer +`$CODEX_HOME/NAME.config.toml` onto the base user config for the sandboxed +command. + ### Selecting a sandbox policy via `--sandbox` The Rust CLI exposes a dedicated `--sandbox` (`-s`) flag that lets you pick the sandbox policy **without** having to reach for the generic `-c/--config` option: diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index 4833ff2c6dcc..918d81aad220 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -41,9 +41,11 @@ use seatbelt::DenialLogger; pub async fn run_command_under_seatbelt( command: SeatbeltCommand, codex_linux_sandbox_exe: Option, + loader_overrides: LoaderOverrides, ) -> anyhow::Result<()> { let SeatbeltCommand { permissions_profile, + config_profile: _, cwd, include_managed_config, allow_unix_sockets, @@ -60,6 +62,7 @@ pub async fn run_command_under_seatbelt( permissions_profile, cwd, managed_requirements_mode, + loader_overrides, }, command, config_overrides, @@ -75,6 +78,7 @@ pub async fn run_command_under_seatbelt( pub async fn run_command_under_seatbelt( _command: SeatbeltCommand, _codex_linux_sandbox_exe: Option, + _loader_overrides: LoaderOverrides, ) -> anyhow::Result<()> { anyhow::bail!("Seatbelt sandbox is only available on macOS"); } @@ -82,9 +86,11 @@ pub async fn run_command_under_seatbelt( pub async fn run_command_under_landlock( command: LandlockCommand, codex_linux_sandbox_exe: Option, + loader_overrides: LoaderOverrides, ) -> anyhow::Result<()> { let LandlockCommand { permissions_profile, + config_profile: _, cwd, include_managed_config, config_overrides, @@ -99,6 +105,7 @@ pub async fn run_command_under_landlock( permissions_profile, cwd, managed_requirements_mode, + loader_overrides, }, command, config_overrides, @@ -113,9 +120,11 @@ pub async fn run_command_under_landlock( pub async fn run_command_under_windows_sandbox( command: WindowsCommand, codex_linux_sandbox_exe: Option, + loader_overrides: LoaderOverrides, ) -> anyhow::Result<()> { let WindowsCommand { permissions_profile, + config_profile: _, cwd, include_managed_config, config_overrides, @@ -130,6 +139,7 @@ pub async fn run_command_under_windows_sandbox( permissions_profile, cwd, managed_requirements_mode, + loader_overrides, }, command, config_overrides, @@ -153,6 +163,7 @@ struct DebugSandboxConfigOptions { permissions_profile: Option, cwd: Option, managed_requirements_mode: ManagedRequirementsMode, + loader_overrides: LoaderOverrides, } #[derive(Debug, Clone, Copy)] @@ -650,7 +661,7 @@ async fn load_debug_sandbox_config( } async fn load_debug_sandbox_config_with_codex_home( - mut cli_overrides: Vec<(String, TomlValue)>, + cli_overrides: Vec<(String, TomlValue)>, codex_linux_sandbox_exe: Option, options: DebugSandboxConfigOptions, codex_home: Option, @@ -660,7 +671,9 @@ async fn load_debug_sandbox_config_with_codex_home( permissions_profile, cwd, managed_requirements_mode, + loader_overrides, } = options; + let mut cli_overrides = cli_overrides; if let Some(permissions_profile) = permissions_profile { cli_overrides.push(( @@ -674,7 +687,7 @@ async fn load_debug_sandbox_config_with_codex_home( // config. Keep that behavior unless this invocation explicitly passes a // legacy `sandbox_mode` CLI override for compatibility with older callers. let uses_legacy_sandbox_mode_override = cli_overrides_use_legacy_sandbox_mode(&cli_overrides); - let config = build_debug_sandbox_config( + let config = build_debug_sandbox_config_with_loader_overrides( cli_overrides.clone(), ConfigOverrides { cwd: cwd.clone(), @@ -683,6 +696,7 @@ async fn load_debug_sandbox_config_with_codex_home( }, codex_home.clone(), managed_requirements_mode, + loader_overrides.clone(), strict_config, ) .await?; @@ -691,7 +705,7 @@ async fn load_debug_sandbox_config_with_codex_home( return Ok(config); } - build_debug_sandbox_config( + build_debug_sandbox_config_with_loader_overrides( cli_overrides, ConfigOverrides { sandbox_mode: Some(SandboxMode::ReadOnly), @@ -701,17 +715,19 @@ async fn load_debug_sandbox_config_with_codex_home( }, codex_home, managed_requirements_mode, + loader_overrides, strict_config, ) .await .map_err(Into::into) } -async fn build_debug_sandbox_config( +async fn build_debug_sandbox_config_with_loader_overrides( cli_overrides: Vec<(String, TomlValue)>, harness_overrides: ConfigOverrides, codex_home: Option, managed_requirements_mode: ManagedRequirementsMode, + mut loader_overrides: LoaderOverrides, strict_config: bool, ) -> std::io::Result { let mut builder = ConfigBuilder::default() @@ -719,11 +735,9 @@ async fn build_debug_sandbox_config( .harness_overrides(harness_overrides) .strict_config(strict_config); if matches!(managed_requirements_mode, ManagedRequirementsMode::Ignore) { - builder = builder.loader_overrides(LoaderOverrides { - ignore_managed_requirements: true, - ..LoaderOverrides::default() - }); + loader_overrides.ignore_managed_requirements = true; } + builder = builder.loader_overrides(loader_overrides); if let Some(codex_home) = codex_home { builder = builder .codex_home(codex_home.clone()) @@ -750,6 +764,24 @@ mod tests { use pretty_assertions::assert_eq; use tempfile::TempDir; + async fn build_debug_sandbox_config( + cli_overrides: Vec<(String, TomlValue)>, + harness_overrides: ConfigOverrides, + codex_home: Option, + managed_requirements_mode: ManagedRequirementsMode, + strict_config: bool, + ) -> std::io::Result { + build_debug_sandbox_config_with_loader_overrides( + cli_overrides, + harness_overrides, + codex_home, + managed_requirements_mode, + LoaderOverrides::default(), + strict_config, + ) + .await + } + fn escape_toml_path(path: &std::path::Path) -> String { path.display().to_string().replace('\\', "\\\\") } @@ -758,6 +790,18 @@ mod tests { codex_home: &TempDir, docs: &std::path::Path, private: &std::path::Path, + ) -> std::io::Result<()> { + write_permissions_profile_config_to_path( + &codex_home.path().join("config.toml"), + docs, + private, + ) + } + + fn write_permissions_profile_config_to_path( + config_path: &std::path::Path, + docs: &std::path::Path, + private: &std::path::Path, ) -> std::io::Result<()> { std::fs::create_dir_all(private)?; let config = format!( @@ -772,7 +816,7 @@ mod tests { escape_toml_path(docs), escape_toml_path(private), ); - std::fs::write(codex_home.path().join("config.toml"), config)?; + std::fs::write(config_path, config)?; Ok(()) } @@ -812,6 +856,7 @@ mod tests { permissions_profile: None, cwd: None, managed_requirements_mode: ManagedRequirementsMode::Include, + loader_overrides: LoaderOverrides::default(), }, Some(codex_home_path), /*strict_config*/ false, @@ -836,6 +881,70 @@ mod tests { Ok(()) } + #[tokio::test] + async fn debug_sandbox_honors_config_profile_loader_overrides() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + let sandbox_paths = TempDir::new()?; + let docs = sandbox_paths.path().join("docs"); + let private = docs.join("private"); + let profile_path = codex_home.path().join("work.config.toml"); + write_permissions_profile_config_to_path(&profile_path, &docs, &private)?; + let codex_home_path = codex_home.path().to_path_buf(); + let loader_overrides = LoaderOverrides { + user_config_path: Some(AbsolutePathBuf::from_absolute_path(&profile_path)?), + user_config_profile: Some("work".parse().expect("profile name should parse")), + ..LoaderOverrides::default() + }; + + let profile_config = build_debug_sandbox_config_with_loader_overrides( + Vec::new(), + ConfigOverrides::default(), + Some(codex_home_path.clone()), + ManagedRequirementsMode::Include, + loader_overrides.clone(), + /*strict_config*/ false, + ) + .await?; + let read_only_config = build_debug_sandbox_config( + Vec::new(), + ConfigOverrides { + sandbox_mode: Some(SandboxMode::ReadOnly), + ..Default::default() + }, + Some(codex_home_path.clone()), + ManagedRequirementsMode::Include, + /*strict_config*/ false, + ) + .await?; + + let config = load_debug_sandbox_config_with_codex_home( + Vec::new(), + /*codex_linux_sandbox_exe*/ None, + DebugSandboxConfigOptions { + permissions_profile: None, + cwd: None, + managed_requirements_mode: ManagedRequirementsMode::Include, + loader_overrides, + }, + Some(codex_home_path), + /*strict_config*/ false, + ) + .await?; + + assert!(config_uses_permission_profiles(&config)); + assert_ne!( + profile_config.permissions.file_system_sandbox_policy(), + read_only_config.permissions.file_system_sandbox_policy(), + "test fixture should distinguish the profile config from read-only" + ); + assert_eq!( + config.permissions.file_system_sandbox_policy(), + profile_config.permissions.file_system_sandbox_policy(), + ); + + Ok(()) + } + #[tokio::test] async fn debug_sandbox_honors_explicit_legacy_sandbox_mode() -> anyhow::Result<()> { let codex_home = TempDir::new()?; @@ -872,6 +981,7 @@ mod tests { permissions_profile: None, cwd: None, managed_requirements_mode: ManagedRequirementsMode::Include, + loader_overrides: LoaderOverrides::default(), }, Some(codex_home_path), /*strict_config*/ false, @@ -929,6 +1039,7 @@ mod tests { permissions_profile: None, cwd: None, managed_requirements_mode: ManagedRequirementsMode::Include, + loader_overrides: LoaderOverrides::default(), }, Some(codex_home_path), /*strict_config*/ false, @@ -955,6 +1066,7 @@ mod tests { permissions_profile: Some(":workspace".to_string()), cwd: None, managed_requirements_mode: ManagedRequirementsMode::Ignore, + loader_overrides: LoaderOverrides::default(), }, Some(codex_home.path().to_path_buf()), /*strict_config*/ false, @@ -993,6 +1105,7 @@ mod tests { permissions_profile: Some("limited-read-test".to_string()), cwd: None, managed_requirements_mode: ManagedRequirementsMode::Ignore, + loader_overrides: LoaderOverrides::default(), }, Some(codex_home.path().to_path_buf()), /*strict_config*/ false, @@ -1031,6 +1144,7 @@ mod tests { permissions_profile: Some(":workspace".to_string()), cwd: Some(cwd.path().to_path_buf()), managed_requirements_mode: ManagedRequirementsMode::Ignore, + loader_overrides: LoaderOverrides::default(), }, Some(codex_home.path().to_path_buf()), /*strict_config*/ false, diff --git a/codex-rs/cli/src/lib.rs b/codex-rs/cli/src/lib.rs index d9e1efe980ab..5e2ba0caa5b9 100644 --- a/codex-rs/cli/src/lib.rs +++ b/codex-rs/cli/src/lib.rs @@ -5,6 +5,7 @@ pub(crate) mod login; use clap::Parser; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_cli::CliConfigOverrides; +use codex_utils_cli::ProfileV2Name; use std::path::PathBuf; pub use debug_sandbox::run_command_under_landlock; @@ -28,6 +29,10 @@ pub struct SeatbeltCommand { #[arg(long = "permissions-profile", value_name = "NAME")] pub permissions_profile: Option, + /// Layer $CODEX_HOME/.config.toml on top of the base user config. + #[arg(long = "profile", short = 'p')] + pub config_profile: Option, + /// Working directory used for profile resolution and command execution. #[arg( short = 'C', @@ -72,6 +77,10 @@ pub struct LandlockCommand { #[arg(long = "permissions-profile", value_name = "NAME")] pub permissions_profile: Option, + /// Layer $CODEX_HOME/.config.toml on top of the base user config. + #[arg(long = "profile", short = 'p')] + pub config_profile: Option, + /// Working directory used for profile resolution and command execution. #[arg( short = 'C', @@ -103,6 +112,10 @@ pub struct WindowsCommand { #[arg(long = "permissions-profile", value_name = "NAME")] pub permissions_profile: Option, + /// Layer $CODEX_HOME/.config.toml on top of the base user config. + #[arg(long = "profile", short = 'p')] + pub config_profile: Option, + /// Working directory used for profile resolution and command execution. #[arg( short = 'C', diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index aefc022221e4..4bb7ef74a023 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -353,6 +353,10 @@ type HostSandboxArgs = UnsupportedSandboxArgs; #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] #[derive(Debug, Parser)] struct UnsupportedSandboxArgs { + /// Layer $CODEX_HOME/.config.toml on top of the base user config. + #[arg(long = "profile", short = 'p')] + pub config_profile: Option, + #[clap(skip)] pub config_overrides: CliConfigOverrides, @@ -1242,6 +1246,11 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { root_remote_auth_token_env.as_deref(), "sandbox", )?; + let config_profile = sandbox_cli + .config_profile + .as_ref() + .or(interactive.config_profile_v2.as_ref()); + let loader_overrides = loader_overrides_for_profile(config_profile)?; prepend_config_flags( &mut sandbox_cli.config_overrides, root_config_overrides.clone(), @@ -1250,22 +1259,28 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { codex_cli::run_command_under_seatbelt( sandbox_cli, arg0_paths.codex_linux_sandbox_exe.clone(), + loader_overrides, ) .await?; #[cfg(target_os = "linux")] codex_cli::run_command_under_landlock( sandbox_cli, arg0_paths.codex_linux_sandbox_exe.clone(), + loader_overrides, ) .await?; #[cfg(target_os = "windows")] codex_cli::run_command_under_windows_sandbox( sandbox_cli, arg0_paths.codex_linux_sandbox_exe.clone(), + loader_overrides, ) .await?; #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] - anyhow::bail!("`codex sandbox` is not supported on this operating system"); + { + let _ = loader_overrides; + anyhow::bail!("`codex sandbox` is not supported on this operating system"); + } } Some(Subcommand::Debug(DebugCommand { subcommand })) => match subcommand { DebugSubcommand::Models(cmd) => { @@ -1441,11 +1456,12 @@ fn profile_v2_for_subcommand<'a>( | Subcommand::Resume(_) | Subcommand::Fork(_) | Subcommand::Mcp(_) + | Subcommand::Sandbox(_) | Subcommand::Debug(DebugCommand { subcommand: DebugSubcommand::PromptInput(_), }) => Ok(Some(profile_v2)), _ => anyhow::bail!( - "--profile only applies to runtime commands and `codex mcp`: `codex`, `codex exec`, `codex review`, `codex resume`, `codex fork`, `codex mcp`, and `codex debug prompt-input`." + "--profile only applies to runtime commands and `codex mcp`: `codex`, `codex exec`, `codex review`, `codex resume`, `codex fork`, `codex mcp`, `codex sandbox`, and `codex debug prompt-input`." ), } } @@ -2252,6 +2268,12 @@ mod tests { .as_deref(), Some("work") ); + assert_eq!( + profile_v2_for_args(&["codex", "--profile", "work", "sandbox"]) + .expect("sandbox supports config profile") + .as_deref(), + Some("work") + ); } #[test] @@ -2500,6 +2522,21 @@ mod tests { assert_eq!(command.command, vec!["echo"]); } + #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] + #[test] + fn sandbox_parses_config_profile() { + let cli = + MultitoolCli::try_parse_from(["codex", "sandbox", "--profile", "work", "--", "echo"]) + .expect("parse"); + + let Some(Subcommand::Sandbox(command)) = cli.subcommand else { + panic!("expected sandbox command"); + }; + + assert_eq!(command.config_profile.as_deref(), Some("work")); + assert_eq!(command.command, vec!["echo"]); + } + #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] #[test] fn sandbox_rejects_explicit_profile_controls_without_profile() { From 3c83e57bfa88a071fcf88df8da9ab9e32eb7a814 Mon Sep 17 00:00:00 2001 From: mchen-oai Date: Fri, 22 May 2026 13:10:56 -0700 Subject: [PATCH 48/64] Add trace_id to TurnStartedEvent (#23980) ## Why [Recent PR](https://github.com/openai/codex/pull/22709) removed `trace_id` from `TurnContextItem`. ## What changed - Add to `TurnStartedEvent` so rollout consumers can correlate turns with telemetry traces. - Note that the branch name is out of date because I originally re-added to `TurnContextItem`, but we decided to move it to `TurnStartedEvent`. ## Verification - `cargo test -p codex-protocol` - `cargo test -p codex-core --lib regular_turn_emits_turn_started_without_waiting_for_startup_prewarm` - `cargo test -p codex-core --test all emits_warning_when_resumed_model_differs` - `cargo test -p codex-rollout` - `cargo test -p codex-state` --- .../src/protocol/thread_history.rs | 24 ++++++++++++ .../app-server/src/bespoke_event_handling.rs | 3 ++ .../thread_processor_tests.rs | 1 + .../tests/suite/v2/thread_resume.rs | 3 ++ codex-rs/core/src/agent/control_tests.rs | 1 + codex-rs/core/src/compact.rs | 1 + codex-rs/core/src/compact_remote.rs | 1 + codex-rs/core/src/compact_remote_v2.rs | 1 + .../session/rollout_reconstruction_tests.rs | 27 +++++++++++++ codex-rs/core/src/session/tests.rs | 38 ++++++++++++++++--- codex-rs/core/src/tasks/regular.rs | 1 + codex-rs/core/src/tasks/user_shell.rs | 1 + codex-rs/core/src/thread_manager_tests.rs | 2 + codex-rs/core/tests/suite/resume_warning.rs | 1 + .../external-agent-sessions/src/export.rs | 1 + codex-rs/protocol/src/protocol.rs | 4 ++ 16 files changed, 104 insertions(+), 6 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index 0fbb93f39757..c0c7d5552839 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -1393,6 +1393,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -1471,6 +1472,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-image".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -1806,6 +1808,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -1866,6 +1869,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -1978,6 +1982,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -2043,6 +2048,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -2109,6 +2115,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -2199,6 +2206,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -2283,6 +2291,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -2347,6 +2356,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -2367,6 +2377,7 @@ mod tests { }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -2441,6 +2452,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -2461,6 +2473,7 @@ mod tests { }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -2530,6 +2543,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -2595,6 +2609,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -2660,6 +2675,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -2680,6 +2696,7 @@ mod tests { }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -2728,6 +2745,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -2748,6 +2766,7 @@ mod tests { }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -2789,6 +2808,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-compact".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -3042,6 +3062,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -3098,6 +3119,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -3156,6 +3178,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -3204,6 +3227,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index efe6c69a32e9..5f784ea23a29 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -3219,6 +3219,7 @@ mod tests { "turn-1", &EventMsg::TurnStarted(codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + trace_id: None, started_at: Some(42), model_context_window: None, collaboration_mode_kind: Default::default(), @@ -3252,6 +3253,7 @@ mod tests { id: "turn-1".to_string(), msg: EventMsg::TurnStarted(codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + trace_id: None, started_at: Some(42), model_context_window: None, collaboration_mode_kind: Default::default(), @@ -3301,6 +3303,7 @@ mod tests { &event_turn_id, &EventMsg::TurnStarted(codex_protocol::protocol::TurnStartedEvent { turn_id: event_turn_id.clone(), + trace_id: None, started_at: Some(42), model_context_window: None, collaboration_mode_kind: Default::default(), diff --git a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs index f4593ab7c43d..c6b5541aa2d6 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs @@ -1155,6 +1155,7 @@ mod thread_processor_behavior_tests { "turn-1", &EventMsg::TurnStarted(codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index f21a973f63fd..6416499663aa 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -1431,6 +1431,7 @@ async fn thread_resume_token_usage_replay_ignores_stale_interrupted_tail_turn() "type": "event_msg", "payload": serde_json::to_value(EventMsg::TurnStarted(TurnStartedEvent { turn_id: stale_turn_id.to_string(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -1518,6 +1519,7 @@ async fn thread_resume_token_usage_replay_can_belong_to_interrupted_turn() -> Re "type": "event_msg", "payload": serde_json::to_value(EventMsg::TurnStarted(TurnStartedEvent { turn_id: interrupted_turn_id.to_string(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -1835,6 +1837,7 @@ async fn thread_resume_and_read_interrupt_incomplete_rollout_turn_when_thread_is "type": "event_msg", "payload": serde_json::to_value(EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 54114f05d127..ed39df1548a7 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -271,6 +271,7 @@ async fn get_status_returns_not_found_without_manager() { async fn on_event_updates_status_from_task_started() { let status = agent_status_from_event(&EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index b5802268d349..f2bded8d0652 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -100,6 +100,7 @@ pub(crate) async fn run_compact_task( ) -> CodexResult<()> { let start_event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + trace_id: turn_context.trace_id.clone(), started_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 30d1e5f0e841..969da4754479 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -62,6 +62,7 @@ pub(crate) async fn run_remote_compact_task( ) -> CodexResult<()> { let start_event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + trace_id: turn_context.trace_id.clone(), started_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index 251d37b35c1f..37e44021bc20 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -78,6 +78,7 @@ pub(crate) async fn run_remote_compact_task( ) -> CodexResult<()> { let start_event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + trace_id: turn_context.trace_id.clone(), started_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, diff --git a/codex-rs/core/src/session/rollout_reconstruction_tests.rs b/codex-rs/core/src/session/rollout_reconstruction_tests.rs index fc2811710ab4..11b8651ae6b9 100644 --- a/codex-rs/core/src/session/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/session/rollout_reconstruction_tests.rs @@ -122,6 +122,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -189,6 +190,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -218,6 +220,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -289,6 +292,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -318,6 +322,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -381,6 +386,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -410,6 +416,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: second_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -438,6 +445,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: standalone_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -501,6 +509,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -530,6 +539,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: assistant_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -590,6 +600,7 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: only_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -643,6 +654,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: user_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -671,6 +683,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: standalone_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -716,6 +729,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -743,6 +757,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -875,6 +890,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -939,6 +955,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -1043,6 +1060,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -1070,6 +1088,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: aborted_turn_id, + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -1151,6 +1170,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -1178,6 +1198,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -1268,6 +1289,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -1295,6 +1317,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -1346,6 +1369,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_preserves_turn_ RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id, + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -1420,6 +1444,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -1447,6 +1472,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: compacted_incomplete_turn_id, + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -1470,6 +1496,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: replacing_turn_id, + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index d4d0c286608f..df7559a7d97f 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -267,8 +267,24 @@ fn skill_message(text: &str) -> ResponseItem { } #[tokio::test] -async fn regular_turn_emits_turn_started_without_waiting_for_startup_prewarm() { - let (sess, tc, rx) = make_session_and_context_with_rx().await; +async fn regular_turn_emits_turn_started_with_trace_id_without_waiting_for_startup_prewarm() { + let _trace_test_context = install_test_tracing("codex-core-tests"); + let request_parent = W3cTraceContext { + traceparent: Some("00-00000000000000000000000000000011-0000000000000022-01".into()), + tracestate: Some("vendor=value".into()), + }; + let request_span = info_span!("app_server.request"); + assert!(set_parent_from_w3c_trace_context( + &request_span, + &request_parent + )); + let (sess, tc, rx) = make_session_and_context_with_rx() + .instrument(request_span) + .await; + assert_eq!( + tc.trace_id.as_deref(), + Some("00000000000000000000000000000011") + ); let (_tx, startup_prewarm_rx) = tokio::sync::oneshot::channel::<()>(); let handle = tokio::spawn(async move { let _ = startup_prewarm_rx.await; @@ -294,10 +310,11 @@ async fn regular_turn_emits_turn_started_without_waiting_for_startup_prewarm() { .await .expect("expected turn started event without waiting for startup prewarm") .expect("channel open"); - assert!(matches!( - first.msg, - EventMsg::TurnStarted(TurnStartedEvent { turn_id, .. }) if turn_id == tc.sub_id - )); + let EventMsg::TurnStarted(turn_started) = first.msg else { + panic!("expected turn started event"); + }; + assert_eq!(turn_started.turn_id, tc.sub_id); + assert_eq!(turn_started.trace_id, tc.trace_id); sess.abort_all_tasks(TurnAbortReason::Interrupted).await; } @@ -2378,6 +2395,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -2573,6 +2591,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -2600,6 +2619,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -2684,6 +2704,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -2709,6 +2730,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: compact_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -2728,6 +2750,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -2783,6 +2806,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -2808,6 +2832,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-2".to_string(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -2833,6 +2858,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-3".to_string(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, diff --git a/codex-rs/core/src/tasks/regular.rs b/codex-rs/core/src/tasks/regular.rs index 50414df2787b..531d5d7da791 100644 --- a/codex-rs/core/src/tasks/regular.rs +++ b/codex-rs/core/src/tasks/regular.rs @@ -51,6 +51,7 @@ impl SessionTask for RegularTask { // not wait on startup prewarm resolution. let event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: ctx.sub_id.clone(), + trace_id: ctx.trace_id.clone(), started_at: ctx.turn_timing_state.started_at_unix_secs().await, model_context_window: ctx.model_context_window(), collaboration_mode_kind: ctx.collaboration_mode.mode, diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 23f3882edaf5..2ce4056957ee 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -114,6 +114,7 @@ pub(crate) async fn execute_user_shell_command( // freshly reinjected context before the summary/replacement history is applied. let event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + trace_id: turn_context.trace_id.clone(), started_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 255a8336b9bf..c79a6859d790 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -187,6 +187,7 @@ fn out_of_range_truncation_drops_pre_user_active_turn_prefix() { RolloutItem::ResponseItem(assistant_msg("a1")), RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-2".to_string(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -1307,6 +1308,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { InitialHistory::Forked(vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-explicit".to_string(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index d2bdcd1d3203..51242ede2afd 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -50,6 +50,7 @@ fn resume_history( history: vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.clone(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, diff --git a/codex-rs/external-agent-sessions/src/export.rs b/codex-rs/external-agent-sessions/src/export.rs index 3682a4f7f816..01220c07c178 100644 --- a/codex-rs/external-agent-sessions/src/export.rs +++ b/codex-rs/external-agent-sessions/src/export.rs @@ -67,6 +67,7 @@ fn rollout_items_from_messages(messages: &[ConversationMessage]) -> Vec, /// Unix timestamp (in seconds) when the turn started. #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(type = "number | null", optional)] From 6963145cb617cd77dc4c24e4dbbfb8b29f6cfbe0 Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Fri, 22 May 2026 13:21:01 -0700 Subject: [PATCH 49/64] Support OAuth options in codex mcp add (#24120) ## Summary - add `--oauth-client-id` and `--oauth-resource` options for streamable HTTP `codex mcp add` registrations - persist those options in MCP server config and use them during the immediate OAuth login flow - cover add-time serialization of both OAuth options in the CLI integration tests ## Testing - `just fmt` - `cargo test -p codex-cli` - `just fix -p codex-cli` --- codex-rs/cli/src/mcp_cmd.rs | 59 +++++++++++++++++++--------- codex-rs/cli/tests/mcp_add_remove.rs | 36 +++++++++++++++++ 2 files changed, 77 insertions(+), 18 deletions(-) diff --git a/codex-rs/cli/src/mcp_cmd.rs b/codex-rs/cli/src/mcp_cmd.rs index 3104262c23fd..a84d1c90198f 100644 --- a/codex-rs/cli/src/mcp_cmd.rs +++ b/codex-rs/cli/src/mcp_cmd.rs @@ -8,6 +8,7 @@ use anyhow::bail; use clap::ArgGroup; use codex_config::types::AppToolApproval; use codex_config::types::McpServerConfig; +use codex_config::types::McpServerOAuthConfig; use codex_config::types::McpServerTransportConfig; use codex_core::McpManager; use codex_core::config::Config; @@ -134,6 +135,14 @@ pub struct AddMcpStreamableHttpArgs { requires = "url" )] pub bearer_token_env_var: Option, + + /// Optional OAuth client identifier to use for this MCP server. + #[arg(long = "oauth-client-id", value_name = "CLIENT_ID", requires = "url")] + pub oauth_client_id: Option, + + /// Optional OAuth resource parameter to include during MCP login. + #[arg(long = "oauth-resource", value_name = "RESOURCE", requires = "url")] + pub oauth_resource: Option, } #[derive(Debug, clap::Parser)] @@ -282,7 +291,7 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re .await .with_context(|| format!("failed to load MCP servers from {}", codex_home.display()))?; - let transport = match transport_args { + let (transport, oauth_client_id, oauth_resource) = match transport_args { AddMcpTransportArgs { stdio: Some(stdio), .. } => { @@ -297,27 +306,37 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re } else { Some(stdio.env.into_iter().collect::>()) }; - McpServerTransportConfig::Stdio { - command: command_bin, - args: command_args, - env: env_map, - env_vars: Vec::new(), - cwd: None, - } + ( + McpServerTransportConfig::Stdio { + command: command_bin, + args: command_args, + env: env_map, + env_vars: Vec::new(), + cwd: None, + }, + None, + None, + ) } AddMcpTransportArgs { streamable_http: Some(AddMcpStreamableHttpArgs { url, bearer_token_env_var, + oauth_client_id, + oauth_resource, }), .. - } => McpServerTransportConfig::StreamableHttp { - url, - bearer_token_env_var, - http_headers: None, - env_http_headers: None, - }, + } => ( + McpServerTransportConfig::StreamableHttp { + url, + bearer_token_env_var, + http_headers: None, + env_http_headers: None, + }, + oauth_client_id, + oauth_resource, + ), AddMcpTransportArgs { .. } => bail!("exactly one of --command or --url must be provided"), }; @@ -334,8 +353,12 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re enabled_tools: None, disabled_tools: None, scopes: None, - oauth: None, - oauth_resource: None, + oauth: oauth_client_id + .clone() + .map(|client_id| McpServerOAuthConfig { + client_id: Some(client_id), + }), + oauth_resource: oauth_resource.clone(), tools: HashMap::new(), }; @@ -364,8 +387,8 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re oauth_config.http_headers, oauth_config.env_http_headers, &resolved_scopes, - /*oauth_client_id*/ None, - /*oauth_resource*/ None, + oauth_client_id.as_deref(), + oauth_resource.as_deref(), config.mcp_oauth_callback_port, config.mcp_oauth_callback_url.as_deref(), ) diff --git a/codex-rs/cli/tests/mcp_add_remove.rs b/codex-rs/cli/tests/mcp_add_remove.rs index ecfbed1264d5..d0fc5f327db2 100644 --- a/codex-rs/cli/tests/mcp_add_remove.rs +++ b/codex-rs/cli/tests/mcp_add_remove.rs @@ -198,6 +198,42 @@ async fn add_streamable_http_with_custom_env_var() -> Result<()> { Ok(()) } +#[tokio::test] +async fn add_streamable_http_with_oauth_options() -> Result<()> { + let codex_home = TempDir::new()?; + + let mut add_cmd = codex_command(codex_home.path())?; + add_cmd + .args([ + "mcp", + "add", + "oauth-server", + "--url", + "https://example.com/mcp", + "--oauth-client-id", + "eci-prd-pub-codex-123", + "--oauth-resource", + "https://resource.example.com", + ]) + .assert() + .success(); + + let servers = load_global_mcp_servers(codex_home.path()).await?; + let oauth_server = servers + .get("oauth-server") + .expect("oauth server should exist"); + assert_eq!( + oauth_server.oauth_client_id(), + Some("eci-prd-pub-codex-123") + ); + assert_eq!( + oauth_server.oauth_resource.as_deref(), + Some("https://resource.example.com") + ); + + Ok(()) +} + #[tokio::test] async fn add_streamable_http_rejects_removed_flag() -> Result<()> { let codex_home = TempDir::new()?; From 811db957e7e52556dbeaa25d54caa715ea9b1b4f Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Fri, 22 May 2026 13:41:30 -0700 Subject: [PATCH 50/64] Release 0.134.0-alpha.2 --- codex-rs/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index df169504f081..daa3d1fdcf39 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -117,7 +117,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.0.0" +version = "0.134.0-alpha.2" # Track the edition for all workspace crates in one place. Individual # crates can still override this value, but keeping it here means new # crates created with `cargo new -w ...` automatically inherit the 2024 From 423488480fa8b4502d1813f5fefe2292dd0f6bf7 Mon Sep 17 00:00:00 2001 From: Won Park Date: Fri, 22 May 2026 14:10:55 -0700 Subject: [PATCH 51/64] Add typed Images client to codex-api (#23989) ## Why Standalone image generation needs a typed `codex-api` client surface for the Codex image proxy routes before the harness and model-facing tool layers are wired in. ## What changed - Added `ImagesClient` support for JSON `images/generations` and `images/edits` requests. - Added typed request and response shapes for generation, JSON edit image URLs, image metadata, and base64 image outputs. - Kept generation model slugs open-ended while requiring the generation model field that the downstream endpoint expects. - Exported the new client and image types from `codex-api`. - Added coverage for generation and edit wire shapes, extra response metadata that the client ignores, and malformed image responses missing `data`. ## Validation - `cargo test -p codex-api` - `just fix -p codex-api` - `just fmt` - `git diff --check main` --- codex-rs/codex-api/src/endpoint/images.rs | 302 ++++++++++++++++++++++ codex-rs/codex-api/src/endpoint/mod.rs | 2 + codex-rs/codex-api/src/images.rs | 70 +++++ codex-rs/codex-api/src/lib.rs | 9 + 4 files changed, 383 insertions(+) create mode 100644 codex-rs/codex-api/src/endpoint/images.rs create mode 100644 codex-rs/codex-api/src/images.rs diff --git a/codex-rs/codex-api/src/endpoint/images.rs b/codex-rs/codex-api/src/endpoint/images.rs new file mode 100644 index 000000000000..9d1bd41eea3c --- /dev/null +++ b/codex-rs/codex-api/src/endpoint/images.rs @@ -0,0 +1,302 @@ +use crate::auth::SharedAuthProvider; +use crate::endpoint::session::EndpointSession; +use crate::error::ApiError; +use crate::images::ImageEditRequest; +use crate::images::ImageGenerationRequest; +use crate::images::ImageResponse; +use crate::provider::Provider; +use codex_client::HttpTransport; +use codex_client::RequestTelemetry; +use http::HeaderMap; +use http::Method; +use serde::Serialize; +use serde_json::to_value; +use std::sync::Arc; + +pub struct ImagesClient { + session: EndpointSession, +} + +impl ImagesClient { + pub fn new(transport: T, provider: Provider, auth: SharedAuthProvider) -> Self { + Self { + session: EndpointSession::new(transport, provider, auth), + } + } + + pub fn with_telemetry(self, request: Option>) -> Self { + Self { + session: self.session.with_request_telemetry(request), + } + } + + pub async fn generate( + &self, + request: &ImageGenerationRequest, + extra_headers: HeaderMap, + ) -> Result { + self.post_image_request( + "images/generations", + request, + extra_headers, + "image generation", + ) + .await + } + + pub async fn edit( + &self, + request: &ImageEditRequest, + extra_headers: HeaderMap, + ) -> Result { + self.post_image_request("images/edits", request, extra_headers, "image edit") + .await + } + + async fn post_image_request( + &self, + path: &str, + request: &R, + extra_headers: HeaderMap, + operation: &str, + ) -> Result { + let body = to_value(request) + .map_err(|e| ApiError::Stream(format!("failed to encode {operation} request: {e}")))?; + let resp = self + .session + .execute(Method::POST, path, extra_headers, Some(body)) + .await?; + serde_json::from_slice(&resp.body) + .map_err(|e| ApiError::Stream(format!("failed to decode {operation} response: {e}"))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::AuthProvider; + use crate::images::ImageBackground; + use crate::images::ImageData; + use crate::images::ImageQuality; + use crate::images::ImageUrl; + use crate::provider::RetryConfig; + use async_trait::async_trait; + use codex_client::Request; + use codex_client::RequestBody; + use codex_client::Response; + use codex_client::StreamResponse; + use codex_client::TransportError; + use http::StatusCode; + use pretty_assertions::assert_eq; + use serde_json::json; + use std::sync::Mutex; + use std::time::Duration; + + #[derive(Clone, Default)] + struct DummyAuth; + + impl AuthProvider for DummyAuth { + fn add_auth_headers(&self, _headers: &mut HeaderMap) {} + } + + #[derive(Clone)] + struct CapturingTransport { + last_request: Arc>>, + response_body: Arc>, + } + + impl CapturingTransport { + fn new(response_body: Vec) -> Self { + Self { + last_request: Arc::new(Mutex::new(None)), + response_body: Arc::new(response_body), + } + } + } + + #[async_trait] + impl HttpTransport for CapturingTransport { + async fn execute(&self, req: Request) -> Result { + *self.last_request.lock().expect("lock request store") = Some(req); + Ok(Response { + status: StatusCode::OK, + headers: HeaderMap::new(), + body: self.response_body.as_ref().clone().into(), + }) + } + + async fn stream(&self, _req: Request) -> Result { + Err(TransportError::Build("stream should not run".to_string())) + } + } + + fn provider() -> Provider { + Provider { + name: "test".to_string(), + base_url: "https://example.com/api/codex".to_string(), + query_params: None, + headers: HeaderMap::new(), + retry: RetryConfig { + max_attempts: 1, + base_delay: Duration::from_millis(1), + retry_429: false, + retry_5xx: true, + retry_transport: true, + }, + stream_idle_timeout: Duration::from_secs(1), + } + } + + fn response_body() -> Vec { + serde_json::to_vec(&json!({ + "created": 1778832973u64, + "background": "opaque", + "data": [{"b64_json": "REDACT"}], + "output_format": "png", + "quality": "medium", + "size": "1024x1536", + "usage": { + "input_tokens": 1474, + "input_tokens_details": { + "image_tokens": 1457, + "text_tokens": 17, + }, + "output_tokens": 1372, + "output_tokens_details": { + "image_tokens": 1372, + "text_tokens": 0, + }, + "total_tokens": 2846, + } + })) + .expect("serialize response") + } + + fn expected_response() -> ImageResponse { + ImageResponse { + created: 1778832973, + background: Some(ImageBackground::Opaque), + data: vec![ImageData { + b64_json: "REDACT".to_string(), + }], + quality: Some(ImageQuality::Medium), + size: Some("1024x1536".to_string()), + } + } + + fn captured_request(transport: &CapturingTransport) -> Request { + transport + .last_request + .lock() + .expect("lock request store") + .clone() + .expect("request should be captured") + } + + #[tokio::test] + async fn generate_posts_typed_request_and_parses_image_response() { + let transport = CapturingTransport::new(response_body()); + let client = ImagesClient::new(transport.clone(), provider(), Arc::new(DummyAuth)); + + let response = client + .generate( + &ImageGenerationRequest { + prompt: "a red fox in a field".to_string(), + background: Some(ImageBackground::Opaque), + model: "gpt-image-1.5".to_string(), + n: None, + quality: Some(ImageQuality::Medium), + size: Some("1024x1536".to_string()), + }, + HeaderMap::new(), + ) + .await + .expect("image generation request should succeed"); + + assert_eq!(response, expected_response()); + + let request = captured_request(&transport); + assert_eq!( + request.url, + "https://example.com/api/codex/images/generations" + ); + assert_eq!( + request.body.as_ref().and_then(RequestBody::json), + Some(&json!({ + "prompt": "a red fox in a field", + "background": "opaque", + "model": "gpt-image-1.5", + "quality": "medium", + "size": "1024x1536", + })) + ); + } + + #[tokio::test] + async fn edit_posts_typed_request_and_parses_image_response() { + let transport = CapturingTransport::new(response_body()); + let client = ImagesClient::new(transport.clone(), provider(), Arc::new(DummyAuth)); + + let response = client + .edit( + &ImageEditRequest { + images: vec![ImageUrl { + image_url: "data:image/png;base64,Zm9v".to_string(), + }], + prompt: "add a red hat".to_string(), + background: None, + model: "gpt-image-1.5".to_string(), + n: None, + quality: None, + size: None, + }, + HeaderMap::new(), + ) + .await + .expect("image edit request should succeed"); + + assert_eq!(response, expected_response()); + + let request = captured_request(&transport); + assert_eq!(request.url, "https://example.com/api/codex/images/edits"); + assert_eq!( + request.body.as_ref().and_then(RequestBody::json), + Some(&json!({ + "images": [{"image_url": "data:image/png;base64,Zm9v"}], + "prompt": "add a red hat", + "model": "gpt-image-1.5", + })) + ); + } + + #[tokio::test] + async fn image_response_requires_image_data() { + let transport = CapturingTransport::new( + serde_json::to_vec(&json!({"created": 1778832973u64})).expect("serialize response"), + ); + let client = ImagesClient::new(transport, provider(), Arc::new(DummyAuth)); + + let error = client + .generate( + &ImageGenerationRequest { + prompt: "a red fox in a field".to_string(), + background: None, + model: "gpt-image-1.5".to_string(), + n: None, + quality: None, + size: None, + }, + HeaderMap::new(), + ) + .await + .expect_err("image response without data should fail"); + + let ApiError::Stream(message) = error else { + panic!("expected image response decode error"); + }; + assert!( + message.starts_with("failed to decode image generation response: missing field `data`"), + "{message}" + ); + } +} diff --git a/codex-rs/codex-api/src/endpoint/mod.rs b/codex-rs/codex-api/src/endpoint/mod.rs index 21ebf372a174..106c5d73ff2e 100644 --- a/codex-rs/codex-api/src/endpoint/mod.rs +++ b/codex-rs/codex-api/src/endpoint/mod.rs @@ -1,4 +1,5 @@ pub(crate) mod compact; +pub(crate) mod images; pub(crate) mod memories; pub(crate) mod models; pub(crate) mod realtime_call; @@ -9,6 +10,7 @@ pub(crate) mod search; mod session; pub use compact::CompactClient; +pub use images::ImagesClient; pub use memories::MemoriesClient; pub use models::ModelsClient; pub use realtime_call::RealtimeCallClient; diff --git a/codex-rs/codex-api/src/images.rs b/codex-rs/codex-api/src/images.rs new file mode 100644 index 000000000000..f915a5f78d22 --- /dev/null +++ b/codex-rs/codex-api/src/images.rs @@ -0,0 +1,70 @@ +use serde::Deserialize; +use serde::Serialize; + +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct ImageGenerationRequest { + pub prompt: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub background: Option, + pub model: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub n: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub quality: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub size: Option, +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct ImageEditRequest { + pub images: Vec, + pub prompt: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub background: Option, + pub model: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub n: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub quality: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub size: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ImageUrl { + pub image_url: String, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ImageBackground { + Transparent, + Opaque, + Auto, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ImageQuality { + Low, + Medium, + High, + Auto, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct ImageResponse { + pub created: u64, + pub data: Vec, + #[serde(default)] + pub background: Option, + #[serde(default)] + pub quality: Option, + #[serde(default)] + pub size: Option, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct ImageData { + pub b64_json: String, +} diff --git a/codex-rs/codex-api/src/lib.rs b/codex-rs/codex-api/src/lib.rs index f47791b799b2..08176d8dec65 100644 --- a/codex-rs/codex-api/src/lib.rs +++ b/codex-rs/codex-api/src/lib.rs @@ -4,6 +4,7 @@ pub(crate) mod common; pub(crate) mod endpoint; pub(crate) mod error; pub(crate) mod files; +pub(crate) mod images; pub(crate) mod provider; pub(crate) mod rate_limits; pub(crate) mod requests; @@ -41,6 +42,7 @@ pub use crate::common::WS_REQUEST_HEADER_TRACESTATE_CLIENT_METADATA_KEY; pub use crate::common::create_text_param_for_request; pub use crate::common::response_create_client_metadata; pub use crate::endpoint::CompactClient; +pub use crate::endpoint::ImagesClient; pub use crate::endpoint::MemoriesClient; pub use crate::endpoint::ModelsClient; pub use crate::endpoint::RealtimeCallClient; @@ -63,6 +65,13 @@ pub use crate::endpoint::SearchClient; pub use crate::endpoint::session_update_session_json; pub use crate::error::ApiError; pub use crate::files::upload_local_file; +pub use crate::images::ImageBackground; +pub use crate::images::ImageData; +pub use crate::images::ImageEditRequest; +pub use crate::images::ImageGenerationRequest; +pub use crate::images::ImageQuality; +pub use crate::images::ImageResponse; +pub use crate::images::ImageUrl; pub use crate::provider::Provider; pub use crate::provider::RetryConfig; pub use crate::provider::is_azure_responses_provider; From 6419402a7c3ae679dc0f7f8ea8e196fac1a0d277 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 22 May 2026 14:34:22 -0700 Subject: [PATCH 52/64] [codex-analytics] split compaction v2 analytics implementation (#24146) ## What changed - Add a distinct `responses_compaction_v2` value for `CodexCompactionEvent.implementation`. - Emit that value from the remote compaction v2 path. - Keep local compaction as `responses` and legacy `/responses/compact` as `responses_compact`. ## Why Remote compaction v2 and local prompt-based compaction were both reported as `responses`, which made the analytics table collapse two different compaction mechanisms into one implementation bucket. ## Validation - `just fmt` - `just test -p codex-analytics` `just test -p codex-core` was started locally, but this PR is intentionally being pushed for CI to finish the remaining validation. --- codex-rs/analytics/src/analytics_client_tests.rs | 8 ++++++++ codex-rs/analytics/src/facts.rs | 1 + codex-rs/core/src/compact_remote_v2.rs | 2 +- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index ef5051cabaf2..cabea808865d 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -1262,6 +1262,14 @@ fn compaction_event_serializes_expected_shape() { ); } +#[test] +fn compaction_implementation_serializes_remote_v2() { + let payload = serde_json::to_value(CompactionImplementation::ResponsesCompactionV2) + .expect("serialize compaction implementation"); + + assert_eq!(payload, json!("responses_compaction_v2")); +} + #[test] fn app_used_dedupe_is_keyed_by_turn_and_connector() { let (sender, _receiver) = mpsc::channel(1); diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index d7e2c069d6c2..56bd0a5d2c3e 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -229,6 +229,7 @@ pub enum CompactionReason { #[serde(rename_all = "snake_case")] pub enum CompactionImplementation { Responses, + ResponsesCompactionV2, ResponsesCompact, } diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index 37e44021bc20..0e235a941f57 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -111,7 +111,7 @@ async fn run_remote_compact_task_inner( turn_context.as_ref(), trigger, reason, - CompactionImplementation::Responses, + CompactionImplementation::ResponsesCompactionV2, phase, ) .await; From 195ba3eb881d38288166f910134df9a13d658d89 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Fri, 22 May 2026 14:38:44 -0700 Subject: [PATCH 53/64] package: factor DotSlash executable fetching (#24129) ## Why The package builder already fetches `rg` from a checked-in DotSlash manifest. The zsh packaging work needs the same fetch/cache/size-check/SHA-256/extract path for another manifest, but keeping that refactor inside the zsh PR makes the review harder to follow. This PR factors the existing `rg`-specific implementation into a reusable helper with no intended behavior change for `rg` packaging. ## What Changed - Added `scripts/codex_package/dotslash.py` for checked-in DotSlash manifest parsing, archive download, cache reuse, size validation, SHA-256 validation, and member extraction. - Updated `scripts/codex_package/ripgrep.py` to delegate to the shared helper. - Preserved the existing `rg` manifest path, cache key, destination filename, and executable-bit behavior. ## Testing - `python3 -m py_compile scripts/codex_package/dotslash.py scripts/codex_package/ripgrep.py scripts/codex_package/cli.py scripts/codex_package/layout.py scripts/codex_package/zsh.py` - `python3 -m unittest discover scripts/codex_package` --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/24129). * #23768 * #23756 * __->__ #24129 --- scripts/codex_package/dotslash.py | 222 ++++++++++++++++++++++++++++++ scripts/codex_package/ripgrep.py | 180 ++---------------------- 2 files changed, 232 insertions(+), 170 deletions(-) create mode 100644 scripts/codex_package/dotslash.py diff --git a/scripts/codex_package/dotslash.py b/scripts/codex_package/dotslash.py new file mode 100644 index 000000000000..9252ae29f1ab --- /dev/null +++ b/scripts/codex_package/dotslash.py @@ -0,0 +1,222 @@ +"""Fetch executable artifacts from checked-in DotSlash manifests.""" + +import hashlib +import json +import shutil +import stat +import tarfile +import tempfile +import zipfile +from dataclasses import dataclass +from pathlib import Path +from urllib.parse import urlparse +from urllib.request import urlopen + +from .targets import TargetSpec + + +DOWNLOAD_TIMEOUT_SECS = 60 + + +@dataclass(frozen=True) +class DotSlashArtifact: + size: int + digest: str + archive_format: str + archive_member: str + url: str + + +def fetch_dotslash_executable( + spec: TargetSpec, + *, + manifest_path: Path, + artifact_label: str, + cache_key: str, + dest_name: str, + missing_ok: bool = False, +) -> Path | None: + artifact = artifact_for_target( + spec, + manifest_path, + artifact_label=artifact_label, + missing_ok=missing_ok, + ) + if artifact is None: + return None + + cache_dir = default_cache_root() / cache_key + archive_path = cache_dir / archive_filename(artifact.url) + + if not archive_is_valid(archive_path, artifact, artifact_label): + download_archive(artifact.url, archive_path) + try: + verify_archive(archive_path, artifact, artifact_label) + except RuntimeError: + archive_path.unlink(missing_ok=True) + raise + + dest = cache_dir / dest_name + extract_archive_member(archive_path, artifact, dest, artifact_label) + if not spec.is_windows: + mode = dest.stat().st_mode + dest.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + return dest + + +def artifact_for_target( + spec: TargetSpec, + manifest_path: Path, + *, + artifact_label: str, + missing_ok: bool = False, +) -> DotSlashArtifact | None: + manifest = load_manifest(manifest_path) + platform_info = manifest.get("platforms", {}).get(spec.dotslash_platform) + if platform_info is None: + if missing_ok: + return None + raise RuntimeError( + f"{artifact_label} manifest {manifest_path} is missing platform " + f"{spec.dotslash_platform!r}" + ) + + providers = platform_info.get("providers") + if not providers: + raise RuntimeError( + f"{artifact_label} manifest {manifest_path} has no providers for " + f"{spec.dotslash_platform!r}" + ) + + hash_name = platform_info.get("hash") + if hash_name != "sha256": + raise RuntimeError( + f"Unsupported {artifact_label} hash {hash_name!r} for " + f"{spec.dotslash_platform!r}; expected sha256" + ) + + return DotSlashArtifact( + size=int(platform_info["size"]), + digest=str(platform_info["digest"]), + archive_format=str(platform_info["format"]), + archive_member=str(platform_info["path"]), + url=str(providers[0]["url"]), + ) + + +def load_manifest(manifest_path: Path) -> dict: + text = manifest_path.read_text(encoding="utf-8") + if text.startswith("#!"): + text = "\n".join(text.splitlines()[1:]) + return json.loads(text) + + +def default_cache_root() -> Path: + return Path(tempfile.gettempdir()) / "codex-package" + + +def archive_filename(url: str) -> str: + filename = Path(urlparse(url).path).name + if not filename: + raise RuntimeError(f"Unable to determine archive filename from {url}") + return filename + + +def archive_is_valid( + archive_path: Path, + artifact: DotSlashArtifact, + artifact_label: str, +) -> bool: + if not archive_path.is_file(): + return False + try: + verify_archive(archive_path, artifact, artifact_label) + except RuntimeError: + archive_path.unlink(missing_ok=True) + return False + return True + + +def verify_archive( + archive_path: Path, + artifact: DotSlashArtifact, + artifact_label: str, +) -> None: + actual_size = archive_path.stat().st_size + if actual_size != artifact.size: + raise RuntimeError( + f"{artifact_label} archive {archive_path} has size {actual_size}, " + f"expected {artifact.size}" + ) + + digest = hashlib.sha256() + with open(archive_path, "rb") as fh: + for chunk in iter(lambda: fh.read(1024 * 1024), b""): + digest.update(chunk) + + actual_digest = digest.hexdigest() + if actual_digest != artifact.digest: + raise RuntimeError( + f"{artifact_label} archive {archive_path} has sha256 {actual_digest}, " + f"expected {artifact.digest}" + ) + + +def download_archive(url: str, archive_path: Path) -> None: + archive_path.parent.mkdir(parents=True, exist_ok=True) + temp_path = archive_path.with_suffix(f"{archive_path.suffix}.tmp") + temp_path.unlink(missing_ok=True) + try: + with urlopen(url, timeout=DOWNLOAD_TIMEOUT_SECS) as response: + with open(temp_path, "wb") as out: + shutil.copyfileobj(response, out) + temp_path.replace(archive_path) + finally: + temp_path.unlink(missing_ok=True) + + +def extract_archive_member( + archive_path: Path, + artifact: DotSlashArtifact, + dest: Path, + artifact_label: str, +) -> None: + dest.parent.mkdir(parents=True, exist_ok=True) + dest.unlink(missing_ok=True) + + if artifact.archive_format == "tar.gz": + with tarfile.open(archive_path, "r:gz") as archive: + try: + member = archive.getmember(artifact.archive_member) + except KeyError as exc: + raise RuntimeError( + f"{artifact_label} archive {archive_path} is missing " + f"{artifact.archive_member!r}" + ) from exc + + extracted = archive.extractfile(member) + if extracted is None: + raise RuntimeError( + f"{artifact_label} archive member {artifact.archive_member!r} is not a file" + ) + with extracted, open(dest, "wb") as out: + shutil.copyfileobj(extracted, out) + return + + if artifact.archive_format == "zip": + with zipfile.ZipFile(archive_path) as archive: + try: + with archive.open(artifact.archive_member) as extracted: + with open(dest, "wb") as out: + shutil.copyfileobj(extracted, out) + except KeyError as exc: + raise RuntimeError( + f"{artifact_label} archive {archive_path} is missing " + f"{artifact.archive_member!r}" + ) from exc + return + + raise RuntimeError( + f"Unsupported {artifact_label} archive format {artifact.archive_format!r}; " + "expected tar.gz or zip" + ) diff --git a/scripts/codex_package/ripgrep.py b/scripts/codex_package/ripgrep.py index ce3ad7dc3424..33a484022b36 100644 --- a/scripts/codex_package/ripgrep.py +++ b/scripts/codex_package/ripgrep.py @@ -1,33 +1,14 @@ """Fetch ripgrep from the DotSlash manifest used by the package builder.""" -import hashlib -import json -import shutil -import stat -import tarfile -import tempfile -import zipfile -from dataclasses import dataclass from pathlib import Path -from urllib.parse import urlparse -from urllib.request import urlopen +from .dotslash import fetch_dotslash_executable from .targets import REPO_ROOT from .targets import TargetSpec from .targets import resolve_input_path RG_MANIFEST = REPO_ROOT / "scripts" / "codex_package" / "rg" -DOWNLOAD_TIMEOUT_SECS = 60 - - -@dataclass(frozen=True) -class RgArtifact: - size: int - digest: str - archive_format: str - archive_member: str - url: str def resolve_rg_bin(spec: TargetSpec, rg_bin: Path | None) -> Path: @@ -41,155 +22,14 @@ def fetch_rg( spec: TargetSpec, *, manifest_path: Path = RG_MANIFEST, - cache_root: Path | None = None, ) -> Path: - artifact = artifact_for_target(spec, manifest_path) - cache_dir = (cache_root or default_cache_root()) / f"{spec.target}-rg" - archive_path = cache_dir / archive_filename(artifact.url) - - if not archive_is_valid(archive_path, artifact): - download_archive(artifact.url, archive_path) - try: - verify_archive(archive_path, artifact) - except RuntimeError: - archive_path.unlink(missing_ok=True) - raise - - dest = cache_dir / spec.rg_name - extract_rg(archive_path, artifact, dest) - if not spec.is_windows: - mode = dest.stat().st_mode - dest.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) - return dest - - -def artifact_for_target(spec: TargetSpec, manifest_path: Path) -> RgArtifact: - manifest = load_manifest(manifest_path) - try: - platform_info = manifest["platforms"][spec.dotslash_platform] - except KeyError as exc: - raise RuntimeError( - f"ripgrep manifest {manifest_path} is missing platform {spec.dotslash_platform!r}" - ) from exc - - providers = platform_info.get("providers") - if not providers: - raise RuntimeError( - f"ripgrep manifest {manifest_path} has no providers for {spec.dotslash_platform!r}" - ) - - hash_name = platform_info.get("hash") - if hash_name != "sha256": - raise RuntimeError( - f"Unsupported ripgrep hash {hash_name!r} for " - f"{spec.dotslash_platform!r}; expected sha256" - ) - - return RgArtifact( - size=int(platform_info["size"]), - digest=str(platform_info["digest"]), - archive_format=str(platform_info["format"]), - archive_member=str(platform_info["path"]), - url=str(providers[0]["url"]), - ) - - -def load_manifest(manifest_path: Path) -> dict: - text = manifest_path.read_text(encoding="utf-8") - if text.startswith("#!"): - text = "\n".join(text.splitlines()[1:]) - return json.loads(text) - - -def default_cache_root() -> Path: - return Path(tempfile.gettempdir()) / "codex-package" - - -def archive_filename(url: str) -> str: - filename = Path(urlparse(url).path).name - if not filename: - raise RuntimeError(f"Unable to determine archive filename from {url}") - return filename - - -def archive_is_valid(archive_path: Path, artifact: RgArtifact) -> bool: - if not archive_path.is_file(): - return False - try: - verify_archive(archive_path, artifact) - except RuntimeError: - archive_path.unlink(missing_ok=True) - return False - return True - - -def verify_archive(archive_path: Path, artifact: RgArtifact) -> None: - actual_size = archive_path.stat().st_size - if actual_size != artifact.size: - raise RuntimeError( - f"ripgrep archive {archive_path} has size {actual_size}, expected {artifact.size}" - ) - - digest = hashlib.sha256() - with open(archive_path, "rb") as fh: - for chunk in iter(lambda: fh.read(1024 * 1024), b""): - digest.update(chunk) - - actual_digest = digest.hexdigest() - if actual_digest != artifact.digest: - raise RuntimeError( - f"ripgrep archive {archive_path} has sha256 {actual_digest}, " - f"expected {artifact.digest}" - ) - - -def download_archive(url: str, archive_path: Path) -> None: - archive_path.parent.mkdir(parents=True, exist_ok=True) - temp_path = archive_path.with_suffix(f"{archive_path.suffix}.tmp") - temp_path.unlink(missing_ok=True) - try: - with urlopen(url, timeout=DOWNLOAD_TIMEOUT_SECS) as response: - with open(temp_path, "wb") as out: - shutil.copyfileobj(response, out) - temp_path.replace(archive_path) - finally: - temp_path.unlink(missing_ok=True) - - -def extract_rg(archive_path: Path, artifact: RgArtifact, dest: Path) -> None: - dest.parent.mkdir(parents=True, exist_ok=True) - dest.unlink(missing_ok=True) - - if artifact.archive_format == "tar.gz": - with tarfile.open(archive_path, "r:gz") as archive: - try: - member = archive.getmember(artifact.archive_member) - except KeyError as exc: - raise RuntimeError( - f"ripgrep archive {archive_path} is missing {artifact.archive_member!r}" - ) from exc - - extracted = archive.extractfile(member) - if extracted is None: - raise RuntimeError( - f"ripgrep archive member {artifact.archive_member!r} is not a file" - ) - with extracted, open(dest, "wb") as out: - shutil.copyfileobj(extracted, out) - return - - if artifact.archive_format == "zip": - with zipfile.ZipFile(archive_path) as archive: - try: - with archive.open(artifact.archive_member) as extracted: - with open(dest, "wb") as out: - shutil.copyfileobj(extracted, out) - except KeyError as exc: - raise RuntimeError( - f"ripgrep archive {archive_path} is missing {artifact.archive_member!r}" - ) from exc - return - - raise RuntimeError( - f"Unsupported ripgrep archive format {artifact.archive_format!r}; expected tar.gz or zip" + rg_bin = fetch_dotslash_executable( + spec, + manifest_path=manifest_path, + artifact_label="ripgrep", + cache_key=f"{spec.target}-rg", + dest_name=spec.rg_name, ) + if rg_bin is None: + raise AssertionError("ripgrep is required for all package targets") + return rg_bin From 52ce382a40975a9883ebbdfdc5a57b16cbfa8583 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 21:45:31 +0000 Subject: [PATCH 54/64] Seed Termux release automation --- .github/workflows/rust-release.yml | 1807 +++++++---------- .github/workflows/shell-tool-mcp.yml | 461 +++++ .../workflows/termux-release-checkpoint.yml | 103 + .github/workflows/termux-release-deploy.yml | 243 +++ .github/workflows/termux-release-promote.yml | 135 ++ scripts/termux-configure-git.sh | 40 + scripts/termux-create-checkpoint-pr.sh | 275 +++ ...ermux-create-or-update-mirrored-release.sh | 111 + scripts/termux-download-release-artifact.sh | 78 + scripts/termux-find-release-pr.sh | 53 + scripts/termux-read-release-metadata.sh | 77 + scripts/termux-release-asset-state.sh | 37 + scripts/termux-release-paths.sh | 55 + scripts/termux-resolve-release-ref.sh | 36 + scripts/termux-validate-gh-env.sh | 16 + 15 files changed, 2445 insertions(+), 1082 deletions(-) create mode 100644 .github/workflows/shell-tool-mcp.yml create mode 100644 .github/workflows/termux-release-checkpoint.yml create mode 100644 .github/workflows/termux-release-deploy.yml create mode 100644 .github/workflows/termux-release-promote.yml create mode 100755 scripts/termux-configure-git.sh create mode 100755 scripts/termux-create-checkpoint-pr.sh create mode 100755 scripts/termux-create-or-update-mirrored-release.sh create mode 100755 scripts/termux-download-release-artifact.sh create mode 100755 scripts/termux-find-release-pr.sh create mode 100755 scripts/termux-read-release-metadata.sh create mode 100755 scripts/termux-release-asset-state.sh create mode 100755 scripts/termux-release-paths.sh create mode 100755 scripts/termux-resolve-release-ref.sh create mode 100755 scripts/termux-validate-gh-env.sh diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 2cd11e66fe0f..56d0977e6e67 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -4,46 +4,49 @@ # git tag -a rust-v0.1.0 -m "Release 0.1.0" # git push origin rust-v0.1.0 # ``` -# -# To use external macOS signing, manually dispatch `release_mode=build_unsigned`, -# sign the unsigned macOS artifacts in a secure enclave, upload the signed handoff -# archive as a GitHub Release asset, then manually dispatch -# `release_mode=promote_signed` with `unsigned_run_id` and `signed_macos_asset`. -# The signed handoff archive should contain target or artifact directories such -# as `aarch64-apple-darwin/` with signed binaries. name: rust-release on: - push: - tags: - - "rust-v*.*.*" + pull_request: + branches: + - "release/**" workflow_dispatch: inputs: - release_mode: - description: "build_unsigned creates unsigned macOS handoff artifacts; promote_signed finishes a release from signed macOS handoff artifacts." + source_ref: + description: "Branch, tag, or commit SHA to build (code only; keeps .github from dispatched ref)" required: false + type: string + default: "" + build_target: + description: "Target to build" + required: true type: choice - default: build_unsigned + default: all options: - - build_unsigned - - promote_signed - sign_macos: - description: "Deprecated compatibility input; use release_mode instead." - required: false - type: boolean - default: false - unsigned_run_id: - description: "For promote_signed: workflow run id from the build_unsigned run." - required: false - type: string - signed_macos_asset: - description: "For promote_signed: exact GitHub Release asset name containing signed macOS handoff artifacts." - required: false - type: string - signed_macos_sha256: - description: "For promote_signed: optional SHA-256 of signed_macos_asset." - required: false - type: string + - all + - aarch64-apple-darwin + - x86_64-apple-darwin + - x86_64-unknown-linux-musl + - x86_64-unknown-linux-gnu + - aarch64-unknown-linux-musl + - aarch64-unknown-linux-gnu + - aarch64-linux-android + - x86_64-pc-windows-msvc + - aarch64-pc-windows-msvc + +permissions: + actions: read + attestations: read + checks: read + contents: read + deployments: read + issues: read + discussions: read + packages: read + pages: read + pull-requests: read + repository-projects: read + statuses: read concurrency: group: ${{ github.workflow }} @@ -51,72 +54,26 @@ concurrency: jobs: tag-check: + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: actions/checkout@v6 + - name: 🧰 Actions Toolbox + # This is required for the GitHub CLI + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: wallentx/gh-actions/composite/actions-toolbox@main + - uses: dtolnay/rust-toolchain@1.92 - name: Validate tag matches Cargo.toml version shell: bash - env: - RELEASE_MODE: ${{ github.event_name == 'workflow_dispatch' && inputs.release_mode || 'signed' }} - REQUESTED_SIGN_MACOS: ${{ inputs.sign_macos }} - SIGNED_MACOS_ASSET: ${{ inputs.signed_macos_asset }} - UNSIGNED_RUN_ID: ${{ inputs.unsigned_run_id }} run: | set -euo pipefail echo "::group::Tag validation" - case "${RELEASE_MODE}" in - signed) - if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then - echo "❌ Manual rust-release runs must use release_mode=build_unsigned or release_mode=promote_signed" - exit 1 - fi - ;; - build_unsigned) - if [[ "${GITHUB_EVENT_NAME}" != "workflow_dispatch" ]]; then - echo "❌ release_mode=build_unsigned is only valid for manual runs" - exit 1 - fi - ;; - promote_signed) - if [[ "${GITHUB_EVENT_NAME}" != "workflow_dispatch" ]]; then - echo "❌ release_mode=promote_signed is only valid for manual runs" - exit 1 - fi - if [[ ! "${UNSIGNED_RUN_ID}" =~ ^[0-9]+$ ]]; then - echo "❌ release_mode=promote_signed requires unsigned_run_id to be a workflow run id" - exit 1 - fi - if [[ -z "${SIGNED_MACOS_ASSET}" ]]; then - echo "❌ release_mode=promote_signed requires signed_macos_asset" - exit 1 - fi - if [[ "${SIGNED_MACOS_ASSET}" == */* || "${SIGNED_MACOS_ASSET}" == *"*"* || "${SIGNED_MACOS_ASSET}" == *"?"* || "${SIGNED_MACOS_ASSET}" == *"["* ]]; then - echo "❌ signed_macos_asset must be an exact release asset name, not a path or glob" - exit 1 - fi - if [[ "${UNSIGNED_RUN_ID}" == "${GITHUB_RUN_ID}" ]]; then - echo "❌ unsigned_run_id must refer to the earlier build_unsigned run, not this run" - exit 1 - fi - ;; - *) - echo "❌ Unknown release_mode '${RELEASE_MODE}'" - exit 1 - ;; - esac - - if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${REQUESTED_SIGN_MACOS}" == "true" ]]; then - echo "::warning title=Deprecated sign_macos input ignored::Use release_mode=build_unsigned or release_mode=promote_signed instead." - fi - # 1. Must be a tag and match the regex [[ "${GITHUB_REF_TYPE}" == "tag" ]] \ || { echo "❌ Not a tag push"; exit 1; } - [[ "${GITHUB_REF_NAME}" =~ ^rust-v[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta)(\.[0-9]+)?)?$ ]] \ + [[ "${GITHUB_REF_NAME}" =~ ^rust-v[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta)(\.[0-9]+)?)?(-termux)?$ ]] \ || { echo "❌ Tag '${GITHUB_REF_NAME}' doesn't match expected format"; exit 1; } # 2. Extract versions @@ -131,115 +88,198 @@ jobs: echo "✅ Tag and Cargo.toml agree (${tag_ver})" echo "::endgroup::" - build: - if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} + select-build-matrix: + if: always() && (github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' || needs.tag-check.result == 'success') needs: tag-check - name: Build - ${{ matrix.runner }} - ${{ matrix.target }} - ${{ matrix.bundle }} - runs-on: ${{ matrix.runs_on || matrix.runner }} - # Release builds can take a long time, so leave some headroom to avoid - # having to restart the full workflow due to a timeout. - timeout-minutes: 90 - permissions: - contents: read - id-token: write + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.select.outputs.matrix }} + build_version: ${{ steps.select.outputs.build_version }} + steps: + - uses: actions/checkout@v6 + - name: 🧰 Actions Toolbox + # This is required for the GitHub CLI + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: wallentx/gh-actions/composite/actions-toolbox@main + - id: select + shell: bash + env: + REPO_OWNER: ${{ github.repository_owner }} + EVENT_NAME: ${{ github.event_name }} + BUILD_TARGET: ${{ inputs.build_target }} + run: | + set -euo pipefail + + matrix='[ + {"runner":"macos-15-xlarge","target":"aarch64-apple-darwin"}, + {"runner":"macos-15-xlarge","target":"x86_64-apple-darwin"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-gnu"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-gnu"}, + {"runner":"ubuntu-24.04","target":"aarch64-linux-android"}, + {"runner":"windows-latest","target":"x86_64-pc-windows-msvc"}, + {"runner":"windows-11-arm","target":"aarch64-pc-windows-msvc"} + ]' + + if [[ "${REPO_OWNER}" != "openai" ]]; then + matrix="$(jq -c '[.[] | select(.runner | startswith("macos") | not)]' <<< "${matrix}")" + fi + + if [[ "${EVENT_NAME}" == "pull_request" ]]; then + matrix="$(jq -c '[.[] | select(.target == "aarch64-linux-android")]' <<< "${matrix}")" + elif [[ "${EVENT_NAME}" == "workflow_dispatch" && -n "${BUILD_TARGET}" && "${BUILD_TARGET}" != "all" ]]; then + matrix="$(jq -c --arg build_target "${BUILD_TARGET}" '[.[] | select(.target == $build_target)]' <<< "${matrix}")" + fi + + if [[ "$(jq 'length' <<< "${matrix}")" -eq 0 ]]; then + echo "No build targets selected after applying owner/dispatch filters." >&2 + exit 1 + fi + + echo "matrix={\"include\":${matrix}}" >> "$GITHUB_OUTPUT" + + if [[ "${EVENT_NAME}" == "pull_request" ]]; then + metadata=".github/termux-release.json" + if [[ ! -f "${metadata}" ]]; then + echo "${metadata} is required for release train PR builds" >&2 + exit 1 + fi + upstream_tag="$(jq -r '.upstream_tag // empty' "${metadata}")" + if [[ -z "${upstream_tag}" || "${upstream_tag}" != rust-v* ]]; then + echo "Unable to determine upstream rust tag from ${metadata}" >&2 + exit 1 + fi + build_version="${upstream_tag#rust-v}" + echo "build_version=${build_version}" >> "$GITHUB_OUTPUT" + elif [[ "${EVENT_NAME}" == "workflow_dispatch" ]]; then + latest_release_tag="$( + GH_TOKEN="${{ github.token }}" gh api repos/openai/codex/releases/latest --jq '.tag_name' + )" + + if [[ -z "${latest_release_tag}" || "${latest_release_tag}" != rust-v* ]]; then + echo "Unable to determine latest stable release tag from openai/codex" >&2 + exit 1 + fi + + latest_release_version="${latest_release_tag#rust-v}" + build_version="${latest_release_version}-dev" + echo "build_version=${build_version}" >> "$GITHUB_OUTPUT" + fi + + build: + if: >- + always() && + (github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' || needs.tag-check.result == 'success') && + needs.select-build-matrix.result == 'success' + needs: + - tag-check + - select-build-matrix + name: Build - ${{ matrix.runner }} - ${{ matrix.target }} + runs-on: ${{ matrix.runner }} + permissions: write-all + timeout-minutes: 120 defaults: run: working-directory: codex-rs env: - # 2026-03-04: temporarily change releases to use thin LTO because - # Ubuntu ARM is timing out at 60 minutes. - CARGO_PROFILE_RELEASE_LTO: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'thin' }} - SIGN_MACOS: ${{ github.event_name != 'workflow_dispatch' }} + CODEX_BWRAP_ENABLE_FFI: ${{ contains(matrix.target, 'unknown-linux') && '1' || '0' }} + CODEX_BUILD_VERSION: ${{ needs.select-build-matrix.outputs.build_version }} + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" strategy: fail-fast: false - matrix: - include: - - runner: macos-15-xlarge - target: aarch64-apple-darwin - bundle: primary - artifact_name: aarch64-apple-darwin - binaries: "codex codex-responses-api-proxy" - build_dmg: "true" - - runner: macos-15-xlarge - target: aarch64-apple-darwin - bundle: app-server - artifact_name: aarch64-apple-darwin-app-server - binaries: "codex-app-server" - build_dmg: "false" - - runner: macos-15-xlarge - target: x86_64-apple-darwin - bundle: primary - artifact_name: x86_64-apple-darwin - binaries: "codex codex-responses-api-proxy" - build_dmg: "true" - - runner: macos-15-xlarge - target: x86_64-apple-darwin - bundle: app-server - artifact_name: x86_64-apple-darwin-app-server - binaries: "codex-app-server" - build_dmg: "false" - # Release artifacts intentionally ship MUSL-linked Linux binaries. - - runner: codex-linux-x64-xl - target: x86_64-unknown-linux-musl - bundle: primary - artifact_name: x86_64-unknown-linux-musl - binaries: "codex codex-responses-api-proxy bwrap" - build_dmg: "false" - - runner: codex-linux-x64-xl - target: x86_64-unknown-linux-musl - bundle: app-server - artifact_name: x86_64-unknown-linux-musl-app-server - binaries: "codex-app-server" - build_dmg: "false" - - runner: codex-linux-arm64 - target: aarch64-unknown-linux-musl - bundle: primary - artifact_name: aarch64-unknown-linux-musl - binaries: "codex codex-responses-api-proxy bwrap" - build_dmg: "false" - - runner: codex-linux-arm64 - target: aarch64-unknown-linux-musl - bundle: app-server - artifact_name: aarch64-unknown-linux-musl-app-server - binaries: "codex-app-server" - build_dmg: "false" + matrix: ${{ fromJson(needs.select-build-matrix.outputs.matrix) }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@v6 + - name: 🧰 Actions Toolbox + # This is required for the GitHub CLI + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: wallentx/gh-actions/composite/actions-toolbox@main + with: + verbose: true + - name: Checkout selected source ref + if: ${{ github.event_name == 'workflow_dispatch' && inputs.source_ref != '' }} + uses: actions/checkout@v6 with: - persist-credentials: false - - name: Print runner specs (Linux) - if: ${{ runner.os == 'Linux' }} + ref: ${{ inputs.source_ref }} + path: __source__ + fetch-depth: 1 + - name: Overlay selected source ref (excluding .github) + if: ${{ github.event_name == 'workflow_dispatch' && inputs.source_ref != '' }} + shell: bash + working-directory: ${{ github.workspace }} + run: | + set -euo pipefail + echo "Using source ref: ${{ inputs.source_ref }}" + + # Keep workflow/actions from the dispatch ref while replacing all other files. + find . -mindepth 1 -maxdepth 1 \ + ! -name .git \ + ! -name .github \ + ! -name __source__ \ + -exec rm -rf -- {} + + tar --exclude='.github' -C __source__ -cf - . | tar -xf - + rm -rf __source__ + + - name: Apply selected build version + if: ${{ github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' }} shell: bash + working-directory: ${{ github.workspace }} run: | set -euo pipefail - cpu_model="$(lscpu | awk -F: '/Model name/ {gsub(/^[ \t]+/, "", $2); print $2; exit}')" - total_ram="$(awk '/MemTotal/ {printf "%.1f GiB\n", $2 / 1024 / 1024}' /proc/meminfo)" - echo "Runner: ${RUNNER_NAME:-unknown}" - echo "OS: $(uname -a)" - echo "CPU model: ${cpu_model}" - echo "Logical CPUs: $(nproc)" - echo "Total RAM: ${total_ram}" - echo "Disk usage:" - df -h . - - name: Print runner specs (macOS) - if: ${{ runner.os == 'macOS' }} + if [[ -z "${CODEX_BUILD_VERSION}" ]]; then + echo "CODEX_BUILD_VERSION is empty for workflow_dispatch" >&2 + exit 1 + fi + echo "Using build version: ${CODEX_BUILD_VERSION}" + BUILD_VERSION="${CODEX_BUILD_VERSION}" perl -0777 -i -pe \ + 's/(\[workspace\.package\][^\[]*?version = ")([^"]+)(")/${1}$ENV{BUILD_VERSION}$3/s' \ + codex-rs/Cargo.toml + + grep -n "^\[workspace.package\]\|^version = " codex-rs/Cargo.toml | head -n 4 + + - name: Select Android NDK + if: ${{ matrix.target == 'aarch64-linux-android' }} shell: bash run: | set -euo pipefail - total_ram="$(sysctl -n hw.memsize | awk '{printf "%.1f GiB\n", $1 / 1024 / 1024 / 1024}')" - echo "Runner: ${RUNNER_NAME:-unknown}" - echo "OS: $(sw_vers -productName) $(sw_vers -productVersion)" - echo "Hardware model: $(sysctl -n hw.model)" - echo "CPU architecture: $(uname -m)" - echo "Logical CPUs: $(sysctl -n hw.logicalcpu)" - echo "Physical CPUs: $(sysctl -n hw.physicalcpu)" - echo "Total RAM: ${total_ram}" - echo "Disk usage:" - df -h . + + fallback_version="29.0.13113456" + ndk_root="${ANDROID_HOME}/ndk" + selected_ndk="" + + if [[ -d "${ndk_root}" ]]; then + while IFS= read -r candidate; do + toolchain="${candidate}/toolchains/llvm/prebuilt/linux-x86_64" + if [[ -x "${toolchain}/bin/aarch64-linux-android24-clang" ]]; then + selected_ndk="${candidate}" + break + fi + done < <(find "${ndk_root}" -mindepth 1 -maxdepth 1 -type d | sort -Vr) + fi + + if [[ -z "${selected_ndk}" ]]; then + echo "No usable preinstalled Android NDK found; installing ${fallback_version}" + yes | sudo "${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager" --licenses || true + sudo "${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager" "ndk;${fallback_version}" + selected_ndk="${ndk_root}/${fallback_version}" + fi + + if [[ ! -x "${selected_ndk}/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android24-clang" ]]; then + echo "Selected Android NDK does not contain a usable aarch64 API 24 clang: ${selected_ndk}" >&2 + exit 1 + fi + + echo "Selected Android NDK: ${selected_ndk}" + "${selected_ndk}/toolchains/llvm/prebuilt/linux-x86_64/bin/clang" --version + echo "ANDROID_NDK_HOME=${selected_ndk}" >> "$GITHUB_ENV" + echo "NDK_HOME=${selected_ndk}" >> "$GITHUB_ENV" - name: Install Linux bwrap build dependencies - if: ${{ runner.os == 'Linux' }} + if: ${{ runner.os == 'Linux' && matrix.target != 'aarch64-linux-android' }} shell: bash run: | set -euo pipefail @@ -254,9 +294,173 @@ jobs: sudo apt-get update -y sudo DEBIAN_FRONTEND=noninteractive apt-get install -y libubsan1 fi - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: dtolnay/rust-toolchain@1.93 with: targets: ${{ matrix.target }} + - name: Set up sccache (Android) + if: ${{ matrix.target == 'aarch64-linux-android' }} + uses: mozilla-actions/sccache-action@main + - name: Enable sccache (Android) + if: ${{ matrix.target == 'aarch64-linux-android' }} + shell: bash + run: | + set -euo pipefail + echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV" + echo "SCCACHE_GHA_ENABLED=true" >> "$GITHUB_ENV" + echo "SCCACHE_GHA_VERSION=android-release-${{ matrix.target }}" >> "$GITHUB_ENV" + echo "SCCACHE_CACHE_SIZE=5G" >> "$GITHUB_ENV" + - name: Ensure Rust target is installed + shell: bash + run: | + set -euo pipefail + rustup target add "${{ matrix.target }}" + - if: ${{ matrix.target == 'aarch64-linux-android' }} + name: Configure Android build environment + shell: bash + run: | + set -euo pipefail + ndk="${ANDROID_NDK_HOME}" + target="${{ matrix.target }}" + + # Set up the Android toolchain + toolchain="${ndk}/toolchains/llvm/prebuilt/linux-x86_64" + + # Use API level 24 to follow termux-build convention + api_level="24" + + # Configure Cargo to use the NDK linker + cargo_target_var="CARGO_TARGET_${target^^}_LINKER" + cargo_target_var="${cargo_target_var//-/_}" + echo "${cargo_target_var}=${toolchain}/bin/aarch64-linux-android${api_level}-clang" >> "$GITHUB_ENV" + + # Set CC and AR for the target + target_cc_var="CC_${target}" + target_cc_var="${target_cc_var//-/_}" + echo "${target_cc_var}=${toolchain}/bin/aarch64-linux-android${api_level}-clang" >> "$GITHUB_ENV" + + target_cxx_var="CXX_${target}" + target_cxx_var="${target_cxx_var//-/_}" + echo "${target_cxx_var}=${toolchain}/bin/aarch64-linux-android${api_level}-clang++" >> "$GITHUB_ENV" + + target_ar_var="AR_${target}" + target_ar_var="${target_ar_var//-/_}" + echo "${target_ar_var}=${toolchain}/bin/llvm-ar" >> "$GITHUB_ENV" + + # Add toolchain to PATH + echo "${toolchain}/bin" >> "$GITHUB_PATH" + + tls_align_source="${RUNNER_TEMP}/codex-android-tls-align.S" + tls_align_object="${RUNNER_TEMP}/codex-android-tls-align.o" + cat > "${tls_align_source}" <<'EOF' + .section .tdata.codex_android_tls_align,"awT",%progbits + .p2align 6 + .globl __codex_android_tls_align + .hidden __codex_android_tls_align + .type __codex_android_tls_align, %object + __codex_android_tls_align: + .byte 0 + .size __codex_android_tls_align, 1 + EOF + "${toolchain}/bin/aarch64-linux-android${api_level}-clang" -c "${tls_align_source}" -o "${tls_align_object}" + + v8_compat_source="${RUNNER_TEMP}/codex-android-v8-compat.c" + v8_compat_object="${RUNNER_TEMP}/codex-android-v8-compat.o" + cat > "${v8_compat_source}" <<'EOF' + #include + #include + + extern int posix_memalign(void **memptr, size_t alignment, size_t size); + extern double strtod(const char *nptr, char **endptr); + extern float strtof(const char *nptr, char **endptr); + + void *aligned_alloc(size_t alignment, size_t size) { + void *ptr = 0; + if (posix_memalign(&ptr, alignment, size) != 0) { + return 0; + } + return ptr; + } + + double strtod_l(const char *nptr, char **endptr, locale_t locale) { + (void)locale; + return strtod(nptr, endptr); + } + + float strtof_l(const char *nptr, char **endptr, locale_t locale) { + (void)locale; + return strtof(nptr, endptr); + } + EOF + "${toolchain}/bin/aarch64-linux-android${api_level}-clang" -c "${v8_compat_source}" -o "${v8_compat_object}" + + builtins_archive="$( + find "${toolchain}/lib/clang" \ + -path "*/lib/linux/libclang_rt.builtins-aarch64-android.a" \ + -print -quit + )" + if [[ -z "${builtins_archive}" ]]; then + echo "Could not find Android compiler-rt builtins archive under ${toolchain}/lib/clang" >&2 + exit 1 + fi + builtins_dir="$(dirname "${builtins_archive}")" + + # Provide compatibility symbols and C++ ABI needed by the Android rusty_v8 archive. + rustflags_var="CARGO_TARGET_${target^^}_RUSTFLAGS" + rustflags_var="${rustflags_var//-/_}" + echo "${rustflags_var}=-C link-arg=${tls_align_object} -C link-arg=${v8_compat_object} -C link-arg=-Wl,-u,__codex_android_tls_align -C link-arg=-L${builtins_dir} -C link-arg=-lclang_rt.builtins-aarch64-android -C link-arg=-lc++abi -C link-arg=-Wl,-z,max-page-size=65536" >> "$GITHUB_ENV" + + + # Configure for vendored OpenSSL build + echo "CARGO_BUILD_TARGET_APPLIES_TO_HOST=false" >> "$GITHUB_ENV" + echo "CARGO_BUILD_TARGET=${{ matrix.target }}" >> "$GITHUB_ENV" + + - if: ${{ matrix.target == 'aarch64-linux-android' }} + name: Configure Android rusty_v8 artifact overrides + env: + GH_TOKEN: ${{ github.token }} + TARGET: ${{ matrix.target }} + shell: bash + run: | + set -euo pipefail + binding_dir="${RUNNER_TEMP}/rusty_v8" + archive="${binding_dir}/librusty_v8_release_${TARGET}.a.gz" + binding_path="${binding_dir}/src_binding_release_${TARGET}.rs" + checksums_path="${binding_dir}/rusty_v8_release_${TARGET}.sha256" + artifact_repository="wallentx/codex-termux" + release_tag="rusty-v8-v147.4.0" + mkdir -p "${binding_dir}" + echo "Downloading Android rusty_v8 artifacts from ${artifact_repository}@${release_tag}" + gh release download "${release_tag}" \ + --repo "${artifact_repository}" \ + --dir "${binding_dir}" \ + --pattern "librusty_v8_release_${TARGET}.a.gz" \ + --pattern "src_binding_release_${TARGET}.rs" \ + --pattern "rusty_v8_release_${TARGET}.sha256" + (cd "${binding_dir}" && sha256sum -c "$(basename "${checksums_path}")") + echo "RUSTY_V8_ARCHIVE=${archive}" >> "$GITHUB_ENV" + echo "RUSTY_V8_SRC_BINDING_PATH=${binding_path}" >> "$GITHUB_ENV" + + - if: ${{ matrix.target == 'aarch64-linux-android' }} + name: Install termux-elf-cleaner + shell: bash + run: | + set -euo pipefail + version="v3.0.1" + src_dir="${RUNNER_TEMP}/termux-elf-cleaner-src" + build_dir="${RUNNER_TEMP}/termux-elf-cleaner-build" + bin_dir="${RUNNER_TEMP}/termux-elf-cleaner-bin" + + rm -rf "${src_dir}" "${build_dir}" "${bin_dir}" + mkdir -p "${src_dir}" "${build_dir}" "${bin_dir}" + + curl -fsSL "https://github.com/termux/termux-elf-cleaner/archive/refs/tags/${version}.tar.gz" \ + | tar -xzf - --strip-components=1 -C "${src_dir}" + + cmake -S "${src_dir}" -B "${build_dir}" -DCMAKE_BUILD_TYPE=Release + cmake --build "${build_dir}" --parallel + + install "${build_dir}/termux-elf-cleaner" "${bin_dir}/termux-elf-cleaner" + echo "${bin_dir}" >> "$GITHUB_PATH" - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} name: Use hermetic Cargo home (musl) @@ -269,12 +473,25 @@ jobs: echo "${cargo_home}/bin" >> "$GITHUB_PATH" : > "${cargo_home}/config.toml" + - uses: actions/cache@v5 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + ${{ github.workspace }}/.cargo-home/bin/ + ${{ github.workspace }}/.cargo-home/registry/index/ + ${{ github.workspace }}/.cargo-home/registry/cache/ + ${{ github.workspace }}/.cargo-home/git/db/ + ${{ github.workspace }}/codex-rs/target/ + key: cargo-${{ matrix.runner }}-${{ matrix.target }}-release-${{ hashFiles('**/Cargo.lock') }} + - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} name: Install Zig - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 + uses: mlugg/setup-zig@v2 with: version: 0.14.0 - use-cache: false - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} name: Install musl build tools @@ -310,12 +527,6 @@ jobs: shell: bash run: | set -euo pipefail - # Avoid problematic aws-lc jitter entropy code path on musl builders. - echo "AWS_LC_SYS_NO_JITTER_ENTROPY=1" >> "$GITHUB_ENV" - target_no_jitter="AWS_LC_SYS_NO_JITTER_ENTROPY_${{ matrix.target }}" - target_no_jitter="${target_no_jitter//-/_}" - echo "${target_no_jitter}=1" >> "$GITHUB_ENV" - # Clear global Rust flags so host/proc-macro builds don't pull in UBSan. echo "RUSTFLAGS=" >> "$GITHUB_ENV" echo "CARGO_ENCODED_RUSTFLAGS=" >> "$GITHUB_ENV" @@ -340,93 +551,70 @@ jobs: echo "CFLAGS=${cflags}" >> "$GITHUB_ENV" echo "CXXFLAGS=${cxxflags}" >> "$GITHUB_ENV" - - name: Configure rusty_v8 artifact overrides and verify checksums - uses: ./.github/actions/setup-rusty-v8 - with: - target: ${{ matrix.target }} - - - if: ${{ contains(matrix.target, 'linux') }} - name: Build bwrap and export digest + - name: Cargo build shell: bash run: | set -euo pipefail - target="${{ matrix.target }}" - cargo build --target "$target" --release --timings --bin bwrap - bwrap_path="target/${target}/release/bwrap" - if [[ ! -f "$bwrap_path" ]]; then - echo "bwrap binary ${bwrap_path} not found" - exit 1 + if [[ "${{ matrix.target }}" == 'aarch64-linux-android' ]]; then + export CARGO_BUILD_JOBS=4 + export CARGO_PROFILE_RELEASE_LTO=thin + export CARGO_PROFILE_RELEASE_CODEGEN_UNITS=8 + cargo build --target ${{ matrix.target }} --release --bin codex + exit 0 fi - digest="$(sha256sum "$bwrap_path" | awk '{print $1}')" - echo "CODEX_BWRAP_SHA256=${digest}" >> "$GITHUB_ENV" - echo "Built bwrap ${bwrap_path} with sha256:${digest}" - - - name: Cargo build + if [[ "${{ contains(matrix.target, 'windows') }}" == 'true' ]]; then + cargo build --target ${{ matrix.target }} --release --bin codex --bin codex-responses-api-proxy --bin codex-windows-sandbox-setup --bin codex-command-runner + else + cargo build --target ${{ matrix.target }} --release --bin codex --bin codex-responses-api-proxy + fi + - name: sccache stats (Android) + if: ${{ matrix.target == 'aarch64-linux-android' }} shell: bash - run: | - build_args=() - for binary in ${{ matrix.binaries }}; do - build_args+=(--bin "$binary") - done - echo "CARGO_PROFILE_RELEASE_LTO: ${CARGO_PROFILE_RELEASE_LTO}" - cargo build --target ${{ matrix.target }} --release --timings "${build_args[@]}" - - - name: Upload Cargo timings - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: cargo-timings-rust-release-${{ matrix.target }}-${{ matrix.bundle }} - path: codex-rs/target/**/cargo-timings/cargo-timing.html - if-no-files-found: warn + run: ${SCCACHE_PATH:-sccache} --show-stats || true - - if: ${{ runner.os == 'macOS' && env.SIGN_MACOS != 'true' }} - name: Stage unsigned macOS artifacts + - if: ${{ matrix.target == 'aarch64-linux-android' }} + name: Normalize Android ELF for Termux shell: bash run: | set -euo pipefail - - target="${{ matrix.target }}" - release_dir="target/${target}/release" - dest="unsigned-dist/${target}" - mkdir -p "$dest" - - for binary in ${{ matrix.binaries }}; do - binary_path="${release_dir}/${binary}" - unsigned_name="${binary}-${target}-unsigned" - unsigned_path="${dest}/${unsigned_name}" - if [[ ! -f "${binary_path}" ]]; then - echo "Binary ${binary_path} not found" - exit 1 + for binary in codex codex-responses-api-proxy; do + binary_path="target/${{ matrix.target }}/release/${binary}" + if [[ -f "${binary_path}" ]]; then + termux-elf-cleaner --api-level 24 "${binary_path}" + chmod +x "${binary_path}" + # if [[ "${binary}" == "codex" ]]; then + # # Patch PT_TLS (0x7) to PT_NULL (0x0) at offset 400 to bypass Bionic alignment checks. + # printf '\x00\x00\x00\x00' | dd of="${binary_path}" bs=1 seek=400 count=4 conv=notrunc + # fi fi - - cp "${binary_path}" "${unsigned_path}" - tar -C "$dest" -czf "${unsigned_path}.tar.gz" "${unsigned_name}" - zstd -T0 -19 --rm "${unsigned_path}" done - - if: ${{ runner.os == 'macOS' && env.SIGN_MACOS != 'true' }} - name: Upload unsigned macOS artifacts - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: ${{ matrix.artifact_name }}-unsigned - path: codex-rs/unsigned-dist/${{ matrix.target }}/* - if-no-files-found: error - - - if: ${{ contains(matrix.target, 'linux') }} + - if: ${{ contains(matrix.target, 'linux') && !contains(matrix.target, 'android') && github.repository_owner == 'openai' }} name: Cosign Linux artifacts uses: ./.github/actions/linux-code-sign with: target: ${{ matrix.target }} artifacts-dir: ${{ github.workspace }}/codex-rs/target/${{ matrix.target }}/release - binaries: ${{ matrix.binaries }} - - if: ${{ runner.os == 'macOS' && env.SIGN_MACOS == 'true' }} + - if: ${{ contains(matrix.target, 'windows') && github.repository_owner == 'openai' }} + name: Sign Windows binaries with Azure Trusted Signing + uses: ./.github/actions/windows-code-sign + with: + target: ${{ matrix.target }} + client-id: ${{ secrets.AZURE_TRUSTED_SIGNING_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TRUSTED_SIGNING_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_TRUSTED_SIGNING_SUBSCRIPTION_ID }} + endpoint: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} + account-name: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} + certificate-profile-name: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} + + - if: ${{ runner.os == 'macOS' && github.repository_owner == 'openai' }} name: MacOS code signing (binaries) uses: ./.github/actions/macos-code-sign with: target: ${{ matrix.target }} - binaries: ${{ matrix.binaries }} sign-binaries: "true" sign-dmg: "false" apple-certificate: ${{ secrets.APPLE_CERTIFICATE_P12 }} @@ -435,7 +623,7 @@ jobs: apple-notarization-key-id: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} apple-notarization-issuer-id: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} - - if: ${{ runner.os == 'macOS' && matrix.build_dmg == 'true' && env.SIGN_MACOS == 'true' }} + - if: ${{ runner.os == 'macOS' && github.repository_owner == 'openai' }} name: Build macOS dmg shell: bash run: | @@ -450,17 +638,23 @@ jobs: # The previous "MacOS code signing (binaries)" step signs + notarizes the # built artifacts in `${release_dir}`. This step packages *those same* # signed binaries into a dmg. + codex_binary_path="${release_dir}/codex" + proxy_binary_path="${release_dir}/codex-responses-api-proxy" + rm -rf "$dmg_root" mkdir -p "$dmg_root" - for binary in ${{ matrix.binaries }}; do - binary_path="${release_dir}/${binary}" - if [[ ! -f "${binary_path}" ]]; then - echo "Binary ${binary_path} not found" - exit 1 - fi - ditto "${binary_path}" "${dmg_root}/${binary}" - done + if [[ ! -f "$codex_binary_path" ]]; then + echo "Binary $codex_binary_path not found" + exit 1 + fi + if [[ ! -f "$proxy_binary_path" ]]; then + echo "Binary $proxy_binary_path not found" + exit 1 + fi + + ditto "$codex_binary_path" "${dmg_root}/codex" + ditto "$proxy_binary_path" "${dmg_root}/codex-responses-api-proxy" rm -f "$dmg_path" hdiutil create \ @@ -475,7 +669,7 @@ jobs: exit 1 fi - - if: ${{ runner.os == 'macOS' && matrix.build_dmg == 'true' && env.SIGN_MACOS == 'true' }} + - if: ${{ runner.os == 'macOS' && github.repository_owner == 'openai' }} name: MacOS code signing (dmg) uses: ./.github/actions/macos-code-sign with: @@ -489,113 +683,65 @@ jobs: apple-notarization-issuer-id: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} - name: Stage artifacts - if: ${{ runner.os != 'macOS' || env.SIGN_MACOS == 'true' }} shell: bash run: | dest="dist/${{ matrix.target }}" mkdir -p "$dest" - for binary in ${{ matrix.binaries }}; do - cp "target/${{ matrix.target }}/release/${binary}" "$dest/${binary}-${{ matrix.target }}" - if [[ "${{ matrix.target }}" == *linux* ]]; then - cp "target/${{ matrix.target }}/release/${binary}.sigstore" \ - "$dest/${binary}-${{ matrix.target }}.sigstore" + if [[ "${{ matrix.runner }}" == windows* ]]; then + cp target/${{ matrix.target }}/release/codex.exe "$dest/codex-${{ matrix.target }}.exe" + cp target/${{ matrix.target }}/release/codex-responses-api-proxy.exe "$dest/codex-responses-api-proxy-${{ matrix.target }}.exe" + cp target/${{ matrix.target }}/release/codex-windows-sandbox-setup.exe "$dest/codex-windows-sandbox-setup-${{ matrix.target }}.exe" + cp target/${{ matrix.target }}/release/codex-command-runner.exe "$dest/codex-command-runner-${{ matrix.target }}.exe" + else + # For Android, we want the binary to be named just 'codex' in the archive. + if [[ "${{ matrix.target }}" == "aarch64-linux-android" ]]; then + cp target/${{ matrix.target }}/release/codex "$dest/codex" + else + cp target/${{ matrix.target }}/release/codex "$dest/codex-${{ matrix.target }}" fi - done + if [[ -f target/${{ matrix.target }}/release/codex-responses-api-proxy ]]; then + cp target/${{ matrix.target }}/release/codex-responses-api-proxy "$dest/codex-responses-api-proxy-${{ matrix.target }}" + fi + fi - if [[ "${{ matrix.target }}" == *linux* && "${{ matrix.bundle }}" == "primary" ]]; then - bundle_root="${RUNNER_TEMP}/codex-${{ matrix.target }}-bundle" - rm -rf "$bundle_root" - mkdir -p "$bundle_root/codex-resources" - cp "$dest/codex-${{ matrix.target }}" "$bundle_root/codex" - cp "$dest/bwrap-${{ matrix.target }}" "$bundle_root/codex-resources/bwrap" - chmod 0755 "$bundle_root/codex" "$bundle_root/codex-resources/bwrap" - tar -C "$bundle_root" -cf - codex codex-resources/bwrap | - zstd -T0 -19 -o "$dest/codex-${{ matrix.target }}-bundle.tar.zst" + if [[ "${{ matrix.target }}" == *linux* && "${{ matrix.target }}" != *android* ]]; then + cp target/${{ matrix.target }}/release/codex.sigstore "$dest/codex-${{ matrix.target }}.sigstore" + cp target/${{ matrix.target }}/release/codex-responses-api-proxy.sigstore "$dest/codex-responses-api-proxy-${{ matrix.target }}.sigstore" fi - if [[ "${{ matrix.build_dmg }}" == "true" ]]; then + if [[ "${{ matrix.target }}" == *apple-darwin ]]; then cp target/${{ matrix.target }}/release/codex-${{ matrix.target }}.dmg "$dest/codex-${{ matrix.target }}.dmg" fi - - name: Build Codex package archive - if: ${{ runner.os != 'macOS' || env.SIGN_MACOS == 'true' }} - shell: bash - env: - TARGET: ${{ matrix.target }} - BUNDLE: ${{ matrix.bundle }} - run: | - set -euo pipefail - bash "${GITHUB_WORKSPACE}/.github/scripts/build-codex-package-archive.sh" \ - --target "$TARGET" \ - --bundle "$BUNDLE" \ - --entrypoint-dir "target/${TARGET}/release" \ - --archive-dir "dist/${TARGET}" - - - name: Build Python runtime wheel - if: ${{ matrix.bundle == 'primary' && (runner.os != 'macOS' || env.SIGN_MACOS == 'true') }} - shell: bash - run: | - set -euo pipefail - - case "${{ matrix.target }}" in - aarch64-apple-darwin) - platform_tag="macosx_11_0_arm64" - ;; - x86_64-apple-darwin) - platform_tag="macosx_10_9_x86_64" - ;; - aarch64-unknown-linux-musl) - platform_tag="manylinux_2_17_aarch64" - ;; - x86_64-unknown-linux-musl) - platform_tag="manylinux_2_17_x86_64" - ;; - *) - echo "No Python runtime wheel platform tag for ${{ matrix.target }}" - exit 1 - ;; - esac - - python3 -m venv "${RUNNER_TEMP}/python-runtime-build-venv" - # Do not install into the runner's system Python; macOS runners mark - # the Homebrew Python as externally managed under PEP 668. - "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m pip install build - - stage_dir="${RUNNER_TEMP}/openai-codex-cli-bin-${{ matrix.target }}" - wheel_dir="${GITHUB_WORKSPACE}/python-runtime-dist/${{ matrix.target }}" - stage_runtime_args=( - "${GITHUB_WORKSPACE}/sdk/python/scripts/update_sdk_artifacts.py" - stage-runtime - "$stage_dir" - "dist/${{ matrix.target }}/codex-package-${{ matrix.target }}.tar.gz" - --codex-version "${GITHUB_REF_NAME}" - --platform-tag "$platform_tag" - ) - python3 "${stage_runtime_args[@]}" - "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m build --wheel --outdir "$wheel_dir" "$stage_dir" - - - name: Upload Python runtime wheel - if: ${{ matrix.bundle == 'primary' && (runner.os != 'macOS' || env.SIGN_MACOS == 'true') }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: python-runtime-wheel-${{ matrix.target }} - path: python-runtime-dist/${{ matrix.target }}/*.whl - if-no-files-found: error + - if: ${{ matrix.runner == 'windows-11-arm' }} + name: Install zstd + shell: powershell + run: choco install -y zstandard - name: Compress artifacts - if: ${{ runner.os != 'macOS' || env.SIGN_MACOS == 'true' }} shell: bash run: | # Path that contains the uncompressed binaries for the current # ${{ matrix.target }} dest="dist/${{ matrix.target }}" + repo_root=$PWD + + # We want to ship the raw Windows executables in the GitHub Release + # in addition to the compressed archives. Keep the originals for + # Windows targets; remove them elsewhere to limit the number of + # artifacts that end up in the GitHub Release. + keep_originals=false + if [[ "${{ matrix.runner }}" == windows* ]]; then + keep_originals=true + fi # For compatibility with environments that lack the `zstd` tool we - # additionally create a `.tar.gz` alongside every binary we publish. - # The end result is: + # additionally create a `.tar.gz` for all platforms and `.zip` for + # Windows alongside every single binary that we publish. The end result is: # codex-.zst (existing) # codex-.tar.gz (new) + # codex-.zip (only for Windows) # 1. Produce a .tar.gz for every file in the directory *before* we # run `zstd --rm`, because that flag deletes the original files. @@ -603,7 +749,7 @@ jobs: base="$(basename "$f")" # Skip files that are already archives (shouldn't happen, but be # safe). - if [[ "$base" == *.tar.gz || "$base" == *.tar.zst || "$base" == *.zip || "$base" == *.dmg ]]; then + if [[ "$base" == *.tar.gz || "$base" == *.zip || "$base" == *.dmg ]]; then continue fi @@ -613,344 +759,239 @@ jobs: fi # Create per-binary tar.gz - tar -C "$dest" -czf "$dest/${base}.tar.gz" "$base" - - # Also create .zst and remove the uncompressed binaries to keep - # non-Windows artifact directories small. - zstd -T0 -19 --rm "$dest/$base" - done - - - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - if: ${{ runner.os != 'macOS' || env.SIGN_MACOS == 'true' }} - with: - name: ${{ matrix.artifact_name }} - # Upload the per-binary .zst files, .tar.gz equivalents, and any - # prebuilt archives staged above. - path: | - codex-rs/dist/${{ matrix.target }}/* - - stage-signed-macos: - if: ${{ github.event_name == 'workflow_dispatch' && inputs.release_mode == 'promote_signed' }} - needs: tag-check - name: Stage signed macOS handoff - ${{ matrix.target }} - ${{ matrix.bundle }} - runs-on: macos-15-xlarge - timeout-minutes: 30 - permissions: - contents: read - defaults: - run: - working-directory: codex-rs + # For Android, we want the archive to have the target suffix even though the binary is just 'codex'. + archive_name="${base}" + if [[ "${{ matrix.target }}" == "aarch64-linux-android" && "${base}" == "codex" ]]; then + archive_name="codex-${{ matrix.target }}" + fi + tar -C "$dest" -czf "$dest/${archive_name}.tar.gz" "$base" + + # Create zip archive for Windows binaries + # Must run from inside the dest dir so 7z won't + # embed the directory path inside the zip. + if [[ "${{ matrix.runner }}" == windows* ]]; then + if [[ "$base" == "codex-${{ matrix.target }}.exe" ]]; then + # Bundle the sandbox helper binaries into the main codex zip so + # WinGet installs include the required helpers next to codex.exe. + # Fall back to the single-binary zip if the helpers are missing + # to avoid breaking releases. + bundle_dir="$(mktemp -d)" + runner_src="$dest/codex-command-runner-${{ matrix.target }}.exe" + setup_src="$dest/codex-windows-sandbox-setup-${{ matrix.target }}.exe" + if [[ -f "$runner_src" && -f "$setup_src" ]]; then + cp "$dest/$base" "$bundle_dir/$base" + cp "$runner_src" "$bundle_dir/codex-command-runner.exe" + cp "$setup_src" "$bundle_dir/codex-windows-sandbox-setup.exe" + # Use an absolute path so bundle zips land in the real dist + # dir even when 7z runs from a temp directory. + (cd "$bundle_dir" && 7z a "$repo_root/$dest/${base}.zip" .) + else + echo "warning: missing sandbox binaries; falling back to single-binary zip" + echo "warning: expected $runner_src and $setup_src" + (cd "$dest" && 7z a "${base}.zip" "$base") + fi + rm -rf "$bundle_dir" + else + (cd "$dest" && 7z a "${base}.zip" "$base") + fi + fi - strategy: - fail-fast: false - matrix: - include: - - target: aarch64-apple-darwin - bundle: primary - artifact_name: aarch64-apple-darwin - binaries: "codex codex-responses-api-proxy" - build_dmg: "false" - - target: aarch64-apple-darwin - bundle: app-server - artifact_name: aarch64-apple-darwin-app-server - binaries: "codex-app-server" - build_dmg: "false" - - target: x86_64-apple-darwin - bundle: primary - artifact_name: x86_64-apple-darwin - binaries: "codex codex-responses-api-proxy" - build_dmg: "false" - - target: x86_64-apple-darwin - bundle: app-server - artifact_name: x86_64-apple-darwin-app-server - binaries: "codex-app-server" - build_dmg: "false" + # Also create .zst (existing behaviour) *and* remove the original + # uncompressed binary to keep the directory small. + zstd_args=(-T0 -19) + if [[ "${keep_originals}" == false ]]; then + zstd_args+=(--rm) + fi - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false + if [[ "${archive_name}" != "${base}" ]]; then + zstd "${zstd_args[@]}" "$dest/$base" -o "$dest/${archive_name}.zst" + else + zstd "${zstd_args[@]}" "$dest/$base" + fi + done - - name: Download signed macOS handoff + - name: Add Termux release metadata + if: ${{ (github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch') && matrix.target == 'aarch64-linux-android' }} shell: bash - env: - GH_TOKEN: ${{ github.token }} - SIGNED_MACOS_ASSET: ${{ inputs.signed_macos_asset }} - SIGNED_MACOS_SHA256: ${{ inputs.signed_macos_sha256 }} run: | set -euo pipefail - - download_dir="${RUNNER_TEMP}/signed-macos-download" - handoff_dir="${RUNNER_TEMP}/signed-macos-handoff" - rm -rf "$download_dir" "$handoff_dir" - mkdir -p "$download_dir" "$handoff_dir" - - gh release download "$GITHUB_REF_NAME" \ - --repo "$GITHUB_REPOSITORY" \ - --pattern "$SIGNED_MACOS_ASSET" \ - --dir "$download_dir" - - asset_count="$(find "$download_dir" -maxdepth 1 -type f | wc -l | tr -d '[:space:]')" - if [[ "$asset_count" != "1" ]]; then - echo "Expected exactly one signed macOS handoff asset named ${SIGNED_MACOS_ASSET}; found ${asset_count}" - find "$download_dir" -maxdepth 1 -type f -print - exit 1 - fi - - asset_path="$(find "$download_dir" -maxdepth 1 -type f -print -quit)" - if [[ -n "${SIGNED_MACOS_SHA256}" ]]; then - expected_sha="$(printf '%s' "$SIGNED_MACOS_SHA256" | tr '[:upper:]' '[:lower:]')" - actual_sha="$(shasum -a 256 "$asset_path" | awk '{print $1}')" - if [[ "$actual_sha" != "$expected_sha" ]]; then - echo "signed_macos_sha256 mismatch for ${SIGNED_MACOS_ASSET}" - echo "expected: ${expected_sha}" - echo "actual: ${actual_sha}" - exit 1 - fi + if [[ ! -f "${GITHUB_WORKSPACE}/.github/termux-release.json" ]]; then + echo "No Termux release metadata found; skipping metadata attachment." + exit 0 fi + dest="dist/${{ matrix.target }}" + cp "${GITHUB_WORKSPACE}/.github/termux-release.json" "${dest}/termux-release.json" + ( + cd "${dest}" + sha256sum ./* > SHA256SUMS + ) - asset_name="$(basename "$asset_path")" - case "$asset_name" in - *.tar.zst) - zstd -dc "$asset_path" | tar -C "$handoff_dir" -xf - - ;; - *.tar.gz|*.tgz) - tar -C "$handoff_dir" -xzf "$asset_path" - ;; - *.zip) - ditto -x -k "$asset_path" "$handoff_dir" - ;; - *) - echo "Unsupported signed macOS handoff archive format: ${asset_name}" - exit 1 - ;; - esac - - echo "SIGNED_MACOS_HANDOFF_DIR=$handoff_dir" >> "$GITHUB_ENV" + - id: upload-artifact + uses: actions/upload-artifact@v6 + with: + name: ${{ github.event_name == 'pull_request' && format('termux-android-pr-{0}-{1}', github.event.pull_request.number, github.event.pull_request.head.sha) || matrix.target }} + # Upload the per-binary .zst files as well as the new .tar.gz + # equivalents we generated in the previous step. + path: | + codex-rs/dist/${{ matrix.target }}/* - - name: Stage signed macOS artifacts + - name: Publish Termux artifact notification + if: ${{ github.event_name == 'pull_request' && startsWith(github.base_ref, 'release/') && matrix.target == 'aarch64-linux-android' }} shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ARTIFACT_ID: ${{ steps.upload-artifact.outputs.artifact-id }} + ARTIFACT_NAME: ${{ github.event_name == 'pull_request' && format('termux-android-pr-{0}-{1}', github.event.pull_request.number, github.event.pull_request.head.sha) || matrix.target }} + PR_NUMBER: ${{ github.event.pull_request.number }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} run: | set -euo pipefail - - target="${{ matrix.target }}" - artifact_name="${{ matrix.artifact_name }}" - source_dir="${SIGNED_MACOS_HANDOFF_DIR}/${artifact_name}" - if [[ ! -d "$source_dir" && -d "${SIGNED_MACOS_HANDOFF_DIR}/dist/${artifact_name}" ]]; then - source_dir="${SIGNED_MACOS_HANDOFF_DIR}/dist/${artifact_name}" - fi - if [[ ! -d "$source_dir" && -d "${SIGNED_MACOS_HANDOFF_DIR}/${target}" ]]; then - source_dir="${SIGNED_MACOS_HANDOFF_DIR}/${target}" - fi - if [[ ! -d "$source_dir" && -d "${SIGNED_MACOS_HANDOFF_DIR}/dist/${target}" ]]; then - source_dir="${SIGNED_MACOS_HANDOFF_DIR}/dist/${target}" - fi - if [[ ! -d "$source_dir" ]]; then - echo "Signed macOS handoff is missing ${artifact_name}/" - echo "Expected either:" - echo " ${SIGNED_MACOS_HANDOFF_DIR}/${artifact_name}" - echo " ${SIGNED_MACOS_HANDOFF_DIR}/dist/${artifact_name}" - echo " ${SIGNED_MACOS_HANDOFF_DIR}/${target}" - echo " ${SIGNED_MACOS_HANDOFF_DIR}/dist/${target}" - find "$SIGNED_MACOS_HANDOFF_DIR" -maxdepth 3 -type f -print + if [[ -z "${ARTIFACT_ID}" ]]; then + echo "upload-artifact did not return an artifact id" >&2 exit 1 fi - dest="dist/${target}" - mkdir -p "$dest" - - for binary in ${{ matrix.binaries }}; do - source_path="${source_dir}/${binary}" - if [[ ! -f "$source_path" ]]; then - source_path="${source_dir}/${binary}-${target}" - fi - if [[ ! -f "$source_path" ]]; then - echo "Signed macOS handoff is missing ${binary} for ${artifact_name}" - exit 1 - fi - - release_path="${dest}/${binary}-${target}" - ditto "$source_path" "$release_path" - chmod 0755 "$release_path" - codesign --verify --strict --verbose=2 "$release_path" - done - - # DMG staging is disabled for signed promotion because we no longer - # distribute DMGs from this release path. Keep the branch here so the - # handoff can opt back in by flipping matrix.build_dmg if needed. - if [[ "${{ matrix.build_dmg }}" == "true" ]]; then - dmg_name="codex-${target}.dmg" - dmg_source="${source_dir}/${dmg_name}" - if [[ ! -f "$dmg_source" ]]; then - echo "Signed macOS handoff is missing ${dmg_name} for ${artifact_name}" - exit 1 - fi - - codesign --verify --strict --verbose=2 "$dmg_source" - xcrun stapler validate "$dmg_source" - cp "$dmg_source" "$dest/$dmg_name" + marker="" + artifact_url="https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/artifacts/${ARTIFACT_ID}" + run_url="${GH_WORKFLOW_URL:-https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}}" + body_path="${RUNNER_TEMP}/termux-artifact-comment.md" + { + echo "${marker}" + echo "Termux Android artifact ready for testing:" + echo + echo "- Artifact: [${ARTIFACT_NAME}](${artifact_url})" + echo "- Workflow run: [${GITHUB_RUN_ID}](${run_url})" + echo + echo "You must be signed in to GitHub with repository access to download Actions artifacts." + } > "${body_path}" + + existing_comment_id="$( + gh pr view "${PR_NUMBER}" \ + --repo "${GITHUB_REPOSITORY}" \ + --json comments \ + --jq ".comments | map(select(.author.is_bot == true and (.body | contains(\"${marker}\")))) | .[-1].id // \"\"" + )" + if [[ -n "${existing_comment_id}" ]]; then + gh api graphql \ + -f query=' + mutation($id: ID!, $body: String!) { + updateIssueComment(input: {id: $id, body: $body}) { + issueComment { + id + } + } + } + ' \ + -f id="${existing_comment_id}" \ + -f body="$(cat "${body_path}")" \ + >/dev/null + else + gh pr comment "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" --body-file "${body_path}" fi - - name: Build Codex package archive - shell: bash - env: - TARGET: ${{ matrix.target }} - BUNDLE: ${{ matrix.bundle }} - run: | - set -euo pipefail - bash "${GITHUB_WORKSPACE}/.github/scripts/build-codex-package-archive.sh" \ - --target "$TARGET" \ - --bundle "$BUNDLE" \ - --entrypoint-dir "dist/${TARGET}" \ - --archive-dir "dist/${TARGET}" \ - --target-suffixed-entrypoint - - - name: Build Python runtime wheel - if: ${{ matrix.bundle == 'primary' }} - shell: bash - run: | - set -euo pipefail - - case "${{ matrix.target }}" in - aarch64-apple-darwin) - platform_tag="macosx_11_0_arm64" - ;; - x86_64-apple-darwin) - platform_tag="macosx_10_9_x86_64" - ;; - *) - echo "No Python runtime wheel platform tag for ${{ matrix.target }}" - exit 1 - ;; - esac - - python3 -m venv "${RUNNER_TEMP}/python-runtime-build-venv" - "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m pip install build - - stage_dir="${RUNNER_TEMP}/openai-codex-cli-bin-${{ matrix.target }}" - wheel_dir="${GITHUB_WORKSPACE}/python-runtime-dist/${{ matrix.target }}" - python3 \ - "${GITHUB_WORKSPACE}/sdk/python/scripts/update_sdk_artifacts.py" \ - stage-runtime \ - "$stage_dir" \ - "dist/${{ matrix.target }}/codex-package-${{ matrix.target }}.tar.gz" \ - --codex-version "${GITHUB_REF_NAME}" \ - --platform-tag "$platform_tag" - "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m build --wheel --outdir "$wheel_dir" "$stage_dir" - - - name: Upload Python runtime wheel - if: ${{ matrix.bundle == 'primary' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: python-runtime-wheel-${{ matrix.target }} - path: python-runtime-dist/${{ matrix.target }}/*.whl - if-no-files-found: error - - - name: Compress artifacts - shell: bash - run: | - set -euo pipefail - - dest="dist/${{ matrix.target }}" - for f in "$dest"/*; do - base="$(basename "$f")" - if [[ "$base" == *.tar.gz || "$base" == *.tar.zst || "$base" == *.zip || "$base" == *.dmg ]]; then - continue + gh label create binary-ready \ + --repo "${GITHUB_REPOSITORY}" \ + --color 0e8a16 \ + --description "Android binary is ready for testing" \ + --force + gh pr edit "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" --add-label binary-ready + + if [[ -n "${SLACK_WEBHOOK_URL:-}" ]]; then + slack_payload_path="${RUNNER_TEMP}/termux-artifact-slack.json" + pr_url="https://github.com/${GITHUB_REPOSITORY}/pull/${PR_NUMBER}" + jq -n \ + --arg artifact_name "${ARTIFACT_NAME}" \ + --arg artifact_url "${artifact_url}" \ + --arg pr_number "${PR_NUMBER}" \ + --arg pr_url "${pr_url}" \ + --arg run_id "${GITHUB_RUN_ID}" \ + --arg run_url "${run_url}" \ + '{ + text: ("Termux Android artifact ready for testing: " + $artifact_url), + blocks: [ + { + type: "section", + text: { + type: "mrkdwn", + text: ("*Termux Android artifact ready for testing*\n" + $artifact_url) + } + }, + { + type: "section", + fields: [ + { + type: "mrkdwn", + text: ("*Artifact:*\n<" + $artifact_url + "|" + $artifact_name + ">") + }, + { + type: "mrkdwn", + text: ("*Pull request:*\n<" + $pr_url + "|#" + $pr_number + ">") + }, + { + type: "mrkdwn", + text: ("*Workflow run:*\n<" + $run_url + "|" + $run_id + ">") + } + ] + }, + { + type: "context", + elements: [ + { + type: "mrkdwn", + text: "You must be signed in to GitHub with repository access to download Actions artifacts." + } + ] + } + ] + }' > "${slack_payload_path}" + + if ! curl -fsS \ + -X POST \ + -H "Content-type: application/json" \ + --data @"${slack_payload_path}" \ + "${SLACK_WEBHOOK_URL}"; then + echo "::warning::Failed to send Slack artifact notification" fi + fi - tar -C "$dest" -czf "$dest/${base}.tar.gz" "$base" - zstd -T0 -19 --rm "$dest/$base" - done - - - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: ${{ matrix.artifact_name }} - path: | - codex-rs/dist/${{ matrix.target }}/* - - build-windows: - if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} - needs: tag-check - uses: ./.github/workflows/rust-release-windows.yml - with: - release-lto: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'fat' }} - secrets: inherit - - argument-comment-lint-release-assets: - if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} - name: argument-comment-lint release assets + shell-tool-mcp: + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && github.repository_owner == 'openai' + name: shell-tool-mcp needs: tag-check - uses: ./.github/workflows/rust-release-argument-comment-lint.yml + permissions: + contents: read + id-token: write + uses: ./.github/workflows/shell-tool-mcp.yml with: + release-tag: ${{ github.ref_name }} publish: true - - zsh-release-assets: - if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} - name: zsh release assets - needs: tag-check - uses: ./.github/workflows/rust-release-zsh.yml + secrets: inherit release: + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') needs: - - tag-check - build - - stage-signed-macos - - build-windows - - argument-comment-lint-release-assets - - zsh-release-assets - if: >- - ${{ - always() && - needs.tag-check.result == 'success' && - ( - ( - github.event_name == 'workflow_dispatch' && - inputs.release_mode == 'promote_signed' && - needs.stage-signed-macos.result == 'success' && - needs.build.result == 'skipped' && - needs.build-windows.result == 'skipped' && - needs.argument-comment-lint-release-assets.result == 'skipped' && - needs.zsh-release-assets.result == 'skipped' - ) || - ( - (github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed') && - needs.build.result == 'success' && - needs.stage-signed-macos.result == 'skipped' && - needs.build-windows.result == 'success' && - needs.argument-comment-lint-release-assets.result == 'success' && - needs.zsh-release-assets.result == 'success' - ) - ) - }} + - shell-tool-mcp name: release runs-on: ubuntu-latest permissions: contents: write actions: read - env: - RELEASE_MODE: ${{ github.event_name == 'workflow_dispatch' && inputs.release_mode || 'signed' }} - SIGN_MACOS: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode == 'promote_signed' }} - SIGNED_MACOS_ASSET: ${{ inputs.signed_macos_asset }} - UNSIGNED_RUN_ID: ${{ inputs.unsigned_run_id }} outputs: version: ${{ steps.release_name.outputs.name }} tag: ${{ github.ref_name }} - sign_macos: ${{ steps.release_mode.outputs.sign_macos }} should_publish_npm: ${{ steps.npm_publish_settings.outputs.should_publish }} npm_tag: ${{ steps.npm_publish_settings.outputs.npm_tag }} - should_publish_python_runtime: ${{ steps.python_runtime_publish_settings.outputs.should_publish }} steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false + uses: actions/checkout@v6 - - name: Define release mode - id: release_mode - run: | - echo "release_mode=${RELEASE_MODE}" >> "$GITHUB_OUTPUT" - echo "sign_macos=${SIGN_MACOS}" >> "$GITHUB_OUTPUT" + - name: 🧰 Actions Toolbox + # This is required for the GitHub CLI + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: wallentx/gh-actions/composite/actions-toolbox@main - name: Generate release notes from tag commit message id: release_notes @@ -972,159 +1013,21 @@ jobs: echo "path=${notes_path}" >> "${GITHUB_OUTPUT}" - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + - uses: actions/download-artifact@v7 with: path: dist - - name: Validate unsigned build run - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - - run_summary="$(gh run view "$UNSIGNED_RUN_ID" \ - --repo "$GITHUB_REPOSITORY" \ - --json conclusion,event,headBranch,headSha,status,workflowName,url \ - --jq '[.workflowName, .event, .headBranch, .headSha, .status, .conclusion, .url] | @tsv')" - IFS=$'\t' read -r workflow_name event head_branch head_sha status conclusion run_url <<< "$run_summary" - expected_head_sha="$(git rev-parse "${GITHUB_SHA}^{commit}")" - - if [[ "$workflow_name" != "$GITHUB_WORKFLOW" ]]; then - echo "unsigned_run_id ${UNSIGNED_RUN_ID} is for workflow '${workflow_name}', expected '${GITHUB_WORKFLOW}'" - echo "Run URL: ${run_url}" - exit 1 - fi - - if [[ "$event" != "workflow_dispatch" ]]; then - echo "unsigned_run_id ${UNSIGNED_RUN_ID} was triggered by '${event}', expected 'workflow_dispatch'" - echo "Run URL: ${run_url}" - exit 1 - fi - - if [[ "$head_branch" != "$GITHUB_REF_NAME" ]]; then - echo "unsigned_run_id ${UNSIGNED_RUN_ID} used ref '${head_branch}', expected '${GITHUB_REF_NAME}'" - echo "Run URL: ${run_url}" - exit 1 - fi - - if [[ "$head_sha" != "$expected_head_sha" ]]; then - echo "unsigned_run_id ${UNSIGNED_RUN_ID} used head SHA '${head_sha}', expected '${expected_head_sha}'" - echo "Run URL: ${run_url}" - exit 1 - fi - - if [[ "$status" != "completed" || "$conclusion" != "success" ]]; then - echo "unsigned_run_id ${UNSIGNED_RUN_ID} is ${status}/${conclusion}, expected completed/success" - echo "Run URL: ${run_url}" - exit 1 - fi - - - name: Download artifacts from unsigned build run - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - gh run download "$UNSIGNED_RUN_ID" \ - --repo "$GITHUB_REPOSITORY" \ - --dir dist - - - name: Remove unsigned macOS staging artifacts - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - run: | - set -euo pipefail - find dist -mindepth 1 -maxdepth 1 -type d \ - -name '*-apple-darwin*-unsigned' \ - -exec rm -rf {} + - - - name: Re-upload promoted Linux x64 artifacts - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: x86_64-unknown-linux-musl - path: dist/x86_64-unknown-linux-musl/* - if-no-files-found: error - - - name: Re-upload promoted Linux arm64 artifacts - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: aarch64-unknown-linux-musl - path: dist/aarch64-unknown-linux-musl/* - if-no-files-found: error - - - name: Re-upload promoted Windows x64 artifacts - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: x86_64-pc-windows-msvc - path: dist/x86_64-pc-windows-msvc/* - if-no-files-found: error - - - name: Re-upload promoted Windows arm64 artifacts - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: aarch64-pc-windows-msvc - path: dist/aarch64-pc-windows-msvc/* - if-no-files-found: error - - name: List run: ls -R dist/ - - name: Prune artifacts excluded from unsigned macOS release - if: ${{ env.SIGN_MACOS == 'false' }} - run: | - find dist -mindepth 1 -maxdepth 1 -type d \ - ! -name '*-apple-darwin*-unsigned' \ - ! -name 'aarch64-unknown-linux-musl' \ - ! -name 'aarch64-unknown-linux-musl-app-server' \ - ! -name 'x86_64-unknown-linux-musl' \ - ! -name 'x86_64-unknown-linux-musl-app-server' \ - ! -name 'aarch64-pc-windows-msvc' \ - ! -name 'x86_64-pc-windows-msvc' \ - -exec rm -rf {} + - - if ! find dist -type f -name '*-apple-darwin*-unsigned*' | grep -q .; then - echo "No unsigned macOS artifacts found in downloaded workflow artifacts." - exit 1 - fi - + # This is a temporary fix: we should modify shell-tool-mcp.yml so these + # files do not end up in dist/ in the first place. - name: Delete entries from dist/ that should not go in the release run: | - rm -rf dist/windows-binaries* - # cargo-timing.html appears under multiple target-specific directories. - # If included in files: dist/**, release upload races on duplicate - # asset names and can fail with 404s. - find dist -type f -name 'cargo-timing.html' -delete - find dist -type d -empty -delete + rm -rf dist/shell-tool-mcp* ls -R dist/ - - name: Add Codex package checksum manifest - run: | - set -euo pipefail - - manifest="dist/codex-package_SHA256SUMS" - tmp_manifest="$(mktemp)" - find dist -type f \ - \( -name 'codex-package-*.tar.gz' -o -name 'codex-app-server-package-*.tar.gz' \) \ - -print | - sort | - while IFS= read -r archive; do - sha256sum "$archive" | - awk -v name="$(basename "$archive")" '{ print $1 " " name }' - done > "$tmp_manifest" - - if [[ ! -s "$tmp_manifest" ]]; then - echo "No Codex package archives found for checksum manifest" - exit 1 - fi - - mv "$tmp_manifest" "$manifest" - cat "$manifest" - - name: Add config schema release asset run: | cp codex-rs/core/config.schema.json dist/config-schema.json @@ -1145,12 +1048,6 @@ jobs: set -euo pipefail version="${VERSION}" - if [[ "${SIGN_MACOS}" != "true" ]]; then - echo "should_publish=false" >> "$GITHUB_OUTPUT" - echo "npm_tag=" >> "$GITHUB_OUTPUT" - exit 0 - fi - if [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "should_publish=true" >> "$GITHUB_OUTPUT" echo "npm_tag=" >> "$GITHUB_OUTPUT" @@ -1162,130 +1059,55 @@ jobs: echo "npm_tag=" >> "$GITHUB_OUTPUT" fi - - name: Determine Python runtime publish settings - id: python_runtime_publish_settings - env: - VERSION: ${{ steps.release_name.outputs.name }} - run: | - set -euo pipefail - version="${VERSION}" - - if [[ "${SIGN_MACOS}" != "true" ]]; then - echo "should_publish=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - if [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "should_publish=true" >> "$GITHUB_OUTPUT" - elif [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+-alpha\.[0-9]+$ ]]; then - echo "should_publish=true" >> "$GITHUB_OUTPUT" - else - echo "should_publish=false" >> "$GITHUB_OUTPUT" - fi - - name: Setup pnpm - if: ${{ env.SIGN_MACOS == 'true' }} - uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5 + uses: pnpm/action-setup@v4 with: run_install: false - name: Setup Node.js for npm packaging - if: ${{ env.SIGN_MACOS == 'true' }} - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + uses: actions/setup-node@v6 with: node-version: 22 - name: Install dependencies - if: ${{ env.SIGN_MACOS == 'true' }} run: pnpm install --frozen-lockfile + # stage_npm_packages.py requires DotSlash when staging releases. + - uses: facebook/install-dotslash@v2 - name: Stage npm packages - if: ${{ env.SIGN_MACOS == 'true' }} + if: github.repository_owner == 'openai' env: GH_TOKEN: ${{ github.token }} - RELEASE_VERSION: ${{ steps.release_name.outputs.name }} run: | - workflow_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" ./scripts/stage_npm_packages.py \ - --release-version "$RELEASE_VERSION" \ - --workflow-url "$workflow_url" \ + --release-version "${{ steps.release_name.outputs.name }}" \ --package codex \ --package codex-responses-api-proxy \ --package codex-sdk - - name: Stage installer scripts - if: ${{ env.SIGN_MACOS == 'true' }} - run: | - cp scripts/install/install.sh dist/install.sh - cp scripts/install/install.ps1 dist/install.ps1 - - name: Create GitHub Release - uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1 + uses: softprops/action-gh-release@v2 with: name: ${{ steps.release_name.outputs.name }} tag_name: ${{ github.ref_name }} body_path: ${{ steps.release_notes.outputs.path }} files: dist/** - overwrite_files: true - make_latest: ${{ env.SIGN_MACOS == 'true' && !contains(steps.release_name.outputs.name, '-') }} # Mark as prerelease only when the version has a suffix after x.y.z # (e.g. -alpha, -beta). Otherwise publish a normal release. prerelease: ${{ contains(steps.release_name.outputs.name, '-') }} - - name: Clean up signed promotion handoff assets - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - - release_id="$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${GITHUB_REF_NAME}" --jq '.id')" - gh api --paginate "repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets" \ - --jq '.[] | [.id, .name] | @tsv' | - while IFS=$'\t' read -r asset_id asset_name; do - if [[ -z "$asset_id" || -z "$asset_name" ]]; then - continue - fi - - delete_asset=false - if [[ "$asset_name" == *unsigned* || "$asset_name" == "$SIGNED_MACOS_ASSET" ]]; then - delete_asset=true - fi - - if [[ "$delete_asset" == "true" ]]; then - echo "Deleting release asset ${asset_name}" - gh api -X DELETE "repos/${GITHUB_REPOSITORY}/releases/assets/${asset_id}" - fi - done - - - if: ${{ env.SIGN_MACOS == 'true' }} - uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 + - if: github.repository_owner == 'openai' + uses: facebook/dotslash-publish-release@v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: tag: ${{ github.ref_name }} config: .github/dotslash-config.json - - if: ${{ env.SIGN_MACOS == 'true' }} - uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag: ${{ github.ref_name }} - config: .github/dotslash-zsh-config.json - - - if: ${{ env.SIGN_MACOS == 'true' }} - uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag: ${{ github.ref_name }} - config: .github/dotslash-argument-comment-lint-config.json - - name: Trigger developers.openai.com deploy # Only trigger the deploy if the release is not a pre-release. # The deploy is used to update the developers.openai.com website with the new config schema json file. - if: ${{ env.SIGN_MACOS == 'true' && !contains(steps.release_name.outputs.name, '-') }} + if: ${{ !contains(steps.release_name.outputs.name, '-') && github.repository_owner == 'openai' }} continue-on-error: true env: DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL: ${{ secrets.DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL }} @@ -1300,15 +1122,7 @@ jobs: # npm docs: https://docs.npmjs.com/trusted-publishers publish-npm: # Publish to npm for stable releases and alpha pre-releases with numeric suffixes. - # promote_signed intentionally skips build jobs that are ancestors of release; - # include the !cancelled() status function so Actions does not apply its implicit - # success() check to the whole dependency chain before evaluating release outputs. - if: >- - ${{ - !cancelled() && - needs.release.result == 'success' && - needs.release.outputs.should_publish_npm == 'true' - }} + if: ${{ needs.release.outputs.should_publish_npm == 'true' && github.repository_owner == 'openai' }} name: publish-npm needs: release runs-on: ubuntu-latest @@ -1318,37 +1132,36 @@ jobs: steps: - name: Setup Node.js - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + uses: actions/setup-node@v6 with: - # Node 24 bundles npm >= 11.5.1, which trusted publishing requires. - node-version: 24 + node-version: 22 registry-url: "https://registry.npmjs.org" scope: "@openai" + # Trusted publishing requires npm CLI version 11.5.1 or later. + - name: Update npm + run: npm install -g npm@latest + - name: Download npm tarballs from release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_TAG: ${{ needs.release.outputs.tag }} - RELEASE_VERSION: ${{ needs.release.outputs.version }} run: | set -euo pipefail - version="$RELEASE_VERSION" - tag="$RELEASE_TAG" + version="${{ needs.release.outputs.version }}" + tag="${{ needs.release.outputs.tag }}" mkdir -p dist/npm - patterns=( - "codex-npm-${version}.tgz" - "codex-npm-linux-*-${version}.tgz" - "codex-npm-darwin-*-${version}.tgz" - "codex-npm-win32-*-${version}.tgz" - "codex-responses-api-proxy-npm-${version}.tgz" - "codex-sdk-npm-${version}.tgz" - ) - for pattern in "${patterns[@]}"; do - gh release download "$tag" \ - --repo "${GITHUB_REPOSITORY}" \ - --pattern "$pattern" \ - --dir dist/npm - done + gh release download "$tag" \ + --repo "${GITHUB_REPOSITORY}" \ + --pattern "codex-npm-${version}.tgz" \ + --dir dist/npm + gh release download "$tag" \ + --repo "${GITHUB_REPOSITORY}" \ + --pattern "codex-responses-api-proxy-npm-${version}.tgz" \ + --dir dist/npm + gh release download "$tag" \ + --repo "${GITHUB_REPOSITORY}" \ + --pattern "codex-sdk-npm-${version}.tgz" \ + --dir dist/npm # No NODE_AUTH_TOKEN needed because we use OIDC. - name: Publish to npm @@ -1357,193 +1170,23 @@ jobs: NPM_TAG: ${{ needs.release.outputs.npm_tag }} run: | set -euo pipefail - prefix="" + tag_args=() if [[ -n "${NPM_TAG}" ]]; then - prefix="${NPM_TAG}-" + tag_args+=(--tag "${NPM_TAG}") fi - root_tarball="dist/npm/codex-npm-${VERSION}.tgz" - sdk_tarball="dist/npm/codex-sdk-npm-${VERSION}.tgz" - # Keep this list in sync with CODEX_PLATFORM_PACKAGES in - # codex-cli/scripts/build_npm_package.py. The root wrapper advances - # @openai/codex@latest as soon as it publishes, so every platform - # package it aliases must already exist in the registry first. - platform_tarballs=( - "dist/npm/codex-npm-linux-x64-${VERSION}.tgz" - "dist/npm/codex-npm-linux-arm64-${VERSION}.tgz" - "dist/npm/codex-npm-darwin-x64-${VERSION}.tgz" - "dist/npm/codex-npm-darwin-arm64-${VERSION}.tgz" - "dist/npm/codex-npm-win32-x64-${VERSION}.tgz" - "dist/npm/codex-npm-win32-arm64-${VERSION}.tgz" - ) - - for required_tarball in "${platform_tarballs[@]}" "${root_tarball}"; do - if [[ ! -f "${required_tarball}" ]]; then - echo "Missing npm tarball: ${required_tarball}" - exit 1 - fi - done - - shopt -s nullglob - other_tarballs=() - for tarball in dist/npm/*-"${VERSION}".tgz; do - if [[ "${tarball}" == "${root_tarball}" || "${tarball}" == "${sdk_tarball}" ]]; then - continue - fi - - is_platform_tarball=false - for platform_tarball in "${platform_tarballs[@]}"; do - if [[ "${tarball}" == "${platform_tarball}" ]]; then - is_platform_tarball=true - break - fi - done - if [[ "${is_platform_tarball}" == true ]]; then - continue - fi - - other_tarballs+=("${tarball}") - done - - # Publish the platform packages before the root CLI wrapper. The root - # wrapper advances @openai/codex@latest, so it should only publish - # after the optional dependency versions it references exist. tarballs=( - "${platform_tarballs[@]}" - "${other_tarballs[@]}" - "${root_tarball}" + "codex-npm-${VERSION}.tgz" + "codex-responses-api-proxy-npm-${VERSION}.tgz" + "codex-sdk-npm-${VERSION}.tgz" ) - if [[ -f "${sdk_tarball}" ]]; then - tarballs+=("${sdk_tarball}") - fi for tarball in "${tarballs[@]}"; do - filename="$(basename "${tarball}")" - tag="" - - case "${filename}" in - codex-npm-linux-*-"${VERSION}".tgz|codex-npm-darwin-*-"${VERSION}".tgz|codex-npm-win32-*-"${VERSION}".tgz) - platform="${filename#codex-npm-}" - platform="${platform%-${VERSION}.tgz}" - tag="${prefix}${platform}" - ;; - codex-npm-"${VERSION}".tgz|codex-responses-api-proxy-npm-"${VERSION}".tgz|codex-sdk-npm-"${VERSION}".tgz) - tag="${NPM_TAG}" - ;; - *) - echo "Unexpected npm tarball: ${filename}" - exit 1 - ;; - esac - - publish_cmd=(npm publish "${GITHUB_WORKSPACE}/${tarball}") - if [[ -n "${tag}" ]]; then - publish_cmd+=(--tag "${tag}") - fi - - echo "+ ${publish_cmd[*]}" - set +e - publish_output="$("${publish_cmd[@]}" 2>&1)" - publish_status=$? - set -e - - echo "${publish_output}" - if [[ ${publish_status} -eq 0 ]]; then - continue - fi - - if grep -qiE "previously published|cannot publish over|version already exists" <<< "${publish_output}"; then - echo "Skipping already-published package version for ${filename}" - continue - fi - - exit "${publish_status}" + npm publish "${GITHUB_WORKSPACE}/dist/npm/${tarball}" "${tag_args[@]}" done - # Publish the platform-specific Python runtime wheels using PyPI trusted publishing. - # PyPI project configuration must trust this workflow and job. Keep this - # non-blocking while the Python runtime publishing path is new; failures still - # need release follow-up, but should not invalidate the Rust release itself. - publish-python-runtime: - # Publish to PyPI for stable releases and alpha pre-releases with numeric suffixes. - if: >- - ${{ - !cancelled() && - needs.release.result == 'success' && - needs.release.outputs.should_publish_python_runtime == 'true' - }} - name: publish-python-runtime - needs: release - runs-on: ubuntu-latest - continue-on-error: true - environment: pypi - permissions: - id-token: write # Required for PyPI trusted publishing. - contents: read - - steps: - - name: Download Python runtime wheels from release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_TAG: ${{ needs.release.outputs.tag }} - RELEASE_VERSION: ${{ needs.release.outputs.version }} - run: | - set -euo pipefail - python_version="$RELEASE_VERSION" - python_version="${python_version/-alpha./a}" - python_version="${python_version/-beta./b}" - python_version="${python_version/-rc./rc}" - - mkdir -p dist/python-runtime - gh release download "$RELEASE_TAG" \ - --repo "${GITHUB_REPOSITORY}" \ - --pattern "openai_codex_cli_bin-${python_version}-*.whl" \ - --dir dist/python-runtime - ls -lh dist/python-runtime - - - name: Publish Python runtime wheels to PyPI - uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 - with: - packages-dir: dist/python-runtime - skip-existing: true - - winget: - name: winget - needs: release - # Only publish stable/mainline releases to WinGet; pre-releases include a - # '-' in the semver string (e.g., 1.2.3-alpha.1). - if: >- - ${{ - !cancelled() && - needs.release.result == 'success' && - needs.release.outputs.sign_macos == 'true' && - !contains(needs.release.outputs.version, '-') - }} - # This job only invokes a GitHub Action to open/update the winget-pkgs PR; - # it does not execute Windows-only tooling, so Linux is sufficient. - runs-on: ubuntu-latest - permissions: - contents: read - - steps: - - name: Publish to WinGet - uses: vedantmgoyal9/winget-releaser@7bd472be23763def6e16bd06cc8b1cdfab0e2fd5 - with: - identifier: OpenAI.Codex - version: ${{ needs.release.outputs.version }} - release-tag: ${{ needs.release.outputs.tag }} - fork-user: openai-oss-forks - installers-regex: '^codex-(?:x86_64|aarch64)-pc-windows-msvc\.exe\.zip$' - token: ${{ secrets.WINGET_PUBLISH_PAT }} - update-branch: name: Update latest-alpha-cli branch - if: >- - ${{ - !cancelled() && - needs.release.result == 'success' && - needs.release.outputs.sign_macos == 'true' - }} permissions: contents: write needs: release diff --git a/.github/workflows/shell-tool-mcp.yml b/.github/workflows/shell-tool-mcp.yml new file mode 100644 index 000000000000..66a76aa4d938 --- /dev/null +++ b/.github/workflows/shell-tool-mcp.yml @@ -0,0 +1,461 @@ +name: shell-tool-mcp + +on: + workflow_call: + inputs: + release-version: + description: Version to publish (x.y.z or x.y.z-alpha.N). Defaults to GITHUB_REF_NAME when it starts with rust-v. + required: false + type: string + release-tag: + description: Tag name to use when downloading release artifacts (defaults to rust-v). + required: false + type: string + publish: + description: Whether to publish to npm when the version is releasable. + required: false + default: true + type: boolean + +env: + NODE_VERSION: 22 + +jobs: + metadata: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.compute.outputs.version }} + release_tag: ${{ steps.compute.outputs.release_tag }} + should_publish: ${{ steps.compute.outputs.should_publish }} + npm_tag: ${{ steps.compute.outputs.npm_tag }} + steps: + - name: Compute version and tags + id: compute + run: | + set -euo pipefail + + version="${{ inputs.release-version }}" + release_tag="${{ inputs.release-tag }}" + + if [[ -z "$version" ]]; then + if [[ -n "$release_tag" && "$release_tag" =~ ^rust-v.+ ]]; then + version="${release_tag#rust-v}" + elif [[ "${GITHUB_REF_NAME:-}" =~ ^rust-v.+ ]]; then + version="${GITHUB_REF_NAME#rust-v}" + release_tag="${GITHUB_REF_NAME}" + else + echo "release-version is required when GITHUB_REF_NAME is not a rust-v tag." + exit 1 + fi + fi + + if [[ -z "$release_tag" ]]; then + release_tag="rust-v${version}" + fi + + npm_tag="" + should_publish="false" + if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + should_publish="true" + elif [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+-alpha\.[0-9]+$ ]]; then + should_publish="true" + npm_tag="alpha" + fi + + echo "version=${version}" >> "$GITHUB_OUTPUT" + echo "release_tag=${release_tag}" >> "$GITHUB_OUTPUT" + echo "npm_tag=${npm_tag}" >> "$GITHUB_OUTPUT" + echo "should_publish=${should_publish}" >> "$GITHUB_OUTPUT" + + matrix-setup: + runs-on: ubuntu-latest + outputs: + rust-binaries: ${{ steps.compute.outputs.rust-binaries }} + bash-linux: ${{ steps.compute.outputs.bash-linux }} + bash-darwin: ${{ steps.compute.outputs.bash-darwin }} + steps: + - name: Compute matrices + id: compute + shell: bash + run: | + set -euo pipefail + is_openai="${{ github.repository_owner == 'openai' }}" + + # rust-binaries: always include x86_64-musl; conditionally include paid/fork runners + if [[ "$is_openai" == "true" ]]; then + rust='[ + {"runner":"macos-15-xlarge","target":"aarch64-apple-darwin"}, + {"runner":"macos-15-xlarge","target":"x86_64-apple-darwin"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","install_musl":true} + ]' + else + rust='[{"runner":"macos-latest","target":"aarch64-apple-darwin"}]' + fi + rust=$(echo "$rust" | jq -c '. + [{"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","install_musl":true}]') + echo "rust-binaries={\"include\":$rust}" >> "$GITHUB_OUTPUT" + + # bash-linux: always include x86_64 variants; add arm64 on openai + bash_linux='[ + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","variant":"ubuntu-24.04","image":"ubuntu:24.04"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","variant":"ubuntu-22.04","image":"ubuntu:22.04"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","variant":"debian-12","image":"debian:12"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","variant":"debian-11","image":"debian:11"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","variant":"centos-9","image":"quay.io/centos/centos:stream9"} + ]' + if [[ "$is_openai" == "true" ]]; then + bash_linux=$(echo "$bash_linux" | jq -c '. + [ + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"ubuntu-24.04","image":"arm64v8/ubuntu:24.04"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"ubuntu-22.04","image":"arm64v8/ubuntu:22.04"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"ubuntu-20.04","image":"arm64v8/ubuntu:20.04"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"debian-12","image":"arm64v8/debian:12"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"debian-11","image":"arm64v8/debian:11"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"centos-9","image":"quay.io/centos/centos:stream9"} + ]') + fi + echo "bash-linux={\"include\":$(echo "$bash_linux" | jq -c)}" >> "$GITHUB_OUTPUT" + + # bash-darwin: always include macos-14; add macos-15-xlarge on openai + bash_darwin='[{"runner":"macos-14","target":"aarch64-apple-darwin","variant":"macos-14"}]' + if [[ "$is_openai" == "true" ]]; then + bash_darwin=$(echo "$bash_darwin" | jq -c '. + [ + {"runner":"macos-15-xlarge","target":"aarch64-apple-darwin","variant":"macos-15"} + ]') + fi + echo "bash-darwin={\"include\":$(echo "$bash_darwin" | jq -c)}" >> "$GITHUB_OUTPUT" + + rust-binaries: + name: Build Rust - ${{ matrix.target }} + needs: [metadata, matrix-setup] + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + defaults: + run: + working-directory: codex-rs + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.matrix-setup.outputs.rust-binaries) }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Install UBSan runtime (musl) + if: ${{ matrix.install_musl }} + shell: bash + run: | + set -euo pipefail + if command -v apt-get >/dev/null 2>&1; then + sudo apt-get update -y + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y libubsan1 + fi + + - uses: dtolnay/rust-toolchain@1.93 + with: + targets: ${{ matrix.target }} + + - if: ${{ matrix.install_musl }} + name: Install Zig + uses: mlugg/setup-zig@v2 + with: + version: 0.14.0 + + - if: ${{ matrix.install_musl }} + name: Install musl build dependencies + env: + TARGET: ${{ matrix.target }} + run: bash "${GITHUB_WORKSPACE}/.github/scripts/install-musl-build-tools.sh" + + - if: ${{ matrix.install_musl }} + name: Configure rustc UBSan wrapper (musl host) + shell: bash + run: | + set -euo pipefail + ubsan="" + if command -v ldconfig >/dev/null 2>&1; then + ubsan="$(ldconfig -p | grep -m1 'libubsan\.so\.1' | sed -E 's/.*=> (.*)$/\1/')" + fi + wrapper_root="${RUNNER_TEMP:-/tmp}" + wrapper="${wrapper_root}/rustc-ubsan-wrapper" + cat > "${wrapper}" <> "$GITHUB_ENV" + echo "RUSTC_WORKSPACE_WRAPPER=" >> "$GITHUB_ENV" + + - if: ${{ matrix.install_musl }} + name: Clear sanitizer flags (musl) + shell: bash + run: | + set -euo pipefail + # Clear global Rust flags so host/proc-macro builds don't pull in UBSan. + echo "RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_ENCODED_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "RUSTDOCFLAGS=" >> "$GITHUB_ENV" + # Override any runner-level Cargo config rustflags as well. + echo "CARGO_BUILD_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_RUSTFLAGS=" >> "$GITHUB_ENV" + + sanitize_flags() { + local input="$1" + input="${input//-fsanitize=undefined/}" + input="${input//-fno-sanitize-recover=undefined/}" + input="${input//-fno-sanitize-trap=undefined/}" + echo "$input" + } + + cflags="$(sanitize_flags "${CFLAGS-}")" + cxxflags="$(sanitize_flags "${CXXFLAGS-}")" + echo "CFLAGS=${cflags}" >> "$GITHUB_ENV" + echo "CXXFLAGS=${cxxflags}" >> "$GITHUB_ENV" + + - name: Build exec server binaries + run: cargo build --release --target ${{ matrix.target }} --bin codex-exec-mcp-server --bin codex-execve-wrapper + + - name: Stage exec server binaries + run: | + dest="${GITHUB_WORKSPACE}/artifacts/vendor/${{ matrix.target }}" + mkdir -p "$dest" + cp "target/${{ matrix.target }}/release/codex-exec-mcp-server" "$dest/" + cp "target/${{ matrix.target }}/release/codex-execve-wrapper" "$dest/" + + - uses: actions/upload-artifact@v6 + with: + name: shell-tool-mcp-rust-${{ matrix.target }} + path: artifacts/** + if-no-files-found: error + + bash-linux: + name: Build Bash (Linux) - ${{ matrix.variant }} - ${{ matrix.target }} + needs: [metadata, matrix-setup] + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + container: + image: ${{ matrix.image }} + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.matrix-setup.outputs.bash-linux) }} + steps: + - name: Install build prerequisites + shell: bash + run: | + set -euo pipefail + if command -v apt-get >/dev/null 2>&1; then + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y git build-essential bison autoconf gettext + elif command -v dnf >/dev/null 2>&1; then + dnf install -y git gcc gcc-c++ make bison autoconf gettext + elif command -v yum >/dev/null 2>&1; then + yum install -y git gcc gcc-c++ make bison autoconf gettext + else + echo "Unsupported package manager in container" + exit 1 + fi + + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Build patched Bash + shell: bash + run: | + set -euo pipefail + git clone --depth 1 https://github.com/bolinfest/bash /tmp/bash + cd /tmp/bash + git fetch --depth 1 origin a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b + git checkout a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b + git apply "${GITHUB_WORKSPACE}/shell-tool-mcp/patches/bash-exec-wrapper.patch" + ./configure --without-bash-malloc + cores="$(command -v nproc >/dev/null 2>&1 && nproc || getconf _NPROCESSORS_ONLN)" + make -j"${cores}" + + dest="${GITHUB_WORKSPACE}/artifacts/vendor/${{ matrix.target }}/bash/${{ matrix.variant }}" + mkdir -p "$dest" + cp bash "$dest/bash" + + - uses: actions/upload-artifact@v6 + with: + name: shell-tool-mcp-bash-${{ matrix.target }}-${{ matrix.variant }} + path: artifacts/** + if-no-files-found: error + + bash-darwin: + name: Build Bash (macOS) - ${{ matrix.variant }} - ${{ matrix.target }} + needs: [metadata, matrix-setup] + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.matrix-setup.outputs.bash-darwin) }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Build patched Bash + shell: bash + run: | + set -euo pipefail + git clone --depth 1 https://github.com/bolinfest/bash /tmp/bash + cd /tmp/bash + git fetch --depth 1 origin a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b + git checkout a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b + git apply "${GITHUB_WORKSPACE}/shell-tool-mcp/patches/bash-exec-wrapper.patch" + ./configure --without-bash-malloc + cores="$(getconf _NPROCESSORS_ONLN)" + make -j"${cores}" + + dest="${GITHUB_WORKSPACE}/artifacts/vendor/${{ matrix.target }}/bash/${{ matrix.variant }}" + mkdir -p "$dest" + cp bash "$dest/bash" + + - uses: actions/upload-artifact@v6 + with: + name: shell-tool-mcp-bash-${{ matrix.target }}-${{ matrix.variant }} + path: artifacts/** + if-no-files-found: error + + package: + name: Package npm module + needs: + - metadata + - rust-binaries + - bash-linux + - bash-darwin + runs-on: ubuntu-latest + env: + PACKAGE_VERSION: ${{ needs.metadata.outputs.version }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Install JavaScript dependencies + run: pnpm install --frozen-lockfile + + - name: Build (shell-tool-mcp) + run: pnpm --filter @openai/codex-shell-tool-mcp run build + + - name: Download build artifacts + uses: actions/download-artifact@v7 + with: + path: artifacts + + - name: Assemble staging directory + id: staging + shell: bash + run: | + set -euo pipefail + staging="${STAGING_DIR}" + mkdir -p "$staging" "$staging/vendor" + cp shell-tool-mcp/README.md "$staging/" + cp shell-tool-mcp/package.json "$staging/" + cp -R shell-tool-mcp/bin "$staging/" + + found_vendor="false" + shopt -s nullglob + for vendor_dir in artifacts/*/vendor; do + rsync -av "$vendor_dir/" "$staging/vendor/" + found_vendor="true" + done + if [[ "$found_vendor" == "false" ]]; then + echo "No vendor payloads were downloaded." + exit 1 + fi + + node - <<'NODE' + import fs from "node:fs"; + import path from "node:path"; + + const stagingDir = process.env.STAGING_DIR; + const version = process.env.PACKAGE_VERSION; + const pkgPath = path.join(stagingDir, "package.json"); + const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")); + pkg.version = version; + fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n"); + NODE + + echo "dir=$staging" >> "$GITHUB_OUTPUT" + env: + STAGING_DIR: ${{ runner.temp }}/shell-tool-mcp + + - name: Ensure binaries are executable + run: | + set -euo pipefail + staging="${{ steps.staging.outputs.dir }}" + chmod +x \ + "$staging"/vendor/*/codex-exec-mcp-server \ + "$staging"/vendor/*/codex-execve-wrapper \ + "$staging"/vendor/*/bash/*/bash + + - name: Create npm tarball + shell: bash + run: | + set -euo pipefail + mkdir -p dist/npm + staging="${{ steps.staging.outputs.dir }}" + pack_info=$(cd "$staging" && npm pack --ignore-scripts --json --pack-destination "${GITHUB_WORKSPACE}/dist/npm") + filename=$(PACK_INFO="$pack_info" node -e 'const data = JSON.parse(process.env.PACK_INFO); console.log(data[0].filename);') + mv "dist/npm/${filename}" "dist/npm/codex-shell-tool-mcp-npm-${PACKAGE_VERSION}.tgz" + + - uses: actions/upload-artifact@v6 + with: + name: codex-shell-tool-mcp-npm + path: dist/npm/codex-shell-tool-mcp-npm-${{ env.PACKAGE_VERSION }}.tgz + if-no-files-found: error + + publish: + name: Publish npm package + needs: + - metadata + - package + if: ${{ inputs.publish && needs.metadata.outputs.should_publish == 'true' }} + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + steps: + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + registry-url: https://registry.npmjs.org + scope: "@openai" + + # Trusted publishing requires npm CLI version 11.5.1 or later. + - name: Update npm + run: npm install -g npm@latest + + - name: Download npm tarball + uses: actions/download-artifact@v7 + with: + name: codex-shell-tool-mcp-npm + path: dist/npm + + - name: Publish to npm + if: github.repository == 'openai/codex' + env: + NPM_TAG: ${{ needs.metadata.outputs.npm_tag }} + VERSION: ${{ needs.metadata.outputs.version }} + shell: bash + run: | + set -euo pipefail + tag_args=() + if [[ -n "${NPM_TAG}" ]]; then + tag_args+=(--tag "${NPM_TAG}") + fi + npm publish "dist/npm/codex-shell-tool-mcp-npm-${VERSION}.tgz" "${tag_args[@]}" diff --git a/.github/workflows/termux-release-checkpoint.yml b/.github/workflows/termux-release-checkpoint.yml new file mode 100644 index 000000000000..0347e6f1b3d6 --- /dev/null +++ b/.github/workflows/termux-release-checkpoint.yml @@ -0,0 +1,103 @@ +name: termux-release-checkpoint + +on: + workflow_dispatch: + inputs: + source_branch: + description: "Release branch to checkpoint from, for example release/0.123.0" + required: false + type: string + default: "" + source_sha: + description: "Specific source commit SHA to checkpoint; defaults to the source branch tip" + required: false + type: string + default: "" + destination_branch: + description: "Destination patch branch to receive the checkpoint PR" + required: false + type: string + default: "wallentx/termux-target" + reviewer: + description: "GitHub username to request as reviewer" + required: false + type: string + default: "wallentx" + +permissions: + actions: read + attestations: read + checks: read + contents: read + deployments: read + issues: read + discussions: read + packages: read + pages: read + pull-requests: read + repository-projects: read + statuses: read + +concurrency: + group: termux-release-checkpoint-${{ inputs.source_branch || github.ref_name }} + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + checkpoint: + runs-on: ubuntu-slim + permissions: + contents: write + issues: write + pull-requests: write + env: + DESTINATION_BRANCH: ${{ inputs.destination_branch || 'wallentx/termux-target' }} + REVIEWER: ${{ inputs.reviewer || 'wallentx' }} + REQUESTED_SOURCE_BRANCH: ${{ inputs.source_branch }} + REQUESTED_SOURCE_SHA: ${{ inputs.source_sha }} + TERMUX_AUTOMATION_DIR: ${{ github.workspace }}/.termux-release-automation + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Export GitHub App token for gh + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + run: echo "GH_TOKEN=${APP_TOKEN}" >> "${GITHUB_ENV}" + + - name: Checkout release branch + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ inputs.source_branch || github.ref_name }} + token: ${{ steps.app-token.outputs.token }} + + - name: Checkout automation helpers + uses: actions/checkout@v6 + with: + fetch-depth: 1 + ref: ${{ github.workflow_sha }} + path: .termux-release-automation + token: ${{ steps.app-token.outputs.token }} + + - name: 🧰 Actions Toolbox + uses: wallentx/gh-actions/composite/actions-toolbox@main + + - name: Validate GitHub CLI environment + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-validate-gh-env.sh" + + - name: Configure git + run: | + set -euo pipefail + source_branch="${REQUESTED_SOURCE_BRANCH:-${GITHUB_REF_NAME}}" + bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-configure-git.sh" --origin "${DESTINATION_BRANCH}" "${source_branch}" + + - name: Create checkpoint PR + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-create-checkpoint-pr.sh" diff --git a/.github/workflows/termux-release-deploy.yml b/.github/workflows/termux-release-deploy.yml new file mode 100644 index 000000000000..2071611dacc4 --- /dev/null +++ b/.github/workflows/termux-release-deploy.yml @@ -0,0 +1,243 @@ +name: termux-release-deploy + +on: + push: + branches: + - "release/**" + workflow_dispatch: + inputs: + release_branch: + description: "Release branch to deploy, for example release/0.124.0" + required: true + type: string + release_sha: + description: "Release branch commit SHA to deploy. Defaults to the branch head." + required: false + default: "" + type: string + pr_number: + description: "Merged release PR number to promote. Optional; normally discovered automatically." + required: false + default: "" + type: string + pr_head_sha: + description: "Merged release PR head SHA. Required only when pr_number is set." + required: false + default: "" + type: string + destination_branch: + description: "Destination patch branch to receive the checkpoint PR" + required: false + default: "wallentx/termux-target" + type: string + reviewer: + description: "GitHub username to request as reviewer on the checkpoint PR" + required: false + default: "wallentx" + type: string + +permissions: + actions: read + attestations: read + checks: read + contents: read + deployments: read + issues: read + discussions: read + packages: read + pages: read + pull-requests: read + repository-projects: read + statuses: read + +concurrency: + group: termux-release-deploy-${{ github.event_name == 'workflow_dispatch' && inputs.release_branch || github.ref_name }} + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + deploy: + runs-on: ubuntu-24.04 + if: ${{ github.event_name == 'workflow_dispatch' || !startsWith(github.event.head_commit.message, 'Seed Termux release automation') }} + permissions: + actions: read + contents: write + deployments: write + issues: write + pull-requests: write + env: + REQUESTED_RELEASE_BRANCH: ${{ inputs.release_branch }} + REQUESTED_RELEASE_SHA: ${{ inputs.release_sha }} + INPUT_PR_NUMBER: ${{ inputs.pr_number }} + INPUT_PR_HEAD_SHA: ${{ inputs.pr_head_sha }} + DESTINATION_BRANCH: ${{ inputs.destination_branch || 'wallentx/termux-target' }} + REVIEWER: ${{ inputs.reviewer || 'wallentx' }} + TERMUX_AUTOMATION_DIR: ${{ github.workspace }}/.termux-release-automation + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Export GitHub App token for gh + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + run: echo "GH_TOKEN=${APP_TOKEN}" >> "${GITHUB_ENV}" + + - name: Checkout release branch + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.release_branch || github.ref }} + token: ${{ steps.app-token.outputs.token }} + + - name: Checkout automation helpers + uses: actions/checkout@v6 + with: + fetch-depth: 1 + ref: ${{ github.workflow_sha }} + path: .termux-release-automation + token: ${{ steps.app-token.outputs.token }} + + - name: 🧰 Actions Toolbox + uses: wallentx/gh-actions/composite/actions-toolbox@main + + - name: Validate GitHub CLI environment + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-validate-gh-env.sh" + + - name: Configure git + run: | + set -euo pipefail + release_branch="${REQUESTED_RELEASE_BRANCH:-${GITHUB_REF_NAME}}" + bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-configure-git.sh" --origin "${DESTINATION_BRANCH}" "${release_branch}" + + - name: Resolve release ref + id: release-ref + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-resolve-release-ref.sh" + + - name: Read release metadata + id: metadata + env: + TERMUX_RELEASE_ACTION: deploy + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-read-release-metadata.sh" + + - name: Create deployment + if: steps.metadata.outputs.deploy == 'true' + id: deployment + env: + GH_TOKEN: ${{ github.token }} + RELEASE_SHA: ${{ steps.release-ref.outputs.sha }} + TERMUX_TAG: ${{ steps.metadata.outputs.termux_tag }} + run: | + set -euo pipefail + deployment_id="$( + gh api \ + -X POST \ + "repos/${GITHUB_REPOSITORY}/deployments" \ + -f ref="${RELEASE_SHA}" \ + -f environment="termux-release" \ + -F auto_merge=false \ + -F required_contexts[] \ + -f description="Termux release deployment for ${TERMUX_TAG}" \ + --jq '.id' + )" + echo "id=${deployment_id}" >> "$GITHUB_OUTPUT" + + - name: Mark deployment in progress + if: steps.metadata.outputs.deploy == 'true' + env: + GH_TOKEN: ${{ github.token }} + DEPLOYMENT_ID: ${{ steps.deployment.outputs.id }} + run: | + set -euo pipefail + log_url="${GH_WORKFLOW_URL:-${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}}" + gh api \ + -X POST \ + "repos/${GITHUB_REPOSITORY}/deployments/${DEPLOYMENT_ID}/statuses" \ + -f state="in_progress" \ + -f environment="termux-release" \ + -f log_url="${log_url}" \ + -F auto_inactive=false \ + -f description="Promoting Termux release artifact and preparing checkpoint PR" \ + >/dev/null + + - name: Locate merged pull request + if: steps.metadata.outputs.deploy == 'true' && steps.metadata.outputs.asset_exists != 'true' + id: pr + env: + RELEASE_BRANCH: ${{ steps.release-ref.outputs.branch }} + RELEASE_SHA: ${{ steps.release-ref.outputs.sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-find-release-pr.sh" + + - name: Download promoted PR artifact + if: steps.metadata.outputs.deploy == 'true' && steps.metadata.outputs.asset_exists != 'true' + env: + PR_ARTIFACT_NAME: ${{ steps.pr.outputs.artifact_name }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-download-release-artifact.sh" + + - name: Create or update mirrored Termux release + if: steps.metadata.outputs.deploy == 'true' && steps.metadata.outputs.asset_exists != 'true' + env: + UPSTREAM_TAG: ${{ steps.metadata.outputs.upstream_tag }} + UPSTREAM_REPO: ${{ steps.metadata.outputs.upstream_repo }} + UPSTREAM_NAME: ${{ steps.metadata.outputs.upstream_name }} + TERMUX_TAG: ${{ steps.metadata.outputs.termux_tag }} + UPSTREAM_PRERELEASE: ${{ steps.metadata.outputs.upstream_prerelease }} + UPSTREAM_HTML_URL: ${{ steps.metadata.outputs.upstream_html_url }} + RELEASE_TRAIN: ${{ steps.metadata.outputs.release_train }} + RELEASE_EXISTS: ${{ steps.metadata.outputs.release_exists }} + PR_NUMBER: ${{ steps.pr.outputs.number }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + RELEASE_SHA: ${{ steps.release-ref.outputs.sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-create-or-update-mirrored-release.sh" + + - name: Ensure checkpoint PR + if: steps.metadata.outputs.deploy == 'true' + id: checkpoint + env: + SOURCE_BRANCH: ${{ steps.release-ref.outputs.branch }} + SOURCE_SHA: ${{ steps.release-ref.outputs.sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-create-checkpoint-pr.sh" + + - name: Mark deployment success + if: steps.metadata.outputs.deploy == 'true' + env: + GH_TOKEN: ${{ github.token }} + DEPLOYMENT_ID: ${{ steps.deployment.outputs.id }} + TERMUX_TAG: ${{ steps.metadata.outputs.termux_tag }} + run: | + set -euo pipefail + log_url="${GH_WORKFLOW_URL:-${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}}" + gh api \ + -X POST \ + "repos/${GITHUB_REPOSITORY}/deployments/${DEPLOYMENT_ID}/statuses" \ + -f state="success" \ + -f environment="termux-release" \ + -f log_url="${log_url}" \ + -F auto_inactive=false \ + -f description="Termux release deployment completed for ${TERMUX_TAG}" \ + >/dev/null + + - name: Mark deployment failure + if: failure() && steps.deployment.outputs.id != '' + env: + GH_TOKEN: ${{ github.token }} + DEPLOYMENT_ID: ${{ steps.deployment.outputs.id }} + run: | + set -euo pipefail + log_url="${GH_WORKFLOW_URL:-${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}}" + gh api \ + -X POST \ + "repos/${GITHUB_REPOSITORY}/deployments/${DEPLOYMENT_ID}/statuses" \ + -f state="failure" \ + -f environment="termux-release" \ + -f log_url="${log_url}" \ + -F auto_inactive=false \ + -f description="Termux release deployment failed" \ + >/dev/null diff --git a/.github/workflows/termux-release-promote.yml b/.github/workflows/termux-release-promote.yml new file mode 100644 index 000000000000..22c277c21a70 --- /dev/null +++ b/.github/workflows/termux-release-promote.yml @@ -0,0 +1,135 @@ +name: termux-release-promote + +on: + workflow_dispatch: + inputs: + release_branch: + description: "Release branch to promote, for example release/0.122.0" + required: true + type: string + release_sha: + description: "Release branch commit SHA to promote. Defaults to the branch head." + required: false + default: "" + type: string + pr_number: + description: "Merged PR number to promote. Optional; normally discovered automatically." + required: false + default: "" + type: string + pr_head_sha: + description: "Merged PR head SHA. Required only when pr_number is set." + required: false + default: "" + type: string + +permissions: + actions: read + attestations: read + checks: read + contents: read + deployments: read + issues: read + discussions: read + packages: read + pages: read + pull-requests: read + repository-projects: read + statuses: read + +concurrency: + group: termux-release-promote-${{ github.event_name == 'workflow_dispatch' && inputs.release_branch || github.ref_name }} + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + promote: + runs-on: ubuntu-24.04 + permissions: + actions: read + contents: write + pull-requests: read + env: + TERMUX_AUTOMATION_DIR: ${{ github.workspace }}/.termux-release-automation + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Export GitHub App token for gh + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + run: echo "GH_TOKEN=${APP_TOKEN}" >> "${GITHUB_ENV}" + + - name: Checkout release branch + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.release_branch || github.ref }} + token: ${{ steps.app-token.outputs.token }} + + - name: Checkout automation helpers + uses: actions/checkout@v6 + with: + fetch-depth: 1 + ref: ${{ github.workflow_sha }} + path: .termux-release-automation + token: ${{ steps.app-token.outputs.token }} + + - name: 🧰 Actions Toolbox + uses: wallentx/gh-actions/composite/actions-toolbox@main + + - name: Validate GitHub CLI environment + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-validate-gh-env.sh" + + - name: Resolve release ref + id: release-ref + env: + INPUT_RELEASE_BRANCH: ${{ inputs.release_branch }} + INPUT_RELEASE_SHA: ${{ inputs.release_sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-resolve-release-ref.sh" + + - name: Read release metadata + id: metadata + env: + TERMUX_RELEASE_ACTION: promote + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-read-release-metadata.sh" + + - name: Locate merged pull request + if: steps.metadata.outputs.promote == 'true' + id: pr + env: + RELEASE_BRANCH: ${{ steps.release-ref.outputs.branch }} + RELEASE_SHA: ${{ steps.release-ref.outputs.sha }} + INPUT_PR_NUMBER: ${{ inputs.pr_number }} + INPUT_PR_HEAD_SHA: ${{ inputs.pr_head_sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-find-release-pr.sh" + + - name: Download promoted PR artifact + if: steps.metadata.outputs.promote == 'true' + env: + PR_ARTIFACT_NAME: ${{ steps.pr.outputs.artifact_name }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-download-release-artifact.sh" + + - name: Create or update mirrored Termux release + if: steps.metadata.outputs.promote == 'true' + env: + UPSTREAM_TAG: ${{ steps.metadata.outputs.upstream_tag }} + UPSTREAM_REPO: ${{ steps.metadata.outputs.upstream_repo }} + UPSTREAM_NAME: ${{ steps.metadata.outputs.upstream_name }} + TERMUX_TAG: ${{ steps.metadata.outputs.termux_tag }} + UPSTREAM_PRERELEASE: ${{ steps.metadata.outputs.upstream_prerelease }} + UPSTREAM_HTML_URL: ${{ steps.metadata.outputs.upstream_html_url }} + RELEASE_TRAIN: ${{ steps.metadata.outputs.release_train }} + RELEASE_EXISTS: ${{ steps.metadata.outputs.release_exists }} + PR_NUMBER: ${{ steps.pr.outputs.number }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + RELEASE_SHA: ${{ steps.release-ref.outputs.sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-create-or-update-mirrored-release.sh" diff --git a/scripts/termux-configure-git.sh b/scripts/termux-configure-git.sh new file mode 100755 index 000000000000..fae3abcbab48 --- /dev/null +++ b/scripts/termux-configure-git.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash + +# Configure the explicit bot identity used by release automation and fetch any +# refs requested by the calling workflow. + +set -euo pipefail + +git config user.name "github-actions[bot]" +git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + +while (($#)); do + case "$1" in + --origin) + shift + origin_refs=() + while (($#)) && [[ "$1" != --* ]]; do + origin_refs+=("$1") + shift + done + if ((${#origin_refs[@]})); then + git fetch --prune origin "${origin_refs[@]}" + fi + ;; + --upstream-tag) + if (($# < 3)); then + echo "--upstream-tag requires ." >&2 + exit 1 + fi + upstream_repo="$2" + upstream_tag="$3" + git remote add upstream "https://github.com/${upstream_repo}.git" 2>/dev/null || true + git fetch --prune --no-tags upstream "+refs/tags/${upstream_tag}:refs/tags/${upstream_tag}" + shift 3 + ;; + *) + echo "Unknown argument: $1" >&2 + exit 1 + ;; + esac +done diff --git a/scripts/termux-create-checkpoint-pr.sh b/scripts/termux-create-checkpoint-pr.sh new file mode 100755 index 000000000000..f315a5b91822 --- /dev/null +++ b/scripts/termux-create-checkpoint-pr.sh @@ -0,0 +1,275 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/termux-release-paths.sh +source "${script_dir}/termux-release-paths.sh" + +source_branch="${SOURCE_BRANCH:-${REQUESTED_SOURCE_BRANCH:-${GITHUB_REF_NAME}}}" +source_sha="${SOURCE_SHA:-${REQUESTED_SOURCE_SHA:-}}" +if [[ -z "${source_sha}" ]]; then + if [[ "${GITHUB_EVENT_NAME:-}" == "push" && "${source_branch}" == "${GITHUB_REF_NAME:-}" ]]; then + source_sha="${GITHUB_SHA}" + else + source_sha="$(git rev-parse "origin/${source_branch}")" + fi +fi + +if [[ -z "${DESTINATION_BRANCH:-}" ]]; then + echo "DESTINATION_BRANCH is required." >&2 + exit 1 +fi + +release_only_checkpoint_paths() { + printf '%s\n' "${TERMUX_RELEASE_BRANCH_SCRIPT_PATHS[@]}" +} + +resolve_source_version_conflicts() { + local path="$1" + local resolved_path + resolved_path="$(mktemp)" + + if ! awk ' + function normalize_versions(text) { + gsub(/version = "[^"]+"/, "version = \"\"", text) + return text + } + + BEGIN { + in_block = 0 + side = "" + ours = "" + theirs = "" + blocks = 0 + } + + /^<<<<<<< / { + if (in_block) { + exit 1 + } + in_block = 1 + side = "ours" + ours = "" + theirs = "" + blocks++ + next + } + + /^=======$/ && in_block { + side = "theirs" + next + } + + /^>>>>>>> / && in_block { + if (normalize_versions(ours) != normalize_versions(theirs)) { + exit 1 + } + printf "%s", theirs + in_block = 0 + side = "" + next + } + + { + if (!in_block) { + print + } else if (side == "ours") { + ours = ours $0 ORS + } else if (side == "theirs") { + theirs = theirs $0 ORS + } else { + exit 1 + } + } + + END { + if (in_block || blocks == 0) { + exit 1 + } + } + ' "${path}" > "${resolved_path}"; then + rm -f "${resolved_path}" + return 1 + fi + + cp "${resolved_path}" "${path}" + rm -f "${resolved_path}" +} + +short_sha="${source_sha:0:12}" +source_slug="${source_branch//\//_}" +dest_slug="${DESTINATION_BRANCH//\//_}" +checkpoint_branch="checkpoint/${dest_slug}_from_${source_slug}_${short_sha}" +pr_title="checkpoint: into ${DESTINATION_BRANCH} from ${source_branch} @ ${short_sha}" +merge_conflicted=false +conflict_summary="" + +existing_pr="$( + gh pr list \ + --repo "${GITHUB_REPOSITORY}" \ + --head "${checkpoint_branch}" \ + --state all \ + --json number,state,mergedAt,url \ + --jq '[.[] | select(.state == "OPEN" or .mergedAt != null)] | .[0] // empty' +)" +if [[ -n "${existing_pr}" ]]; then + existing_url="$(jq -r '.url' <<< "${existing_pr}")" + existing_state="$(jq -r '.state' <<< "${existing_pr}")" + echo "Checkpoint PR already exists for ${checkpoint_branch}: ${existing_url} (${existing_state})." + if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + echo "pr_url=${existing_url}" >> "${GITHUB_OUTPUT}" + fi + exit 0 +fi + +git checkout -B "${checkpoint_branch}" "origin/${DESTINATION_BRANCH}" + +if ! git merge --no-ff --no-edit "${source_sha}"; then + mapfile -t conflicted_paths < <(git diff --name-only --diff-filter=U) + for conflicted_path in "${conflicted_paths[@]}"; do + if termux_is_checkpoint_release_only_path "${conflicted_path}"; then + echo "Auto-resolving release-only checkpoint conflict in ${conflicted_path} by keeping ${DESTINATION_BRANCH}." + if git cat-file -e "HEAD:${conflicted_path}" 2>/dev/null; then + git checkout --ours -- "${conflicted_path}" + git add "${conflicted_path}" + else + git rm -f --ignore-unmatch "${conflicted_path}" + fi + fi + done + + mapfile -t remaining_conflicts < <(git diff --name-only --diff-filter=U) + if ((${#remaining_conflicts[@]})); then + cargo_version_conflicts=true + for remaining_conflict in "${remaining_conflicts[@]}"; do + case "${remaining_conflict}" in + codex-rs/Cargo.toml|codex-rs/Cargo.lock) + ;; + *) + cargo_version_conflicts=false + ;; + esac + done + + if [[ "${cargo_version_conflicts}" == "true" ]]; then + for remaining_conflict in "${remaining_conflicts[@]}"; do + if ! resolve_source_version_conflicts "${remaining_conflict}"; then + cargo_version_conflicts=false + break + fi + done + + if [[ "${cargo_version_conflicts}" == "true" ]]; then + echo "Auto-resolving recurring Cargo version checkpoint conflicts by keeping ${source_branch} versions." + git add -- "${remaining_conflicts[@]}" + fi + fi + fi + + mapfile -t remaining_conflicts < <(git diff --name-only --diff-filter=U) + if [[ "${#remaining_conflicts[@]}" -eq 0 ]]; then + git commit --no-edit + else + merge_conflicted=true + conflict_summary="$( + printf '%s\n' "${remaining_conflicts[@]}" | awk '{ print "- `" $0 "`" }' + )" + echo "Automatic checkpoint merge failed; creating a manual-resolution PR instead." >&2 + if git rev-parse -q --verify MERGE_HEAD >/dev/null; then + git merge --abort + fi + git checkout -B "${checkpoint_branch}" "${source_sha}" + fi +fi + +if git cat-file -e "origin/${DESTINATION_BRANCH}:.github" 2>/dev/null; then + git checkout "origin/${DESTINATION_BRANCH}" -- .github + mapfile -t added_github_paths < <( + git diff --name-only --diff-filter=A "origin/${DESTINATION_BRANCH}" -- .github + ) + if ((${#added_github_paths[@]})); then + git rm -f --ignore-unmatch -- "${added_github_paths[@]}" + fi +else + git rm -r --ignore-unmatch .github +fi + +while IFS= read -r release_only_path; do + if git cat-file -e "origin/${DESTINATION_BRANCH}:${release_only_path}" 2>/dev/null; then + git checkout "origin/${DESTINATION_BRANCH}" -- "${release_only_path}" + else + git rm -f --ignore-unmatch -- "${release_only_path}" + fi +done < <(release_only_checkpoint_paths) + +if ! git diff --quiet || ! git diff --cached --quiet; then + if [[ -e .github || -L .github ]] || git ls-files --error-unmatch -- .github >/dev/null 2>&1; then + git add -A .github + fi + while IFS= read -r release_only_path; do + if [[ -e "${release_only_path}" || -L "${release_only_path}" ]] || git ls-files --error-unmatch -- "${release_only_path}" >/dev/null 2>&1; then + git add -A -- "${release_only_path}" + fi + done < <(release_only_checkpoint_paths) + if [[ "${merge_conflicted}" == "true" ]]; then + git commit -m "checkpoint: prepare ${source_branch} for ${DESTINATION_BRANCH}" + else + git commit --amend --no-edit + fi +fi + +if git diff --quiet "origin/${DESTINATION_BRANCH}" HEAD; then + echo "Checkpoint merge produced no destination changes after release-only files were restored." + exit 0 +fi + +git push --force-with-lease origin "${checkpoint_branch}" + +remaining="$( + git log --first-parent --pretty=format:%H "${source_sha}..origin/${source_branch}" | wc -w +)" + +body_path="${RUNNER_TEMP}/termux-checkpoint-pr.md" +{ + echo "## Termux release checkpoint" + echo + echo "- Source branch: \`${source_branch}\`" + echo "- Source hash: \`${source_sha}\`" + echo "- Destination branch: \`${DESTINATION_BRANCH}\`" + echo "- Remaining first-parent commits on source: ${remaining}" + echo + echo "This PR carries release-train conflict fixes and follow-up changes back into the reusable Termux patch branch." + if [[ "${merge_conflicted}" == "true" ]]; then + echo + echo "## Merge conflicts" + echo + echo "GitHub Actions could not create the checkpoint merge commit automatically, so this PR was created from the source branch state for manual conflict resolution." + echo + echo "Conflicted paths from the failed merge attempt:" + if [[ -n "${conflict_summary}" ]]; then + printf '%s\n' "${conflict_summary}" + else + echo "- Conflict details unavailable" + fi + fi + echo + echo "Release-only workflow files and metadata under \`.github\` were restored to the destination branch versions before opening this PR." +} > "${body_path}" + +pr_url="$( + gh pr create \ + --repo "${GITHUB_REPOSITORY}" \ + --base "${DESTINATION_BRANCH}" \ + --head "${checkpoint_branch}" \ + --title "${pr_title}" \ + --body-file "${body_path}" +)" +gh pr edit "${pr_url}" --repo "${GITHUB_REPOSITORY}" --add-reviewer "${REVIEWER}" || true +gh label create checkpoint --repo "${GITHUB_REPOSITORY}" --color c5def5 --description "Checkpoint merge" --force +gh label create termux-release --repo "${GITHUB_REPOSITORY}" --color 0e8a16 --description "Termux release automation" --force +gh pr edit "${pr_url}" --repo "${GITHUB_REPOSITORY}" --add-label "checkpoint" --add-label "termux-release" + +if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + echo "pr_url=${pr_url}" >> "${GITHUB_OUTPUT}" +fi diff --git a/scripts/termux-create-or-update-mirrored-release.sh b/scripts/termux-create-or-update-mirrored-release.sh new file mode 100755 index 000000000000..5787e894582b --- /dev/null +++ b/scripts/termux-create-or-update-mirrored-release.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash + +# Create a mirrored Termux release or repair an existing release that is missing +# the Android tarball. + +set -euo pipefail + +: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" +: "${GH_TOKEN:?GH_TOKEN is required}" +: "${RUNNER_TEMP:?RUNNER_TEMP is required}" +: "${UPSTREAM_TAG:?UPSTREAM_TAG is required}" +: "${UPSTREAM_REPO:?UPSTREAM_REPO is required}" +: "${UPSTREAM_NAME?UPSTREAM_NAME is required}" +: "${TERMUX_TAG:?TERMUX_TAG is required}" +: "${UPSTREAM_PRERELEASE:?UPSTREAM_PRERELEASE is required}" +: "${UPSTREAM_HTML_URL?UPSTREAM_HTML_URL is required}" +: "${RELEASE_TRAIN?RELEASE_TRAIN is required}" +: "${RELEASE_EXISTS:?RELEASE_EXISTS is required}" +: "${PR_NUMBER:?PR_NUMBER is required}" +: "${HEAD_SHA:?HEAD_SHA is required}" +: "${RELEASE_SHA:?RELEASE_SHA is required}" + +promoted_dir="${PROMOTED_DIR:-promoted}" +asset_path="${promoted_dir}/codex-aarch64-linux-android.tar.gz" + +body_path="${RUNNER_TEMP}/release-body.md" +upstream_body_path="${RUNNER_TEMP}/upstream-release-body.md" +upstream_body_without_changelog_path="${RUNNER_TEMP}/upstream-release-body-without-changelog.md" +if gh release view "${UPSTREAM_TAG}" \ + --repo "${UPSTREAM_REPO}" \ + --json body \ + --jq '.body // ""' > "${upstream_body_path}"; then + awk ' + function heading_level(line, text) { + if (match(line, /^(#{1,6})[[:space:]]+(.+)$/, parts)) { + text = tolower(parts[2]) + sub(/[[:space:]]+#+[[:space:]]*$/, "", text) + gsub(/^[[:space:]]+|[[:space:]]+$/, "", text) + if (text == "changelog" || text == "change log") { + return length(parts[1]) + } + } + return 0 + } + + { + if (!skip) { + level = heading_level($0) + if (level > 0) { + skip = 1 + skip_level = level + next + } + print + next + } + + if (match($0, /^(#{1,6})[[:space:]]+/, parts) && length(parts[1]) <= skip_level) { + skip = 0 + print + } + } + ' "${upstream_body_path}" > "${upstream_body_without_changelog_path}" +else + echo "::warning title=Upstream release notes unavailable::Could not read ${UPSTREAM_REPO} release ${UPSTREAM_TAG}." + : > "${upstream_body_without_changelog_path}" +fi + +{ + echo "Termux Android build for ${UPSTREAM_TAG}." + echo + echo "- Upstream release: ${UPSTREAM_HTML_URL}" + echo "- Release train: \`${RELEASE_TRAIN}\`" + echo "- Promoted PR: #${PR_NUMBER}" + echo "- Promoted PR head SHA: \`${HEAD_SHA}\`" +} > "${body_path}" +if [[ -s "${upstream_body_without_changelog_path}" ]]; then + { + echo + echo "## Upstream release notes" + echo + cat "${upstream_body_without_changelog_path}" + } >> "${body_path}" +fi + +release_title="${UPSTREAM_NAME}" +if [[ -z "${release_title}" || "${release_title}" == "null" ]]; then + release_title="${TERMUX_TAG}" +fi + +if [[ "${RELEASE_EXISTS}" == "true" ]]; then + gh release upload \ + "${TERMUX_TAG}" \ + "${asset_path}#codex-termux" \ + --repo "${GITHUB_REPOSITORY}" \ + --clobber + exit 0 +fi + +release_args=( + gh release create "${TERMUX_TAG}" + --repo "${GITHUB_REPOSITORY}" + --target "${RELEASE_SHA}" + --title "${release_title}" + --notes-file "${body_path}" +) +if [[ "${UPSTREAM_PRERELEASE}" == "true" ]]; then + release_args+=(--prerelease) +fi +release_args+=("${asset_path}#codex-termux") +"${release_args[@]}" diff --git a/scripts/termux-download-release-artifact.sh b/scripts/termux-download-release-artifact.sh new file mode 100755 index 000000000000..2acdba9025a0 --- /dev/null +++ b/scripts/termux-download-release-artifact.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ -z "${HEAD_SHA:-}" ]]; then + echo "HEAD_SHA is required." >&2 + exit 1 +fi + +if [[ -z "${PR_ARTIFACT_NAME:-}" ]]; then + echo "PR_ARTIFACT_NAME is required." >&2 + exit 1 +fi + +promoted_dir="${PROMOTED_DIR:-promoted}" + +find_run_id() { + local event="$1" + gh run list \ + --repo "${GITHUB_REPOSITORY}" \ + --workflow rust-release.yml \ + --event "${event}" \ + --status success \ + --commit "${HEAD_SHA}" \ + --limit 1 \ + --json databaseId \ + --jq '.[0].databaseId // empty' +} + +find_artifact_run_id() { + local artifact_name="$1" + + gh api --paginate "repos/${GITHUB_REPOSITORY}/actions/artifacts?name=${artifact_name}" \ + | jq -rs \ + --arg artifact_name "${artifact_name}" \ + --arg head_sha "${HEAD_SHA}" \ + ' + [ + .[].artifacts[] + | select(.name == $artifact_name) + | select(.expired == false) + | select(.workflow_run.head_sha == $head_sha) + ] + | sort_by(.created_at) + | reverse + | .[0].workflow_run.id // empty + ' +} + +artifact_name="${PR_ARTIFACT_NAME}" +run_id="$(find_artifact_run_id "${artifact_name}")" +mkdir -p "${promoted_dir}" +if [[ -n "${run_id}" ]] && gh run download "${run_id}" \ + --repo "${GITHUB_REPOSITORY}" \ + --name "${artifact_name}" \ + --dir "${promoted_dir}"; then + : +else + artifact_name="aarch64-linux-android" + run_id="$(find_run_id workflow_dispatch)" + if [[ -z "${run_id}" ]]; then + echo "No successful rust-release run found for ${HEAD_SHA}" >&2 + exit 1 + fi + gh run download "${run_id}" \ + --repo "${GITHUB_REPOSITORY}" \ + --name "${artifact_name}" \ + --dir "${promoted_dir}" +fi + +ls -la "${promoted_dir}" +if [[ -f "${promoted_dir}/SHA256SUMS" ]]; then + (cd "${promoted_dir}" && sha256sum -c SHA256SUMS) +fi +if [[ ! -f "${promoted_dir}/codex-aarch64-linux-android.tar.gz" ]]; then + echo "Expected ${promoted_dir}/codex-aarch64-linux-android.tar.gz in the downloaded artifact." >&2 + exit 1 +fi diff --git a/scripts/termux-find-release-pr.sh b/scripts/termux-find-release-pr.sh new file mode 100755 index 000000000000..3e35fa42eabe --- /dev/null +++ b/scripts/termux-find-release-pr.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ -n "${INPUT_PR_NUMBER:-}" || -n "${INPUT_PR_HEAD_SHA:-}" ]]; then + if [[ -z "${INPUT_PR_NUMBER:-}" || -z "${INPUT_PR_HEAD_SHA:-}" ]]; then + echo "workflow_dispatch inputs pr_number and pr_head_sha must be provided together." >&2 + exit 1 + fi + + pr_number="${INPUT_PR_NUMBER}" + head_sha="${INPUT_PR_HEAD_SHA}" + head_ref="" +else + if [[ -z "${RELEASE_BRANCH:-}" || -z "${RELEASE_SHA:-}" ]]; then + echo "RELEASE_BRANCH and RELEASE_SHA are required." >&2 + exit 1 + fi + + pr_json="$( + gh pr list \ + --repo "${GITHUB_REPOSITORY}" \ + --base "${RELEASE_BRANCH}" \ + --state merged \ + --limit 100 \ + --json number,headRefOid,headRefName,mergeCommit \ + --jq "[.[] | select(.mergeCommit.oid == \"${RELEASE_SHA}\")] | sort_by(.number) | reverse | .[0] // empty" + )" + if [[ -z "${pr_json}" ]]; then + echo "Unable to find merged PR for ${RELEASE_SHA} into ${RELEASE_BRANCH}" >&2 + exit 1 + fi + + pr_number="$(jq -r '.number' <<< "${pr_json}")" + head_sha="$(jq -r '.headRefOid // .head.sha' <<< "${pr_json}")" + head_ref="$(jq -r '.headRefName // .head.ref' <<< "${pr_json}")" +fi + +artifact_name="termux-android-pr-${pr_number}-${head_sha}" +if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + { + echo "number=${pr_number}" + echo "head_sha=${head_sha}" + echo "head_ref=${head_ref}" + echo "artifact_name=${artifact_name}" + } >> "${GITHUB_OUTPUT}" +else + printf 'number=%s\nhead_sha=%s\nhead_ref=%s\nartifact_name=%s\n' \ + "${pr_number}" \ + "${head_sha}" \ + "${head_ref}" \ + "${artifact_name}" +fi diff --git a/scripts/termux-read-release-metadata.sh b/scripts/termux-read-release-metadata.sh new file mode 100755 index 000000000000..519a0635a368 --- /dev/null +++ b/scripts/termux-read-release-metadata.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash + +# Parse .github/termux-release.json and emit workflow outputs for deploy or +# promote jobs. TERMUX_RELEASE_ACTION must be "deploy" or "promote". + +set -euo pipefail + +: "${GITHUB_OUTPUT:?GITHUB_OUTPUT is required}" +: "${GH_TOKEN:?GH_TOKEN is required}" + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + +metadata=".github/termux-release.json" +action="${TERMUX_RELEASE_ACTION:-deploy}" +case "${action}" in + deploy) + missing_context="deployment" + ;; + promote) + missing_context="promotion" + ;; + *) + echo "TERMUX_RELEASE_ACTION must be deploy or promote." >&2 + exit 1 + ;; +esac + +if [[ ! -f "${metadata}" ]]; then + echo "No ${metadata}; this push is not a Termux release ${missing_context}." + echo "${action}=false" >> "${GITHUB_OUTPUT}" + exit 0 +fi + +upstream_tag="$(jq -r '.upstream_tag // empty' "${metadata}")" +upstream_name="$(jq -r '.upstream_name // .upstream_tag // empty' "${metadata}")" +termux_tag="$(jq -r '.termux_tag // empty' "${metadata}")" +upstream_version="${upstream_tag#rust-v}" +upstream_version="${upstream_version%-termux}" +upstream_prerelease=false +if [[ "${upstream_version}" == *-* ]]; then + upstream_prerelease=true +fi +upstream_html_url="$(jq -r '.upstream_html_url // ""' "${metadata}")" +upstream_repo="$(jq -r '.upstream_repo // "openai/codex"' "${metadata}")" +release_train="$(jq -r '.release_train // ""' "${metadata}")" +if [[ -z "${upstream_tag}" || -z "${termux_tag}" ]]; then + echo "Missing upstream_tag or termux_tag in ${metadata}" >&2 + exit 1 +fi + +release_state="$(TERMUX_TAG="${termux_tag}" "${script_dir}/termux-release-asset-state.sh")" +release_exists="$(awk -F= '$1 == "release_exists" { print $2 }' <<< "${release_state}")" +asset_exists="$(awk -F= '$1 == "asset_exists" { print $2 }' <<< "${release_state}")" + +if [[ "${action}" == "promote" ]]; then + if [[ "${asset_exists}" == "true" ]]; then + echo "${termux_tag} already exists with codex-aarch64-linux-android.tar.gz; skipping promotion." + echo "promote=false" >> "${GITHUB_OUTPUT}" + exit 0 + fi + if [[ "${release_exists}" == "true" ]]; then + echo "${termux_tag} exists but is missing codex-aarch64-linux-android.tar.gz; repairing promotion." + fi +fi + +{ + echo "${action}=true" + echo "upstream_tag=${upstream_tag}" + echo "upstream_name=${upstream_name}" + echo "termux_tag=${termux_tag}" + echo "upstream_prerelease=${upstream_prerelease}" + echo "upstream_html_url=${upstream_html_url}" + echo "upstream_repo=${upstream_repo}" + echo "release_train=${release_train}" + echo "release_exists=${release_exists}" + echo "asset_exists=${asset_exists}" +} >> "${GITHUB_OUTPUT}" diff --git a/scripts/termux-release-asset-state.sh b/scripts/termux-release-asset-state.sh new file mode 100755 index 000000000000..8694480b6a36 --- /dev/null +++ b/scripts/termux-release-asset-state.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash + +set -euo pipefail + +termux_tag="${1:-${TERMUX_TAG:-}}" +release_repo="${RELEASE_REPO:-${GITHUB_REPOSITORY:-}}" +asset_name="${ASSET_NAME:-codex-aarch64-linux-android.tar.gz}" + +if [[ -z "${termux_tag}" ]]; then + echo "TERMUX_TAG or tag argument is required." >&2 + exit 1 +fi + +if [[ -z "${release_repo}" ]]; then + echo "GITHUB_REPOSITORY or RELEASE_REPO is required." >&2 + exit 1 +fi + +release_exists=false +asset_exists=false + +if gh release view "${termux_tag}" --repo "${release_repo}" >/dev/null 2>&1; then + release_exists=true + release_asset_exists="$( + gh release view "${termux_tag}" \ + --repo "${release_repo}" \ + --json assets \ + | jq -r --arg asset_name "${asset_name}" \ + '.assets | map(.name) | any(. == $asset_name)' + )" + if [[ "${release_asset_exists}" == "true" ]]; then + asset_exists=true + fi +fi + +printf 'release_exists=%s\n' "${release_exists}" +printf 'asset_exists=%s\n' "${asset_exists}" diff --git a/scripts/termux-release-paths.sh b/scripts/termux-release-paths.sh new file mode 100755 index 000000000000..6990f323ec44 --- /dev/null +++ b/scripts/termux-release-paths.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash + +# Sourceable path lists for files owned by the Termux release automation. + +set -euo pipefail + +readonly -a TERMUX_RELEASE_WORKFLOW_PATHS=( + .github/workflows/rust-release.yml + .github/workflows/shell-tool-mcp.yml + .github/workflows/termux-release-checkpoint.yml + .github/workflows/termux-release-deploy.yml + .github/workflows/termux-release-promote.yml +) + +readonly -a TERMUX_RELEASE_BRANCH_SCRIPT_PATHS=( + scripts/termux-configure-git.sh + scripts/termux-create-checkpoint-pr.sh + scripts/termux-create-or-update-mirrored-release.sh + scripts/termux-download-release-artifact.sh + scripts/termux-find-release-pr.sh + scripts/termux-read-release-metadata.sh + scripts/termux-release-asset-state.sh + scripts/termux-release-paths.sh + scripts/termux-resolve-release-ref.sh + scripts/termux-validate-gh-env.sh +) + +readonly -a TERMUX_RELEASE_AUTOMATION_PATHS=( + "${TERMUX_RELEASE_WORKFLOW_PATHS[@]}" + "${TERMUX_RELEASE_BRANCH_SCRIPT_PATHS[@]}" +) + +readonly -a TERMUX_CHECKPOINT_RELEASE_ONLY_PATHS=( + "${TERMUX_RELEASE_AUTOMATION_PATHS[@]}" + .github/termux-release.json +) + +termux_path_in_list() { + local candidate="$1" + shift + local listed_path + + for listed_path in "$@"; do + [[ "${candidate}" != "${listed_path}" ]] || return 0 + done + return 1 +} + +termux_is_release_automation_path() { + termux_path_in_list "$1" "${TERMUX_RELEASE_AUTOMATION_PATHS[@]}" +} + +termux_is_checkpoint_release_only_path() { + termux_path_in_list "$1" "${TERMUX_CHECKPOINT_RELEASE_ONLY_PATHS[@]}" +} diff --git a/scripts/termux-resolve-release-ref.sh b/scripts/termux-resolve-release-ref.sh new file mode 100755 index 000000000000..194773d45bed --- /dev/null +++ b/scripts/termux-resolve-release-ref.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash + +# Resolve the release branch and SHA for deploy/promote workflows. + +set -euo pipefail + +: "${GITHUB_EVENT_NAME:?GITHUB_EVENT_NAME is required}" +: "${GITHUB_OUTPUT:?GITHUB_OUTPUT is required}" + +input_release_branch="${INPUT_RELEASE_BRANCH:-${REQUESTED_RELEASE_BRANCH:-}}" +input_release_sha="${INPUT_RELEASE_SHA:-${REQUESTED_RELEASE_SHA:-}}" + +if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then + if [[ -z "${input_release_branch}" ]]; then + echo "release_branch is required for workflow_dispatch." >&2 + exit 1 + fi + + release_branch="${input_release_branch}" + if [[ -n "${input_release_sha}" ]]; then + git checkout --detach "${input_release_sha}" + release_sha="${input_release_sha}" + else + release_sha="$(git rev-parse HEAD)" + fi +else + : "${GITHUB_REF_NAME:?GITHUB_REF_NAME is required}" + : "${GITHUB_SHA:?GITHUB_SHA is required}" + release_branch="${GITHUB_REF_NAME}" + release_sha="${GITHUB_SHA}" +fi + +{ + echo "branch=${release_branch}" + echo "sha=${release_sha}" +} >> "${GITHUB_OUTPUT}" diff --git a/scripts/termux-validate-gh-env.sh b/scripts/termux-validate-gh-env.sh new file mode 100755 index 000000000000..02581fdebceb --- /dev/null +++ b/scripts/termux-validate-gh-env.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash + +# Lightweight post-toolbox check for jobs that rely on authenticated gh calls. + +set -euo pipefail + +: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" +: "${GH_TOKEN:?GH_TOKEN is required}" + +command -v gh +gh auth status --hostname github.com + +printf 'GITHUB_REPOSITORY=%s\n' "${GITHUB_REPOSITORY}" +printf 'REPO=%s\n' "${REPO:-}" +printf 'GH_REPO_URL=%s\n' "${GH_REPO_URL:-}" +printf 'GH_WORKFLOW_URL=%s\n' "${GH_WORKFLOW_URL:-}" From 99614a67f6b9fe8eaf09ec78bdec7f46d0502ed1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 21:45:35 +0000 Subject: [PATCH 55/64] Prepare Termux rust-v0.134.0-alpha.2 --- .github/termux-release.json | 15 + .github/workflows/rust-release.yml | 1798 +++++++---------- .github/workflows/shell-tool-mcp.yml | 461 +++++ .../workflows/termux-release-checkpoint.yml | 103 + .github/workflows/termux-release-deploy.yml | 243 +++ .github/workflows/termux-release-promote.yml | 135 ++ codex-rs/Cargo.toml | 2 +- scripts/termux-configure-git.sh | 40 + scripts/termux-create-checkpoint-pr.sh | 275 +++ ...ermux-create-or-update-mirrored-release.sh | 111 + scripts/termux-download-release-artifact.sh | 78 + scripts/termux-find-release-pr.sh | 53 + scripts/termux-read-release-metadata.sh | 77 + scripts/termux-release-asset-state.sh | 37 + scripts/termux-release-paths.sh | 55 + scripts/termux-resolve-release-ref.sh | 36 + scripts/termux-validate-gh-env.sh | 16 + 17 files changed, 2460 insertions(+), 1075 deletions(-) create mode 100644 .github/termux-release.json create mode 100644 .github/workflows/shell-tool-mcp.yml create mode 100644 .github/workflows/termux-release-checkpoint.yml create mode 100644 .github/workflows/termux-release-deploy.yml create mode 100644 .github/workflows/termux-release-promote.yml create mode 100755 scripts/termux-configure-git.sh create mode 100755 scripts/termux-create-checkpoint-pr.sh create mode 100755 scripts/termux-create-or-update-mirrored-release.sh create mode 100755 scripts/termux-download-release-artifact.sh create mode 100755 scripts/termux-find-release-pr.sh create mode 100755 scripts/termux-read-release-metadata.sh create mode 100755 scripts/termux-release-asset-state.sh create mode 100755 scripts/termux-release-paths.sh create mode 100755 scripts/termux-resolve-release-ref.sh create mode 100755 scripts/termux-validate-gh-env.sh diff --git a/.github/termux-release.json b/.github/termux-release.json new file mode 100644 index 000000000000..459a467dca56 --- /dev/null +++ b/.github/termux-release.json @@ -0,0 +1,15 @@ +{ + "upstream_repo": "openai/codex", + "upstream_tag": "rust-v0.134.0-alpha.2", + "upstream_name": "0.134.0-alpha.2", + "upstream_html_url": "https://github.com/openai/codex/releases/tag/rust-v0.134.0-alpha.2", + "upstream_target": "main", + "upstream_release_id": "328157251", + "upstream_prerelease": true, + "release_train": "0.134.0", + "release_branch": "release/0.134.0", + "work_branch": "upstream/rust-v0.134.0", + "patch_branch": "wallentx/termux-target", + "patch_source_sha": "be3993303eb9b678fc81cb0a5d0de3bd2360f66a", + "termux_tag": "rust-v0.134.0-alpha.2-termux" +} diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index c55337ecfe6e..56d0977e6e67 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -4,46 +4,49 @@ # git tag -a rust-v0.1.0 -m "Release 0.1.0" # git push origin rust-v0.1.0 # ``` -# -# To use external macOS signing, manually dispatch `release_mode=build_unsigned`, -# sign the unsigned macOS artifacts in a secure enclave, upload the signed handoff -# archive as a GitHub Release asset, then manually dispatch -# `release_mode=promote_signed` with `unsigned_run_id` and `signed_macos_asset`. -# The signed handoff archive should contain target or artifact directories such -# as `aarch64-apple-darwin/` with signed binaries. name: rust-release on: - push: - tags: - - "rust-v*.*.*" + pull_request: + branches: + - "release/**" workflow_dispatch: inputs: - release_mode: - description: "build_unsigned creates unsigned macOS handoff artifacts; promote_signed finishes a release from signed macOS handoff artifacts." + source_ref: + description: "Branch, tag, or commit SHA to build (code only; keeps .github from dispatched ref)" required: false + type: string + default: "" + build_target: + description: "Target to build" + required: true type: choice - default: build_unsigned + default: all options: - - build_unsigned - - promote_signed - sign_macos: - description: "Deprecated compatibility input; use release_mode instead." - required: false - type: boolean - default: false - unsigned_run_id: - description: "For promote_signed: workflow run id from the build_unsigned run." - required: false - type: string - signed_macos_asset: - description: "For promote_signed: exact GitHub Release asset name containing signed macOS handoff artifacts." - required: false - type: string - signed_macos_sha256: - description: "For promote_signed: optional SHA-256 of signed_macos_asset." - required: false - type: string + - all + - aarch64-apple-darwin + - x86_64-apple-darwin + - x86_64-unknown-linux-musl + - x86_64-unknown-linux-gnu + - aarch64-unknown-linux-musl + - aarch64-unknown-linux-gnu + - aarch64-linux-android + - x86_64-pc-windows-msvc + - aarch64-pc-windows-msvc + +permissions: + actions: read + attestations: read + checks: read + contents: read + deployments: read + issues: read + discussions: read + packages: read + pages: read + pull-requests: read + repository-projects: read + statuses: read concurrency: group: ${{ github.workflow }} @@ -51,72 +54,26 @@ concurrency: jobs: tag-check: + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: actions/checkout@v6 + - name: 🧰 Actions Toolbox + # This is required for the GitHub CLI + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: wallentx/gh-actions/composite/actions-toolbox@main + - uses: dtolnay/rust-toolchain@1.92 - name: Validate tag matches Cargo.toml version shell: bash - env: - RELEASE_MODE: ${{ github.event_name == 'workflow_dispatch' && inputs.release_mode || 'signed' }} - REQUESTED_SIGN_MACOS: ${{ inputs.sign_macos }} - SIGNED_MACOS_ASSET: ${{ inputs.signed_macos_asset }} - UNSIGNED_RUN_ID: ${{ inputs.unsigned_run_id }} run: | set -euo pipefail echo "::group::Tag validation" - case "${RELEASE_MODE}" in - signed) - if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then - echo "❌ Manual rust-release runs must use release_mode=build_unsigned or release_mode=promote_signed" - exit 1 - fi - ;; - build_unsigned) - if [[ "${GITHUB_EVENT_NAME}" != "workflow_dispatch" ]]; then - echo "❌ release_mode=build_unsigned is only valid for manual runs" - exit 1 - fi - ;; - promote_signed) - if [[ "${GITHUB_EVENT_NAME}" != "workflow_dispatch" ]]; then - echo "❌ release_mode=promote_signed is only valid for manual runs" - exit 1 - fi - if [[ ! "${UNSIGNED_RUN_ID}" =~ ^[0-9]+$ ]]; then - echo "❌ release_mode=promote_signed requires unsigned_run_id to be a workflow run id" - exit 1 - fi - if [[ -z "${SIGNED_MACOS_ASSET}" ]]; then - echo "❌ release_mode=promote_signed requires signed_macos_asset" - exit 1 - fi - if [[ "${SIGNED_MACOS_ASSET}" == */* || "${SIGNED_MACOS_ASSET}" == *"*"* || "${SIGNED_MACOS_ASSET}" == *"?"* || "${SIGNED_MACOS_ASSET}" == *"["* ]]; then - echo "❌ signed_macos_asset must be an exact release asset name, not a path or glob" - exit 1 - fi - if [[ "${UNSIGNED_RUN_ID}" == "${GITHUB_RUN_ID}" ]]; then - echo "❌ unsigned_run_id must refer to the earlier build_unsigned run, not this run" - exit 1 - fi - ;; - *) - echo "❌ Unknown release_mode '${RELEASE_MODE}'" - exit 1 - ;; - esac - - if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${REQUESTED_SIGN_MACOS}" == "true" ]]; then - echo "::warning title=Deprecated sign_macos input ignored::Use release_mode=build_unsigned or release_mode=promote_signed instead." - fi - # 1. Must be a tag and match the regex [[ "${GITHUB_REF_TYPE}" == "tag" ]] \ || { echo "❌ Not a tag push"; exit 1; } - [[ "${GITHUB_REF_NAME}" =~ ^rust-v[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta)(\.[0-9]+)?)?$ ]] \ + [[ "${GITHUB_REF_NAME}" =~ ^rust-v[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta)(\.[0-9]+)?)?(-termux)?$ ]] \ || { echo "❌ Tag '${GITHUB_REF_NAME}' doesn't match expected format"; exit 1; } # 2. Extract versions @@ -131,115 +88,198 @@ jobs: echo "✅ Tag and Cargo.toml agree (${tag_ver})" echo "::endgroup::" - build: - if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} + select-build-matrix: + if: always() && (github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' || needs.tag-check.result == 'success') needs: tag-check - name: Build - ${{ matrix.runner }} - ${{ matrix.target }} - ${{ matrix.bundle }} - runs-on: ${{ matrix.runs_on || matrix.runner }} - # Release builds can take a long time, so leave some headroom to avoid - # having to restart the full workflow due to a timeout. - timeout-minutes: 90 - permissions: - contents: read - id-token: write + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.select.outputs.matrix }} + build_version: ${{ steps.select.outputs.build_version }} + steps: + - uses: actions/checkout@v6 + - name: 🧰 Actions Toolbox + # This is required for the GitHub CLI + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: wallentx/gh-actions/composite/actions-toolbox@main + - id: select + shell: bash + env: + REPO_OWNER: ${{ github.repository_owner }} + EVENT_NAME: ${{ github.event_name }} + BUILD_TARGET: ${{ inputs.build_target }} + run: | + set -euo pipefail + + matrix='[ + {"runner":"macos-15-xlarge","target":"aarch64-apple-darwin"}, + {"runner":"macos-15-xlarge","target":"x86_64-apple-darwin"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-gnu"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-gnu"}, + {"runner":"ubuntu-24.04","target":"aarch64-linux-android"}, + {"runner":"windows-latest","target":"x86_64-pc-windows-msvc"}, + {"runner":"windows-11-arm","target":"aarch64-pc-windows-msvc"} + ]' + + if [[ "${REPO_OWNER}" != "openai" ]]; then + matrix="$(jq -c '[.[] | select(.runner | startswith("macos") | not)]' <<< "${matrix}")" + fi + + if [[ "${EVENT_NAME}" == "pull_request" ]]; then + matrix="$(jq -c '[.[] | select(.target == "aarch64-linux-android")]' <<< "${matrix}")" + elif [[ "${EVENT_NAME}" == "workflow_dispatch" && -n "${BUILD_TARGET}" && "${BUILD_TARGET}" != "all" ]]; then + matrix="$(jq -c --arg build_target "${BUILD_TARGET}" '[.[] | select(.target == $build_target)]' <<< "${matrix}")" + fi + + if [[ "$(jq 'length' <<< "${matrix}")" -eq 0 ]]; then + echo "No build targets selected after applying owner/dispatch filters." >&2 + exit 1 + fi + + echo "matrix={\"include\":${matrix}}" >> "$GITHUB_OUTPUT" + + if [[ "${EVENT_NAME}" == "pull_request" ]]; then + metadata=".github/termux-release.json" + if [[ ! -f "${metadata}" ]]; then + echo "${metadata} is required for release train PR builds" >&2 + exit 1 + fi + upstream_tag="$(jq -r '.upstream_tag // empty' "${metadata}")" + if [[ -z "${upstream_tag}" || "${upstream_tag}" != rust-v* ]]; then + echo "Unable to determine upstream rust tag from ${metadata}" >&2 + exit 1 + fi + build_version="${upstream_tag#rust-v}" + echo "build_version=${build_version}" >> "$GITHUB_OUTPUT" + elif [[ "${EVENT_NAME}" == "workflow_dispatch" ]]; then + latest_release_tag="$( + GH_TOKEN="${{ github.token }}" gh api repos/openai/codex/releases/latest --jq '.tag_name' + )" + + if [[ -z "${latest_release_tag}" || "${latest_release_tag}" != rust-v* ]]; then + echo "Unable to determine latest stable release tag from openai/codex" >&2 + exit 1 + fi + + latest_release_version="${latest_release_tag#rust-v}" + build_version="${latest_release_version}-dev" + echo "build_version=${build_version}" >> "$GITHUB_OUTPUT" + fi + + build: + if: >- + always() && + (github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' || needs.tag-check.result == 'success') && + needs.select-build-matrix.result == 'success' + needs: + - tag-check + - select-build-matrix + name: Build - ${{ matrix.runner }} - ${{ matrix.target }} + runs-on: ${{ matrix.runner }} + permissions: write-all + timeout-minutes: 120 defaults: run: working-directory: codex-rs env: - # 2026-03-04: temporarily change releases to use thin LTO because - # Ubuntu ARM is timing out at 60 minutes. - CARGO_PROFILE_RELEASE_LTO: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'thin' }} - SIGN_MACOS: ${{ github.event_name != 'workflow_dispatch' }} + CODEX_BWRAP_ENABLE_FFI: ${{ contains(matrix.target, 'unknown-linux') && '1' || '0' }} + CODEX_BUILD_VERSION: ${{ needs.select-build-matrix.outputs.build_version }} + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" strategy: fail-fast: false - matrix: - include: - - runner: macos-15-xlarge - target: aarch64-apple-darwin - bundle: primary - artifact_name: aarch64-apple-darwin - binaries: "codex codex-responses-api-proxy" - build_dmg: "true" - - runner: macos-15-xlarge - target: aarch64-apple-darwin - bundle: app-server - artifact_name: aarch64-apple-darwin-app-server - binaries: "codex-app-server" - build_dmg: "false" - - runner: macos-15-xlarge - target: x86_64-apple-darwin - bundle: primary - artifact_name: x86_64-apple-darwin - binaries: "codex codex-responses-api-proxy" - build_dmg: "true" - - runner: macos-15-xlarge - target: x86_64-apple-darwin - bundle: app-server - artifact_name: x86_64-apple-darwin-app-server - binaries: "codex-app-server" - build_dmg: "false" - # Release artifacts intentionally ship MUSL-linked Linux binaries. - - runner: ubuntu-24.04 - target: x86_64-unknown-linux-musl - bundle: primary - artifact_name: x86_64-unknown-linux-musl - binaries: "codex codex-responses-api-proxy bwrap" - build_dmg: "false" - - runner: ubuntu-24.04 - target: x86_64-unknown-linux-musl - bundle: app-server - artifact_name: x86_64-unknown-linux-musl-app-server - binaries: "codex-app-server" - build_dmg: "false" - - runner: ubuntu-24.04-arm - target: aarch64-unknown-linux-musl - bundle: primary - artifact_name: aarch64-unknown-linux-musl - binaries: "codex codex-responses-api-proxy bwrap" - build_dmg: "false" - - runner: ubuntu-24.04-arm - target: aarch64-unknown-linux-musl - bundle: app-server - artifact_name: aarch64-unknown-linux-musl-app-server - binaries: "codex-app-server" - build_dmg: "false" + matrix: ${{ fromJson(needs.select-build-matrix.outputs.matrix) }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@v6 + - name: 🧰 Actions Toolbox + # This is required for the GitHub CLI + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: wallentx/gh-actions/composite/actions-toolbox@main + with: + verbose: true + - name: Checkout selected source ref + if: ${{ github.event_name == 'workflow_dispatch' && inputs.source_ref != '' }} + uses: actions/checkout@v6 with: - persist-credentials: false - - name: Print runner specs (Linux) - if: ${{ runner.os == 'Linux' }} + ref: ${{ inputs.source_ref }} + path: __source__ + fetch-depth: 1 + - name: Overlay selected source ref (excluding .github) + if: ${{ github.event_name == 'workflow_dispatch' && inputs.source_ref != '' }} + shell: bash + working-directory: ${{ github.workspace }} + run: | + set -euo pipefail + echo "Using source ref: ${{ inputs.source_ref }}" + + # Keep workflow/actions from the dispatch ref while replacing all other files. + find . -mindepth 1 -maxdepth 1 \ + ! -name .git \ + ! -name .github \ + ! -name __source__ \ + -exec rm -rf -- {} + + tar --exclude='.github' -C __source__ -cf - . | tar -xf - + rm -rf __source__ + + - name: Apply selected build version + if: ${{ github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' }} shell: bash + working-directory: ${{ github.workspace }} run: | set -euo pipefail - cpu_model="$(lscpu | awk -F: '/Model name/ {gsub(/^[ \t]+/, "", $2); print $2; exit}')" - total_ram="$(awk '/MemTotal/ {printf "%.1f GiB\n", $2 / 1024 / 1024}' /proc/meminfo)" - echo "Runner: ${RUNNER_NAME:-unknown}" - echo "OS: $(uname -a)" - echo "CPU model: ${cpu_model}" - echo "Logical CPUs: $(nproc)" - echo "Total RAM: ${total_ram}" - echo "Disk usage:" - df -h . - - name: Print runner specs (macOS) - if: ${{ runner.os == 'macOS' }} + if [[ -z "${CODEX_BUILD_VERSION}" ]]; then + echo "CODEX_BUILD_VERSION is empty for workflow_dispatch" >&2 + exit 1 + fi + echo "Using build version: ${CODEX_BUILD_VERSION}" + BUILD_VERSION="${CODEX_BUILD_VERSION}" perl -0777 -i -pe \ + 's/(\[workspace\.package\][^\[]*?version = ")([^"]+)(")/${1}$ENV{BUILD_VERSION}$3/s' \ + codex-rs/Cargo.toml + + grep -n "^\[workspace.package\]\|^version = " codex-rs/Cargo.toml | head -n 4 + + - name: Select Android NDK + if: ${{ matrix.target == 'aarch64-linux-android' }} shell: bash run: | set -euo pipefail - total_ram="$(sysctl -n hw.memsize | awk '{printf "%.1f GiB\n", $1 / 1024 / 1024 / 1024}')" - echo "Runner: ${RUNNER_NAME:-unknown}" - echo "OS: $(sw_vers -productName) $(sw_vers -productVersion)" - echo "Hardware model: $(sysctl -n hw.model)" - echo "CPU architecture: $(uname -m)" - echo "Logical CPUs: $(sysctl -n hw.logicalcpu)" - echo "Physical CPUs: $(sysctl -n hw.physicalcpu)" - echo "Total RAM: ${total_ram}" - echo "Disk usage:" - df -h . + + fallback_version="29.0.13113456" + ndk_root="${ANDROID_HOME}/ndk" + selected_ndk="" + + if [[ -d "${ndk_root}" ]]; then + while IFS= read -r candidate; do + toolchain="${candidate}/toolchains/llvm/prebuilt/linux-x86_64" + if [[ -x "${toolchain}/bin/aarch64-linux-android24-clang" ]]; then + selected_ndk="${candidate}" + break + fi + done < <(find "${ndk_root}" -mindepth 1 -maxdepth 1 -type d | sort -Vr) + fi + + if [[ -z "${selected_ndk}" ]]; then + echo "No usable preinstalled Android NDK found; installing ${fallback_version}" + yes | sudo "${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager" --licenses || true + sudo "${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager" "ndk;${fallback_version}" + selected_ndk="${ndk_root}/${fallback_version}" + fi + + if [[ ! -x "${selected_ndk}/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android24-clang" ]]; then + echo "Selected Android NDK does not contain a usable aarch64 API 24 clang: ${selected_ndk}" >&2 + exit 1 + fi + + echo "Selected Android NDK: ${selected_ndk}" + "${selected_ndk}/toolchains/llvm/prebuilt/linux-x86_64/bin/clang" --version + echo "ANDROID_NDK_HOME=${selected_ndk}" >> "$GITHUB_ENV" + echo "NDK_HOME=${selected_ndk}" >> "$GITHUB_ENV" - name: Install Linux bwrap build dependencies - if: ${{ runner.os == 'Linux' }} + if: ${{ runner.os == 'Linux' && matrix.target != 'aarch64-linux-android' }} shell: bash run: | set -euo pipefail @@ -254,9 +294,173 @@ jobs: sudo apt-get update -y sudo DEBIAN_FRONTEND=noninteractive apt-get install -y libubsan1 fi - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: dtolnay/rust-toolchain@1.93 with: targets: ${{ matrix.target }} + - name: Set up sccache (Android) + if: ${{ matrix.target == 'aarch64-linux-android' }} + uses: mozilla-actions/sccache-action@main + - name: Enable sccache (Android) + if: ${{ matrix.target == 'aarch64-linux-android' }} + shell: bash + run: | + set -euo pipefail + echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV" + echo "SCCACHE_GHA_ENABLED=true" >> "$GITHUB_ENV" + echo "SCCACHE_GHA_VERSION=android-release-${{ matrix.target }}" >> "$GITHUB_ENV" + echo "SCCACHE_CACHE_SIZE=5G" >> "$GITHUB_ENV" + - name: Ensure Rust target is installed + shell: bash + run: | + set -euo pipefail + rustup target add "${{ matrix.target }}" + - if: ${{ matrix.target == 'aarch64-linux-android' }} + name: Configure Android build environment + shell: bash + run: | + set -euo pipefail + ndk="${ANDROID_NDK_HOME}" + target="${{ matrix.target }}" + + # Set up the Android toolchain + toolchain="${ndk}/toolchains/llvm/prebuilt/linux-x86_64" + + # Use API level 24 to follow termux-build convention + api_level="24" + + # Configure Cargo to use the NDK linker + cargo_target_var="CARGO_TARGET_${target^^}_LINKER" + cargo_target_var="${cargo_target_var//-/_}" + echo "${cargo_target_var}=${toolchain}/bin/aarch64-linux-android${api_level}-clang" >> "$GITHUB_ENV" + + # Set CC and AR for the target + target_cc_var="CC_${target}" + target_cc_var="${target_cc_var//-/_}" + echo "${target_cc_var}=${toolchain}/bin/aarch64-linux-android${api_level}-clang" >> "$GITHUB_ENV" + + target_cxx_var="CXX_${target}" + target_cxx_var="${target_cxx_var//-/_}" + echo "${target_cxx_var}=${toolchain}/bin/aarch64-linux-android${api_level}-clang++" >> "$GITHUB_ENV" + + target_ar_var="AR_${target}" + target_ar_var="${target_ar_var//-/_}" + echo "${target_ar_var}=${toolchain}/bin/llvm-ar" >> "$GITHUB_ENV" + + # Add toolchain to PATH + echo "${toolchain}/bin" >> "$GITHUB_PATH" + + tls_align_source="${RUNNER_TEMP}/codex-android-tls-align.S" + tls_align_object="${RUNNER_TEMP}/codex-android-tls-align.o" + cat > "${tls_align_source}" <<'EOF' + .section .tdata.codex_android_tls_align,"awT",%progbits + .p2align 6 + .globl __codex_android_tls_align + .hidden __codex_android_tls_align + .type __codex_android_tls_align, %object + __codex_android_tls_align: + .byte 0 + .size __codex_android_tls_align, 1 + EOF + "${toolchain}/bin/aarch64-linux-android${api_level}-clang" -c "${tls_align_source}" -o "${tls_align_object}" + + v8_compat_source="${RUNNER_TEMP}/codex-android-v8-compat.c" + v8_compat_object="${RUNNER_TEMP}/codex-android-v8-compat.o" + cat > "${v8_compat_source}" <<'EOF' + #include + #include + + extern int posix_memalign(void **memptr, size_t alignment, size_t size); + extern double strtod(const char *nptr, char **endptr); + extern float strtof(const char *nptr, char **endptr); + + void *aligned_alloc(size_t alignment, size_t size) { + void *ptr = 0; + if (posix_memalign(&ptr, alignment, size) != 0) { + return 0; + } + return ptr; + } + + double strtod_l(const char *nptr, char **endptr, locale_t locale) { + (void)locale; + return strtod(nptr, endptr); + } + + float strtof_l(const char *nptr, char **endptr, locale_t locale) { + (void)locale; + return strtof(nptr, endptr); + } + EOF + "${toolchain}/bin/aarch64-linux-android${api_level}-clang" -c "${v8_compat_source}" -o "${v8_compat_object}" + + builtins_archive="$( + find "${toolchain}/lib/clang" \ + -path "*/lib/linux/libclang_rt.builtins-aarch64-android.a" \ + -print -quit + )" + if [[ -z "${builtins_archive}" ]]; then + echo "Could not find Android compiler-rt builtins archive under ${toolchain}/lib/clang" >&2 + exit 1 + fi + builtins_dir="$(dirname "${builtins_archive}")" + + # Provide compatibility symbols and C++ ABI needed by the Android rusty_v8 archive. + rustflags_var="CARGO_TARGET_${target^^}_RUSTFLAGS" + rustflags_var="${rustflags_var//-/_}" + echo "${rustflags_var}=-C link-arg=${tls_align_object} -C link-arg=${v8_compat_object} -C link-arg=-Wl,-u,__codex_android_tls_align -C link-arg=-L${builtins_dir} -C link-arg=-lclang_rt.builtins-aarch64-android -C link-arg=-lc++abi -C link-arg=-Wl,-z,max-page-size=65536" >> "$GITHUB_ENV" + + + # Configure for vendored OpenSSL build + echo "CARGO_BUILD_TARGET_APPLIES_TO_HOST=false" >> "$GITHUB_ENV" + echo "CARGO_BUILD_TARGET=${{ matrix.target }}" >> "$GITHUB_ENV" + + - if: ${{ matrix.target == 'aarch64-linux-android' }} + name: Configure Android rusty_v8 artifact overrides + env: + GH_TOKEN: ${{ github.token }} + TARGET: ${{ matrix.target }} + shell: bash + run: | + set -euo pipefail + binding_dir="${RUNNER_TEMP}/rusty_v8" + archive="${binding_dir}/librusty_v8_release_${TARGET}.a.gz" + binding_path="${binding_dir}/src_binding_release_${TARGET}.rs" + checksums_path="${binding_dir}/rusty_v8_release_${TARGET}.sha256" + artifact_repository="wallentx/codex-termux" + release_tag="rusty-v8-v147.4.0" + mkdir -p "${binding_dir}" + echo "Downloading Android rusty_v8 artifacts from ${artifact_repository}@${release_tag}" + gh release download "${release_tag}" \ + --repo "${artifact_repository}" \ + --dir "${binding_dir}" \ + --pattern "librusty_v8_release_${TARGET}.a.gz" \ + --pattern "src_binding_release_${TARGET}.rs" \ + --pattern "rusty_v8_release_${TARGET}.sha256" + (cd "${binding_dir}" && sha256sum -c "$(basename "${checksums_path}")") + echo "RUSTY_V8_ARCHIVE=${archive}" >> "$GITHUB_ENV" + echo "RUSTY_V8_SRC_BINDING_PATH=${binding_path}" >> "$GITHUB_ENV" + + - if: ${{ matrix.target == 'aarch64-linux-android' }} + name: Install termux-elf-cleaner + shell: bash + run: | + set -euo pipefail + version="v3.0.1" + src_dir="${RUNNER_TEMP}/termux-elf-cleaner-src" + build_dir="${RUNNER_TEMP}/termux-elf-cleaner-build" + bin_dir="${RUNNER_TEMP}/termux-elf-cleaner-bin" + + rm -rf "${src_dir}" "${build_dir}" "${bin_dir}" + mkdir -p "${src_dir}" "${build_dir}" "${bin_dir}" + + curl -fsSL "https://github.com/termux/termux-elf-cleaner/archive/refs/tags/${version}.tar.gz" \ + | tar -xzf - --strip-components=1 -C "${src_dir}" + + cmake -S "${src_dir}" -B "${build_dir}" -DCMAKE_BUILD_TYPE=Release + cmake --build "${build_dir}" --parallel + + install "${build_dir}/termux-elf-cleaner" "${bin_dir}/termux-elf-cleaner" + echo "${bin_dir}" >> "$GITHUB_PATH" - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} name: Use hermetic Cargo home (musl) @@ -269,12 +473,25 @@ jobs: echo "${cargo_home}/bin" >> "$GITHUB_PATH" : > "${cargo_home}/config.toml" + - uses: actions/cache@v5 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + ${{ github.workspace }}/.cargo-home/bin/ + ${{ github.workspace }}/.cargo-home/registry/index/ + ${{ github.workspace }}/.cargo-home/registry/cache/ + ${{ github.workspace }}/.cargo-home/git/db/ + ${{ github.workspace }}/codex-rs/target/ + key: cargo-${{ matrix.runner }}-${{ matrix.target }}-release-${{ hashFiles('**/Cargo.lock') }} + - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} name: Install Zig - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 + uses: mlugg/setup-zig@v2 with: version: 0.14.0 - use-cache: false - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} name: Install musl build tools @@ -310,12 +527,6 @@ jobs: shell: bash run: | set -euo pipefail - # Avoid problematic aws-lc jitter entropy code path on musl builders. - echo "AWS_LC_SYS_NO_JITTER_ENTROPY=1" >> "$GITHUB_ENV" - target_no_jitter="AWS_LC_SYS_NO_JITTER_ENTROPY_${{ matrix.target }}" - target_no_jitter="${target_no_jitter//-/_}" - echo "${target_no_jitter}=1" >> "$GITHUB_ENV" - # Clear global Rust flags so host/proc-macro builds don't pull in UBSan. echo "RUSTFLAGS=" >> "$GITHUB_ENV" echo "CARGO_ENCODED_RUSTFLAGS=" >> "$GITHUB_ENV" @@ -340,94 +551,70 @@ jobs: echo "CFLAGS=${cflags}" >> "$GITHUB_ENV" echo "CXXFLAGS=${cxxflags}" >> "$GITHUB_ENV" - - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl' }} - name: Configure musl rusty_v8 artifact overrides and verify checksums - uses: ./.github/actions/setup-rusty-v8-musl - with: - target: ${{ matrix.target }} - - - if: ${{ contains(matrix.target, 'linux') && matrix.bundle == 'primary' }} - name: Build bwrap and export digest + - name: Cargo build shell: bash run: | set -euo pipefail - target="${{ matrix.target }}" - cargo build --target "$target" --release --timings --bin bwrap - bwrap_path="target/${target}/release/bwrap" - if [[ ! -f "$bwrap_path" ]]; then - echo "bwrap binary ${bwrap_path} not found" - exit 1 + if [[ "${{ matrix.target }}" == 'aarch64-linux-android' ]]; then + export CARGO_BUILD_JOBS=4 + export CARGO_PROFILE_RELEASE_LTO=thin + export CARGO_PROFILE_RELEASE_CODEGEN_UNITS=8 + cargo build --target ${{ matrix.target }} --release --bin codex + exit 0 fi - digest="$(sha256sum "$bwrap_path" | awk '{print $1}')" - echo "CODEX_BWRAP_SHA256=${digest}" >> "$GITHUB_ENV" - echo "Built bwrap ${bwrap_path} with sha256:${digest}" - - - name: Cargo build + if [[ "${{ contains(matrix.target, 'windows') }}" == 'true' ]]; then + cargo build --target ${{ matrix.target }} --release --bin codex --bin codex-responses-api-proxy --bin codex-windows-sandbox-setup --bin codex-command-runner + else + cargo build --target ${{ matrix.target }} --release --bin codex --bin codex-responses-api-proxy + fi + - name: sccache stats (Android) + if: ${{ matrix.target == 'aarch64-linux-android' }} shell: bash - run: | - build_args=() - for binary in ${{ matrix.binaries }}; do - build_args+=(--bin "$binary") - done - echo "CARGO_PROFILE_RELEASE_LTO: ${CARGO_PROFILE_RELEASE_LTO}" - cargo build --target ${{ matrix.target }} --release --timings "${build_args[@]}" + run: ${SCCACHE_PATH:-sccache} --show-stats || true - - name: Upload Cargo timings - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: cargo-timings-rust-release-${{ matrix.target }}-${{ matrix.bundle }} - path: codex-rs/target/**/cargo-timings/cargo-timing.html - if-no-files-found: warn - - - if: ${{ runner.os == 'macOS' && env.SIGN_MACOS != 'true' }} - name: Stage unsigned macOS artifacts + - if: ${{ matrix.target == 'aarch64-linux-android' }} + name: Normalize Android ELF for Termux shell: bash run: | set -euo pipefail - - target="${{ matrix.target }}" - release_dir="target/${target}/release" - dest="unsigned-dist/${target}" - mkdir -p "$dest" - - for binary in ${{ matrix.binaries }}; do - binary_path="${release_dir}/${binary}" - unsigned_name="${binary}-${target}-unsigned" - unsigned_path="${dest}/${unsigned_name}" - if [[ ! -f "${binary_path}" ]]; then - echo "Binary ${binary_path} not found" - exit 1 + for binary in codex codex-responses-api-proxy; do + binary_path="target/${{ matrix.target }}/release/${binary}" + if [[ -f "${binary_path}" ]]; then + termux-elf-cleaner --api-level 24 "${binary_path}" + chmod +x "${binary_path}" + # if [[ "${binary}" == "codex" ]]; then + # # Patch PT_TLS (0x7) to PT_NULL (0x0) at offset 400 to bypass Bionic alignment checks. + # printf '\x00\x00\x00\x00' | dd of="${binary_path}" bs=1 seek=400 count=4 conv=notrunc + # fi fi - - cp "${binary_path}" "${unsigned_path}" - tar -C "$dest" -czf "${unsigned_path}.tar.gz" "${unsigned_name}" - zstd -T0 -19 --rm "${unsigned_path}" done - - if: ${{ runner.os == 'macOS' && env.SIGN_MACOS != 'true' }} - name: Upload unsigned macOS artifacts - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: ${{ matrix.artifact_name }}-unsigned - path: codex-rs/unsigned-dist/${{ matrix.target }}/* - if-no-files-found: error - - - if: ${{ contains(matrix.target, 'linux') }} + - if: ${{ contains(matrix.target, 'linux') && !contains(matrix.target, 'android') && github.repository_owner == 'openai' }} name: Cosign Linux artifacts uses: ./.github/actions/linux-code-sign with: target: ${{ matrix.target }} artifacts-dir: ${{ github.workspace }}/codex-rs/target/${{ matrix.target }}/release - binaries: ${{ matrix.binaries }} - - if: ${{ runner.os == 'macOS' && env.SIGN_MACOS == 'true' }} + - if: ${{ contains(matrix.target, 'windows') && github.repository_owner == 'openai' }} + name: Sign Windows binaries with Azure Trusted Signing + uses: ./.github/actions/windows-code-sign + with: + target: ${{ matrix.target }} + client-id: ${{ secrets.AZURE_TRUSTED_SIGNING_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TRUSTED_SIGNING_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_TRUSTED_SIGNING_SUBSCRIPTION_ID }} + endpoint: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} + account-name: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} + certificate-profile-name: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} + + - if: ${{ runner.os == 'macOS' && github.repository_owner == 'openai' }} name: MacOS code signing (binaries) uses: ./.github/actions/macos-code-sign with: target: ${{ matrix.target }} - binaries: ${{ matrix.binaries }} sign-binaries: "true" sign-dmg: "false" apple-certificate: ${{ secrets.APPLE_CERTIFICATE_P12 }} @@ -436,7 +623,7 @@ jobs: apple-notarization-key-id: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} apple-notarization-issuer-id: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} - - if: ${{ runner.os == 'macOS' && matrix.build_dmg == 'true' && env.SIGN_MACOS == 'true' }} + - if: ${{ runner.os == 'macOS' && github.repository_owner == 'openai' }} name: Build macOS dmg shell: bash run: | @@ -451,17 +638,23 @@ jobs: # The previous "MacOS code signing (binaries)" step signs + notarizes the # built artifacts in `${release_dir}`. This step packages *those same* # signed binaries into a dmg. + codex_binary_path="${release_dir}/codex" + proxy_binary_path="${release_dir}/codex-responses-api-proxy" + rm -rf "$dmg_root" mkdir -p "$dmg_root" - for binary in ${{ matrix.binaries }}; do - binary_path="${release_dir}/${binary}" - if [[ ! -f "${binary_path}" ]]; then - echo "Binary ${binary_path} not found" - exit 1 - fi - ditto "${binary_path}" "${dmg_root}/${binary}" - done + if [[ ! -f "$codex_binary_path" ]]; then + echo "Binary $codex_binary_path not found" + exit 1 + fi + if [[ ! -f "$proxy_binary_path" ]]; then + echo "Binary $proxy_binary_path not found" + exit 1 + fi + + ditto "$codex_binary_path" "${dmg_root}/codex" + ditto "$proxy_binary_path" "${dmg_root}/codex-responses-api-proxy" rm -f "$dmg_path" hdiutil create \ @@ -476,7 +669,7 @@ jobs: exit 1 fi - - if: ${{ runner.os == 'macOS' && matrix.build_dmg == 'true' && env.SIGN_MACOS == 'true' }} + - if: ${{ runner.os == 'macOS' && github.repository_owner == 'openai' }} name: MacOS code signing (dmg) uses: ./.github/actions/macos-code-sign with: @@ -490,121 +683,65 @@ jobs: apple-notarization-issuer-id: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} - name: Stage artifacts - if: ${{ runner.os != 'macOS' || env.SIGN_MACOS == 'true' }} shell: bash run: | dest="dist/${{ matrix.target }}" mkdir -p "$dest" - for binary in ${{ matrix.binaries }}; do - cp "target/${{ matrix.target }}/release/${binary}" "$dest/${binary}-${{ matrix.target }}" - if [[ "${{ matrix.target }}" == *linux* ]]; then - cp "target/${{ matrix.target }}/release/${binary}.sigstore" \ - "$dest/${binary}-${{ matrix.target }}.sigstore" + if [[ "${{ matrix.runner }}" == windows* ]]; then + cp target/${{ matrix.target }}/release/codex.exe "$dest/codex-${{ matrix.target }}.exe" + cp target/${{ matrix.target }}/release/codex-responses-api-proxy.exe "$dest/codex-responses-api-proxy-${{ matrix.target }}.exe" + cp target/${{ matrix.target }}/release/codex-windows-sandbox-setup.exe "$dest/codex-windows-sandbox-setup-${{ matrix.target }}.exe" + cp target/${{ matrix.target }}/release/codex-command-runner.exe "$dest/codex-command-runner-${{ matrix.target }}.exe" + else + # For Android, we want the binary to be named just 'codex' in the archive. + if [[ "${{ matrix.target }}" == "aarch64-linux-android" ]]; then + cp target/${{ matrix.target }}/release/codex "$dest/codex" + else + cp target/${{ matrix.target }}/release/codex "$dest/codex-${{ matrix.target }}" + fi + if [[ -f target/${{ matrix.target }}/release/codex-responses-api-proxy ]]; then + cp target/${{ matrix.target }}/release/codex-responses-api-proxy "$dest/codex-responses-api-proxy-${{ matrix.target }}" fi - done - - if [[ "${{ matrix.target }}" == *linux* && "${{ matrix.bundle }}" == "primary" ]]; then - bundle_root="${RUNNER_TEMP}/codex-${{ matrix.target }}-bundle" - rm -rf "$bundle_root" - mkdir -p "$bundle_root/codex-resources" - cp "$dest/codex-${{ matrix.target }}" "$bundle_root/codex" - cp "$dest/bwrap-${{ matrix.target }}" "$bundle_root/codex-resources/bwrap" - chmod 0755 "$bundle_root/codex" "$bundle_root/codex-resources/bwrap" - tar -C "$bundle_root" -cf - codex codex-resources/bwrap | - zstd -T0 -19 -o "$dest/codex-${{ matrix.target }}-bundle.tar.zst" fi - if [[ "${{ matrix.build_dmg }}" == "true" ]]; then - cp target/${{ matrix.target }}/release/codex-${{ matrix.target }}.dmg "$dest/codex-${{ matrix.target }}.dmg" + if [[ "${{ matrix.target }}" == *linux* && "${{ matrix.target }}" != *android* ]]; then + cp target/${{ matrix.target }}/release/codex.sigstore "$dest/codex-${{ matrix.target }}.sigstore" + cp target/${{ matrix.target }}/release/codex-responses-api-proxy.sigstore "$dest/codex-responses-api-proxy-${{ matrix.target }}.sigstore" fi - - name: Build Codex package archive - if: ${{ runner.os != 'macOS' || env.SIGN_MACOS == 'true' }} - shell: bash - env: - TARGET: ${{ matrix.target }} - BUNDLE: ${{ matrix.bundle }} - run: | - set -euo pipefail - bash "${GITHUB_WORKSPACE}/.github/scripts/build-codex-package-archive.sh" \ - --target "$TARGET" \ - --bundle "$BUNDLE" \ - --entrypoint-dir "target/${TARGET}/release" \ - --archive-dir "dist/${TARGET}" - - - name: Build Python runtime wheel - if: ${{ matrix.bundle == 'primary' && (runner.os != 'macOS' || env.SIGN_MACOS == 'true') }} - shell: bash - run: | - set -euo pipefail - - case "${{ matrix.target }}" in - aarch64-apple-darwin) - platform_tag="macosx_11_0_arm64" - ;; - x86_64-apple-darwin) - platform_tag="macosx_10_9_x86_64" - ;; - aarch64-unknown-linux-musl) - platform_tag="manylinux_2_17_aarch64" - ;; - x86_64-unknown-linux-musl) - platform_tag="manylinux_2_17_x86_64" - ;; - *) - echo "No Python runtime wheel platform tag for ${{ matrix.target }}" - exit 1 - ;; - esac - - python3 -m venv "${RUNNER_TEMP}/python-runtime-build-venv" - # Do not install into the runner's system Python; macOS runners mark - # the Homebrew Python as externally managed under PEP 668. - "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m pip install build - - stage_dir="${RUNNER_TEMP}/openai-codex-cli-bin-${{ matrix.target }}" - wheel_dir="${GITHUB_WORKSPACE}/python-runtime-dist/${{ matrix.target }}" - stage_runtime_args=( - "${GITHUB_WORKSPACE}/sdk/python/scripts/update_sdk_artifacts.py" - stage-runtime - "$stage_dir" - "${GITHUB_WORKSPACE}/codex-rs/target/${{ matrix.target }}/release/codex" - --codex-version "${GITHUB_REF_NAME}" - --platform-tag "$platform_tag" - ) - if [[ "${{ matrix.target }}" == *linux* ]]; then - # Keep bwrap in the runtime wheel so Linux sandbox fallback behavior - # matches the standalone release bundle on hosts without system bwrap. - stage_runtime_args+=( - --resource-binary - "${GITHUB_WORKSPACE}/codex-rs/target/${{ matrix.target }}/release/bwrap" - ) + if [[ "${{ matrix.target }}" == *apple-darwin ]]; then + cp target/${{ matrix.target }}/release/codex-${{ matrix.target }}.dmg "$dest/codex-${{ matrix.target }}.dmg" fi - python3 "${stage_runtime_args[@]}" - "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m build --wheel --outdir "$wheel_dir" "$stage_dir" - - name: Upload Python runtime wheel - if: ${{ matrix.bundle == 'primary' && (runner.os != 'macOS' || env.SIGN_MACOS == 'true') }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: python-runtime-wheel-${{ matrix.target }} - path: python-runtime-dist/${{ matrix.target }}/*.whl - if-no-files-found: error + - if: ${{ matrix.runner == 'windows-11-arm' }} + name: Install zstd + shell: powershell + run: choco install -y zstandard - name: Compress artifacts - if: ${{ runner.os != 'macOS' || env.SIGN_MACOS == 'true' }} shell: bash run: | # Path that contains the uncompressed binaries for the current # ${{ matrix.target }} dest="dist/${{ matrix.target }}" + repo_root=$PWD + + # We want to ship the raw Windows executables in the GitHub Release + # in addition to the compressed archives. Keep the originals for + # Windows targets; remove them elsewhere to limit the number of + # artifacts that end up in the GitHub Release. + keep_originals=false + if [[ "${{ matrix.runner }}" == windows* ]]; then + keep_originals=true + fi # For compatibility with environments that lack the `zstd` tool we - # additionally create a `.tar.gz` alongside every binary we publish. - # The end result is: + # additionally create a `.tar.gz` for all platforms and `.zip` for + # Windows alongside every single binary that we publish. The end result is: # codex-.zst (existing) # codex-.tar.gz (new) + # codex-.zip (only for Windows) # 1. Produce a .tar.gz for every file in the directory *before* we # run `zstd --rm`, because that flag deletes the original files. @@ -612,7 +749,7 @@ jobs: base="$(basename "$f")" # Skip files that are already archives (shouldn't happen, but be # safe). - if [[ "$base" == *.tar.gz || "$base" == *.tar.zst || "$base" == *.zip || "$base" == *.dmg ]]; then + if [[ "$base" == *.tar.gz || "$base" == *.zip || "$base" == *.dmg ]]; then continue fi @@ -622,344 +759,239 @@ jobs: fi # Create per-binary tar.gz - tar -C "$dest" -czf "$dest/${base}.tar.gz" "$base" - - # Also create .zst and remove the uncompressed binaries to keep - # non-Windows artifact directories small. - zstd -T0 -19 --rm "$dest/$base" - done - - - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - if: ${{ runner.os != 'macOS' || env.SIGN_MACOS == 'true' }} - with: - name: ${{ matrix.artifact_name }} - # Upload the per-binary .zst files, .tar.gz equivalents, and any - # prebuilt archives staged above. - path: | - codex-rs/dist/${{ matrix.target }}/* - - stage-signed-macos: - if: ${{ github.event_name == 'workflow_dispatch' && inputs.release_mode == 'promote_signed' }} - needs: tag-check - name: Stage signed macOS handoff - ${{ matrix.target }} - ${{ matrix.bundle }} - runs-on: macos-15-xlarge - timeout-minutes: 30 - permissions: - contents: read - defaults: - run: - working-directory: codex-rs + # For Android, we want the archive to have the target suffix even though the binary is just 'codex'. + archive_name="${base}" + if [[ "${{ matrix.target }}" == "aarch64-linux-android" && "${base}" == "codex" ]]; then + archive_name="codex-${{ matrix.target }}" + fi + tar -C "$dest" -czf "$dest/${archive_name}.tar.gz" "$base" + + # Create zip archive for Windows binaries + # Must run from inside the dest dir so 7z won't + # embed the directory path inside the zip. + if [[ "${{ matrix.runner }}" == windows* ]]; then + if [[ "$base" == "codex-${{ matrix.target }}.exe" ]]; then + # Bundle the sandbox helper binaries into the main codex zip so + # WinGet installs include the required helpers next to codex.exe. + # Fall back to the single-binary zip if the helpers are missing + # to avoid breaking releases. + bundle_dir="$(mktemp -d)" + runner_src="$dest/codex-command-runner-${{ matrix.target }}.exe" + setup_src="$dest/codex-windows-sandbox-setup-${{ matrix.target }}.exe" + if [[ -f "$runner_src" && -f "$setup_src" ]]; then + cp "$dest/$base" "$bundle_dir/$base" + cp "$runner_src" "$bundle_dir/codex-command-runner.exe" + cp "$setup_src" "$bundle_dir/codex-windows-sandbox-setup.exe" + # Use an absolute path so bundle zips land in the real dist + # dir even when 7z runs from a temp directory. + (cd "$bundle_dir" && 7z a "$repo_root/$dest/${base}.zip" .) + else + echo "warning: missing sandbox binaries; falling back to single-binary zip" + echo "warning: expected $runner_src and $setup_src" + (cd "$dest" && 7z a "${base}.zip" "$base") + fi + rm -rf "$bundle_dir" + else + (cd "$dest" && 7z a "${base}.zip" "$base") + fi + fi - strategy: - fail-fast: false - matrix: - include: - - target: aarch64-apple-darwin - bundle: primary - artifact_name: aarch64-apple-darwin - binaries: "codex codex-responses-api-proxy" - build_dmg: "false" - - target: aarch64-apple-darwin - bundle: app-server - artifact_name: aarch64-apple-darwin-app-server - binaries: "codex-app-server" - build_dmg: "false" - - target: x86_64-apple-darwin - bundle: primary - artifact_name: x86_64-apple-darwin - binaries: "codex codex-responses-api-proxy" - build_dmg: "false" - - target: x86_64-apple-darwin - bundle: app-server - artifact_name: x86_64-apple-darwin-app-server - binaries: "codex-app-server" - build_dmg: "false" + # Also create .zst (existing behaviour) *and* remove the original + # uncompressed binary to keep the directory small. + zstd_args=(-T0 -19) + if [[ "${keep_originals}" == false ]]; then + zstd_args+=(--rm) + fi - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false + if [[ "${archive_name}" != "${base}" ]]; then + zstd "${zstd_args[@]}" "$dest/$base" -o "$dest/${archive_name}.zst" + else + zstd "${zstd_args[@]}" "$dest/$base" + fi + done - - name: Download signed macOS handoff + - name: Add Termux release metadata + if: ${{ (github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch') && matrix.target == 'aarch64-linux-android' }} shell: bash - env: - GH_TOKEN: ${{ github.token }} - SIGNED_MACOS_ASSET: ${{ inputs.signed_macos_asset }} - SIGNED_MACOS_SHA256: ${{ inputs.signed_macos_sha256 }} run: | set -euo pipefail - - download_dir="${RUNNER_TEMP}/signed-macos-download" - handoff_dir="${RUNNER_TEMP}/signed-macos-handoff" - rm -rf "$download_dir" "$handoff_dir" - mkdir -p "$download_dir" "$handoff_dir" - - gh release download "$GITHUB_REF_NAME" \ - --repo "$GITHUB_REPOSITORY" \ - --pattern "$SIGNED_MACOS_ASSET" \ - --dir "$download_dir" - - asset_count="$(find "$download_dir" -maxdepth 1 -type f | wc -l | tr -d '[:space:]')" - if [[ "$asset_count" != "1" ]]; then - echo "Expected exactly one signed macOS handoff asset named ${SIGNED_MACOS_ASSET}; found ${asset_count}" - find "$download_dir" -maxdepth 1 -type f -print - exit 1 - fi - - asset_path="$(find "$download_dir" -maxdepth 1 -type f -print -quit)" - if [[ -n "${SIGNED_MACOS_SHA256}" ]]; then - expected_sha="$(printf '%s' "$SIGNED_MACOS_SHA256" | tr '[:upper:]' '[:lower:]')" - actual_sha="$(shasum -a 256 "$asset_path" | awk '{print $1}')" - if [[ "$actual_sha" != "$expected_sha" ]]; then - echo "signed_macos_sha256 mismatch for ${SIGNED_MACOS_ASSET}" - echo "expected: ${expected_sha}" - echo "actual: ${actual_sha}" - exit 1 - fi + if [[ ! -f "${GITHUB_WORKSPACE}/.github/termux-release.json" ]]; then + echo "No Termux release metadata found; skipping metadata attachment." + exit 0 fi + dest="dist/${{ matrix.target }}" + cp "${GITHUB_WORKSPACE}/.github/termux-release.json" "${dest}/termux-release.json" + ( + cd "${dest}" + sha256sum ./* > SHA256SUMS + ) - asset_name="$(basename "$asset_path")" - case "$asset_name" in - *.tar.zst) - zstd -dc "$asset_path" | tar -C "$handoff_dir" -xf - - ;; - *.tar.gz|*.tgz) - tar -C "$handoff_dir" -xzf "$asset_path" - ;; - *.zip) - ditto -x -k "$asset_path" "$handoff_dir" - ;; - *) - echo "Unsupported signed macOS handoff archive format: ${asset_name}" - exit 1 - ;; - esac - - echo "SIGNED_MACOS_HANDOFF_DIR=$handoff_dir" >> "$GITHUB_ENV" + - id: upload-artifact + uses: actions/upload-artifact@v6 + with: + name: ${{ github.event_name == 'pull_request' && format('termux-android-pr-{0}-{1}', github.event.pull_request.number, github.event.pull_request.head.sha) || matrix.target }} + # Upload the per-binary .zst files as well as the new .tar.gz + # equivalents we generated in the previous step. + path: | + codex-rs/dist/${{ matrix.target }}/* - - name: Stage signed macOS artifacts + - name: Publish Termux artifact notification + if: ${{ github.event_name == 'pull_request' && startsWith(github.base_ref, 'release/') && matrix.target == 'aarch64-linux-android' }} shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ARTIFACT_ID: ${{ steps.upload-artifact.outputs.artifact-id }} + ARTIFACT_NAME: ${{ github.event_name == 'pull_request' && format('termux-android-pr-{0}-{1}', github.event.pull_request.number, github.event.pull_request.head.sha) || matrix.target }} + PR_NUMBER: ${{ github.event.pull_request.number }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} run: | set -euo pipefail - - target="${{ matrix.target }}" - artifact_name="${{ matrix.artifact_name }}" - source_dir="${SIGNED_MACOS_HANDOFF_DIR}/${artifact_name}" - if [[ ! -d "$source_dir" && -d "${SIGNED_MACOS_HANDOFF_DIR}/dist/${artifact_name}" ]]; then - source_dir="${SIGNED_MACOS_HANDOFF_DIR}/dist/${artifact_name}" - fi - if [[ ! -d "$source_dir" && -d "${SIGNED_MACOS_HANDOFF_DIR}/${target}" ]]; then - source_dir="${SIGNED_MACOS_HANDOFF_DIR}/${target}" - fi - if [[ ! -d "$source_dir" && -d "${SIGNED_MACOS_HANDOFF_DIR}/dist/${target}" ]]; then - source_dir="${SIGNED_MACOS_HANDOFF_DIR}/dist/${target}" - fi - if [[ ! -d "$source_dir" ]]; then - echo "Signed macOS handoff is missing ${artifact_name}/" - echo "Expected either:" - echo " ${SIGNED_MACOS_HANDOFF_DIR}/${artifact_name}" - echo " ${SIGNED_MACOS_HANDOFF_DIR}/dist/${artifact_name}" - echo " ${SIGNED_MACOS_HANDOFF_DIR}/${target}" - echo " ${SIGNED_MACOS_HANDOFF_DIR}/dist/${target}" - find "$SIGNED_MACOS_HANDOFF_DIR" -maxdepth 3 -type f -print + if [[ -z "${ARTIFACT_ID}" ]]; then + echo "upload-artifact did not return an artifact id" >&2 exit 1 fi - dest="dist/${target}" - mkdir -p "$dest" - - for binary in ${{ matrix.binaries }}; do - source_path="${source_dir}/${binary}" - if [[ ! -f "$source_path" ]]; then - source_path="${source_dir}/${binary}-${target}" - fi - if [[ ! -f "$source_path" ]]; then - echo "Signed macOS handoff is missing ${binary} for ${artifact_name}" - exit 1 - fi - - release_path="${dest}/${binary}-${target}" - ditto "$source_path" "$release_path" - chmod 0755 "$release_path" - codesign --verify --strict --verbose=2 "$release_path" - done - - # DMG staging is disabled for signed promotion because we no longer - # distribute DMGs from this release path. Keep the branch here so the - # handoff can opt back in by flipping matrix.build_dmg if needed. - if [[ "${{ matrix.build_dmg }}" == "true" ]]; then - dmg_name="codex-${target}.dmg" - dmg_source="${source_dir}/${dmg_name}" - if [[ ! -f "$dmg_source" ]]; then - echo "Signed macOS handoff is missing ${dmg_name} for ${artifact_name}" - exit 1 - fi - - codesign --verify --strict --verbose=2 "$dmg_source" - xcrun stapler validate "$dmg_source" - cp "$dmg_source" "$dest/$dmg_name" + marker="" + artifact_url="https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/artifacts/${ARTIFACT_ID}" + run_url="${GH_WORKFLOW_URL:-https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}}" + body_path="${RUNNER_TEMP}/termux-artifact-comment.md" + { + echo "${marker}" + echo "Termux Android artifact ready for testing:" + echo + echo "- Artifact: [${ARTIFACT_NAME}](${artifact_url})" + echo "- Workflow run: [${GITHUB_RUN_ID}](${run_url})" + echo + echo "You must be signed in to GitHub with repository access to download Actions artifacts." + } > "${body_path}" + + existing_comment_id="$( + gh pr view "${PR_NUMBER}" \ + --repo "${GITHUB_REPOSITORY}" \ + --json comments \ + --jq ".comments | map(select(.author.is_bot == true and (.body | contains(\"${marker}\")))) | .[-1].id // \"\"" + )" + if [[ -n "${existing_comment_id}" ]]; then + gh api graphql \ + -f query=' + mutation($id: ID!, $body: String!) { + updateIssueComment(input: {id: $id, body: $body}) { + issueComment { + id + } + } + } + ' \ + -f id="${existing_comment_id}" \ + -f body="$(cat "${body_path}")" \ + >/dev/null + else + gh pr comment "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" --body-file "${body_path}" fi - - name: Build Python runtime wheel - if: ${{ matrix.bundle == 'primary' }} - shell: bash - run: | - set -euo pipefail - - case "${{ matrix.target }}" in - aarch64-apple-darwin) - platform_tag="macosx_11_0_arm64" - ;; - x86_64-apple-darwin) - platform_tag="macosx_10_9_x86_64" - ;; - *) - echo "No Python runtime wheel platform tag for ${{ matrix.target }}" - exit 1 - ;; - esac - - python3 -m venv "${RUNNER_TEMP}/python-runtime-build-venv" - "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m pip install build - - stage_dir="${RUNNER_TEMP}/openai-codex-cli-bin-${{ matrix.target }}" - wheel_dir="${GITHUB_WORKSPACE}/python-runtime-dist/${{ matrix.target }}" - python3 \ - "${GITHUB_WORKSPACE}/sdk/python/scripts/update_sdk_artifacts.py" \ - stage-runtime \ - "$stage_dir" \ - "${GITHUB_WORKSPACE}/codex-rs/dist/${{ matrix.target }}/codex-${{ matrix.target }}" \ - --codex-version "${GITHUB_REF_NAME}" \ - --platform-tag "$platform_tag" - "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m build --wheel --outdir "$wheel_dir" "$stage_dir" - - - name: Build Codex package archive - shell: bash - env: - TARGET: ${{ matrix.target }} - BUNDLE: ${{ matrix.bundle }} - run: | - set -euo pipefail - bash "${GITHUB_WORKSPACE}/.github/scripts/build-codex-package-archive.sh" \ - --target "$TARGET" \ - --bundle "$BUNDLE" \ - --entrypoint-dir "dist/${TARGET}" \ - --archive-dir "dist/${TARGET}" \ - --target-suffixed-entrypoint - - - name: Upload Python runtime wheel - if: ${{ matrix.bundle == 'primary' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: python-runtime-wheel-${{ matrix.target }} - path: python-runtime-dist/${{ matrix.target }}/*.whl - if-no-files-found: error - - - name: Compress artifacts - shell: bash - run: | - set -euo pipefail - - dest="dist/${{ matrix.target }}" - for f in "$dest"/*; do - base="$(basename "$f")" - if [[ "$base" == *.tar.gz || "$base" == *.tar.zst || "$base" == *.zip || "$base" == *.dmg ]]; then - continue + gh label create binary-ready \ + --repo "${GITHUB_REPOSITORY}" \ + --color 0e8a16 \ + --description "Android binary is ready for testing" \ + --force + gh pr edit "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" --add-label binary-ready + + if [[ -n "${SLACK_WEBHOOK_URL:-}" ]]; then + slack_payload_path="${RUNNER_TEMP}/termux-artifact-slack.json" + pr_url="https://github.com/${GITHUB_REPOSITORY}/pull/${PR_NUMBER}" + jq -n \ + --arg artifact_name "${ARTIFACT_NAME}" \ + --arg artifact_url "${artifact_url}" \ + --arg pr_number "${PR_NUMBER}" \ + --arg pr_url "${pr_url}" \ + --arg run_id "${GITHUB_RUN_ID}" \ + --arg run_url "${run_url}" \ + '{ + text: ("Termux Android artifact ready for testing: " + $artifact_url), + blocks: [ + { + type: "section", + text: { + type: "mrkdwn", + text: ("*Termux Android artifact ready for testing*\n" + $artifact_url) + } + }, + { + type: "section", + fields: [ + { + type: "mrkdwn", + text: ("*Artifact:*\n<" + $artifact_url + "|" + $artifact_name + ">") + }, + { + type: "mrkdwn", + text: ("*Pull request:*\n<" + $pr_url + "|#" + $pr_number + ">") + }, + { + type: "mrkdwn", + text: ("*Workflow run:*\n<" + $run_url + "|" + $run_id + ">") + } + ] + }, + { + type: "context", + elements: [ + { + type: "mrkdwn", + text: "You must be signed in to GitHub with repository access to download Actions artifacts." + } + ] + } + ] + }' > "${slack_payload_path}" + + if ! curl -fsS \ + -X POST \ + -H "Content-type: application/json" \ + --data @"${slack_payload_path}" \ + "${SLACK_WEBHOOK_URL}"; then + echo "::warning::Failed to send Slack artifact notification" fi + fi - tar -C "$dest" -czf "$dest/${base}.tar.gz" "$base" - zstd -T0 -19 --rm "$dest/$base" - done - - - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: ${{ matrix.artifact_name }} - path: | - codex-rs/dist/${{ matrix.target }}/* - - build-windows: - if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} - needs: tag-check - uses: ./.github/workflows/rust-release-windows.yml - with: - release-lto: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'fat' }} - secrets: inherit - - argument-comment-lint-release-assets: - if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} - name: argument-comment-lint release assets + shell-tool-mcp: + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && github.repository_owner == 'openai' + name: shell-tool-mcp needs: tag-check - uses: ./.github/workflows/rust-release-argument-comment-lint.yml + permissions: + contents: read + id-token: write + uses: ./.github/workflows/shell-tool-mcp.yml with: + release-tag: ${{ github.ref_name }} publish: true - - zsh-release-assets: - if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} - name: zsh release assets - needs: tag-check - uses: ./.github/workflows/rust-release-zsh.yml + secrets: inherit release: + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') needs: - - tag-check - build - - stage-signed-macos - - build-windows - - argument-comment-lint-release-assets - - zsh-release-assets - if: >- - ${{ - always() && - needs.tag-check.result == 'success' && - ( - ( - github.event_name == 'workflow_dispatch' && - inputs.release_mode == 'promote_signed' && - needs.stage-signed-macos.result == 'success' && - needs.build.result == 'skipped' && - needs.build-windows.result == 'skipped' && - needs.argument-comment-lint-release-assets.result == 'skipped' && - needs.zsh-release-assets.result == 'skipped' - ) || - ( - (github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed') && - needs.build.result == 'success' && - needs.stage-signed-macos.result == 'skipped' && - needs.build-windows.result == 'success' && - needs.argument-comment-lint-release-assets.result == 'success' && - needs.zsh-release-assets.result == 'success' - ) - ) - }} + - shell-tool-mcp name: release runs-on: ubuntu-latest permissions: contents: write actions: read - env: - RELEASE_MODE: ${{ github.event_name == 'workflow_dispatch' && inputs.release_mode || 'signed' }} - SIGN_MACOS: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode == 'promote_signed' }} - SIGNED_MACOS_ASSET: ${{ inputs.signed_macos_asset }} - UNSIGNED_RUN_ID: ${{ inputs.unsigned_run_id }} outputs: version: ${{ steps.release_name.outputs.name }} tag: ${{ github.ref_name }} - sign_macos: ${{ steps.release_mode.outputs.sign_macos }} should_publish_npm: ${{ steps.npm_publish_settings.outputs.should_publish }} npm_tag: ${{ steps.npm_publish_settings.outputs.npm_tag }} - should_publish_python_runtime: ${{ steps.python_runtime_publish_settings.outputs.should_publish }} steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false + uses: actions/checkout@v6 - - name: Define release mode - id: release_mode - run: | - echo "release_mode=${RELEASE_MODE}" >> "$GITHUB_OUTPUT" - echo "sign_macos=${SIGN_MACOS}" >> "$GITHUB_OUTPUT" + - name: 🧰 Actions Toolbox + # This is required for the GitHub CLI + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: wallentx/gh-actions/composite/actions-toolbox@main - name: Generate release notes from tag commit message id: release_notes @@ -981,138 +1013,18 @@ jobs: echo "path=${notes_path}" >> "${GITHUB_OUTPUT}" - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + - uses: actions/download-artifact@v7 with: path: dist - - name: Validate unsigned build run - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - - run_summary="$(gh run view "$UNSIGNED_RUN_ID" \ - --repo "$GITHUB_REPOSITORY" \ - --json conclusion,event,headBranch,headSha,status,workflowName,url \ - --jq '[.workflowName, .event, .headBranch, .headSha, .status, .conclusion, .url] | @tsv')" - IFS=$'\t' read -r workflow_name event head_branch head_sha status conclusion run_url <<< "$run_summary" - expected_head_sha="$(git rev-parse "${GITHUB_SHA}^{commit}")" - - if [[ "$workflow_name" != "$GITHUB_WORKFLOW" ]]; then - echo "unsigned_run_id ${UNSIGNED_RUN_ID} is for workflow '${workflow_name}', expected '${GITHUB_WORKFLOW}'" - echo "Run URL: ${run_url}" - exit 1 - fi - - if [[ "$event" != "workflow_dispatch" ]]; then - echo "unsigned_run_id ${UNSIGNED_RUN_ID} was triggered by '${event}', expected 'workflow_dispatch'" - echo "Run URL: ${run_url}" - exit 1 - fi - - if [[ "$head_branch" != "$GITHUB_REF_NAME" ]]; then - echo "unsigned_run_id ${UNSIGNED_RUN_ID} used ref '${head_branch}', expected '${GITHUB_REF_NAME}'" - echo "Run URL: ${run_url}" - exit 1 - fi - - if [[ "$head_sha" != "$expected_head_sha" ]]; then - echo "unsigned_run_id ${UNSIGNED_RUN_ID} used head SHA '${head_sha}', expected '${expected_head_sha}'" - echo "Run URL: ${run_url}" - exit 1 - fi - - if [[ "$status" != "completed" || "$conclusion" != "success" ]]; then - echo "unsigned_run_id ${UNSIGNED_RUN_ID} is ${status}/${conclusion}, expected completed/success" - echo "Run URL: ${run_url}" - exit 1 - fi - - - name: Download artifacts from unsigned build run - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - gh run download "$UNSIGNED_RUN_ID" \ - --repo "$GITHUB_REPOSITORY" \ - --dir dist - - - name: Remove unsigned macOS staging artifacts - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - run: | - set -euo pipefail - find dist -mindepth 1 -maxdepth 1 -type d \ - -name '*-apple-darwin*-unsigned' \ - -exec rm -rf {} + - - - name: Re-upload promoted Linux x64 artifacts - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: x86_64-unknown-linux-musl - path: dist/x86_64-unknown-linux-musl/* - if-no-files-found: error - - - name: Re-upload promoted Linux arm64 artifacts - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: aarch64-unknown-linux-musl - path: dist/aarch64-unknown-linux-musl/* - if-no-files-found: error - - - name: Re-upload promoted Windows x64 artifacts - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: x86_64-pc-windows-msvc - path: dist/x86_64-pc-windows-msvc/* - if-no-files-found: error - - - name: Re-upload promoted Windows arm64 artifacts - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: aarch64-pc-windows-msvc - path: dist/aarch64-pc-windows-msvc/* - if-no-files-found: error - - name: List run: ls -R dist/ - - name: Prune artifacts excluded from unsigned macOS release - if: ${{ env.SIGN_MACOS == 'false' }} - run: | - find dist -mindepth 1 -maxdepth 1 -type d \ - ! -name '*-apple-darwin*-unsigned' \ - ! -name 'aarch64-unknown-linux-musl' \ - ! -name 'aarch64-unknown-linux-musl-app-server' \ - ! -name 'x86_64-unknown-linux-musl' \ - ! -name 'x86_64-unknown-linux-musl-app-server' \ - ! -name 'aarch64-pc-windows-msvc' \ - ! -name 'x86_64-pc-windows-msvc' \ - -exec rm -rf {} + - - if ! find dist -type f -name '*-apple-darwin*-unsigned*' | grep -q .; then - echo "No unsigned macOS artifacts found in downloaded workflow artifacts." - exit 1 - fi - + # This is a temporary fix: we should modify shell-tool-mcp.yml so these + # files do not end up in dist/ in the first place. - name: Delete entries from dist/ that should not go in the release run: | - rm -rf dist/windows-binaries* - # cargo-timing.html appears under multiple target-specific directories. - # If included in files: dist/**, release upload races on duplicate - # asset names and can fail with 404s. - find dist -type f -name 'cargo-timing.html' -delete - # Keep package-builder sidecar archives as workflow artifacts only - # until distribution channels are ready to consume them. - find dist -type f \ - \( -name 'codex-package-*' -o -name 'codex-app-server-package-*' \) \ - -delete - find dist -type d -empty -delete + rm -rf dist/shell-tool-mcp* ls -R dist/ @@ -1136,12 +1048,6 @@ jobs: set -euo pipefail version="${VERSION}" - if [[ "${SIGN_MACOS}" != "true" ]]; then - echo "should_publish=false" >> "$GITHUB_OUTPUT" - echo "npm_tag=" >> "$GITHUB_OUTPUT" - exit 0 - fi - if [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "should_publish=true" >> "$GITHUB_OUTPUT" echo "npm_tag=" >> "$GITHUB_OUTPUT" @@ -1153,132 +1059,55 @@ jobs: echo "npm_tag=" >> "$GITHUB_OUTPUT" fi - - name: Determine Python runtime publish settings - id: python_runtime_publish_settings - env: - VERSION: ${{ steps.release_name.outputs.name }} - run: | - set -euo pipefail - version="${VERSION}" - - if [[ "${SIGN_MACOS}" != "true" ]]; then - echo "should_publish=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - if [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "should_publish=true" >> "$GITHUB_OUTPUT" - elif [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+-alpha\.[0-9]+$ ]]; then - echo "should_publish=true" >> "$GITHUB_OUTPUT" - else - echo "should_publish=false" >> "$GITHUB_OUTPUT" - fi - - name: Setup pnpm - if: ${{ env.SIGN_MACOS == 'true' }} - uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5 + uses: pnpm/action-setup@v4 with: run_install: false - name: Setup Node.js for npm packaging - if: ${{ env.SIGN_MACOS == 'true' }} - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + uses: actions/setup-node@v6 with: node-version: 22 - name: Install dependencies - if: ${{ env.SIGN_MACOS == 'true' }} run: pnpm install --frozen-lockfile # stage_npm_packages.py requires DotSlash when staging releases. - - uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2 + - uses: facebook/install-dotslash@v2 - name: Stage npm packages - if: ${{ env.SIGN_MACOS == 'true' }} + if: github.repository_owner == 'openai' env: GH_TOKEN: ${{ github.token }} - RELEASE_VERSION: ${{ steps.release_name.outputs.name }} run: | - workflow_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" ./scripts/stage_npm_packages.py \ - --release-version "$RELEASE_VERSION" \ - --workflow-url "$workflow_url" \ + --release-version "${{ steps.release_name.outputs.name }}" \ --package codex \ --package codex-responses-api-proxy \ --package codex-sdk - - name: Stage installer scripts - if: ${{ env.SIGN_MACOS == 'true' }} - run: | - cp scripts/install/install.sh dist/install.sh - cp scripts/install/install.ps1 dist/install.ps1 - - name: Create GitHub Release - uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1 + uses: softprops/action-gh-release@v2 with: name: ${{ steps.release_name.outputs.name }} tag_name: ${{ github.ref_name }} body_path: ${{ steps.release_notes.outputs.path }} files: dist/** - overwrite_files: true - make_latest: ${{ env.SIGN_MACOS == 'true' && !contains(steps.release_name.outputs.name, '-') }} # Mark as prerelease only when the version has a suffix after x.y.z # (e.g. -alpha, -beta). Otherwise publish a normal release. prerelease: ${{ contains(steps.release_name.outputs.name, '-') }} - - name: Clean up signed promotion handoff assets - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - - release_id="$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${GITHUB_REF_NAME}" --jq '.id')" - gh api --paginate "repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets" \ - --jq '.[] | [.id, .name] | @tsv' | - while IFS=$'\t' read -r asset_id asset_name; do - if [[ -z "$asset_id" || -z "$asset_name" ]]; then - continue - fi - - delete_asset=false - if [[ "$asset_name" == *unsigned* || "$asset_name" == "$SIGNED_MACOS_ASSET" ]]; then - delete_asset=true - fi - - if [[ "$delete_asset" == "true" ]]; then - echo "Deleting release asset ${asset_name}" - gh api -X DELETE "repos/${GITHUB_REPOSITORY}/releases/assets/${asset_id}" - fi - done - - - if: ${{ env.SIGN_MACOS == 'true' }} - uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 + - if: github.repository_owner == 'openai' + uses: facebook/dotslash-publish-release@v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: tag: ${{ github.ref_name }} config: .github/dotslash-config.json - - if: ${{ env.SIGN_MACOS == 'true' }} - uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag: ${{ github.ref_name }} - config: .github/dotslash-zsh-config.json - - - if: ${{ env.SIGN_MACOS == 'true' }} - uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag: ${{ github.ref_name }} - config: .github/dotslash-argument-comment-lint-config.json - - name: Trigger developers.openai.com deploy # Only trigger the deploy if the release is not a pre-release. # The deploy is used to update the developers.openai.com website with the new config schema json file. - if: ${{ env.SIGN_MACOS == 'true' && !contains(steps.release_name.outputs.name, '-') }} + if: ${{ !contains(steps.release_name.outputs.name, '-') && github.repository_owner == 'openai' }} continue-on-error: true env: DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL: ${{ secrets.DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL }} @@ -1293,15 +1122,7 @@ jobs: # npm docs: https://docs.npmjs.com/trusted-publishers publish-npm: # Publish to npm for stable releases and alpha pre-releases with numeric suffixes. - # promote_signed intentionally skips build jobs that are ancestors of release; - # include the !cancelled() status function so Actions does not apply its implicit - # success() check to the whole dependency chain before evaluating release outputs. - if: >- - ${{ - !cancelled() && - needs.release.result == 'success' && - needs.release.outputs.should_publish_npm == 'true' - }} + if: ${{ needs.release.outputs.should_publish_npm == 'true' && github.repository_owner == 'openai' }} name: publish-npm needs: release runs-on: ubuntu-latest @@ -1311,37 +1132,36 @@ jobs: steps: - name: Setup Node.js - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + uses: actions/setup-node@v6 with: - # Node 24 bundles npm >= 11.5.1, which trusted publishing requires. - node-version: 24 + node-version: 22 registry-url: "https://registry.npmjs.org" scope: "@openai" + # Trusted publishing requires npm CLI version 11.5.1 or later. + - name: Update npm + run: npm install -g npm@latest + - name: Download npm tarballs from release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_TAG: ${{ needs.release.outputs.tag }} - RELEASE_VERSION: ${{ needs.release.outputs.version }} run: | set -euo pipefail - version="$RELEASE_VERSION" - tag="$RELEASE_TAG" + version="${{ needs.release.outputs.version }}" + tag="${{ needs.release.outputs.tag }}" mkdir -p dist/npm - patterns=( - "codex-npm-${version}.tgz" - "codex-npm-linux-*-${version}.tgz" - "codex-npm-darwin-*-${version}.tgz" - "codex-npm-win32-*-${version}.tgz" - "codex-responses-api-proxy-npm-${version}.tgz" - "codex-sdk-npm-${version}.tgz" - ) - for pattern in "${patterns[@]}"; do - gh release download "$tag" \ - --repo "${GITHUB_REPOSITORY}" \ - --pattern "$pattern" \ - --dir dist/npm - done + gh release download "$tag" \ + --repo "${GITHUB_REPOSITORY}" \ + --pattern "codex-npm-${version}.tgz" \ + --dir dist/npm + gh release download "$tag" \ + --repo "${GITHUB_REPOSITORY}" \ + --pattern "codex-responses-api-proxy-npm-${version}.tgz" \ + --dir dist/npm + gh release download "$tag" \ + --repo "${GITHUB_REPOSITORY}" \ + --pattern "codex-sdk-npm-${version}.tgz" \ + --dir dist/npm # No NODE_AUTH_TOKEN needed because we use OIDC. - name: Publish to npm @@ -1350,193 +1170,23 @@ jobs: NPM_TAG: ${{ needs.release.outputs.npm_tag }} run: | set -euo pipefail - prefix="" + tag_args=() if [[ -n "${NPM_TAG}" ]]; then - prefix="${NPM_TAG}-" + tag_args+=(--tag "${NPM_TAG}") fi - root_tarball="dist/npm/codex-npm-${VERSION}.tgz" - sdk_tarball="dist/npm/codex-sdk-npm-${VERSION}.tgz" - # Keep this list in sync with CODEX_PLATFORM_PACKAGES in - # codex-cli/scripts/build_npm_package.py. The root wrapper advances - # @openai/codex@latest as soon as it publishes, so every platform - # package it aliases must already exist in the registry first. - platform_tarballs=( - "dist/npm/codex-npm-linux-x64-${VERSION}.tgz" - "dist/npm/codex-npm-linux-arm64-${VERSION}.tgz" - "dist/npm/codex-npm-darwin-x64-${VERSION}.tgz" - "dist/npm/codex-npm-darwin-arm64-${VERSION}.tgz" - "dist/npm/codex-npm-win32-x64-${VERSION}.tgz" - "dist/npm/codex-npm-win32-arm64-${VERSION}.tgz" - ) - - for required_tarball in "${platform_tarballs[@]}" "${root_tarball}"; do - if [[ ! -f "${required_tarball}" ]]; then - echo "Missing npm tarball: ${required_tarball}" - exit 1 - fi - done - - shopt -s nullglob - other_tarballs=() - for tarball in dist/npm/*-"${VERSION}".tgz; do - if [[ "${tarball}" == "${root_tarball}" || "${tarball}" == "${sdk_tarball}" ]]; then - continue - fi - - is_platform_tarball=false - for platform_tarball in "${platform_tarballs[@]}"; do - if [[ "${tarball}" == "${platform_tarball}" ]]; then - is_platform_tarball=true - break - fi - done - if [[ "${is_platform_tarball}" == true ]]; then - continue - fi - - other_tarballs+=("${tarball}") - done - - # Publish the platform packages before the root CLI wrapper. The root - # wrapper advances @openai/codex@latest, so it should only publish - # after the optional dependency versions it references exist. tarballs=( - "${platform_tarballs[@]}" - "${other_tarballs[@]}" - "${root_tarball}" + "codex-npm-${VERSION}.tgz" + "codex-responses-api-proxy-npm-${VERSION}.tgz" + "codex-sdk-npm-${VERSION}.tgz" ) - if [[ -f "${sdk_tarball}" ]]; then - tarballs+=("${sdk_tarball}") - fi for tarball in "${tarballs[@]}"; do - filename="$(basename "${tarball}")" - tag="" - - case "${filename}" in - codex-npm-linux-*-"${VERSION}".tgz|codex-npm-darwin-*-"${VERSION}".tgz|codex-npm-win32-*-"${VERSION}".tgz) - platform="${filename#codex-npm-}" - platform="${platform%-${VERSION}.tgz}" - tag="${prefix}${platform}" - ;; - codex-npm-"${VERSION}".tgz|codex-responses-api-proxy-npm-"${VERSION}".tgz|codex-sdk-npm-"${VERSION}".tgz) - tag="${NPM_TAG}" - ;; - *) - echo "Unexpected npm tarball: ${filename}" - exit 1 - ;; - esac - - publish_cmd=(npm publish "${GITHUB_WORKSPACE}/${tarball}") - if [[ -n "${tag}" ]]; then - publish_cmd+=(--tag "${tag}") - fi - - echo "+ ${publish_cmd[*]}" - set +e - publish_output="$("${publish_cmd[@]}" 2>&1)" - publish_status=$? - set -e - - echo "${publish_output}" - if [[ ${publish_status} -eq 0 ]]; then - continue - fi - - if grep -qiE "previously published|cannot publish over|version already exists" <<< "${publish_output}"; then - echo "Skipping already-published package version for ${filename}" - continue - fi - - exit "${publish_status}" + npm publish "${GITHUB_WORKSPACE}/dist/npm/${tarball}" "${tag_args[@]}" done - # Publish the platform-specific Python runtime wheels using PyPI trusted publishing. - # PyPI project configuration must trust this workflow and job. Keep this - # non-blocking while the Python runtime publishing path is new; failures still - # need release follow-up, but should not invalidate the Rust release itself. - publish-python-runtime: - # Publish to PyPI for stable releases and alpha pre-releases with numeric suffixes. - if: >- - ${{ - !cancelled() && - needs.release.result == 'success' && - needs.release.outputs.should_publish_python_runtime == 'true' - }} - name: publish-python-runtime - needs: release - runs-on: ubuntu-latest - continue-on-error: true - environment: pypi - permissions: - id-token: write # Required for PyPI trusted publishing. - contents: read - - steps: - - name: Download Python runtime wheels from release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_TAG: ${{ needs.release.outputs.tag }} - RELEASE_VERSION: ${{ needs.release.outputs.version }} - run: | - set -euo pipefail - python_version="$RELEASE_VERSION" - python_version="${python_version/-alpha./a}" - python_version="${python_version/-beta./b}" - python_version="${python_version/-rc./rc}" - - mkdir -p dist/python-runtime - gh release download "$RELEASE_TAG" \ - --repo "${GITHUB_REPOSITORY}" \ - --pattern "openai_codex_cli_bin-${python_version}-*.whl" \ - --dir dist/python-runtime - ls -lh dist/python-runtime - - - name: Publish Python runtime wheels to PyPI - uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 - with: - packages-dir: dist/python-runtime - skip-existing: true - - winget: - name: winget - needs: release - # Only publish stable/mainline releases to WinGet; pre-releases include a - # '-' in the semver string (e.g., 1.2.3-alpha.1). - if: >- - ${{ - !cancelled() && - needs.release.result == 'success' && - needs.release.outputs.sign_macos == 'true' && - !contains(needs.release.outputs.version, '-') - }} - # This job only invokes a GitHub Action to open/update the winget-pkgs PR; - # it does not execute Windows-only tooling, so Linux is sufficient. - runs-on: ubuntu-latest - permissions: - contents: read - - steps: - - name: Publish to WinGet - uses: vedantmgoyal9/winget-releaser@7bd472be23763def6e16bd06cc8b1cdfab0e2fd5 - with: - identifier: OpenAI.Codex - version: ${{ needs.release.outputs.version }} - release-tag: ${{ needs.release.outputs.tag }} - fork-user: openai-oss-forks - installers-regex: '^codex-(?:x86_64|aarch64)-pc-windows-msvc\.exe\.zip$' - token: ${{ secrets.WINGET_PUBLISH_PAT }} - update-branch: name: Update latest-alpha-cli branch - if: >- - ${{ - !cancelled() && - needs.release.result == 'success' && - needs.release.outputs.sign_macos == 'true' - }} permissions: contents: write needs: release diff --git a/.github/workflows/shell-tool-mcp.yml b/.github/workflows/shell-tool-mcp.yml new file mode 100644 index 000000000000..66a76aa4d938 --- /dev/null +++ b/.github/workflows/shell-tool-mcp.yml @@ -0,0 +1,461 @@ +name: shell-tool-mcp + +on: + workflow_call: + inputs: + release-version: + description: Version to publish (x.y.z or x.y.z-alpha.N). Defaults to GITHUB_REF_NAME when it starts with rust-v. + required: false + type: string + release-tag: + description: Tag name to use when downloading release artifacts (defaults to rust-v). + required: false + type: string + publish: + description: Whether to publish to npm when the version is releasable. + required: false + default: true + type: boolean + +env: + NODE_VERSION: 22 + +jobs: + metadata: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.compute.outputs.version }} + release_tag: ${{ steps.compute.outputs.release_tag }} + should_publish: ${{ steps.compute.outputs.should_publish }} + npm_tag: ${{ steps.compute.outputs.npm_tag }} + steps: + - name: Compute version and tags + id: compute + run: | + set -euo pipefail + + version="${{ inputs.release-version }}" + release_tag="${{ inputs.release-tag }}" + + if [[ -z "$version" ]]; then + if [[ -n "$release_tag" && "$release_tag" =~ ^rust-v.+ ]]; then + version="${release_tag#rust-v}" + elif [[ "${GITHUB_REF_NAME:-}" =~ ^rust-v.+ ]]; then + version="${GITHUB_REF_NAME#rust-v}" + release_tag="${GITHUB_REF_NAME}" + else + echo "release-version is required when GITHUB_REF_NAME is not a rust-v tag." + exit 1 + fi + fi + + if [[ -z "$release_tag" ]]; then + release_tag="rust-v${version}" + fi + + npm_tag="" + should_publish="false" + if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + should_publish="true" + elif [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+-alpha\.[0-9]+$ ]]; then + should_publish="true" + npm_tag="alpha" + fi + + echo "version=${version}" >> "$GITHUB_OUTPUT" + echo "release_tag=${release_tag}" >> "$GITHUB_OUTPUT" + echo "npm_tag=${npm_tag}" >> "$GITHUB_OUTPUT" + echo "should_publish=${should_publish}" >> "$GITHUB_OUTPUT" + + matrix-setup: + runs-on: ubuntu-latest + outputs: + rust-binaries: ${{ steps.compute.outputs.rust-binaries }} + bash-linux: ${{ steps.compute.outputs.bash-linux }} + bash-darwin: ${{ steps.compute.outputs.bash-darwin }} + steps: + - name: Compute matrices + id: compute + shell: bash + run: | + set -euo pipefail + is_openai="${{ github.repository_owner == 'openai' }}" + + # rust-binaries: always include x86_64-musl; conditionally include paid/fork runners + if [[ "$is_openai" == "true" ]]; then + rust='[ + {"runner":"macos-15-xlarge","target":"aarch64-apple-darwin"}, + {"runner":"macos-15-xlarge","target":"x86_64-apple-darwin"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","install_musl":true} + ]' + else + rust='[{"runner":"macos-latest","target":"aarch64-apple-darwin"}]' + fi + rust=$(echo "$rust" | jq -c '. + [{"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","install_musl":true}]') + echo "rust-binaries={\"include\":$rust}" >> "$GITHUB_OUTPUT" + + # bash-linux: always include x86_64 variants; add arm64 on openai + bash_linux='[ + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","variant":"ubuntu-24.04","image":"ubuntu:24.04"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","variant":"ubuntu-22.04","image":"ubuntu:22.04"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","variant":"debian-12","image":"debian:12"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","variant":"debian-11","image":"debian:11"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","variant":"centos-9","image":"quay.io/centos/centos:stream9"} + ]' + if [[ "$is_openai" == "true" ]]; then + bash_linux=$(echo "$bash_linux" | jq -c '. + [ + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"ubuntu-24.04","image":"arm64v8/ubuntu:24.04"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"ubuntu-22.04","image":"arm64v8/ubuntu:22.04"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"ubuntu-20.04","image":"arm64v8/ubuntu:20.04"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"debian-12","image":"arm64v8/debian:12"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"debian-11","image":"arm64v8/debian:11"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"centos-9","image":"quay.io/centos/centos:stream9"} + ]') + fi + echo "bash-linux={\"include\":$(echo "$bash_linux" | jq -c)}" >> "$GITHUB_OUTPUT" + + # bash-darwin: always include macos-14; add macos-15-xlarge on openai + bash_darwin='[{"runner":"macos-14","target":"aarch64-apple-darwin","variant":"macos-14"}]' + if [[ "$is_openai" == "true" ]]; then + bash_darwin=$(echo "$bash_darwin" | jq -c '. + [ + {"runner":"macos-15-xlarge","target":"aarch64-apple-darwin","variant":"macos-15"} + ]') + fi + echo "bash-darwin={\"include\":$(echo "$bash_darwin" | jq -c)}" >> "$GITHUB_OUTPUT" + + rust-binaries: + name: Build Rust - ${{ matrix.target }} + needs: [metadata, matrix-setup] + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + defaults: + run: + working-directory: codex-rs + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.matrix-setup.outputs.rust-binaries) }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Install UBSan runtime (musl) + if: ${{ matrix.install_musl }} + shell: bash + run: | + set -euo pipefail + if command -v apt-get >/dev/null 2>&1; then + sudo apt-get update -y + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y libubsan1 + fi + + - uses: dtolnay/rust-toolchain@1.93 + with: + targets: ${{ matrix.target }} + + - if: ${{ matrix.install_musl }} + name: Install Zig + uses: mlugg/setup-zig@v2 + with: + version: 0.14.0 + + - if: ${{ matrix.install_musl }} + name: Install musl build dependencies + env: + TARGET: ${{ matrix.target }} + run: bash "${GITHUB_WORKSPACE}/.github/scripts/install-musl-build-tools.sh" + + - if: ${{ matrix.install_musl }} + name: Configure rustc UBSan wrapper (musl host) + shell: bash + run: | + set -euo pipefail + ubsan="" + if command -v ldconfig >/dev/null 2>&1; then + ubsan="$(ldconfig -p | grep -m1 'libubsan\.so\.1' | sed -E 's/.*=> (.*)$/\1/')" + fi + wrapper_root="${RUNNER_TEMP:-/tmp}" + wrapper="${wrapper_root}/rustc-ubsan-wrapper" + cat > "${wrapper}" <> "$GITHUB_ENV" + echo "RUSTC_WORKSPACE_WRAPPER=" >> "$GITHUB_ENV" + + - if: ${{ matrix.install_musl }} + name: Clear sanitizer flags (musl) + shell: bash + run: | + set -euo pipefail + # Clear global Rust flags so host/proc-macro builds don't pull in UBSan. + echo "RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_ENCODED_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "RUSTDOCFLAGS=" >> "$GITHUB_ENV" + # Override any runner-level Cargo config rustflags as well. + echo "CARGO_BUILD_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_RUSTFLAGS=" >> "$GITHUB_ENV" + + sanitize_flags() { + local input="$1" + input="${input//-fsanitize=undefined/}" + input="${input//-fno-sanitize-recover=undefined/}" + input="${input//-fno-sanitize-trap=undefined/}" + echo "$input" + } + + cflags="$(sanitize_flags "${CFLAGS-}")" + cxxflags="$(sanitize_flags "${CXXFLAGS-}")" + echo "CFLAGS=${cflags}" >> "$GITHUB_ENV" + echo "CXXFLAGS=${cxxflags}" >> "$GITHUB_ENV" + + - name: Build exec server binaries + run: cargo build --release --target ${{ matrix.target }} --bin codex-exec-mcp-server --bin codex-execve-wrapper + + - name: Stage exec server binaries + run: | + dest="${GITHUB_WORKSPACE}/artifacts/vendor/${{ matrix.target }}" + mkdir -p "$dest" + cp "target/${{ matrix.target }}/release/codex-exec-mcp-server" "$dest/" + cp "target/${{ matrix.target }}/release/codex-execve-wrapper" "$dest/" + + - uses: actions/upload-artifact@v6 + with: + name: shell-tool-mcp-rust-${{ matrix.target }} + path: artifacts/** + if-no-files-found: error + + bash-linux: + name: Build Bash (Linux) - ${{ matrix.variant }} - ${{ matrix.target }} + needs: [metadata, matrix-setup] + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + container: + image: ${{ matrix.image }} + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.matrix-setup.outputs.bash-linux) }} + steps: + - name: Install build prerequisites + shell: bash + run: | + set -euo pipefail + if command -v apt-get >/dev/null 2>&1; then + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y git build-essential bison autoconf gettext + elif command -v dnf >/dev/null 2>&1; then + dnf install -y git gcc gcc-c++ make bison autoconf gettext + elif command -v yum >/dev/null 2>&1; then + yum install -y git gcc gcc-c++ make bison autoconf gettext + else + echo "Unsupported package manager in container" + exit 1 + fi + + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Build patched Bash + shell: bash + run: | + set -euo pipefail + git clone --depth 1 https://github.com/bolinfest/bash /tmp/bash + cd /tmp/bash + git fetch --depth 1 origin a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b + git checkout a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b + git apply "${GITHUB_WORKSPACE}/shell-tool-mcp/patches/bash-exec-wrapper.patch" + ./configure --without-bash-malloc + cores="$(command -v nproc >/dev/null 2>&1 && nproc || getconf _NPROCESSORS_ONLN)" + make -j"${cores}" + + dest="${GITHUB_WORKSPACE}/artifacts/vendor/${{ matrix.target }}/bash/${{ matrix.variant }}" + mkdir -p "$dest" + cp bash "$dest/bash" + + - uses: actions/upload-artifact@v6 + with: + name: shell-tool-mcp-bash-${{ matrix.target }}-${{ matrix.variant }} + path: artifacts/** + if-no-files-found: error + + bash-darwin: + name: Build Bash (macOS) - ${{ matrix.variant }} - ${{ matrix.target }} + needs: [metadata, matrix-setup] + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.matrix-setup.outputs.bash-darwin) }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Build patched Bash + shell: bash + run: | + set -euo pipefail + git clone --depth 1 https://github.com/bolinfest/bash /tmp/bash + cd /tmp/bash + git fetch --depth 1 origin a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b + git checkout a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b + git apply "${GITHUB_WORKSPACE}/shell-tool-mcp/patches/bash-exec-wrapper.patch" + ./configure --without-bash-malloc + cores="$(getconf _NPROCESSORS_ONLN)" + make -j"${cores}" + + dest="${GITHUB_WORKSPACE}/artifacts/vendor/${{ matrix.target }}/bash/${{ matrix.variant }}" + mkdir -p "$dest" + cp bash "$dest/bash" + + - uses: actions/upload-artifact@v6 + with: + name: shell-tool-mcp-bash-${{ matrix.target }}-${{ matrix.variant }} + path: artifacts/** + if-no-files-found: error + + package: + name: Package npm module + needs: + - metadata + - rust-binaries + - bash-linux + - bash-darwin + runs-on: ubuntu-latest + env: + PACKAGE_VERSION: ${{ needs.metadata.outputs.version }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Install JavaScript dependencies + run: pnpm install --frozen-lockfile + + - name: Build (shell-tool-mcp) + run: pnpm --filter @openai/codex-shell-tool-mcp run build + + - name: Download build artifacts + uses: actions/download-artifact@v7 + with: + path: artifacts + + - name: Assemble staging directory + id: staging + shell: bash + run: | + set -euo pipefail + staging="${STAGING_DIR}" + mkdir -p "$staging" "$staging/vendor" + cp shell-tool-mcp/README.md "$staging/" + cp shell-tool-mcp/package.json "$staging/" + cp -R shell-tool-mcp/bin "$staging/" + + found_vendor="false" + shopt -s nullglob + for vendor_dir in artifacts/*/vendor; do + rsync -av "$vendor_dir/" "$staging/vendor/" + found_vendor="true" + done + if [[ "$found_vendor" == "false" ]]; then + echo "No vendor payloads were downloaded." + exit 1 + fi + + node - <<'NODE' + import fs from "node:fs"; + import path from "node:path"; + + const stagingDir = process.env.STAGING_DIR; + const version = process.env.PACKAGE_VERSION; + const pkgPath = path.join(stagingDir, "package.json"); + const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")); + pkg.version = version; + fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n"); + NODE + + echo "dir=$staging" >> "$GITHUB_OUTPUT" + env: + STAGING_DIR: ${{ runner.temp }}/shell-tool-mcp + + - name: Ensure binaries are executable + run: | + set -euo pipefail + staging="${{ steps.staging.outputs.dir }}" + chmod +x \ + "$staging"/vendor/*/codex-exec-mcp-server \ + "$staging"/vendor/*/codex-execve-wrapper \ + "$staging"/vendor/*/bash/*/bash + + - name: Create npm tarball + shell: bash + run: | + set -euo pipefail + mkdir -p dist/npm + staging="${{ steps.staging.outputs.dir }}" + pack_info=$(cd "$staging" && npm pack --ignore-scripts --json --pack-destination "${GITHUB_WORKSPACE}/dist/npm") + filename=$(PACK_INFO="$pack_info" node -e 'const data = JSON.parse(process.env.PACK_INFO); console.log(data[0].filename);') + mv "dist/npm/${filename}" "dist/npm/codex-shell-tool-mcp-npm-${PACKAGE_VERSION}.tgz" + + - uses: actions/upload-artifact@v6 + with: + name: codex-shell-tool-mcp-npm + path: dist/npm/codex-shell-tool-mcp-npm-${{ env.PACKAGE_VERSION }}.tgz + if-no-files-found: error + + publish: + name: Publish npm package + needs: + - metadata + - package + if: ${{ inputs.publish && needs.metadata.outputs.should_publish == 'true' }} + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + steps: + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + registry-url: https://registry.npmjs.org + scope: "@openai" + + # Trusted publishing requires npm CLI version 11.5.1 or later. + - name: Update npm + run: npm install -g npm@latest + + - name: Download npm tarball + uses: actions/download-artifact@v7 + with: + name: codex-shell-tool-mcp-npm + path: dist/npm + + - name: Publish to npm + if: github.repository == 'openai/codex' + env: + NPM_TAG: ${{ needs.metadata.outputs.npm_tag }} + VERSION: ${{ needs.metadata.outputs.version }} + shell: bash + run: | + set -euo pipefail + tag_args=() + if [[ -n "${NPM_TAG}" ]]; then + tag_args+=(--tag "${NPM_TAG}") + fi + npm publish "dist/npm/codex-shell-tool-mcp-npm-${VERSION}.tgz" "${tag_args[@]}" diff --git a/.github/workflows/termux-release-checkpoint.yml b/.github/workflows/termux-release-checkpoint.yml new file mode 100644 index 000000000000..0347e6f1b3d6 --- /dev/null +++ b/.github/workflows/termux-release-checkpoint.yml @@ -0,0 +1,103 @@ +name: termux-release-checkpoint + +on: + workflow_dispatch: + inputs: + source_branch: + description: "Release branch to checkpoint from, for example release/0.123.0" + required: false + type: string + default: "" + source_sha: + description: "Specific source commit SHA to checkpoint; defaults to the source branch tip" + required: false + type: string + default: "" + destination_branch: + description: "Destination patch branch to receive the checkpoint PR" + required: false + type: string + default: "wallentx/termux-target" + reviewer: + description: "GitHub username to request as reviewer" + required: false + type: string + default: "wallentx" + +permissions: + actions: read + attestations: read + checks: read + contents: read + deployments: read + issues: read + discussions: read + packages: read + pages: read + pull-requests: read + repository-projects: read + statuses: read + +concurrency: + group: termux-release-checkpoint-${{ inputs.source_branch || github.ref_name }} + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + checkpoint: + runs-on: ubuntu-slim + permissions: + contents: write + issues: write + pull-requests: write + env: + DESTINATION_BRANCH: ${{ inputs.destination_branch || 'wallentx/termux-target' }} + REVIEWER: ${{ inputs.reviewer || 'wallentx' }} + REQUESTED_SOURCE_BRANCH: ${{ inputs.source_branch }} + REQUESTED_SOURCE_SHA: ${{ inputs.source_sha }} + TERMUX_AUTOMATION_DIR: ${{ github.workspace }}/.termux-release-automation + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Export GitHub App token for gh + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + run: echo "GH_TOKEN=${APP_TOKEN}" >> "${GITHUB_ENV}" + + - name: Checkout release branch + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ inputs.source_branch || github.ref_name }} + token: ${{ steps.app-token.outputs.token }} + + - name: Checkout automation helpers + uses: actions/checkout@v6 + with: + fetch-depth: 1 + ref: ${{ github.workflow_sha }} + path: .termux-release-automation + token: ${{ steps.app-token.outputs.token }} + + - name: 🧰 Actions Toolbox + uses: wallentx/gh-actions/composite/actions-toolbox@main + + - name: Validate GitHub CLI environment + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-validate-gh-env.sh" + + - name: Configure git + run: | + set -euo pipefail + source_branch="${REQUESTED_SOURCE_BRANCH:-${GITHUB_REF_NAME}}" + bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-configure-git.sh" --origin "${DESTINATION_BRANCH}" "${source_branch}" + + - name: Create checkpoint PR + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-create-checkpoint-pr.sh" diff --git a/.github/workflows/termux-release-deploy.yml b/.github/workflows/termux-release-deploy.yml new file mode 100644 index 000000000000..2071611dacc4 --- /dev/null +++ b/.github/workflows/termux-release-deploy.yml @@ -0,0 +1,243 @@ +name: termux-release-deploy + +on: + push: + branches: + - "release/**" + workflow_dispatch: + inputs: + release_branch: + description: "Release branch to deploy, for example release/0.124.0" + required: true + type: string + release_sha: + description: "Release branch commit SHA to deploy. Defaults to the branch head." + required: false + default: "" + type: string + pr_number: + description: "Merged release PR number to promote. Optional; normally discovered automatically." + required: false + default: "" + type: string + pr_head_sha: + description: "Merged release PR head SHA. Required only when pr_number is set." + required: false + default: "" + type: string + destination_branch: + description: "Destination patch branch to receive the checkpoint PR" + required: false + default: "wallentx/termux-target" + type: string + reviewer: + description: "GitHub username to request as reviewer on the checkpoint PR" + required: false + default: "wallentx" + type: string + +permissions: + actions: read + attestations: read + checks: read + contents: read + deployments: read + issues: read + discussions: read + packages: read + pages: read + pull-requests: read + repository-projects: read + statuses: read + +concurrency: + group: termux-release-deploy-${{ github.event_name == 'workflow_dispatch' && inputs.release_branch || github.ref_name }} + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + deploy: + runs-on: ubuntu-24.04 + if: ${{ github.event_name == 'workflow_dispatch' || !startsWith(github.event.head_commit.message, 'Seed Termux release automation') }} + permissions: + actions: read + contents: write + deployments: write + issues: write + pull-requests: write + env: + REQUESTED_RELEASE_BRANCH: ${{ inputs.release_branch }} + REQUESTED_RELEASE_SHA: ${{ inputs.release_sha }} + INPUT_PR_NUMBER: ${{ inputs.pr_number }} + INPUT_PR_HEAD_SHA: ${{ inputs.pr_head_sha }} + DESTINATION_BRANCH: ${{ inputs.destination_branch || 'wallentx/termux-target' }} + REVIEWER: ${{ inputs.reviewer || 'wallentx' }} + TERMUX_AUTOMATION_DIR: ${{ github.workspace }}/.termux-release-automation + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Export GitHub App token for gh + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + run: echo "GH_TOKEN=${APP_TOKEN}" >> "${GITHUB_ENV}" + + - name: Checkout release branch + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.release_branch || github.ref }} + token: ${{ steps.app-token.outputs.token }} + + - name: Checkout automation helpers + uses: actions/checkout@v6 + with: + fetch-depth: 1 + ref: ${{ github.workflow_sha }} + path: .termux-release-automation + token: ${{ steps.app-token.outputs.token }} + + - name: 🧰 Actions Toolbox + uses: wallentx/gh-actions/composite/actions-toolbox@main + + - name: Validate GitHub CLI environment + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-validate-gh-env.sh" + + - name: Configure git + run: | + set -euo pipefail + release_branch="${REQUESTED_RELEASE_BRANCH:-${GITHUB_REF_NAME}}" + bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-configure-git.sh" --origin "${DESTINATION_BRANCH}" "${release_branch}" + + - name: Resolve release ref + id: release-ref + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-resolve-release-ref.sh" + + - name: Read release metadata + id: metadata + env: + TERMUX_RELEASE_ACTION: deploy + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-read-release-metadata.sh" + + - name: Create deployment + if: steps.metadata.outputs.deploy == 'true' + id: deployment + env: + GH_TOKEN: ${{ github.token }} + RELEASE_SHA: ${{ steps.release-ref.outputs.sha }} + TERMUX_TAG: ${{ steps.metadata.outputs.termux_tag }} + run: | + set -euo pipefail + deployment_id="$( + gh api \ + -X POST \ + "repos/${GITHUB_REPOSITORY}/deployments" \ + -f ref="${RELEASE_SHA}" \ + -f environment="termux-release" \ + -F auto_merge=false \ + -F required_contexts[] \ + -f description="Termux release deployment for ${TERMUX_TAG}" \ + --jq '.id' + )" + echo "id=${deployment_id}" >> "$GITHUB_OUTPUT" + + - name: Mark deployment in progress + if: steps.metadata.outputs.deploy == 'true' + env: + GH_TOKEN: ${{ github.token }} + DEPLOYMENT_ID: ${{ steps.deployment.outputs.id }} + run: | + set -euo pipefail + log_url="${GH_WORKFLOW_URL:-${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}}" + gh api \ + -X POST \ + "repos/${GITHUB_REPOSITORY}/deployments/${DEPLOYMENT_ID}/statuses" \ + -f state="in_progress" \ + -f environment="termux-release" \ + -f log_url="${log_url}" \ + -F auto_inactive=false \ + -f description="Promoting Termux release artifact and preparing checkpoint PR" \ + >/dev/null + + - name: Locate merged pull request + if: steps.metadata.outputs.deploy == 'true' && steps.metadata.outputs.asset_exists != 'true' + id: pr + env: + RELEASE_BRANCH: ${{ steps.release-ref.outputs.branch }} + RELEASE_SHA: ${{ steps.release-ref.outputs.sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-find-release-pr.sh" + + - name: Download promoted PR artifact + if: steps.metadata.outputs.deploy == 'true' && steps.metadata.outputs.asset_exists != 'true' + env: + PR_ARTIFACT_NAME: ${{ steps.pr.outputs.artifact_name }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-download-release-artifact.sh" + + - name: Create or update mirrored Termux release + if: steps.metadata.outputs.deploy == 'true' && steps.metadata.outputs.asset_exists != 'true' + env: + UPSTREAM_TAG: ${{ steps.metadata.outputs.upstream_tag }} + UPSTREAM_REPO: ${{ steps.metadata.outputs.upstream_repo }} + UPSTREAM_NAME: ${{ steps.metadata.outputs.upstream_name }} + TERMUX_TAG: ${{ steps.metadata.outputs.termux_tag }} + UPSTREAM_PRERELEASE: ${{ steps.metadata.outputs.upstream_prerelease }} + UPSTREAM_HTML_URL: ${{ steps.metadata.outputs.upstream_html_url }} + RELEASE_TRAIN: ${{ steps.metadata.outputs.release_train }} + RELEASE_EXISTS: ${{ steps.metadata.outputs.release_exists }} + PR_NUMBER: ${{ steps.pr.outputs.number }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + RELEASE_SHA: ${{ steps.release-ref.outputs.sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-create-or-update-mirrored-release.sh" + + - name: Ensure checkpoint PR + if: steps.metadata.outputs.deploy == 'true' + id: checkpoint + env: + SOURCE_BRANCH: ${{ steps.release-ref.outputs.branch }} + SOURCE_SHA: ${{ steps.release-ref.outputs.sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-create-checkpoint-pr.sh" + + - name: Mark deployment success + if: steps.metadata.outputs.deploy == 'true' + env: + GH_TOKEN: ${{ github.token }} + DEPLOYMENT_ID: ${{ steps.deployment.outputs.id }} + TERMUX_TAG: ${{ steps.metadata.outputs.termux_tag }} + run: | + set -euo pipefail + log_url="${GH_WORKFLOW_URL:-${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}}" + gh api \ + -X POST \ + "repos/${GITHUB_REPOSITORY}/deployments/${DEPLOYMENT_ID}/statuses" \ + -f state="success" \ + -f environment="termux-release" \ + -f log_url="${log_url}" \ + -F auto_inactive=false \ + -f description="Termux release deployment completed for ${TERMUX_TAG}" \ + >/dev/null + + - name: Mark deployment failure + if: failure() && steps.deployment.outputs.id != '' + env: + GH_TOKEN: ${{ github.token }} + DEPLOYMENT_ID: ${{ steps.deployment.outputs.id }} + run: | + set -euo pipefail + log_url="${GH_WORKFLOW_URL:-${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}}" + gh api \ + -X POST \ + "repos/${GITHUB_REPOSITORY}/deployments/${DEPLOYMENT_ID}/statuses" \ + -f state="failure" \ + -f environment="termux-release" \ + -f log_url="${log_url}" \ + -F auto_inactive=false \ + -f description="Termux release deployment failed" \ + >/dev/null diff --git a/.github/workflows/termux-release-promote.yml b/.github/workflows/termux-release-promote.yml new file mode 100644 index 000000000000..22c277c21a70 --- /dev/null +++ b/.github/workflows/termux-release-promote.yml @@ -0,0 +1,135 @@ +name: termux-release-promote + +on: + workflow_dispatch: + inputs: + release_branch: + description: "Release branch to promote, for example release/0.122.0" + required: true + type: string + release_sha: + description: "Release branch commit SHA to promote. Defaults to the branch head." + required: false + default: "" + type: string + pr_number: + description: "Merged PR number to promote. Optional; normally discovered automatically." + required: false + default: "" + type: string + pr_head_sha: + description: "Merged PR head SHA. Required only when pr_number is set." + required: false + default: "" + type: string + +permissions: + actions: read + attestations: read + checks: read + contents: read + deployments: read + issues: read + discussions: read + packages: read + pages: read + pull-requests: read + repository-projects: read + statuses: read + +concurrency: + group: termux-release-promote-${{ github.event_name == 'workflow_dispatch' && inputs.release_branch || github.ref_name }} + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + promote: + runs-on: ubuntu-24.04 + permissions: + actions: read + contents: write + pull-requests: read + env: + TERMUX_AUTOMATION_DIR: ${{ github.workspace }}/.termux-release-automation + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Export GitHub App token for gh + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + run: echo "GH_TOKEN=${APP_TOKEN}" >> "${GITHUB_ENV}" + + - name: Checkout release branch + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.release_branch || github.ref }} + token: ${{ steps.app-token.outputs.token }} + + - name: Checkout automation helpers + uses: actions/checkout@v6 + with: + fetch-depth: 1 + ref: ${{ github.workflow_sha }} + path: .termux-release-automation + token: ${{ steps.app-token.outputs.token }} + + - name: 🧰 Actions Toolbox + uses: wallentx/gh-actions/composite/actions-toolbox@main + + - name: Validate GitHub CLI environment + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-validate-gh-env.sh" + + - name: Resolve release ref + id: release-ref + env: + INPUT_RELEASE_BRANCH: ${{ inputs.release_branch }} + INPUT_RELEASE_SHA: ${{ inputs.release_sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-resolve-release-ref.sh" + + - name: Read release metadata + id: metadata + env: + TERMUX_RELEASE_ACTION: promote + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-read-release-metadata.sh" + + - name: Locate merged pull request + if: steps.metadata.outputs.promote == 'true' + id: pr + env: + RELEASE_BRANCH: ${{ steps.release-ref.outputs.branch }} + RELEASE_SHA: ${{ steps.release-ref.outputs.sha }} + INPUT_PR_NUMBER: ${{ inputs.pr_number }} + INPUT_PR_HEAD_SHA: ${{ inputs.pr_head_sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-find-release-pr.sh" + + - name: Download promoted PR artifact + if: steps.metadata.outputs.promote == 'true' + env: + PR_ARTIFACT_NAME: ${{ steps.pr.outputs.artifact_name }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-download-release-artifact.sh" + + - name: Create or update mirrored Termux release + if: steps.metadata.outputs.promote == 'true' + env: + UPSTREAM_TAG: ${{ steps.metadata.outputs.upstream_tag }} + UPSTREAM_REPO: ${{ steps.metadata.outputs.upstream_repo }} + UPSTREAM_NAME: ${{ steps.metadata.outputs.upstream_name }} + TERMUX_TAG: ${{ steps.metadata.outputs.termux_tag }} + UPSTREAM_PRERELEASE: ${{ steps.metadata.outputs.upstream_prerelease }} + UPSTREAM_HTML_URL: ${{ steps.metadata.outputs.upstream_html_url }} + RELEASE_TRAIN: ${{ steps.metadata.outputs.release_train }} + RELEASE_EXISTS: ${{ steps.metadata.outputs.release_exists }} + PR_NUMBER: ${{ steps.pr.outputs.number }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + RELEASE_SHA: ${{ steps.release-ref.outputs.sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-create-or-update-mirrored-release.sh" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 739f710f6111..bb02db818059 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -118,7 +118,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.133.0" +version = "0.134.0-alpha.2" # Track the edition for all workspace crates in one place. Individual # crates can still override this value, but keeping it here means new # crates created with `cargo new -w ...` automatically inherit the 2024 diff --git a/scripts/termux-configure-git.sh b/scripts/termux-configure-git.sh new file mode 100755 index 000000000000..fae3abcbab48 --- /dev/null +++ b/scripts/termux-configure-git.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash + +# Configure the explicit bot identity used by release automation and fetch any +# refs requested by the calling workflow. + +set -euo pipefail + +git config user.name "github-actions[bot]" +git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + +while (($#)); do + case "$1" in + --origin) + shift + origin_refs=() + while (($#)) && [[ "$1" != --* ]]; do + origin_refs+=("$1") + shift + done + if ((${#origin_refs[@]})); then + git fetch --prune origin "${origin_refs[@]}" + fi + ;; + --upstream-tag) + if (($# < 3)); then + echo "--upstream-tag requires ." >&2 + exit 1 + fi + upstream_repo="$2" + upstream_tag="$3" + git remote add upstream "https://github.com/${upstream_repo}.git" 2>/dev/null || true + git fetch --prune --no-tags upstream "+refs/tags/${upstream_tag}:refs/tags/${upstream_tag}" + shift 3 + ;; + *) + echo "Unknown argument: $1" >&2 + exit 1 + ;; + esac +done diff --git a/scripts/termux-create-checkpoint-pr.sh b/scripts/termux-create-checkpoint-pr.sh new file mode 100755 index 000000000000..f315a5b91822 --- /dev/null +++ b/scripts/termux-create-checkpoint-pr.sh @@ -0,0 +1,275 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/termux-release-paths.sh +source "${script_dir}/termux-release-paths.sh" + +source_branch="${SOURCE_BRANCH:-${REQUESTED_SOURCE_BRANCH:-${GITHUB_REF_NAME}}}" +source_sha="${SOURCE_SHA:-${REQUESTED_SOURCE_SHA:-}}" +if [[ -z "${source_sha}" ]]; then + if [[ "${GITHUB_EVENT_NAME:-}" == "push" && "${source_branch}" == "${GITHUB_REF_NAME:-}" ]]; then + source_sha="${GITHUB_SHA}" + else + source_sha="$(git rev-parse "origin/${source_branch}")" + fi +fi + +if [[ -z "${DESTINATION_BRANCH:-}" ]]; then + echo "DESTINATION_BRANCH is required." >&2 + exit 1 +fi + +release_only_checkpoint_paths() { + printf '%s\n' "${TERMUX_RELEASE_BRANCH_SCRIPT_PATHS[@]}" +} + +resolve_source_version_conflicts() { + local path="$1" + local resolved_path + resolved_path="$(mktemp)" + + if ! awk ' + function normalize_versions(text) { + gsub(/version = "[^"]+"/, "version = \"\"", text) + return text + } + + BEGIN { + in_block = 0 + side = "" + ours = "" + theirs = "" + blocks = 0 + } + + /^<<<<<<< / { + if (in_block) { + exit 1 + } + in_block = 1 + side = "ours" + ours = "" + theirs = "" + blocks++ + next + } + + /^=======$/ && in_block { + side = "theirs" + next + } + + /^>>>>>>> / && in_block { + if (normalize_versions(ours) != normalize_versions(theirs)) { + exit 1 + } + printf "%s", theirs + in_block = 0 + side = "" + next + } + + { + if (!in_block) { + print + } else if (side == "ours") { + ours = ours $0 ORS + } else if (side == "theirs") { + theirs = theirs $0 ORS + } else { + exit 1 + } + } + + END { + if (in_block || blocks == 0) { + exit 1 + } + } + ' "${path}" > "${resolved_path}"; then + rm -f "${resolved_path}" + return 1 + fi + + cp "${resolved_path}" "${path}" + rm -f "${resolved_path}" +} + +short_sha="${source_sha:0:12}" +source_slug="${source_branch//\//_}" +dest_slug="${DESTINATION_BRANCH//\//_}" +checkpoint_branch="checkpoint/${dest_slug}_from_${source_slug}_${short_sha}" +pr_title="checkpoint: into ${DESTINATION_BRANCH} from ${source_branch} @ ${short_sha}" +merge_conflicted=false +conflict_summary="" + +existing_pr="$( + gh pr list \ + --repo "${GITHUB_REPOSITORY}" \ + --head "${checkpoint_branch}" \ + --state all \ + --json number,state,mergedAt,url \ + --jq '[.[] | select(.state == "OPEN" or .mergedAt != null)] | .[0] // empty' +)" +if [[ -n "${existing_pr}" ]]; then + existing_url="$(jq -r '.url' <<< "${existing_pr}")" + existing_state="$(jq -r '.state' <<< "${existing_pr}")" + echo "Checkpoint PR already exists for ${checkpoint_branch}: ${existing_url} (${existing_state})." + if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + echo "pr_url=${existing_url}" >> "${GITHUB_OUTPUT}" + fi + exit 0 +fi + +git checkout -B "${checkpoint_branch}" "origin/${DESTINATION_BRANCH}" + +if ! git merge --no-ff --no-edit "${source_sha}"; then + mapfile -t conflicted_paths < <(git diff --name-only --diff-filter=U) + for conflicted_path in "${conflicted_paths[@]}"; do + if termux_is_checkpoint_release_only_path "${conflicted_path}"; then + echo "Auto-resolving release-only checkpoint conflict in ${conflicted_path} by keeping ${DESTINATION_BRANCH}." + if git cat-file -e "HEAD:${conflicted_path}" 2>/dev/null; then + git checkout --ours -- "${conflicted_path}" + git add "${conflicted_path}" + else + git rm -f --ignore-unmatch "${conflicted_path}" + fi + fi + done + + mapfile -t remaining_conflicts < <(git diff --name-only --diff-filter=U) + if ((${#remaining_conflicts[@]})); then + cargo_version_conflicts=true + for remaining_conflict in "${remaining_conflicts[@]}"; do + case "${remaining_conflict}" in + codex-rs/Cargo.toml|codex-rs/Cargo.lock) + ;; + *) + cargo_version_conflicts=false + ;; + esac + done + + if [[ "${cargo_version_conflicts}" == "true" ]]; then + for remaining_conflict in "${remaining_conflicts[@]}"; do + if ! resolve_source_version_conflicts "${remaining_conflict}"; then + cargo_version_conflicts=false + break + fi + done + + if [[ "${cargo_version_conflicts}" == "true" ]]; then + echo "Auto-resolving recurring Cargo version checkpoint conflicts by keeping ${source_branch} versions." + git add -- "${remaining_conflicts[@]}" + fi + fi + fi + + mapfile -t remaining_conflicts < <(git diff --name-only --diff-filter=U) + if [[ "${#remaining_conflicts[@]}" -eq 0 ]]; then + git commit --no-edit + else + merge_conflicted=true + conflict_summary="$( + printf '%s\n' "${remaining_conflicts[@]}" | awk '{ print "- `" $0 "`" }' + )" + echo "Automatic checkpoint merge failed; creating a manual-resolution PR instead." >&2 + if git rev-parse -q --verify MERGE_HEAD >/dev/null; then + git merge --abort + fi + git checkout -B "${checkpoint_branch}" "${source_sha}" + fi +fi + +if git cat-file -e "origin/${DESTINATION_BRANCH}:.github" 2>/dev/null; then + git checkout "origin/${DESTINATION_BRANCH}" -- .github + mapfile -t added_github_paths < <( + git diff --name-only --diff-filter=A "origin/${DESTINATION_BRANCH}" -- .github + ) + if ((${#added_github_paths[@]})); then + git rm -f --ignore-unmatch -- "${added_github_paths[@]}" + fi +else + git rm -r --ignore-unmatch .github +fi + +while IFS= read -r release_only_path; do + if git cat-file -e "origin/${DESTINATION_BRANCH}:${release_only_path}" 2>/dev/null; then + git checkout "origin/${DESTINATION_BRANCH}" -- "${release_only_path}" + else + git rm -f --ignore-unmatch -- "${release_only_path}" + fi +done < <(release_only_checkpoint_paths) + +if ! git diff --quiet || ! git diff --cached --quiet; then + if [[ -e .github || -L .github ]] || git ls-files --error-unmatch -- .github >/dev/null 2>&1; then + git add -A .github + fi + while IFS= read -r release_only_path; do + if [[ -e "${release_only_path}" || -L "${release_only_path}" ]] || git ls-files --error-unmatch -- "${release_only_path}" >/dev/null 2>&1; then + git add -A -- "${release_only_path}" + fi + done < <(release_only_checkpoint_paths) + if [[ "${merge_conflicted}" == "true" ]]; then + git commit -m "checkpoint: prepare ${source_branch} for ${DESTINATION_BRANCH}" + else + git commit --amend --no-edit + fi +fi + +if git diff --quiet "origin/${DESTINATION_BRANCH}" HEAD; then + echo "Checkpoint merge produced no destination changes after release-only files were restored." + exit 0 +fi + +git push --force-with-lease origin "${checkpoint_branch}" + +remaining="$( + git log --first-parent --pretty=format:%H "${source_sha}..origin/${source_branch}" | wc -w +)" + +body_path="${RUNNER_TEMP}/termux-checkpoint-pr.md" +{ + echo "## Termux release checkpoint" + echo + echo "- Source branch: \`${source_branch}\`" + echo "- Source hash: \`${source_sha}\`" + echo "- Destination branch: \`${DESTINATION_BRANCH}\`" + echo "- Remaining first-parent commits on source: ${remaining}" + echo + echo "This PR carries release-train conflict fixes and follow-up changes back into the reusable Termux patch branch." + if [[ "${merge_conflicted}" == "true" ]]; then + echo + echo "## Merge conflicts" + echo + echo "GitHub Actions could not create the checkpoint merge commit automatically, so this PR was created from the source branch state for manual conflict resolution." + echo + echo "Conflicted paths from the failed merge attempt:" + if [[ -n "${conflict_summary}" ]]; then + printf '%s\n' "${conflict_summary}" + else + echo "- Conflict details unavailable" + fi + fi + echo + echo "Release-only workflow files and metadata under \`.github\` were restored to the destination branch versions before opening this PR." +} > "${body_path}" + +pr_url="$( + gh pr create \ + --repo "${GITHUB_REPOSITORY}" \ + --base "${DESTINATION_BRANCH}" \ + --head "${checkpoint_branch}" \ + --title "${pr_title}" \ + --body-file "${body_path}" +)" +gh pr edit "${pr_url}" --repo "${GITHUB_REPOSITORY}" --add-reviewer "${REVIEWER}" || true +gh label create checkpoint --repo "${GITHUB_REPOSITORY}" --color c5def5 --description "Checkpoint merge" --force +gh label create termux-release --repo "${GITHUB_REPOSITORY}" --color 0e8a16 --description "Termux release automation" --force +gh pr edit "${pr_url}" --repo "${GITHUB_REPOSITORY}" --add-label "checkpoint" --add-label "termux-release" + +if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + echo "pr_url=${pr_url}" >> "${GITHUB_OUTPUT}" +fi diff --git a/scripts/termux-create-or-update-mirrored-release.sh b/scripts/termux-create-or-update-mirrored-release.sh new file mode 100755 index 000000000000..5787e894582b --- /dev/null +++ b/scripts/termux-create-or-update-mirrored-release.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash + +# Create a mirrored Termux release or repair an existing release that is missing +# the Android tarball. + +set -euo pipefail + +: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" +: "${GH_TOKEN:?GH_TOKEN is required}" +: "${RUNNER_TEMP:?RUNNER_TEMP is required}" +: "${UPSTREAM_TAG:?UPSTREAM_TAG is required}" +: "${UPSTREAM_REPO:?UPSTREAM_REPO is required}" +: "${UPSTREAM_NAME?UPSTREAM_NAME is required}" +: "${TERMUX_TAG:?TERMUX_TAG is required}" +: "${UPSTREAM_PRERELEASE:?UPSTREAM_PRERELEASE is required}" +: "${UPSTREAM_HTML_URL?UPSTREAM_HTML_URL is required}" +: "${RELEASE_TRAIN?RELEASE_TRAIN is required}" +: "${RELEASE_EXISTS:?RELEASE_EXISTS is required}" +: "${PR_NUMBER:?PR_NUMBER is required}" +: "${HEAD_SHA:?HEAD_SHA is required}" +: "${RELEASE_SHA:?RELEASE_SHA is required}" + +promoted_dir="${PROMOTED_DIR:-promoted}" +asset_path="${promoted_dir}/codex-aarch64-linux-android.tar.gz" + +body_path="${RUNNER_TEMP}/release-body.md" +upstream_body_path="${RUNNER_TEMP}/upstream-release-body.md" +upstream_body_without_changelog_path="${RUNNER_TEMP}/upstream-release-body-without-changelog.md" +if gh release view "${UPSTREAM_TAG}" \ + --repo "${UPSTREAM_REPO}" \ + --json body \ + --jq '.body // ""' > "${upstream_body_path}"; then + awk ' + function heading_level(line, text) { + if (match(line, /^(#{1,6})[[:space:]]+(.+)$/, parts)) { + text = tolower(parts[2]) + sub(/[[:space:]]+#+[[:space:]]*$/, "", text) + gsub(/^[[:space:]]+|[[:space:]]+$/, "", text) + if (text == "changelog" || text == "change log") { + return length(parts[1]) + } + } + return 0 + } + + { + if (!skip) { + level = heading_level($0) + if (level > 0) { + skip = 1 + skip_level = level + next + } + print + next + } + + if (match($0, /^(#{1,6})[[:space:]]+/, parts) && length(parts[1]) <= skip_level) { + skip = 0 + print + } + } + ' "${upstream_body_path}" > "${upstream_body_without_changelog_path}" +else + echo "::warning title=Upstream release notes unavailable::Could not read ${UPSTREAM_REPO} release ${UPSTREAM_TAG}." + : > "${upstream_body_without_changelog_path}" +fi + +{ + echo "Termux Android build for ${UPSTREAM_TAG}." + echo + echo "- Upstream release: ${UPSTREAM_HTML_URL}" + echo "- Release train: \`${RELEASE_TRAIN}\`" + echo "- Promoted PR: #${PR_NUMBER}" + echo "- Promoted PR head SHA: \`${HEAD_SHA}\`" +} > "${body_path}" +if [[ -s "${upstream_body_without_changelog_path}" ]]; then + { + echo + echo "## Upstream release notes" + echo + cat "${upstream_body_without_changelog_path}" + } >> "${body_path}" +fi + +release_title="${UPSTREAM_NAME}" +if [[ -z "${release_title}" || "${release_title}" == "null" ]]; then + release_title="${TERMUX_TAG}" +fi + +if [[ "${RELEASE_EXISTS}" == "true" ]]; then + gh release upload \ + "${TERMUX_TAG}" \ + "${asset_path}#codex-termux" \ + --repo "${GITHUB_REPOSITORY}" \ + --clobber + exit 0 +fi + +release_args=( + gh release create "${TERMUX_TAG}" + --repo "${GITHUB_REPOSITORY}" + --target "${RELEASE_SHA}" + --title "${release_title}" + --notes-file "${body_path}" +) +if [[ "${UPSTREAM_PRERELEASE}" == "true" ]]; then + release_args+=(--prerelease) +fi +release_args+=("${asset_path}#codex-termux") +"${release_args[@]}" diff --git a/scripts/termux-download-release-artifact.sh b/scripts/termux-download-release-artifact.sh new file mode 100755 index 000000000000..2acdba9025a0 --- /dev/null +++ b/scripts/termux-download-release-artifact.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ -z "${HEAD_SHA:-}" ]]; then + echo "HEAD_SHA is required." >&2 + exit 1 +fi + +if [[ -z "${PR_ARTIFACT_NAME:-}" ]]; then + echo "PR_ARTIFACT_NAME is required." >&2 + exit 1 +fi + +promoted_dir="${PROMOTED_DIR:-promoted}" + +find_run_id() { + local event="$1" + gh run list \ + --repo "${GITHUB_REPOSITORY}" \ + --workflow rust-release.yml \ + --event "${event}" \ + --status success \ + --commit "${HEAD_SHA}" \ + --limit 1 \ + --json databaseId \ + --jq '.[0].databaseId // empty' +} + +find_artifact_run_id() { + local artifact_name="$1" + + gh api --paginate "repos/${GITHUB_REPOSITORY}/actions/artifacts?name=${artifact_name}" \ + | jq -rs \ + --arg artifact_name "${artifact_name}" \ + --arg head_sha "${HEAD_SHA}" \ + ' + [ + .[].artifacts[] + | select(.name == $artifact_name) + | select(.expired == false) + | select(.workflow_run.head_sha == $head_sha) + ] + | sort_by(.created_at) + | reverse + | .[0].workflow_run.id // empty + ' +} + +artifact_name="${PR_ARTIFACT_NAME}" +run_id="$(find_artifact_run_id "${artifact_name}")" +mkdir -p "${promoted_dir}" +if [[ -n "${run_id}" ]] && gh run download "${run_id}" \ + --repo "${GITHUB_REPOSITORY}" \ + --name "${artifact_name}" \ + --dir "${promoted_dir}"; then + : +else + artifact_name="aarch64-linux-android" + run_id="$(find_run_id workflow_dispatch)" + if [[ -z "${run_id}" ]]; then + echo "No successful rust-release run found for ${HEAD_SHA}" >&2 + exit 1 + fi + gh run download "${run_id}" \ + --repo "${GITHUB_REPOSITORY}" \ + --name "${artifact_name}" \ + --dir "${promoted_dir}" +fi + +ls -la "${promoted_dir}" +if [[ -f "${promoted_dir}/SHA256SUMS" ]]; then + (cd "${promoted_dir}" && sha256sum -c SHA256SUMS) +fi +if [[ ! -f "${promoted_dir}/codex-aarch64-linux-android.tar.gz" ]]; then + echo "Expected ${promoted_dir}/codex-aarch64-linux-android.tar.gz in the downloaded artifact." >&2 + exit 1 +fi diff --git a/scripts/termux-find-release-pr.sh b/scripts/termux-find-release-pr.sh new file mode 100755 index 000000000000..3e35fa42eabe --- /dev/null +++ b/scripts/termux-find-release-pr.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ -n "${INPUT_PR_NUMBER:-}" || -n "${INPUT_PR_HEAD_SHA:-}" ]]; then + if [[ -z "${INPUT_PR_NUMBER:-}" || -z "${INPUT_PR_HEAD_SHA:-}" ]]; then + echo "workflow_dispatch inputs pr_number and pr_head_sha must be provided together." >&2 + exit 1 + fi + + pr_number="${INPUT_PR_NUMBER}" + head_sha="${INPUT_PR_HEAD_SHA}" + head_ref="" +else + if [[ -z "${RELEASE_BRANCH:-}" || -z "${RELEASE_SHA:-}" ]]; then + echo "RELEASE_BRANCH and RELEASE_SHA are required." >&2 + exit 1 + fi + + pr_json="$( + gh pr list \ + --repo "${GITHUB_REPOSITORY}" \ + --base "${RELEASE_BRANCH}" \ + --state merged \ + --limit 100 \ + --json number,headRefOid,headRefName,mergeCommit \ + --jq "[.[] | select(.mergeCommit.oid == \"${RELEASE_SHA}\")] | sort_by(.number) | reverse | .[0] // empty" + )" + if [[ -z "${pr_json}" ]]; then + echo "Unable to find merged PR for ${RELEASE_SHA} into ${RELEASE_BRANCH}" >&2 + exit 1 + fi + + pr_number="$(jq -r '.number' <<< "${pr_json}")" + head_sha="$(jq -r '.headRefOid // .head.sha' <<< "${pr_json}")" + head_ref="$(jq -r '.headRefName // .head.ref' <<< "${pr_json}")" +fi + +artifact_name="termux-android-pr-${pr_number}-${head_sha}" +if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + { + echo "number=${pr_number}" + echo "head_sha=${head_sha}" + echo "head_ref=${head_ref}" + echo "artifact_name=${artifact_name}" + } >> "${GITHUB_OUTPUT}" +else + printf 'number=%s\nhead_sha=%s\nhead_ref=%s\nartifact_name=%s\n' \ + "${pr_number}" \ + "${head_sha}" \ + "${head_ref}" \ + "${artifact_name}" +fi diff --git a/scripts/termux-read-release-metadata.sh b/scripts/termux-read-release-metadata.sh new file mode 100755 index 000000000000..519a0635a368 --- /dev/null +++ b/scripts/termux-read-release-metadata.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash + +# Parse .github/termux-release.json and emit workflow outputs for deploy or +# promote jobs. TERMUX_RELEASE_ACTION must be "deploy" or "promote". + +set -euo pipefail + +: "${GITHUB_OUTPUT:?GITHUB_OUTPUT is required}" +: "${GH_TOKEN:?GH_TOKEN is required}" + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + +metadata=".github/termux-release.json" +action="${TERMUX_RELEASE_ACTION:-deploy}" +case "${action}" in + deploy) + missing_context="deployment" + ;; + promote) + missing_context="promotion" + ;; + *) + echo "TERMUX_RELEASE_ACTION must be deploy or promote." >&2 + exit 1 + ;; +esac + +if [[ ! -f "${metadata}" ]]; then + echo "No ${metadata}; this push is not a Termux release ${missing_context}." + echo "${action}=false" >> "${GITHUB_OUTPUT}" + exit 0 +fi + +upstream_tag="$(jq -r '.upstream_tag // empty' "${metadata}")" +upstream_name="$(jq -r '.upstream_name // .upstream_tag // empty' "${metadata}")" +termux_tag="$(jq -r '.termux_tag // empty' "${metadata}")" +upstream_version="${upstream_tag#rust-v}" +upstream_version="${upstream_version%-termux}" +upstream_prerelease=false +if [[ "${upstream_version}" == *-* ]]; then + upstream_prerelease=true +fi +upstream_html_url="$(jq -r '.upstream_html_url // ""' "${metadata}")" +upstream_repo="$(jq -r '.upstream_repo // "openai/codex"' "${metadata}")" +release_train="$(jq -r '.release_train // ""' "${metadata}")" +if [[ -z "${upstream_tag}" || -z "${termux_tag}" ]]; then + echo "Missing upstream_tag or termux_tag in ${metadata}" >&2 + exit 1 +fi + +release_state="$(TERMUX_TAG="${termux_tag}" "${script_dir}/termux-release-asset-state.sh")" +release_exists="$(awk -F= '$1 == "release_exists" { print $2 }' <<< "${release_state}")" +asset_exists="$(awk -F= '$1 == "asset_exists" { print $2 }' <<< "${release_state}")" + +if [[ "${action}" == "promote" ]]; then + if [[ "${asset_exists}" == "true" ]]; then + echo "${termux_tag} already exists with codex-aarch64-linux-android.tar.gz; skipping promotion." + echo "promote=false" >> "${GITHUB_OUTPUT}" + exit 0 + fi + if [[ "${release_exists}" == "true" ]]; then + echo "${termux_tag} exists but is missing codex-aarch64-linux-android.tar.gz; repairing promotion." + fi +fi + +{ + echo "${action}=true" + echo "upstream_tag=${upstream_tag}" + echo "upstream_name=${upstream_name}" + echo "termux_tag=${termux_tag}" + echo "upstream_prerelease=${upstream_prerelease}" + echo "upstream_html_url=${upstream_html_url}" + echo "upstream_repo=${upstream_repo}" + echo "release_train=${release_train}" + echo "release_exists=${release_exists}" + echo "asset_exists=${asset_exists}" +} >> "${GITHUB_OUTPUT}" diff --git a/scripts/termux-release-asset-state.sh b/scripts/termux-release-asset-state.sh new file mode 100755 index 000000000000..8694480b6a36 --- /dev/null +++ b/scripts/termux-release-asset-state.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash + +set -euo pipefail + +termux_tag="${1:-${TERMUX_TAG:-}}" +release_repo="${RELEASE_REPO:-${GITHUB_REPOSITORY:-}}" +asset_name="${ASSET_NAME:-codex-aarch64-linux-android.tar.gz}" + +if [[ -z "${termux_tag}" ]]; then + echo "TERMUX_TAG or tag argument is required." >&2 + exit 1 +fi + +if [[ -z "${release_repo}" ]]; then + echo "GITHUB_REPOSITORY or RELEASE_REPO is required." >&2 + exit 1 +fi + +release_exists=false +asset_exists=false + +if gh release view "${termux_tag}" --repo "${release_repo}" >/dev/null 2>&1; then + release_exists=true + release_asset_exists="$( + gh release view "${termux_tag}" \ + --repo "${release_repo}" \ + --json assets \ + | jq -r --arg asset_name "${asset_name}" \ + '.assets | map(.name) | any(. == $asset_name)' + )" + if [[ "${release_asset_exists}" == "true" ]]; then + asset_exists=true + fi +fi + +printf 'release_exists=%s\n' "${release_exists}" +printf 'asset_exists=%s\n' "${asset_exists}" diff --git a/scripts/termux-release-paths.sh b/scripts/termux-release-paths.sh new file mode 100755 index 000000000000..6990f323ec44 --- /dev/null +++ b/scripts/termux-release-paths.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash + +# Sourceable path lists for files owned by the Termux release automation. + +set -euo pipefail + +readonly -a TERMUX_RELEASE_WORKFLOW_PATHS=( + .github/workflows/rust-release.yml + .github/workflows/shell-tool-mcp.yml + .github/workflows/termux-release-checkpoint.yml + .github/workflows/termux-release-deploy.yml + .github/workflows/termux-release-promote.yml +) + +readonly -a TERMUX_RELEASE_BRANCH_SCRIPT_PATHS=( + scripts/termux-configure-git.sh + scripts/termux-create-checkpoint-pr.sh + scripts/termux-create-or-update-mirrored-release.sh + scripts/termux-download-release-artifact.sh + scripts/termux-find-release-pr.sh + scripts/termux-read-release-metadata.sh + scripts/termux-release-asset-state.sh + scripts/termux-release-paths.sh + scripts/termux-resolve-release-ref.sh + scripts/termux-validate-gh-env.sh +) + +readonly -a TERMUX_RELEASE_AUTOMATION_PATHS=( + "${TERMUX_RELEASE_WORKFLOW_PATHS[@]}" + "${TERMUX_RELEASE_BRANCH_SCRIPT_PATHS[@]}" +) + +readonly -a TERMUX_CHECKPOINT_RELEASE_ONLY_PATHS=( + "${TERMUX_RELEASE_AUTOMATION_PATHS[@]}" + .github/termux-release.json +) + +termux_path_in_list() { + local candidate="$1" + shift + local listed_path + + for listed_path in "$@"; do + [[ "${candidate}" != "${listed_path}" ]] || return 0 + done + return 1 +} + +termux_is_release_automation_path() { + termux_path_in_list "$1" "${TERMUX_RELEASE_AUTOMATION_PATHS[@]}" +} + +termux_is_checkpoint_release_only_path() { + termux_path_in_list "$1" "${TERMUX_CHECKPOINT_RELEASE_ONLY_PATHS[@]}" +} diff --git a/scripts/termux-resolve-release-ref.sh b/scripts/termux-resolve-release-ref.sh new file mode 100755 index 000000000000..194773d45bed --- /dev/null +++ b/scripts/termux-resolve-release-ref.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash + +# Resolve the release branch and SHA for deploy/promote workflows. + +set -euo pipefail + +: "${GITHUB_EVENT_NAME:?GITHUB_EVENT_NAME is required}" +: "${GITHUB_OUTPUT:?GITHUB_OUTPUT is required}" + +input_release_branch="${INPUT_RELEASE_BRANCH:-${REQUESTED_RELEASE_BRANCH:-}}" +input_release_sha="${INPUT_RELEASE_SHA:-${REQUESTED_RELEASE_SHA:-}}" + +if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then + if [[ -z "${input_release_branch}" ]]; then + echo "release_branch is required for workflow_dispatch." >&2 + exit 1 + fi + + release_branch="${input_release_branch}" + if [[ -n "${input_release_sha}" ]]; then + git checkout --detach "${input_release_sha}" + release_sha="${input_release_sha}" + else + release_sha="$(git rev-parse HEAD)" + fi +else + : "${GITHUB_REF_NAME:?GITHUB_REF_NAME is required}" + : "${GITHUB_SHA:?GITHUB_SHA is required}" + release_branch="${GITHUB_REF_NAME}" + release_sha="${GITHUB_SHA}" +fi + +{ + echo "branch=${release_branch}" + echo "sha=${release_sha}" +} >> "${GITHUB_OUTPUT}" diff --git a/scripts/termux-validate-gh-env.sh b/scripts/termux-validate-gh-env.sh new file mode 100755 index 000000000000..02581fdebceb --- /dev/null +++ b/scripts/termux-validate-gh-env.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash + +# Lightweight post-toolbox check for jobs that rely on authenticated gh calls. + +set -euo pipefail + +: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" +: "${GH_TOKEN:?GH_TOKEN is required}" + +command -v gh +gh auth status --hostname github.com + +printf 'GITHUB_REPOSITORY=%s\n' "${GITHUB_REPOSITORY}" +printf 'REPO=%s\n' "${REPO:-}" +printf 'GH_REPO_URL=%s\n' "${GH_REPO_URL:-}" +printf 'GH_WORKFLOW_URL=%s\n' "${GH_WORKFLOW_URL:-}" From fbd4efa9ed6b9fe13dacd56247cc714903df72b7 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Fri, 22 May 2026 15:21:08 -0700 Subject: [PATCH 56/64] [codex] Use TurnInput for session task input (#24151) ## Why The idea here is to erase the difference between initial and followup inputs to a turn. Followup inputs are already represented as TurnInput. Eventual goal is not to have explicit on task input at all and pull everything from input Q. ## What Changed - Changes `SessionTask::run` and the erased `AnySessionTask::run` path to accept `Vec`. - Wraps user-submitted spawn input as `TurnInput::UserInput` at the session task start boundary. - Updates `run_turn` to record initial `TurnInput` using the same hook and recording path used for pending input. - Keeps review-specific conversion local to `ReviewTask`, where the sub-Codex one-shot API still expects `Vec`. - Moves the synthetic compact prompt into `CompactTask` and starts compact tasks with empty task input. ## Validation - `cargo check -p codex-core` - `just test -p codex-core -E 'test(task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input) | test(queued_response_items_for_next_turn_move_into_next_active_turn) | test(steered_input_reopens_mailbox_delivery_for_current_turn)'` --- codex-rs/core/src/session/handlers.rs | 12 +-- codex-rs/core/src/session/tests.rs | 6 +- codex-rs/core/src/session/turn.rs | 104 +++++++++++++------------- codex-rs/core/src/tasks/compact.rs | 8 +- codex-rs/core/src/tasks/mod.rs | 13 +++- codex-rs/core/src/tasks/regular.rs | 4 +- codex-rs/core/src/tasks/review.rs | 13 +++- codex-rs/core/src/tasks/user_shell.rs | 4 +- 8 files changed, 89 insertions(+), 75 deletions(-) diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index 3d57bd707491..b1e36b034724 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -460,16 +460,8 @@ pub async fn reload_user_config(sess: &Arc) { pub async fn compact(sess: &Arc, sub_id: String) { let turn_context = sess.new_default_turn_with_sub_id(sub_id).await; - sess.spawn_task( - Arc::clone(&turn_context), - vec![UserInput::Text { - text: turn_context.compact_prompt().to_string(), - // Compaction prompt is synthesized; no UI element ranges to preserve. - text_elements: Vec::new(), - }], - CompactTask, - ) - .await; + sess.spawn_task(Arc::clone(&turn_context), Vec::new(), CompactTask) + .await; } pub async fn thread_rollback(sess: &Arc, sub_id: String, num_turns: u32) { diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index df7559a7d97f..30d26191d741 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -5738,7 +5738,7 @@ async fn spawn_task_turn_span_inherits_dispatch_trace_context() { self: Arc, _session: Arc, _ctx: Arc, - _input: Vec, + _input: Vec, _cancellation_token: CancellationToken, ) -> Option { let mut trace = self @@ -7814,7 +7814,7 @@ impl SessionTask for NeverEndingTask { self: Arc, _session: Arc, _ctx: Arc, - _input: Vec, + _input: Vec, cancellation_token: CancellationToken, ) -> Option { if self.listen_to_cancellation_token { @@ -7843,7 +7843,7 @@ impl SessionTask for GuardianDeniedApprovalTask { self: Arc, session: Arc, ctx: Arc, - _input: Vec, + _input: Vec, cancellation_token: CancellationToken, ) -> Option { let session = session.clone_session(); diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 26ba845460c3..f81b2b1f7429 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -114,8 +114,8 @@ use tracing::trace; use tracing::trace_span; use tracing::warn; -/// Takes a user message as input and runs a loop where, at each sampling request, the model -/// replies with either: +/// Takes initial turn input and runs a loop where, at each sampling request, +/// the model replies with either: /// /// - requested function calls /// - an assistant message @@ -132,7 +132,7 @@ pub(crate) async fn run_turn( sess: Arc, turn_context: Arc, turn_extension_data: Arc, - input: Vec, + input: Vec, prewarmed_client_session: Option, cancellation_token: CancellationToken, ) -> Option { @@ -172,26 +172,9 @@ pub(crate) async fn run_turn( if run_pending_session_start_hooks(&sess, &turn_context).await { return None; } - if !input.is_empty() { - let initial_turn_input = TurnInput::UserInput(input.clone()); - let user_prompt_submit_outcome = - inspect_pending_input(&sess, &turn_context, &initial_turn_input).await; - if user_prompt_submit_outcome.should_stop { - record_additional_contexts( - &sess, - &turn_context, - user_prompt_submit_outcome.additional_contexts, - ) - .await; - return None; - } - record_pending_input( - &sess, - &turn_context, - initial_turn_input, - user_prompt_submit_outcome.additional_contexts, - ) - .await; + let mut can_drain_pending_input = input.is_empty(); + if run_hooks_and_record_inputs(&sess, &turn_context, &input).await { + return None; } sess.merge_connector_selection(explicitly_enabled_connectors.clone()) @@ -232,9 +215,8 @@ pub(crate) async fn run_turn( // one instance across retries within this turn. // Pending input is drained into history before building the next model request. // However, we defer that drain until after sampling in two cases: - // 1. At the start of a turn, so the fresh user prompt in `input` gets sampled first. + // 1. At the start of a turn, so the fresh turn input in `input` gets sampled first. // 2. After auto-compact, when model/tool continuation needs to resume before any steer. - let mut can_drain_pending_input = input.is_empty(); loop { // Note that pending_input would be something like a message the user @@ -246,27 +228,7 @@ pub(crate) async fn run_turn( Vec::new() }; - let mut blocked_pending_input = false; - let mut accepted_pending_input = false; - for pending_input_item in pending_input { - let hook_outcome = - inspect_pending_input(&sess, &turn_context, &pending_input_item).await; - if hook_outcome.should_stop { - blocked_pending_input = true; - record_additional_contexts(&sess, &turn_context, hook_outcome.additional_contexts) - .await; - } else { - accepted_pending_input = true; - record_pending_input( - &sess, - &turn_context, - pending_input_item, - hook_outcome.additional_contexts, - ) - .await; - } - } - if blocked_pending_input && !accepted_pending_input { + if run_hooks_and_record_inputs(&sess, &turn_context, &pending_input).await { break; } @@ -450,6 +412,32 @@ pub(crate) async fn run_turn( last_agent_message } +async fn run_hooks_and_record_inputs( + sess: &Arc, + turn_context: &Arc, + input: &[TurnInput], +) -> bool { + let mut blocked_input = false; + let mut accepted_input = false; + for input_item in input { + let hook_outcome = inspect_pending_input(sess, turn_context, input_item).await; + if hook_outcome.should_stop { + blocked_input = true; + record_additional_contexts(sess, turn_context, hook_outcome.additional_contexts).await; + } else { + accepted_input = true; + record_pending_input( + sess, + turn_context, + input_item.clone(), + hook_outcome.additional_contexts, + ) + .await; + } + } + blocked_input && !accepted_input +} + #[expect( clippy::await_holding_invalid_type, reason = "MCP tool listing borrows the read guard across cancellation-aware await" @@ -457,9 +445,18 @@ pub(crate) async fn run_turn( async fn build_skills_and_plugins( sess: &Arc, turn_context: &TurnContext, - input: &[UserInput], + input: &[TurnInput], cancellation_token: &CancellationToken, ) -> Option<(Vec, HashSet)> { + let user_input = input + .iter() + .filter_map(|item| match item { + TurnInput::UserInput(content) => Some(content.as_slice()), + TurnInput::ResponseInputItem(_) => None, + }) + .flatten() + .cloned() + .collect::>(); let tracking = build_track_events_context( turn_context.model_info.slug.clone(), sess.conversation_id.to_string(), @@ -473,7 +470,7 @@ async fn build_skills_and_plugins( // Structured plugin:// mentions are resolved from the current session's // enabled plugins, then converted into turn-scoped guidance below. let mentioned_plugins = - collect_explicit_plugin_mentions(input, loaded_plugins.capability_summaries()); + collect_explicit_plugin_mentions(&user_input, loaded_plugins.capability_summaries()); let mcp_tools = if turn_context.apps_enabled() || !mentioned_plugins.is_empty() { // Plugin mentions need raw MCP/app inventory even when app tools // are normally hidden so we can describe the plugin's currently @@ -511,7 +508,7 @@ async fn build_skills_and_plugins( let skill_name_counts_lower = build_skill_name_counts(&skills_outcome.skills, &skills_outcome.disabled_paths).1; let mentioned_skills = collect_explicit_skill_mentions( - input, + &user_input, &skills_outcome.skills, &skills_outcome.disabled_paths, &connector_slug_counts, @@ -553,7 +550,7 @@ async fn build_skills_and_plugins( ); let plugin_items = build_plugin_injections(&mentioned_plugins, &mcp_tools, &available_connectors); - let mut explicitly_enabled_connectors = collect_explicit_app_ids(input); + let mut explicitly_enabled_connectors = collect_explicit_app_ids(&user_input); explicitly_enabled_connectors.extend(skill_connector_ids); let connector_names_by_id = available_connectors .iter() @@ -589,7 +586,7 @@ async fn build_skills_and_plugins( async fn track_turn_resolved_config_analytics( sess: &Session, turn_context: &TurnContext, - input: &[UserInput], + input: &[TurnInput], ) { let thread_config = { let state = sess.state.lock().await; @@ -606,6 +603,11 @@ async fn track_turn_resolved_config_analytics( thread_id: sess.conversation_id.to_string(), num_input_images: input .iter() + .filter_map(|item| match item { + TurnInput::UserInput(content) => Some(content.as_slice()), + TurnInput::ResponseInputItem(_) => None, + }) + .flatten() .filter(|item| { matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) }) diff --git a/codex-rs/core/src/tasks/compact.rs b/codex-rs/core/src/tasks/compact.rs index dddf46391ed5..77914633a204 100644 --- a/codex-rs/core/src/tasks/compact.rs +++ b/codex-rs/core/src/tasks/compact.rs @@ -2,6 +2,7 @@ use std::sync::Arc; use super::SessionTask; use super::SessionTaskContext; +use crate::session::TurnInput; use crate::session::turn_context::TurnContext; use crate::state::TaskKind; use codex_protocol::user_input::UserInput; @@ -23,7 +24,7 @@ impl SessionTask for CompactTask { self: Arc, session: Arc, ctx: Arc, - input: Vec, + _input: Vec, _cancellation_token: CancellationToken, ) -> Option { let session = session.clone_session(); @@ -47,6 +48,11 @@ impl SessionTask for CompactTask { /*inc*/ 1, &[("type", "local")], ); + let input = vec![UserInput::Text { + text: ctx.compact_prompt().to_string(), + // Compaction prompt is synthesized; no UI element ranges to preserve. + text_elements: Vec::new(), + }]; crate::compact::run_compact_task(session.clone(), ctx, input).await }; None diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 9cb6b5f0f20e..4d6db8acf22f 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -214,7 +214,7 @@ pub(crate) trait SessionTask: Send + Sync + 'static { self: Arc, session: Arc, ctx: Arc, - input: Vec, + input: Vec, cancellation_token: CancellationToken, ) -> impl std::future::Future> + Send; @@ -245,7 +245,7 @@ pub(crate) trait AnySessionTask: Send + Sync + 'static { self: Arc, session: Arc, ctx: Arc, - input: Vec, + input: Vec, cancellation_token: CancellationToken, ) -> BoxFuture<'static, Option>; @@ -276,7 +276,7 @@ where self: Arc, session: Arc, ctx: Arc, - input: Vec, + input: Vec, cancellation_token: CancellationToken, ) -> BoxFuture<'static, Option> { Box::pin(SessionTask::run( @@ -380,6 +380,11 @@ impl Session { )); let ctx = Arc::clone(&turn_context); let task_for_run = Arc::clone(&task); + let task_input = if input.is_empty() { + Vec::new() + } else { + vec![TurnInput::UserInput(input)] + }; let task_cancellation_token = cancellation_token.child_token(); // Task-owned turn spans keep a core-owned span open for the // full task lifecycle after the submission dispatch span ends. @@ -405,7 +410,7 @@ impl Session { .run( Arc::clone(&session_ctx), ctx, - input, + task_input, task_cancellation_token.child_token(), ) .await; diff --git a/codex-rs/core/src/tasks/regular.rs b/codex-rs/core/src/tasks/regular.rs index 531d5d7da791..6c6a0e5b0920 100644 --- a/codex-rs/core/src/tasks/regular.rs +++ b/codex-rs/core/src/tasks/regular.rs @@ -2,13 +2,13 @@ use std::sync::Arc; use tokio_util::sync::CancellationToken; +use crate::session::TurnInput; use crate::session::turn::run_turn; use crate::session::turn_context::TurnContext; use crate::session_startup_prewarm::SessionStartupPrewarmResolution; use crate::state::TaskKind; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::TurnStartedEvent; -use codex_protocol::user_input::UserInput; use tracing::Instrument; use tracing::trace_span; @@ -41,7 +41,7 @@ impl SessionTask for RegularTask { self: Arc, session: Arc, ctx: Arc, - input: Vec, + input: Vec, cancellation_token: CancellationToken, ) -> Option { let sess = session.clone_session(); diff --git a/codex-rs/core/src/tasks/review.rs b/codex-rs/core/src/tasks/review.rs index f89c7d062f41..ff7f38a7eaef 100644 --- a/codex-rs/core/src/tasks/review.rs +++ b/codex-rs/core/src/tasks/review.rs @@ -20,6 +20,7 @@ use crate::codex_delegate::run_codex_thread_one_shot; use crate::config::Constrained; use crate::review_format::format_review_findings_block; use crate::review_format::render_review_output_text; +use crate::session::TurnInput; use crate::session::session::Session; use crate::session::turn_context::TurnContext; use crate::state::TaskKind; @@ -59,7 +60,7 @@ impl SessionTask for ReviewTask { self: Arc, session: Arc, ctx: Arc, - input: Vec, + input: Vec, cancellation_token: CancellationToken, ) -> Option { session.session.services.session_telemetry.counter( @@ -68,11 +69,19 @@ impl SessionTask for ReviewTask { &[], ); + let mut user_input = Vec::new(); + for item in input { + match item { + TurnInput::UserInput(mut content) => user_input.append(&mut content), + TurnInput::ResponseInputItem(_) => {} + } + } + // Start sub-codex conversation and get the receiver for events. let output = match start_review_conversation( session.clone(), ctx.clone(), - input, + user_input, cancellation_token.clone(), ) .await diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 2ce4056957ee..396aecbeea89 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -7,7 +7,6 @@ use codex_network_proxy::PROXY_ACTIVE_ENV_KEY; use codex_network_proxy::PROXY_ENV_KEYS; #[cfg(target_os = "macos")] use codex_network_proxy::PROXY_GIT_SSH_COMMAND_ENV_KEY; -use codex_protocol::user_input::UserInput; use tokio_util::sync::CancellationToken; use tracing::error; use uuid::Uuid; @@ -17,6 +16,7 @@ use crate::exec::StdoutStream; use crate::exec::execute_exec_request; use crate::exec_env::create_env; use crate::sandboxing::ExecRequest; +use crate::session::TurnInput; use crate::session::turn_context::TurnContext; use crate::state::TaskKind; use crate::tools::format_exec_output_str; @@ -77,7 +77,7 @@ impl SessionTask for UserShellCommandTask { self: Arc, session: Arc, turn_context: Arc, - _input: Vec, + _input: Vec, cancellation_token: CancellationToken, ) -> Option { execute_user_shell_command( From 7924743c38e9a28795c1533ab5143dceea5ca566 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Fri, 22 May 2026 15:38:40 -0700 Subject: [PATCH 57/64] [codex] Add image re-encoding benchmarks (#23935) ## Summary - add Divan benchmarks for prompt image re-encoding paths - wire the image benchmark smoke test into Rust CI workflows ## Why Image prompt handling includes re-encoding work that benefits from repeatable benchmark coverage so changes can be measured in CI and locally. This already helped identify a potential regression from changing compiler flags. ## Impact Developers can run and compare the new image re-encoding benchmarks, and CI exercises the benchmark target via the Rust benchmark smoke test. --- .github/workflows/rust-ci-full.yml | 5 + .github/workflows/rust-ci.yml | 5 + MODULE.bazel.lock | 3 + codex-rs/Cargo.lock | 34 +++- codex-rs/Cargo.toml | 1 + codex-rs/utils/image/Cargo.toml | 5 + codex-rs/utils/image/benches/prompt_images.rs | 178 ++++++++++++++++++ justfile | 9 + 8 files changed, 239 insertions(+), 1 deletion(-) create mode 100644 codex-rs/utils/image/benches/prompt_images.rs diff --git a/.github/workflows/rust-ci-full.yml b/.github/workflows/rust-ci-full.yml index b65c2f5d8fc9..c4a9329c0035 100644 --- a/.github/workflows/rust-ci-full.yml +++ b/.github/workflows/rust-ci-full.yml @@ -28,8 +28,13 @@ jobs: - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 with: components: rustfmt + - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49 + with: + tool: just - name: cargo fmt run: cargo fmt -- --config imports_granularity=Item --check + - name: Rust benchmark smoke test + run: just bench-smoke cargo_shear: name: cargo shear diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 75c5c3360123..029b6f2c7ee4 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -70,8 +70,13 @@ jobs: - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 with: components: rustfmt + - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49 + with: + tool: just - name: cargo fmt run: cargo fmt -- --config imports_granularity=Item --check + - name: Rust benchmark smoke test + run: just bench-smoke cargo_shear: name: cargo shear diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 1392b5a5681a..ad56196dd9db 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -768,6 +768,7 @@ "compact_str_0.8.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"borsh\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"castaway\",\"req\":\"^0.2.3\"},{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"cfg-if\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"diesel\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"itoa\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"markup\",\"optional\":true,\"req\":\"^0.13\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"proptest\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"quickcheck\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"quickcheck_macros\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"size_32\"],\"name\":\"rkyv\",\"optional\":true,\"req\":\"^0.7\"},{\"default_features\":false,\"features\":[\"alloc\",\"size_32\"],\"kind\":\"dev\",\"name\":\"rkyv\",\"req\":\"^0.7\"},{\"name\":\"rustversion\",\"req\":\"^1\"},{\"name\":\"ryu\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"derive\",\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"features\":[\"union\"],\"name\":\"smallvec\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"name\":\"sqlx\",\"optional\":true,\"req\":\"^0.7\"},{\"name\":\"static_assertions\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"test-case\",\"req\":\"^3\"},{\"kind\":\"dev\",\"name\":\"test-strategy\",\"req\":\"^0.3\"}],\"features\":{\"arbitrary\":[\"dep:arbitrary\"],\"borsh\":[\"dep:borsh\"],\"bytes\":[\"dep:bytes\"],\"default\":[\"std\"],\"diesel\":[\"dep:diesel\"],\"markup\":[\"dep:markup\"],\"proptest\":[\"dep:proptest\"],\"quickcheck\":[\"dep:quickcheck\"],\"rkyv\":[\"dep:rkyv\"],\"serde\":[\"dep:serde\"],\"smallvec\":[\"dep:smallvec\"],\"sqlx\":[\"dep:sqlx\",\"std\"],\"sqlx-mysql\":[\"sqlx\",\"sqlx/mysql\"],\"sqlx-postgres\":[\"sqlx\",\"sqlx/postgres\"],\"sqlx-sqlite\":[\"sqlx\",\"sqlx/sqlite\"],\"std\":[]}}", "compiletest_rs_0.11.2": "{\"dependencies\":[{\"name\":\"diff\",\"req\":\"^0.1.10\"},{\"name\":\"filetime\",\"req\":\"^0.2\"},{\"name\":\"getopts\",\"req\":\"^0.2\"},{\"name\":\"lazy_static\",\"req\":\"^1.4\"},{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"name\":\"log\",\"req\":\"^0.4\"},{\"name\":\"miow\",\"req\":\"^0.6\",\"target\":\"cfg(windows)\"},{\"name\":\"regex\",\"req\":\"^1.0\"},{\"name\":\"rustfix\",\"req\":\"^0.8\"},{\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_derive\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"tempfile\",\"optional\":true,\"req\":\"^3.0\"},{\"name\":\"tester\",\"req\":\"^0.9\"},{\"features\":[\"Win32\"],\"name\":\"windows-sys\",\"req\":\"^0.59\",\"target\":\"cfg(windows)\"}],\"features\":{\"rustc\":[],\"stable\":[],\"tmp\":[\"tempfile\"]}}", "concurrent-queue_2.5.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"cargo_bench_support\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"default_features\":false,\"name\":\"crossbeam-utils\",\"req\":\"^0.8.11\"},{\"kind\":\"dev\",\"name\":\"easy-parallel\",\"req\":\"^3.1.0\"},{\"kind\":\"dev\",\"name\":\"fastrand\",\"req\":\"^2.0.0\"},{\"name\":\"loom\",\"optional\":true,\"req\":\"^0.7\",\"target\":\"cfg(loom)\"},{\"default_features\":false,\"name\":\"portable-atomic\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", + "condtype_1.3.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"cfg-if\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.141\"}],\"features\":{}}", "console_0.15.11": "{\"dependencies\":[{\"name\":\"encode_unicode\",\"req\":\"^1\",\"target\":\"cfg(windows)\"},{\"name\":\"libc\",\"req\":\"^0.2.99\"},{\"name\":\"once_cell\",\"req\":\"^1.8\"},{\"default_features\":false,\"features\":[\"std\",\"bit-set\",\"break-dead-code\"],\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.4.2\"},{\"name\":\"unicode-width\",\"optional\":true,\"req\":\"^0.2\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_Console\",\"Win32_Storage_FileSystem\",\"Win32_UI_Input_KeyboardAndMouse\"],\"name\":\"windows-sys\",\"req\":\"^0.59\",\"target\":\"cfg(windows)\"}],\"features\":{\"ansi-parsing\":[],\"default\":[\"unicode-width\",\"ansi-parsing\"],\"windows-console-colors\":[\"ansi-parsing\"]}}", "const-hex_1.17.0": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"cpufeatures\",\"req\":\"^0.2\",\"target\":\"cfg(any(target_arch = \\\"x86\\\", target_arch = \\\"x86_64\\\"))\"},{\"kind\":\"dev\",\"name\":\"divan\",\"package\":\"codspeed-divan-compat\",\"req\":\"^3\"},{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"faster-hex\",\"req\":\"^0.10.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"~0.4.2\"},{\"default_features\":false,\"name\":\"proptest\",\"optional\":true,\"req\":\"^1.4\"},{\"kind\":\"dev\",\"name\":\"rustc-hex\",\"req\":\"^2.1\"},{\"default_features\":false,\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{\"__fuzzing\":[\"dep:proptest\",\"std\"],\"alloc\":[\"serde_core?/alloc\",\"proptest?/alloc\"],\"core-error\":[],\"default\":[\"std\"],\"force-generic\":[],\"hex\":[],\"nightly\":[],\"portable-simd\":[],\"serde\":[\"dep:serde_core\"],\"std\":[\"serde_core?/std\",\"proptest?/std\",\"alloc\"]}}", "const-oid_0.9.6": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.2\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.3\"}],\"features\":{\"db\":[],\"std\":[]}}", @@ -859,6 +860,8 @@ "dispatch2_0.3.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"std\"],\"name\":\"bitflags\",\"req\":\"^2.5.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"block2\",\"optional\":true,\"req\":\">=0.6.1, <0.8.0\"},{\"default_features\":false,\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.80\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"objc2\",\"optional\":true,\"req\":\">=0.6.1, <0.8.0\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1.0\"}],\"features\":{\"alloc\":[],\"block2\":[\"dep:block2\"],\"default\":[\"std\",\"block2\",\"libc\",\"objc2\"],\"libc\":[\"dep:libc\"],\"objc2\":[\"dep:objc2\"],\"std\":[\"alloc\"]}}", "display_container_0.9.0": "{\"dependencies\":[{\"name\":\"either\",\"req\":\"^1.8\"},{\"name\":\"indenter\",\"req\":\"^0.3.3\"}],\"features\":{}}", "displaydoc_0.2.5": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^0.6.1\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1\"},{\"name\":\"syn\",\"req\":\"^2.0\"},{\"kind\":\"dev\",\"name\":\"thiserror\",\"req\":\"^1.0.24\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", + "divan-macros_0.1.21": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"quote\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"full\",\"clone-impls\",\"parsing\",\"printing\",\"proc-macro\"],\"name\":\"syn\",\"req\":\"^2.0.18\"}],\"features\":{}}", + "divan_0.1.21": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"std\",\"env\"],\"name\":\"clap\",\"req\":\"^4\"},{\"name\":\"condtype\",\"req\":\"^1.3\"},{\"name\":\"divan-macros\",\"req\":\"=0.1.21\"},{\"name\":\"libc\",\"req\":\"^0.2.148\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"mimalloc\",\"req\":\"^0.1\"},{\"default_features\":false,\"features\":[\"std\",\"string\"],\"name\":\"regex\",\"package\":\"regex-lite\",\"req\":\"^0.1\"}],\"features\":{\"default\":[\"wrap_help\"],\"dyn_thread_local\":[],\"help\":[\"clap/help\"],\"internal_benches\":[],\"wrap_help\":[\"help\",\"clap/wrap_help\"]}}", "dns-lookup_3.0.1": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"name\":\"socket2\",\"req\":\"^0.6.0\"},{\"features\":[\"Win32_Networking_WinSock\",\"Win32_Foundation\"],\"name\":\"windows-sys\",\"req\":\"^0.60\",\"target\":\"cfg(windows)\"}],\"features\":{}}", "document-features_0.2.12": "{\"dependencies\":[{\"name\":\"litrs\",\"req\":\"^1.0.0\"}],\"features\":{\"default\":[],\"self-test\":[]}}", "dotenvy_0.15.7": "{\"dependencies\":[{\"name\":\"clap\",\"optional\":true,\"req\":\"^3.2\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.16.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.3.0\"}],\"features\":{\"cli\":[\"clap\"]}}", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 38ce92e46b30..779785704d44 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3981,6 +3981,7 @@ version = "0.0.0" dependencies = [ "base64 0.22.1", "codex-utils-cache", + "divan", "image", "mime_guess", "thiserror 2.0.18", @@ -4223,6 +4224,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "condtype" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf0a07a401f374238ab8e2f11a104d2851bf9ce711ec69804834de8af45c7af" + [[package]] name = "console" version = "0.15.11" @@ -5219,6 +5226,31 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "divan" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a405457ec78b8fe08b0e32b4a3570ab5dff6dd16eb9e76a5ee0a9d9cbd898933" +dependencies = [ + "cfg-if", + "clap", + "condtype", + "divan-macros", + "libc", + "regex-lite", +] + +[[package]] +name = "divan-macros" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9556bc800956545d6420a640173e5ba7dfa82f38d3ea5a167eb555bc69ac3323" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "dns-lookup" version = "3.0.1" @@ -5492,7 +5524,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index df169504f081..95c065c74bdf 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -275,6 +275,7 @@ deno_core_icudata = "0.77.0" derive_more = "2" diffy = "0.4.2" dirs = "6" +divan = "0.1.21" dns-lookup = "3.0.1" dotenvy = "0.15.7" dunce = "1.0.4" diff --git a/codex-rs/utils/image/Cargo.toml b/codex-rs/utils/image/Cargo.toml index 5ac187caaa1b..7ba28f49962a 100644 --- a/codex-rs/utils/image/Cargo.toml +++ b/codex-rs/utils/image/Cargo.toml @@ -16,7 +16,12 @@ thiserror = { workspace = true } tokio = { workspace = true, features = ["fs", "rt", "rt-multi-thread", "macros"] } [dev-dependencies] +divan = { workspace = true } image = { workspace = true, features = ["jpeg", "png", "gif", "webp"] } [lib] doctest = false + +[[bench]] +name = "prompt_images" +harness = false diff --git a/codex-rs/utils/image/benches/prompt_images.rs b/codex-rs/utils/image/benches/prompt_images.rs new file mode 100644 index 000000000000..5d2bcb61cb14 --- /dev/null +++ b/codex-rs/utils/image/benches/prompt_images.rs @@ -0,0 +1,178 @@ +use std::io::Cursor; +use std::path::Path; + +use codex_utils_image::PromptImageMode; +use codex_utils_image::load_for_prompt_bytes; +use divan::Bencher; +use image::DynamicImage; +use image::ImageFormat; +use image::Rgb; +use image::RgbImage; +use image::Rgba; +use image::RgbaImage; + +const CACHE_MISS_VARIANT_COUNT: usize = 48; + +const SMALL_SCREENSHOT: ImageSize = ImageSize { + width: 1_536, + height: 864, +}; +const LARGE_SCREENSHOT: ImageSize = ImageSize { + width: 2_560, + height: 1_440, +}; +const LARGE_PHOTO: ImageSize = ImageSize { + width: 3_264, + height: 2_448, +}; + +#[derive(Clone, Copy)] +struct ImageSize { + width: u32, + height: u32, +} + +fn main() { + divan::main(); +} + +#[divan::bench] +fn small_png_screenshot_fresh_attachment(bencher: Bencher) { + bench_fresh_attachment( + bencher, + "small-screenshot.png", + cache_miss_variants(screenshot_png(SMALL_SCREENSHOT)), + ); +} + +#[divan::bench] +fn large_png_screenshot_fresh_attachment(bencher: Bencher) { + bench_fresh_attachment( + bencher, + "large-screenshot.png", + cache_miss_variants(screenshot_png(LARGE_SCREENSHOT)), + ); +} + +#[divan::bench] +fn large_jpeg_photo_fresh_attachment(bencher: Bencher) { + bench_fresh_attachment( + bencher, + "large-photo.jpg", + cache_miss_variants(photo_jpeg(LARGE_PHOTO)), + ); +} + +#[divan::bench] +fn small_png_screenshot_repeated_attachment(bencher: Bencher) { + bench_repeated_attachment( + bencher, + "small-screenshot.png", + screenshot_png(SMALL_SCREENSHOT), + ); +} + +fn bench_fresh_attachment(bencher: Bencher, path: &'static str, images: Vec>) { + let mut image_index = 0; + + bencher + // Divan excludes `with_inputs` from the measured benchmark timing. + .with_inputs(move || { + let image = images[image_index].clone(); + image_index = (image_index + 1) % images.len(); + image + }) + .bench_local_values(move |image| prepare_prompt_data_url(path, image)); +} + +fn bench_repeated_attachment(bencher: Bencher, path: &'static str, image: Vec) { + let _ = prepare_prompt_data_url(path, image.clone()); + + bencher + // Divan excludes the per-iteration input clone from measured timing. + .with_inputs(move || image.clone()) + .bench_local_values(move |image| prepare_prompt_data_url(path, image)); +} + +fn prepare_prompt_data_url(path: &str, image: Vec) -> String { + #[allow(clippy::expect_used)] + load_for_prompt_bytes(Path::new(path), image, PromptImageMode::ResizeToFit) + .expect("benchmark fixture should load") + .into_data_url() +} + +fn cache_miss_variants(image: Vec) -> Vec> { + // The loader caches by content digest. Suffixes keep this workload on the miss path. + (0..CACHE_MISS_VARIANT_COUNT) + .map(|variant| { + let mut image = image.clone(); + image.extend_from_slice(&variant.to_le_bytes()); + image + }) + .collect() +} + +/// Encodes a synthetic UI screenshot fixture for prompt image benchmarks. +fn screenshot_png(size: ImageSize) -> Vec { + let image = RgbaImage::from_fn(size.width, size.height, |x, y| { + let toolbar = y < 52; + let sidebar = x < 240; + let panel_border = x % 320 < 2 || y % 216 < 2; + let text_row = x > 270 && y > 88 && x % 19 < 13 && y % 31 < 3; + + if toolbar { + Rgba([33, 40, 52, 255]) + } else if sidebar { + let selection = y / 68 % 5 == 2; + if selection { + Rgba([65, 106, 171, 255]) + } else { + Rgba([44, 54, 67, 255]) + } + } else if panel_border { + Rgba([198, 205, 216, 255]) + } else if text_row { + Rgba([72, 82, 96, 255]) + } else { + let panel = ((x / 320) + (y / 216) * 3) % 4; + match panel { + 0 => Rgba([246, 248, 252, 255]), + 1 => Rgba([234, 241, 250, 255]), + 2 => Rgba([240, 247, 236, 255]), + _ => Rgba([250, 240, 235, 255]), + } + } + }); + + encode_fixture(DynamicImage::ImageRgba8(image), ImageFormat::Png) +} + +/// Encodes a synthetic textured photo fixture for prompt image benchmarks. +fn photo_jpeg(size: ImageSize) -> Vec { + let image = RgbImage::from_fn(size.width, size.height, |x, y| { + let x_gradient = x * 255 / size.width; + let y_gradient = y * 255 / size.height; + let texture = ((x.wrapping_mul(17) ^ y.wrapping_mul(31) ^ (x / 7) ^ (y / 11)) & 0xff) as u8; + + Rgb([ + blend_channel(x_gradient, texture, 3), + blend_channel((x_gradient + y_gradient) / 2, texture, 5), + blend_channel(255 - y_gradient, texture, 4), + ]) + }); + + encode_fixture(DynamicImage::ImageRgb8(image), ImageFormat::Jpeg) +} + +fn blend_channel(gradient: u32, texture: u8, divisor: u32) -> u8 { + ((gradient + u32::from(texture) / divisor) % 256) as u8 +} + +fn encode_fixture(image: DynamicImage, format: ImageFormat) -> Vec { + let mut encoded = Cursor::new(Vec::new()); + #[allow(clippy::expect_used)] + image + .write_to(&mut encoded, format) + .expect("benchmark fixture should encode"); + encoded.into_inner() +} diff --git a/justfile b/justfile index 907cd71f6db7..d5e9fc3a36e1 100644 --- a/justfile +++ b/justfile @@ -53,6 +53,15 @@ install: # there should be no need to add `--all-features`. test *args: RUST_MIN_STACK={{ rust_min_stack }} cargo nextest run --no-fail-fast "$@" + just bench-smoke + +# Run explicit workspace benchmark targets. +bench *args: + cargo bench --workspace --bench '*' "$@" + +# Run benchmark targets once to ensure they start successfully. +bench-smoke: + just bench -- --test # Build and run Codex from source using Bazel. # Note we have to use the combination of `[no-cd]` and `--run_under="cd $PWD &&"` From 10ac2781eb7d83d6900686138242dfde12451453 Mon Sep 17 00:00:00 2001 From: Celia Chen Date: Fri, 22 May 2026 16:31:33 -0700 Subject: [PATCH 58/64] chore: add JSON schema policy fixture coverage (#24152) ## Why Before changing the Codex Bridge JSON schema policy, add integration coverage around real connector-like MCP tool schemas. The existing unit tests cover individual sanitizer behaviors, but they do not make it easy to see whether full fixture schemas keep model-visible guidance, prune only unreachable definitions, drop unsupported JSON Schema fields, and stay within the Responses API schema budget. ## What Changed - Added `tools/tests/json_schema_policy_fixtures.rs`, which converts MCP tool fixtures through `mcp_tool_to_responses_api_tool` and validates the resulting Responses tool parameters. - Added connector-style fixtures for Slack, Google Calendar, Google Drive, Notion, and Microsoft Outlook Email under `tools/tests/fixtures/json_schema_policy/`. - Added fixture assertions for preserved guidance, pruned definitions, expected field drops after `JsonSchema` conversion, marker count baselines, and dangling local `$ref` prevention. - Added a real oversized golden Notion `create_page` input schema fixture to exercise the compaction path that strips descriptions, drops root `$defs`, rewrites local refs, and fits the compacted schema under the budget. --- codex-rs/Cargo.lock | 1 + codex-rs/tools/Cargo.toml | 1 + .../json_schema_policy/google_calendar.json | 85 ++ .../json_schema_policy/google_drive.json | 65 + .../microsoft_outlook_email.json | 90 ++ .../fixtures/json_schema_policy/notion.json | 72 ++ ...sized_notion_create_page_input_schema.json | 1124 +++++++++++++++++ .../fixtures/json_schema_policy/slack.json | 75 ++ .../tests/json_schema_policy_fixtures.rs | 224 ++++ 9 files changed, 1737 insertions(+) create mode 100644 codex-rs/tools/tests/fixtures/json_schema_policy/google_calendar.json create mode 100644 codex-rs/tools/tests/fixtures/json_schema_policy/google_drive.json create mode 100644 codex-rs/tools/tests/fixtures/json_schema_policy/microsoft_outlook_email.json create mode 100644 codex-rs/tools/tests/fixtures/json_schema_policy/notion.json create mode 100644 codex-rs/tools/tests/fixtures/json_schema_policy/oversized_notion_create_page_input_schema.json create mode 100644 codex-rs/tools/tests/fixtures/json_schema_policy/slack.json create mode 100644 codex-rs/tools/tests/json_schema_policy_fixtures.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 779785704d44..b843fcae6bf7 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3770,6 +3770,7 @@ dependencies = [ "codex-features", "codex-protocol", "codex-utils-absolute-path", + "codex-utils-cargo-bin", "codex-utils-output-truncation", "codex-utils-pty", "codex-utils-string", diff --git a/codex-rs/tools/Cargo.toml b/codex-rs/tools/Cargo.toml index 7d02f1bf3602..7cc0e348458f 100644 --- a/codex-rs/tools/Cargo.toml +++ b/codex-rs/tools/Cargo.toml @@ -31,6 +31,7 @@ tracing = { workspace = true } urlencoding = { workspace = true } [dev-dependencies] +codex-utils-cargo-bin = { workspace = true } pretty_assertions = { workspace = true } [lib] diff --git a/codex-rs/tools/tests/fixtures/json_schema_policy/google_calendar.json b/codex-rs/tools/tests/fixtures/json_schema_policy/google_calendar.json new file mode 100644 index 000000000000..9673ecf59671 --- /dev/null +++ b/codex-rs/tools/tests/fixtures/json_schema_policy/google_calendar.json @@ -0,0 +1,85 @@ +{ + "source": "standard/google_calendar/tools", + "tools": [ + { + "name": "google_calendar_create_event", + "description": "Create a calendar event.", + "input_schema": { + "type": "object", + "properties": { + "event": { + "$ref": "#/definitions/Event" + }, + "notify_attendees": { + "type": "boolean", + "description": "Whether attendees should receive notifications." + } + }, + "required": [ + "event" + ], + "definitions": { + "Event": { + "type": "object", + "description": "Calendar event payload.", + "properties": { + "title": { + "type": "string", + "description": "Event title." + }, + "start": { + "anyOf": [ + { + "$ref": "#/definitions/DateTime" + }, + { + "type": "null" + } + ] + } + } + }, + "DateTime": { + "type": "object", + "description": "Calendar date-time.", + "properties": { + "dateTime": { + "type": "string", + "format": "date-time", + "description": "RFC3339 date-time." + }, + "timeZone": { + "type": "string", + "enum": [ + "UTC", + "America/Los_Angeles" + ], + "description": "IANA time zone." + } + } + }, + "UnusedCalendarResource": { + "type": "string", + "description": "Unreachable calendar resource." + } + } + }, + "expected_preserved": [ + { + "pointer": "/properties/event/$ref", + "value": "#/definitions/Event" + }, + { + "pointer": "/definitions/DateTime/properties/timeZone/enum/1", + "value": "America/Los_Angeles" + } + ], + "expected_pruned": [ + "/definitions/UnusedCalendarResource" + ], + "expected_dropped_fields": [ + "/definitions/DateTime/properties/dateTime/format" + ] + } + ] +} diff --git a/codex-rs/tools/tests/fixtures/json_schema_policy/google_drive.json b/codex-rs/tools/tests/fixtures/json_schema_policy/google_drive.json new file mode 100644 index 000000000000..bf37296cc029 --- /dev/null +++ b/codex-rs/tools/tests/fixtures/json_schema_policy/google_drive.json @@ -0,0 +1,65 @@ +{ + "source": "standard/google_drive/tools", + "tools": [ + { + "name": "google_drive_copy_file", + "description": "Copy a Google Drive file.", + "input_schema": { + "type": "object", + "properties": { + "file": { + "description": "File selector.", + "oneOf": [ + { + "type": "string", + "description": "A Drive file ID." + }, + { + "type": "object", + "properties": { + "shared_drive_id": { + "type": "string", + "description": "Shared drive identifier." + } + } + } + ] + }, + "metadata": { + "description": "Optional copy metadata.", + "allOf": [ + { + "type": "object", + "description": "Base metadata.", + "properties": { + "source": { + "type": "string", + "description": "Metadata source." + } + } + } + ] + }, + "title": { + "type": "string", + "description": "Copied file title." + } + }, + "required": [ + "file" + ] + }, + "expected_preserved": [ + { + "pointer": "/properties/title/description", + "value": "Copied file title." + } + ], + "expected_pruned": [], + "expected_dropped_fields": [ + "/properties/file/oneOf", + "/properties/metadata/allOf" + ] + } + ] +} diff --git a/codex-rs/tools/tests/fixtures/json_schema_policy/microsoft_outlook_email.json b/codex-rs/tools/tests/fixtures/json_schema_policy/microsoft_outlook_email.json new file mode 100644 index 000000000000..bf0aff4118ca --- /dev/null +++ b/codex-rs/tools/tests/fixtures/json_schema_policy/microsoft_outlook_email.json @@ -0,0 +1,90 @@ +{ + "source": "golden/microsoft_outlook_email/tools", + "tools": [ + { + "name": "microsoft_outlook_email_send", + "description": "Send an Outlook email.", + "input_schema": { + "type": "object", + "properties": { + "message_id": { + "$ref": "#/$defs/Message/properties/id" + }, + "attachment": { + "type": "array", + "items": { + "$ref": "#/$defs/Attachment" + } + } + }, + "$defs": { + "Message": { + "type": "object", + "description": "Outlook message.", + "properties": { + "id": { + "type": "string", + "description": "Message identifier." + }, + "sender": { + "$ref": "#/$defs/EmailAddress" + }, + "importance": { + "type": "string", + "enum": [ + "low", + "normal", + "high" + ], + "description": "Message importance." + } + } + }, + "Attachment": { + "type": "object", + "description": "Email attachment.", + "properties": { + "name": { + "type": "string", + "description": "Attachment file name." + } + } + }, + "EmailAddress": { + "type": "object", + "description": "Email address object.", + "properties": { + "address": { + "type": "string", + "description": "SMTP address." + } + } + }, + "GiantUnusedPayload": { + "type": "object", + "description": "Representative unreachable Outlook payload.", + "properties": { + "opaque": { + "type": "string" + } + } + } + } + }, + "expected_preserved": [ + { + "pointer": "/properties/message_id/$ref", + "value": "#/$defs/Message/properties/id" + }, + { + "pointer": "/$defs/Message/properties/importance/enum/2", + "value": "high" + } + ], + "expected_pruned": [ + "/$defs/GiantUnusedPayload" + ], + "expected_dropped_fields": [] + } + ] +} diff --git a/codex-rs/tools/tests/fixtures/json_schema_policy/notion.json b/codex-rs/tools/tests/fixtures/json_schema_policy/notion.json new file mode 100644 index 000000000000..49a362d49e58 --- /dev/null +++ b/codex-rs/tools/tests/fixtures/json_schema_policy/notion.json @@ -0,0 +1,72 @@ +{ + "source": "golden/notion/tools", + "tools": [ + { + "name": "notion_fetch_page", + "description": "Fetch a Notion page.", + "input_schema": { + "type": "object", + "properties": { + "page": { + "$ref": "#/$defs/Page%20Ref" + }, + "filter": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/Filter" + } + }, + "content_mode": { + "description": "How much content to return.", + "anyOf": [ + { + "type": "string", + "enum": [ + "summary", + "full" + ] + }, + { + "type": "null" + } + ] + } + }, + "$defs": { + "Page Ref": { + "type": "string", + "description": "Notion page ID or URL." + }, + "Filter": { + "type": "object", + "description": "Filter object.", + "properties": { + "created_by": { + "type": "string", + "description": "Creator user ID." + } + } + }, + "UnusedDatabase": { + "type": "object", + "description": "Unreachable Notion database schema." + } + } + }, + "expected_preserved": [ + { + "pointer": "/properties/page/$ref", + "value": "#/$defs/Page%20Ref" + }, + { + "pointer": "/properties/content_mode/anyOf/0/enum/0", + "value": "summary" + } + ], + "expected_pruned": [ + "/$defs/UnusedDatabase" + ], + "expected_dropped_fields": [] + } + ] +} diff --git a/codex-rs/tools/tests/fixtures/json_schema_policy/oversized_notion_create_page_input_schema.json b/codex-rs/tools/tests/fixtures/json_schema_policy/oversized_notion_create_page_input_schema.json new file mode 100644 index 000000000000..b4957a1830bc --- /dev/null +++ b/codex-rs/tools/tests/fixtures/json_schema_policy/oversized_notion_create_page_input_schema.json @@ -0,0 +1,1124 @@ +{ + "source": "golden/notion/tools", + "tools": [ + { + "name": "create_page", + "description": "Create a Notion page.", + "input_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "create_page.input", + "type": "object", + "description": "Input schema for create_page.", + "$defs": { + "rich_text": { + "type": "object", + "description": "Notion rich text object. This schema captures the stable high-value fields and permits provider extensions.", + "properties": { + "type": { + "type": "string", + "description": "Rich text discriminator such as `text`, `mention`, or `equation`." + }, + "plain_text": { + "type": "string", + "description": "Flattened plain-text representation." + }, + "href": { + "type": [ + "string", + "null" + ], + "description": "Optional hyperlink target when present." + }, + "annotations": { + "type": "object", + "description": "Text annotations applied to this run.", + "properties": { + "bold": { + "type": "boolean" + }, + "italic": { + "type": "boolean" + }, + "strikethrough": { + "type": "boolean" + }, + "underline": { + "type": "boolean" + }, + "code": { + "type": "boolean" + }, + "color": { + "type": "string" + } + }, + "additionalProperties": true + }, + "text": { + "type": "object", + "description": "Text payload for `type=text` entries.", + "properties": { + "content": { + "type": "string" + }, + "link": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "url" + ], + "additionalProperties": false + } + ] + } + }, + "additionalProperties": true + } + }, + "required": [ + "type" + ], + "additionalProperties": true + }, + "icon": { + "type": "object", + "description": "Notion icon union. Keep provider variants explicit and allow future variants.", + "properties": { + "type": { + "type": "string", + "description": "Icon discriminator such as `emoji`, `external`, `file_upload`, `custom_emoji`, or `icon`." + }, + "emoji": { + "type": "string", + "description": "Emoji character for `type=emoji`." + }, + "name": { + "type": "string", + "description": "Named native icon or custom emoji name when applicable." + }, + "color": { + "type": "string", + "description": "Optional icon color for native icon variants." + }, + "external": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + } + }, + "additionalProperties": true + }, + "file_upload": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "additionalProperties": true + }, + "custom_emoji": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "additionalProperties": true + } + }, + "required": [ + "type" + ], + "additionalProperties": true + }, + "file_ref": { + "type": "object", + "description": "Notion file union used by cover and attachments.", + "properties": { + "type": { + "type": "string", + "description": "File discriminator such as `external`, `file`, or `file_upload`." + }, + "name": { + "type": "string" + }, + "external": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "url" + ], + "additionalProperties": true + }, + "file": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "expiry_time": { + "type": "string", + "format": "date-time" + } + }, + "additionalProperties": true + }, + "file_upload": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ], + "additionalProperties": true + } + }, + "required": [ + "type" + ], + "additionalProperties": true + }, + "parent": { + "type": "object", + "description": "Notion parent reference. The exact allowed variants depend on the endpoint.", + "properties": { + "type": { + "type": "string", + "description": "Parent discriminator such as `page_id`, `data_source_id`, `database_id`, `block_id`, or `workspace`." + }, + "page_id": { + "type": "string" + }, + "data_source_id": { + "type": "string" + }, + "database_id": { + "type": "string" + }, + "block_id": { + "type": "string" + }, + "workspace": { + "type": "boolean" + } + }, + "required": [ + "type" + ], + "additionalProperties": true + }, + "date_value": { + "type": "object", + "properties": { + "start": { + "type": "string", + "description": "ISO 8601 date or datetime string." + }, + "end": { + "type": [ + "string", + "null" + ] + }, + "time_zone": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "start" + ], + "additionalProperties": false + }, + "user": { + "type": "object", + "description": "Notion user or bot object.", + "properties": { + "object": { + "type": "string", + "const": "user" + }, + "id": { + "type": "string" + }, + "name": { + "type": [ + "string", + "null" + ] + }, + "avatar_url": { + "type": [ + "string", + "null" + ], + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "person", + "bot" + ] + }, + "person": { + "type": "object", + "properties": { + "email": { + "type": [ + "string", + "null" + ], + "format": "email" + } + }, + "additionalProperties": true + }, + "bot": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "object", + "id", + "type" + ], + "additionalProperties": true + }, + "property_value": { + "type": "object", + "description": "Provider-native page property value object. This schema captures common property families and leaves room for future Notion property variants.", + "properties": { + "type": { + "type": "string" + }, + "title": { + "type": "array", + "items": { + "$ref": "#/$defs/rich_text" + } + }, + "rich_text": { + "type": "array", + "items": { + "$ref": "#/$defs/rich_text" + } + }, + "number": { + "type": [ + "number", + "null" + ] + }, + "checkbox": { + "type": "boolean" + }, + "url": { + "type": [ + "string", + "null" + ], + "format": "uri" + }, + "email": { + "type": [ + "string", + "null" + ], + "format": "email" + }, + "phone_number": { + "type": [ + "string", + "null" + ] + }, + "date": { + "anyOf": [ + { + "$ref": "#/$defs/date_value" + }, + { + "type": "null" + } + ] + }, + "select": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "color": { + "type": "string" + } + }, + "additionalProperties": true + } + ] + }, + "status": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "color": { + "type": "string" + } + }, + "additionalProperties": true + } + ] + }, + "multi_select": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "color": { + "type": "string" + } + }, + "additionalProperties": true + } + }, + "people": { + "type": "array", + "items": { + "$ref": "#/$defs/user" + } + }, + "relation": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ], + "additionalProperties": false + } + }, + "files": { + "type": "array", + "items": { + "$ref": "#/$defs/file_ref" + } + }, + "created_time": { + "type": "string", + "format": "date-time" + }, + "last_edited_time": { + "type": "string", + "format": "date-time" + }, + "formula": { + "type": "object", + "additionalProperties": true + }, + "rollup": { + "type": "object", + "additionalProperties": true + }, + "verification": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "type" + ], + "additionalProperties": true + }, + "page": { + "type": "object", + "description": "Notion page object, modeled as a practical schema-authoring subset.", + "properties": { + "object": { + "type": "string", + "const": "page" + }, + "id": { + "type": "string" + }, + "created_time": { + "type": "string", + "format": "date-time" + }, + "last_edited_time": { + "type": "string", + "format": "date-time" + }, + "created_by": { + "$ref": "#/$defs/user" + }, + "last_edited_by": { + "$ref": "#/$defs/user" + }, + "cover": { + "anyOf": [ + { + "$ref": "#/$defs/file_ref" + }, + { + "type": "null" + } + ] + }, + "icon": { + "anyOf": [ + { + "$ref": "#/$defs/icon" + }, + { + "type": "null" + } + ] + }, + "parent": { + "$ref": "#/$defs/parent" + }, + "archived": { + "type": "boolean" + }, + "in_trash": { + "type": "boolean" + }, + "is_locked": { + "type": "boolean" + }, + "url": { + "type": "string", + "format": "uri" + }, + "properties": { + "type": "object", + "description": "Page property map keyed by property name.", + "additionalProperties": { + "$ref": "#/$defs/property_value" + } + } + }, + "required": [ + "object", + "id", + "parent", + "properties" + ], + "additionalProperties": true + }, + "text_block_payload": { + "type": "object", + "properties": { + "rich_text": { + "type": "array", + "items": { + "$ref": "#/$defs/rich_text" + } + }, + "color": { + "type": "string" + }, + "children": { + "type": "array", + "items": { + "$ref": "#/$defs/block" + } + } + }, + "additionalProperties": true + }, + "block": { + "type": "object", + "description": "Notion block object. The schema preserves the top-level discriminator and important typed payload families while allowing future provider expansion.", + "properties": { + "object": { + "type": "string", + "const": "block" + }, + "id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "has_children": { + "type": "boolean" + }, + "archived": { + "type": "boolean" + }, + "in_trash": { + "type": "boolean" + }, + "created_time": { + "type": "string", + "format": "date-time" + }, + "last_edited_time": { + "type": "string", + "format": "date-time" + }, + "created_by": { + "$ref": "#/$defs/user" + }, + "last_edited_by": { + "$ref": "#/$defs/user" + }, + "parent": { + "$ref": "#/$defs/parent" + }, + "paragraph": { + "$ref": "#/$defs/text_block_payload" + }, + "heading_1": { + "$ref": "#/$defs/text_block_payload" + }, + "heading_2": { + "$ref": "#/$defs/text_block_payload" + }, + "heading_3": { + "$ref": "#/$defs/text_block_payload" + }, + "heading_4": { + "$ref": "#/$defs/text_block_payload" + }, + "bulleted_list_item": { + "$ref": "#/$defs/text_block_payload" + }, + "numbered_list_item": { + "$ref": "#/$defs/text_block_payload" + }, + "toggle": { + "$ref": "#/$defs/text_block_payload" + }, + "to_do": { + "allOf": [ + { + "$ref": "#/$defs/text_block_payload" + }, + { + "type": "object", + "properties": { + "checked": { + "type": "boolean" + } + }, + "additionalProperties": true + } + ] + }, + "callout": { + "allOf": [ + { + "$ref": "#/$defs/text_block_payload" + }, + { + "type": "object", + "properties": { + "icon": { + "anyOf": [ + { + "$ref": "#/$defs/icon" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": true + } + ] + }, + "code": { + "type": "object", + "properties": { + "rich_text": { + "type": "array", + "items": { + "$ref": "#/$defs/rich_text" + } + }, + "caption": { + "type": "array", + "items": { + "$ref": "#/$defs/rich_text" + } + }, + "language": { + "type": "string" + } + }, + "additionalProperties": true + }, + "image": { + "type": "object", + "properties": { + "caption": { + "type": "array", + "items": { + "$ref": "#/$defs/rich_text" + } + }, + "type": { + "type": "string" + }, + "file": { + "$ref": "#/$defs/file_ref" + }, + "external": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + } + }, + "additionalProperties": true + } + }, + "additionalProperties": true + }, + "tab": { + "type": "object", + "additionalProperties": true + }, + "meeting_notes": { + "type": "object", + "additionalProperties": true + }, + "unsupported": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "object", + "id", + "type" + ], + "additionalProperties": true + }, + "property_schema": { + "type": "object", + "description": "Data source property schema entry.", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/rich_text" + } + } + ] + }, + "type": { + "type": "string" + }, + "number": { + "type": "object", + "additionalProperties": true + }, + "select": { + "type": "object", + "additionalProperties": true + }, + "multi_select": { + "type": "object", + "additionalProperties": true + }, + "status": { + "type": "object", + "additionalProperties": true + }, + "date": { + "type": "object", + "additionalProperties": true + }, + "people": { + "type": "object", + "additionalProperties": true + }, + "files": { + "type": "object", + "additionalProperties": true + }, + "checkbox": { + "type": "object", + "additionalProperties": true + }, + "url": { + "type": "object", + "additionalProperties": true + }, + "email": { + "type": "object", + "additionalProperties": true + }, + "phone_number": { + "type": "object", + "additionalProperties": true + }, + "formula": { + "type": "object", + "additionalProperties": true + }, + "relation": { + "type": "object", + "additionalProperties": true + }, + "rollup": { + "type": "object", + "additionalProperties": true + }, + "created_time": { + "type": "object", + "additionalProperties": true + }, + "last_edited_time": { + "type": "object", + "additionalProperties": true + }, + "created_by": { + "type": "object", + "additionalProperties": true + }, + "last_edited_by": { + "type": "object", + "additionalProperties": true + }, + "verification": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "type" + ], + "additionalProperties": true + }, + "data_source": { + "type": "object", + "description": "Notion data source object representing a table under a database.", + "properties": { + "object": { + "type": "string", + "const": "data_source" + }, + "id": { + "type": "string" + }, + "name": { + "type": [ + "string", + "null" + ] + }, + "title": { + "type": "array", + "items": { + "$ref": "#/$defs/rich_text" + } + }, + "icon": { + "anyOf": [ + { + "$ref": "#/$defs/icon" + }, + { + "type": "null" + } + ] + }, + "parent": { + "$ref": "#/$defs/parent" + }, + "url": { + "type": "string", + "format": "uri" + }, + "properties": { + "type": "object", + "description": "Schema map keyed by property name or id.", + "additionalProperties": { + "$ref": "#/$defs/property_schema" + } + }, + "in_trash": { + "type": "boolean" + } + }, + "required": [ + "object", + "id", + "parent" + ], + "additionalProperties": true + }, + "database": { + "type": "object", + "description": "Notion database container object.", + "properties": { + "object": { + "type": "string", + "const": "database" + }, + "id": { + "type": "string" + }, + "parent": { + "$ref": "#/$defs/parent" + }, + "title": { + "type": "array", + "items": { + "$ref": "#/$defs/rich_text" + } + }, + "description": { + "type": "array", + "items": { + "$ref": "#/$defs/rich_text" + } + }, + "icon": { + "anyOf": [ + { + "$ref": "#/$defs/icon" + }, + { + "type": "null" + } + ] + }, + "cover": { + "anyOf": [ + { + "$ref": "#/$defs/file_ref" + }, + { + "type": "null" + } + ] + }, + "is_inline": { + "type": "boolean" + }, + "is_locked": { + "type": "boolean" + }, + "in_trash": { + "type": "boolean" + }, + "url": { + "type": "string", + "format": "uri" + }, + "data_sources": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id" + ], + "additionalProperties": true + } + } + }, + "required": [ + "object", + "id", + "parent" + ], + "additionalProperties": true + }, + "comment": { + "type": "object", + "description": "Notion comment object.", + "properties": { + "object": { + "type": "string", + "const": "comment" + }, + "id": { + "type": "string" + }, + "discussion_id": { + "type": "string" + }, + "created_time": { + "type": "string", + "format": "date-time" + }, + "last_edited_time": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "created_by": { + "$ref": "#/$defs/user" + }, + "parent": { + "$ref": "#/$defs/parent" + }, + "rich_text": { + "type": "array", + "items": { + "$ref": "#/$defs/rich_text" + } + }, + "markdown": { + "type": "string" + }, + "attachments": { + "type": "array", + "items": { + "$ref": "#/$defs/file_ref" + } + } + }, + "required": [ + "object", + "id", + "discussion_id", + "created_by" + ], + "additionalProperties": true + } + }, + "properties": { + "parent": { + "$ref": "#/$defs/parent", + "description": "Request body field. Parent reference controlling whether the page is standalone or a row under a data source." + }, + "properties": { + "type": "object", + "description": "Request body field. Page property map keyed by property name. Values should follow the target data source schema when the parent is a data source.", + "additionalProperties": { + "$ref": "#/$defs/property_value" + } + }, + "children": { + "type": "array", + "description": "Request body field. Initial child blocks. Prefer this structured provider-native content form over markdown when exact block structure matters.", + "items": { + "$ref": "#/$defs/block" + } + }, + "icon": { + "$ref": "#/$defs/icon", + "description": "Request body field. Optional page icon." + }, + "cover": { + "$ref": "#/$defs/file_ref", + "description": "Request body field. Optional page cover image/file reference." + }, + "template": { + "type": "object", + "description": "Request body field. Optional template application settings. Template application may be asynchronous.", + "additionalProperties": true + }, + "markdown": { + "type": "string", + "description": "Request body field. Optional provider-supported markdown body. Included because Notion supports it, but block children remain the preferred structural primitive in this package." + } + }, + "additionalProperties": false, + "required": [ + "parent", + "properties" + ], + "not": { + "required": [ + "children", + "markdown" + ] + } + } + } + ] +} diff --git a/codex-rs/tools/tests/fixtures/json_schema_policy/slack.json b/codex-rs/tools/tests/fixtures/json_schema_policy/slack.json new file mode 100644 index 000000000000..7461da73ddbf --- /dev/null +++ b/codex-rs/tools/tests/fixtures/json_schema_policy/slack.json @@ -0,0 +1,75 @@ +{ + "source": "standard/slack/tools", + "tools": [ + { + "name": "slack_schedule_message", + "description": "Schedule a Slack message.", + "input_schema": { + "type": "object", + "properties": { + "channel_id": { + "type": "string", + "description": "Slack channel ID." + }, + "message": { + "type": "string", + "description": "Message text." + }, + "post_at": { + "type": "integer", + "description": "Unix timestamp for delivery." + }, + "thread_ts": { + "description": "Optional parent thread timestamp.", + "anyOf": [ + { + "$ref": "#/$defs/SlackTs" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "channel_id", + "message", + "post_at" + ], + "additionalProperties": false, + "$defs": { + "SlackTs": { + "type": "string", + "description": "Slack timestamp string.", + "pattern": "^[0-9]+[.][0-9]+$" + }, + "UnusedPayload": { + "type": "object", + "description": "Large unreachable Slack payload.", + "properties": { + "debug": { + "type": "string" + } + } + } + } + }, + "expected_preserved": [ + { + "pointer": "/properties/thread_ts/anyOf/0/$ref", + "value": "#/$defs/SlackTs" + }, + { + "pointer": "/$defs/SlackTs/description", + "value": "Slack timestamp string." + } + ], + "expected_pruned": [ + "/$defs/UnusedPayload" + ], + "expected_dropped_fields": [ + "/$defs/SlackTs/pattern" + ] + } + ] +} diff --git a/codex-rs/tools/tests/json_schema_policy_fixtures.rs b/codex-rs/tools/tests/json_schema_policy_fixtures.rs new file mode 100644 index 000000000000..1e244ade1edd --- /dev/null +++ b/codex-rs/tools/tests/json_schema_policy_fixtures.rs @@ -0,0 +1,224 @@ +use codex_tools::ToolName; +use codex_tools::mcp_tool_to_responses_api_tool; +use pretty_assertions::assert_eq; +use serde::Deserialize; +use serde::de::DeserializeOwned; +use serde_json::Value; +use serde_json::json; +use std::fs; +use std::sync::Arc; + +const FIXTURE_PATHS: [&str; 5] = [ + "tests/fixtures/json_schema_policy/slack.json", + "tests/fixtures/json_schema_policy/google_calendar.json", + "tests/fixtures/json_schema_policy/google_drive.json", + "tests/fixtures/json_schema_policy/notion.json", + "tests/fixtures/json_schema_policy/microsoft_outlook_email.json", +]; +const OVERSIZED_NOTION_CREATE_PAGE_SCHEMA_PATH: &str = + "tests/fixtures/json_schema_policy/oversized_notion_create_page_input_schema.json"; + +#[derive(Debug, Deserialize)] +struct FixtureFile { + source: String, + tools: Vec, +} + +#[derive(Debug, Deserialize)] +struct FixtureTool { + name: String, + description: String, + input_schema: Value, + #[serde(default)] + expected_preserved: Vec, + #[serde(default)] + expected_pruned: Vec, + #[serde(default)] + expected_dropped_fields: Vec, +} + +#[derive(Debug, Deserialize)] +struct ExpectedValue { + pointer: String, + value: Value, +} + +#[test] +fn json_schema_policy_fixtures_convert_to_responses_tools() { + for fixture in FIXTURE_PATHS.into_iter().map(load_fixture::) { + for fixture_tool in &fixture.tools { + let responses_tool = convert_fixture_tool(&fixture, fixture_tool); + let parameters = serde_json::to_value(&responses_tool.parameters) + .expect("responses parameters should serialize"); + + let expected_fields = [ + ( + "preserve the tool name", + json!(fixture_tool.name), + json!(responses_tool.name), + ), + ( + "preserve the tool description", + json!(fixture_tool.description), + json!(responses_tool.description), + ), + ( + "remain a strict:false tool", + json!(false), + json!(responses_tool.strict), + ), + ( + "produce object-shaped parameters", + json!("object"), + parameters.get("type").cloned().unwrap_or(Value::Null), + ), + ]; + + for (message, expected, actual) in expected_fields { + assert_eq!(actual, expected, "{} should {message}", fixture_tool.name); + } + assert!( + parameters.get("properties").is_some_and(Value::is_object), + "{} should produce a parameters.properties object", + fixture_tool.name + ); + + for expected in &fixture_tool.expected_preserved { + assert_eq!( + parameters.pointer(&expected.pointer), + Some(&expected.value), + "{} should preserve {}", + fixture_tool.name, + expected.pointer + ); + } + + for pointer in &fixture_tool.expected_pruned { + assert!( + parameters.pointer(pointer).is_none(), + "{} should prune unreachable definition {pointer}", + fixture_tool.name + ); + } + + for pointer in &fixture_tool.expected_dropped_fields { + assert!( + fixture_tool.input_schema.pointer(pointer).is_some(), + "{} fixture should contain expected dropped field {pointer}", + fixture_tool.name + ); + assert!( + parameters.pointer(pointer).is_none(), + "{} should drop field {pointer} after JsonSchema conversion", + fixture_tool.name + ); + } + } + } +} + +#[test] +fn json_schema_policy_oversized_golden_schema_triggers_compaction() { + let fixture: FixtureFile = load_fixture(OVERSIZED_NOTION_CREATE_PAGE_SCHEMA_PATH); + let fixture_tool = fixture + .tools + .first() + .expect("oversized fixture should contain a tool"); + let input_bytes = compact_json_len(&fixture_tool.input_schema); + + let responses_tool = convert_fixture_tool(&fixture, fixture_tool); + let parameters = + serde_json::to_value(&responses_tool.parameters).expect("responses parameters serialize"); + let output_bytes = compact_json_len(¶meters); + + assert!( + output_bytes < input_bytes, + "compaction should reduce schema size from {input_bytes} bytes" + ); + + let absent_pointers = [ + ("/description", "drop root description"), + ("/properties/parent/description", "drop nested descriptions"), + ( + "/$defs", + "drop root definitions after stripping descriptions is insufficient", + ), + ]; + for (pointer, message) in absent_pointers { + assert!( + parameters.pointer(pointer).is_none(), + "oversized schema should {message}" + ); + } + + let expected_values = [ + ( + "/properties/parent", + json!({}), + "rewrite local refs before dropping root definitions", + ), + ( + "/properties/children/items", + json!({}), + "rewrite nested local refs before dropping root definitions", + ), + ( + "/properties/markdown/type", + json!("string"), + "retain top-level argument shape", + ), + ( + "/properties/properties/type", + json!("object"), + "retain object argument shape", + ), + ]; + for (pointer, expected, message) in expected_values { + assert_eq!( + parameters.pointer(pointer), + Some(&expected), + "oversized schema should {message}" + ); + } +} + +fn load_fixture(path: &str) -> T { + let path = codex_utils_cargo_bin::find_resource!(path) + .unwrap_or_else(|err| panic!("resolve fixture {path}: {err}")); + let fixture = fs::read_to_string(&path) + .unwrap_or_else(|err| panic!("read fixture {}: {err}", path.display())); + serde_json::from_str(&fixture) + .unwrap_or_else(|err| panic!("parse fixture {}: {err}", path.display())) +} + +fn convert_fixture_tool( + fixture: &FixtureFile, + fixture_tool: &FixtureTool, +) -> codex_tools::ResponsesApiTool { + let name = &fixture_tool.name; + let input_schema = fixture_tool + .input_schema + .as_object() + .unwrap_or_else(|| panic!("{name} input_schema should be an object")) + .clone(); + let tool = rmcp::model::Tool { + name: name.to_string().into(), + title: None, + description: Some(fixture_tool.description.clone().into()), + input_schema: Arc::new(input_schema), + output_schema: None, + annotations: None, + execution: None, + icons: None, + meta: None, + }; + + mcp_tool_to_responses_api_tool(&ToolName::namespaced(&fixture.source, name), &tool) + .unwrap_or_else(|err| panic!("convert {name} from {}: {err}", fixture.source)) +} + +fn compact_json_len(value: &Value) -> usize { + serde_json::to_vec(value) + .unwrap_or_else(|err| panic!("serialize compact JSON: {err}")) + .len() +} From 6ad3a8350902f5825fe4172ef06cb5b506d10f3d Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Fri, 22 May 2026 16:46:25 -0700 Subject: [PATCH 59/64] [codex] Remove external client session reset plumbing (#24157) ## Why The turn loop no longer needs to decide when a `ModelClientSession` should reset its websocket state after compaction. That reset behavior belongs inside the model client, where the websocket cache and retry state are owned. The repo guidance now calls this out explicitly so future changes let the incremental request logic decide whether the previous request can be reused. ## What Changed - Removed the `reset_client_session` return value from pre-sampling and auto-compact helpers in `core/src/session/turn.rs`. - Changed compaction helpers to return `CodexResult<()>` so callers only handle success or failure. - Made `ModelClientSession::reset_websocket_session` private to `core/src/client.rs`, leaving it callable only from model-client internals. - Added `AGENTS.md` guidance not to call `reset_client_session` unnecessarily. ## Validation - `just test -p codex-core session::turn` --- AGENTS.md | 1 + codex-rs/core/src/client.rs | 2 +- codex-rs/core/src/session/turn.rs | 101 +++++++++++------------------- 3 files changed, 40 insertions(+), 64 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9906d3039a2f..4714b1b8aa43 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,7 @@ In the codex-rs folder where the rust code lives: - Prefer private modules and explicitly exported public crate API. - If you change `ConfigToml` or nested config types, run `just write-config-schema` to update `codex-rs/core/config.schema.json`. - When working with MCP tool calls, prefer using `codex-rs/codex-mcp/src/mcp_connection_manager.rs` to handle mutation of tools and tool calls. Aim to minimize the footprint of changes and leverage existing abstractions rather than plumbing code through multiple levels of function calls. +- Do not call `reset_client_session` unnecessarily; let the incremental check logic decide whether to reuse the previous request. - If you change Rust dependencies (`Cargo.toml` or `Cargo.lock`), run `just bazel-lock-update` from the repo root to refresh `MODULE.bazel.lock`, and include that lockfile update in the same change. - After dependency changes, run `just bazel-lock-check` from the repo root so lockfile drift is caught diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 07a1c2adf5e9..d84b32a8b4b1 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -938,7 +938,7 @@ impl Drop for ModelClientSession { } impl ModelClientSession { - pub(crate) fn reset_websocket_session(&mut self) { + fn reset_websocket_session(&mut self) { self.websocket_session.connection = None; self.websocket_session.last_request = None; self.websocket_session.last_response_rx = None; diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index f81b2b1f7429..5a2ed71dda0c 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -142,25 +142,18 @@ pub(crate) async fn run_turn( // new user message are recorded. Estimate pending incoming items (context // diffs/full reinjection + user input) and trigger compaction preemptively // when they would push the thread over the compaction threshold. - let pre_sampling_compact = - match run_pre_sampling_compact(&sess, &turn_context, &mut client_session).await { - Ok(pre_sampling_compact) => pre_sampling_compact, - Err(err) => { - if err.to_codex_protocol_error() == CodexErrorInfo::UsageLimitExceeded - && let Err(err) = sess - .goal_runtime_apply(GoalRuntimeEvent::UsageLimitReached { - turn_context: turn_context.as_ref(), - }) - .await - { - warn!("failed to usage-limit active goal after usage-limit error: {err}"); - } - error!("Failed to run pre-sampling compact"); - return None; - } - }; - if pre_sampling_compact.reset_client_session { - client_session.reset_websocket_session(); + if let Err(err) = run_pre_sampling_compact(&sess, &turn_context, &mut client_session).await { + if err.to_codex_protocol_error() == CodexErrorInfo::UsageLimitExceeded + && let Err(err) = sess + .goal_runtime_apply(GoalRuntimeEvent::UsageLimitReached { + turn_context: turn_context.as_ref(), + }) + .await + { + warn!("failed to usage-limit active goal after usage-limit error: {err}"); + } + error!("Failed to run pre-sampling compact"); + return None; } sess.record_context_updates_and_set_reference_context_item(turn_context.as_ref()) @@ -287,7 +280,7 @@ pub(crate) async fn run_turn( // as long as compaction works well in getting us way below the token limit, we shouldn't worry about being in an infinite loop. if token_limit_reached && needs_follow_up { - let reset_client_session = match run_auto_compact( + if let Err(err) = run_auto_compact( &sess, &turn_context, &mut client_session, @@ -297,24 +290,18 @@ pub(crate) async fn run_turn( ) .await { - Ok(reset_client_session) => reset_client_session, - Err(err) => { - if err.to_codex_protocol_error() == CodexErrorInfo::UsageLimitExceeded - && let Err(err) = sess - .goal_runtime_apply(GoalRuntimeEvent::UsageLimitReached { - turn_context: turn_context.as_ref(), - }) - .await - { - warn!( - "failed to usage-limit active goal after usage-limit error: {err}" - ); - } - return None; + if err.to_codex_protocol_error() == CodexErrorInfo::UsageLimitExceeded + && let Err(err) = sess + .goal_runtime_apply(GoalRuntimeEvent::UsageLimitReached { + turn_context: turn_context.as_ref(), + }) + .await + { + warn!( + "failed to usage-limit active goal after usage-limit error: {err}" + ); } - }; - if reset_client_session { - client_session.reset_websocket_session(); + return None; } can_drain_pending_input = !model_needs_follow_up; continue; @@ -636,10 +623,6 @@ async fn track_turn_resolved_config_analytics( }); } -struct PreSamplingCompactResult { - reset_client_session: bool, -} - #[derive(Debug)] struct AutoCompactTokenStatus { // Full active context usage, independent of the configured auto-compact scope. @@ -710,14 +693,12 @@ async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, client_session: &mut ModelClientSession, -) -> CodexResult { - let mut pre_sampling_compacted = - maybe_run_previous_model_inline_compact(sess, turn_context, client_session).await?; - let mut reset_client_session = pre_sampling_compacted; +) -> CodexResult<()> { + maybe_run_previous_model_inline_compact(sess, turn_context, client_session).await?; let token_status = auto_compact_token_status(sess.as_ref(), turn_context.as_ref()).await; // Compact if the configured auto-compaction budget or usable context window is exhausted. if token_status.token_limit_reached { - reset_client_session |= run_auto_compact( + run_auto_compact( sess, turn_context, client_session, @@ -726,26 +707,21 @@ async fn run_pre_sampling_compact( CompactionPhase::PreTurn, ) .await?; - pre_sampling_compacted = true; } - Ok(PreSamplingCompactResult { - reset_client_session: pre_sampling_compacted && reset_client_session, - }) + Ok(()) } /// Runs pre-sampling compaction against the previous model when switching to a smaller /// context-window model. /// -/// Returns `Ok(true)` when compaction ran successfully, `Ok(false)` when compaction was skipped -/// because the model/context-window preconditions were not met, and `Err(_)` only when compaction -/// was attempted and failed. +/// Returns `Err(_)` only when compaction was attempted and failed. async fn maybe_run_previous_model_inline_compact( sess: &Arc, turn_context: &Arc, client_session: &mut ModelClientSession, -) -> CodexResult { +) -> CodexResult<()> { let Some(previous_turn_settings) = sess.previous_turn_settings().await else { - return Ok(false); + return Ok(()); }; let previous_model_turn_context = Arc::new( turn_context @@ -754,10 +730,10 @@ async fn maybe_run_previous_model_inline_compact( ); let Some(old_context_window) = previous_model_turn_context.model_context_window() else { - return Ok(false); + return Ok(()); }; let Some(new_context_window) = turn_context.model_context_window() else { - return Ok(false); + return Ok(()); }; let active_context_tokens = sess.get_total_token_usage().await; let previous_model_limit_reached = match turn_context @@ -778,7 +754,7 @@ async fn maybe_run_previous_model_inline_compact( && previous_model_turn_context.model_info.slug != turn_context.model_info.slug && old_context_window > new_context_window; if should_run { - let _ = run_auto_compact( + run_auto_compact( sess, &previous_model_turn_context, client_session, @@ -787,9 +763,8 @@ async fn maybe_run_previous_model_inline_compact( CompactionPhase::PreTurn, ) .await?; - return Ok(true); } - Ok(false) + Ok(()) } async fn run_auto_compact( @@ -799,7 +774,7 @@ async fn run_auto_compact( initial_context_injection: InitialContextInjection, reason: CompactionReason, phase: CompactionPhase, -) -> CodexResult { +) -> CodexResult<()> { if should_use_remote_compact_task(turn_context.provider.info()) { if turn_context.features.enabled(Feature::RemoteCompactionV2) { run_inline_remote_auto_compact_task_v2( @@ -811,7 +786,7 @@ async fn run_auto_compact( phase, ) .await?; - return Ok(false); + return Ok(()); } run_inline_remote_auto_compact_task( Arc::clone(sess), @@ -831,7 +806,7 @@ async fn run_auto_compact( ) .await?; } - Ok(true) + Ok(()) } pub(super) fn collect_explicit_app_ids_from_skill_items( From 4bcabbfbec668de26bdb2b1c39f4748c419962ff Mon Sep 17 00:00:00 2001 From: dhruvgupta-oai Date: Fri, 22 May 2026 19:58:49 -0400 Subject: [PATCH 60/64] Display workspace usage limit error copy from response header (#24114) ## Why `openai/openai#947613` adds `X-Codex-Rate-Limit-Reached-Type` for Codex workspace credit-depletion and spend-cap responses. The CLI currently reads the adjacent promo header but otherwise renders generic usage-limit copy, so those responses do not explain the workspace-specific action the user needs to take. Backend dependency: https://github.com/openai/openai/pull/947613 ## What Changed - Parse `X-Codex-Rate-Limit-Reached-Type` in the usage-limit error handling path alongside `x-codex-promo-message`. - Keep the header value parsing with the shared `RateLimitReachedType` enum. - Carry the parsed type on `UsageLimitReachedError` and render client-owned copy for the four workspace owner/member credit and spend-cap values. - Preserve existing promo and plan-based text for absent, generic, or unknown header values. - Keep the existing TUI workspace-owner nudge state path unchanged; the response header only selects the displayed error string. - Add focused display coverage for all specific type values and the generic fallback case. ## Test Plan - Added `usage_limit_reached_error_formats_rate_limit_reached_types` coverage. - Not run manually, per request; CI runs validation on the pushed commit. --- codex-rs/codex-api/src/api_bridge.rs | 4 ++ codex-rs/codex-api/src/api_bridge_tests.rs | 31 +++++++++++++ codex-rs/codex-api/src/rate_limits.rs | 8 ++++ codex-rs/protocol/src/error.rs | 34 ++++++++++++++ codex-rs/protocol/src/error_tests.rs | 54 ++++++++++++++++++++++ codex-rs/protocol/src/protocol.rs | 15 ++++++ 6 files changed, 146 insertions(+) diff --git a/codex-rs/codex-api/src/api_bridge.rs b/codex-rs/codex-api/src/api_bridge.rs index 401dfab3a928..1c34d8bbf235 100644 --- a/codex-rs/codex-api/src/api_bridge.rs +++ b/codex-rs/codex-api/src/api_bridge.rs @@ -2,6 +2,7 @@ use crate::TransportError; use crate::error::ApiError; use crate::rate_limits::parse_promo_message; use crate::rate_limits::parse_rate_limit_for_limit; +use crate::rate_limits::parse_rate_limit_reached_type; use base64::Engine; use chrono::DateTime; use chrono::Utc; @@ -85,6 +86,8 @@ pub fn map_api_error(err: ApiError) -> CodexErr { parse_rate_limit_for_limit(map, limit_id.as_deref()) }); let promo_message = headers.as_ref().and_then(parse_promo_message); + let rate_limit_reached_type = + headers.as_ref().and_then(parse_rate_limit_reached_type); let resets_at = err .error .resets_at @@ -94,6 +97,7 @@ pub fn map_api_error(err: ApiError) -> CodexErr { resets_at, rate_limits: rate_limits.map(Box::new), promo_message, + rate_limit_reached_type, }); } else if err.error.error_type.as_deref() == Some("usage_not_included") { return CodexErr::UsageNotIncluded; diff --git a/codex-rs/codex-api/src/api_bridge_tests.rs b/codex-rs/codex-api/src/api_bridge_tests.rs index af7e34a6492f..101e5566fe22 100644 --- a/codex-rs/codex-api/src/api_bridge_tests.rs +++ b/codex-rs/codex-api/src/api_bridge_tests.rs @@ -194,6 +194,37 @@ fn map_api_error_does_not_fallback_limit_name_to_limit_id() { ); } +#[test] +fn map_api_error_ignores_unparseable_rate_limit_reached_type_headers() { + let values = [ + http::HeaderValue::from_static("future_rate_limit_reached_type"), + http::HeaderValue::from_bytes(&[0xff]).expect("valid opaque header value"), + ]; + + for value in values { + let mut headers = HeaderMap::new(); + headers.insert("x-codex-rate-limit-reached-type", value); + let body = serde_json::json!({ + "error": { + "type": "usage_limit_reached", + "plan_type": "pro", + } + }) + .to_string(); + let err = map_api_error(ApiError::Transport(TransportError::Http { + status: http::StatusCode::TOO_MANY_REQUESTS, + url: Some("http://example.com/v1/responses".to_string()), + headers: Some(headers), + body: Some(body), + })); + + let CodexErr::UsageLimitReached(usage_limit) = err else { + panic!("expected CodexErr::UsageLimitReached, got {err:?}"); + }; + assert_eq!(usage_limit.rate_limit_reached_type, None); + } +} + #[test] fn map_api_error_extracts_identity_auth_details_from_headers() { let mut headers = HeaderMap::new(); diff --git a/codex-rs/codex-api/src/rate_limits.rs b/codex-rs/codex-api/src/rate_limits.rs index 979500cdabc4..a2ad876671cf 100644 --- a/codex-rs/codex-api/src/rate_limits.rs +++ b/codex-rs/codex-api/src/rate_limits.rs @@ -1,5 +1,6 @@ use codex_protocol::account::PlanType; use codex_protocol::protocol::CreditsSnapshot; +use codex_protocol::protocol::RateLimitReachedType; use codex_protocol::protocol::RateLimitSnapshot; use codex_protocol::protocol::RateLimitWindow; use http::HeaderMap; @@ -178,6 +179,13 @@ pub fn parse_promo_message(headers: &HeaderMap) -> Option { .map(std::string::ToString::to_string) } +pub(crate) fn parse_rate_limit_reached_type(headers: &HeaderMap) -> Option { + parse_header_str(headers, "x-codex-rate-limit-reached-type")? + .trim() + .parse() + .ok() +} + fn parse_rate_limit_window( headers: &HeaderMap, used_percent_header: &str, diff --git a/codex-rs/protocol/src/error.rs b/codex-rs/protocol/src/error.rs index ef9c86cadf91..d7e953af0b75 100644 --- a/codex-rs/protocol/src/error.rs +++ b/codex-rs/protocol/src/error.rs @@ -7,6 +7,7 @@ use crate::exec_output::ExecToolCallOutput; use crate::network_policy::NetworkPolicyDecisionPayload; use crate::protocol::CodexErrorInfo; use crate::protocol::ErrorEvent; +use crate::protocol::RateLimitReachedType; use crate::protocol::RateLimitSnapshot; use crate::protocol::TruncationPolicy; use chrono::DateTime; @@ -451,6 +452,7 @@ pub struct UsageLimitReachedError { pub resets_at: Option>, pub rate_limits: Option>, pub promo_message: Option, + pub rate_limit_reached_type: Option, } impl std::fmt::Display for UsageLimitReachedError { @@ -470,6 +472,38 @@ impl std::fmt::Display for UsageLimitReachedError { ); } + if let Some(rate_limit_reached_type) = self.rate_limit_reached_type { + match rate_limit_reached_type { + RateLimitReachedType::WorkspaceOwnerCreditsDepleted => { + return write!( + f, + "Your workspace is out of credits. Add credits to continue." + ); + } + RateLimitReachedType::WorkspaceMemberCreditsDepleted => { + return write!( + f, + "Your workspace is out of credits. Ask your workspace owner to refill in order to continue." + ); + } + RateLimitReachedType::WorkspaceOwnerUsageLimitReached => { + return write!( + f, + "You hit your spend cap set in your workspace. Increase your spend cap to continue." + ); + } + RateLimitReachedType::WorkspaceMemberUsageLimitReached => { + return write!( + f, + "You hit your spend cap set by the owner of your workspace. Ask an owner to increase your spend cap to continue." + ); + } + RateLimitReachedType::RateLimitReached => { + // Generic limits intentionally use the existing promo or plan copy below. + } + } + } + if let Some(promo_message) = &self.promo_message { return write!( f, diff --git a/codex-rs/protocol/src/error_tests.rs b/codex-rs/protocol/src/error_tests.rs index aef7478607c6..11bd26133b88 100644 --- a/codex-rs/protocol/src/error_tests.rs +++ b/codex-rs/protocol/src/error_tests.rs @@ -56,6 +56,7 @@ fn usage_limit_reached_error_formats_plus_plan() { resets_at: None, rate_limits: Some(Box::new(rate_limit_snapshot())), promo_message: None, + rate_limit_reached_type: None, }; assert_eq!( err.to_string(), @@ -63,6 +64,44 @@ fn usage_limit_reached_error_formats_plus_plan() { ); } +#[test] +fn usage_limit_reached_error_formats_rate_limit_reached_types() { + let cases = [ + ( + RateLimitReachedType::RateLimitReached, + "You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro), visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again later.", + ), + ( + RateLimitReachedType::WorkspaceOwnerCreditsDepleted, + "Your workspace is out of credits. Add credits to continue.", + ), + ( + RateLimitReachedType::WorkspaceMemberCreditsDepleted, + "Your workspace is out of credits. Ask your workspace owner to refill in order to continue.", + ), + ( + RateLimitReachedType::WorkspaceOwnerUsageLimitReached, + "You hit your spend cap set in your workspace. Increase your spend cap to continue.", + ), + ( + RateLimitReachedType::WorkspaceMemberUsageLimitReached, + "You hit your spend cap set by the owner of your workspace. Ask an owner to increase your spend cap to continue.", + ), + ]; + + for (rate_limit_reached_type, expected) in cases { + let err = UsageLimitReachedError { + plan_type: Some(PlanType::Known(KnownPlan::Plus)), + resets_at: None, + rate_limits: Some(Box::new(rate_limit_snapshot())), + promo_message: None, + rate_limit_reached_type: Some(rate_limit_reached_type), + }; + + assert_eq!(err.to_string(), expected); + } +} + #[test] fn server_overloaded_maps_to_protocol() { let err = CodexErr::ServerOverloaded; @@ -177,6 +216,7 @@ fn usage_limit_reached_error_formats_free_plan() { resets_at: None, rate_limits: Some(Box::new(rate_limit_snapshot())), promo_message: None, + rate_limit_reached_type: None, }; assert_eq!( err.to_string(), @@ -191,6 +231,7 @@ fn usage_limit_reached_error_formats_go_plan() { resets_at: None, rate_limits: Some(Box::new(rate_limit_snapshot())), promo_message: None, + rate_limit_reached_type: None, }; assert_eq!( err.to_string(), @@ -205,6 +246,7 @@ fn usage_limit_reached_error_formats_default_when_none() { resets_at: None, rate_limits: Some(Box::new(rate_limit_snapshot())), promo_message: None, + rate_limit_reached_type: None, }; assert_eq!( err.to_string(), @@ -223,6 +265,7 @@ fn usage_limit_reached_error_formats_team_plan() { resets_at: Some(resets_at), rate_limits: Some(Box::new(rate_limit_snapshot())), promo_message: None, + rate_limit_reached_type: None, }; let expected = format!( "You've hit your usage limit. To get more access now, send a request to your admin or try again at {expected_time}." @@ -238,6 +281,7 @@ fn usage_limit_reached_error_formats_business_plan_without_reset() { resets_at: None, rate_limits: Some(Box::new(rate_limit_snapshot())), promo_message: None, + rate_limit_reached_type: None, }; assert_eq!( err.to_string(), @@ -252,6 +296,7 @@ fn usage_limit_reached_error_formats_self_serve_business_usage_based_plan() { resets_at: None, rate_limits: Some(Box::new(rate_limit_snapshot())), promo_message: None, + rate_limit_reached_type: None, }; assert_eq!( err.to_string(), @@ -266,6 +311,7 @@ fn usage_limit_reached_error_formats_enterprise_cbp_usage_based_plan() { resets_at: None, rate_limits: Some(Box::new(rate_limit_snapshot())), promo_message: None, + rate_limit_reached_type: None, }; assert_eq!( err.to_string(), @@ -280,6 +326,7 @@ fn usage_limit_reached_error_formats_default_for_other_plans() { resets_at: None, rate_limits: Some(Box::new(rate_limit_snapshot())), promo_message: None, + rate_limit_reached_type: None, }; assert_eq!( err.to_string(), @@ -298,6 +345,7 @@ fn usage_limit_reached_error_formats_pro_plan_with_reset() { resets_at: Some(resets_at), rate_limits: Some(Box::new(rate_limit_snapshot())), promo_message: None, + rate_limit_reached_type: None, }; let expected = format!( "You've hit your usage limit. Visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at {expected_time}." @@ -324,6 +372,7 @@ fn usage_limit_reached_error_hides_upsell_for_non_codex_limit_name() { "Visit https://chatgpt.com/codex/settings/usage to purchase more credits" .to_string(), ), + rate_limit_reached_type: None, }; let expected = format!( "You've hit your usage limit for codex_other. Switch to another model now, or try again at {expected_time}." @@ -343,6 +392,7 @@ fn usage_limit_reached_includes_minutes_when_available() { resets_at: Some(resets_at), rate_limits: Some(Box::new(rate_limit_snapshot())), promo_message: None, + rate_limit_reached_type: None, }; let expected = format!("You've hit your usage limit. Try again at {expected_time}."); assert_eq!(err.to_string(), expected); @@ -482,6 +532,7 @@ fn usage_limit_reached_includes_hours_and_minutes() { resets_at: Some(resets_at), rate_limits: Some(Box::new(rate_limit_snapshot())), promo_message: None, + rate_limit_reached_type: None, }; let expected = format!( "You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro), visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at {expected_time}." @@ -502,6 +553,7 @@ fn usage_limit_reached_includes_days_hours_minutes() { resets_at: Some(resets_at), rate_limits: Some(Box::new(rate_limit_snapshot())), promo_message: None, + rate_limit_reached_type: None, }; let expected = format!("You've hit your usage limit. Try again at {expected_time}."); assert_eq!(err.to_string(), expected); @@ -519,6 +571,7 @@ fn usage_limit_reached_less_than_minute() { resets_at: Some(resets_at), rate_limits: Some(Box::new(rate_limit_snapshot())), promo_message: None, + rate_limit_reached_type: None, }; let expected = format!("You've hit your usage limit. Try again at {expected_time}."); assert_eq!(err.to_string(), expected); @@ -538,6 +591,7 @@ fn usage_limit_reached_with_promo_message() { promo_message: Some( "To continue using Codex, start a free trial of today".to_string(), ), + rate_limit_reached_type: None, }; let expected = format!( "You've hit your usage limit. To continue using Codex, start a free trial of today, or try again at {expected_time}." diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 7d955f1d1f29..d3b8a9e820cb 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2000,6 +2000,21 @@ pub enum RateLimitReachedType { WorkspaceMemberUsageLimitReached, } +impl FromStr for RateLimitReachedType { + type Err = String; + + fn from_str(value: &str) -> Result { + match value { + "rate_limit_reached" => Ok(Self::RateLimitReached), + "workspace_owner_credits_depleted" => Ok(Self::WorkspaceOwnerCreditsDepleted), + "workspace_member_credits_depleted" => Ok(Self::WorkspaceMemberCreditsDepleted), + "workspace_owner_usage_limit_reached" => Ok(Self::WorkspaceOwnerUsageLimitReached), + "workspace_member_usage_limit_reached" => Ok(Self::WorkspaceMemberUsageLimitReached), + other => Err(format!("unknown rate limit reached type: {other}")), + } + } +} + #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct RateLimitWindow { /// Percentage (0-100) of the window that has been consumed. From ed47f1ab1eba595df81e7ce62568b83328d9c42a Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Fri, 22 May 2026 17:08:59 -0700 Subject: [PATCH 61/64] release: build macOS x64 zsh artifact (#24165) ## Why The zsh release workflow currently publishes macOS arm64 and Linux zsh fork artifacts, but no macOS x64 artifact. The Codex package builder therefore cannot include codex-resources/zsh/bin/zsh for x86_64-apple-darwin packages. ## What Changed - Added an x86_64-apple-darwin row to the macOS zsh release matrix. - Runs that row on macos-15-large, the Intel macOS runner appropriate for the native zsh build. - Added the matching macos-x86_64 platform to the zsh DotSlash publish config so the generated release manifest can reference the new tarball. --- .github/dotslash-zsh-config.json | 5 +++++ .github/workflows/rust-release-zsh.yml | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/.github/dotslash-zsh-config.json b/.github/dotslash-zsh-config.json index db2c41640154..37285f19e24c 100644 --- a/.github/dotslash-zsh-config.json +++ b/.github/dotslash-zsh-config.json @@ -7,6 +7,11 @@ "format": "tar.gz", "path": "codex-zsh/bin/zsh" }, + "macos-x86_64": { + "name": "codex-zsh-x86_64-apple-darwin.tar.gz", + "format": "tar.gz", + "path": "codex-zsh/bin/zsh" + }, "linux-x86_64": { "name": "codex-zsh-x86_64-unknown-linux-musl.tar.gz", "format": "tar.gz", diff --git a/.github/workflows/rust-release-zsh.yml b/.github/workflows/rust-release-zsh.yml index 492b8dc5e75d..b55d2e714bcf 100644 --- a/.github/workflows/rust-release-zsh.yml +++ b/.github/workflows/rust-release-zsh.yml @@ -69,6 +69,10 @@ jobs: fail-fast: false matrix: include: + - runner: macos-15-large + target: x86_64-apple-darwin + variant: macos-15 + archive_name: codex-zsh-x86_64-apple-darwin.tar.gz - runner: macos-15-xlarge target: aarch64-apple-darwin variant: macos-15 From b11a7c17278e819917152997b0c038e3bfc44545 Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Fri, 22 May 2026 17:13:54 -0700 Subject: [PATCH 62/64] Release 0.134.0-alpha.3 --- codex-rs/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 95c065c74bdf..b75cd1ba5265 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -117,7 +117,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.0.0" +version = "0.134.0-alpha.3" # Track the edition for all workspace crates in one place. Individual # crates can still override this value, but keeping it here means new # crates created with `cargo new -w ...` automatically inherit the 2024 From 8a8bb2155303c2c212344906795b7c034f875fe1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 23 May 2026 01:17:37 +0000 Subject: [PATCH 63/64] Seed Termux release automation --- .github/workflows/rust-release.yml | 1807 +++++++---------- .github/workflows/shell-tool-mcp.yml | 461 +++++ .../workflows/termux-release-checkpoint.yml | 103 + .github/workflows/termux-release-deploy.yml | 243 +++ .github/workflows/termux-release-promote.yml | 135 ++ scripts/termux-configure-git.sh | 40 + scripts/termux-create-checkpoint-pr.sh | 275 +++ ...ermux-create-or-update-mirrored-release.sh | 111 + scripts/termux-download-release-artifact.sh | 78 + scripts/termux-find-release-pr.sh | 53 + scripts/termux-read-release-metadata.sh | 77 + scripts/termux-release-asset-state.sh | 37 + scripts/termux-release-paths.sh | 55 + scripts/termux-resolve-release-ref.sh | 36 + scripts/termux-validate-gh-env.sh | 16 + 15 files changed, 2445 insertions(+), 1082 deletions(-) create mode 100644 .github/workflows/shell-tool-mcp.yml create mode 100644 .github/workflows/termux-release-checkpoint.yml create mode 100644 .github/workflows/termux-release-deploy.yml create mode 100644 .github/workflows/termux-release-promote.yml create mode 100755 scripts/termux-configure-git.sh create mode 100755 scripts/termux-create-checkpoint-pr.sh create mode 100755 scripts/termux-create-or-update-mirrored-release.sh create mode 100755 scripts/termux-download-release-artifact.sh create mode 100755 scripts/termux-find-release-pr.sh create mode 100755 scripts/termux-read-release-metadata.sh create mode 100755 scripts/termux-release-asset-state.sh create mode 100755 scripts/termux-release-paths.sh create mode 100755 scripts/termux-resolve-release-ref.sh create mode 100755 scripts/termux-validate-gh-env.sh diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 2cd11e66fe0f..56d0977e6e67 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -4,46 +4,49 @@ # git tag -a rust-v0.1.0 -m "Release 0.1.0" # git push origin rust-v0.1.0 # ``` -# -# To use external macOS signing, manually dispatch `release_mode=build_unsigned`, -# sign the unsigned macOS artifacts in a secure enclave, upload the signed handoff -# archive as a GitHub Release asset, then manually dispatch -# `release_mode=promote_signed` with `unsigned_run_id` and `signed_macos_asset`. -# The signed handoff archive should contain target or artifact directories such -# as `aarch64-apple-darwin/` with signed binaries. name: rust-release on: - push: - tags: - - "rust-v*.*.*" + pull_request: + branches: + - "release/**" workflow_dispatch: inputs: - release_mode: - description: "build_unsigned creates unsigned macOS handoff artifacts; promote_signed finishes a release from signed macOS handoff artifacts." + source_ref: + description: "Branch, tag, or commit SHA to build (code only; keeps .github from dispatched ref)" required: false + type: string + default: "" + build_target: + description: "Target to build" + required: true type: choice - default: build_unsigned + default: all options: - - build_unsigned - - promote_signed - sign_macos: - description: "Deprecated compatibility input; use release_mode instead." - required: false - type: boolean - default: false - unsigned_run_id: - description: "For promote_signed: workflow run id from the build_unsigned run." - required: false - type: string - signed_macos_asset: - description: "For promote_signed: exact GitHub Release asset name containing signed macOS handoff artifacts." - required: false - type: string - signed_macos_sha256: - description: "For promote_signed: optional SHA-256 of signed_macos_asset." - required: false - type: string + - all + - aarch64-apple-darwin + - x86_64-apple-darwin + - x86_64-unknown-linux-musl + - x86_64-unknown-linux-gnu + - aarch64-unknown-linux-musl + - aarch64-unknown-linux-gnu + - aarch64-linux-android + - x86_64-pc-windows-msvc + - aarch64-pc-windows-msvc + +permissions: + actions: read + attestations: read + checks: read + contents: read + deployments: read + issues: read + discussions: read + packages: read + pages: read + pull-requests: read + repository-projects: read + statuses: read concurrency: group: ${{ github.workflow }} @@ -51,72 +54,26 @@ concurrency: jobs: tag-check: + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: actions/checkout@v6 + - name: 🧰 Actions Toolbox + # This is required for the GitHub CLI + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: wallentx/gh-actions/composite/actions-toolbox@main + - uses: dtolnay/rust-toolchain@1.92 - name: Validate tag matches Cargo.toml version shell: bash - env: - RELEASE_MODE: ${{ github.event_name == 'workflow_dispatch' && inputs.release_mode || 'signed' }} - REQUESTED_SIGN_MACOS: ${{ inputs.sign_macos }} - SIGNED_MACOS_ASSET: ${{ inputs.signed_macos_asset }} - UNSIGNED_RUN_ID: ${{ inputs.unsigned_run_id }} run: | set -euo pipefail echo "::group::Tag validation" - case "${RELEASE_MODE}" in - signed) - if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then - echo "❌ Manual rust-release runs must use release_mode=build_unsigned or release_mode=promote_signed" - exit 1 - fi - ;; - build_unsigned) - if [[ "${GITHUB_EVENT_NAME}" != "workflow_dispatch" ]]; then - echo "❌ release_mode=build_unsigned is only valid for manual runs" - exit 1 - fi - ;; - promote_signed) - if [[ "${GITHUB_EVENT_NAME}" != "workflow_dispatch" ]]; then - echo "❌ release_mode=promote_signed is only valid for manual runs" - exit 1 - fi - if [[ ! "${UNSIGNED_RUN_ID}" =~ ^[0-9]+$ ]]; then - echo "❌ release_mode=promote_signed requires unsigned_run_id to be a workflow run id" - exit 1 - fi - if [[ -z "${SIGNED_MACOS_ASSET}" ]]; then - echo "❌ release_mode=promote_signed requires signed_macos_asset" - exit 1 - fi - if [[ "${SIGNED_MACOS_ASSET}" == */* || "${SIGNED_MACOS_ASSET}" == *"*"* || "${SIGNED_MACOS_ASSET}" == *"?"* || "${SIGNED_MACOS_ASSET}" == *"["* ]]; then - echo "❌ signed_macos_asset must be an exact release asset name, not a path or glob" - exit 1 - fi - if [[ "${UNSIGNED_RUN_ID}" == "${GITHUB_RUN_ID}" ]]; then - echo "❌ unsigned_run_id must refer to the earlier build_unsigned run, not this run" - exit 1 - fi - ;; - *) - echo "❌ Unknown release_mode '${RELEASE_MODE}'" - exit 1 - ;; - esac - - if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${REQUESTED_SIGN_MACOS}" == "true" ]]; then - echo "::warning title=Deprecated sign_macos input ignored::Use release_mode=build_unsigned or release_mode=promote_signed instead." - fi - # 1. Must be a tag and match the regex [[ "${GITHUB_REF_TYPE}" == "tag" ]] \ || { echo "❌ Not a tag push"; exit 1; } - [[ "${GITHUB_REF_NAME}" =~ ^rust-v[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta)(\.[0-9]+)?)?$ ]] \ + [[ "${GITHUB_REF_NAME}" =~ ^rust-v[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta)(\.[0-9]+)?)?(-termux)?$ ]] \ || { echo "❌ Tag '${GITHUB_REF_NAME}' doesn't match expected format"; exit 1; } # 2. Extract versions @@ -131,115 +88,198 @@ jobs: echo "✅ Tag and Cargo.toml agree (${tag_ver})" echo "::endgroup::" - build: - if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} + select-build-matrix: + if: always() && (github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' || needs.tag-check.result == 'success') needs: tag-check - name: Build - ${{ matrix.runner }} - ${{ matrix.target }} - ${{ matrix.bundle }} - runs-on: ${{ matrix.runs_on || matrix.runner }} - # Release builds can take a long time, so leave some headroom to avoid - # having to restart the full workflow due to a timeout. - timeout-minutes: 90 - permissions: - contents: read - id-token: write + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.select.outputs.matrix }} + build_version: ${{ steps.select.outputs.build_version }} + steps: + - uses: actions/checkout@v6 + - name: 🧰 Actions Toolbox + # This is required for the GitHub CLI + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: wallentx/gh-actions/composite/actions-toolbox@main + - id: select + shell: bash + env: + REPO_OWNER: ${{ github.repository_owner }} + EVENT_NAME: ${{ github.event_name }} + BUILD_TARGET: ${{ inputs.build_target }} + run: | + set -euo pipefail + + matrix='[ + {"runner":"macos-15-xlarge","target":"aarch64-apple-darwin"}, + {"runner":"macos-15-xlarge","target":"x86_64-apple-darwin"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-gnu"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-gnu"}, + {"runner":"ubuntu-24.04","target":"aarch64-linux-android"}, + {"runner":"windows-latest","target":"x86_64-pc-windows-msvc"}, + {"runner":"windows-11-arm","target":"aarch64-pc-windows-msvc"} + ]' + + if [[ "${REPO_OWNER}" != "openai" ]]; then + matrix="$(jq -c '[.[] | select(.runner | startswith("macos") | not)]' <<< "${matrix}")" + fi + + if [[ "${EVENT_NAME}" == "pull_request" ]]; then + matrix="$(jq -c '[.[] | select(.target == "aarch64-linux-android")]' <<< "${matrix}")" + elif [[ "${EVENT_NAME}" == "workflow_dispatch" && -n "${BUILD_TARGET}" && "${BUILD_TARGET}" != "all" ]]; then + matrix="$(jq -c --arg build_target "${BUILD_TARGET}" '[.[] | select(.target == $build_target)]' <<< "${matrix}")" + fi + + if [[ "$(jq 'length' <<< "${matrix}")" -eq 0 ]]; then + echo "No build targets selected after applying owner/dispatch filters." >&2 + exit 1 + fi + + echo "matrix={\"include\":${matrix}}" >> "$GITHUB_OUTPUT" + + if [[ "${EVENT_NAME}" == "pull_request" ]]; then + metadata=".github/termux-release.json" + if [[ ! -f "${metadata}" ]]; then + echo "${metadata} is required for release train PR builds" >&2 + exit 1 + fi + upstream_tag="$(jq -r '.upstream_tag // empty' "${metadata}")" + if [[ -z "${upstream_tag}" || "${upstream_tag}" != rust-v* ]]; then + echo "Unable to determine upstream rust tag from ${metadata}" >&2 + exit 1 + fi + build_version="${upstream_tag#rust-v}" + echo "build_version=${build_version}" >> "$GITHUB_OUTPUT" + elif [[ "${EVENT_NAME}" == "workflow_dispatch" ]]; then + latest_release_tag="$( + GH_TOKEN="${{ github.token }}" gh api repos/openai/codex/releases/latest --jq '.tag_name' + )" + + if [[ -z "${latest_release_tag}" || "${latest_release_tag}" != rust-v* ]]; then + echo "Unable to determine latest stable release tag from openai/codex" >&2 + exit 1 + fi + + latest_release_version="${latest_release_tag#rust-v}" + build_version="${latest_release_version}-dev" + echo "build_version=${build_version}" >> "$GITHUB_OUTPUT" + fi + + build: + if: >- + always() && + (github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' || needs.tag-check.result == 'success') && + needs.select-build-matrix.result == 'success' + needs: + - tag-check + - select-build-matrix + name: Build - ${{ matrix.runner }} - ${{ matrix.target }} + runs-on: ${{ matrix.runner }} + permissions: write-all + timeout-minutes: 120 defaults: run: working-directory: codex-rs env: - # 2026-03-04: temporarily change releases to use thin LTO because - # Ubuntu ARM is timing out at 60 minutes. - CARGO_PROFILE_RELEASE_LTO: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'thin' }} - SIGN_MACOS: ${{ github.event_name != 'workflow_dispatch' }} + CODEX_BWRAP_ENABLE_FFI: ${{ contains(matrix.target, 'unknown-linux') && '1' || '0' }} + CODEX_BUILD_VERSION: ${{ needs.select-build-matrix.outputs.build_version }} + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" strategy: fail-fast: false - matrix: - include: - - runner: macos-15-xlarge - target: aarch64-apple-darwin - bundle: primary - artifact_name: aarch64-apple-darwin - binaries: "codex codex-responses-api-proxy" - build_dmg: "true" - - runner: macos-15-xlarge - target: aarch64-apple-darwin - bundle: app-server - artifact_name: aarch64-apple-darwin-app-server - binaries: "codex-app-server" - build_dmg: "false" - - runner: macos-15-xlarge - target: x86_64-apple-darwin - bundle: primary - artifact_name: x86_64-apple-darwin - binaries: "codex codex-responses-api-proxy" - build_dmg: "true" - - runner: macos-15-xlarge - target: x86_64-apple-darwin - bundle: app-server - artifact_name: x86_64-apple-darwin-app-server - binaries: "codex-app-server" - build_dmg: "false" - # Release artifacts intentionally ship MUSL-linked Linux binaries. - - runner: codex-linux-x64-xl - target: x86_64-unknown-linux-musl - bundle: primary - artifact_name: x86_64-unknown-linux-musl - binaries: "codex codex-responses-api-proxy bwrap" - build_dmg: "false" - - runner: codex-linux-x64-xl - target: x86_64-unknown-linux-musl - bundle: app-server - artifact_name: x86_64-unknown-linux-musl-app-server - binaries: "codex-app-server" - build_dmg: "false" - - runner: codex-linux-arm64 - target: aarch64-unknown-linux-musl - bundle: primary - artifact_name: aarch64-unknown-linux-musl - binaries: "codex codex-responses-api-proxy bwrap" - build_dmg: "false" - - runner: codex-linux-arm64 - target: aarch64-unknown-linux-musl - bundle: app-server - artifact_name: aarch64-unknown-linux-musl-app-server - binaries: "codex-app-server" - build_dmg: "false" + matrix: ${{ fromJson(needs.select-build-matrix.outputs.matrix) }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@v6 + - name: 🧰 Actions Toolbox + # This is required for the GitHub CLI + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: wallentx/gh-actions/composite/actions-toolbox@main + with: + verbose: true + - name: Checkout selected source ref + if: ${{ github.event_name == 'workflow_dispatch' && inputs.source_ref != '' }} + uses: actions/checkout@v6 with: - persist-credentials: false - - name: Print runner specs (Linux) - if: ${{ runner.os == 'Linux' }} + ref: ${{ inputs.source_ref }} + path: __source__ + fetch-depth: 1 + - name: Overlay selected source ref (excluding .github) + if: ${{ github.event_name == 'workflow_dispatch' && inputs.source_ref != '' }} + shell: bash + working-directory: ${{ github.workspace }} + run: | + set -euo pipefail + echo "Using source ref: ${{ inputs.source_ref }}" + + # Keep workflow/actions from the dispatch ref while replacing all other files. + find . -mindepth 1 -maxdepth 1 \ + ! -name .git \ + ! -name .github \ + ! -name __source__ \ + -exec rm -rf -- {} + + tar --exclude='.github' -C __source__ -cf - . | tar -xf - + rm -rf __source__ + + - name: Apply selected build version + if: ${{ github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' }} shell: bash + working-directory: ${{ github.workspace }} run: | set -euo pipefail - cpu_model="$(lscpu | awk -F: '/Model name/ {gsub(/^[ \t]+/, "", $2); print $2; exit}')" - total_ram="$(awk '/MemTotal/ {printf "%.1f GiB\n", $2 / 1024 / 1024}' /proc/meminfo)" - echo "Runner: ${RUNNER_NAME:-unknown}" - echo "OS: $(uname -a)" - echo "CPU model: ${cpu_model}" - echo "Logical CPUs: $(nproc)" - echo "Total RAM: ${total_ram}" - echo "Disk usage:" - df -h . - - name: Print runner specs (macOS) - if: ${{ runner.os == 'macOS' }} + if [[ -z "${CODEX_BUILD_VERSION}" ]]; then + echo "CODEX_BUILD_VERSION is empty for workflow_dispatch" >&2 + exit 1 + fi + echo "Using build version: ${CODEX_BUILD_VERSION}" + BUILD_VERSION="${CODEX_BUILD_VERSION}" perl -0777 -i -pe \ + 's/(\[workspace\.package\][^\[]*?version = ")([^"]+)(")/${1}$ENV{BUILD_VERSION}$3/s' \ + codex-rs/Cargo.toml + + grep -n "^\[workspace.package\]\|^version = " codex-rs/Cargo.toml | head -n 4 + + - name: Select Android NDK + if: ${{ matrix.target == 'aarch64-linux-android' }} shell: bash run: | set -euo pipefail - total_ram="$(sysctl -n hw.memsize | awk '{printf "%.1f GiB\n", $1 / 1024 / 1024 / 1024}')" - echo "Runner: ${RUNNER_NAME:-unknown}" - echo "OS: $(sw_vers -productName) $(sw_vers -productVersion)" - echo "Hardware model: $(sysctl -n hw.model)" - echo "CPU architecture: $(uname -m)" - echo "Logical CPUs: $(sysctl -n hw.logicalcpu)" - echo "Physical CPUs: $(sysctl -n hw.physicalcpu)" - echo "Total RAM: ${total_ram}" - echo "Disk usage:" - df -h . + + fallback_version="29.0.13113456" + ndk_root="${ANDROID_HOME}/ndk" + selected_ndk="" + + if [[ -d "${ndk_root}" ]]; then + while IFS= read -r candidate; do + toolchain="${candidate}/toolchains/llvm/prebuilt/linux-x86_64" + if [[ -x "${toolchain}/bin/aarch64-linux-android24-clang" ]]; then + selected_ndk="${candidate}" + break + fi + done < <(find "${ndk_root}" -mindepth 1 -maxdepth 1 -type d | sort -Vr) + fi + + if [[ -z "${selected_ndk}" ]]; then + echo "No usable preinstalled Android NDK found; installing ${fallback_version}" + yes | sudo "${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager" --licenses || true + sudo "${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager" "ndk;${fallback_version}" + selected_ndk="${ndk_root}/${fallback_version}" + fi + + if [[ ! -x "${selected_ndk}/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android24-clang" ]]; then + echo "Selected Android NDK does not contain a usable aarch64 API 24 clang: ${selected_ndk}" >&2 + exit 1 + fi + + echo "Selected Android NDK: ${selected_ndk}" + "${selected_ndk}/toolchains/llvm/prebuilt/linux-x86_64/bin/clang" --version + echo "ANDROID_NDK_HOME=${selected_ndk}" >> "$GITHUB_ENV" + echo "NDK_HOME=${selected_ndk}" >> "$GITHUB_ENV" - name: Install Linux bwrap build dependencies - if: ${{ runner.os == 'Linux' }} + if: ${{ runner.os == 'Linux' && matrix.target != 'aarch64-linux-android' }} shell: bash run: | set -euo pipefail @@ -254,9 +294,173 @@ jobs: sudo apt-get update -y sudo DEBIAN_FRONTEND=noninteractive apt-get install -y libubsan1 fi - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: dtolnay/rust-toolchain@1.93 with: targets: ${{ matrix.target }} + - name: Set up sccache (Android) + if: ${{ matrix.target == 'aarch64-linux-android' }} + uses: mozilla-actions/sccache-action@main + - name: Enable sccache (Android) + if: ${{ matrix.target == 'aarch64-linux-android' }} + shell: bash + run: | + set -euo pipefail + echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV" + echo "SCCACHE_GHA_ENABLED=true" >> "$GITHUB_ENV" + echo "SCCACHE_GHA_VERSION=android-release-${{ matrix.target }}" >> "$GITHUB_ENV" + echo "SCCACHE_CACHE_SIZE=5G" >> "$GITHUB_ENV" + - name: Ensure Rust target is installed + shell: bash + run: | + set -euo pipefail + rustup target add "${{ matrix.target }}" + - if: ${{ matrix.target == 'aarch64-linux-android' }} + name: Configure Android build environment + shell: bash + run: | + set -euo pipefail + ndk="${ANDROID_NDK_HOME}" + target="${{ matrix.target }}" + + # Set up the Android toolchain + toolchain="${ndk}/toolchains/llvm/prebuilt/linux-x86_64" + + # Use API level 24 to follow termux-build convention + api_level="24" + + # Configure Cargo to use the NDK linker + cargo_target_var="CARGO_TARGET_${target^^}_LINKER" + cargo_target_var="${cargo_target_var//-/_}" + echo "${cargo_target_var}=${toolchain}/bin/aarch64-linux-android${api_level}-clang" >> "$GITHUB_ENV" + + # Set CC and AR for the target + target_cc_var="CC_${target}" + target_cc_var="${target_cc_var//-/_}" + echo "${target_cc_var}=${toolchain}/bin/aarch64-linux-android${api_level}-clang" >> "$GITHUB_ENV" + + target_cxx_var="CXX_${target}" + target_cxx_var="${target_cxx_var//-/_}" + echo "${target_cxx_var}=${toolchain}/bin/aarch64-linux-android${api_level}-clang++" >> "$GITHUB_ENV" + + target_ar_var="AR_${target}" + target_ar_var="${target_ar_var//-/_}" + echo "${target_ar_var}=${toolchain}/bin/llvm-ar" >> "$GITHUB_ENV" + + # Add toolchain to PATH + echo "${toolchain}/bin" >> "$GITHUB_PATH" + + tls_align_source="${RUNNER_TEMP}/codex-android-tls-align.S" + tls_align_object="${RUNNER_TEMP}/codex-android-tls-align.o" + cat > "${tls_align_source}" <<'EOF' + .section .tdata.codex_android_tls_align,"awT",%progbits + .p2align 6 + .globl __codex_android_tls_align + .hidden __codex_android_tls_align + .type __codex_android_tls_align, %object + __codex_android_tls_align: + .byte 0 + .size __codex_android_tls_align, 1 + EOF + "${toolchain}/bin/aarch64-linux-android${api_level}-clang" -c "${tls_align_source}" -o "${tls_align_object}" + + v8_compat_source="${RUNNER_TEMP}/codex-android-v8-compat.c" + v8_compat_object="${RUNNER_TEMP}/codex-android-v8-compat.o" + cat > "${v8_compat_source}" <<'EOF' + #include + #include + + extern int posix_memalign(void **memptr, size_t alignment, size_t size); + extern double strtod(const char *nptr, char **endptr); + extern float strtof(const char *nptr, char **endptr); + + void *aligned_alloc(size_t alignment, size_t size) { + void *ptr = 0; + if (posix_memalign(&ptr, alignment, size) != 0) { + return 0; + } + return ptr; + } + + double strtod_l(const char *nptr, char **endptr, locale_t locale) { + (void)locale; + return strtod(nptr, endptr); + } + + float strtof_l(const char *nptr, char **endptr, locale_t locale) { + (void)locale; + return strtof(nptr, endptr); + } + EOF + "${toolchain}/bin/aarch64-linux-android${api_level}-clang" -c "${v8_compat_source}" -o "${v8_compat_object}" + + builtins_archive="$( + find "${toolchain}/lib/clang" \ + -path "*/lib/linux/libclang_rt.builtins-aarch64-android.a" \ + -print -quit + )" + if [[ -z "${builtins_archive}" ]]; then + echo "Could not find Android compiler-rt builtins archive under ${toolchain}/lib/clang" >&2 + exit 1 + fi + builtins_dir="$(dirname "${builtins_archive}")" + + # Provide compatibility symbols and C++ ABI needed by the Android rusty_v8 archive. + rustflags_var="CARGO_TARGET_${target^^}_RUSTFLAGS" + rustflags_var="${rustflags_var//-/_}" + echo "${rustflags_var}=-C link-arg=${tls_align_object} -C link-arg=${v8_compat_object} -C link-arg=-Wl,-u,__codex_android_tls_align -C link-arg=-L${builtins_dir} -C link-arg=-lclang_rt.builtins-aarch64-android -C link-arg=-lc++abi -C link-arg=-Wl,-z,max-page-size=65536" >> "$GITHUB_ENV" + + + # Configure for vendored OpenSSL build + echo "CARGO_BUILD_TARGET_APPLIES_TO_HOST=false" >> "$GITHUB_ENV" + echo "CARGO_BUILD_TARGET=${{ matrix.target }}" >> "$GITHUB_ENV" + + - if: ${{ matrix.target == 'aarch64-linux-android' }} + name: Configure Android rusty_v8 artifact overrides + env: + GH_TOKEN: ${{ github.token }} + TARGET: ${{ matrix.target }} + shell: bash + run: | + set -euo pipefail + binding_dir="${RUNNER_TEMP}/rusty_v8" + archive="${binding_dir}/librusty_v8_release_${TARGET}.a.gz" + binding_path="${binding_dir}/src_binding_release_${TARGET}.rs" + checksums_path="${binding_dir}/rusty_v8_release_${TARGET}.sha256" + artifact_repository="wallentx/codex-termux" + release_tag="rusty-v8-v147.4.0" + mkdir -p "${binding_dir}" + echo "Downloading Android rusty_v8 artifacts from ${artifact_repository}@${release_tag}" + gh release download "${release_tag}" \ + --repo "${artifact_repository}" \ + --dir "${binding_dir}" \ + --pattern "librusty_v8_release_${TARGET}.a.gz" \ + --pattern "src_binding_release_${TARGET}.rs" \ + --pattern "rusty_v8_release_${TARGET}.sha256" + (cd "${binding_dir}" && sha256sum -c "$(basename "${checksums_path}")") + echo "RUSTY_V8_ARCHIVE=${archive}" >> "$GITHUB_ENV" + echo "RUSTY_V8_SRC_BINDING_PATH=${binding_path}" >> "$GITHUB_ENV" + + - if: ${{ matrix.target == 'aarch64-linux-android' }} + name: Install termux-elf-cleaner + shell: bash + run: | + set -euo pipefail + version="v3.0.1" + src_dir="${RUNNER_TEMP}/termux-elf-cleaner-src" + build_dir="${RUNNER_TEMP}/termux-elf-cleaner-build" + bin_dir="${RUNNER_TEMP}/termux-elf-cleaner-bin" + + rm -rf "${src_dir}" "${build_dir}" "${bin_dir}" + mkdir -p "${src_dir}" "${build_dir}" "${bin_dir}" + + curl -fsSL "https://github.com/termux/termux-elf-cleaner/archive/refs/tags/${version}.tar.gz" \ + | tar -xzf - --strip-components=1 -C "${src_dir}" + + cmake -S "${src_dir}" -B "${build_dir}" -DCMAKE_BUILD_TYPE=Release + cmake --build "${build_dir}" --parallel + + install "${build_dir}/termux-elf-cleaner" "${bin_dir}/termux-elf-cleaner" + echo "${bin_dir}" >> "$GITHUB_PATH" - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} name: Use hermetic Cargo home (musl) @@ -269,12 +473,25 @@ jobs: echo "${cargo_home}/bin" >> "$GITHUB_PATH" : > "${cargo_home}/config.toml" + - uses: actions/cache@v5 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + ${{ github.workspace }}/.cargo-home/bin/ + ${{ github.workspace }}/.cargo-home/registry/index/ + ${{ github.workspace }}/.cargo-home/registry/cache/ + ${{ github.workspace }}/.cargo-home/git/db/ + ${{ github.workspace }}/codex-rs/target/ + key: cargo-${{ matrix.runner }}-${{ matrix.target }}-release-${{ hashFiles('**/Cargo.lock') }} + - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} name: Install Zig - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 + uses: mlugg/setup-zig@v2 with: version: 0.14.0 - use-cache: false - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} name: Install musl build tools @@ -310,12 +527,6 @@ jobs: shell: bash run: | set -euo pipefail - # Avoid problematic aws-lc jitter entropy code path on musl builders. - echo "AWS_LC_SYS_NO_JITTER_ENTROPY=1" >> "$GITHUB_ENV" - target_no_jitter="AWS_LC_SYS_NO_JITTER_ENTROPY_${{ matrix.target }}" - target_no_jitter="${target_no_jitter//-/_}" - echo "${target_no_jitter}=1" >> "$GITHUB_ENV" - # Clear global Rust flags so host/proc-macro builds don't pull in UBSan. echo "RUSTFLAGS=" >> "$GITHUB_ENV" echo "CARGO_ENCODED_RUSTFLAGS=" >> "$GITHUB_ENV" @@ -340,93 +551,70 @@ jobs: echo "CFLAGS=${cflags}" >> "$GITHUB_ENV" echo "CXXFLAGS=${cxxflags}" >> "$GITHUB_ENV" - - name: Configure rusty_v8 artifact overrides and verify checksums - uses: ./.github/actions/setup-rusty-v8 - with: - target: ${{ matrix.target }} - - - if: ${{ contains(matrix.target, 'linux') }} - name: Build bwrap and export digest + - name: Cargo build shell: bash run: | set -euo pipefail - target="${{ matrix.target }}" - cargo build --target "$target" --release --timings --bin bwrap - bwrap_path="target/${target}/release/bwrap" - if [[ ! -f "$bwrap_path" ]]; then - echo "bwrap binary ${bwrap_path} not found" - exit 1 + if [[ "${{ matrix.target }}" == 'aarch64-linux-android' ]]; then + export CARGO_BUILD_JOBS=4 + export CARGO_PROFILE_RELEASE_LTO=thin + export CARGO_PROFILE_RELEASE_CODEGEN_UNITS=8 + cargo build --target ${{ matrix.target }} --release --bin codex + exit 0 fi - digest="$(sha256sum "$bwrap_path" | awk '{print $1}')" - echo "CODEX_BWRAP_SHA256=${digest}" >> "$GITHUB_ENV" - echo "Built bwrap ${bwrap_path} with sha256:${digest}" - - - name: Cargo build + if [[ "${{ contains(matrix.target, 'windows') }}" == 'true' ]]; then + cargo build --target ${{ matrix.target }} --release --bin codex --bin codex-responses-api-proxy --bin codex-windows-sandbox-setup --bin codex-command-runner + else + cargo build --target ${{ matrix.target }} --release --bin codex --bin codex-responses-api-proxy + fi + - name: sccache stats (Android) + if: ${{ matrix.target == 'aarch64-linux-android' }} shell: bash - run: | - build_args=() - for binary in ${{ matrix.binaries }}; do - build_args+=(--bin "$binary") - done - echo "CARGO_PROFILE_RELEASE_LTO: ${CARGO_PROFILE_RELEASE_LTO}" - cargo build --target ${{ matrix.target }} --release --timings "${build_args[@]}" - - - name: Upload Cargo timings - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: cargo-timings-rust-release-${{ matrix.target }}-${{ matrix.bundle }} - path: codex-rs/target/**/cargo-timings/cargo-timing.html - if-no-files-found: warn + run: ${SCCACHE_PATH:-sccache} --show-stats || true - - if: ${{ runner.os == 'macOS' && env.SIGN_MACOS != 'true' }} - name: Stage unsigned macOS artifacts + - if: ${{ matrix.target == 'aarch64-linux-android' }} + name: Normalize Android ELF for Termux shell: bash run: | set -euo pipefail - - target="${{ matrix.target }}" - release_dir="target/${target}/release" - dest="unsigned-dist/${target}" - mkdir -p "$dest" - - for binary in ${{ matrix.binaries }}; do - binary_path="${release_dir}/${binary}" - unsigned_name="${binary}-${target}-unsigned" - unsigned_path="${dest}/${unsigned_name}" - if [[ ! -f "${binary_path}" ]]; then - echo "Binary ${binary_path} not found" - exit 1 + for binary in codex codex-responses-api-proxy; do + binary_path="target/${{ matrix.target }}/release/${binary}" + if [[ -f "${binary_path}" ]]; then + termux-elf-cleaner --api-level 24 "${binary_path}" + chmod +x "${binary_path}" + # if [[ "${binary}" == "codex" ]]; then + # # Patch PT_TLS (0x7) to PT_NULL (0x0) at offset 400 to bypass Bionic alignment checks. + # printf '\x00\x00\x00\x00' | dd of="${binary_path}" bs=1 seek=400 count=4 conv=notrunc + # fi fi - - cp "${binary_path}" "${unsigned_path}" - tar -C "$dest" -czf "${unsigned_path}.tar.gz" "${unsigned_name}" - zstd -T0 -19 --rm "${unsigned_path}" done - - if: ${{ runner.os == 'macOS' && env.SIGN_MACOS != 'true' }} - name: Upload unsigned macOS artifacts - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: ${{ matrix.artifact_name }}-unsigned - path: codex-rs/unsigned-dist/${{ matrix.target }}/* - if-no-files-found: error - - - if: ${{ contains(matrix.target, 'linux') }} + - if: ${{ contains(matrix.target, 'linux') && !contains(matrix.target, 'android') && github.repository_owner == 'openai' }} name: Cosign Linux artifacts uses: ./.github/actions/linux-code-sign with: target: ${{ matrix.target }} artifacts-dir: ${{ github.workspace }}/codex-rs/target/${{ matrix.target }}/release - binaries: ${{ matrix.binaries }} - - if: ${{ runner.os == 'macOS' && env.SIGN_MACOS == 'true' }} + - if: ${{ contains(matrix.target, 'windows') && github.repository_owner == 'openai' }} + name: Sign Windows binaries with Azure Trusted Signing + uses: ./.github/actions/windows-code-sign + with: + target: ${{ matrix.target }} + client-id: ${{ secrets.AZURE_TRUSTED_SIGNING_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TRUSTED_SIGNING_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_TRUSTED_SIGNING_SUBSCRIPTION_ID }} + endpoint: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} + account-name: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} + certificate-profile-name: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} + + - if: ${{ runner.os == 'macOS' && github.repository_owner == 'openai' }} name: MacOS code signing (binaries) uses: ./.github/actions/macos-code-sign with: target: ${{ matrix.target }} - binaries: ${{ matrix.binaries }} sign-binaries: "true" sign-dmg: "false" apple-certificate: ${{ secrets.APPLE_CERTIFICATE_P12 }} @@ -435,7 +623,7 @@ jobs: apple-notarization-key-id: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} apple-notarization-issuer-id: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} - - if: ${{ runner.os == 'macOS' && matrix.build_dmg == 'true' && env.SIGN_MACOS == 'true' }} + - if: ${{ runner.os == 'macOS' && github.repository_owner == 'openai' }} name: Build macOS dmg shell: bash run: | @@ -450,17 +638,23 @@ jobs: # The previous "MacOS code signing (binaries)" step signs + notarizes the # built artifacts in `${release_dir}`. This step packages *those same* # signed binaries into a dmg. + codex_binary_path="${release_dir}/codex" + proxy_binary_path="${release_dir}/codex-responses-api-proxy" + rm -rf "$dmg_root" mkdir -p "$dmg_root" - for binary in ${{ matrix.binaries }}; do - binary_path="${release_dir}/${binary}" - if [[ ! -f "${binary_path}" ]]; then - echo "Binary ${binary_path} not found" - exit 1 - fi - ditto "${binary_path}" "${dmg_root}/${binary}" - done + if [[ ! -f "$codex_binary_path" ]]; then + echo "Binary $codex_binary_path not found" + exit 1 + fi + if [[ ! -f "$proxy_binary_path" ]]; then + echo "Binary $proxy_binary_path not found" + exit 1 + fi + + ditto "$codex_binary_path" "${dmg_root}/codex" + ditto "$proxy_binary_path" "${dmg_root}/codex-responses-api-proxy" rm -f "$dmg_path" hdiutil create \ @@ -475,7 +669,7 @@ jobs: exit 1 fi - - if: ${{ runner.os == 'macOS' && matrix.build_dmg == 'true' && env.SIGN_MACOS == 'true' }} + - if: ${{ runner.os == 'macOS' && github.repository_owner == 'openai' }} name: MacOS code signing (dmg) uses: ./.github/actions/macos-code-sign with: @@ -489,113 +683,65 @@ jobs: apple-notarization-issuer-id: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} - name: Stage artifacts - if: ${{ runner.os != 'macOS' || env.SIGN_MACOS == 'true' }} shell: bash run: | dest="dist/${{ matrix.target }}" mkdir -p "$dest" - for binary in ${{ matrix.binaries }}; do - cp "target/${{ matrix.target }}/release/${binary}" "$dest/${binary}-${{ matrix.target }}" - if [[ "${{ matrix.target }}" == *linux* ]]; then - cp "target/${{ matrix.target }}/release/${binary}.sigstore" \ - "$dest/${binary}-${{ matrix.target }}.sigstore" + if [[ "${{ matrix.runner }}" == windows* ]]; then + cp target/${{ matrix.target }}/release/codex.exe "$dest/codex-${{ matrix.target }}.exe" + cp target/${{ matrix.target }}/release/codex-responses-api-proxy.exe "$dest/codex-responses-api-proxy-${{ matrix.target }}.exe" + cp target/${{ matrix.target }}/release/codex-windows-sandbox-setup.exe "$dest/codex-windows-sandbox-setup-${{ matrix.target }}.exe" + cp target/${{ matrix.target }}/release/codex-command-runner.exe "$dest/codex-command-runner-${{ matrix.target }}.exe" + else + # For Android, we want the binary to be named just 'codex' in the archive. + if [[ "${{ matrix.target }}" == "aarch64-linux-android" ]]; then + cp target/${{ matrix.target }}/release/codex "$dest/codex" + else + cp target/${{ matrix.target }}/release/codex "$dest/codex-${{ matrix.target }}" fi - done + if [[ -f target/${{ matrix.target }}/release/codex-responses-api-proxy ]]; then + cp target/${{ matrix.target }}/release/codex-responses-api-proxy "$dest/codex-responses-api-proxy-${{ matrix.target }}" + fi + fi - if [[ "${{ matrix.target }}" == *linux* && "${{ matrix.bundle }}" == "primary" ]]; then - bundle_root="${RUNNER_TEMP}/codex-${{ matrix.target }}-bundle" - rm -rf "$bundle_root" - mkdir -p "$bundle_root/codex-resources" - cp "$dest/codex-${{ matrix.target }}" "$bundle_root/codex" - cp "$dest/bwrap-${{ matrix.target }}" "$bundle_root/codex-resources/bwrap" - chmod 0755 "$bundle_root/codex" "$bundle_root/codex-resources/bwrap" - tar -C "$bundle_root" -cf - codex codex-resources/bwrap | - zstd -T0 -19 -o "$dest/codex-${{ matrix.target }}-bundle.tar.zst" + if [[ "${{ matrix.target }}" == *linux* && "${{ matrix.target }}" != *android* ]]; then + cp target/${{ matrix.target }}/release/codex.sigstore "$dest/codex-${{ matrix.target }}.sigstore" + cp target/${{ matrix.target }}/release/codex-responses-api-proxy.sigstore "$dest/codex-responses-api-proxy-${{ matrix.target }}.sigstore" fi - if [[ "${{ matrix.build_dmg }}" == "true" ]]; then + if [[ "${{ matrix.target }}" == *apple-darwin ]]; then cp target/${{ matrix.target }}/release/codex-${{ matrix.target }}.dmg "$dest/codex-${{ matrix.target }}.dmg" fi - - name: Build Codex package archive - if: ${{ runner.os != 'macOS' || env.SIGN_MACOS == 'true' }} - shell: bash - env: - TARGET: ${{ matrix.target }} - BUNDLE: ${{ matrix.bundle }} - run: | - set -euo pipefail - bash "${GITHUB_WORKSPACE}/.github/scripts/build-codex-package-archive.sh" \ - --target "$TARGET" \ - --bundle "$BUNDLE" \ - --entrypoint-dir "target/${TARGET}/release" \ - --archive-dir "dist/${TARGET}" - - - name: Build Python runtime wheel - if: ${{ matrix.bundle == 'primary' && (runner.os != 'macOS' || env.SIGN_MACOS == 'true') }} - shell: bash - run: | - set -euo pipefail - - case "${{ matrix.target }}" in - aarch64-apple-darwin) - platform_tag="macosx_11_0_arm64" - ;; - x86_64-apple-darwin) - platform_tag="macosx_10_9_x86_64" - ;; - aarch64-unknown-linux-musl) - platform_tag="manylinux_2_17_aarch64" - ;; - x86_64-unknown-linux-musl) - platform_tag="manylinux_2_17_x86_64" - ;; - *) - echo "No Python runtime wheel platform tag for ${{ matrix.target }}" - exit 1 - ;; - esac - - python3 -m venv "${RUNNER_TEMP}/python-runtime-build-venv" - # Do not install into the runner's system Python; macOS runners mark - # the Homebrew Python as externally managed under PEP 668. - "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m pip install build - - stage_dir="${RUNNER_TEMP}/openai-codex-cli-bin-${{ matrix.target }}" - wheel_dir="${GITHUB_WORKSPACE}/python-runtime-dist/${{ matrix.target }}" - stage_runtime_args=( - "${GITHUB_WORKSPACE}/sdk/python/scripts/update_sdk_artifacts.py" - stage-runtime - "$stage_dir" - "dist/${{ matrix.target }}/codex-package-${{ matrix.target }}.tar.gz" - --codex-version "${GITHUB_REF_NAME}" - --platform-tag "$platform_tag" - ) - python3 "${stage_runtime_args[@]}" - "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m build --wheel --outdir "$wheel_dir" "$stage_dir" - - - name: Upload Python runtime wheel - if: ${{ matrix.bundle == 'primary' && (runner.os != 'macOS' || env.SIGN_MACOS == 'true') }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: python-runtime-wheel-${{ matrix.target }} - path: python-runtime-dist/${{ matrix.target }}/*.whl - if-no-files-found: error + - if: ${{ matrix.runner == 'windows-11-arm' }} + name: Install zstd + shell: powershell + run: choco install -y zstandard - name: Compress artifacts - if: ${{ runner.os != 'macOS' || env.SIGN_MACOS == 'true' }} shell: bash run: | # Path that contains the uncompressed binaries for the current # ${{ matrix.target }} dest="dist/${{ matrix.target }}" + repo_root=$PWD + + # We want to ship the raw Windows executables in the GitHub Release + # in addition to the compressed archives. Keep the originals for + # Windows targets; remove them elsewhere to limit the number of + # artifacts that end up in the GitHub Release. + keep_originals=false + if [[ "${{ matrix.runner }}" == windows* ]]; then + keep_originals=true + fi # For compatibility with environments that lack the `zstd` tool we - # additionally create a `.tar.gz` alongside every binary we publish. - # The end result is: + # additionally create a `.tar.gz` for all platforms and `.zip` for + # Windows alongside every single binary that we publish. The end result is: # codex-.zst (existing) # codex-.tar.gz (new) + # codex-.zip (only for Windows) # 1. Produce a .tar.gz for every file in the directory *before* we # run `zstd --rm`, because that flag deletes the original files. @@ -603,7 +749,7 @@ jobs: base="$(basename "$f")" # Skip files that are already archives (shouldn't happen, but be # safe). - if [[ "$base" == *.tar.gz || "$base" == *.tar.zst || "$base" == *.zip || "$base" == *.dmg ]]; then + if [[ "$base" == *.tar.gz || "$base" == *.zip || "$base" == *.dmg ]]; then continue fi @@ -613,344 +759,239 @@ jobs: fi # Create per-binary tar.gz - tar -C "$dest" -czf "$dest/${base}.tar.gz" "$base" - - # Also create .zst and remove the uncompressed binaries to keep - # non-Windows artifact directories small. - zstd -T0 -19 --rm "$dest/$base" - done - - - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - if: ${{ runner.os != 'macOS' || env.SIGN_MACOS == 'true' }} - with: - name: ${{ matrix.artifact_name }} - # Upload the per-binary .zst files, .tar.gz equivalents, and any - # prebuilt archives staged above. - path: | - codex-rs/dist/${{ matrix.target }}/* - - stage-signed-macos: - if: ${{ github.event_name == 'workflow_dispatch' && inputs.release_mode == 'promote_signed' }} - needs: tag-check - name: Stage signed macOS handoff - ${{ matrix.target }} - ${{ matrix.bundle }} - runs-on: macos-15-xlarge - timeout-minutes: 30 - permissions: - contents: read - defaults: - run: - working-directory: codex-rs + # For Android, we want the archive to have the target suffix even though the binary is just 'codex'. + archive_name="${base}" + if [[ "${{ matrix.target }}" == "aarch64-linux-android" && "${base}" == "codex" ]]; then + archive_name="codex-${{ matrix.target }}" + fi + tar -C "$dest" -czf "$dest/${archive_name}.tar.gz" "$base" + + # Create zip archive for Windows binaries + # Must run from inside the dest dir so 7z won't + # embed the directory path inside the zip. + if [[ "${{ matrix.runner }}" == windows* ]]; then + if [[ "$base" == "codex-${{ matrix.target }}.exe" ]]; then + # Bundle the sandbox helper binaries into the main codex zip so + # WinGet installs include the required helpers next to codex.exe. + # Fall back to the single-binary zip if the helpers are missing + # to avoid breaking releases. + bundle_dir="$(mktemp -d)" + runner_src="$dest/codex-command-runner-${{ matrix.target }}.exe" + setup_src="$dest/codex-windows-sandbox-setup-${{ matrix.target }}.exe" + if [[ -f "$runner_src" && -f "$setup_src" ]]; then + cp "$dest/$base" "$bundle_dir/$base" + cp "$runner_src" "$bundle_dir/codex-command-runner.exe" + cp "$setup_src" "$bundle_dir/codex-windows-sandbox-setup.exe" + # Use an absolute path so bundle zips land in the real dist + # dir even when 7z runs from a temp directory. + (cd "$bundle_dir" && 7z a "$repo_root/$dest/${base}.zip" .) + else + echo "warning: missing sandbox binaries; falling back to single-binary zip" + echo "warning: expected $runner_src and $setup_src" + (cd "$dest" && 7z a "${base}.zip" "$base") + fi + rm -rf "$bundle_dir" + else + (cd "$dest" && 7z a "${base}.zip" "$base") + fi + fi - strategy: - fail-fast: false - matrix: - include: - - target: aarch64-apple-darwin - bundle: primary - artifact_name: aarch64-apple-darwin - binaries: "codex codex-responses-api-proxy" - build_dmg: "false" - - target: aarch64-apple-darwin - bundle: app-server - artifact_name: aarch64-apple-darwin-app-server - binaries: "codex-app-server" - build_dmg: "false" - - target: x86_64-apple-darwin - bundle: primary - artifact_name: x86_64-apple-darwin - binaries: "codex codex-responses-api-proxy" - build_dmg: "false" - - target: x86_64-apple-darwin - bundle: app-server - artifact_name: x86_64-apple-darwin-app-server - binaries: "codex-app-server" - build_dmg: "false" + # Also create .zst (existing behaviour) *and* remove the original + # uncompressed binary to keep the directory small. + zstd_args=(-T0 -19) + if [[ "${keep_originals}" == false ]]; then + zstd_args+=(--rm) + fi - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false + if [[ "${archive_name}" != "${base}" ]]; then + zstd "${zstd_args[@]}" "$dest/$base" -o "$dest/${archive_name}.zst" + else + zstd "${zstd_args[@]}" "$dest/$base" + fi + done - - name: Download signed macOS handoff + - name: Add Termux release metadata + if: ${{ (github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch') && matrix.target == 'aarch64-linux-android' }} shell: bash - env: - GH_TOKEN: ${{ github.token }} - SIGNED_MACOS_ASSET: ${{ inputs.signed_macos_asset }} - SIGNED_MACOS_SHA256: ${{ inputs.signed_macos_sha256 }} run: | set -euo pipefail - - download_dir="${RUNNER_TEMP}/signed-macos-download" - handoff_dir="${RUNNER_TEMP}/signed-macos-handoff" - rm -rf "$download_dir" "$handoff_dir" - mkdir -p "$download_dir" "$handoff_dir" - - gh release download "$GITHUB_REF_NAME" \ - --repo "$GITHUB_REPOSITORY" \ - --pattern "$SIGNED_MACOS_ASSET" \ - --dir "$download_dir" - - asset_count="$(find "$download_dir" -maxdepth 1 -type f | wc -l | tr -d '[:space:]')" - if [[ "$asset_count" != "1" ]]; then - echo "Expected exactly one signed macOS handoff asset named ${SIGNED_MACOS_ASSET}; found ${asset_count}" - find "$download_dir" -maxdepth 1 -type f -print - exit 1 - fi - - asset_path="$(find "$download_dir" -maxdepth 1 -type f -print -quit)" - if [[ -n "${SIGNED_MACOS_SHA256}" ]]; then - expected_sha="$(printf '%s' "$SIGNED_MACOS_SHA256" | tr '[:upper:]' '[:lower:]')" - actual_sha="$(shasum -a 256 "$asset_path" | awk '{print $1}')" - if [[ "$actual_sha" != "$expected_sha" ]]; then - echo "signed_macos_sha256 mismatch for ${SIGNED_MACOS_ASSET}" - echo "expected: ${expected_sha}" - echo "actual: ${actual_sha}" - exit 1 - fi + if [[ ! -f "${GITHUB_WORKSPACE}/.github/termux-release.json" ]]; then + echo "No Termux release metadata found; skipping metadata attachment." + exit 0 fi + dest="dist/${{ matrix.target }}" + cp "${GITHUB_WORKSPACE}/.github/termux-release.json" "${dest}/termux-release.json" + ( + cd "${dest}" + sha256sum ./* > SHA256SUMS + ) - asset_name="$(basename "$asset_path")" - case "$asset_name" in - *.tar.zst) - zstd -dc "$asset_path" | tar -C "$handoff_dir" -xf - - ;; - *.tar.gz|*.tgz) - tar -C "$handoff_dir" -xzf "$asset_path" - ;; - *.zip) - ditto -x -k "$asset_path" "$handoff_dir" - ;; - *) - echo "Unsupported signed macOS handoff archive format: ${asset_name}" - exit 1 - ;; - esac - - echo "SIGNED_MACOS_HANDOFF_DIR=$handoff_dir" >> "$GITHUB_ENV" + - id: upload-artifact + uses: actions/upload-artifact@v6 + with: + name: ${{ github.event_name == 'pull_request' && format('termux-android-pr-{0}-{1}', github.event.pull_request.number, github.event.pull_request.head.sha) || matrix.target }} + # Upload the per-binary .zst files as well as the new .tar.gz + # equivalents we generated in the previous step. + path: | + codex-rs/dist/${{ matrix.target }}/* - - name: Stage signed macOS artifacts + - name: Publish Termux artifact notification + if: ${{ github.event_name == 'pull_request' && startsWith(github.base_ref, 'release/') && matrix.target == 'aarch64-linux-android' }} shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ARTIFACT_ID: ${{ steps.upload-artifact.outputs.artifact-id }} + ARTIFACT_NAME: ${{ github.event_name == 'pull_request' && format('termux-android-pr-{0}-{1}', github.event.pull_request.number, github.event.pull_request.head.sha) || matrix.target }} + PR_NUMBER: ${{ github.event.pull_request.number }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} run: | set -euo pipefail - - target="${{ matrix.target }}" - artifact_name="${{ matrix.artifact_name }}" - source_dir="${SIGNED_MACOS_HANDOFF_DIR}/${artifact_name}" - if [[ ! -d "$source_dir" && -d "${SIGNED_MACOS_HANDOFF_DIR}/dist/${artifact_name}" ]]; then - source_dir="${SIGNED_MACOS_HANDOFF_DIR}/dist/${artifact_name}" - fi - if [[ ! -d "$source_dir" && -d "${SIGNED_MACOS_HANDOFF_DIR}/${target}" ]]; then - source_dir="${SIGNED_MACOS_HANDOFF_DIR}/${target}" - fi - if [[ ! -d "$source_dir" && -d "${SIGNED_MACOS_HANDOFF_DIR}/dist/${target}" ]]; then - source_dir="${SIGNED_MACOS_HANDOFF_DIR}/dist/${target}" - fi - if [[ ! -d "$source_dir" ]]; then - echo "Signed macOS handoff is missing ${artifact_name}/" - echo "Expected either:" - echo " ${SIGNED_MACOS_HANDOFF_DIR}/${artifact_name}" - echo " ${SIGNED_MACOS_HANDOFF_DIR}/dist/${artifact_name}" - echo " ${SIGNED_MACOS_HANDOFF_DIR}/${target}" - echo " ${SIGNED_MACOS_HANDOFF_DIR}/dist/${target}" - find "$SIGNED_MACOS_HANDOFF_DIR" -maxdepth 3 -type f -print + if [[ -z "${ARTIFACT_ID}" ]]; then + echo "upload-artifact did not return an artifact id" >&2 exit 1 fi - dest="dist/${target}" - mkdir -p "$dest" - - for binary in ${{ matrix.binaries }}; do - source_path="${source_dir}/${binary}" - if [[ ! -f "$source_path" ]]; then - source_path="${source_dir}/${binary}-${target}" - fi - if [[ ! -f "$source_path" ]]; then - echo "Signed macOS handoff is missing ${binary} for ${artifact_name}" - exit 1 - fi - - release_path="${dest}/${binary}-${target}" - ditto "$source_path" "$release_path" - chmod 0755 "$release_path" - codesign --verify --strict --verbose=2 "$release_path" - done - - # DMG staging is disabled for signed promotion because we no longer - # distribute DMGs from this release path. Keep the branch here so the - # handoff can opt back in by flipping matrix.build_dmg if needed. - if [[ "${{ matrix.build_dmg }}" == "true" ]]; then - dmg_name="codex-${target}.dmg" - dmg_source="${source_dir}/${dmg_name}" - if [[ ! -f "$dmg_source" ]]; then - echo "Signed macOS handoff is missing ${dmg_name} for ${artifact_name}" - exit 1 - fi - - codesign --verify --strict --verbose=2 "$dmg_source" - xcrun stapler validate "$dmg_source" - cp "$dmg_source" "$dest/$dmg_name" + marker="" + artifact_url="https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/artifacts/${ARTIFACT_ID}" + run_url="${GH_WORKFLOW_URL:-https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}}" + body_path="${RUNNER_TEMP}/termux-artifact-comment.md" + { + echo "${marker}" + echo "Termux Android artifact ready for testing:" + echo + echo "- Artifact: [${ARTIFACT_NAME}](${artifact_url})" + echo "- Workflow run: [${GITHUB_RUN_ID}](${run_url})" + echo + echo "You must be signed in to GitHub with repository access to download Actions artifacts." + } > "${body_path}" + + existing_comment_id="$( + gh pr view "${PR_NUMBER}" \ + --repo "${GITHUB_REPOSITORY}" \ + --json comments \ + --jq ".comments | map(select(.author.is_bot == true and (.body | contains(\"${marker}\")))) | .[-1].id // \"\"" + )" + if [[ -n "${existing_comment_id}" ]]; then + gh api graphql \ + -f query=' + mutation($id: ID!, $body: String!) { + updateIssueComment(input: {id: $id, body: $body}) { + issueComment { + id + } + } + } + ' \ + -f id="${existing_comment_id}" \ + -f body="$(cat "${body_path}")" \ + >/dev/null + else + gh pr comment "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" --body-file "${body_path}" fi - - name: Build Codex package archive - shell: bash - env: - TARGET: ${{ matrix.target }} - BUNDLE: ${{ matrix.bundle }} - run: | - set -euo pipefail - bash "${GITHUB_WORKSPACE}/.github/scripts/build-codex-package-archive.sh" \ - --target "$TARGET" \ - --bundle "$BUNDLE" \ - --entrypoint-dir "dist/${TARGET}" \ - --archive-dir "dist/${TARGET}" \ - --target-suffixed-entrypoint - - - name: Build Python runtime wheel - if: ${{ matrix.bundle == 'primary' }} - shell: bash - run: | - set -euo pipefail - - case "${{ matrix.target }}" in - aarch64-apple-darwin) - platform_tag="macosx_11_0_arm64" - ;; - x86_64-apple-darwin) - platform_tag="macosx_10_9_x86_64" - ;; - *) - echo "No Python runtime wheel platform tag for ${{ matrix.target }}" - exit 1 - ;; - esac - - python3 -m venv "${RUNNER_TEMP}/python-runtime-build-venv" - "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m pip install build - - stage_dir="${RUNNER_TEMP}/openai-codex-cli-bin-${{ matrix.target }}" - wheel_dir="${GITHUB_WORKSPACE}/python-runtime-dist/${{ matrix.target }}" - python3 \ - "${GITHUB_WORKSPACE}/sdk/python/scripts/update_sdk_artifacts.py" \ - stage-runtime \ - "$stage_dir" \ - "dist/${{ matrix.target }}/codex-package-${{ matrix.target }}.tar.gz" \ - --codex-version "${GITHUB_REF_NAME}" \ - --platform-tag "$platform_tag" - "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m build --wheel --outdir "$wheel_dir" "$stage_dir" - - - name: Upload Python runtime wheel - if: ${{ matrix.bundle == 'primary' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: python-runtime-wheel-${{ matrix.target }} - path: python-runtime-dist/${{ matrix.target }}/*.whl - if-no-files-found: error - - - name: Compress artifacts - shell: bash - run: | - set -euo pipefail - - dest="dist/${{ matrix.target }}" - for f in "$dest"/*; do - base="$(basename "$f")" - if [[ "$base" == *.tar.gz || "$base" == *.tar.zst || "$base" == *.zip || "$base" == *.dmg ]]; then - continue + gh label create binary-ready \ + --repo "${GITHUB_REPOSITORY}" \ + --color 0e8a16 \ + --description "Android binary is ready for testing" \ + --force + gh pr edit "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" --add-label binary-ready + + if [[ -n "${SLACK_WEBHOOK_URL:-}" ]]; then + slack_payload_path="${RUNNER_TEMP}/termux-artifact-slack.json" + pr_url="https://github.com/${GITHUB_REPOSITORY}/pull/${PR_NUMBER}" + jq -n \ + --arg artifact_name "${ARTIFACT_NAME}" \ + --arg artifact_url "${artifact_url}" \ + --arg pr_number "${PR_NUMBER}" \ + --arg pr_url "${pr_url}" \ + --arg run_id "${GITHUB_RUN_ID}" \ + --arg run_url "${run_url}" \ + '{ + text: ("Termux Android artifact ready for testing: " + $artifact_url), + blocks: [ + { + type: "section", + text: { + type: "mrkdwn", + text: ("*Termux Android artifact ready for testing*\n" + $artifact_url) + } + }, + { + type: "section", + fields: [ + { + type: "mrkdwn", + text: ("*Artifact:*\n<" + $artifact_url + "|" + $artifact_name + ">") + }, + { + type: "mrkdwn", + text: ("*Pull request:*\n<" + $pr_url + "|#" + $pr_number + ">") + }, + { + type: "mrkdwn", + text: ("*Workflow run:*\n<" + $run_url + "|" + $run_id + ">") + } + ] + }, + { + type: "context", + elements: [ + { + type: "mrkdwn", + text: "You must be signed in to GitHub with repository access to download Actions artifacts." + } + ] + } + ] + }' > "${slack_payload_path}" + + if ! curl -fsS \ + -X POST \ + -H "Content-type: application/json" \ + --data @"${slack_payload_path}" \ + "${SLACK_WEBHOOK_URL}"; then + echo "::warning::Failed to send Slack artifact notification" fi + fi - tar -C "$dest" -czf "$dest/${base}.tar.gz" "$base" - zstd -T0 -19 --rm "$dest/$base" - done - - - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: ${{ matrix.artifact_name }} - path: | - codex-rs/dist/${{ matrix.target }}/* - - build-windows: - if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} - needs: tag-check - uses: ./.github/workflows/rust-release-windows.yml - with: - release-lto: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'fat' }} - secrets: inherit - - argument-comment-lint-release-assets: - if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} - name: argument-comment-lint release assets + shell-tool-mcp: + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && github.repository_owner == 'openai' + name: shell-tool-mcp needs: tag-check - uses: ./.github/workflows/rust-release-argument-comment-lint.yml + permissions: + contents: read + id-token: write + uses: ./.github/workflows/shell-tool-mcp.yml with: + release-tag: ${{ github.ref_name }} publish: true - - zsh-release-assets: - if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} - name: zsh release assets - needs: tag-check - uses: ./.github/workflows/rust-release-zsh.yml + secrets: inherit release: + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') needs: - - tag-check - build - - stage-signed-macos - - build-windows - - argument-comment-lint-release-assets - - zsh-release-assets - if: >- - ${{ - always() && - needs.tag-check.result == 'success' && - ( - ( - github.event_name == 'workflow_dispatch' && - inputs.release_mode == 'promote_signed' && - needs.stage-signed-macos.result == 'success' && - needs.build.result == 'skipped' && - needs.build-windows.result == 'skipped' && - needs.argument-comment-lint-release-assets.result == 'skipped' && - needs.zsh-release-assets.result == 'skipped' - ) || - ( - (github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed') && - needs.build.result == 'success' && - needs.stage-signed-macos.result == 'skipped' && - needs.build-windows.result == 'success' && - needs.argument-comment-lint-release-assets.result == 'success' && - needs.zsh-release-assets.result == 'success' - ) - ) - }} + - shell-tool-mcp name: release runs-on: ubuntu-latest permissions: contents: write actions: read - env: - RELEASE_MODE: ${{ github.event_name == 'workflow_dispatch' && inputs.release_mode || 'signed' }} - SIGN_MACOS: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode == 'promote_signed' }} - SIGNED_MACOS_ASSET: ${{ inputs.signed_macos_asset }} - UNSIGNED_RUN_ID: ${{ inputs.unsigned_run_id }} outputs: version: ${{ steps.release_name.outputs.name }} tag: ${{ github.ref_name }} - sign_macos: ${{ steps.release_mode.outputs.sign_macos }} should_publish_npm: ${{ steps.npm_publish_settings.outputs.should_publish }} npm_tag: ${{ steps.npm_publish_settings.outputs.npm_tag }} - should_publish_python_runtime: ${{ steps.python_runtime_publish_settings.outputs.should_publish }} steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false + uses: actions/checkout@v6 - - name: Define release mode - id: release_mode - run: | - echo "release_mode=${RELEASE_MODE}" >> "$GITHUB_OUTPUT" - echo "sign_macos=${SIGN_MACOS}" >> "$GITHUB_OUTPUT" + - name: 🧰 Actions Toolbox + # This is required for the GitHub CLI + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: wallentx/gh-actions/composite/actions-toolbox@main - name: Generate release notes from tag commit message id: release_notes @@ -972,159 +1013,21 @@ jobs: echo "path=${notes_path}" >> "${GITHUB_OUTPUT}" - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + - uses: actions/download-artifact@v7 with: path: dist - - name: Validate unsigned build run - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - - run_summary="$(gh run view "$UNSIGNED_RUN_ID" \ - --repo "$GITHUB_REPOSITORY" \ - --json conclusion,event,headBranch,headSha,status,workflowName,url \ - --jq '[.workflowName, .event, .headBranch, .headSha, .status, .conclusion, .url] | @tsv')" - IFS=$'\t' read -r workflow_name event head_branch head_sha status conclusion run_url <<< "$run_summary" - expected_head_sha="$(git rev-parse "${GITHUB_SHA}^{commit}")" - - if [[ "$workflow_name" != "$GITHUB_WORKFLOW" ]]; then - echo "unsigned_run_id ${UNSIGNED_RUN_ID} is for workflow '${workflow_name}', expected '${GITHUB_WORKFLOW}'" - echo "Run URL: ${run_url}" - exit 1 - fi - - if [[ "$event" != "workflow_dispatch" ]]; then - echo "unsigned_run_id ${UNSIGNED_RUN_ID} was triggered by '${event}', expected 'workflow_dispatch'" - echo "Run URL: ${run_url}" - exit 1 - fi - - if [[ "$head_branch" != "$GITHUB_REF_NAME" ]]; then - echo "unsigned_run_id ${UNSIGNED_RUN_ID} used ref '${head_branch}', expected '${GITHUB_REF_NAME}'" - echo "Run URL: ${run_url}" - exit 1 - fi - - if [[ "$head_sha" != "$expected_head_sha" ]]; then - echo "unsigned_run_id ${UNSIGNED_RUN_ID} used head SHA '${head_sha}', expected '${expected_head_sha}'" - echo "Run URL: ${run_url}" - exit 1 - fi - - if [[ "$status" != "completed" || "$conclusion" != "success" ]]; then - echo "unsigned_run_id ${UNSIGNED_RUN_ID} is ${status}/${conclusion}, expected completed/success" - echo "Run URL: ${run_url}" - exit 1 - fi - - - name: Download artifacts from unsigned build run - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - gh run download "$UNSIGNED_RUN_ID" \ - --repo "$GITHUB_REPOSITORY" \ - --dir dist - - - name: Remove unsigned macOS staging artifacts - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - run: | - set -euo pipefail - find dist -mindepth 1 -maxdepth 1 -type d \ - -name '*-apple-darwin*-unsigned' \ - -exec rm -rf {} + - - - name: Re-upload promoted Linux x64 artifacts - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: x86_64-unknown-linux-musl - path: dist/x86_64-unknown-linux-musl/* - if-no-files-found: error - - - name: Re-upload promoted Linux arm64 artifacts - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: aarch64-unknown-linux-musl - path: dist/aarch64-unknown-linux-musl/* - if-no-files-found: error - - - name: Re-upload promoted Windows x64 artifacts - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: x86_64-pc-windows-msvc - path: dist/x86_64-pc-windows-msvc/* - if-no-files-found: error - - - name: Re-upload promoted Windows arm64 artifacts - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: aarch64-pc-windows-msvc - path: dist/aarch64-pc-windows-msvc/* - if-no-files-found: error - - name: List run: ls -R dist/ - - name: Prune artifacts excluded from unsigned macOS release - if: ${{ env.SIGN_MACOS == 'false' }} - run: | - find dist -mindepth 1 -maxdepth 1 -type d \ - ! -name '*-apple-darwin*-unsigned' \ - ! -name 'aarch64-unknown-linux-musl' \ - ! -name 'aarch64-unknown-linux-musl-app-server' \ - ! -name 'x86_64-unknown-linux-musl' \ - ! -name 'x86_64-unknown-linux-musl-app-server' \ - ! -name 'aarch64-pc-windows-msvc' \ - ! -name 'x86_64-pc-windows-msvc' \ - -exec rm -rf {} + - - if ! find dist -type f -name '*-apple-darwin*-unsigned*' | grep -q .; then - echo "No unsigned macOS artifacts found in downloaded workflow artifacts." - exit 1 - fi - + # This is a temporary fix: we should modify shell-tool-mcp.yml so these + # files do not end up in dist/ in the first place. - name: Delete entries from dist/ that should not go in the release run: | - rm -rf dist/windows-binaries* - # cargo-timing.html appears under multiple target-specific directories. - # If included in files: dist/**, release upload races on duplicate - # asset names and can fail with 404s. - find dist -type f -name 'cargo-timing.html' -delete - find dist -type d -empty -delete + rm -rf dist/shell-tool-mcp* ls -R dist/ - - name: Add Codex package checksum manifest - run: | - set -euo pipefail - - manifest="dist/codex-package_SHA256SUMS" - tmp_manifest="$(mktemp)" - find dist -type f \ - \( -name 'codex-package-*.tar.gz' -o -name 'codex-app-server-package-*.tar.gz' \) \ - -print | - sort | - while IFS= read -r archive; do - sha256sum "$archive" | - awk -v name="$(basename "$archive")" '{ print $1 " " name }' - done > "$tmp_manifest" - - if [[ ! -s "$tmp_manifest" ]]; then - echo "No Codex package archives found for checksum manifest" - exit 1 - fi - - mv "$tmp_manifest" "$manifest" - cat "$manifest" - - name: Add config schema release asset run: | cp codex-rs/core/config.schema.json dist/config-schema.json @@ -1145,12 +1048,6 @@ jobs: set -euo pipefail version="${VERSION}" - if [[ "${SIGN_MACOS}" != "true" ]]; then - echo "should_publish=false" >> "$GITHUB_OUTPUT" - echo "npm_tag=" >> "$GITHUB_OUTPUT" - exit 0 - fi - if [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "should_publish=true" >> "$GITHUB_OUTPUT" echo "npm_tag=" >> "$GITHUB_OUTPUT" @@ -1162,130 +1059,55 @@ jobs: echo "npm_tag=" >> "$GITHUB_OUTPUT" fi - - name: Determine Python runtime publish settings - id: python_runtime_publish_settings - env: - VERSION: ${{ steps.release_name.outputs.name }} - run: | - set -euo pipefail - version="${VERSION}" - - if [[ "${SIGN_MACOS}" != "true" ]]; then - echo "should_publish=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - if [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "should_publish=true" >> "$GITHUB_OUTPUT" - elif [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+-alpha\.[0-9]+$ ]]; then - echo "should_publish=true" >> "$GITHUB_OUTPUT" - else - echo "should_publish=false" >> "$GITHUB_OUTPUT" - fi - - name: Setup pnpm - if: ${{ env.SIGN_MACOS == 'true' }} - uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5 + uses: pnpm/action-setup@v4 with: run_install: false - name: Setup Node.js for npm packaging - if: ${{ env.SIGN_MACOS == 'true' }} - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + uses: actions/setup-node@v6 with: node-version: 22 - name: Install dependencies - if: ${{ env.SIGN_MACOS == 'true' }} run: pnpm install --frozen-lockfile + # stage_npm_packages.py requires DotSlash when staging releases. + - uses: facebook/install-dotslash@v2 - name: Stage npm packages - if: ${{ env.SIGN_MACOS == 'true' }} + if: github.repository_owner == 'openai' env: GH_TOKEN: ${{ github.token }} - RELEASE_VERSION: ${{ steps.release_name.outputs.name }} run: | - workflow_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" ./scripts/stage_npm_packages.py \ - --release-version "$RELEASE_VERSION" \ - --workflow-url "$workflow_url" \ + --release-version "${{ steps.release_name.outputs.name }}" \ --package codex \ --package codex-responses-api-proxy \ --package codex-sdk - - name: Stage installer scripts - if: ${{ env.SIGN_MACOS == 'true' }} - run: | - cp scripts/install/install.sh dist/install.sh - cp scripts/install/install.ps1 dist/install.ps1 - - name: Create GitHub Release - uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1 + uses: softprops/action-gh-release@v2 with: name: ${{ steps.release_name.outputs.name }} tag_name: ${{ github.ref_name }} body_path: ${{ steps.release_notes.outputs.path }} files: dist/** - overwrite_files: true - make_latest: ${{ env.SIGN_MACOS == 'true' && !contains(steps.release_name.outputs.name, '-') }} # Mark as prerelease only when the version has a suffix after x.y.z # (e.g. -alpha, -beta). Otherwise publish a normal release. prerelease: ${{ contains(steps.release_name.outputs.name, '-') }} - - name: Clean up signed promotion handoff assets - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - - release_id="$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${GITHUB_REF_NAME}" --jq '.id')" - gh api --paginate "repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets" \ - --jq '.[] | [.id, .name] | @tsv' | - while IFS=$'\t' read -r asset_id asset_name; do - if [[ -z "$asset_id" || -z "$asset_name" ]]; then - continue - fi - - delete_asset=false - if [[ "$asset_name" == *unsigned* || "$asset_name" == "$SIGNED_MACOS_ASSET" ]]; then - delete_asset=true - fi - - if [[ "$delete_asset" == "true" ]]; then - echo "Deleting release asset ${asset_name}" - gh api -X DELETE "repos/${GITHUB_REPOSITORY}/releases/assets/${asset_id}" - fi - done - - - if: ${{ env.SIGN_MACOS == 'true' }} - uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 + - if: github.repository_owner == 'openai' + uses: facebook/dotslash-publish-release@v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: tag: ${{ github.ref_name }} config: .github/dotslash-config.json - - if: ${{ env.SIGN_MACOS == 'true' }} - uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag: ${{ github.ref_name }} - config: .github/dotslash-zsh-config.json - - - if: ${{ env.SIGN_MACOS == 'true' }} - uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag: ${{ github.ref_name }} - config: .github/dotslash-argument-comment-lint-config.json - - name: Trigger developers.openai.com deploy # Only trigger the deploy if the release is not a pre-release. # The deploy is used to update the developers.openai.com website with the new config schema json file. - if: ${{ env.SIGN_MACOS == 'true' && !contains(steps.release_name.outputs.name, '-') }} + if: ${{ !contains(steps.release_name.outputs.name, '-') && github.repository_owner == 'openai' }} continue-on-error: true env: DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL: ${{ secrets.DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL }} @@ -1300,15 +1122,7 @@ jobs: # npm docs: https://docs.npmjs.com/trusted-publishers publish-npm: # Publish to npm for stable releases and alpha pre-releases with numeric suffixes. - # promote_signed intentionally skips build jobs that are ancestors of release; - # include the !cancelled() status function so Actions does not apply its implicit - # success() check to the whole dependency chain before evaluating release outputs. - if: >- - ${{ - !cancelled() && - needs.release.result == 'success' && - needs.release.outputs.should_publish_npm == 'true' - }} + if: ${{ needs.release.outputs.should_publish_npm == 'true' && github.repository_owner == 'openai' }} name: publish-npm needs: release runs-on: ubuntu-latest @@ -1318,37 +1132,36 @@ jobs: steps: - name: Setup Node.js - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + uses: actions/setup-node@v6 with: - # Node 24 bundles npm >= 11.5.1, which trusted publishing requires. - node-version: 24 + node-version: 22 registry-url: "https://registry.npmjs.org" scope: "@openai" + # Trusted publishing requires npm CLI version 11.5.1 or later. + - name: Update npm + run: npm install -g npm@latest + - name: Download npm tarballs from release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_TAG: ${{ needs.release.outputs.tag }} - RELEASE_VERSION: ${{ needs.release.outputs.version }} run: | set -euo pipefail - version="$RELEASE_VERSION" - tag="$RELEASE_TAG" + version="${{ needs.release.outputs.version }}" + tag="${{ needs.release.outputs.tag }}" mkdir -p dist/npm - patterns=( - "codex-npm-${version}.tgz" - "codex-npm-linux-*-${version}.tgz" - "codex-npm-darwin-*-${version}.tgz" - "codex-npm-win32-*-${version}.tgz" - "codex-responses-api-proxy-npm-${version}.tgz" - "codex-sdk-npm-${version}.tgz" - ) - for pattern in "${patterns[@]}"; do - gh release download "$tag" \ - --repo "${GITHUB_REPOSITORY}" \ - --pattern "$pattern" \ - --dir dist/npm - done + gh release download "$tag" \ + --repo "${GITHUB_REPOSITORY}" \ + --pattern "codex-npm-${version}.tgz" \ + --dir dist/npm + gh release download "$tag" \ + --repo "${GITHUB_REPOSITORY}" \ + --pattern "codex-responses-api-proxy-npm-${version}.tgz" \ + --dir dist/npm + gh release download "$tag" \ + --repo "${GITHUB_REPOSITORY}" \ + --pattern "codex-sdk-npm-${version}.tgz" \ + --dir dist/npm # No NODE_AUTH_TOKEN needed because we use OIDC. - name: Publish to npm @@ -1357,193 +1170,23 @@ jobs: NPM_TAG: ${{ needs.release.outputs.npm_tag }} run: | set -euo pipefail - prefix="" + tag_args=() if [[ -n "${NPM_TAG}" ]]; then - prefix="${NPM_TAG}-" + tag_args+=(--tag "${NPM_TAG}") fi - root_tarball="dist/npm/codex-npm-${VERSION}.tgz" - sdk_tarball="dist/npm/codex-sdk-npm-${VERSION}.tgz" - # Keep this list in sync with CODEX_PLATFORM_PACKAGES in - # codex-cli/scripts/build_npm_package.py. The root wrapper advances - # @openai/codex@latest as soon as it publishes, so every platform - # package it aliases must already exist in the registry first. - platform_tarballs=( - "dist/npm/codex-npm-linux-x64-${VERSION}.tgz" - "dist/npm/codex-npm-linux-arm64-${VERSION}.tgz" - "dist/npm/codex-npm-darwin-x64-${VERSION}.tgz" - "dist/npm/codex-npm-darwin-arm64-${VERSION}.tgz" - "dist/npm/codex-npm-win32-x64-${VERSION}.tgz" - "dist/npm/codex-npm-win32-arm64-${VERSION}.tgz" - ) - - for required_tarball in "${platform_tarballs[@]}" "${root_tarball}"; do - if [[ ! -f "${required_tarball}" ]]; then - echo "Missing npm tarball: ${required_tarball}" - exit 1 - fi - done - - shopt -s nullglob - other_tarballs=() - for tarball in dist/npm/*-"${VERSION}".tgz; do - if [[ "${tarball}" == "${root_tarball}" || "${tarball}" == "${sdk_tarball}" ]]; then - continue - fi - - is_platform_tarball=false - for platform_tarball in "${platform_tarballs[@]}"; do - if [[ "${tarball}" == "${platform_tarball}" ]]; then - is_platform_tarball=true - break - fi - done - if [[ "${is_platform_tarball}" == true ]]; then - continue - fi - - other_tarballs+=("${tarball}") - done - - # Publish the platform packages before the root CLI wrapper. The root - # wrapper advances @openai/codex@latest, so it should only publish - # after the optional dependency versions it references exist. tarballs=( - "${platform_tarballs[@]}" - "${other_tarballs[@]}" - "${root_tarball}" + "codex-npm-${VERSION}.tgz" + "codex-responses-api-proxy-npm-${VERSION}.tgz" + "codex-sdk-npm-${VERSION}.tgz" ) - if [[ -f "${sdk_tarball}" ]]; then - tarballs+=("${sdk_tarball}") - fi for tarball in "${tarballs[@]}"; do - filename="$(basename "${tarball}")" - tag="" - - case "${filename}" in - codex-npm-linux-*-"${VERSION}".tgz|codex-npm-darwin-*-"${VERSION}".tgz|codex-npm-win32-*-"${VERSION}".tgz) - platform="${filename#codex-npm-}" - platform="${platform%-${VERSION}.tgz}" - tag="${prefix}${platform}" - ;; - codex-npm-"${VERSION}".tgz|codex-responses-api-proxy-npm-"${VERSION}".tgz|codex-sdk-npm-"${VERSION}".tgz) - tag="${NPM_TAG}" - ;; - *) - echo "Unexpected npm tarball: ${filename}" - exit 1 - ;; - esac - - publish_cmd=(npm publish "${GITHUB_WORKSPACE}/${tarball}") - if [[ -n "${tag}" ]]; then - publish_cmd+=(--tag "${tag}") - fi - - echo "+ ${publish_cmd[*]}" - set +e - publish_output="$("${publish_cmd[@]}" 2>&1)" - publish_status=$? - set -e - - echo "${publish_output}" - if [[ ${publish_status} -eq 0 ]]; then - continue - fi - - if grep -qiE "previously published|cannot publish over|version already exists" <<< "${publish_output}"; then - echo "Skipping already-published package version for ${filename}" - continue - fi - - exit "${publish_status}" + npm publish "${GITHUB_WORKSPACE}/dist/npm/${tarball}" "${tag_args[@]}" done - # Publish the platform-specific Python runtime wheels using PyPI trusted publishing. - # PyPI project configuration must trust this workflow and job. Keep this - # non-blocking while the Python runtime publishing path is new; failures still - # need release follow-up, but should not invalidate the Rust release itself. - publish-python-runtime: - # Publish to PyPI for stable releases and alpha pre-releases with numeric suffixes. - if: >- - ${{ - !cancelled() && - needs.release.result == 'success' && - needs.release.outputs.should_publish_python_runtime == 'true' - }} - name: publish-python-runtime - needs: release - runs-on: ubuntu-latest - continue-on-error: true - environment: pypi - permissions: - id-token: write # Required for PyPI trusted publishing. - contents: read - - steps: - - name: Download Python runtime wheels from release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_TAG: ${{ needs.release.outputs.tag }} - RELEASE_VERSION: ${{ needs.release.outputs.version }} - run: | - set -euo pipefail - python_version="$RELEASE_VERSION" - python_version="${python_version/-alpha./a}" - python_version="${python_version/-beta./b}" - python_version="${python_version/-rc./rc}" - - mkdir -p dist/python-runtime - gh release download "$RELEASE_TAG" \ - --repo "${GITHUB_REPOSITORY}" \ - --pattern "openai_codex_cli_bin-${python_version}-*.whl" \ - --dir dist/python-runtime - ls -lh dist/python-runtime - - - name: Publish Python runtime wheels to PyPI - uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 - with: - packages-dir: dist/python-runtime - skip-existing: true - - winget: - name: winget - needs: release - # Only publish stable/mainline releases to WinGet; pre-releases include a - # '-' in the semver string (e.g., 1.2.3-alpha.1). - if: >- - ${{ - !cancelled() && - needs.release.result == 'success' && - needs.release.outputs.sign_macos == 'true' && - !contains(needs.release.outputs.version, '-') - }} - # This job only invokes a GitHub Action to open/update the winget-pkgs PR; - # it does not execute Windows-only tooling, so Linux is sufficient. - runs-on: ubuntu-latest - permissions: - contents: read - - steps: - - name: Publish to WinGet - uses: vedantmgoyal9/winget-releaser@7bd472be23763def6e16bd06cc8b1cdfab0e2fd5 - with: - identifier: OpenAI.Codex - version: ${{ needs.release.outputs.version }} - release-tag: ${{ needs.release.outputs.tag }} - fork-user: openai-oss-forks - installers-regex: '^codex-(?:x86_64|aarch64)-pc-windows-msvc\.exe\.zip$' - token: ${{ secrets.WINGET_PUBLISH_PAT }} - update-branch: name: Update latest-alpha-cli branch - if: >- - ${{ - !cancelled() && - needs.release.result == 'success' && - needs.release.outputs.sign_macos == 'true' - }} permissions: contents: write needs: release diff --git a/.github/workflows/shell-tool-mcp.yml b/.github/workflows/shell-tool-mcp.yml new file mode 100644 index 000000000000..66a76aa4d938 --- /dev/null +++ b/.github/workflows/shell-tool-mcp.yml @@ -0,0 +1,461 @@ +name: shell-tool-mcp + +on: + workflow_call: + inputs: + release-version: + description: Version to publish (x.y.z or x.y.z-alpha.N). Defaults to GITHUB_REF_NAME when it starts with rust-v. + required: false + type: string + release-tag: + description: Tag name to use when downloading release artifacts (defaults to rust-v). + required: false + type: string + publish: + description: Whether to publish to npm when the version is releasable. + required: false + default: true + type: boolean + +env: + NODE_VERSION: 22 + +jobs: + metadata: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.compute.outputs.version }} + release_tag: ${{ steps.compute.outputs.release_tag }} + should_publish: ${{ steps.compute.outputs.should_publish }} + npm_tag: ${{ steps.compute.outputs.npm_tag }} + steps: + - name: Compute version and tags + id: compute + run: | + set -euo pipefail + + version="${{ inputs.release-version }}" + release_tag="${{ inputs.release-tag }}" + + if [[ -z "$version" ]]; then + if [[ -n "$release_tag" && "$release_tag" =~ ^rust-v.+ ]]; then + version="${release_tag#rust-v}" + elif [[ "${GITHUB_REF_NAME:-}" =~ ^rust-v.+ ]]; then + version="${GITHUB_REF_NAME#rust-v}" + release_tag="${GITHUB_REF_NAME}" + else + echo "release-version is required when GITHUB_REF_NAME is not a rust-v tag." + exit 1 + fi + fi + + if [[ -z "$release_tag" ]]; then + release_tag="rust-v${version}" + fi + + npm_tag="" + should_publish="false" + if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + should_publish="true" + elif [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+-alpha\.[0-9]+$ ]]; then + should_publish="true" + npm_tag="alpha" + fi + + echo "version=${version}" >> "$GITHUB_OUTPUT" + echo "release_tag=${release_tag}" >> "$GITHUB_OUTPUT" + echo "npm_tag=${npm_tag}" >> "$GITHUB_OUTPUT" + echo "should_publish=${should_publish}" >> "$GITHUB_OUTPUT" + + matrix-setup: + runs-on: ubuntu-latest + outputs: + rust-binaries: ${{ steps.compute.outputs.rust-binaries }} + bash-linux: ${{ steps.compute.outputs.bash-linux }} + bash-darwin: ${{ steps.compute.outputs.bash-darwin }} + steps: + - name: Compute matrices + id: compute + shell: bash + run: | + set -euo pipefail + is_openai="${{ github.repository_owner == 'openai' }}" + + # rust-binaries: always include x86_64-musl; conditionally include paid/fork runners + if [[ "$is_openai" == "true" ]]; then + rust='[ + {"runner":"macos-15-xlarge","target":"aarch64-apple-darwin"}, + {"runner":"macos-15-xlarge","target":"x86_64-apple-darwin"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","install_musl":true} + ]' + else + rust='[{"runner":"macos-latest","target":"aarch64-apple-darwin"}]' + fi + rust=$(echo "$rust" | jq -c '. + [{"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","install_musl":true}]') + echo "rust-binaries={\"include\":$rust}" >> "$GITHUB_OUTPUT" + + # bash-linux: always include x86_64 variants; add arm64 on openai + bash_linux='[ + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","variant":"ubuntu-24.04","image":"ubuntu:24.04"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","variant":"ubuntu-22.04","image":"ubuntu:22.04"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","variant":"debian-12","image":"debian:12"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","variant":"debian-11","image":"debian:11"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","variant":"centos-9","image":"quay.io/centos/centos:stream9"} + ]' + if [[ "$is_openai" == "true" ]]; then + bash_linux=$(echo "$bash_linux" | jq -c '. + [ + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"ubuntu-24.04","image":"arm64v8/ubuntu:24.04"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"ubuntu-22.04","image":"arm64v8/ubuntu:22.04"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"ubuntu-20.04","image":"arm64v8/ubuntu:20.04"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"debian-12","image":"arm64v8/debian:12"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"debian-11","image":"arm64v8/debian:11"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"centos-9","image":"quay.io/centos/centos:stream9"} + ]') + fi + echo "bash-linux={\"include\":$(echo "$bash_linux" | jq -c)}" >> "$GITHUB_OUTPUT" + + # bash-darwin: always include macos-14; add macos-15-xlarge on openai + bash_darwin='[{"runner":"macos-14","target":"aarch64-apple-darwin","variant":"macos-14"}]' + if [[ "$is_openai" == "true" ]]; then + bash_darwin=$(echo "$bash_darwin" | jq -c '. + [ + {"runner":"macos-15-xlarge","target":"aarch64-apple-darwin","variant":"macos-15"} + ]') + fi + echo "bash-darwin={\"include\":$(echo "$bash_darwin" | jq -c)}" >> "$GITHUB_OUTPUT" + + rust-binaries: + name: Build Rust - ${{ matrix.target }} + needs: [metadata, matrix-setup] + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + defaults: + run: + working-directory: codex-rs + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.matrix-setup.outputs.rust-binaries) }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Install UBSan runtime (musl) + if: ${{ matrix.install_musl }} + shell: bash + run: | + set -euo pipefail + if command -v apt-get >/dev/null 2>&1; then + sudo apt-get update -y + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y libubsan1 + fi + + - uses: dtolnay/rust-toolchain@1.93 + with: + targets: ${{ matrix.target }} + + - if: ${{ matrix.install_musl }} + name: Install Zig + uses: mlugg/setup-zig@v2 + with: + version: 0.14.0 + + - if: ${{ matrix.install_musl }} + name: Install musl build dependencies + env: + TARGET: ${{ matrix.target }} + run: bash "${GITHUB_WORKSPACE}/.github/scripts/install-musl-build-tools.sh" + + - if: ${{ matrix.install_musl }} + name: Configure rustc UBSan wrapper (musl host) + shell: bash + run: | + set -euo pipefail + ubsan="" + if command -v ldconfig >/dev/null 2>&1; then + ubsan="$(ldconfig -p | grep -m1 'libubsan\.so\.1' | sed -E 's/.*=> (.*)$/\1/')" + fi + wrapper_root="${RUNNER_TEMP:-/tmp}" + wrapper="${wrapper_root}/rustc-ubsan-wrapper" + cat > "${wrapper}" <> "$GITHUB_ENV" + echo "RUSTC_WORKSPACE_WRAPPER=" >> "$GITHUB_ENV" + + - if: ${{ matrix.install_musl }} + name: Clear sanitizer flags (musl) + shell: bash + run: | + set -euo pipefail + # Clear global Rust flags so host/proc-macro builds don't pull in UBSan. + echo "RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_ENCODED_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "RUSTDOCFLAGS=" >> "$GITHUB_ENV" + # Override any runner-level Cargo config rustflags as well. + echo "CARGO_BUILD_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_RUSTFLAGS=" >> "$GITHUB_ENV" + + sanitize_flags() { + local input="$1" + input="${input//-fsanitize=undefined/}" + input="${input//-fno-sanitize-recover=undefined/}" + input="${input//-fno-sanitize-trap=undefined/}" + echo "$input" + } + + cflags="$(sanitize_flags "${CFLAGS-}")" + cxxflags="$(sanitize_flags "${CXXFLAGS-}")" + echo "CFLAGS=${cflags}" >> "$GITHUB_ENV" + echo "CXXFLAGS=${cxxflags}" >> "$GITHUB_ENV" + + - name: Build exec server binaries + run: cargo build --release --target ${{ matrix.target }} --bin codex-exec-mcp-server --bin codex-execve-wrapper + + - name: Stage exec server binaries + run: | + dest="${GITHUB_WORKSPACE}/artifacts/vendor/${{ matrix.target }}" + mkdir -p "$dest" + cp "target/${{ matrix.target }}/release/codex-exec-mcp-server" "$dest/" + cp "target/${{ matrix.target }}/release/codex-execve-wrapper" "$dest/" + + - uses: actions/upload-artifact@v6 + with: + name: shell-tool-mcp-rust-${{ matrix.target }} + path: artifacts/** + if-no-files-found: error + + bash-linux: + name: Build Bash (Linux) - ${{ matrix.variant }} - ${{ matrix.target }} + needs: [metadata, matrix-setup] + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + container: + image: ${{ matrix.image }} + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.matrix-setup.outputs.bash-linux) }} + steps: + - name: Install build prerequisites + shell: bash + run: | + set -euo pipefail + if command -v apt-get >/dev/null 2>&1; then + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y git build-essential bison autoconf gettext + elif command -v dnf >/dev/null 2>&1; then + dnf install -y git gcc gcc-c++ make bison autoconf gettext + elif command -v yum >/dev/null 2>&1; then + yum install -y git gcc gcc-c++ make bison autoconf gettext + else + echo "Unsupported package manager in container" + exit 1 + fi + + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Build patched Bash + shell: bash + run: | + set -euo pipefail + git clone --depth 1 https://github.com/bolinfest/bash /tmp/bash + cd /tmp/bash + git fetch --depth 1 origin a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b + git checkout a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b + git apply "${GITHUB_WORKSPACE}/shell-tool-mcp/patches/bash-exec-wrapper.patch" + ./configure --without-bash-malloc + cores="$(command -v nproc >/dev/null 2>&1 && nproc || getconf _NPROCESSORS_ONLN)" + make -j"${cores}" + + dest="${GITHUB_WORKSPACE}/artifacts/vendor/${{ matrix.target }}/bash/${{ matrix.variant }}" + mkdir -p "$dest" + cp bash "$dest/bash" + + - uses: actions/upload-artifact@v6 + with: + name: shell-tool-mcp-bash-${{ matrix.target }}-${{ matrix.variant }} + path: artifacts/** + if-no-files-found: error + + bash-darwin: + name: Build Bash (macOS) - ${{ matrix.variant }} - ${{ matrix.target }} + needs: [metadata, matrix-setup] + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.matrix-setup.outputs.bash-darwin) }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Build patched Bash + shell: bash + run: | + set -euo pipefail + git clone --depth 1 https://github.com/bolinfest/bash /tmp/bash + cd /tmp/bash + git fetch --depth 1 origin a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b + git checkout a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b + git apply "${GITHUB_WORKSPACE}/shell-tool-mcp/patches/bash-exec-wrapper.patch" + ./configure --without-bash-malloc + cores="$(getconf _NPROCESSORS_ONLN)" + make -j"${cores}" + + dest="${GITHUB_WORKSPACE}/artifacts/vendor/${{ matrix.target }}/bash/${{ matrix.variant }}" + mkdir -p "$dest" + cp bash "$dest/bash" + + - uses: actions/upload-artifact@v6 + with: + name: shell-tool-mcp-bash-${{ matrix.target }}-${{ matrix.variant }} + path: artifacts/** + if-no-files-found: error + + package: + name: Package npm module + needs: + - metadata + - rust-binaries + - bash-linux + - bash-darwin + runs-on: ubuntu-latest + env: + PACKAGE_VERSION: ${{ needs.metadata.outputs.version }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Install JavaScript dependencies + run: pnpm install --frozen-lockfile + + - name: Build (shell-tool-mcp) + run: pnpm --filter @openai/codex-shell-tool-mcp run build + + - name: Download build artifacts + uses: actions/download-artifact@v7 + with: + path: artifacts + + - name: Assemble staging directory + id: staging + shell: bash + run: | + set -euo pipefail + staging="${STAGING_DIR}" + mkdir -p "$staging" "$staging/vendor" + cp shell-tool-mcp/README.md "$staging/" + cp shell-tool-mcp/package.json "$staging/" + cp -R shell-tool-mcp/bin "$staging/" + + found_vendor="false" + shopt -s nullglob + for vendor_dir in artifacts/*/vendor; do + rsync -av "$vendor_dir/" "$staging/vendor/" + found_vendor="true" + done + if [[ "$found_vendor" == "false" ]]; then + echo "No vendor payloads were downloaded." + exit 1 + fi + + node - <<'NODE' + import fs from "node:fs"; + import path from "node:path"; + + const stagingDir = process.env.STAGING_DIR; + const version = process.env.PACKAGE_VERSION; + const pkgPath = path.join(stagingDir, "package.json"); + const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")); + pkg.version = version; + fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n"); + NODE + + echo "dir=$staging" >> "$GITHUB_OUTPUT" + env: + STAGING_DIR: ${{ runner.temp }}/shell-tool-mcp + + - name: Ensure binaries are executable + run: | + set -euo pipefail + staging="${{ steps.staging.outputs.dir }}" + chmod +x \ + "$staging"/vendor/*/codex-exec-mcp-server \ + "$staging"/vendor/*/codex-execve-wrapper \ + "$staging"/vendor/*/bash/*/bash + + - name: Create npm tarball + shell: bash + run: | + set -euo pipefail + mkdir -p dist/npm + staging="${{ steps.staging.outputs.dir }}" + pack_info=$(cd "$staging" && npm pack --ignore-scripts --json --pack-destination "${GITHUB_WORKSPACE}/dist/npm") + filename=$(PACK_INFO="$pack_info" node -e 'const data = JSON.parse(process.env.PACK_INFO); console.log(data[0].filename);') + mv "dist/npm/${filename}" "dist/npm/codex-shell-tool-mcp-npm-${PACKAGE_VERSION}.tgz" + + - uses: actions/upload-artifact@v6 + with: + name: codex-shell-tool-mcp-npm + path: dist/npm/codex-shell-tool-mcp-npm-${{ env.PACKAGE_VERSION }}.tgz + if-no-files-found: error + + publish: + name: Publish npm package + needs: + - metadata + - package + if: ${{ inputs.publish && needs.metadata.outputs.should_publish == 'true' }} + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + steps: + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + registry-url: https://registry.npmjs.org + scope: "@openai" + + # Trusted publishing requires npm CLI version 11.5.1 or later. + - name: Update npm + run: npm install -g npm@latest + + - name: Download npm tarball + uses: actions/download-artifact@v7 + with: + name: codex-shell-tool-mcp-npm + path: dist/npm + + - name: Publish to npm + if: github.repository == 'openai/codex' + env: + NPM_TAG: ${{ needs.metadata.outputs.npm_tag }} + VERSION: ${{ needs.metadata.outputs.version }} + shell: bash + run: | + set -euo pipefail + tag_args=() + if [[ -n "${NPM_TAG}" ]]; then + tag_args+=(--tag "${NPM_TAG}") + fi + npm publish "dist/npm/codex-shell-tool-mcp-npm-${VERSION}.tgz" "${tag_args[@]}" diff --git a/.github/workflows/termux-release-checkpoint.yml b/.github/workflows/termux-release-checkpoint.yml new file mode 100644 index 000000000000..0347e6f1b3d6 --- /dev/null +++ b/.github/workflows/termux-release-checkpoint.yml @@ -0,0 +1,103 @@ +name: termux-release-checkpoint + +on: + workflow_dispatch: + inputs: + source_branch: + description: "Release branch to checkpoint from, for example release/0.123.0" + required: false + type: string + default: "" + source_sha: + description: "Specific source commit SHA to checkpoint; defaults to the source branch tip" + required: false + type: string + default: "" + destination_branch: + description: "Destination patch branch to receive the checkpoint PR" + required: false + type: string + default: "wallentx/termux-target" + reviewer: + description: "GitHub username to request as reviewer" + required: false + type: string + default: "wallentx" + +permissions: + actions: read + attestations: read + checks: read + contents: read + deployments: read + issues: read + discussions: read + packages: read + pages: read + pull-requests: read + repository-projects: read + statuses: read + +concurrency: + group: termux-release-checkpoint-${{ inputs.source_branch || github.ref_name }} + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + checkpoint: + runs-on: ubuntu-slim + permissions: + contents: write + issues: write + pull-requests: write + env: + DESTINATION_BRANCH: ${{ inputs.destination_branch || 'wallentx/termux-target' }} + REVIEWER: ${{ inputs.reviewer || 'wallentx' }} + REQUESTED_SOURCE_BRANCH: ${{ inputs.source_branch }} + REQUESTED_SOURCE_SHA: ${{ inputs.source_sha }} + TERMUX_AUTOMATION_DIR: ${{ github.workspace }}/.termux-release-automation + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Export GitHub App token for gh + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + run: echo "GH_TOKEN=${APP_TOKEN}" >> "${GITHUB_ENV}" + + - name: Checkout release branch + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ inputs.source_branch || github.ref_name }} + token: ${{ steps.app-token.outputs.token }} + + - name: Checkout automation helpers + uses: actions/checkout@v6 + with: + fetch-depth: 1 + ref: ${{ github.workflow_sha }} + path: .termux-release-automation + token: ${{ steps.app-token.outputs.token }} + + - name: 🧰 Actions Toolbox + uses: wallentx/gh-actions/composite/actions-toolbox@main + + - name: Validate GitHub CLI environment + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-validate-gh-env.sh" + + - name: Configure git + run: | + set -euo pipefail + source_branch="${REQUESTED_SOURCE_BRANCH:-${GITHUB_REF_NAME}}" + bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-configure-git.sh" --origin "${DESTINATION_BRANCH}" "${source_branch}" + + - name: Create checkpoint PR + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-create-checkpoint-pr.sh" diff --git a/.github/workflows/termux-release-deploy.yml b/.github/workflows/termux-release-deploy.yml new file mode 100644 index 000000000000..2071611dacc4 --- /dev/null +++ b/.github/workflows/termux-release-deploy.yml @@ -0,0 +1,243 @@ +name: termux-release-deploy + +on: + push: + branches: + - "release/**" + workflow_dispatch: + inputs: + release_branch: + description: "Release branch to deploy, for example release/0.124.0" + required: true + type: string + release_sha: + description: "Release branch commit SHA to deploy. Defaults to the branch head." + required: false + default: "" + type: string + pr_number: + description: "Merged release PR number to promote. Optional; normally discovered automatically." + required: false + default: "" + type: string + pr_head_sha: + description: "Merged release PR head SHA. Required only when pr_number is set." + required: false + default: "" + type: string + destination_branch: + description: "Destination patch branch to receive the checkpoint PR" + required: false + default: "wallentx/termux-target" + type: string + reviewer: + description: "GitHub username to request as reviewer on the checkpoint PR" + required: false + default: "wallentx" + type: string + +permissions: + actions: read + attestations: read + checks: read + contents: read + deployments: read + issues: read + discussions: read + packages: read + pages: read + pull-requests: read + repository-projects: read + statuses: read + +concurrency: + group: termux-release-deploy-${{ github.event_name == 'workflow_dispatch' && inputs.release_branch || github.ref_name }} + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + deploy: + runs-on: ubuntu-24.04 + if: ${{ github.event_name == 'workflow_dispatch' || !startsWith(github.event.head_commit.message, 'Seed Termux release automation') }} + permissions: + actions: read + contents: write + deployments: write + issues: write + pull-requests: write + env: + REQUESTED_RELEASE_BRANCH: ${{ inputs.release_branch }} + REQUESTED_RELEASE_SHA: ${{ inputs.release_sha }} + INPUT_PR_NUMBER: ${{ inputs.pr_number }} + INPUT_PR_HEAD_SHA: ${{ inputs.pr_head_sha }} + DESTINATION_BRANCH: ${{ inputs.destination_branch || 'wallentx/termux-target' }} + REVIEWER: ${{ inputs.reviewer || 'wallentx' }} + TERMUX_AUTOMATION_DIR: ${{ github.workspace }}/.termux-release-automation + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Export GitHub App token for gh + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + run: echo "GH_TOKEN=${APP_TOKEN}" >> "${GITHUB_ENV}" + + - name: Checkout release branch + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.release_branch || github.ref }} + token: ${{ steps.app-token.outputs.token }} + + - name: Checkout automation helpers + uses: actions/checkout@v6 + with: + fetch-depth: 1 + ref: ${{ github.workflow_sha }} + path: .termux-release-automation + token: ${{ steps.app-token.outputs.token }} + + - name: 🧰 Actions Toolbox + uses: wallentx/gh-actions/composite/actions-toolbox@main + + - name: Validate GitHub CLI environment + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-validate-gh-env.sh" + + - name: Configure git + run: | + set -euo pipefail + release_branch="${REQUESTED_RELEASE_BRANCH:-${GITHUB_REF_NAME}}" + bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-configure-git.sh" --origin "${DESTINATION_BRANCH}" "${release_branch}" + + - name: Resolve release ref + id: release-ref + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-resolve-release-ref.sh" + + - name: Read release metadata + id: metadata + env: + TERMUX_RELEASE_ACTION: deploy + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-read-release-metadata.sh" + + - name: Create deployment + if: steps.metadata.outputs.deploy == 'true' + id: deployment + env: + GH_TOKEN: ${{ github.token }} + RELEASE_SHA: ${{ steps.release-ref.outputs.sha }} + TERMUX_TAG: ${{ steps.metadata.outputs.termux_tag }} + run: | + set -euo pipefail + deployment_id="$( + gh api \ + -X POST \ + "repos/${GITHUB_REPOSITORY}/deployments" \ + -f ref="${RELEASE_SHA}" \ + -f environment="termux-release" \ + -F auto_merge=false \ + -F required_contexts[] \ + -f description="Termux release deployment for ${TERMUX_TAG}" \ + --jq '.id' + )" + echo "id=${deployment_id}" >> "$GITHUB_OUTPUT" + + - name: Mark deployment in progress + if: steps.metadata.outputs.deploy == 'true' + env: + GH_TOKEN: ${{ github.token }} + DEPLOYMENT_ID: ${{ steps.deployment.outputs.id }} + run: | + set -euo pipefail + log_url="${GH_WORKFLOW_URL:-${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}}" + gh api \ + -X POST \ + "repos/${GITHUB_REPOSITORY}/deployments/${DEPLOYMENT_ID}/statuses" \ + -f state="in_progress" \ + -f environment="termux-release" \ + -f log_url="${log_url}" \ + -F auto_inactive=false \ + -f description="Promoting Termux release artifact and preparing checkpoint PR" \ + >/dev/null + + - name: Locate merged pull request + if: steps.metadata.outputs.deploy == 'true' && steps.metadata.outputs.asset_exists != 'true' + id: pr + env: + RELEASE_BRANCH: ${{ steps.release-ref.outputs.branch }} + RELEASE_SHA: ${{ steps.release-ref.outputs.sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-find-release-pr.sh" + + - name: Download promoted PR artifact + if: steps.metadata.outputs.deploy == 'true' && steps.metadata.outputs.asset_exists != 'true' + env: + PR_ARTIFACT_NAME: ${{ steps.pr.outputs.artifact_name }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-download-release-artifact.sh" + + - name: Create or update mirrored Termux release + if: steps.metadata.outputs.deploy == 'true' && steps.metadata.outputs.asset_exists != 'true' + env: + UPSTREAM_TAG: ${{ steps.metadata.outputs.upstream_tag }} + UPSTREAM_REPO: ${{ steps.metadata.outputs.upstream_repo }} + UPSTREAM_NAME: ${{ steps.metadata.outputs.upstream_name }} + TERMUX_TAG: ${{ steps.metadata.outputs.termux_tag }} + UPSTREAM_PRERELEASE: ${{ steps.metadata.outputs.upstream_prerelease }} + UPSTREAM_HTML_URL: ${{ steps.metadata.outputs.upstream_html_url }} + RELEASE_TRAIN: ${{ steps.metadata.outputs.release_train }} + RELEASE_EXISTS: ${{ steps.metadata.outputs.release_exists }} + PR_NUMBER: ${{ steps.pr.outputs.number }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + RELEASE_SHA: ${{ steps.release-ref.outputs.sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-create-or-update-mirrored-release.sh" + + - name: Ensure checkpoint PR + if: steps.metadata.outputs.deploy == 'true' + id: checkpoint + env: + SOURCE_BRANCH: ${{ steps.release-ref.outputs.branch }} + SOURCE_SHA: ${{ steps.release-ref.outputs.sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-create-checkpoint-pr.sh" + + - name: Mark deployment success + if: steps.metadata.outputs.deploy == 'true' + env: + GH_TOKEN: ${{ github.token }} + DEPLOYMENT_ID: ${{ steps.deployment.outputs.id }} + TERMUX_TAG: ${{ steps.metadata.outputs.termux_tag }} + run: | + set -euo pipefail + log_url="${GH_WORKFLOW_URL:-${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}}" + gh api \ + -X POST \ + "repos/${GITHUB_REPOSITORY}/deployments/${DEPLOYMENT_ID}/statuses" \ + -f state="success" \ + -f environment="termux-release" \ + -f log_url="${log_url}" \ + -F auto_inactive=false \ + -f description="Termux release deployment completed for ${TERMUX_TAG}" \ + >/dev/null + + - name: Mark deployment failure + if: failure() && steps.deployment.outputs.id != '' + env: + GH_TOKEN: ${{ github.token }} + DEPLOYMENT_ID: ${{ steps.deployment.outputs.id }} + run: | + set -euo pipefail + log_url="${GH_WORKFLOW_URL:-${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}}" + gh api \ + -X POST \ + "repos/${GITHUB_REPOSITORY}/deployments/${DEPLOYMENT_ID}/statuses" \ + -f state="failure" \ + -f environment="termux-release" \ + -f log_url="${log_url}" \ + -F auto_inactive=false \ + -f description="Termux release deployment failed" \ + >/dev/null diff --git a/.github/workflows/termux-release-promote.yml b/.github/workflows/termux-release-promote.yml new file mode 100644 index 000000000000..22c277c21a70 --- /dev/null +++ b/.github/workflows/termux-release-promote.yml @@ -0,0 +1,135 @@ +name: termux-release-promote + +on: + workflow_dispatch: + inputs: + release_branch: + description: "Release branch to promote, for example release/0.122.0" + required: true + type: string + release_sha: + description: "Release branch commit SHA to promote. Defaults to the branch head." + required: false + default: "" + type: string + pr_number: + description: "Merged PR number to promote. Optional; normally discovered automatically." + required: false + default: "" + type: string + pr_head_sha: + description: "Merged PR head SHA. Required only when pr_number is set." + required: false + default: "" + type: string + +permissions: + actions: read + attestations: read + checks: read + contents: read + deployments: read + issues: read + discussions: read + packages: read + pages: read + pull-requests: read + repository-projects: read + statuses: read + +concurrency: + group: termux-release-promote-${{ github.event_name == 'workflow_dispatch' && inputs.release_branch || github.ref_name }} + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + promote: + runs-on: ubuntu-24.04 + permissions: + actions: read + contents: write + pull-requests: read + env: + TERMUX_AUTOMATION_DIR: ${{ github.workspace }}/.termux-release-automation + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Export GitHub App token for gh + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + run: echo "GH_TOKEN=${APP_TOKEN}" >> "${GITHUB_ENV}" + + - name: Checkout release branch + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.release_branch || github.ref }} + token: ${{ steps.app-token.outputs.token }} + + - name: Checkout automation helpers + uses: actions/checkout@v6 + with: + fetch-depth: 1 + ref: ${{ github.workflow_sha }} + path: .termux-release-automation + token: ${{ steps.app-token.outputs.token }} + + - name: 🧰 Actions Toolbox + uses: wallentx/gh-actions/composite/actions-toolbox@main + + - name: Validate GitHub CLI environment + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-validate-gh-env.sh" + + - name: Resolve release ref + id: release-ref + env: + INPUT_RELEASE_BRANCH: ${{ inputs.release_branch }} + INPUT_RELEASE_SHA: ${{ inputs.release_sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-resolve-release-ref.sh" + + - name: Read release metadata + id: metadata + env: + TERMUX_RELEASE_ACTION: promote + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-read-release-metadata.sh" + + - name: Locate merged pull request + if: steps.metadata.outputs.promote == 'true' + id: pr + env: + RELEASE_BRANCH: ${{ steps.release-ref.outputs.branch }} + RELEASE_SHA: ${{ steps.release-ref.outputs.sha }} + INPUT_PR_NUMBER: ${{ inputs.pr_number }} + INPUT_PR_HEAD_SHA: ${{ inputs.pr_head_sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-find-release-pr.sh" + + - name: Download promoted PR artifact + if: steps.metadata.outputs.promote == 'true' + env: + PR_ARTIFACT_NAME: ${{ steps.pr.outputs.artifact_name }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-download-release-artifact.sh" + + - name: Create or update mirrored Termux release + if: steps.metadata.outputs.promote == 'true' + env: + UPSTREAM_TAG: ${{ steps.metadata.outputs.upstream_tag }} + UPSTREAM_REPO: ${{ steps.metadata.outputs.upstream_repo }} + UPSTREAM_NAME: ${{ steps.metadata.outputs.upstream_name }} + TERMUX_TAG: ${{ steps.metadata.outputs.termux_tag }} + UPSTREAM_PRERELEASE: ${{ steps.metadata.outputs.upstream_prerelease }} + UPSTREAM_HTML_URL: ${{ steps.metadata.outputs.upstream_html_url }} + RELEASE_TRAIN: ${{ steps.metadata.outputs.release_train }} + RELEASE_EXISTS: ${{ steps.metadata.outputs.release_exists }} + PR_NUMBER: ${{ steps.pr.outputs.number }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + RELEASE_SHA: ${{ steps.release-ref.outputs.sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-create-or-update-mirrored-release.sh" diff --git a/scripts/termux-configure-git.sh b/scripts/termux-configure-git.sh new file mode 100755 index 000000000000..fae3abcbab48 --- /dev/null +++ b/scripts/termux-configure-git.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash + +# Configure the explicit bot identity used by release automation and fetch any +# refs requested by the calling workflow. + +set -euo pipefail + +git config user.name "github-actions[bot]" +git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + +while (($#)); do + case "$1" in + --origin) + shift + origin_refs=() + while (($#)) && [[ "$1" != --* ]]; do + origin_refs+=("$1") + shift + done + if ((${#origin_refs[@]})); then + git fetch --prune origin "${origin_refs[@]}" + fi + ;; + --upstream-tag) + if (($# < 3)); then + echo "--upstream-tag requires ." >&2 + exit 1 + fi + upstream_repo="$2" + upstream_tag="$3" + git remote add upstream "https://github.com/${upstream_repo}.git" 2>/dev/null || true + git fetch --prune --no-tags upstream "+refs/tags/${upstream_tag}:refs/tags/${upstream_tag}" + shift 3 + ;; + *) + echo "Unknown argument: $1" >&2 + exit 1 + ;; + esac +done diff --git a/scripts/termux-create-checkpoint-pr.sh b/scripts/termux-create-checkpoint-pr.sh new file mode 100755 index 000000000000..f315a5b91822 --- /dev/null +++ b/scripts/termux-create-checkpoint-pr.sh @@ -0,0 +1,275 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/termux-release-paths.sh +source "${script_dir}/termux-release-paths.sh" + +source_branch="${SOURCE_BRANCH:-${REQUESTED_SOURCE_BRANCH:-${GITHUB_REF_NAME}}}" +source_sha="${SOURCE_SHA:-${REQUESTED_SOURCE_SHA:-}}" +if [[ -z "${source_sha}" ]]; then + if [[ "${GITHUB_EVENT_NAME:-}" == "push" && "${source_branch}" == "${GITHUB_REF_NAME:-}" ]]; then + source_sha="${GITHUB_SHA}" + else + source_sha="$(git rev-parse "origin/${source_branch}")" + fi +fi + +if [[ -z "${DESTINATION_BRANCH:-}" ]]; then + echo "DESTINATION_BRANCH is required." >&2 + exit 1 +fi + +release_only_checkpoint_paths() { + printf '%s\n' "${TERMUX_RELEASE_BRANCH_SCRIPT_PATHS[@]}" +} + +resolve_source_version_conflicts() { + local path="$1" + local resolved_path + resolved_path="$(mktemp)" + + if ! awk ' + function normalize_versions(text) { + gsub(/version = "[^"]+"/, "version = \"\"", text) + return text + } + + BEGIN { + in_block = 0 + side = "" + ours = "" + theirs = "" + blocks = 0 + } + + /^<<<<<<< / { + if (in_block) { + exit 1 + } + in_block = 1 + side = "ours" + ours = "" + theirs = "" + blocks++ + next + } + + /^=======$/ && in_block { + side = "theirs" + next + } + + /^>>>>>>> / && in_block { + if (normalize_versions(ours) != normalize_versions(theirs)) { + exit 1 + } + printf "%s", theirs + in_block = 0 + side = "" + next + } + + { + if (!in_block) { + print + } else if (side == "ours") { + ours = ours $0 ORS + } else if (side == "theirs") { + theirs = theirs $0 ORS + } else { + exit 1 + } + } + + END { + if (in_block || blocks == 0) { + exit 1 + } + } + ' "${path}" > "${resolved_path}"; then + rm -f "${resolved_path}" + return 1 + fi + + cp "${resolved_path}" "${path}" + rm -f "${resolved_path}" +} + +short_sha="${source_sha:0:12}" +source_slug="${source_branch//\//_}" +dest_slug="${DESTINATION_BRANCH//\//_}" +checkpoint_branch="checkpoint/${dest_slug}_from_${source_slug}_${short_sha}" +pr_title="checkpoint: into ${DESTINATION_BRANCH} from ${source_branch} @ ${short_sha}" +merge_conflicted=false +conflict_summary="" + +existing_pr="$( + gh pr list \ + --repo "${GITHUB_REPOSITORY}" \ + --head "${checkpoint_branch}" \ + --state all \ + --json number,state,mergedAt,url \ + --jq '[.[] | select(.state == "OPEN" or .mergedAt != null)] | .[0] // empty' +)" +if [[ -n "${existing_pr}" ]]; then + existing_url="$(jq -r '.url' <<< "${existing_pr}")" + existing_state="$(jq -r '.state' <<< "${existing_pr}")" + echo "Checkpoint PR already exists for ${checkpoint_branch}: ${existing_url} (${existing_state})." + if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + echo "pr_url=${existing_url}" >> "${GITHUB_OUTPUT}" + fi + exit 0 +fi + +git checkout -B "${checkpoint_branch}" "origin/${DESTINATION_BRANCH}" + +if ! git merge --no-ff --no-edit "${source_sha}"; then + mapfile -t conflicted_paths < <(git diff --name-only --diff-filter=U) + for conflicted_path in "${conflicted_paths[@]}"; do + if termux_is_checkpoint_release_only_path "${conflicted_path}"; then + echo "Auto-resolving release-only checkpoint conflict in ${conflicted_path} by keeping ${DESTINATION_BRANCH}." + if git cat-file -e "HEAD:${conflicted_path}" 2>/dev/null; then + git checkout --ours -- "${conflicted_path}" + git add "${conflicted_path}" + else + git rm -f --ignore-unmatch "${conflicted_path}" + fi + fi + done + + mapfile -t remaining_conflicts < <(git diff --name-only --diff-filter=U) + if ((${#remaining_conflicts[@]})); then + cargo_version_conflicts=true + for remaining_conflict in "${remaining_conflicts[@]}"; do + case "${remaining_conflict}" in + codex-rs/Cargo.toml|codex-rs/Cargo.lock) + ;; + *) + cargo_version_conflicts=false + ;; + esac + done + + if [[ "${cargo_version_conflicts}" == "true" ]]; then + for remaining_conflict in "${remaining_conflicts[@]}"; do + if ! resolve_source_version_conflicts "${remaining_conflict}"; then + cargo_version_conflicts=false + break + fi + done + + if [[ "${cargo_version_conflicts}" == "true" ]]; then + echo "Auto-resolving recurring Cargo version checkpoint conflicts by keeping ${source_branch} versions." + git add -- "${remaining_conflicts[@]}" + fi + fi + fi + + mapfile -t remaining_conflicts < <(git diff --name-only --diff-filter=U) + if [[ "${#remaining_conflicts[@]}" -eq 0 ]]; then + git commit --no-edit + else + merge_conflicted=true + conflict_summary="$( + printf '%s\n' "${remaining_conflicts[@]}" | awk '{ print "- `" $0 "`" }' + )" + echo "Automatic checkpoint merge failed; creating a manual-resolution PR instead." >&2 + if git rev-parse -q --verify MERGE_HEAD >/dev/null; then + git merge --abort + fi + git checkout -B "${checkpoint_branch}" "${source_sha}" + fi +fi + +if git cat-file -e "origin/${DESTINATION_BRANCH}:.github" 2>/dev/null; then + git checkout "origin/${DESTINATION_BRANCH}" -- .github + mapfile -t added_github_paths < <( + git diff --name-only --diff-filter=A "origin/${DESTINATION_BRANCH}" -- .github + ) + if ((${#added_github_paths[@]})); then + git rm -f --ignore-unmatch -- "${added_github_paths[@]}" + fi +else + git rm -r --ignore-unmatch .github +fi + +while IFS= read -r release_only_path; do + if git cat-file -e "origin/${DESTINATION_BRANCH}:${release_only_path}" 2>/dev/null; then + git checkout "origin/${DESTINATION_BRANCH}" -- "${release_only_path}" + else + git rm -f --ignore-unmatch -- "${release_only_path}" + fi +done < <(release_only_checkpoint_paths) + +if ! git diff --quiet || ! git diff --cached --quiet; then + if [[ -e .github || -L .github ]] || git ls-files --error-unmatch -- .github >/dev/null 2>&1; then + git add -A .github + fi + while IFS= read -r release_only_path; do + if [[ -e "${release_only_path}" || -L "${release_only_path}" ]] || git ls-files --error-unmatch -- "${release_only_path}" >/dev/null 2>&1; then + git add -A -- "${release_only_path}" + fi + done < <(release_only_checkpoint_paths) + if [[ "${merge_conflicted}" == "true" ]]; then + git commit -m "checkpoint: prepare ${source_branch} for ${DESTINATION_BRANCH}" + else + git commit --amend --no-edit + fi +fi + +if git diff --quiet "origin/${DESTINATION_BRANCH}" HEAD; then + echo "Checkpoint merge produced no destination changes after release-only files were restored." + exit 0 +fi + +git push --force-with-lease origin "${checkpoint_branch}" + +remaining="$( + git log --first-parent --pretty=format:%H "${source_sha}..origin/${source_branch}" | wc -w +)" + +body_path="${RUNNER_TEMP}/termux-checkpoint-pr.md" +{ + echo "## Termux release checkpoint" + echo + echo "- Source branch: \`${source_branch}\`" + echo "- Source hash: \`${source_sha}\`" + echo "- Destination branch: \`${DESTINATION_BRANCH}\`" + echo "- Remaining first-parent commits on source: ${remaining}" + echo + echo "This PR carries release-train conflict fixes and follow-up changes back into the reusable Termux patch branch." + if [[ "${merge_conflicted}" == "true" ]]; then + echo + echo "## Merge conflicts" + echo + echo "GitHub Actions could not create the checkpoint merge commit automatically, so this PR was created from the source branch state for manual conflict resolution." + echo + echo "Conflicted paths from the failed merge attempt:" + if [[ -n "${conflict_summary}" ]]; then + printf '%s\n' "${conflict_summary}" + else + echo "- Conflict details unavailable" + fi + fi + echo + echo "Release-only workflow files and metadata under \`.github\` were restored to the destination branch versions before opening this PR." +} > "${body_path}" + +pr_url="$( + gh pr create \ + --repo "${GITHUB_REPOSITORY}" \ + --base "${DESTINATION_BRANCH}" \ + --head "${checkpoint_branch}" \ + --title "${pr_title}" \ + --body-file "${body_path}" +)" +gh pr edit "${pr_url}" --repo "${GITHUB_REPOSITORY}" --add-reviewer "${REVIEWER}" || true +gh label create checkpoint --repo "${GITHUB_REPOSITORY}" --color c5def5 --description "Checkpoint merge" --force +gh label create termux-release --repo "${GITHUB_REPOSITORY}" --color 0e8a16 --description "Termux release automation" --force +gh pr edit "${pr_url}" --repo "${GITHUB_REPOSITORY}" --add-label "checkpoint" --add-label "termux-release" + +if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + echo "pr_url=${pr_url}" >> "${GITHUB_OUTPUT}" +fi diff --git a/scripts/termux-create-or-update-mirrored-release.sh b/scripts/termux-create-or-update-mirrored-release.sh new file mode 100755 index 000000000000..5787e894582b --- /dev/null +++ b/scripts/termux-create-or-update-mirrored-release.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash + +# Create a mirrored Termux release or repair an existing release that is missing +# the Android tarball. + +set -euo pipefail + +: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" +: "${GH_TOKEN:?GH_TOKEN is required}" +: "${RUNNER_TEMP:?RUNNER_TEMP is required}" +: "${UPSTREAM_TAG:?UPSTREAM_TAG is required}" +: "${UPSTREAM_REPO:?UPSTREAM_REPO is required}" +: "${UPSTREAM_NAME?UPSTREAM_NAME is required}" +: "${TERMUX_TAG:?TERMUX_TAG is required}" +: "${UPSTREAM_PRERELEASE:?UPSTREAM_PRERELEASE is required}" +: "${UPSTREAM_HTML_URL?UPSTREAM_HTML_URL is required}" +: "${RELEASE_TRAIN?RELEASE_TRAIN is required}" +: "${RELEASE_EXISTS:?RELEASE_EXISTS is required}" +: "${PR_NUMBER:?PR_NUMBER is required}" +: "${HEAD_SHA:?HEAD_SHA is required}" +: "${RELEASE_SHA:?RELEASE_SHA is required}" + +promoted_dir="${PROMOTED_DIR:-promoted}" +asset_path="${promoted_dir}/codex-aarch64-linux-android.tar.gz" + +body_path="${RUNNER_TEMP}/release-body.md" +upstream_body_path="${RUNNER_TEMP}/upstream-release-body.md" +upstream_body_without_changelog_path="${RUNNER_TEMP}/upstream-release-body-without-changelog.md" +if gh release view "${UPSTREAM_TAG}" \ + --repo "${UPSTREAM_REPO}" \ + --json body \ + --jq '.body // ""' > "${upstream_body_path}"; then + awk ' + function heading_level(line, text) { + if (match(line, /^(#{1,6})[[:space:]]+(.+)$/, parts)) { + text = tolower(parts[2]) + sub(/[[:space:]]+#+[[:space:]]*$/, "", text) + gsub(/^[[:space:]]+|[[:space:]]+$/, "", text) + if (text == "changelog" || text == "change log") { + return length(parts[1]) + } + } + return 0 + } + + { + if (!skip) { + level = heading_level($0) + if (level > 0) { + skip = 1 + skip_level = level + next + } + print + next + } + + if (match($0, /^(#{1,6})[[:space:]]+/, parts) && length(parts[1]) <= skip_level) { + skip = 0 + print + } + } + ' "${upstream_body_path}" > "${upstream_body_without_changelog_path}" +else + echo "::warning title=Upstream release notes unavailable::Could not read ${UPSTREAM_REPO} release ${UPSTREAM_TAG}." + : > "${upstream_body_without_changelog_path}" +fi + +{ + echo "Termux Android build for ${UPSTREAM_TAG}." + echo + echo "- Upstream release: ${UPSTREAM_HTML_URL}" + echo "- Release train: \`${RELEASE_TRAIN}\`" + echo "- Promoted PR: #${PR_NUMBER}" + echo "- Promoted PR head SHA: \`${HEAD_SHA}\`" +} > "${body_path}" +if [[ -s "${upstream_body_without_changelog_path}" ]]; then + { + echo + echo "## Upstream release notes" + echo + cat "${upstream_body_without_changelog_path}" + } >> "${body_path}" +fi + +release_title="${UPSTREAM_NAME}" +if [[ -z "${release_title}" || "${release_title}" == "null" ]]; then + release_title="${TERMUX_TAG}" +fi + +if [[ "${RELEASE_EXISTS}" == "true" ]]; then + gh release upload \ + "${TERMUX_TAG}" \ + "${asset_path}#codex-termux" \ + --repo "${GITHUB_REPOSITORY}" \ + --clobber + exit 0 +fi + +release_args=( + gh release create "${TERMUX_TAG}" + --repo "${GITHUB_REPOSITORY}" + --target "${RELEASE_SHA}" + --title "${release_title}" + --notes-file "${body_path}" +) +if [[ "${UPSTREAM_PRERELEASE}" == "true" ]]; then + release_args+=(--prerelease) +fi +release_args+=("${asset_path}#codex-termux") +"${release_args[@]}" diff --git a/scripts/termux-download-release-artifact.sh b/scripts/termux-download-release-artifact.sh new file mode 100755 index 000000000000..2acdba9025a0 --- /dev/null +++ b/scripts/termux-download-release-artifact.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ -z "${HEAD_SHA:-}" ]]; then + echo "HEAD_SHA is required." >&2 + exit 1 +fi + +if [[ -z "${PR_ARTIFACT_NAME:-}" ]]; then + echo "PR_ARTIFACT_NAME is required." >&2 + exit 1 +fi + +promoted_dir="${PROMOTED_DIR:-promoted}" + +find_run_id() { + local event="$1" + gh run list \ + --repo "${GITHUB_REPOSITORY}" \ + --workflow rust-release.yml \ + --event "${event}" \ + --status success \ + --commit "${HEAD_SHA}" \ + --limit 1 \ + --json databaseId \ + --jq '.[0].databaseId // empty' +} + +find_artifact_run_id() { + local artifact_name="$1" + + gh api --paginate "repos/${GITHUB_REPOSITORY}/actions/artifacts?name=${artifact_name}" \ + | jq -rs \ + --arg artifact_name "${artifact_name}" \ + --arg head_sha "${HEAD_SHA}" \ + ' + [ + .[].artifacts[] + | select(.name == $artifact_name) + | select(.expired == false) + | select(.workflow_run.head_sha == $head_sha) + ] + | sort_by(.created_at) + | reverse + | .[0].workflow_run.id // empty + ' +} + +artifact_name="${PR_ARTIFACT_NAME}" +run_id="$(find_artifact_run_id "${artifact_name}")" +mkdir -p "${promoted_dir}" +if [[ -n "${run_id}" ]] && gh run download "${run_id}" \ + --repo "${GITHUB_REPOSITORY}" \ + --name "${artifact_name}" \ + --dir "${promoted_dir}"; then + : +else + artifact_name="aarch64-linux-android" + run_id="$(find_run_id workflow_dispatch)" + if [[ -z "${run_id}" ]]; then + echo "No successful rust-release run found for ${HEAD_SHA}" >&2 + exit 1 + fi + gh run download "${run_id}" \ + --repo "${GITHUB_REPOSITORY}" \ + --name "${artifact_name}" \ + --dir "${promoted_dir}" +fi + +ls -la "${promoted_dir}" +if [[ -f "${promoted_dir}/SHA256SUMS" ]]; then + (cd "${promoted_dir}" && sha256sum -c SHA256SUMS) +fi +if [[ ! -f "${promoted_dir}/codex-aarch64-linux-android.tar.gz" ]]; then + echo "Expected ${promoted_dir}/codex-aarch64-linux-android.tar.gz in the downloaded artifact." >&2 + exit 1 +fi diff --git a/scripts/termux-find-release-pr.sh b/scripts/termux-find-release-pr.sh new file mode 100755 index 000000000000..3e35fa42eabe --- /dev/null +++ b/scripts/termux-find-release-pr.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ -n "${INPUT_PR_NUMBER:-}" || -n "${INPUT_PR_HEAD_SHA:-}" ]]; then + if [[ -z "${INPUT_PR_NUMBER:-}" || -z "${INPUT_PR_HEAD_SHA:-}" ]]; then + echo "workflow_dispatch inputs pr_number and pr_head_sha must be provided together." >&2 + exit 1 + fi + + pr_number="${INPUT_PR_NUMBER}" + head_sha="${INPUT_PR_HEAD_SHA}" + head_ref="" +else + if [[ -z "${RELEASE_BRANCH:-}" || -z "${RELEASE_SHA:-}" ]]; then + echo "RELEASE_BRANCH and RELEASE_SHA are required." >&2 + exit 1 + fi + + pr_json="$( + gh pr list \ + --repo "${GITHUB_REPOSITORY}" \ + --base "${RELEASE_BRANCH}" \ + --state merged \ + --limit 100 \ + --json number,headRefOid,headRefName,mergeCommit \ + --jq "[.[] | select(.mergeCommit.oid == \"${RELEASE_SHA}\")] | sort_by(.number) | reverse | .[0] // empty" + )" + if [[ -z "${pr_json}" ]]; then + echo "Unable to find merged PR for ${RELEASE_SHA} into ${RELEASE_BRANCH}" >&2 + exit 1 + fi + + pr_number="$(jq -r '.number' <<< "${pr_json}")" + head_sha="$(jq -r '.headRefOid // .head.sha' <<< "${pr_json}")" + head_ref="$(jq -r '.headRefName // .head.ref' <<< "${pr_json}")" +fi + +artifact_name="termux-android-pr-${pr_number}-${head_sha}" +if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + { + echo "number=${pr_number}" + echo "head_sha=${head_sha}" + echo "head_ref=${head_ref}" + echo "artifact_name=${artifact_name}" + } >> "${GITHUB_OUTPUT}" +else + printf 'number=%s\nhead_sha=%s\nhead_ref=%s\nartifact_name=%s\n' \ + "${pr_number}" \ + "${head_sha}" \ + "${head_ref}" \ + "${artifact_name}" +fi diff --git a/scripts/termux-read-release-metadata.sh b/scripts/termux-read-release-metadata.sh new file mode 100755 index 000000000000..519a0635a368 --- /dev/null +++ b/scripts/termux-read-release-metadata.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash + +# Parse .github/termux-release.json and emit workflow outputs for deploy or +# promote jobs. TERMUX_RELEASE_ACTION must be "deploy" or "promote". + +set -euo pipefail + +: "${GITHUB_OUTPUT:?GITHUB_OUTPUT is required}" +: "${GH_TOKEN:?GH_TOKEN is required}" + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + +metadata=".github/termux-release.json" +action="${TERMUX_RELEASE_ACTION:-deploy}" +case "${action}" in + deploy) + missing_context="deployment" + ;; + promote) + missing_context="promotion" + ;; + *) + echo "TERMUX_RELEASE_ACTION must be deploy or promote." >&2 + exit 1 + ;; +esac + +if [[ ! -f "${metadata}" ]]; then + echo "No ${metadata}; this push is not a Termux release ${missing_context}." + echo "${action}=false" >> "${GITHUB_OUTPUT}" + exit 0 +fi + +upstream_tag="$(jq -r '.upstream_tag // empty' "${metadata}")" +upstream_name="$(jq -r '.upstream_name // .upstream_tag // empty' "${metadata}")" +termux_tag="$(jq -r '.termux_tag // empty' "${metadata}")" +upstream_version="${upstream_tag#rust-v}" +upstream_version="${upstream_version%-termux}" +upstream_prerelease=false +if [[ "${upstream_version}" == *-* ]]; then + upstream_prerelease=true +fi +upstream_html_url="$(jq -r '.upstream_html_url // ""' "${metadata}")" +upstream_repo="$(jq -r '.upstream_repo // "openai/codex"' "${metadata}")" +release_train="$(jq -r '.release_train // ""' "${metadata}")" +if [[ -z "${upstream_tag}" || -z "${termux_tag}" ]]; then + echo "Missing upstream_tag or termux_tag in ${metadata}" >&2 + exit 1 +fi + +release_state="$(TERMUX_TAG="${termux_tag}" "${script_dir}/termux-release-asset-state.sh")" +release_exists="$(awk -F= '$1 == "release_exists" { print $2 }' <<< "${release_state}")" +asset_exists="$(awk -F= '$1 == "asset_exists" { print $2 }' <<< "${release_state}")" + +if [[ "${action}" == "promote" ]]; then + if [[ "${asset_exists}" == "true" ]]; then + echo "${termux_tag} already exists with codex-aarch64-linux-android.tar.gz; skipping promotion." + echo "promote=false" >> "${GITHUB_OUTPUT}" + exit 0 + fi + if [[ "${release_exists}" == "true" ]]; then + echo "${termux_tag} exists but is missing codex-aarch64-linux-android.tar.gz; repairing promotion." + fi +fi + +{ + echo "${action}=true" + echo "upstream_tag=${upstream_tag}" + echo "upstream_name=${upstream_name}" + echo "termux_tag=${termux_tag}" + echo "upstream_prerelease=${upstream_prerelease}" + echo "upstream_html_url=${upstream_html_url}" + echo "upstream_repo=${upstream_repo}" + echo "release_train=${release_train}" + echo "release_exists=${release_exists}" + echo "asset_exists=${asset_exists}" +} >> "${GITHUB_OUTPUT}" diff --git a/scripts/termux-release-asset-state.sh b/scripts/termux-release-asset-state.sh new file mode 100755 index 000000000000..8694480b6a36 --- /dev/null +++ b/scripts/termux-release-asset-state.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash + +set -euo pipefail + +termux_tag="${1:-${TERMUX_TAG:-}}" +release_repo="${RELEASE_REPO:-${GITHUB_REPOSITORY:-}}" +asset_name="${ASSET_NAME:-codex-aarch64-linux-android.tar.gz}" + +if [[ -z "${termux_tag}" ]]; then + echo "TERMUX_TAG or tag argument is required." >&2 + exit 1 +fi + +if [[ -z "${release_repo}" ]]; then + echo "GITHUB_REPOSITORY or RELEASE_REPO is required." >&2 + exit 1 +fi + +release_exists=false +asset_exists=false + +if gh release view "${termux_tag}" --repo "${release_repo}" >/dev/null 2>&1; then + release_exists=true + release_asset_exists="$( + gh release view "${termux_tag}" \ + --repo "${release_repo}" \ + --json assets \ + | jq -r --arg asset_name "${asset_name}" \ + '.assets | map(.name) | any(. == $asset_name)' + )" + if [[ "${release_asset_exists}" == "true" ]]; then + asset_exists=true + fi +fi + +printf 'release_exists=%s\n' "${release_exists}" +printf 'asset_exists=%s\n' "${asset_exists}" diff --git a/scripts/termux-release-paths.sh b/scripts/termux-release-paths.sh new file mode 100755 index 000000000000..6990f323ec44 --- /dev/null +++ b/scripts/termux-release-paths.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash + +# Sourceable path lists for files owned by the Termux release automation. + +set -euo pipefail + +readonly -a TERMUX_RELEASE_WORKFLOW_PATHS=( + .github/workflows/rust-release.yml + .github/workflows/shell-tool-mcp.yml + .github/workflows/termux-release-checkpoint.yml + .github/workflows/termux-release-deploy.yml + .github/workflows/termux-release-promote.yml +) + +readonly -a TERMUX_RELEASE_BRANCH_SCRIPT_PATHS=( + scripts/termux-configure-git.sh + scripts/termux-create-checkpoint-pr.sh + scripts/termux-create-or-update-mirrored-release.sh + scripts/termux-download-release-artifact.sh + scripts/termux-find-release-pr.sh + scripts/termux-read-release-metadata.sh + scripts/termux-release-asset-state.sh + scripts/termux-release-paths.sh + scripts/termux-resolve-release-ref.sh + scripts/termux-validate-gh-env.sh +) + +readonly -a TERMUX_RELEASE_AUTOMATION_PATHS=( + "${TERMUX_RELEASE_WORKFLOW_PATHS[@]}" + "${TERMUX_RELEASE_BRANCH_SCRIPT_PATHS[@]}" +) + +readonly -a TERMUX_CHECKPOINT_RELEASE_ONLY_PATHS=( + "${TERMUX_RELEASE_AUTOMATION_PATHS[@]}" + .github/termux-release.json +) + +termux_path_in_list() { + local candidate="$1" + shift + local listed_path + + for listed_path in "$@"; do + [[ "${candidate}" != "${listed_path}" ]] || return 0 + done + return 1 +} + +termux_is_release_automation_path() { + termux_path_in_list "$1" "${TERMUX_RELEASE_AUTOMATION_PATHS[@]}" +} + +termux_is_checkpoint_release_only_path() { + termux_path_in_list "$1" "${TERMUX_CHECKPOINT_RELEASE_ONLY_PATHS[@]}" +} diff --git a/scripts/termux-resolve-release-ref.sh b/scripts/termux-resolve-release-ref.sh new file mode 100755 index 000000000000..194773d45bed --- /dev/null +++ b/scripts/termux-resolve-release-ref.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash + +# Resolve the release branch and SHA for deploy/promote workflows. + +set -euo pipefail + +: "${GITHUB_EVENT_NAME:?GITHUB_EVENT_NAME is required}" +: "${GITHUB_OUTPUT:?GITHUB_OUTPUT is required}" + +input_release_branch="${INPUT_RELEASE_BRANCH:-${REQUESTED_RELEASE_BRANCH:-}}" +input_release_sha="${INPUT_RELEASE_SHA:-${REQUESTED_RELEASE_SHA:-}}" + +if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then + if [[ -z "${input_release_branch}" ]]; then + echo "release_branch is required for workflow_dispatch." >&2 + exit 1 + fi + + release_branch="${input_release_branch}" + if [[ -n "${input_release_sha}" ]]; then + git checkout --detach "${input_release_sha}" + release_sha="${input_release_sha}" + else + release_sha="$(git rev-parse HEAD)" + fi +else + : "${GITHUB_REF_NAME:?GITHUB_REF_NAME is required}" + : "${GITHUB_SHA:?GITHUB_SHA is required}" + release_branch="${GITHUB_REF_NAME}" + release_sha="${GITHUB_SHA}" +fi + +{ + echo "branch=${release_branch}" + echo "sha=${release_sha}" +} >> "${GITHUB_OUTPUT}" diff --git a/scripts/termux-validate-gh-env.sh b/scripts/termux-validate-gh-env.sh new file mode 100755 index 000000000000..02581fdebceb --- /dev/null +++ b/scripts/termux-validate-gh-env.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash + +# Lightweight post-toolbox check for jobs that rely on authenticated gh calls. + +set -euo pipefail + +: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" +: "${GH_TOKEN:?GH_TOKEN is required}" + +command -v gh +gh auth status --hostname github.com + +printf 'GITHUB_REPOSITORY=%s\n' "${GITHUB_REPOSITORY}" +printf 'REPO=%s\n' "${REPO:-}" +printf 'GH_REPO_URL=%s\n' "${GH_REPO_URL:-}" +printf 'GH_WORKFLOW_URL=%s\n' "${GH_WORKFLOW_URL:-}" From 00ce9485a70e7783d5162f6ccc4d5ff4c91b2d92 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 23 May 2026 01:17:39 +0000 Subject: [PATCH 64/64] Prepare Termux rust-v0.134.0-alpha.3 --- .github/termux-release.json | 15 + .github/workflows/rust-release.yml | 1798 +++++++---------- .github/workflows/shell-tool-mcp.yml | 461 +++++ .../workflows/termux-release-checkpoint.yml | 103 + .github/workflows/termux-release-deploy.yml | 243 +++ .github/workflows/termux-release-promote.yml | 135 ++ codex-rs/Cargo.toml | 2 +- scripts/termux-configure-git.sh | 40 + scripts/termux-create-checkpoint-pr.sh | 275 +++ ...ermux-create-or-update-mirrored-release.sh | 111 + scripts/termux-download-release-artifact.sh | 78 + scripts/termux-find-release-pr.sh | 53 + scripts/termux-read-release-metadata.sh | 77 + scripts/termux-release-asset-state.sh | 37 + scripts/termux-release-paths.sh | 55 + scripts/termux-resolve-release-ref.sh | 36 + scripts/termux-validate-gh-env.sh | 16 + 17 files changed, 2460 insertions(+), 1075 deletions(-) create mode 100644 .github/termux-release.json create mode 100644 .github/workflows/shell-tool-mcp.yml create mode 100644 .github/workflows/termux-release-checkpoint.yml create mode 100644 .github/workflows/termux-release-deploy.yml create mode 100644 .github/workflows/termux-release-promote.yml create mode 100755 scripts/termux-configure-git.sh create mode 100755 scripts/termux-create-checkpoint-pr.sh create mode 100755 scripts/termux-create-or-update-mirrored-release.sh create mode 100755 scripts/termux-download-release-artifact.sh create mode 100755 scripts/termux-find-release-pr.sh create mode 100755 scripts/termux-read-release-metadata.sh create mode 100755 scripts/termux-release-asset-state.sh create mode 100755 scripts/termux-release-paths.sh create mode 100755 scripts/termux-resolve-release-ref.sh create mode 100755 scripts/termux-validate-gh-env.sh diff --git a/.github/termux-release.json b/.github/termux-release.json new file mode 100644 index 000000000000..557ac08657ea --- /dev/null +++ b/.github/termux-release.json @@ -0,0 +1,15 @@ +{ + "upstream_repo": "openai/codex", + "upstream_tag": "rust-v0.134.0-alpha.3", + "upstream_name": "0.134.0-alpha.3", + "upstream_html_url": "https://github.com/openai/codex/releases/tag/rust-v0.134.0-alpha.3", + "upstream_target": "main", + "upstream_release_id": "328195858", + "upstream_prerelease": true, + "release_train": "0.134.0", + "release_branch": "release/0.134.0", + "work_branch": "upstream/rust-v0.134.0", + "patch_branch": "wallentx/termux-target", + "patch_source_sha": "f9ffdd551125e12530a5abdee255565bae052c6a", + "termux_tag": "rust-v0.134.0-alpha.3-termux" +} diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index c55337ecfe6e..56d0977e6e67 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -4,46 +4,49 @@ # git tag -a rust-v0.1.0 -m "Release 0.1.0" # git push origin rust-v0.1.0 # ``` -# -# To use external macOS signing, manually dispatch `release_mode=build_unsigned`, -# sign the unsigned macOS artifacts in a secure enclave, upload the signed handoff -# archive as a GitHub Release asset, then manually dispatch -# `release_mode=promote_signed` with `unsigned_run_id` and `signed_macos_asset`. -# The signed handoff archive should contain target or artifact directories such -# as `aarch64-apple-darwin/` with signed binaries. name: rust-release on: - push: - tags: - - "rust-v*.*.*" + pull_request: + branches: + - "release/**" workflow_dispatch: inputs: - release_mode: - description: "build_unsigned creates unsigned macOS handoff artifacts; promote_signed finishes a release from signed macOS handoff artifacts." + source_ref: + description: "Branch, tag, or commit SHA to build (code only; keeps .github from dispatched ref)" required: false + type: string + default: "" + build_target: + description: "Target to build" + required: true type: choice - default: build_unsigned + default: all options: - - build_unsigned - - promote_signed - sign_macos: - description: "Deprecated compatibility input; use release_mode instead." - required: false - type: boolean - default: false - unsigned_run_id: - description: "For promote_signed: workflow run id from the build_unsigned run." - required: false - type: string - signed_macos_asset: - description: "For promote_signed: exact GitHub Release asset name containing signed macOS handoff artifacts." - required: false - type: string - signed_macos_sha256: - description: "For promote_signed: optional SHA-256 of signed_macos_asset." - required: false - type: string + - all + - aarch64-apple-darwin + - x86_64-apple-darwin + - x86_64-unknown-linux-musl + - x86_64-unknown-linux-gnu + - aarch64-unknown-linux-musl + - aarch64-unknown-linux-gnu + - aarch64-linux-android + - x86_64-pc-windows-msvc + - aarch64-pc-windows-msvc + +permissions: + actions: read + attestations: read + checks: read + contents: read + deployments: read + issues: read + discussions: read + packages: read + pages: read + pull-requests: read + repository-projects: read + statuses: read concurrency: group: ${{ github.workflow }} @@ -51,72 +54,26 @@ concurrency: jobs: tag-check: + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: actions/checkout@v6 + - name: 🧰 Actions Toolbox + # This is required for the GitHub CLI + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: wallentx/gh-actions/composite/actions-toolbox@main + - uses: dtolnay/rust-toolchain@1.92 - name: Validate tag matches Cargo.toml version shell: bash - env: - RELEASE_MODE: ${{ github.event_name == 'workflow_dispatch' && inputs.release_mode || 'signed' }} - REQUESTED_SIGN_MACOS: ${{ inputs.sign_macos }} - SIGNED_MACOS_ASSET: ${{ inputs.signed_macos_asset }} - UNSIGNED_RUN_ID: ${{ inputs.unsigned_run_id }} run: | set -euo pipefail echo "::group::Tag validation" - case "${RELEASE_MODE}" in - signed) - if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then - echo "❌ Manual rust-release runs must use release_mode=build_unsigned or release_mode=promote_signed" - exit 1 - fi - ;; - build_unsigned) - if [[ "${GITHUB_EVENT_NAME}" != "workflow_dispatch" ]]; then - echo "❌ release_mode=build_unsigned is only valid for manual runs" - exit 1 - fi - ;; - promote_signed) - if [[ "${GITHUB_EVENT_NAME}" != "workflow_dispatch" ]]; then - echo "❌ release_mode=promote_signed is only valid for manual runs" - exit 1 - fi - if [[ ! "${UNSIGNED_RUN_ID}" =~ ^[0-9]+$ ]]; then - echo "❌ release_mode=promote_signed requires unsigned_run_id to be a workflow run id" - exit 1 - fi - if [[ -z "${SIGNED_MACOS_ASSET}" ]]; then - echo "❌ release_mode=promote_signed requires signed_macos_asset" - exit 1 - fi - if [[ "${SIGNED_MACOS_ASSET}" == */* || "${SIGNED_MACOS_ASSET}" == *"*"* || "${SIGNED_MACOS_ASSET}" == *"?"* || "${SIGNED_MACOS_ASSET}" == *"["* ]]; then - echo "❌ signed_macos_asset must be an exact release asset name, not a path or glob" - exit 1 - fi - if [[ "${UNSIGNED_RUN_ID}" == "${GITHUB_RUN_ID}" ]]; then - echo "❌ unsigned_run_id must refer to the earlier build_unsigned run, not this run" - exit 1 - fi - ;; - *) - echo "❌ Unknown release_mode '${RELEASE_MODE}'" - exit 1 - ;; - esac - - if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${REQUESTED_SIGN_MACOS}" == "true" ]]; then - echo "::warning title=Deprecated sign_macos input ignored::Use release_mode=build_unsigned or release_mode=promote_signed instead." - fi - # 1. Must be a tag and match the regex [[ "${GITHUB_REF_TYPE}" == "tag" ]] \ || { echo "❌ Not a tag push"; exit 1; } - [[ "${GITHUB_REF_NAME}" =~ ^rust-v[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta)(\.[0-9]+)?)?$ ]] \ + [[ "${GITHUB_REF_NAME}" =~ ^rust-v[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta)(\.[0-9]+)?)?(-termux)?$ ]] \ || { echo "❌ Tag '${GITHUB_REF_NAME}' doesn't match expected format"; exit 1; } # 2. Extract versions @@ -131,115 +88,198 @@ jobs: echo "✅ Tag and Cargo.toml agree (${tag_ver})" echo "::endgroup::" - build: - if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} + select-build-matrix: + if: always() && (github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' || needs.tag-check.result == 'success') needs: tag-check - name: Build - ${{ matrix.runner }} - ${{ matrix.target }} - ${{ matrix.bundle }} - runs-on: ${{ matrix.runs_on || matrix.runner }} - # Release builds can take a long time, so leave some headroom to avoid - # having to restart the full workflow due to a timeout. - timeout-minutes: 90 - permissions: - contents: read - id-token: write + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.select.outputs.matrix }} + build_version: ${{ steps.select.outputs.build_version }} + steps: + - uses: actions/checkout@v6 + - name: 🧰 Actions Toolbox + # This is required for the GitHub CLI + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: wallentx/gh-actions/composite/actions-toolbox@main + - id: select + shell: bash + env: + REPO_OWNER: ${{ github.repository_owner }} + EVENT_NAME: ${{ github.event_name }} + BUILD_TARGET: ${{ inputs.build_target }} + run: | + set -euo pipefail + + matrix='[ + {"runner":"macos-15-xlarge","target":"aarch64-apple-darwin"}, + {"runner":"macos-15-xlarge","target":"x86_64-apple-darwin"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-gnu"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-gnu"}, + {"runner":"ubuntu-24.04","target":"aarch64-linux-android"}, + {"runner":"windows-latest","target":"x86_64-pc-windows-msvc"}, + {"runner":"windows-11-arm","target":"aarch64-pc-windows-msvc"} + ]' + + if [[ "${REPO_OWNER}" != "openai" ]]; then + matrix="$(jq -c '[.[] | select(.runner | startswith("macos") | not)]' <<< "${matrix}")" + fi + + if [[ "${EVENT_NAME}" == "pull_request" ]]; then + matrix="$(jq -c '[.[] | select(.target == "aarch64-linux-android")]' <<< "${matrix}")" + elif [[ "${EVENT_NAME}" == "workflow_dispatch" && -n "${BUILD_TARGET}" && "${BUILD_TARGET}" != "all" ]]; then + matrix="$(jq -c --arg build_target "${BUILD_TARGET}" '[.[] | select(.target == $build_target)]' <<< "${matrix}")" + fi + + if [[ "$(jq 'length' <<< "${matrix}")" -eq 0 ]]; then + echo "No build targets selected after applying owner/dispatch filters." >&2 + exit 1 + fi + + echo "matrix={\"include\":${matrix}}" >> "$GITHUB_OUTPUT" + + if [[ "${EVENT_NAME}" == "pull_request" ]]; then + metadata=".github/termux-release.json" + if [[ ! -f "${metadata}" ]]; then + echo "${metadata} is required for release train PR builds" >&2 + exit 1 + fi + upstream_tag="$(jq -r '.upstream_tag // empty' "${metadata}")" + if [[ -z "${upstream_tag}" || "${upstream_tag}" != rust-v* ]]; then + echo "Unable to determine upstream rust tag from ${metadata}" >&2 + exit 1 + fi + build_version="${upstream_tag#rust-v}" + echo "build_version=${build_version}" >> "$GITHUB_OUTPUT" + elif [[ "${EVENT_NAME}" == "workflow_dispatch" ]]; then + latest_release_tag="$( + GH_TOKEN="${{ github.token }}" gh api repos/openai/codex/releases/latest --jq '.tag_name' + )" + + if [[ -z "${latest_release_tag}" || "${latest_release_tag}" != rust-v* ]]; then + echo "Unable to determine latest stable release tag from openai/codex" >&2 + exit 1 + fi + + latest_release_version="${latest_release_tag#rust-v}" + build_version="${latest_release_version}-dev" + echo "build_version=${build_version}" >> "$GITHUB_OUTPUT" + fi + + build: + if: >- + always() && + (github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' || needs.tag-check.result == 'success') && + needs.select-build-matrix.result == 'success' + needs: + - tag-check + - select-build-matrix + name: Build - ${{ matrix.runner }} - ${{ matrix.target }} + runs-on: ${{ matrix.runner }} + permissions: write-all + timeout-minutes: 120 defaults: run: working-directory: codex-rs env: - # 2026-03-04: temporarily change releases to use thin LTO because - # Ubuntu ARM is timing out at 60 minutes. - CARGO_PROFILE_RELEASE_LTO: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'thin' }} - SIGN_MACOS: ${{ github.event_name != 'workflow_dispatch' }} + CODEX_BWRAP_ENABLE_FFI: ${{ contains(matrix.target, 'unknown-linux') && '1' || '0' }} + CODEX_BUILD_VERSION: ${{ needs.select-build-matrix.outputs.build_version }} + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" strategy: fail-fast: false - matrix: - include: - - runner: macos-15-xlarge - target: aarch64-apple-darwin - bundle: primary - artifact_name: aarch64-apple-darwin - binaries: "codex codex-responses-api-proxy" - build_dmg: "true" - - runner: macos-15-xlarge - target: aarch64-apple-darwin - bundle: app-server - artifact_name: aarch64-apple-darwin-app-server - binaries: "codex-app-server" - build_dmg: "false" - - runner: macos-15-xlarge - target: x86_64-apple-darwin - bundle: primary - artifact_name: x86_64-apple-darwin - binaries: "codex codex-responses-api-proxy" - build_dmg: "true" - - runner: macos-15-xlarge - target: x86_64-apple-darwin - bundle: app-server - artifact_name: x86_64-apple-darwin-app-server - binaries: "codex-app-server" - build_dmg: "false" - # Release artifacts intentionally ship MUSL-linked Linux binaries. - - runner: ubuntu-24.04 - target: x86_64-unknown-linux-musl - bundle: primary - artifact_name: x86_64-unknown-linux-musl - binaries: "codex codex-responses-api-proxy bwrap" - build_dmg: "false" - - runner: ubuntu-24.04 - target: x86_64-unknown-linux-musl - bundle: app-server - artifact_name: x86_64-unknown-linux-musl-app-server - binaries: "codex-app-server" - build_dmg: "false" - - runner: ubuntu-24.04-arm - target: aarch64-unknown-linux-musl - bundle: primary - artifact_name: aarch64-unknown-linux-musl - binaries: "codex codex-responses-api-proxy bwrap" - build_dmg: "false" - - runner: ubuntu-24.04-arm - target: aarch64-unknown-linux-musl - bundle: app-server - artifact_name: aarch64-unknown-linux-musl-app-server - binaries: "codex-app-server" - build_dmg: "false" + matrix: ${{ fromJson(needs.select-build-matrix.outputs.matrix) }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@v6 + - name: 🧰 Actions Toolbox + # This is required for the GitHub CLI + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: wallentx/gh-actions/composite/actions-toolbox@main + with: + verbose: true + - name: Checkout selected source ref + if: ${{ github.event_name == 'workflow_dispatch' && inputs.source_ref != '' }} + uses: actions/checkout@v6 with: - persist-credentials: false - - name: Print runner specs (Linux) - if: ${{ runner.os == 'Linux' }} + ref: ${{ inputs.source_ref }} + path: __source__ + fetch-depth: 1 + - name: Overlay selected source ref (excluding .github) + if: ${{ github.event_name == 'workflow_dispatch' && inputs.source_ref != '' }} + shell: bash + working-directory: ${{ github.workspace }} + run: | + set -euo pipefail + echo "Using source ref: ${{ inputs.source_ref }}" + + # Keep workflow/actions from the dispatch ref while replacing all other files. + find . -mindepth 1 -maxdepth 1 \ + ! -name .git \ + ! -name .github \ + ! -name __source__ \ + -exec rm -rf -- {} + + tar --exclude='.github' -C __source__ -cf - . | tar -xf - + rm -rf __source__ + + - name: Apply selected build version + if: ${{ github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' }} shell: bash + working-directory: ${{ github.workspace }} run: | set -euo pipefail - cpu_model="$(lscpu | awk -F: '/Model name/ {gsub(/^[ \t]+/, "", $2); print $2; exit}')" - total_ram="$(awk '/MemTotal/ {printf "%.1f GiB\n", $2 / 1024 / 1024}' /proc/meminfo)" - echo "Runner: ${RUNNER_NAME:-unknown}" - echo "OS: $(uname -a)" - echo "CPU model: ${cpu_model}" - echo "Logical CPUs: $(nproc)" - echo "Total RAM: ${total_ram}" - echo "Disk usage:" - df -h . - - name: Print runner specs (macOS) - if: ${{ runner.os == 'macOS' }} + if [[ -z "${CODEX_BUILD_VERSION}" ]]; then + echo "CODEX_BUILD_VERSION is empty for workflow_dispatch" >&2 + exit 1 + fi + echo "Using build version: ${CODEX_BUILD_VERSION}" + BUILD_VERSION="${CODEX_BUILD_VERSION}" perl -0777 -i -pe \ + 's/(\[workspace\.package\][^\[]*?version = ")([^"]+)(")/${1}$ENV{BUILD_VERSION}$3/s' \ + codex-rs/Cargo.toml + + grep -n "^\[workspace.package\]\|^version = " codex-rs/Cargo.toml | head -n 4 + + - name: Select Android NDK + if: ${{ matrix.target == 'aarch64-linux-android' }} shell: bash run: | set -euo pipefail - total_ram="$(sysctl -n hw.memsize | awk '{printf "%.1f GiB\n", $1 / 1024 / 1024 / 1024}')" - echo "Runner: ${RUNNER_NAME:-unknown}" - echo "OS: $(sw_vers -productName) $(sw_vers -productVersion)" - echo "Hardware model: $(sysctl -n hw.model)" - echo "CPU architecture: $(uname -m)" - echo "Logical CPUs: $(sysctl -n hw.logicalcpu)" - echo "Physical CPUs: $(sysctl -n hw.physicalcpu)" - echo "Total RAM: ${total_ram}" - echo "Disk usage:" - df -h . + + fallback_version="29.0.13113456" + ndk_root="${ANDROID_HOME}/ndk" + selected_ndk="" + + if [[ -d "${ndk_root}" ]]; then + while IFS= read -r candidate; do + toolchain="${candidate}/toolchains/llvm/prebuilt/linux-x86_64" + if [[ -x "${toolchain}/bin/aarch64-linux-android24-clang" ]]; then + selected_ndk="${candidate}" + break + fi + done < <(find "${ndk_root}" -mindepth 1 -maxdepth 1 -type d | sort -Vr) + fi + + if [[ -z "${selected_ndk}" ]]; then + echo "No usable preinstalled Android NDK found; installing ${fallback_version}" + yes | sudo "${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager" --licenses || true + sudo "${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager" "ndk;${fallback_version}" + selected_ndk="${ndk_root}/${fallback_version}" + fi + + if [[ ! -x "${selected_ndk}/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android24-clang" ]]; then + echo "Selected Android NDK does not contain a usable aarch64 API 24 clang: ${selected_ndk}" >&2 + exit 1 + fi + + echo "Selected Android NDK: ${selected_ndk}" + "${selected_ndk}/toolchains/llvm/prebuilt/linux-x86_64/bin/clang" --version + echo "ANDROID_NDK_HOME=${selected_ndk}" >> "$GITHUB_ENV" + echo "NDK_HOME=${selected_ndk}" >> "$GITHUB_ENV" - name: Install Linux bwrap build dependencies - if: ${{ runner.os == 'Linux' }} + if: ${{ runner.os == 'Linux' && matrix.target != 'aarch64-linux-android' }} shell: bash run: | set -euo pipefail @@ -254,9 +294,173 @@ jobs: sudo apt-get update -y sudo DEBIAN_FRONTEND=noninteractive apt-get install -y libubsan1 fi - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: dtolnay/rust-toolchain@1.93 with: targets: ${{ matrix.target }} + - name: Set up sccache (Android) + if: ${{ matrix.target == 'aarch64-linux-android' }} + uses: mozilla-actions/sccache-action@main + - name: Enable sccache (Android) + if: ${{ matrix.target == 'aarch64-linux-android' }} + shell: bash + run: | + set -euo pipefail + echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV" + echo "SCCACHE_GHA_ENABLED=true" >> "$GITHUB_ENV" + echo "SCCACHE_GHA_VERSION=android-release-${{ matrix.target }}" >> "$GITHUB_ENV" + echo "SCCACHE_CACHE_SIZE=5G" >> "$GITHUB_ENV" + - name: Ensure Rust target is installed + shell: bash + run: | + set -euo pipefail + rustup target add "${{ matrix.target }}" + - if: ${{ matrix.target == 'aarch64-linux-android' }} + name: Configure Android build environment + shell: bash + run: | + set -euo pipefail + ndk="${ANDROID_NDK_HOME}" + target="${{ matrix.target }}" + + # Set up the Android toolchain + toolchain="${ndk}/toolchains/llvm/prebuilt/linux-x86_64" + + # Use API level 24 to follow termux-build convention + api_level="24" + + # Configure Cargo to use the NDK linker + cargo_target_var="CARGO_TARGET_${target^^}_LINKER" + cargo_target_var="${cargo_target_var//-/_}" + echo "${cargo_target_var}=${toolchain}/bin/aarch64-linux-android${api_level}-clang" >> "$GITHUB_ENV" + + # Set CC and AR for the target + target_cc_var="CC_${target}" + target_cc_var="${target_cc_var//-/_}" + echo "${target_cc_var}=${toolchain}/bin/aarch64-linux-android${api_level}-clang" >> "$GITHUB_ENV" + + target_cxx_var="CXX_${target}" + target_cxx_var="${target_cxx_var//-/_}" + echo "${target_cxx_var}=${toolchain}/bin/aarch64-linux-android${api_level}-clang++" >> "$GITHUB_ENV" + + target_ar_var="AR_${target}" + target_ar_var="${target_ar_var//-/_}" + echo "${target_ar_var}=${toolchain}/bin/llvm-ar" >> "$GITHUB_ENV" + + # Add toolchain to PATH + echo "${toolchain}/bin" >> "$GITHUB_PATH" + + tls_align_source="${RUNNER_TEMP}/codex-android-tls-align.S" + tls_align_object="${RUNNER_TEMP}/codex-android-tls-align.o" + cat > "${tls_align_source}" <<'EOF' + .section .tdata.codex_android_tls_align,"awT",%progbits + .p2align 6 + .globl __codex_android_tls_align + .hidden __codex_android_tls_align + .type __codex_android_tls_align, %object + __codex_android_tls_align: + .byte 0 + .size __codex_android_tls_align, 1 + EOF + "${toolchain}/bin/aarch64-linux-android${api_level}-clang" -c "${tls_align_source}" -o "${tls_align_object}" + + v8_compat_source="${RUNNER_TEMP}/codex-android-v8-compat.c" + v8_compat_object="${RUNNER_TEMP}/codex-android-v8-compat.o" + cat > "${v8_compat_source}" <<'EOF' + #include + #include + + extern int posix_memalign(void **memptr, size_t alignment, size_t size); + extern double strtod(const char *nptr, char **endptr); + extern float strtof(const char *nptr, char **endptr); + + void *aligned_alloc(size_t alignment, size_t size) { + void *ptr = 0; + if (posix_memalign(&ptr, alignment, size) != 0) { + return 0; + } + return ptr; + } + + double strtod_l(const char *nptr, char **endptr, locale_t locale) { + (void)locale; + return strtod(nptr, endptr); + } + + float strtof_l(const char *nptr, char **endptr, locale_t locale) { + (void)locale; + return strtof(nptr, endptr); + } + EOF + "${toolchain}/bin/aarch64-linux-android${api_level}-clang" -c "${v8_compat_source}" -o "${v8_compat_object}" + + builtins_archive="$( + find "${toolchain}/lib/clang" \ + -path "*/lib/linux/libclang_rt.builtins-aarch64-android.a" \ + -print -quit + )" + if [[ -z "${builtins_archive}" ]]; then + echo "Could not find Android compiler-rt builtins archive under ${toolchain}/lib/clang" >&2 + exit 1 + fi + builtins_dir="$(dirname "${builtins_archive}")" + + # Provide compatibility symbols and C++ ABI needed by the Android rusty_v8 archive. + rustflags_var="CARGO_TARGET_${target^^}_RUSTFLAGS" + rustflags_var="${rustflags_var//-/_}" + echo "${rustflags_var}=-C link-arg=${tls_align_object} -C link-arg=${v8_compat_object} -C link-arg=-Wl,-u,__codex_android_tls_align -C link-arg=-L${builtins_dir} -C link-arg=-lclang_rt.builtins-aarch64-android -C link-arg=-lc++abi -C link-arg=-Wl,-z,max-page-size=65536" >> "$GITHUB_ENV" + + + # Configure for vendored OpenSSL build + echo "CARGO_BUILD_TARGET_APPLIES_TO_HOST=false" >> "$GITHUB_ENV" + echo "CARGO_BUILD_TARGET=${{ matrix.target }}" >> "$GITHUB_ENV" + + - if: ${{ matrix.target == 'aarch64-linux-android' }} + name: Configure Android rusty_v8 artifact overrides + env: + GH_TOKEN: ${{ github.token }} + TARGET: ${{ matrix.target }} + shell: bash + run: | + set -euo pipefail + binding_dir="${RUNNER_TEMP}/rusty_v8" + archive="${binding_dir}/librusty_v8_release_${TARGET}.a.gz" + binding_path="${binding_dir}/src_binding_release_${TARGET}.rs" + checksums_path="${binding_dir}/rusty_v8_release_${TARGET}.sha256" + artifact_repository="wallentx/codex-termux" + release_tag="rusty-v8-v147.4.0" + mkdir -p "${binding_dir}" + echo "Downloading Android rusty_v8 artifacts from ${artifact_repository}@${release_tag}" + gh release download "${release_tag}" \ + --repo "${artifact_repository}" \ + --dir "${binding_dir}" \ + --pattern "librusty_v8_release_${TARGET}.a.gz" \ + --pattern "src_binding_release_${TARGET}.rs" \ + --pattern "rusty_v8_release_${TARGET}.sha256" + (cd "${binding_dir}" && sha256sum -c "$(basename "${checksums_path}")") + echo "RUSTY_V8_ARCHIVE=${archive}" >> "$GITHUB_ENV" + echo "RUSTY_V8_SRC_BINDING_PATH=${binding_path}" >> "$GITHUB_ENV" + + - if: ${{ matrix.target == 'aarch64-linux-android' }} + name: Install termux-elf-cleaner + shell: bash + run: | + set -euo pipefail + version="v3.0.1" + src_dir="${RUNNER_TEMP}/termux-elf-cleaner-src" + build_dir="${RUNNER_TEMP}/termux-elf-cleaner-build" + bin_dir="${RUNNER_TEMP}/termux-elf-cleaner-bin" + + rm -rf "${src_dir}" "${build_dir}" "${bin_dir}" + mkdir -p "${src_dir}" "${build_dir}" "${bin_dir}" + + curl -fsSL "https://github.com/termux/termux-elf-cleaner/archive/refs/tags/${version}.tar.gz" \ + | tar -xzf - --strip-components=1 -C "${src_dir}" + + cmake -S "${src_dir}" -B "${build_dir}" -DCMAKE_BUILD_TYPE=Release + cmake --build "${build_dir}" --parallel + + install "${build_dir}/termux-elf-cleaner" "${bin_dir}/termux-elf-cleaner" + echo "${bin_dir}" >> "$GITHUB_PATH" - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} name: Use hermetic Cargo home (musl) @@ -269,12 +473,25 @@ jobs: echo "${cargo_home}/bin" >> "$GITHUB_PATH" : > "${cargo_home}/config.toml" + - uses: actions/cache@v5 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + ${{ github.workspace }}/.cargo-home/bin/ + ${{ github.workspace }}/.cargo-home/registry/index/ + ${{ github.workspace }}/.cargo-home/registry/cache/ + ${{ github.workspace }}/.cargo-home/git/db/ + ${{ github.workspace }}/codex-rs/target/ + key: cargo-${{ matrix.runner }}-${{ matrix.target }}-release-${{ hashFiles('**/Cargo.lock') }} + - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} name: Install Zig - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 + uses: mlugg/setup-zig@v2 with: version: 0.14.0 - use-cache: false - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} name: Install musl build tools @@ -310,12 +527,6 @@ jobs: shell: bash run: | set -euo pipefail - # Avoid problematic aws-lc jitter entropy code path on musl builders. - echo "AWS_LC_SYS_NO_JITTER_ENTROPY=1" >> "$GITHUB_ENV" - target_no_jitter="AWS_LC_SYS_NO_JITTER_ENTROPY_${{ matrix.target }}" - target_no_jitter="${target_no_jitter//-/_}" - echo "${target_no_jitter}=1" >> "$GITHUB_ENV" - # Clear global Rust flags so host/proc-macro builds don't pull in UBSan. echo "RUSTFLAGS=" >> "$GITHUB_ENV" echo "CARGO_ENCODED_RUSTFLAGS=" >> "$GITHUB_ENV" @@ -340,94 +551,70 @@ jobs: echo "CFLAGS=${cflags}" >> "$GITHUB_ENV" echo "CXXFLAGS=${cxxflags}" >> "$GITHUB_ENV" - - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl' }} - name: Configure musl rusty_v8 artifact overrides and verify checksums - uses: ./.github/actions/setup-rusty-v8-musl - with: - target: ${{ matrix.target }} - - - if: ${{ contains(matrix.target, 'linux') && matrix.bundle == 'primary' }} - name: Build bwrap and export digest + - name: Cargo build shell: bash run: | set -euo pipefail - target="${{ matrix.target }}" - cargo build --target "$target" --release --timings --bin bwrap - bwrap_path="target/${target}/release/bwrap" - if [[ ! -f "$bwrap_path" ]]; then - echo "bwrap binary ${bwrap_path} not found" - exit 1 + if [[ "${{ matrix.target }}" == 'aarch64-linux-android' ]]; then + export CARGO_BUILD_JOBS=4 + export CARGO_PROFILE_RELEASE_LTO=thin + export CARGO_PROFILE_RELEASE_CODEGEN_UNITS=8 + cargo build --target ${{ matrix.target }} --release --bin codex + exit 0 fi - digest="$(sha256sum "$bwrap_path" | awk '{print $1}')" - echo "CODEX_BWRAP_SHA256=${digest}" >> "$GITHUB_ENV" - echo "Built bwrap ${bwrap_path} with sha256:${digest}" - - - name: Cargo build + if [[ "${{ contains(matrix.target, 'windows') }}" == 'true' ]]; then + cargo build --target ${{ matrix.target }} --release --bin codex --bin codex-responses-api-proxy --bin codex-windows-sandbox-setup --bin codex-command-runner + else + cargo build --target ${{ matrix.target }} --release --bin codex --bin codex-responses-api-proxy + fi + - name: sccache stats (Android) + if: ${{ matrix.target == 'aarch64-linux-android' }} shell: bash - run: | - build_args=() - for binary in ${{ matrix.binaries }}; do - build_args+=(--bin "$binary") - done - echo "CARGO_PROFILE_RELEASE_LTO: ${CARGO_PROFILE_RELEASE_LTO}" - cargo build --target ${{ matrix.target }} --release --timings "${build_args[@]}" + run: ${SCCACHE_PATH:-sccache} --show-stats || true - - name: Upload Cargo timings - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: cargo-timings-rust-release-${{ matrix.target }}-${{ matrix.bundle }} - path: codex-rs/target/**/cargo-timings/cargo-timing.html - if-no-files-found: warn - - - if: ${{ runner.os == 'macOS' && env.SIGN_MACOS != 'true' }} - name: Stage unsigned macOS artifacts + - if: ${{ matrix.target == 'aarch64-linux-android' }} + name: Normalize Android ELF for Termux shell: bash run: | set -euo pipefail - - target="${{ matrix.target }}" - release_dir="target/${target}/release" - dest="unsigned-dist/${target}" - mkdir -p "$dest" - - for binary in ${{ matrix.binaries }}; do - binary_path="${release_dir}/${binary}" - unsigned_name="${binary}-${target}-unsigned" - unsigned_path="${dest}/${unsigned_name}" - if [[ ! -f "${binary_path}" ]]; then - echo "Binary ${binary_path} not found" - exit 1 + for binary in codex codex-responses-api-proxy; do + binary_path="target/${{ matrix.target }}/release/${binary}" + if [[ -f "${binary_path}" ]]; then + termux-elf-cleaner --api-level 24 "${binary_path}" + chmod +x "${binary_path}" + # if [[ "${binary}" == "codex" ]]; then + # # Patch PT_TLS (0x7) to PT_NULL (0x0) at offset 400 to bypass Bionic alignment checks. + # printf '\x00\x00\x00\x00' | dd of="${binary_path}" bs=1 seek=400 count=4 conv=notrunc + # fi fi - - cp "${binary_path}" "${unsigned_path}" - tar -C "$dest" -czf "${unsigned_path}.tar.gz" "${unsigned_name}" - zstd -T0 -19 --rm "${unsigned_path}" done - - if: ${{ runner.os == 'macOS' && env.SIGN_MACOS != 'true' }} - name: Upload unsigned macOS artifacts - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: ${{ matrix.artifact_name }}-unsigned - path: codex-rs/unsigned-dist/${{ matrix.target }}/* - if-no-files-found: error - - - if: ${{ contains(matrix.target, 'linux') }} + - if: ${{ contains(matrix.target, 'linux') && !contains(matrix.target, 'android') && github.repository_owner == 'openai' }} name: Cosign Linux artifacts uses: ./.github/actions/linux-code-sign with: target: ${{ matrix.target }} artifacts-dir: ${{ github.workspace }}/codex-rs/target/${{ matrix.target }}/release - binaries: ${{ matrix.binaries }} - - if: ${{ runner.os == 'macOS' && env.SIGN_MACOS == 'true' }} + - if: ${{ contains(matrix.target, 'windows') && github.repository_owner == 'openai' }} + name: Sign Windows binaries with Azure Trusted Signing + uses: ./.github/actions/windows-code-sign + with: + target: ${{ matrix.target }} + client-id: ${{ secrets.AZURE_TRUSTED_SIGNING_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TRUSTED_SIGNING_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_TRUSTED_SIGNING_SUBSCRIPTION_ID }} + endpoint: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} + account-name: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} + certificate-profile-name: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} + + - if: ${{ runner.os == 'macOS' && github.repository_owner == 'openai' }} name: MacOS code signing (binaries) uses: ./.github/actions/macos-code-sign with: target: ${{ matrix.target }} - binaries: ${{ matrix.binaries }} sign-binaries: "true" sign-dmg: "false" apple-certificate: ${{ secrets.APPLE_CERTIFICATE_P12 }} @@ -436,7 +623,7 @@ jobs: apple-notarization-key-id: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} apple-notarization-issuer-id: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} - - if: ${{ runner.os == 'macOS' && matrix.build_dmg == 'true' && env.SIGN_MACOS == 'true' }} + - if: ${{ runner.os == 'macOS' && github.repository_owner == 'openai' }} name: Build macOS dmg shell: bash run: | @@ -451,17 +638,23 @@ jobs: # The previous "MacOS code signing (binaries)" step signs + notarizes the # built artifacts in `${release_dir}`. This step packages *those same* # signed binaries into a dmg. + codex_binary_path="${release_dir}/codex" + proxy_binary_path="${release_dir}/codex-responses-api-proxy" + rm -rf "$dmg_root" mkdir -p "$dmg_root" - for binary in ${{ matrix.binaries }}; do - binary_path="${release_dir}/${binary}" - if [[ ! -f "${binary_path}" ]]; then - echo "Binary ${binary_path} not found" - exit 1 - fi - ditto "${binary_path}" "${dmg_root}/${binary}" - done + if [[ ! -f "$codex_binary_path" ]]; then + echo "Binary $codex_binary_path not found" + exit 1 + fi + if [[ ! -f "$proxy_binary_path" ]]; then + echo "Binary $proxy_binary_path not found" + exit 1 + fi + + ditto "$codex_binary_path" "${dmg_root}/codex" + ditto "$proxy_binary_path" "${dmg_root}/codex-responses-api-proxy" rm -f "$dmg_path" hdiutil create \ @@ -476,7 +669,7 @@ jobs: exit 1 fi - - if: ${{ runner.os == 'macOS' && matrix.build_dmg == 'true' && env.SIGN_MACOS == 'true' }} + - if: ${{ runner.os == 'macOS' && github.repository_owner == 'openai' }} name: MacOS code signing (dmg) uses: ./.github/actions/macos-code-sign with: @@ -490,121 +683,65 @@ jobs: apple-notarization-issuer-id: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} - name: Stage artifacts - if: ${{ runner.os != 'macOS' || env.SIGN_MACOS == 'true' }} shell: bash run: | dest="dist/${{ matrix.target }}" mkdir -p "$dest" - for binary in ${{ matrix.binaries }}; do - cp "target/${{ matrix.target }}/release/${binary}" "$dest/${binary}-${{ matrix.target }}" - if [[ "${{ matrix.target }}" == *linux* ]]; then - cp "target/${{ matrix.target }}/release/${binary}.sigstore" \ - "$dest/${binary}-${{ matrix.target }}.sigstore" + if [[ "${{ matrix.runner }}" == windows* ]]; then + cp target/${{ matrix.target }}/release/codex.exe "$dest/codex-${{ matrix.target }}.exe" + cp target/${{ matrix.target }}/release/codex-responses-api-proxy.exe "$dest/codex-responses-api-proxy-${{ matrix.target }}.exe" + cp target/${{ matrix.target }}/release/codex-windows-sandbox-setup.exe "$dest/codex-windows-sandbox-setup-${{ matrix.target }}.exe" + cp target/${{ matrix.target }}/release/codex-command-runner.exe "$dest/codex-command-runner-${{ matrix.target }}.exe" + else + # For Android, we want the binary to be named just 'codex' in the archive. + if [[ "${{ matrix.target }}" == "aarch64-linux-android" ]]; then + cp target/${{ matrix.target }}/release/codex "$dest/codex" + else + cp target/${{ matrix.target }}/release/codex "$dest/codex-${{ matrix.target }}" + fi + if [[ -f target/${{ matrix.target }}/release/codex-responses-api-proxy ]]; then + cp target/${{ matrix.target }}/release/codex-responses-api-proxy "$dest/codex-responses-api-proxy-${{ matrix.target }}" fi - done - - if [[ "${{ matrix.target }}" == *linux* && "${{ matrix.bundle }}" == "primary" ]]; then - bundle_root="${RUNNER_TEMP}/codex-${{ matrix.target }}-bundle" - rm -rf "$bundle_root" - mkdir -p "$bundle_root/codex-resources" - cp "$dest/codex-${{ matrix.target }}" "$bundle_root/codex" - cp "$dest/bwrap-${{ matrix.target }}" "$bundle_root/codex-resources/bwrap" - chmod 0755 "$bundle_root/codex" "$bundle_root/codex-resources/bwrap" - tar -C "$bundle_root" -cf - codex codex-resources/bwrap | - zstd -T0 -19 -o "$dest/codex-${{ matrix.target }}-bundle.tar.zst" fi - if [[ "${{ matrix.build_dmg }}" == "true" ]]; then - cp target/${{ matrix.target }}/release/codex-${{ matrix.target }}.dmg "$dest/codex-${{ matrix.target }}.dmg" + if [[ "${{ matrix.target }}" == *linux* && "${{ matrix.target }}" != *android* ]]; then + cp target/${{ matrix.target }}/release/codex.sigstore "$dest/codex-${{ matrix.target }}.sigstore" + cp target/${{ matrix.target }}/release/codex-responses-api-proxy.sigstore "$dest/codex-responses-api-proxy-${{ matrix.target }}.sigstore" fi - - name: Build Codex package archive - if: ${{ runner.os != 'macOS' || env.SIGN_MACOS == 'true' }} - shell: bash - env: - TARGET: ${{ matrix.target }} - BUNDLE: ${{ matrix.bundle }} - run: | - set -euo pipefail - bash "${GITHUB_WORKSPACE}/.github/scripts/build-codex-package-archive.sh" \ - --target "$TARGET" \ - --bundle "$BUNDLE" \ - --entrypoint-dir "target/${TARGET}/release" \ - --archive-dir "dist/${TARGET}" - - - name: Build Python runtime wheel - if: ${{ matrix.bundle == 'primary' && (runner.os != 'macOS' || env.SIGN_MACOS == 'true') }} - shell: bash - run: | - set -euo pipefail - - case "${{ matrix.target }}" in - aarch64-apple-darwin) - platform_tag="macosx_11_0_arm64" - ;; - x86_64-apple-darwin) - platform_tag="macosx_10_9_x86_64" - ;; - aarch64-unknown-linux-musl) - platform_tag="manylinux_2_17_aarch64" - ;; - x86_64-unknown-linux-musl) - platform_tag="manylinux_2_17_x86_64" - ;; - *) - echo "No Python runtime wheel platform tag for ${{ matrix.target }}" - exit 1 - ;; - esac - - python3 -m venv "${RUNNER_TEMP}/python-runtime-build-venv" - # Do not install into the runner's system Python; macOS runners mark - # the Homebrew Python as externally managed under PEP 668. - "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m pip install build - - stage_dir="${RUNNER_TEMP}/openai-codex-cli-bin-${{ matrix.target }}" - wheel_dir="${GITHUB_WORKSPACE}/python-runtime-dist/${{ matrix.target }}" - stage_runtime_args=( - "${GITHUB_WORKSPACE}/sdk/python/scripts/update_sdk_artifacts.py" - stage-runtime - "$stage_dir" - "${GITHUB_WORKSPACE}/codex-rs/target/${{ matrix.target }}/release/codex" - --codex-version "${GITHUB_REF_NAME}" - --platform-tag "$platform_tag" - ) - if [[ "${{ matrix.target }}" == *linux* ]]; then - # Keep bwrap in the runtime wheel so Linux sandbox fallback behavior - # matches the standalone release bundle on hosts without system bwrap. - stage_runtime_args+=( - --resource-binary - "${GITHUB_WORKSPACE}/codex-rs/target/${{ matrix.target }}/release/bwrap" - ) + if [[ "${{ matrix.target }}" == *apple-darwin ]]; then + cp target/${{ matrix.target }}/release/codex-${{ matrix.target }}.dmg "$dest/codex-${{ matrix.target }}.dmg" fi - python3 "${stage_runtime_args[@]}" - "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m build --wheel --outdir "$wheel_dir" "$stage_dir" - - name: Upload Python runtime wheel - if: ${{ matrix.bundle == 'primary' && (runner.os != 'macOS' || env.SIGN_MACOS == 'true') }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: python-runtime-wheel-${{ matrix.target }} - path: python-runtime-dist/${{ matrix.target }}/*.whl - if-no-files-found: error + - if: ${{ matrix.runner == 'windows-11-arm' }} + name: Install zstd + shell: powershell + run: choco install -y zstandard - name: Compress artifacts - if: ${{ runner.os != 'macOS' || env.SIGN_MACOS == 'true' }} shell: bash run: | # Path that contains the uncompressed binaries for the current # ${{ matrix.target }} dest="dist/${{ matrix.target }}" + repo_root=$PWD + + # We want to ship the raw Windows executables in the GitHub Release + # in addition to the compressed archives. Keep the originals for + # Windows targets; remove them elsewhere to limit the number of + # artifacts that end up in the GitHub Release. + keep_originals=false + if [[ "${{ matrix.runner }}" == windows* ]]; then + keep_originals=true + fi # For compatibility with environments that lack the `zstd` tool we - # additionally create a `.tar.gz` alongside every binary we publish. - # The end result is: + # additionally create a `.tar.gz` for all platforms and `.zip` for + # Windows alongside every single binary that we publish. The end result is: # codex-.zst (existing) # codex-.tar.gz (new) + # codex-.zip (only for Windows) # 1. Produce a .tar.gz for every file in the directory *before* we # run `zstd --rm`, because that flag deletes the original files. @@ -612,7 +749,7 @@ jobs: base="$(basename "$f")" # Skip files that are already archives (shouldn't happen, but be # safe). - if [[ "$base" == *.tar.gz || "$base" == *.tar.zst || "$base" == *.zip || "$base" == *.dmg ]]; then + if [[ "$base" == *.tar.gz || "$base" == *.zip || "$base" == *.dmg ]]; then continue fi @@ -622,344 +759,239 @@ jobs: fi # Create per-binary tar.gz - tar -C "$dest" -czf "$dest/${base}.tar.gz" "$base" - - # Also create .zst and remove the uncompressed binaries to keep - # non-Windows artifact directories small. - zstd -T0 -19 --rm "$dest/$base" - done - - - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - if: ${{ runner.os != 'macOS' || env.SIGN_MACOS == 'true' }} - with: - name: ${{ matrix.artifact_name }} - # Upload the per-binary .zst files, .tar.gz equivalents, and any - # prebuilt archives staged above. - path: | - codex-rs/dist/${{ matrix.target }}/* - - stage-signed-macos: - if: ${{ github.event_name == 'workflow_dispatch' && inputs.release_mode == 'promote_signed' }} - needs: tag-check - name: Stage signed macOS handoff - ${{ matrix.target }} - ${{ matrix.bundle }} - runs-on: macos-15-xlarge - timeout-minutes: 30 - permissions: - contents: read - defaults: - run: - working-directory: codex-rs + # For Android, we want the archive to have the target suffix even though the binary is just 'codex'. + archive_name="${base}" + if [[ "${{ matrix.target }}" == "aarch64-linux-android" && "${base}" == "codex" ]]; then + archive_name="codex-${{ matrix.target }}" + fi + tar -C "$dest" -czf "$dest/${archive_name}.tar.gz" "$base" + + # Create zip archive for Windows binaries + # Must run from inside the dest dir so 7z won't + # embed the directory path inside the zip. + if [[ "${{ matrix.runner }}" == windows* ]]; then + if [[ "$base" == "codex-${{ matrix.target }}.exe" ]]; then + # Bundle the sandbox helper binaries into the main codex zip so + # WinGet installs include the required helpers next to codex.exe. + # Fall back to the single-binary zip if the helpers are missing + # to avoid breaking releases. + bundle_dir="$(mktemp -d)" + runner_src="$dest/codex-command-runner-${{ matrix.target }}.exe" + setup_src="$dest/codex-windows-sandbox-setup-${{ matrix.target }}.exe" + if [[ -f "$runner_src" && -f "$setup_src" ]]; then + cp "$dest/$base" "$bundle_dir/$base" + cp "$runner_src" "$bundle_dir/codex-command-runner.exe" + cp "$setup_src" "$bundle_dir/codex-windows-sandbox-setup.exe" + # Use an absolute path so bundle zips land in the real dist + # dir even when 7z runs from a temp directory. + (cd "$bundle_dir" && 7z a "$repo_root/$dest/${base}.zip" .) + else + echo "warning: missing sandbox binaries; falling back to single-binary zip" + echo "warning: expected $runner_src and $setup_src" + (cd "$dest" && 7z a "${base}.zip" "$base") + fi + rm -rf "$bundle_dir" + else + (cd "$dest" && 7z a "${base}.zip" "$base") + fi + fi - strategy: - fail-fast: false - matrix: - include: - - target: aarch64-apple-darwin - bundle: primary - artifact_name: aarch64-apple-darwin - binaries: "codex codex-responses-api-proxy" - build_dmg: "false" - - target: aarch64-apple-darwin - bundle: app-server - artifact_name: aarch64-apple-darwin-app-server - binaries: "codex-app-server" - build_dmg: "false" - - target: x86_64-apple-darwin - bundle: primary - artifact_name: x86_64-apple-darwin - binaries: "codex codex-responses-api-proxy" - build_dmg: "false" - - target: x86_64-apple-darwin - bundle: app-server - artifact_name: x86_64-apple-darwin-app-server - binaries: "codex-app-server" - build_dmg: "false" + # Also create .zst (existing behaviour) *and* remove the original + # uncompressed binary to keep the directory small. + zstd_args=(-T0 -19) + if [[ "${keep_originals}" == false ]]; then + zstd_args+=(--rm) + fi - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false + if [[ "${archive_name}" != "${base}" ]]; then + zstd "${zstd_args[@]}" "$dest/$base" -o "$dest/${archive_name}.zst" + else + zstd "${zstd_args[@]}" "$dest/$base" + fi + done - - name: Download signed macOS handoff + - name: Add Termux release metadata + if: ${{ (github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch') && matrix.target == 'aarch64-linux-android' }} shell: bash - env: - GH_TOKEN: ${{ github.token }} - SIGNED_MACOS_ASSET: ${{ inputs.signed_macos_asset }} - SIGNED_MACOS_SHA256: ${{ inputs.signed_macos_sha256 }} run: | set -euo pipefail - - download_dir="${RUNNER_TEMP}/signed-macos-download" - handoff_dir="${RUNNER_TEMP}/signed-macos-handoff" - rm -rf "$download_dir" "$handoff_dir" - mkdir -p "$download_dir" "$handoff_dir" - - gh release download "$GITHUB_REF_NAME" \ - --repo "$GITHUB_REPOSITORY" \ - --pattern "$SIGNED_MACOS_ASSET" \ - --dir "$download_dir" - - asset_count="$(find "$download_dir" -maxdepth 1 -type f | wc -l | tr -d '[:space:]')" - if [[ "$asset_count" != "1" ]]; then - echo "Expected exactly one signed macOS handoff asset named ${SIGNED_MACOS_ASSET}; found ${asset_count}" - find "$download_dir" -maxdepth 1 -type f -print - exit 1 - fi - - asset_path="$(find "$download_dir" -maxdepth 1 -type f -print -quit)" - if [[ -n "${SIGNED_MACOS_SHA256}" ]]; then - expected_sha="$(printf '%s' "$SIGNED_MACOS_SHA256" | tr '[:upper:]' '[:lower:]')" - actual_sha="$(shasum -a 256 "$asset_path" | awk '{print $1}')" - if [[ "$actual_sha" != "$expected_sha" ]]; then - echo "signed_macos_sha256 mismatch for ${SIGNED_MACOS_ASSET}" - echo "expected: ${expected_sha}" - echo "actual: ${actual_sha}" - exit 1 - fi + if [[ ! -f "${GITHUB_WORKSPACE}/.github/termux-release.json" ]]; then + echo "No Termux release metadata found; skipping metadata attachment." + exit 0 fi + dest="dist/${{ matrix.target }}" + cp "${GITHUB_WORKSPACE}/.github/termux-release.json" "${dest}/termux-release.json" + ( + cd "${dest}" + sha256sum ./* > SHA256SUMS + ) - asset_name="$(basename "$asset_path")" - case "$asset_name" in - *.tar.zst) - zstd -dc "$asset_path" | tar -C "$handoff_dir" -xf - - ;; - *.tar.gz|*.tgz) - tar -C "$handoff_dir" -xzf "$asset_path" - ;; - *.zip) - ditto -x -k "$asset_path" "$handoff_dir" - ;; - *) - echo "Unsupported signed macOS handoff archive format: ${asset_name}" - exit 1 - ;; - esac - - echo "SIGNED_MACOS_HANDOFF_DIR=$handoff_dir" >> "$GITHUB_ENV" + - id: upload-artifact + uses: actions/upload-artifact@v6 + with: + name: ${{ github.event_name == 'pull_request' && format('termux-android-pr-{0}-{1}', github.event.pull_request.number, github.event.pull_request.head.sha) || matrix.target }} + # Upload the per-binary .zst files as well as the new .tar.gz + # equivalents we generated in the previous step. + path: | + codex-rs/dist/${{ matrix.target }}/* - - name: Stage signed macOS artifacts + - name: Publish Termux artifact notification + if: ${{ github.event_name == 'pull_request' && startsWith(github.base_ref, 'release/') && matrix.target == 'aarch64-linux-android' }} shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ARTIFACT_ID: ${{ steps.upload-artifact.outputs.artifact-id }} + ARTIFACT_NAME: ${{ github.event_name == 'pull_request' && format('termux-android-pr-{0}-{1}', github.event.pull_request.number, github.event.pull_request.head.sha) || matrix.target }} + PR_NUMBER: ${{ github.event.pull_request.number }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} run: | set -euo pipefail - - target="${{ matrix.target }}" - artifact_name="${{ matrix.artifact_name }}" - source_dir="${SIGNED_MACOS_HANDOFF_DIR}/${artifact_name}" - if [[ ! -d "$source_dir" && -d "${SIGNED_MACOS_HANDOFF_DIR}/dist/${artifact_name}" ]]; then - source_dir="${SIGNED_MACOS_HANDOFF_DIR}/dist/${artifact_name}" - fi - if [[ ! -d "$source_dir" && -d "${SIGNED_MACOS_HANDOFF_DIR}/${target}" ]]; then - source_dir="${SIGNED_MACOS_HANDOFF_DIR}/${target}" - fi - if [[ ! -d "$source_dir" && -d "${SIGNED_MACOS_HANDOFF_DIR}/dist/${target}" ]]; then - source_dir="${SIGNED_MACOS_HANDOFF_DIR}/dist/${target}" - fi - if [[ ! -d "$source_dir" ]]; then - echo "Signed macOS handoff is missing ${artifact_name}/" - echo "Expected either:" - echo " ${SIGNED_MACOS_HANDOFF_DIR}/${artifact_name}" - echo " ${SIGNED_MACOS_HANDOFF_DIR}/dist/${artifact_name}" - echo " ${SIGNED_MACOS_HANDOFF_DIR}/${target}" - echo " ${SIGNED_MACOS_HANDOFF_DIR}/dist/${target}" - find "$SIGNED_MACOS_HANDOFF_DIR" -maxdepth 3 -type f -print + if [[ -z "${ARTIFACT_ID}" ]]; then + echo "upload-artifact did not return an artifact id" >&2 exit 1 fi - dest="dist/${target}" - mkdir -p "$dest" - - for binary in ${{ matrix.binaries }}; do - source_path="${source_dir}/${binary}" - if [[ ! -f "$source_path" ]]; then - source_path="${source_dir}/${binary}-${target}" - fi - if [[ ! -f "$source_path" ]]; then - echo "Signed macOS handoff is missing ${binary} for ${artifact_name}" - exit 1 - fi - - release_path="${dest}/${binary}-${target}" - ditto "$source_path" "$release_path" - chmod 0755 "$release_path" - codesign --verify --strict --verbose=2 "$release_path" - done - - # DMG staging is disabled for signed promotion because we no longer - # distribute DMGs from this release path. Keep the branch here so the - # handoff can opt back in by flipping matrix.build_dmg if needed. - if [[ "${{ matrix.build_dmg }}" == "true" ]]; then - dmg_name="codex-${target}.dmg" - dmg_source="${source_dir}/${dmg_name}" - if [[ ! -f "$dmg_source" ]]; then - echo "Signed macOS handoff is missing ${dmg_name} for ${artifact_name}" - exit 1 - fi - - codesign --verify --strict --verbose=2 "$dmg_source" - xcrun stapler validate "$dmg_source" - cp "$dmg_source" "$dest/$dmg_name" + marker="" + artifact_url="https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/artifacts/${ARTIFACT_ID}" + run_url="${GH_WORKFLOW_URL:-https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}}" + body_path="${RUNNER_TEMP}/termux-artifact-comment.md" + { + echo "${marker}" + echo "Termux Android artifact ready for testing:" + echo + echo "- Artifact: [${ARTIFACT_NAME}](${artifact_url})" + echo "- Workflow run: [${GITHUB_RUN_ID}](${run_url})" + echo + echo "You must be signed in to GitHub with repository access to download Actions artifacts." + } > "${body_path}" + + existing_comment_id="$( + gh pr view "${PR_NUMBER}" \ + --repo "${GITHUB_REPOSITORY}" \ + --json comments \ + --jq ".comments | map(select(.author.is_bot == true and (.body | contains(\"${marker}\")))) | .[-1].id // \"\"" + )" + if [[ -n "${existing_comment_id}" ]]; then + gh api graphql \ + -f query=' + mutation($id: ID!, $body: String!) { + updateIssueComment(input: {id: $id, body: $body}) { + issueComment { + id + } + } + } + ' \ + -f id="${existing_comment_id}" \ + -f body="$(cat "${body_path}")" \ + >/dev/null + else + gh pr comment "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" --body-file "${body_path}" fi - - name: Build Python runtime wheel - if: ${{ matrix.bundle == 'primary' }} - shell: bash - run: | - set -euo pipefail - - case "${{ matrix.target }}" in - aarch64-apple-darwin) - platform_tag="macosx_11_0_arm64" - ;; - x86_64-apple-darwin) - platform_tag="macosx_10_9_x86_64" - ;; - *) - echo "No Python runtime wheel platform tag for ${{ matrix.target }}" - exit 1 - ;; - esac - - python3 -m venv "${RUNNER_TEMP}/python-runtime-build-venv" - "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m pip install build - - stage_dir="${RUNNER_TEMP}/openai-codex-cli-bin-${{ matrix.target }}" - wheel_dir="${GITHUB_WORKSPACE}/python-runtime-dist/${{ matrix.target }}" - python3 \ - "${GITHUB_WORKSPACE}/sdk/python/scripts/update_sdk_artifacts.py" \ - stage-runtime \ - "$stage_dir" \ - "${GITHUB_WORKSPACE}/codex-rs/dist/${{ matrix.target }}/codex-${{ matrix.target }}" \ - --codex-version "${GITHUB_REF_NAME}" \ - --platform-tag "$platform_tag" - "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m build --wheel --outdir "$wheel_dir" "$stage_dir" - - - name: Build Codex package archive - shell: bash - env: - TARGET: ${{ matrix.target }} - BUNDLE: ${{ matrix.bundle }} - run: | - set -euo pipefail - bash "${GITHUB_WORKSPACE}/.github/scripts/build-codex-package-archive.sh" \ - --target "$TARGET" \ - --bundle "$BUNDLE" \ - --entrypoint-dir "dist/${TARGET}" \ - --archive-dir "dist/${TARGET}" \ - --target-suffixed-entrypoint - - - name: Upload Python runtime wheel - if: ${{ matrix.bundle == 'primary' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: python-runtime-wheel-${{ matrix.target }} - path: python-runtime-dist/${{ matrix.target }}/*.whl - if-no-files-found: error - - - name: Compress artifacts - shell: bash - run: | - set -euo pipefail - - dest="dist/${{ matrix.target }}" - for f in "$dest"/*; do - base="$(basename "$f")" - if [[ "$base" == *.tar.gz || "$base" == *.tar.zst || "$base" == *.zip || "$base" == *.dmg ]]; then - continue + gh label create binary-ready \ + --repo "${GITHUB_REPOSITORY}" \ + --color 0e8a16 \ + --description "Android binary is ready for testing" \ + --force + gh pr edit "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" --add-label binary-ready + + if [[ -n "${SLACK_WEBHOOK_URL:-}" ]]; then + slack_payload_path="${RUNNER_TEMP}/termux-artifact-slack.json" + pr_url="https://github.com/${GITHUB_REPOSITORY}/pull/${PR_NUMBER}" + jq -n \ + --arg artifact_name "${ARTIFACT_NAME}" \ + --arg artifact_url "${artifact_url}" \ + --arg pr_number "${PR_NUMBER}" \ + --arg pr_url "${pr_url}" \ + --arg run_id "${GITHUB_RUN_ID}" \ + --arg run_url "${run_url}" \ + '{ + text: ("Termux Android artifact ready for testing: " + $artifact_url), + blocks: [ + { + type: "section", + text: { + type: "mrkdwn", + text: ("*Termux Android artifact ready for testing*\n" + $artifact_url) + } + }, + { + type: "section", + fields: [ + { + type: "mrkdwn", + text: ("*Artifact:*\n<" + $artifact_url + "|" + $artifact_name + ">") + }, + { + type: "mrkdwn", + text: ("*Pull request:*\n<" + $pr_url + "|#" + $pr_number + ">") + }, + { + type: "mrkdwn", + text: ("*Workflow run:*\n<" + $run_url + "|" + $run_id + ">") + } + ] + }, + { + type: "context", + elements: [ + { + type: "mrkdwn", + text: "You must be signed in to GitHub with repository access to download Actions artifacts." + } + ] + } + ] + }' > "${slack_payload_path}" + + if ! curl -fsS \ + -X POST \ + -H "Content-type: application/json" \ + --data @"${slack_payload_path}" \ + "${SLACK_WEBHOOK_URL}"; then + echo "::warning::Failed to send Slack artifact notification" fi + fi - tar -C "$dest" -czf "$dest/${base}.tar.gz" "$base" - zstd -T0 -19 --rm "$dest/$base" - done - - - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: ${{ matrix.artifact_name }} - path: | - codex-rs/dist/${{ matrix.target }}/* - - build-windows: - if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} - needs: tag-check - uses: ./.github/workflows/rust-release-windows.yml - with: - release-lto: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'fat' }} - secrets: inherit - - argument-comment-lint-release-assets: - if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} - name: argument-comment-lint release assets + shell-tool-mcp: + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && github.repository_owner == 'openai' + name: shell-tool-mcp needs: tag-check - uses: ./.github/workflows/rust-release-argument-comment-lint.yml + permissions: + contents: read + id-token: write + uses: ./.github/workflows/shell-tool-mcp.yml with: + release-tag: ${{ github.ref_name }} publish: true - - zsh-release-assets: - if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} - name: zsh release assets - needs: tag-check - uses: ./.github/workflows/rust-release-zsh.yml + secrets: inherit release: + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') needs: - - tag-check - build - - stage-signed-macos - - build-windows - - argument-comment-lint-release-assets - - zsh-release-assets - if: >- - ${{ - always() && - needs.tag-check.result == 'success' && - ( - ( - github.event_name == 'workflow_dispatch' && - inputs.release_mode == 'promote_signed' && - needs.stage-signed-macos.result == 'success' && - needs.build.result == 'skipped' && - needs.build-windows.result == 'skipped' && - needs.argument-comment-lint-release-assets.result == 'skipped' && - needs.zsh-release-assets.result == 'skipped' - ) || - ( - (github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed') && - needs.build.result == 'success' && - needs.stage-signed-macos.result == 'skipped' && - needs.build-windows.result == 'success' && - needs.argument-comment-lint-release-assets.result == 'success' && - needs.zsh-release-assets.result == 'success' - ) - ) - }} + - shell-tool-mcp name: release runs-on: ubuntu-latest permissions: contents: write actions: read - env: - RELEASE_MODE: ${{ github.event_name == 'workflow_dispatch' && inputs.release_mode || 'signed' }} - SIGN_MACOS: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode == 'promote_signed' }} - SIGNED_MACOS_ASSET: ${{ inputs.signed_macos_asset }} - UNSIGNED_RUN_ID: ${{ inputs.unsigned_run_id }} outputs: version: ${{ steps.release_name.outputs.name }} tag: ${{ github.ref_name }} - sign_macos: ${{ steps.release_mode.outputs.sign_macos }} should_publish_npm: ${{ steps.npm_publish_settings.outputs.should_publish }} npm_tag: ${{ steps.npm_publish_settings.outputs.npm_tag }} - should_publish_python_runtime: ${{ steps.python_runtime_publish_settings.outputs.should_publish }} steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false + uses: actions/checkout@v6 - - name: Define release mode - id: release_mode - run: | - echo "release_mode=${RELEASE_MODE}" >> "$GITHUB_OUTPUT" - echo "sign_macos=${SIGN_MACOS}" >> "$GITHUB_OUTPUT" + - name: 🧰 Actions Toolbox + # This is required for the GitHub CLI + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: wallentx/gh-actions/composite/actions-toolbox@main - name: Generate release notes from tag commit message id: release_notes @@ -981,138 +1013,18 @@ jobs: echo "path=${notes_path}" >> "${GITHUB_OUTPUT}" - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + - uses: actions/download-artifact@v7 with: path: dist - - name: Validate unsigned build run - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - - run_summary="$(gh run view "$UNSIGNED_RUN_ID" \ - --repo "$GITHUB_REPOSITORY" \ - --json conclusion,event,headBranch,headSha,status,workflowName,url \ - --jq '[.workflowName, .event, .headBranch, .headSha, .status, .conclusion, .url] | @tsv')" - IFS=$'\t' read -r workflow_name event head_branch head_sha status conclusion run_url <<< "$run_summary" - expected_head_sha="$(git rev-parse "${GITHUB_SHA}^{commit}")" - - if [[ "$workflow_name" != "$GITHUB_WORKFLOW" ]]; then - echo "unsigned_run_id ${UNSIGNED_RUN_ID} is for workflow '${workflow_name}', expected '${GITHUB_WORKFLOW}'" - echo "Run URL: ${run_url}" - exit 1 - fi - - if [[ "$event" != "workflow_dispatch" ]]; then - echo "unsigned_run_id ${UNSIGNED_RUN_ID} was triggered by '${event}', expected 'workflow_dispatch'" - echo "Run URL: ${run_url}" - exit 1 - fi - - if [[ "$head_branch" != "$GITHUB_REF_NAME" ]]; then - echo "unsigned_run_id ${UNSIGNED_RUN_ID} used ref '${head_branch}', expected '${GITHUB_REF_NAME}'" - echo "Run URL: ${run_url}" - exit 1 - fi - - if [[ "$head_sha" != "$expected_head_sha" ]]; then - echo "unsigned_run_id ${UNSIGNED_RUN_ID} used head SHA '${head_sha}', expected '${expected_head_sha}'" - echo "Run URL: ${run_url}" - exit 1 - fi - - if [[ "$status" != "completed" || "$conclusion" != "success" ]]; then - echo "unsigned_run_id ${UNSIGNED_RUN_ID} is ${status}/${conclusion}, expected completed/success" - echo "Run URL: ${run_url}" - exit 1 - fi - - - name: Download artifacts from unsigned build run - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - gh run download "$UNSIGNED_RUN_ID" \ - --repo "$GITHUB_REPOSITORY" \ - --dir dist - - - name: Remove unsigned macOS staging artifacts - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - run: | - set -euo pipefail - find dist -mindepth 1 -maxdepth 1 -type d \ - -name '*-apple-darwin*-unsigned' \ - -exec rm -rf {} + - - - name: Re-upload promoted Linux x64 artifacts - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: x86_64-unknown-linux-musl - path: dist/x86_64-unknown-linux-musl/* - if-no-files-found: error - - - name: Re-upload promoted Linux arm64 artifacts - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: aarch64-unknown-linux-musl - path: dist/aarch64-unknown-linux-musl/* - if-no-files-found: error - - - name: Re-upload promoted Windows x64 artifacts - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: x86_64-pc-windows-msvc - path: dist/x86_64-pc-windows-msvc/* - if-no-files-found: error - - - name: Re-upload promoted Windows arm64 artifacts - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: aarch64-pc-windows-msvc - path: dist/aarch64-pc-windows-msvc/* - if-no-files-found: error - - name: List run: ls -R dist/ - - name: Prune artifacts excluded from unsigned macOS release - if: ${{ env.SIGN_MACOS == 'false' }} - run: | - find dist -mindepth 1 -maxdepth 1 -type d \ - ! -name '*-apple-darwin*-unsigned' \ - ! -name 'aarch64-unknown-linux-musl' \ - ! -name 'aarch64-unknown-linux-musl-app-server' \ - ! -name 'x86_64-unknown-linux-musl' \ - ! -name 'x86_64-unknown-linux-musl-app-server' \ - ! -name 'aarch64-pc-windows-msvc' \ - ! -name 'x86_64-pc-windows-msvc' \ - -exec rm -rf {} + - - if ! find dist -type f -name '*-apple-darwin*-unsigned*' | grep -q .; then - echo "No unsigned macOS artifacts found in downloaded workflow artifacts." - exit 1 - fi - + # This is a temporary fix: we should modify shell-tool-mcp.yml so these + # files do not end up in dist/ in the first place. - name: Delete entries from dist/ that should not go in the release run: | - rm -rf dist/windows-binaries* - # cargo-timing.html appears under multiple target-specific directories. - # If included in files: dist/**, release upload races on duplicate - # asset names and can fail with 404s. - find dist -type f -name 'cargo-timing.html' -delete - # Keep package-builder sidecar archives as workflow artifacts only - # until distribution channels are ready to consume them. - find dist -type f \ - \( -name 'codex-package-*' -o -name 'codex-app-server-package-*' \) \ - -delete - find dist -type d -empty -delete + rm -rf dist/shell-tool-mcp* ls -R dist/ @@ -1136,12 +1048,6 @@ jobs: set -euo pipefail version="${VERSION}" - if [[ "${SIGN_MACOS}" != "true" ]]; then - echo "should_publish=false" >> "$GITHUB_OUTPUT" - echo "npm_tag=" >> "$GITHUB_OUTPUT" - exit 0 - fi - if [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "should_publish=true" >> "$GITHUB_OUTPUT" echo "npm_tag=" >> "$GITHUB_OUTPUT" @@ -1153,132 +1059,55 @@ jobs: echo "npm_tag=" >> "$GITHUB_OUTPUT" fi - - name: Determine Python runtime publish settings - id: python_runtime_publish_settings - env: - VERSION: ${{ steps.release_name.outputs.name }} - run: | - set -euo pipefail - version="${VERSION}" - - if [[ "${SIGN_MACOS}" != "true" ]]; then - echo "should_publish=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - if [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "should_publish=true" >> "$GITHUB_OUTPUT" - elif [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+-alpha\.[0-9]+$ ]]; then - echo "should_publish=true" >> "$GITHUB_OUTPUT" - else - echo "should_publish=false" >> "$GITHUB_OUTPUT" - fi - - name: Setup pnpm - if: ${{ env.SIGN_MACOS == 'true' }} - uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5 + uses: pnpm/action-setup@v4 with: run_install: false - name: Setup Node.js for npm packaging - if: ${{ env.SIGN_MACOS == 'true' }} - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + uses: actions/setup-node@v6 with: node-version: 22 - name: Install dependencies - if: ${{ env.SIGN_MACOS == 'true' }} run: pnpm install --frozen-lockfile # stage_npm_packages.py requires DotSlash when staging releases. - - uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2 + - uses: facebook/install-dotslash@v2 - name: Stage npm packages - if: ${{ env.SIGN_MACOS == 'true' }} + if: github.repository_owner == 'openai' env: GH_TOKEN: ${{ github.token }} - RELEASE_VERSION: ${{ steps.release_name.outputs.name }} run: | - workflow_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" ./scripts/stage_npm_packages.py \ - --release-version "$RELEASE_VERSION" \ - --workflow-url "$workflow_url" \ + --release-version "${{ steps.release_name.outputs.name }}" \ --package codex \ --package codex-responses-api-proxy \ --package codex-sdk - - name: Stage installer scripts - if: ${{ env.SIGN_MACOS == 'true' }} - run: | - cp scripts/install/install.sh dist/install.sh - cp scripts/install/install.ps1 dist/install.ps1 - - name: Create GitHub Release - uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1 + uses: softprops/action-gh-release@v2 with: name: ${{ steps.release_name.outputs.name }} tag_name: ${{ github.ref_name }} body_path: ${{ steps.release_notes.outputs.path }} files: dist/** - overwrite_files: true - make_latest: ${{ env.SIGN_MACOS == 'true' && !contains(steps.release_name.outputs.name, '-') }} # Mark as prerelease only when the version has a suffix after x.y.z # (e.g. -alpha, -beta). Otherwise publish a normal release. prerelease: ${{ contains(steps.release_name.outputs.name, '-') }} - - name: Clean up signed promotion handoff assets - if: ${{ env.RELEASE_MODE == 'promote_signed' }} - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - - release_id="$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${GITHUB_REF_NAME}" --jq '.id')" - gh api --paginate "repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets" \ - --jq '.[] | [.id, .name] | @tsv' | - while IFS=$'\t' read -r asset_id asset_name; do - if [[ -z "$asset_id" || -z "$asset_name" ]]; then - continue - fi - - delete_asset=false - if [[ "$asset_name" == *unsigned* || "$asset_name" == "$SIGNED_MACOS_ASSET" ]]; then - delete_asset=true - fi - - if [[ "$delete_asset" == "true" ]]; then - echo "Deleting release asset ${asset_name}" - gh api -X DELETE "repos/${GITHUB_REPOSITORY}/releases/assets/${asset_id}" - fi - done - - - if: ${{ env.SIGN_MACOS == 'true' }} - uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 + - if: github.repository_owner == 'openai' + uses: facebook/dotslash-publish-release@v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: tag: ${{ github.ref_name }} config: .github/dotslash-config.json - - if: ${{ env.SIGN_MACOS == 'true' }} - uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag: ${{ github.ref_name }} - config: .github/dotslash-zsh-config.json - - - if: ${{ env.SIGN_MACOS == 'true' }} - uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag: ${{ github.ref_name }} - config: .github/dotslash-argument-comment-lint-config.json - - name: Trigger developers.openai.com deploy # Only trigger the deploy if the release is not a pre-release. # The deploy is used to update the developers.openai.com website with the new config schema json file. - if: ${{ env.SIGN_MACOS == 'true' && !contains(steps.release_name.outputs.name, '-') }} + if: ${{ !contains(steps.release_name.outputs.name, '-') && github.repository_owner == 'openai' }} continue-on-error: true env: DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL: ${{ secrets.DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL }} @@ -1293,15 +1122,7 @@ jobs: # npm docs: https://docs.npmjs.com/trusted-publishers publish-npm: # Publish to npm for stable releases and alpha pre-releases with numeric suffixes. - # promote_signed intentionally skips build jobs that are ancestors of release; - # include the !cancelled() status function so Actions does not apply its implicit - # success() check to the whole dependency chain before evaluating release outputs. - if: >- - ${{ - !cancelled() && - needs.release.result == 'success' && - needs.release.outputs.should_publish_npm == 'true' - }} + if: ${{ needs.release.outputs.should_publish_npm == 'true' && github.repository_owner == 'openai' }} name: publish-npm needs: release runs-on: ubuntu-latest @@ -1311,37 +1132,36 @@ jobs: steps: - name: Setup Node.js - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + uses: actions/setup-node@v6 with: - # Node 24 bundles npm >= 11.5.1, which trusted publishing requires. - node-version: 24 + node-version: 22 registry-url: "https://registry.npmjs.org" scope: "@openai" + # Trusted publishing requires npm CLI version 11.5.1 or later. + - name: Update npm + run: npm install -g npm@latest + - name: Download npm tarballs from release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_TAG: ${{ needs.release.outputs.tag }} - RELEASE_VERSION: ${{ needs.release.outputs.version }} run: | set -euo pipefail - version="$RELEASE_VERSION" - tag="$RELEASE_TAG" + version="${{ needs.release.outputs.version }}" + tag="${{ needs.release.outputs.tag }}" mkdir -p dist/npm - patterns=( - "codex-npm-${version}.tgz" - "codex-npm-linux-*-${version}.tgz" - "codex-npm-darwin-*-${version}.tgz" - "codex-npm-win32-*-${version}.tgz" - "codex-responses-api-proxy-npm-${version}.tgz" - "codex-sdk-npm-${version}.tgz" - ) - for pattern in "${patterns[@]}"; do - gh release download "$tag" \ - --repo "${GITHUB_REPOSITORY}" \ - --pattern "$pattern" \ - --dir dist/npm - done + gh release download "$tag" \ + --repo "${GITHUB_REPOSITORY}" \ + --pattern "codex-npm-${version}.tgz" \ + --dir dist/npm + gh release download "$tag" \ + --repo "${GITHUB_REPOSITORY}" \ + --pattern "codex-responses-api-proxy-npm-${version}.tgz" \ + --dir dist/npm + gh release download "$tag" \ + --repo "${GITHUB_REPOSITORY}" \ + --pattern "codex-sdk-npm-${version}.tgz" \ + --dir dist/npm # No NODE_AUTH_TOKEN needed because we use OIDC. - name: Publish to npm @@ -1350,193 +1170,23 @@ jobs: NPM_TAG: ${{ needs.release.outputs.npm_tag }} run: | set -euo pipefail - prefix="" + tag_args=() if [[ -n "${NPM_TAG}" ]]; then - prefix="${NPM_TAG}-" + tag_args+=(--tag "${NPM_TAG}") fi - root_tarball="dist/npm/codex-npm-${VERSION}.tgz" - sdk_tarball="dist/npm/codex-sdk-npm-${VERSION}.tgz" - # Keep this list in sync with CODEX_PLATFORM_PACKAGES in - # codex-cli/scripts/build_npm_package.py. The root wrapper advances - # @openai/codex@latest as soon as it publishes, so every platform - # package it aliases must already exist in the registry first. - platform_tarballs=( - "dist/npm/codex-npm-linux-x64-${VERSION}.tgz" - "dist/npm/codex-npm-linux-arm64-${VERSION}.tgz" - "dist/npm/codex-npm-darwin-x64-${VERSION}.tgz" - "dist/npm/codex-npm-darwin-arm64-${VERSION}.tgz" - "dist/npm/codex-npm-win32-x64-${VERSION}.tgz" - "dist/npm/codex-npm-win32-arm64-${VERSION}.tgz" - ) - - for required_tarball in "${platform_tarballs[@]}" "${root_tarball}"; do - if [[ ! -f "${required_tarball}" ]]; then - echo "Missing npm tarball: ${required_tarball}" - exit 1 - fi - done - - shopt -s nullglob - other_tarballs=() - for tarball in dist/npm/*-"${VERSION}".tgz; do - if [[ "${tarball}" == "${root_tarball}" || "${tarball}" == "${sdk_tarball}" ]]; then - continue - fi - - is_platform_tarball=false - for platform_tarball in "${platform_tarballs[@]}"; do - if [[ "${tarball}" == "${platform_tarball}" ]]; then - is_platform_tarball=true - break - fi - done - if [[ "${is_platform_tarball}" == true ]]; then - continue - fi - - other_tarballs+=("${tarball}") - done - - # Publish the platform packages before the root CLI wrapper. The root - # wrapper advances @openai/codex@latest, so it should only publish - # after the optional dependency versions it references exist. tarballs=( - "${platform_tarballs[@]}" - "${other_tarballs[@]}" - "${root_tarball}" + "codex-npm-${VERSION}.tgz" + "codex-responses-api-proxy-npm-${VERSION}.tgz" + "codex-sdk-npm-${VERSION}.tgz" ) - if [[ -f "${sdk_tarball}" ]]; then - tarballs+=("${sdk_tarball}") - fi for tarball in "${tarballs[@]}"; do - filename="$(basename "${tarball}")" - tag="" - - case "${filename}" in - codex-npm-linux-*-"${VERSION}".tgz|codex-npm-darwin-*-"${VERSION}".tgz|codex-npm-win32-*-"${VERSION}".tgz) - platform="${filename#codex-npm-}" - platform="${platform%-${VERSION}.tgz}" - tag="${prefix}${platform}" - ;; - codex-npm-"${VERSION}".tgz|codex-responses-api-proxy-npm-"${VERSION}".tgz|codex-sdk-npm-"${VERSION}".tgz) - tag="${NPM_TAG}" - ;; - *) - echo "Unexpected npm tarball: ${filename}" - exit 1 - ;; - esac - - publish_cmd=(npm publish "${GITHUB_WORKSPACE}/${tarball}") - if [[ -n "${tag}" ]]; then - publish_cmd+=(--tag "${tag}") - fi - - echo "+ ${publish_cmd[*]}" - set +e - publish_output="$("${publish_cmd[@]}" 2>&1)" - publish_status=$? - set -e - - echo "${publish_output}" - if [[ ${publish_status} -eq 0 ]]; then - continue - fi - - if grep -qiE "previously published|cannot publish over|version already exists" <<< "${publish_output}"; then - echo "Skipping already-published package version for ${filename}" - continue - fi - - exit "${publish_status}" + npm publish "${GITHUB_WORKSPACE}/dist/npm/${tarball}" "${tag_args[@]}" done - # Publish the platform-specific Python runtime wheels using PyPI trusted publishing. - # PyPI project configuration must trust this workflow and job. Keep this - # non-blocking while the Python runtime publishing path is new; failures still - # need release follow-up, but should not invalidate the Rust release itself. - publish-python-runtime: - # Publish to PyPI for stable releases and alpha pre-releases with numeric suffixes. - if: >- - ${{ - !cancelled() && - needs.release.result == 'success' && - needs.release.outputs.should_publish_python_runtime == 'true' - }} - name: publish-python-runtime - needs: release - runs-on: ubuntu-latest - continue-on-error: true - environment: pypi - permissions: - id-token: write # Required for PyPI trusted publishing. - contents: read - - steps: - - name: Download Python runtime wheels from release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_TAG: ${{ needs.release.outputs.tag }} - RELEASE_VERSION: ${{ needs.release.outputs.version }} - run: | - set -euo pipefail - python_version="$RELEASE_VERSION" - python_version="${python_version/-alpha./a}" - python_version="${python_version/-beta./b}" - python_version="${python_version/-rc./rc}" - - mkdir -p dist/python-runtime - gh release download "$RELEASE_TAG" \ - --repo "${GITHUB_REPOSITORY}" \ - --pattern "openai_codex_cli_bin-${python_version}-*.whl" \ - --dir dist/python-runtime - ls -lh dist/python-runtime - - - name: Publish Python runtime wheels to PyPI - uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 - with: - packages-dir: dist/python-runtime - skip-existing: true - - winget: - name: winget - needs: release - # Only publish stable/mainline releases to WinGet; pre-releases include a - # '-' in the semver string (e.g., 1.2.3-alpha.1). - if: >- - ${{ - !cancelled() && - needs.release.result == 'success' && - needs.release.outputs.sign_macos == 'true' && - !contains(needs.release.outputs.version, '-') - }} - # This job only invokes a GitHub Action to open/update the winget-pkgs PR; - # it does not execute Windows-only tooling, so Linux is sufficient. - runs-on: ubuntu-latest - permissions: - contents: read - - steps: - - name: Publish to WinGet - uses: vedantmgoyal9/winget-releaser@7bd472be23763def6e16bd06cc8b1cdfab0e2fd5 - with: - identifier: OpenAI.Codex - version: ${{ needs.release.outputs.version }} - release-tag: ${{ needs.release.outputs.tag }} - fork-user: openai-oss-forks - installers-regex: '^codex-(?:x86_64|aarch64)-pc-windows-msvc\.exe\.zip$' - token: ${{ secrets.WINGET_PUBLISH_PAT }} - update-branch: name: Update latest-alpha-cli branch - if: >- - ${{ - !cancelled() && - needs.release.result == 'success' && - needs.release.outputs.sign_macos == 'true' - }} permissions: contents: write needs: release diff --git a/.github/workflows/shell-tool-mcp.yml b/.github/workflows/shell-tool-mcp.yml new file mode 100644 index 000000000000..66a76aa4d938 --- /dev/null +++ b/.github/workflows/shell-tool-mcp.yml @@ -0,0 +1,461 @@ +name: shell-tool-mcp + +on: + workflow_call: + inputs: + release-version: + description: Version to publish (x.y.z or x.y.z-alpha.N). Defaults to GITHUB_REF_NAME when it starts with rust-v. + required: false + type: string + release-tag: + description: Tag name to use when downloading release artifacts (defaults to rust-v). + required: false + type: string + publish: + description: Whether to publish to npm when the version is releasable. + required: false + default: true + type: boolean + +env: + NODE_VERSION: 22 + +jobs: + metadata: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.compute.outputs.version }} + release_tag: ${{ steps.compute.outputs.release_tag }} + should_publish: ${{ steps.compute.outputs.should_publish }} + npm_tag: ${{ steps.compute.outputs.npm_tag }} + steps: + - name: Compute version and tags + id: compute + run: | + set -euo pipefail + + version="${{ inputs.release-version }}" + release_tag="${{ inputs.release-tag }}" + + if [[ -z "$version" ]]; then + if [[ -n "$release_tag" && "$release_tag" =~ ^rust-v.+ ]]; then + version="${release_tag#rust-v}" + elif [[ "${GITHUB_REF_NAME:-}" =~ ^rust-v.+ ]]; then + version="${GITHUB_REF_NAME#rust-v}" + release_tag="${GITHUB_REF_NAME}" + else + echo "release-version is required when GITHUB_REF_NAME is not a rust-v tag." + exit 1 + fi + fi + + if [[ -z "$release_tag" ]]; then + release_tag="rust-v${version}" + fi + + npm_tag="" + should_publish="false" + if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + should_publish="true" + elif [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+-alpha\.[0-9]+$ ]]; then + should_publish="true" + npm_tag="alpha" + fi + + echo "version=${version}" >> "$GITHUB_OUTPUT" + echo "release_tag=${release_tag}" >> "$GITHUB_OUTPUT" + echo "npm_tag=${npm_tag}" >> "$GITHUB_OUTPUT" + echo "should_publish=${should_publish}" >> "$GITHUB_OUTPUT" + + matrix-setup: + runs-on: ubuntu-latest + outputs: + rust-binaries: ${{ steps.compute.outputs.rust-binaries }} + bash-linux: ${{ steps.compute.outputs.bash-linux }} + bash-darwin: ${{ steps.compute.outputs.bash-darwin }} + steps: + - name: Compute matrices + id: compute + shell: bash + run: | + set -euo pipefail + is_openai="${{ github.repository_owner == 'openai' }}" + + # rust-binaries: always include x86_64-musl; conditionally include paid/fork runners + if [[ "$is_openai" == "true" ]]; then + rust='[ + {"runner":"macos-15-xlarge","target":"aarch64-apple-darwin"}, + {"runner":"macos-15-xlarge","target":"x86_64-apple-darwin"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","install_musl":true} + ]' + else + rust='[{"runner":"macos-latest","target":"aarch64-apple-darwin"}]' + fi + rust=$(echo "$rust" | jq -c '. + [{"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","install_musl":true}]') + echo "rust-binaries={\"include\":$rust}" >> "$GITHUB_OUTPUT" + + # bash-linux: always include x86_64 variants; add arm64 on openai + bash_linux='[ + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","variant":"ubuntu-24.04","image":"ubuntu:24.04"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","variant":"ubuntu-22.04","image":"ubuntu:22.04"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","variant":"debian-12","image":"debian:12"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","variant":"debian-11","image":"debian:11"}, + {"runner":"ubuntu-24.04","target":"x86_64-unknown-linux-musl","variant":"centos-9","image":"quay.io/centos/centos:stream9"} + ]' + if [[ "$is_openai" == "true" ]]; then + bash_linux=$(echo "$bash_linux" | jq -c '. + [ + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"ubuntu-24.04","image":"arm64v8/ubuntu:24.04"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"ubuntu-22.04","image":"arm64v8/ubuntu:22.04"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"ubuntu-20.04","image":"arm64v8/ubuntu:20.04"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"debian-12","image":"arm64v8/debian:12"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"debian-11","image":"arm64v8/debian:11"}, + {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl","variant":"centos-9","image":"quay.io/centos/centos:stream9"} + ]') + fi + echo "bash-linux={\"include\":$(echo "$bash_linux" | jq -c)}" >> "$GITHUB_OUTPUT" + + # bash-darwin: always include macos-14; add macos-15-xlarge on openai + bash_darwin='[{"runner":"macos-14","target":"aarch64-apple-darwin","variant":"macos-14"}]' + if [[ "$is_openai" == "true" ]]; then + bash_darwin=$(echo "$bash_darwin" | jq -c '. + [ + {"runner":"macos-15-xlarge","target":"aarch64-apple-darwin","variant":"macos-15"} + ]') + fi + echo "bash-darwin={\"include\":$(echo "$bash_darwin" | jq -c)}" >> "$GITHUB_OUTPUT" + + rust-binaries: + name: Build Rust - ${{ matrix.target }} + needs: [metadata, matrix-setup] + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + defaults: + run: + working-directory: codex-rs + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.matrix-setup.outputs.rust-binaries) }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Install UBSan runtime (musl) + if: ${{ matrix.install_musl }} + shell: bash + run: | + set -euo pipefail + if command -v apt-get >/dev/null 2>&1; then + sudo apt-get update -y + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y libubsan1 + fi + + - uses: dtolnay/rust-toolchain@1.93 + with: + targets: ${{ matrix.target }} + + - if: ${{ matrix.install_musl }} + name: Install Zig + uses: mlugg/setup-zig@v2 + with: + version: 0.14.0 + + - if: ${{ matrix.install_musl }} + name: Install musl build dependencies + env: + TARGET: ${{ matrix.target }} + run: bash "${GITHUB_WORKSPACE}/.github/scripts/install-musl-build-tools.sh" + + - if: ${{ matrix.install_musl }} + name: Configure rustc UBSan wrapper (musl host) + shell: bash + run: | + set -euo pipefail + ubsan="" + if command -v ldconfig >/dev/null 2>&1; then + ubsan="$(ldconfig -p | grep -m1 'libubsan\.so\.1' | sed -E 's/.*=> (.*)$/\1/')" + fi + wrapper_root="${RUNNER_TEMP:-/tmp}" + wrapper="${wrapper_root}/rustc-ubsan-wrapper" + cat > "${wrapper}" <> "$GITHUB_ENV" + echo "RUSTC_WORKSPACE_WRAPPER=" >> "$GITHUB_ENV" + + - if: ${{ matrix.install_musl }} + name: Clear sanitizer flags (musl) + shell: bash + run: | + set -euo pipefail + # Clear global Rust flags so host/proc-macro builds don't pull in UBSan. + echo "RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_ENCODED_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "RUSTDOCFLAGS=" >> "$GITHUB_ENV" + # Override any runner-level Cargo config rustflags as well. + echo "CARGO_BUILD_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_RUSTFLAGS=" >> "$GITHUB_ENV" + + sanitize_flags() { + local input="$1" + input="${input//-fsanitize=undefined/}" + input="${input//-fno-sanitize-recover=undefined/}" + input="${input//-fno-sanitize-trap=undefined/}" + echo "$input" + } + + cflags="$(sanitize_flags "${CFLAGS-}")" + cxxflags="$(sanitize_flags "${CXXFLAGS-}")" + echo "CFLAGS=${cflags}" >> "$GITHUB_ENV" + echo "CXXFLAGS=${cxxflags}" >> "$GITHUB_ENV" + + - name: Build exec server binaries + run: cargo build --release --target ${{ matrix.target }} --bin codex-exec-mcp-server --bin codex-execve-wrapper + + - name: Stage exec server binaries + run: | + dest="${GITHUB_WORKSPACE}/artifacts/vendor/${{ matrix.target }}" + mkdir -p "$dest" + cp "target/${{ matrix.target }}/release/codex-exec-mcp-server" "$dest/" + cp "target/${{ matrix.target }}/release/codex-execve-wrapper" "$dest/" + + - uses: actions/upload-artifact@v6 + with: + name: shell-tool-mcp-rust-${{ matrix.target }} + path: artifacts/** + if-no-files-found: error + + bash-linux: + name: Build Bash (Linux) - ${{ matrix.variant }} - ${{ matrix.target }} + needs: [metadata, matrix-setup] + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + container: + image: ${{ matrix.image }} + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.matrix-setup.outputs.bash-linux) }} + steps: + - name: Install build prerequisites + shell: bash + run: | + set -euo pipefail + if command -v apt-get >/dev/null 2>&1; then + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y git build-essential bison autoconf gettext + elif command -v dnf >/dev/null 2>&1; then + dnf install -y git gcc gcc-c++ make bison autoconf gettext + elif command -v yum >/dev/null 2>&1; then + yum install -y git gcc gcc-c++ make bison autoconf gettext + else + echo "Unsupported package manager in container" + exit 1 + fi + + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Build patched Bash + shell: bash + run: | + set -euo pipefail + git clone --depth 1 https://github.com/bolinfest/bash /tmp/bash + cd /tmp/bash + git fetch --depth 1 origin a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b + git checkout a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b + git apply "${GITHUB_WORKSPACE}/shell-tool-mcp/patches/bash-exec-wrapper.patch" + ./configure --without-bash-malloc + cores="$(command -v nproc >/dev/null 2>&1 && nproc || getconf _NPROCESSORS_ONLN)" + make -j"${cores}" + + dest="${GITHUB_WORKSPACE}/artifacts/vendor/${{ matrix.target }}/bash/${{ matrix.variant }}" + mkdir -p "$dest" + cp bash "$dest/bash" + + - uses: actions/upload-artifact@v6 + with: + name: shell-tool-mcp-bash-${{ matrix.target }}-${{ matrix.variant }} + path: artifacts/** + if-no-files-found: error + + bash-darwin: + name: Build Bash (macOS) - ${{ matrix.variant }} - ${{ matrix.target }} + needs: [metadata, matrix-setup] + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.matrix-setup.outputs.bash-darwin) }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Build patched Bash + shell: bash + run: | + set -euo pipefail + git clone --depth 1 https://github.com/bolinfest/bash /tmp/bash + cd /tmp/bash + git fetch --depth 1 origin a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b + git checkout a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b + git apply "${GITHUB_WORKSPACE}/shell-tool-mcp/patches/bash-exec-wrapper.patch" + ./configure --without-bash-malloc + cores="$(getconf _NPROCESSORS_ONLN)" + make -j"${cores}" + + dest="${GITHUB_WORKSPACE}/artifacts/vendor/${{ matrix.target }}/bash/${{ matrix.variant }}" + mkdir -p "$dest" + cp bash "$dest/bash" + + - uses: actions/upload-artifact@v6 + with: + name: shell-tool-mcp-bash-${{ matrix.target }}-${{ matrix.variant }} + path: artifacts/** + if-no-files-found: error + + package: + name: Package npm module + needs: + - metadata + - rust-binaries + - bash-linux + - bash-darwin + runs-on: ubuntu-latest + env: + PACKAGE_VERSION: ${{ needs.metadata.outputs.version }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Install JavaScript dependencies + run: pnpm install --frozen-lockfile + + - name: Build (shell-tool-mcp) + run: pnpm --filter @openai/codex-shell-tool-mcp run build + + - name: Download build artifacts + uses: actions/download-artifact@v7 + with: + path: artifacts + + - name: Assemble staging directory + id: staging + shell: bash + run: | + set -euo pipefail + staging="${STAGING_DIR}" + mkdir -p "$staging" "$staging/vendor" + cp shell-tool-mcp/README.md "$staging/" + cp shell-tool-mcp/package.json "$staging/" + cp -R shell-tool-mcp/bin "$staging/" + + found_vendor="false" + shopt -s nullglob + for vendor_dir in artifacts/*/vendor; do + rsync -av "$vendor_dir/" "$staging/vendor/" + found_vendor="true" + done + if [[ "$found_vendor" == "false" ]]; then + echo "No vendor payloads were downloaded." + exit 1 + fi + + node - <<'NODE' + import fs from "node:fs"; + import path from "node:path"; + + const stagingDir = process.env.STAGING_DIR; + const version = process.env.PACKAGE_VERSION; + const pkgPath = path.join(stagingDir, "package.json"); + const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")); + pkg.version = version; + fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n"); + NODE + + echo "dir=$staging" >> "$GITHUB_OUTPUT" + env: + STAGING_DIR: ${{ runner.temp }}/shell-tool-mcp + + - name: Ensure binaries are executable + run: | + set -euo pipefail + staging="${{ steps.staging.outputs.dir }}" + chmod +x \ + "$staging"/vendor/*/codex-exec-mcp-server \ + "$staging"/vendor/*/codex-execve-wrapper \ + "$staging"/vendor/*/bash/*/bash + + - name: Create npm tarball + shell: bash + run: | + set -euo pipefail + mkdir -p dist/npm + staging="${{ steps.staging.outputs.dir }}" + pack_info=$(cd "$staging" && npm pack --ignore-scripts --json --pack-destination "${GITHUB_WORKSPACE}/dist/npm") + filename=$(PACK_INFO="$pack_info" node -e 'const data = JSON.parse(process.env.PACK_INFO); console.log(data[0].filename);') + mv "dist/npm/${filename}" "dist/npm/codex-shell-tool-mcp-npm-${PACKAGE_VERSION}.tgz" + + - uses: actions/upload-artifact@v6 + with: + name: codex-shell-tool-mcp-npm + path: dist/npm/codex-shell-tool-mcp-npm-${{ env.PACKAGE_VERSION }}.tgz + if-no-files-found: error + + publish: + name: Publish npm package + needs: + - metadata + - package + if: ${{ inputs.publish && needs.metadata.outputs.should_publish == 'true' }} + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + steps: + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + registry-url: https://registry.npmjs.org + scope: "@openai" + + # Trusted publishing requires npm CLI version 11.5.1 or later. + - name: Update npm + run: npm install -g npm@latest + + - name: Download npm tarball + uses: actions/download-artifact@v7 + with: + name: codex-shell-tool-mcp-npm + path: dist/npm + + - name: Publish to npm + if: github.repository == 'openai/codex' + env: + NPM_TAG: ${{ needs.metadata.outputs.npm_tag }} + VERSION: ${{ needs.metadata.outputs.version }} + shell: bash + run: | + set -euo pipefail + tag_args=() + if [[ -n "${NPM_TAG}" ]]; then + tag_args+=(--tag "${NPM_TAG}") + fi + npm publish "dist/npm/codex-shell-tool-mcp-npm-${VERSION}.tgz" "${tag_args[@]}" diff --git a/.github/workflows/termux-release-checkpoint.yml b/.github/workflows/termux-release-checkpoint.yml new file mode 100644 index 000000000000..0347e6f1b3d6 --- /dev/null +++ b/.github/workflows/termux-release-checkpoint.yml @@ -0,0 +1,103 @@ +name: termux-release-checkpoint + +on: + workflow_dispatch: + inputs: + source_branch: + description: "Release branch to checkpoint from, for example release/0.123.0" + required: false + type: string + default: "" + source_sha: + description: "Specific source commit SHA to checkpoint; defaults to the source branch tip" + required: false + type: string + default: "" + destination_branch: + description: "Destination patch branch to receive the checkpoint PR" + required: false + type: string + default: "wallentx/termux-target" + reviewer: + description: "GitHub username to request as reviewer" + required: false + type: string + default: "wallentx" + +permissions: + actions: read + attestations: read + checks: read + contents: read + deployments: read + issues: read + discussions: read + packages: read + pages: read + pull-requests: read + repository-projects: read + statuses: read + +concurrency: + group: termux-release-checkpoint-${{ inputs.source_branch || github.ref_name }} + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + checkpoint: + runs-on: ubuntu-slim + permissions: + contents: write + issues: write + pull-requests: write + env: + DESTINATION_BRANCH: ${{ inputs.destination_branch || 'wallentx/termux-target' }} + REVIEWER: ${{ inputs.reviewer || 'wallentx' }} + REQUESTED_SOURCE_BRANCH: ${{ inputs.source_branch }} + REQUESTED_SOURCE_SHA: ${{ inputs.source_sha }} + TERMUX_AUTOMATION_DIR: ${{ github.workspace }}/.termux-release-automation + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Export GitHub App token for gh + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + run: echo "GH_TOKEN=${APP_TOKEN}" >> "${GITHUB_ENV}" + + - name: Checkout release branch + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ inputs.source_branch || github.ref_name }} + token: ${{ steps.app-token.outputs.token }} + + - name: Checkout automation helpers + uses: actions/checkout@v6 + with: + fetch-depth: 1 + ref: ${{ github.workflow_sha }} + path: .termux-release-automation + token: ${{ steps.app-token.outputs.token }} + + - name: 🧰 Actions Toolbox + uses: wallentx/gh-actions/composite/actions-toolbox@main + + - name: Validate GitHub CLI environment + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-validate-gh-env.sh" + + - name: Configure git + run: | + set -euo pipefail + source_branch="${REQUESTED_SOURCE_BRANCH:-${GITHUB_REF_NAME}}" + bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-configure-git.sh" --origin "${DESTINATION_BRANCH}" "${source_branch}" + + - name: Create checkpoint PR + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-create-checkpoint-pr.sh" diff --git a/.github/workflows/termux-release-deploy.yml b/.github/workflows/termux-release-deploy.yml new file mode 100644 index 000000000000..2071611dacc4 --- /dev/null +++ b/.github/workflows/termux-release-deploy.yml @@ -0,0 +1,243 @@ +name: termux-release-deploy + +on: + push: + branches: + - "release/**" + workflow_dispatch: + inputs: + release_branch: + description: "Release branch to deploy, for example release/0.124.0" + required: true + type: string + release_sha: + description: "Release branch commit SHA to deploy. Defaults to the branch head." + required: false + default: "" + type: string + pr_number: + description: "Merged release PR number to promote. Optional; normally discovered automatically." + required: false + default: "" + type: string + pr_head_sha: + description: "Merged release PR head SHA. Required only when pr_number is set." + required: false + default: "" + type: string + destination_branch: + description: "Destination patch branch to receive the checkpoint PR" + required: false + default: "wallentx/termux-target" + type: string + reviewer: + description: "GitHub username to request as reviewer on the checkpoint PR" + required: false + default: "wallentx" + type: string + +permissions: + actions: read + attestations: read + checks: read + contents: read + deployments: read + issues: read + discussions: read + packages: read + pages: read + pull-requests: read + repository-projects: read + statuses: read + +concurrency: + group: termux-release-deploy-${{ github.event_name == 'workflow_dispatch' && inputs.release_branch || github.ref_name }} + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + deploy: + runs-on: ubuntu-24.04 + if: ${{ github.event_name == 'workflow_dispatch' || !startsWith(github.event.head_commit.message, 'Seed Termux release automation') }} + permissions: + actions: read + contents: write + deployments: write + issues: write + pull-requests: write + env: + REQUESTED_RELEASE_BRANCH: ${{ inputs.release_branch }} + REQUESTED_RELEASE_SHA: ${{ inputs.release_sha }} + INPUT_PR_NUMBER: ${{ inputs.pr_number }} + INPUT_PR_HEAD_SHA: ${{ inputs.pr_head_sha }} + DESTINATION_BRANCH: ${{ inputs.destination_branch || 'wallentx/termux-target' }} + REVIEWER: ${{ inputs.reviewer || 'wallentx' }} + TERMUX_AUTOMATION_DIR: ${{ github.workspace }}/.termux-release-automation + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Export GitHub App token for gh + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + run: echo "GH_TOKEN=${APP_TOKEN}" >> "${GITHUB_ENV}" + + - name: Checkout release branch + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.release_branch || github.ref }} + token: ${{ steps.app-token.outputs.token }} + + - name: Checkout automation helpers + uses: actions/checkout@v6 + with: + fetch-depth: 1 + ref: ${{ github.workflow_sha }} + path: .termux-release-automation + token: ${{ steps.app-token.outputs.token }} + + - name: 🧰 Actions Toolbox + uses: wallentx/gh-actions/composite/actions-toolbox@main + + - name: Validate GitHub CLI environment + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-validate-gh-env.sh" + + - name: Configure git + run: | + set -euo pipefail + release_branch="${REQUESTED_RELEASE_BRANCH:-${GITHUB_REF_NAME}}" + bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-configure-git.sh" --origin "${DESTINATION_BRANCH}" "${release_branch}" + + - name: Resolve release ref + id: release-ref + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-resolve-release-ref.sh" + + - name: Read release metadata + id: metadata + env: + TERMUX_RELEASE_ACTION: deploy + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-read-release-metadata.sh" + + - name: Create deployment + if: steps.metadata.outputs.deploy == 'true' + id: deployment + env: + GH_TOKEN: ${{ github.token }} + RELEASE_SHA: ${{ steps.release-ref.outputs.sha }} + TERMUX_TAG: ${{ steps.metadata.outputs.termux_tag }} + run: | + set -euo pipefail + deployment_id="$( + gh api \ + -X POST \ + "repos/${GITHUB_REPOSITORY}/deployments" \ + -f ref="${RELEASE_SHA}" \ + -f environment="termux-release" \ + -F auto_merge=false \ + -F required_contexts[] \ + -f description="Termux release deployment for ${TERMUX_TAG}" \ + --jq '.id' + )" + echo "id=${deployment_id}" >> "$GITHUB_OUTPUT" + + - name: Mark deployment in progress + if: steps.metadata.outputs.deploy == 'true' + env: + GH_TOKEN: ${{ github.token }} + DEPLOYMENT_ID: ${{ steps.deployment.outputs.id }} + run: | + set -euo pipefail + log_url="${GH_WORKFLOW_URL:-${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}}" + gh api \ + -X POST \ + "repos/${GITHUB_REPOSITORY}/deployments/${DEPLOYMENT_ID}/statuses" \ + -f state="in_progress" \ + -f environment="termux-release" \ + -f log_url="${log_url}" \ + -F auto_inactive=false \ + -f description="Promoting Termux release artifact and preparing checkpoint PR" \ + >/dev/null + + - name: Locate merged pull request + if: steps.metadata.outputs.deploy == 'true' && steps.metadata.outputs.asset_exists != 'true' + id: pr + env: + RELEASE_BRANCH: ${{ steps.release-ref.outputs.branch }} + RELEASE_SHA: ${{ steps.release-ref.outputs.sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-find-release-pr.sh" + + - name: Download promoted PR artifact + if: steps.metadata.outputs.deploy == 'true' && steps.metadata.outputs.asset_exists != 'true' + env: + PR_ARTIFACT_NAME: ${{ steps.pr.outputs.artifact_name }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-download-release-artifact.sh" + + - name: Create or update mirrored Termux release + if: steps.metadata.outputs.deploy == 'true' && steps.metadata.outputs.asset_exists != 'true' + env: + UPSTREAM_TAG: ${{ steps.metadata.outputs.upstream_tag }} + UPSTREAM_REPO: ${{ steps.metadata.outputs.upstream_repo }} + UPSTREAM_NAME: ${{ steps.metadata.outputs.upstream_name }} + TERMUX_TAG: ${{ steps.metadata.outputs.termux_tag }} + UPSTREAM_PRERELEASE: ${{ steps.metadata.outputs.upstream_prerelease }} + UPSTREAM_HTML_URL: ${{ steps.metadata.outputs.upstream_html_url }} + RELEASE_TRAIN: ${{ steps.metadata.outputs.release_train }} + RELEASE_EXISTS: ${{ steps.metadata.outputs.release_exists }} + PR_NUMBER: ${{ steps.pr.outputs.number }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + RELEASE_SHA: ${{ steps.release-ref.outputs.sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-create-or-update-mirrored-release.sh" + + - name: Ensure checkpoint PR + if: steps.metadata.outputs.deploy == 'true' + id: checkpoint + env: + SOURCE_BRANCH: ${{ steps.release-ref.outputs.branch }} + SOURCE_SHA: ${{ steps.release-ref.outputs.sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-create-checkpoint-pr.sh" + + - name: Mark deployment success + if: steps.metadata.outputs.deploy == 'true' + env: + GH_TOKEN: ${{ github.token }} + DEPLOYMENT_ID: ${{ steps.deployment.outputs.id }} + TERMUX_TAG: ${{ steps.metadata.outputs.termux_tag }} + run: | + set -euo pipefail + log_url="${GH_WORKFLOW_URL:-${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}}" + gh api \ + -X POST \ + "repos/${GITHUB_REPOSITORY}/deployments/${DEPLOYMENT_ID}/statuses" \ + -f state="success" \ + -f environment="termux-release" \ + -f log_url="${log_url}" \ + -F auto_inactive=false \ + -f description="Termux release deployment completed for ${TERMUX_TAG}" \ + >/dev/null + + - name: Mark deployment failure + if: failure() && steps.deployment.outputs.id != '' + env: + GH_TOKEN: ${{ github.token }} + DEPLOYMENT_ID: ${{ steps.deployment.outputs.id }} + run: | + set -euo pipefail + log_url="${GH_WORKFLOW_URL:-${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}}" + gh api \ + -X POST \ + "repos/${GITHUB_REPOSITORY}/deployments/${DEPLOYMENT_ID}/statuses" \ + -f state="failure" \ + -f environment="termux-release" \ + -f log_url="${log_url}" \ + -F auto_inactive=false \ + -f description="Termux release deployment failed" \ + >/dev/null diff --git a/.github/workflows/termux-release-promote.yml b/.github/workflows/termux-release-promote.yml new file mode 100644 index 000000000000..22c277c21a70 --- /dev/null +++ b/.github/workflows/termux-release-promote.yml @@ -0,0 +1,135 @@ +name: termux-release-promote + +on: + workflow_dispatch: + inputs: + release_branch: + description: "Release branch to promote, for example release/0.122.0" + required: true + type: string + release_sha: + description: "Release branch commit SHA to promote. Defaults to the branch head." + required: false + default: "" + type: string + pr_number: + description: "Merged PR number to promote. Optional; normally discovered automatically." + required: false + default: "" + type: string + pr_head_sha: + description: "Merged PR head SHA. Required only when pr_number is set." + required: false + default: "" + type: string + +permissions: + actions: read + attestations: read + checks: read + contents: read + deployments: read + issues: read + discussions: read + packages: read + pages: read + pull-requests: read + repository-projects: read + statuses: read + +concurrency: + group: termux-release-promote-${{ github.event_name == 'workflow_dispatch' && inputs.release_branch || github.ref_name }} + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + promote: + runs-on: ubuntu-24.04 + permissions: + actions: read + contents: write + pull-requests: read + env: + TERMUX_AUTOMATION_DIR: ${{ github.workspace }}/.termux-release-automation + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Export GitHub App token for gh + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + run: echo "GH_TOKEN=${APP_TOKEN}" >> "${GITHUB_ENV}" + + - name: Checkout release branch + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.release_branch || github.ref }} + token: ${{ steps.app-token.outputs.token }} + + - name: Checkout automation helpers + uses: actions/checkout@v6 + with: + fetch-depth: 1 + ref: ${{ github.workflow_sha }} + path: .termux-release-automation + token: ${{ steps.app-token.outputs.token }} + + - name: 🧰 Actions Toolbox + uses: wallentx/gh-actions/composite/actions-toolbox@main + + - name: Validate GitHub CLI environment + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-validate-gh-env.sh" + + - name: Resolve release ref + id: release-ref + env: + INPUT_RELEASE_BRANCH: ${{ inputs.release_branch }} + INPUT_RELEASE_SHA: ${{ inputs.release_sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-resolve-release-ref.sh" + + - name: Read release metadata + id: metadata + env: + TERMUX_RELEASE_ACTION: promote + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-read-release-metadata.sh" + + - name: Locate merged pull request + if: steps.metadata.outputs.promote == 'true' + id: pr + env: + RELEASE_BRANCH: ${{ steps.release-ref.outputs.branch }} + RELEASE_SHA: ${{ steps.release-ref.outputs.sha }} + INPUT_PR_NUMBER: ${{ inputs.pr_number }} + INPUT_PR_HEAD_SHA: ${{ inputs.pr_head_sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-find-release-pr.sh" + + - name: Download promoted PR artifact + if: steps.metadata.outputs.promote == 'true' + env: + PR_ARTIFACT_NAME: ${{ steps.pr.outputs.artifact_name }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-download-release-artifact.sh" + + - name: Create or update mirrored Termux release + if: steps.metadata.outputs.promote == 'true' + env: + UPSTREAM_TAG: ${{ steps.metadata.outputs.upstream_tag }} + UPSTREAM_REPO: ${{ steps.metadata.outputs.upstream_repo }} + UPSTREAM_NAME: ${{ steps.metadata.outputs.upstream_name }} + TERMUX_TAG: ${{ steps.metadata.outputs.termux_tag }} + UPSTREAM_PRERELEASE: ${{ steps.metadata.outputs.upstream_prerelease }} + UPSTREAM_HTML_URL: ${{ steps.metadata.outputs.upstream_html_url }} + RELEASE_TRAIN: ${{ steps.metadata.outputs.release_train }} + RELEASE_EXISTS: ${{ steps.metadata.outputs.release_exists }} + PR_NUMBER: ${{ steps.pr.outputs.number }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + RELEASE_SHA: ${{ steps.release-ref.outputs.sha }} + run: bash "${TERMUX_AUTOMATION_DIR}/scripts/termux-create-or-update-mirrored-release.sh" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index c9aab8a5c793..f73d14ef3e27 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -118,7 +118,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.134.0-alpha.2" +version = "0.134.0-alpha.3" # Track the edition for all workspace crates in one place. Individual # crates can still override this value, but keeping it here means new # crates created with `cargo new -w ...` automatically inherit the 2024 diff --git a/scripts/termux-configure-git.sh b/scripts/termux-configure-git.sh new file mode 100755 index 000000000000..fae3abcbab48 --- /dev/null +++ b/scripts/termux-configure-git.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash + +# Configure the explicit bot identity used by release automation and fetch any +# refs requested by the calling workflow. + +set -euo pipefail + +git config user.name "github-actions[bot]" +git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + +while (($#)); do + case "$1" in + --origin) + shift + origin_refs=() + while (($#)) && [[ "$1" != --* ]]; do + origin_refs+=("$1") + shift + done + if ((${#origin_refs[@]})); then + git fetch --prune origin "${origin_refs[@]}" + fi + ;; + --upstream-tag) + if (($# < 3)); then + echo "--upstream-tag requires ." >&2 + exit 1 + fi + upstream_repo="$2" + upstream_tag="$3" + git remote add upstream "https://github.com/${upstream_repo}.git" 2>/dev/null || true + git fetch --prune --no-tags upstream "+refs/tags/${upstream_tag}:refs/tags/${upstream_tag}" + shift 3 + ;; + *) + echo "Unknown argument: $1" >&2 + exit 1 + ;; + esac +done diff --git a/scripts/termux-create-checkpoint-pr.sh b/scripts/termux-create-checkpoint-pr.sh new file mode 100755 index 000000000000..f315a5b91822 --- /dev/null +++ b/scripts/termux-create-checkpoint-pr.sh @@ -0,0 +1,275 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/termux-release-paths.sh +source "${script_dir}/termux-release-paths.sh" + +source_branch="${SOURCE_BRANCH:-${REQUESTED_SOURCE_BRANCH:-${GITHUB_REF_NAME}}}" +source_sha="${SOURCE_SHA:-${REQUESTED_SOURCE_SHA:-}}" +if [[ -z "${source_sha}" ]]; then + if [[ "${GITHUB_EVENT_NAME:-}" == "push" && "${source_branch}" == "${GITHUB_REF_NAME:-}" ]]; then + source_sha="${GITHUB_SHA}" + else + source_sha="$(git rev-parse "origin/${source_branch}")" + fi +fi + +if [[ -z "${DESTINATION_BRANCH:-}" ]]; then + echo "DESTINATION_BRANCH is required." >&2 + exit 1 +fi + +release_only_checkpoint_paths() { + printf '%s\n' "${TERMUX_RELEASE_BRANCH_SCRIPT_PATHS[@]}" +} + +resolve_source_version_conflicts() { + local path="$1" + local resolved_path + resolved_path="$(mktemp)" + + if ! awk ' + function normalize_versions(text) { + gsub(/version = "[^"]+"/, "version = \"\"", text) + return text + } + + BEGIN { + in_block = 0 + side = "" + ours = "" + theirs = "" + blocks = 0 + } + + /^<<<<<<< / { + if (in_block) { + exit 1 + } + in_block = 1 + side = "ours" + ours = "" + theirs = "" + blocks++ + next + } + + /^=======$/ && in_block { + side = "theirs" + next + } + + /^>>>>>>> / && in_block { + if (normalize_versions(ours) != normalize_versions(theirs)) { + exit 1 + } + printf "%s", theirs + in_block = 0 + side = "" + next + } + + { + if (!in_block) { + print + } else if (side == "ours") { + ours = ours $0 ORS + } else if (side == "theirs") { + theirs = theirs $0 ORS + } else { + exit 1 + } + } + + END { + if (in_block || blocks == 0) { + exit 1 + } + } + ' "${path}" > "${resolved_path}"; then + rm -f "${resolved_path}" + return 1 + fi + + cp "${resolved_path}" "${path}" + rm -f "${resolved_path}" +} + +short_sha="${source_sha:0:12}" +source_slug="${source_branch//\//_}" +dest_slug="${DESTINATION_BRANCH//\//_}" +checkpoint_branch="checkpoint/${dest_slug}_from_${source_slug}_${short_sha}" +pr_title="checkpoint: into ${DESTINATION_BRANCH} from ${source_branch} @ ${short_sha}" +merge_conflicted=false +conflict_summary="" + +existing_pr="$( + gh pr list \ + --repo "${GITHUB_REPOSITORY}" \ + --head "${checkpoint_branch}" \ + --state all \ + --json number,state,mergedAt,url \ + --jq '[.[] | select(.state == "OPEN" or .mergedAt != null)] | .[0] // empty' +)" +if [[ -n "${existing_pr}" ]]; then + existing_url="$(jq -r '.url' <<< "${existing_pr}")" + existing_state="$(jq -r '.state' <<< "${existing_pr}")" + echo "Checkpoint PR already exists for ${checkpoint_branch}: ${existing_url} (${existing_state})." + if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + echo "pr_url=${existing_url}" >> "${GITHUB_OUTPUT}" + fi + exit 0 +fi + +git checkout -B "${checkpoint_branch}" "origin/${DESTINATION_BRANCH}" + +if ! git merge --no-ff --no-edit "${source_sha}"; then + mapfile -t conflicted_paths < <(git diff --name-only --diff-filter=U) + for conflicted_path in "${conflicted_paths[@]}"; do + if termux_is_checkpoint_release_only_path "${conflicted_path}"; then + echo "Auto-resolving release-only checkpoint conflict in ${conflicted_path} by keeping ${DESTINATION_BRANCH}." + if git cat-file -e "HEAD:${conflicted_path}" 2>/dev/null; then + git checkout --ours -- "${conflicted_path}" + git add "${conflicted_path}" + else + git rm -f --ignore-unmatch "${conflicted_path}" + fi + fi + done + + mapfile -t remaining_conflicts < <(git diff --name-only --diff-filter=U) + if ((${#remaining_conflicts[@]})); then + cargo_version_conflicts=true + for remaining_conflict in "${remaining_conflicts[@]}"; do + case "${remaining_conflict}" in + codex-rs/Cargo.toml|codex-rs/Cargo.lock) + ;; + *) + cargo_version_conflicts=false + ;; + esac + done + + if [[ "${cargo_version_conflicts}" == "true" ]]; then + for remaining_conflict in "${remaining_conflicts[@]}"; do + if ! resolve_source_version_conflicts "${remaining_conflict}"; then + cargo_version_conflicts=false + break + fi + done + + if [[ "${cargo_version_conflicts}" == "true" ]]; then + echo "Auto-resolving recurring Cargo version checkpoint conflicts by keeping ${source_branch} versions." + git add -- "${remaining_conflicts[@]}" + fi + fi + fi + + mapfile -t remaining_conflicts < <(git diff --name-only --diff-filter=U) + if [[ "${#remaining_conflicts[@]}" -eq 0 ]]; then + git commit --no-edit + else + merge_conflicted=true + conflict_summary="$( + printf '%s\n' "${remaining_conflicts[@]}" | awk '{ print "- `" $0 "`" }' + )" + echo "Automatic checkpoint merge failed; creating a manual-resolution PR instead." >&2 + if git rev-parse -q --verify MERGE_HEAD >/dev/null; then + git merge --abort + fi + git checkout -B "${checkpoint_branch}" "${source_sha}" + fi +fi + +if git cat-file -e "origin/${DESTINATION_BRANCH}:.github" 2>/dev/null; then + git checkout "origin/${DESTINATION_BRANCH}" -- .github + mapfile -t added_github_paths < <( + git diff --name-only --diff-filter=A "origin/${DESTINATION_BRANCH}" -- .github + ) + if ((${#added_github_paths[@]})); then + git rm -f --ignore-unmatch -- "${added_github_paths[@]}" + fi +else + git rm -r --ignore-unmatch .github +fi + +while IFS= read -r release_only_path; do + if git cat-file -e "origin/${DESTINATION_BRANCH}:${release_only_path}" 2>/dev/null; then + git checkout "origin/${DESTINATION_BRANCH}" -- "${release_only_path}" + else + git rm -f --ignore-unmatch -- "${release_only_path}" + fi +done < <(release_only_checkpoint_paths) + +if ! git diff --quiet || ! git diff --cached --quiet; then + if [[ -e .github || -L .github ]] || git ls-files --error-unmatch -- .github >/dev/null 2>&1; then + git add -A .github + fi + while IFS= read -r release_only_path; do + if [[ -e "${release_only_path}" || -L "${release_only_path}" ]] || git ls-files --error-unmatch -- "${release_only_path}" >/dev/null 2>&1; then + git add -A -- "${release_only_path}" + fi + done < <(release_only_checkpoint_paths) + if [[ "${merge_conflicted}" == "true" ]]; then + git commit -m "checkpoint: prepare ${source_branch} for ${DESTINATION_BRANCH}" + else + git commit --amend --no-edit + fi +fi + +if git diff --quiet "origin/${DESTINATION_BRANCH}" HEAD; then + echo "Checkpoint merge produced no destination changes after release-only files were restored." + exit 0 +fi + +git push --force-with-lease origin "${checkpoint_branch}" + +remaining="$( + git log --first-parent --pretty=format:%H "${source_sha}..origin/${source_branch}" | wc -w +)" + +body_path="${RUNNER_TEMP}/termux-checkpoint-pr.md" +{ + echo "## Termux release checkpoint" + echo + echo "- Source branch: \`${source_branch}\`" + echo "- Source hash: \`${source_sha}\`" + echo "- Destination branch: \`${DESTINATION_BRANCH}\`" + echo "- Remaining first-parent commits on source: ${remaining}" + echo + echo "This PR carries release-train conflict fixes and follow-up changes back into the reusable Termux patch branch." + if [[ "${merge_conflicted}" == "true" ]]; then + echo + echo "## Merge conflicts" + echo + echo "GitHub Actions could not create the checkpoint merge commit automatically, so this PR was created from the source branch state for manual conflict resolution." + echo + echo "Conflicted paths from the failed merge attempt:" + if [[ -n "${conflict_summary}" ]]; then + printf '%s\n' "${conflict_summary}" + else + echo "- Conflict details unavailable" + fi + fi + echo + echo "Release-only workflow files and metadata under \`.github\` were restored to the destination branch versions before opening this PR." +} > "${body_path}" + +pr_url="$( + gh pr create \ + --repo "${GITHUB_REPOSITORY}" \ + --base "${DESTINATION_BRANCH}" \ + --head "${checkpoint_branch}" \ + --title "${pr_title}" \ + --body-file "${body_path}" +)" +gh pr edit "${pr_url}" --repo "${GITHUB_REPOSITORY}" --add-reviewer "${REVIEWER}" || true +gh label create checkpoint --repo "${GITHUB_REPOSITORY}" --color c5def5 --description "Checkpoint merge" --force +gh label create termux-release --repo "${GITHUB_REPOSITORY}" --color 0e8a16 --description "Termux release automation" --force +gh pr edit "${pr_url}" --repo "${GITHUB_REPOSITORY}" --add-label "checkpoint" --add-label "termux-release" + +if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + echo "pr_url=${pr_url}" >> "${GITHUB_OUTPUT}" +fi diff --git a/scripts/termux-create-or-update-mirrored-release.sh b/scripts/termux-create-or-update-mirrored-release.sh new file mode 100755 index 000000000000..5787e894582b --- /dev/null +++ b/scripts/termux-create-or-update-mirrored-release.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash + +# Create a mirrored Termux release or repair an existing release that is missing +# the Android tarball. + +set -euo pipefail + +: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" +: "${GH_TOKEN:?GH_TOKEN is required}" +: "${RUNNER_TEMP:?RUNNER_TEMP is required}" +: "${UPSTREAM_TAG:?UPSTREAM_TAG is required}" +: "${UPSTREAM_REPO:?UPSTREAM_REPO is required}" +: "${UPSTREAM_NAME?UPSTREAM_NAME is required}" +: "${TERMUX_TAG:?TERMUX_TAG is required}" +: "${UPSTREAM_PRERELEASE:?UPSTREAM_PRERELEASE is required}" +: "${UPSTREAM_HTML_URL?UPSTREAM_HTML_URL is required}" +: "${RELEASE_TRAIN?RELEASE_TRAIN is required}" +: "${RELEASE_EXISTS:?RELEASE_EXISTS is required}" +: "${PR_NUMBER:?PR_NUMBER is required}" +: "${HEAD_SHA:?HEAD_SHA is required}" +: "${RELEASE_SHA:?RELEASE_SHA is required}" + +promoted_dir="${PROMOTED_DIR:-promoted}" +asset_path="${promoted_dir}/codex-aarch64-linux-android.tar.gz" + +body_path="${RUNNER_TEMP}/release-body.md" +upstream_body_path="${RUNNER_TEMP}/upstream-release-body.md" +upstream_body_without_changelog_path="${RUNNER_TEMP}/upstream-release-body-without-changelog.md" +if gh release view "${UPSTREAM_TAG}" \ + --repo "${UPSTREAM_REPO}" \ + --json body \ + --jq '.body // ""' > "${upstream_body_path}"; then + awk ' + function heading_level(line, text) { + if (match(line, /^(#{1,6})[[:space:]]+(.+)$/, parts)) { + text = tolower(parts[2]) + sub(/[[:space:]]+#+[[:space:]]*$/, "", text) + gsub(/^[[:space:]]+|[[:space:]]+$/, "", text) + if (text == "changelog" || text == "change log") { + return length(parts[1]) + } + } + return 0 + } + + { + if (!skip) { + level = heading_level($0) + if (level > 0) { + skip = 1 + skip_level = level + next + } + print + next + } + + if (match($0, /^(#{1,6})[[:space:]]+/, parts) && length(parts[1]) <= skip_level) { + skip = 0 + print + } + } + ' "${upstream_body_path}" > "${upstream_body_without_changelog_path}" +else + echo "::warning title=Upstream release notes unavailable::Could not read ${UPSTREAM_REPO} release ${UPSTREAM_TAG}." + : > "${upstream_body_without_changelog_path}" +fi + +{ + echo "Termux Android build for ${UPSTREAM_TAG}." + echo + echo "- Upstream release: ${UPSTREAM_HTML_URL}" + echo "- Release train: \`${RELEASE_TRAIN}\`" + echo "- Promoted PR: #${PR_NUMBER}" + echo "- Promoted PR head SHA: \`${HEAD_SHA}\`" +} > "${body_path}" +if [[ -s "${upstream_body_without_changelog_path}" ]]; then + { + echo + echo "## Upstream release notes" + echo + cat "${upstream_body_without_changelog_path}" + } >> "${body_path}" +fi + +release_title="${UPSTREAM_NAME}" +if [[ -z "${release_title}" || "${release_title}" == "null" ]]; then + release_title="${TERMUX_TAG}" +fi + +if [[ "${RELEASE_EXISTS}" == "true" ]]; then + gh release upload \ + "${TERMUX_TAG}" \ + "${asset_path}#codex-termux" \ + --repo "${GITHUB_REPOSITORY}" \ + --clobber + exit 0 +fi + +release_args=( + gh release create "${TERMUX_TAG}" + --repo "${GITHUB_REPOSITORY}" + --target "${RELEASE_SHA}" + --title "${release_title}" + --notes-file "${body_path}" +) +if [[ "${UPSTREAM_PRERELEASE}" == "true" ]]; then + release_args+=(--prerelease) +fi +release_args+=("${asset_path}#codex-termux") +"${release_args[@]}" diff --git a/scripts/termux-download-release-artifact.sh b/scripts/termux-download-release-artifact.sh new file mode 100755 index 000000000000..2acdba9025a0 --- /dev/null +++ b/scripts/termux-download-release-artifact.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ -z "${HEAD_SHA:-}" ]]; then + echo "HEAD_SHA is required." >&2 + exit 1 +fi + +if [[ -z "${PR_ARTIFACT_NAME:-}" ]]; then + echo "PR_ARTIFACT_NAME is required." >&2 + exit 1 +fi + +promoted_dir="${PROMOTED_DIR:-promoted}" + +find_run_id() { + local event="$1" + gh run list \ + --repo "${GITHUB_REPOSITORY}" \ + --workflow rust-release.yml \ + --event "${event}" \ + --status success \ + --commit "${HEAD_SHA}" \ + --limit 1 \ + --json databaseId \ + --jq '.[0].databaseId // empty' +} + +find_artifact_run_id() { + local artifact_name="$1" + + gh api --paginate "repos/${GITHUB_REPOSITORY}/actions/artifacts?name=${artifact_name}" \ + | jq -rs \ + --arg artifact_name "${artifact_name}" \ + --arg head_sha "${HEAD_SHA}" \ + ' + [ + .[].artifacts[] + | select(.name == $artifact_name) + | select(.expired == false) + | select(.workflow_run.head_sha == $head_sha) + ] + | sort_by(.created_at) + | reverse + | .[0].workflow_run.id // empty + ' +} + +artifact_name="${PR_ARTIFACT_NAME}" +run_id="$(find_artifact_run_id "${artifact_name}")" +mkdir -p "${promoted_dir}" +if [[ -n "${run_id}" ]] && gh run download "${run_id}" \ + --repo "${GITHUB_REPOSITORY}" \ + --name "${artifact_name}" \ + --dir "${promoted_dir}"; then + : +else + artifact_name="aarch64-linux-android" + run_id="$(find_run_id workflow_dispatch)" + if [[ -z "${run_id}" ]]; then + echo "No successful rust-release run found for ${HEAD_SHA}" >&2 + exit 1 + fi + gh run download "${run_id}" \ + --repo "${GITHUB_REPOSITORY}" \ + --name "${artifact_name}" \ + --dir "${promoted_dir}" +fi + +ls -la "${promoted_dir}" +if [[ -f "${promoted_dir}/SHA256SUMS" ]]; then + (cd "${promoted_dir}" && sha256sum -c SHA256SUMS) +fi +if [[ ! -f "${promoted_dir}/codex-aarch64-linux-android.tar.gz" ]]; then + echo "Expected ${promoted_dir}/codex-aarch64-linux-android.tar.gz in the downloaded artifact." >&2 + exit 1 +fi diff --git a/scripts/termux-find-release-pr.sh b/scripts/termux-find-release-pr.sh new file mode 100755 index 000000000000..3e35fa42eabe --- /dev/null +++ b/scripts/termux-find-release-pr.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ -n "${INPUT_PR_NUMBER:-}" || -n "${INPUT_PR_HEAD_SHA:-}" ]]; then + if [[ -z "${INPUT_PR_NUMBER:-}" || -z "${INPUT_PR_HEAD_SHA:-}" ]]; then + echo "workflow_dispatch inputs pr_number and pr_head_sha must be provided together." >&2 + exit 1 + fi + + pr_number="${INPUT_PR_NUMBER}" + head_sha="${INPUT_PR_HEAD_SHA}" + head_ref="" +else + if [[ -z "${RELEASE_BRANCH:-}" || -z "${RELEASE_SHA:-}" ]]; then + echo "RELEASE_BRANCH and RELEASE_SHA are required." >&2 + exit 1 + fi + + pr_json="$( + gh pr list \ + --repo "${GITHUB_REPOSITORY}" \ + --base "${RELEASE_BRANCH}" \ + --state merged \ + --limit 100 \ + --json number,headRefOid,headRefName,mergeCommit \ + --jq "[.[] | select(.mergeCommit.oid == \"${RELEASE_SHA}\")] | sort_by(.number) | reverse | .[0] // empty" + )" + if [[ -z "${pr_json}" ]]; then + echo "Unable to find merged PR for ${RELEASE_SHA} into ${RELEASE_BRANCH}" >&2 + exit 1 + fi + + pr_number="$(jq -r '.number' <<< "${pr_json}")" + head_sha="$(jq -r '.headRefOid // .head.sha' <<< "${pr_json}")" + head_ref="$(jq -r '.headRefName // .head.ref' <<< "${pr_json}")" +fi + +artifact_name="termux-android-pr-${pr_number}-${head_sha}" +if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + { + echo "number=${pr_number}" + echo "head_sha=${head_sha}" + echo "head_ref=${head_ref}" + echo "artifact_name=${artifact_name}" + } >> "${GITHUB_OUTPUT}" +else + printf 'number=%s\nhead_sha=%s\nhead_ref=%s\nartifact_name=%s\n' \ + "${pr_number}" \ + "${head_sha}" \ + "${head_ref}" \ + "${artifact_name}" +fi diff --git a/scripts/termux-read-release-metadata.sh b/scripts/termux-read-release-metadata.sh new file mode 100755 index 000000000000..519a0635a368 --- /dev/null +++ b/scripts/termux-read-release-metadata.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash + +# Parse .github/termux-release.json and emit workflow outputs for deploy or +# promote jobs. TERMUX_RELEASE_ACTION must be "deploy" or "promote". + +set -euo pipefail + +: "${GITHUB_OUTPUT:?GITHUB_OUTPUT is required}" +: "${GH_TOKEN:?GH_TOKEN is required}" + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + +metadata=".github/termux-release.json" +action="${TERMUX_RELEASE_ACTION:-deploy}" +case "${action}" in + deploy) + missing_context="deployment" + ;; + promote) + missing_context="promotion" + ;; + *) + echo "TERMUX_RELEASE_ACTION must be deploy or promote." >&2 + exit 1 + ;; +esac + +if [[ ! -f "${metadata}" ]]; then + echo "No ${metadata}; this push is not a Termux release ${missing_context}." + echo "${action}=false" >> "${GITHUB_OUTPUT}" + exit 0 +fi + +upstream_tag="$(jq -r '.upstream_tag // empty' "${metadata}")" +upstream_name="$(jq -r '.upstream_name // .upstream_tag // empty' "${metadata}")" +termux_tag="$(jq -r '.termux_tag // empty' "${metadata}")" +upstream_version="${upstream_tag#rust-v}" +upstream_version="${upstream_version%-termux}" +upstream_prerelease=false +if [[ "${upstream_version}" == *-* ]]; then + upstream_prerelease=true +fi +upstream_html_url="$(jq -r '.upstream_html_url // ""' "${metadata}")" +upstream_repo="$(jq -r '.upstream_repo // "openai/codex"' "${metadata}")" +release_train="$(jq -r '.release_train // ""' "${metadata}")" +if [[ -z "${upstream_tag}" || -z "${termux_tag}" ]]; then + echo "Missing upstream_tag or termux_tag in ${metadata}" >&2 + exit 1 +fi + +release_state="$(TERMUX_TAG="${termux_tag}" "${script_dir}/termux-release-asset-state.sh")" +release_exists="$(awk -F= '$1 == "release_exists" { print $2 }' <<< "${release_state}")" +asset_exists="$(awk -F= '$1 == "asset_exists" { print $2 }' <<< "${release_state}")" + +if [[ "${action}" == "promote" ]]; then + if [[ "${asset_exists}" == "true" ]]; then + echo "${termux_tag} already exists with codex-aarch64-linux-android.tar.gz; skipping promotion." + echo "promote=false" >> "${GITHUB_OUTPUT}" + exit 0 + fi + if [[ "${release_exists}" == "true" ]]; then + echo "${termux_tag} exists but is missing codex-aarch64-linux-android.tar.gz; repairing promotion." + fi +fi + +{ + echo "${action}=true" + echo "upstream_tag=${upstream_tag}" + echo "upstream_name=${upstream_name}" + echo "termux_tag=${termux_tag}" + echo "upstream_prerelease=${upstream_prerelease}" + echo "upstream_html_url=${upstream_html_url}" + echo "upstream_repo=${upstream_repo}" + echo "release_train=${release_train}" + echo "release_exists=${release_exists}" + echo "asset_exists=${asset_exists}" +} >> "${GITHUB_OUTPUT}" diff --git a/scripts/termux-release-asset-state.sh b/scripts/termux-release-asset-state.sh new file mode 100755 index 000000000000..8694480b6a36 --- /dev/null +++ b/scripts/termux-release-asset-state.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash + +set -euo pipefail + +termux_tag="${1:-${TERMUX_TAG:-}}" +release_repo="${RELEASE_REPO:-${GITHUB_REPOSITORY:-}}" +asset_name="${ASSET_NAME:-codex-aarch64-linux-android.tar.gz}" + +if [[ -z "${termux_tag}" ]]; then + echo "TERMUX_TAG or tag argument is required." >&2 + exit 1 +fi + +if [[ -z "${release_repo}" ]]; then + echo "GITHUB_REPOSITORY or RELEASE_REPO is required." >&2 + exit 1 +fi + +release_exists=false +asset_exists=false + +if gh release view "${termux_tag}" --repo "${release_repo}" >/dev/null 2>&1; then + release_exists=true + release_asset_exists="$( + gh release view "${termux_tag}" \ + --repo "${release_repo}" \ + --json assets \ + | jq -r --arg asset_name "${asset_name}" \ + '.assets | map(.name) | any(. == $asset_name)' + )" + if [[ "${release_asset_exists}" == "true" ]]; then + asset_exists=true + fi +fi + +printf 'release_exists=%s\n' "${release_exists}" +printf 'asset_exists=%s\n' "${asset_exists}" diff --git a/scripts/termux-release-paths.sh b/scripts/termux-release-paths.sh new file mode 100755 index 000000000000..6990f323ec44 --- /dev/null +++ b/scripts/termux-release-paths.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash + +# Sourceable path lists for files owned by the Termux release automation. + +set -euo pipefail + +readonly -a TERMUX_RELEASE_WORKFLOW_PATHS=( + .github/workflows/rust-release.yml + .github/workflows/shell-tool-mcp.yml + .github/workflows/termux-release-checkpoint.yml + .github/workflows/termux-release-deploy.yml + .github/workflows/termux-release-promote.yml +) + +readonly -a TERMUX_RELEASE_BRANCH_SCRIPT_PATHS=( + scripts/termux-configure-git.sh + scripts/termux-create-checkpoint-pr.sh + scripts/termux-create-or-update-mirrored-release.sh + scripts/termux-download-release-artifact.sh + scripts/termux-find-release-pr.sh + scripts/termux-read-release-metadata.sh + scripts/termux-release-asset-state.sh + scripts/termux-release-paths.sh + scripts/termux-resolve-release-ref.sh + scripts/termux-validate-gh-env.sh +) + +readonly -a TERMUX_RELEASE_AUTOMATION_PATHS=( + "${TERMUX_RELEASE_WORKFLOW_PATHS[@]}" + "${TERMUX_RELEASE_BRANCH_SCRIPT_PATHS[@]}" +) + +readonly -a TERMUX_CHECKPOINT_RELEASE_ONLY_PATHS=( + "${TERMUX_RELEASE_AUTOMATION_PATHS[@]}" + .github/termux-release.json +) + +termux_path_in_list() { + local candidate="$1" + shift + local listed_path + + for listed_path in "$@"; do + [[ "${candidate}" != "${listed_path}" ]] || return 0 + done + return 1 +} + +termux_is_release_automation_path() { + termux_path_in_list "$1" "${TERMUX_RELEASE_AUTOMATION_PATHS[@]}" +} + +termux_is_checkpoint_release_only_path() { + termux_path_in_list "$1" "${TERMUX_CHECKPOINT_RELEASE_ONLY_PATHS[@]}" +} diff --git a/scripts/termux-resolve-release-ref.sh b/scripts/termux-resolve-release-ref.sh new file mode 100755 index 000000000000..194773d45bed --- /dev/null +++ b/scripts/termux-resolve-release-ref.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash + +# Resolve the release branch and SHA for deploy/promote workflows. + +set -euo pipefail + +: "${GITHUB_EVENT_NAME:?GITHUB_EVENT_NAME is required}" +: "${GITHUB_OUTPUT:?GITHUB_OUTPUT is required}" + +input_release_branch="${INPUT_RELEASE_BRANCH:-${REQUESTED_RELEASE_BRANCH:-}}" +input_release_sha="${INPUT_RELEASE_SHA:-${REQUESTED_RELEASE_SHA:-}}" + +if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then + if [[ -z "${input_release_branch}" ]]; then + echo "release_branch is required for workflow_dispatch." >&2 + exit 1 + fi + + release_branch="${input_release_branch}" + if [[ -n "${input_release_sha}" ]]; then + git checkout --detach "${input_release_sha}" + release_sha="${input_release_sha}" + else + release_sha="$(git rev-parse HEAD)" + fi +else + : "${GITHUB_REF_NAME:?GITHUB_REF_NAME is required}" + : "${GITHUB_SHA:?GITHUB_SHA is required}" + release_branch="${GITHUB_REF_NAME}" + release_sha="${GITHUB_SHA}" +fi + +{ + echo "branch=${release_branch}" + echo "sha=${release_sha}" +} >> "${GITHUB_OUTPUT}" diff --git a/scripts/termux-validate-gh-env.sh b/scripts/termux-validate-gh-env.sh new file mode 100755 index 000000000000..02581fdebceb --- /dev/null +++ b/scripts/termux-validate-gh-env.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash + +# Lightweight post-toolbox check for jobs that rely on authenticated gh calls. + +set -euo pipefail + +: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" +: "${GH_TOKEN:?GH_TOKEN is required}" + +command -v gh +gh auth status --hostname github.com + +printf 'GITHUB_REPOSITORY=%s\n' "${GITHUB_REPOSITORY}" +printf 'REPO=%s\n' "${REPO:-}" +printf 'GH_REPO_URL=%s\n' "${GH_REPO_URL:-}" +printf 'GH_WORKFLOW_URL=%s\n' "${GH_WORKFLOW_URL:-}"