From 74e0161c21701fad2dab450cedecfedff8f9b8c3 Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Mon, 18 May 2026 12:21:56 -0700 Subject: [PATCH 001/342] 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 efc64577dd7..ff15aef2ca9 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 002/342] ## 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 efc64577dd7..5e0b16c4cb1 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 003/342] 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 5ced1803fa4..e6b21cb9546 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 ff15aef2ca9..319f61b72be 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 7ee21a770e4..7c2c1769202 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 2f6ae4653c6..5a558769a7e 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 8472918dcae..fdf1d4b18a9 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 a42e6b6d835..a87c660f613 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 b22226a79e4..b87af084d7d 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 e25fd1bd914..1ef36b1ff5d 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 00000000000..face70c5351 --- /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 00000000000..5e3877fc163 --- /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 00000000000..fdcfbdb81e7 --- /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 ab2fbc63629..569a0469521 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 00000000000..ac40f8e5724 --- /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 004/342] 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 c213e92bae4..40624baba0d 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 9ece5e3776f..fbd1d7eb79b 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 005/342] 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 e6b21cb9546..a3b39903eef 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 319f61b72be..af11ae99099 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 006/342] 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 ec38a87cff9..91b73d562b5 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 007/342] 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 b6c293d6cdc..248da7df3b7 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 00000000000..66a76aa4d93 --- /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 00000000000..0347e6f1b3d --- /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 00000000000..2071611dacc --- /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 00000000000..22c277c21a7 --- /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 00000000000..fae3abcbab4 --- /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 00000000000..ba07c289952 --- /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 00000000000..5787e894582 --- /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 00000000000..2acdba9025a --- /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 00000000000..3e35fa42eab --- /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 00000000000..519a0635a36 --- /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 00000000000..8694480b6a3 --- /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 00000000000..6990f323ec4 --- /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 00000000000..194773d45be --- /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 00000000000..02581fdebce --- /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 008/342] 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 00000000000..3c4f4d41916 --- /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 5ced1803fa4..2f9714c07d2 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 5e0b16c4cb1..447c680eabd 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 7ee21a770e4..7c2c1769202 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 2f6ae4653c6..5a558769a7e 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 8472918dcae..fdf1d4b18a9 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 a42e6b6d835..a87c660f613 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 b22226a79e4..b87af084d7d 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 e25fd1bd914..1ef36b1ff5d 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 c213e92bae4..40624baba0d 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 9ece5e3776f..fbd1d7eb79b 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 00000000000..face70c5351 --- /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 00000000000..5e3877fc163 --- /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 00000000000..fdcfbdb81e7 --- /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 ab2fbc63629..569a0469521 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 00000000000..ac40f8e5724 --- /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 009/342] 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 50953506d32..56d0977e6e6 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 00000000000..66a76aa4d93 --- /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 00000000000..0347e6f1b3d --- /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 00000000000..2071611dacc --- /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 00000000000..22c277c21a7 --- /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 00000000000..fae3abcbab4 --- /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 00000000000..ba07c289952 --- /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 00000000000..5787e894582 --- /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 00000000000..2acdba9025a --- /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 00000000000..3e35fa42eab --- /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 00000000000..519a0635a36 --- /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 00000000000..8694480b6a3 --- /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 00000000000..6990f323ec4 --- /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 00000000000..194773d45be --- /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 00000000000..02581fdebce --- /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 010/342] 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 00000000000..3cf1ce21c8e --- /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 1950939e652..54e1f7a1a07 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 91b73d562b5..c2ae163ee1c 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 7ee21a770e4..7c2c1769202 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 2f6ae4653c6..5a558769a7e 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 8472918dcae..fdf1d4b18a9 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 a42e6b6d835..a87c660f613 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 b22226a79e4..b87af084d7d 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 e25fd1bd914..1ef36b1ff5d 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 c213e92bae4..40624baba0d 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 40cd121c713..8a71fd738e1 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 00000000000..face70c5351 --- /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 00000000000..5e3877fc163 --- /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 00000000000..fdcfbdb81e7 --- /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 ab2fbc63629..569a0469521 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 00000000000..ac40f8e5724 --- /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 011/342] 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 ec38a87cff9..9dfb0bf3d31 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 012/342] 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 c88fede7fa8..56d0977e6e6 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 00000000000..66a76aa4d93 --- /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 00000000000..0347e6f1b3d --- /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 00000000000..2071611dacc --- /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 00000000000..22c277c21a7 --- /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 00000000000..fae3abcbab4 --- /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 00000000000..ba07c289952 --- /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 00000000000..5787e894582 --- /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 00000000000..2acdba9025a --- /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 00000000000..3e35fa42eab --- /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 00000000000..519a0635a36 --- /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 00000000000..8694480b6a3 --- /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 00000000000..6990f323ec4 --- /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 00000000000..194773d45be --- /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 00000000000..02581fdebce --- /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 013/342] 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 00000000000..86ebe891946 --- /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 823eb6d3c1a..38395b2766f 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 9dfb0bf3d31..ce3b943429e 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 7ee21a770e4..7c2c1769202 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 2f6ae4653c6..5a558769a7e 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 8472918dcae..fdf1d4b18a9 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 a42e6b6d835..a87c660f613 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 b22226a79e4..b87af084d7d 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 e25fd1bd914..1ef36b1ff5d 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 c213e92bae4..40624baba0d 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 40cd121c713..8a71fd738e1 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 00000000000..face70c5351 --- /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 00000000000..5e3877fc163 --- /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 00000000000..fdcfbdb81e7 --- /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 ab2fbc63629..569a0469521 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 00000000000..ac40f8e5724 --- /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 014/342] ## 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 ec38a87cff9..1808715c083 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 015/342] 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 4f10efa9dcf..56d0977e6e6 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 00000000000..66a76aa4d93 --- /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 00000000000..0347e6f1b3d --- /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 00000000000..2071611dacc --- /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 00000000000..22c277c21a7 --- /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 00000000000..fae3abcbab4 --- /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 00000000000..f315a5b9182 --- /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 00000000000..5787e894582 --- /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 00000000000..2acdba9025a --- /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 00000000000..3e35fa42eab --- /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 00000000000..519a0635a36 --- /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 00000000000..8694480b6a3 --- /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 00000000000..6990f323ec4 --- /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 00000000000..194773d45be --- /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 00000000000..02581fdebce --- /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 016/342] 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 00000000000..ee27af17fe2 --- /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 c55337ecfe6..56d0977e6e6 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 00000000000..66a76aa4d93 --- /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 00000000000..0347e6f1b3d --- /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 00000000000..2071611dacc --- /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 00000000000..22c277c21a7 --- /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 ce3b943429e..739f710f611 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 00000000000..fae3abcbab4 --- /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 00000000000..f315a5b9182 --- /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 00000000000..5787e894582 --- /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 00000000000..2acdba9025a --- /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 00000000000..3e35fa42eab --- /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 00000000000..519a0635a36 --- /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 00000000000..8694480b6a3 --- /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 00000000000..6990f323ec4 --- /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 00000000000..194773d45be --- /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 00000000000..02581fdebce --- /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 811db957e7e52556dbeaa25d54caa715ea9b1b4f Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Fri, 22 May 2026 13:41:30 -0700 Subject: [PATCH 017/342] 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 df169504f08..daa3d1fdcf3 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 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 018/342] 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 2cd11e66fe0..56d0977e6e6 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 00000000000..66a76aa4d93 --- /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 00000000000..0347e6f1b3d --- /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 00000000000..2071611dacc --- /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 00000000000..22c277c21a7 --- /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 00000000000..fae3abcbab4 --- /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 00000000000..f315a5b9182 --- /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 00000000000..5787e894582 --- /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 00000000000..2acdba9025a --- /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 00000000000..3e35fa42eab --- /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 00000000000..519a0635a36 --- /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 00000000000..8694480b6a3 --- /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 00000000000..6990f323ec4 --- /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 00000000000..194773d45be --- /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 00000000000..02581fdebce --- /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 019/342] 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 00000000000..459a467dca5 --- /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 c55337ecfe6..56d0977e6e6 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 00000000000..66a76aa4d93 --- /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 00000000000..0347e6f1b3d --- /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 00000000000..2071611dacc --- /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 00000000000..22c277c21a7 --- /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 739f710f611..bb02db81805 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 00000000000..fae3abcbab4 --- /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 00000000000..f315a5b9182 --- /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 00000000000..5787e894582 --- /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 00000000000..2acdba9025a --- /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 00000000000..3e35fa42eab --- /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 00000000000..519a0635a36 --- /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 00000000000..8694480b6a3 --- /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 00000000000..6990f323ec4 --- /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 00000000000..194773d45be --- /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 00000000000..02581fdebce --- /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 b11a7c17278e819917152997b0c038e3bfc44545 Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Fri, 22 May 2026 17:13:54 -0700 Subject: [PATCH 020/342] 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 95c065c74bd..b75cd1ba526 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 021/342] 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 2cd11e66fe0..56d0977e6e6 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 00000000000..66a76aa4d93 --- /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 00000000000..0347e6f1b3d --- /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 00000000000..2071611dacc --- /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 00000000000..22c277c21a7 --- /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 00000000000..fae3abcbab4 --- /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 00000000000..f315a5b9182 --- /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 00000000000..5787e894582 --- /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 00000000000..2acdba9025a --- /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 00000000000..3e35fa42eab --- /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 00000000000..519a0635a36 --- /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 00000000000..8694480b6a3 --- /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 00000000000..6990f323ec4 --- /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 00000000000..194773d45be --- /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 00000000000..02581fdebce --- /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 022/342] 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 00000000000..557ac08657e --- /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 c55337ecfe6..56d0977e6e6 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 00000000000..66a76aa4d93 --- /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 00000000000..0347e6f1b3d --- /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 00000000000..2071611dacc --- /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 00000000000..22c277c21a7 --- /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 c9aab8a5c79..f73d14ef3e2 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 00000000000..fae3abcbab4 --- /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 00000000000..f315a5b9182 --- /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 00000000000..5787e894582 --- /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 00000000000..2acdba9025a --- /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 00000000000..3e35fa42eab --- /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 00000000000..519a0635a36 --- /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 00000000000..8694480b6a3 --- /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 00000000000..6990f323ec4 --- /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 00000000000..194773d45be --- /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 00000000000..02581fdebce --- /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 a75c443fdb64db48c3cf4bdb247c7ee52c0144c9 Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Tue, 26 May 2026 09:46:10 -0700 Subject: [PATCH 023/342] ## New Features - Added search across local conversation history, including case-insensitive content matches with result previews. (#23519, #23921) - Made `--profile` the primary profile selector across CLI, TUI permissions, and sandbox flows, with legacy profile configs rejected through migration guidance. (#23708, #23883, #23890, #24051, #24055, #24059, #24067, #24110) - Improved MCP setup with per-server environment targeting and OAuth options for streamable HTTP servers. (#23583, #24120) - Made connector tool schemas more reliable by preserving local `$ref`/`$defs` structures and compacting oversized schemas before exposure. (#23357, #23904) - Let read-only MCP tools run concurrently when they advertise `readOnlyHint`. (#23750) - Added richer extension and hook context, including conversation history for extension tools and subagent identity in hook inputs. (#22882, #23963) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Bug Fixes - Improved remote reliability by reconnecting stale exec-server websocket clients, retrying remote control immediately after auth recovery, and retrying remote compaction v2 streams. (#23867, #23775, #23951) - Fixed Windows TUI rendering corruption by restoring virtual terminal mode before drawing. (#24082) - Displayed workspace-specific usage-limit messages for credit and spend-cap failures. (#24114) - Allowed plugin skills to reuse shared plugin-level icon assets. (#23776) - Preserved active permission profile metadata when syncing auto-review runtime settings. (#23956) - Ensured Node-based tools honor Codex’s managed network proxy environment. (#23905) ## Documentation - Documented the curl and PowerShell installer paths in the README. (#24106) - Updated developer docs to prefer `just test` over direct `cargo test` for repo-local test runs. (#23910) - Added profile migration documentation links to relevant config errors. (#23879) ## Chores - Simplified release packaging around canonical native artifacts, reusable DotSlash fetching, and a new macOS x64 zsh artifact. (#23833, #23836, #24129, #24165) - Added release-build support for Codex-produced V8 artifacts. (#23934) - Added image re-encoding benchmarks and connector-style JSON schema policy fixtures. (#23935, #24152) - Improved tracing and analytics for websocket requests, turn starts, and remote compaction v2. (#23581, #23980, #24146) ## Changelog Full Changelog: https://github.com/openai/codex/compare/rust-v0.133.0...rust-v0.134.0 - #23581 Trace logical websocket request after untraced warmup @jif-oai - #23718 [codex] Steer budget-limited goal extension turns @jif-oai - #23861 fix: cargo lock @jif-oai - #23728 feat: retain remote compaction truncation parity in v2 @jif-oai - #23870 Make tool executor specs mandatory @jif-oai - #23882 [codex] Stabilize subagent start hook test @jif-oai - #23876 refactor: centralize tool exposure planning @jif-oai - #23879 chore: link doc in profile error messages @jif-oai - #23883 cli: rename profile v2 flag to --profile @jif-oai - #23835 docs: add description to codex-cli/package.json @bolinfest - #23583 Route MCP servers through explicit environments @starr-openai - #23886 cli: remove legacy profile v1 plumbing @jif-oai - #23708 tui: plumb permission profile selection @viyatb-oai - #23833 packaging: move rg manifest out of npm bin @bolinfest - #23796 Improve `/goal` error messages for ephemeral sessions @etraut-openai - #23867 Reconnect disconnected exec-server websocket clients with fresh sessions @starr-openai - #23792 TUI: skip goal replace prompt for completed goals @etraut-openai - #23519 [codex] Add rollout-backed thread content search @fc-oai - #22552 Remove plugin hooks feature flag @abhinav-oai - #23836 npm: remove legacy package artifact synthesis @bolinfest - #23921 [codex] Make thread search case-insensitive @fc-oai - #23775 fix(remote-control): retry after auth recovery @apanasenko-oai - #22882 Add subagent identity to hook inputs @abhinav-oai - #22915 [3 of 4] tui: route feature and memory toggles through app server @etraut-openai - #23776 fix: Allow plugin skills to share plugin-level icon assets @xl-openai - #23860 Add Bedrock Mantle GovCloud region @CHARLESPALEN-OAI - #23956 Fix auto-review permission profile override @etraut-openai - #23357 feat: support local refs and defs in tool input schemas @celia-oai - #23963 Expose conversation history to extension tools @sayan-oai - #23904 feat: best-effort compact large tool schemas @celia-oai - #23750 Allow parallel MCP tool calls when annotated readOnly @anp-oai - #23905 [codex] Enable Node env proxy for managed network proxy @rreichel3-oai - #23890 mcp: surface profile migration guidance under --profile @jif-oai - #24051 config: remove legacy profile v1 resolution @jif-oai - #24055 config: remove legacy profile write paths @jif-oai - #24057 Avoid config snapshots in live agent subtree traversal @jif-oai - #24061 otel: drop legacy profile usage telemetry @jif-oai - #24059 fix: reject legacy profile selectors @jif-oai - #23934 ci: Use codex produced v8 artifacts for release builds @cconger - #24099 fix(app-server): fix optional bool annotations @owenlin0 - #23910 Prefer `just test` over `cargo test` in docs @anp-oai - #23951 retry remote compaction v2 requests @rhan-oai - #24081 tui: make `codex-tui.log` opt-in @jif-oai - #24102 cli: infer host sandbox backend @bolinfest - #24067 app-server: drop legacy profile config surface @jif-oai - #23736 Add new enterprise requirement gate @adams-oai - #24117 [codex] Use rolling files for Windows sandbox logs @iceweasel-oai - #24106 docs: update README.md to mention curl-based installer @bolinfest - #24082 fix(tui): restore Windows VT before TUI renders @fcoury-oai - #24110 cli: support --profile for codex sandbox @bolinfest - #23980 Add trace_id to TurnStartedEvent @mchen-oai - #24120 Support OAuth options in codex mcp add @mzeng-openai - #23989 Add typed Images client to codex-api @won-openai - #24146 [codex-analytics] split compaction v2 analytics implementation @rhan-oai - #24129 package: factor DotSlash executable fetching @bolinfest - #24151 [codex] Use TurnInput for session task input @pakrym-oai - #23935 [codex] Add image re-encoding benchmarks @anp-oai - #24152 chore: add JSON schema policy fixture coverage @celia-oai - #24157 [codex] Remove external client session reset plumbing @pakrym-oai - #24114 Display workspace usage limit error copy from response header @dhruvgupta-oai - #24165 release: build macOS x64 zsh artifact @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 95c065c74bd..0e573035638 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" # 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 9e518fe53c45f5756613072b773123ff512167f4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 21:58:24 +0000 Subject: [PATCH 024/342] 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 2cd11e66fe0..56d0977e6e6 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 00000000000..66a76aa4d93 --- /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 00000000000..0347e6f1b3d --- /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 00000000000..2071611dacc --- /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 00000000000..22c277c21a7 --- /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 00000000000..fae3abcbab4 --- /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 00000000000..f315a5b9182 --- /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 00000000000..5787e894582 --- /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 00000000000..2acdba9025a --- /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 00000000000..3e35fa42eab --- /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 00000000000..519a0635a36 --- /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 00000000000..8694480b6a3 --- /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 00000000000..6990f323ec4 --- /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 00000000000..194773d45be --- /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 00000000000..02581fdebce --- /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 113163fa19fe46110866a3bf22664805226cf395 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 21:58:26 +0000 Subject: [PATCH 025/342] Prepare Termux rust-v0.134.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 00000000000..6d7721b5079 --- /dev/null +++ b/.github/termux-release.json @@ -0,0 +1,15 @@ +{ + "upstream_repo": "openai/codex", + "upstream_tag": "rust-v0.134.0", + "upstream_name": "0.134.0", + "upstream_html_url": "https://github.com/openai/codex/releases/tag/rust-v0.134.0", + "upstream_target": "main", + "upstream_release_id": "329640454", + "upstream_prerelease": false, + "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": "f008348be7cf8ec3bf5ed1b7afa1517c3a5aabbe", + "termux_tag": "rust-v0.134.0-termux" +} diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index c55337ecfe6..56d0977e6e6 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 00000000000..66a76aa4d93 --- /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 00000000000..0347e6f1b3d --- /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 00000000000..2071611dacc --- /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 00000000000..22c277c21a7 --- /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 44aeba13be7..da7fa5299a4 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.3" +version = "0.134.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 00000000000..fae3abcbab4 --- /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 00000000000..f315a5b9182 --- /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 00000000000..5787e894582 --- /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 00000000000..2acdba9025a --- /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 00000000000..3e35fa42eab --- /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 00000000000..519a0635a36 --- /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 00000000000..8694480b6a3 --- /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 00000000000..6990f323ec4 --- /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 00000000000..194773d45be --- /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 00000000000..02581fdebce --- /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 75b2877a23355744b52fa18e35e14f8aa7d504ed Mon Sep 17 00:00:00 2001 From: Celia Chen Date: Wed, 27 May 2026 12:39:45 -0700 Subject: [PATCH 026/342] Release 0.135.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 c85a55890c8..838e724c223 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.135.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 45dfbbb974c61d423b956ba4e09d138bef30fbc3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 22:04:12 +0000 Subject: [PATCH 027/342] Seed Termux release automation --- .github/workflows/rust-release.yml | 1844 +++++++---------- .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, 2455 insertions(+), 1109 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 eaf886cd01d..56d0977e6e6 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@e081816240890017053eacbb1bdf337761dc5582 # 1.95.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: - 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@e081816240890017053eacbb1bdf337761dc5582 # 1.95.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[@]}" + 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 }} @@ -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,140 +1059,70 @@ 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 + - 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: ${{ !contains(steps.release_name.outputs.name, '-') && github.repository_owner == 'openai' }} + continue-on-error: true env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag: ${{ github.ref_name }} - config: .github/dotslash-argument-comment-lint-config.json + DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL: ${{ secrets.DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL }} + run: | + if ! curl -sS -f -o /dev/null -X POST "$DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL"; then + echo "::warning title=developers.openai.com deploy hook failed::Vercel deploy hook POST failed for ${GITHUB_REF_NAME}" + exit 1 + fi # Publish to npm using OIDC authentication. # July 31, 2025: https://github.blog/changelog/2025-07-31-npm-trusted-publishing-with-oidc-is-generally-available/ # 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 @@ -1305,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 @@ -1344,223 +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 - - deploy-dev-website: - name: Trigger developers.openai.com deploy - needs: release - # Only trigger the deploy for a stable signed release. - # The deploy updates developers.openai.com with the new config schema json file. - if: >- - ${{ - !cancelled() && - needs.release.result == 'success' && - needs.release.outputs.sign_macos == 'true' && - !contains(needs.release.outputs.version, '-') - }} - runs-on: ubuntu-latest - continue-on-error: true - permissions: {} - environment: - name: dev-website-vercel-deploy - deployment: false - - steps: - - name: Trigger developers.openai.com deploy - continue-on-error: true - env: - DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL: ${{ secrets.DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL }} - run: | - if ! curl -sS -f -o /dev/null -X POST "$DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL"; then - echo "::warning title=developers.openai.com deploy hook failed::Vercel deploy hook POST failed for ${GITHUB_REF_NAME}" - exit 1 - fi - - 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 00000000000..66a76aa4d93 --- /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 00000000000..0347e6f1b3d --- /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 00000000000..2071611dacc --- /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 00000000000..22c277c21a7 --- /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 00000000000..fae3abcbab4 --- /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 00000000000..f315a5b9182 --- /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 00000000000..5787e894582 --- /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 00000000000..2acdba9025a --- /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 00000000000..3e35fa42eab --- /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 00000000000..519a0635a36 --- /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 00000000000..8694480b6a3 --- /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 00000000000..6990f323ec4 --- /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 00000000000..194773d45be --- /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 00000000000..02581fdebce --- /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 88f148ca4d09b74bb86195037dbbe4ff5a808285 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 22:04:16 +0000 Subject: [PATCH 028/342] Prepare Termux rust-v0.135.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 00000000000..7b964e19361 --- /dev/null +++ b/.github/termux-release.json @@ -0,0 +1,15 @@ +{ + "upstream_repo": "openai/codex", + "upstream_tag": "rust-v0.135.0-alpha.2", + "upstream_name": "0.135.0-alpha.2", + "upstream_html_url": "https://github.com/openai/codex/releases/tag/rust-v0.135.0-alpha.2", + "upstream_target": "main", + "upstream_release_id": "330530086", + "upstream_prerelease": true, + "release_train": "0.135.0", + "release_branch": "release/0.135.0", + "work_branch": "upstream/rust-v0.135.0", + "patch_branch": "wallentx/termux-target", + "patch_source_sha": "7d53d86bef7d785a271b3f6e273b33d0097bce9b", + "termux_tag": "rust-v0.135.0-alpha.2-termux" +} diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index c55337ecfe6..56d0977e6e6 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 00000000000..66a76aa4d93 --- /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 00000000000..0347e6f1b3d --- /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 00000000000..2071611dacc --- /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 00000000000..22c277c21a7 --- /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 da7fa5299a4..58c013eb716 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -118,7 +118,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.134.0" +version = "0.135.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 00000000000..fae3abcbab4 --- /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 00000000000..f315a5b9182 --- /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 00000000000..5787e894582 --- /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 00000000000..2acdba9025a --- /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 00000000000..3e35fa42eab --- /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 00000000000..519a0635a36 --- /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 00000000000..8694480b6a3 --- /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 00000000000..6990f323ec4 --- /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 00000000000..194773d45be --- /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 00000000000..02581fdebce --- /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 4daceea869704f9f35e0a3949fc34711ef978a4e Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Thu, 28 May 2026 09:14:50 -0700 Subject: [PATCH 029/342] ## New Features - `codex doctor` now reports richer environment, Git, terminal, app-server, and thread inventory diagnostics for support cases. (#24261, #24311, #24305) - `/status` shows remote connection details and server version when the TUI is connected over a remote transport. (#24420) - Vim mode gained text-object editing, improved word/line-end behavior, and a configurable interrupt-turn binding. (#24382, #24380, #24766) - `/permissions` now understands named permission profiles and displays configured custom profiles. (#21559) - Packaged Codex builds can discover and use the bundled patched zsh helper across supported macOS and Linux targets. (#23756, #24171) - The Python SDK now exposes friendly `Sandbox` presets for thread and turn APIs. (#24772) ## Bug Fixes - Markdown tables and multiline lists render more readably in the TUI, with better column sizing and app-style table formatting. (#24489, #24346, #24351) - TUI output is more stable on macOS and Zellij, avoiding stderr/composer corruption and raw-output overlap. (#24459, #24479, #24593) - Slash-command completion now preserves existing draft text for commands that accept inline arguments. (#23950) - Older tmux/iTerm control-mode sessions no longer lose normal `Ctrl-C` handling from unsupported keyboard enhancement setup. (#24371) - App mentions now exclude inaccessible or disabled apps instead of offering unusable `$` suggestions. (#24625) - Resume flows now include non-interactive exec sessions when requested and honor cwd overrides for idle cached threads. (#24503, #24528) ## Documentation - Clarified image-viewing tool detail behavior and removed stale TUI composer documentation references. (#23949, #24641) - Updated Python SDK docs, examples, and notebook content to use the new sandbox preset API. (#24772) ## Chores - Updated Rust toolchain pins and SQLx/SQLite dependencies. (#24684, #24728) - Moved memory runtime state into a dedicated SQLite database. (#24591) - Removed remaining legacy config-profile consumers and routed more TUI config/plugin state through app-server-owned APIs. (#24076, #24254, #24255, #24265, #24266, #24257) - Centralized Responses retry handling and MCP tool naming logic to reduce duplicated internal plumbing. (#24131, #21576) ## Changelog Full Changelog: https://github.com/openai/codex/compare/rust-v0.134.0...rust-v0.135.0 - #24164 fix(remote-control): cap reconnect backoff @apanasenko-oai - #23756 package: include zsh fork in Codex package @bolinfest - #23757 Default function tools into tool hooks @abhinav-oai - #24171 package: add x64 macOS codex-zsh artifact @bolinfest - #24159 code-mode: merge stored values by key @cconger - #23983 fix: plugin bundle archive handling for upload and install @xl-openai - #24261 feat(doctor): add environment diagnostics @fcoury-oai - #24311 Report app-server version in codex doctor @etraut-openai - #24314 tui: label compact rate-limit percentages @etraut-openai - #24420 Show remote connection details in /status @etraut-openai - #24317 Respect hook trust bypass during TUI startup @etraut-openai - #24254 TUI config cleanup: oss_provider @etraut-openai - #24255 TUI config cleanup: trusted projects @etraut-openai - #24265 TUI config cleanup: MCP inventory @etraut-openai - #24305 Add doctor thread inventory audit @etraut-openai - #24346 fix(tui): improve markdown table column allocation @fcoury-oai - #24351 fix(tui): improve multiline markdown list readability @fcoury-oai - #24459 fix(tui): prevent macos stderr from corrupting composer @fcoury-oai - #24479 fix(process-hardening): preserve macos malloc diagnostics @fcoury-oai - #24474 Log rollout writer OS errors @etraut-openai - #24076 chore: stop consuming legacy config profiles @jif-oai - #24131 centralize Responses retry policy @rhan-oai - #23858 [wip] goal shift @jif-oai - #24555 chore: drop orphaned codex memories MCP crate @jif-oai - #24558 chore: move memory prompt builder into extension @jif-oai - #24562 Add ad-hoc memory note tool @jif-oai - #24567 Wire metrics client into memories extension @jif-oai - #24588 fix: drop flake @jif-oai - #24583 Add memory tool call metrics to memories extension @jif-oai - #24586 Wire app-server extension event sink @jif-oai - #24532 Use thread config for TUI MCP inventory @etraut-openai - #24105 [codex] Make active turn task singular @pakrym-oai - #21576 Move MCP tool naming mode into manager @pakrym-oai - #24503 tui: include exec sessions in resume list @etraut-openai - #24600 feat: gate dedicated memories tools in config @jif-oai - #21559 tui: add named permission profile picker @viyatb-oai - #24608 feat: add manual and remote_v2 tags to compaction metric @jif-oai - #24611 test: clean up apply_patch allow-session artifact @jif-oai - #24609 Remove reserved namespaces dedup @pakrym-oai - #23964 Move slash input logic out of chat composer @canvrno-oai - #24615 Add goal extension telemetry parity @jif-oai - #24371 fix(tui): avoid modifyOtherKeys for unknown tmux formats @fcoury-oai - #24626 fix: restore goal accounting after thread resume @jif-oai - #24591 Move memory state to a dedicated SQLite DB @jif-oai - #23823 standalone websearch extension @sayan-oai - #24593 fix(tui): keep raw output above composer in zellij @fcoury-oai - #24625 tui: keep inaccessible apps out of mentions @canvrno-oai - #24154 Add experimental turn additional context @pakrym-oai - #24473 fix(remote-control): surface websocket task stalls @apanasenko-oai - #24528 Respect resume cwd overrides for idle cached threads @etraut-openai - #24160 Add forked_from_thread_id turn metadata @owenlin0 - #24646 make direct only allowed caller for standalone websearch @sayan-oai - #23949 Clarify view_image tool description @fjord-oai - #24266 TUI config cleanup: plugin mentions @etraut-openai - #24320 Avoid repeated marketplace upgrades for alternate layouts @etraut-openai - #23813 windows-sandbox: remove SandboxPolicy runner plumbing @bolinfest - #24652 [codex] remove plain image wrapper spans @pakrym-oai - #24623 Attach Windows sandbox log to feedback reports @iceweasel-oai - #24644 Restore legacy image detail values @rhan-oai - #24655 [codex-analytics] add grouped session id to runtime events @marksteinbrick-oai - #24658 [codex] Remove obsolete goal continuation turn marker @pakrym-oai - #24660 fix: dont compact standalone websearch schema @sayan-oai - #24667 fix(core): instrument stalled tool-listing handoff @apanasenko-oai - #24684 Uprev Rust toolchain pins to 1.95.0 @anp-oai - #21567 fix: add noninteractive install script mode @efrazer-oai - #24707 Allow runtime enablement for remote plugins @xl-openai - #24714 fix(auto-review) skip legacy notify for auto review threads @dylan-hurd-oai - #24690 Revert "Add Bedrock Mantle GovCloud region (#23860)" @celia-oai - #24628 feat: handle goal usage limits in goal extension @jif-oai - #24746 Fix guardian review test user input @jif-oai - #24744 feat: add thread idle lifecycle hook @jif-oai - #24751 Drop startup context when truncating forked rollouts @jif-oai - #24257 TUI config cleanup: plugin marketplace @etraut-openai - #24380 fix(tui): complete vim word-end and line-end behavior @fcoury-oai - #24728 Bump SQLx to pick up newer bundled SQLite @jif-oai - #24637 fix: run standalone updates noninteractively @efrazer-oai - #24778 make vercel webhook url an env secret @sayan-oai - #23950 fix: Preserve draft text when completing argument-taking slash commands @canvrno-oai - #24641 [codex] Remove stale composer narrative doc references @canvrno-oai - #24368 [codex] add compaction metadata to turn headers @ningyi-oai - #24772 [codex] Add friendly Python SDK sandbox presets @aibrahim-oai - #24382 feat(tui): add vim text object bindings @fcoury-oai - #24766 feat(tui): make turn interruption keybind configurable @fcoury-oai - #24489 feat(tui): render markdown tables in app style [1 of 2] @fcoury-oai - #24713 chore: enable namespace tools for Bedrock @celia-oai --- 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 c85a55890c8..60d83f7e8b5 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.135.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 49726f3adc38ebeeb601f6a3ae7e6a4cabf6e303 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 28 May 2026 22:01:30 +0000 Subject: [PATCH 030/342] Seed Termux release automation --- .github/workflows/rust-release.yml | 1886 +++++++---------- .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, 2493 insertions(+), 1113 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 eaf886cd01d..78162e86714 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -4,119 +4,108 @@ # 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 - -concurrency: - group: ${{ github.workflow }} - cancel-in-progress: true + - 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 jobs: + cancel-superseded-pr-runs: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + permissions: + actions: write + contents: read + steps: + - name: Cancel older runs for this PR branch + env: + GH_TOKEN: ${{ github.token }} + HEAD_BRANCH: ${{ github.event.pull_request.head.ref }} + CURRENT_RUN_ID: ${{ github.run_id }} + shell: bash + run: | + set -euo pipefail + + echo "Cancelling older ${GITHUB_WORKFLOW} runs for ${HEAD_BRANCH}" + gh run list \ + --repo "${GITHUB_REPOSITORY}" \ + --workflow "${GITHUB_WORKFLOW}" \ + --limit 100 \ + --json databaseId,event,headBranch,status,url \ + --jq ' + .[] + | select(.event == "pull_request") + | select(.headBranch == env.HEAD_BRANCH) + | select(.databaseId < (env.CURRENT_RUN_ID | tonumber)) + | select(.status == "queued" or .status == "in_progress" or .status == "waiting" or .status == "requested" or .status == "pending") + | "\(.databaseId) \(.url)" + ' \ + | while read -r run_id run_url; do + [[ -n "${run_id}" ]] || continue + echo "Cancelling superseded run ${run_id}: ${run_url}" + gh run cancel "${run_id}" --repo "${GITHUB_REPOSITORY}" || true + done + 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@e081816240890017053eacbb1bdf337761dc5582 # 1.95.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 +120,200 @@ 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') && + (github.event_name != 'pull_request' || needs.cancel-superseded-pr-runs.result == 'success') && + needs.select-build-matrix.result == 'success' + needs: + - cancel-superseded-pr-runs + - 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 +328,173 @@ jobs: sudo apt-get update -y sudo DEBIAN_FRONTEND=noninteractive apt-get install -y libubsan1 fi - - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.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 +507,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 +561,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 +585,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[@]}" + 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 }} @@ -435,7 +657,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 +672,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 +703,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 +717,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 +783,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 +793,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 +1047,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 +1082,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,140 +1093,70 @@ 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 + - 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: ${{ !contains(steps.release_name.outputs.name, '-') && github.repository_owner == 'openai' }} + continue-on-error: true env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag: ${{ github.ref_name }} - config: .github/dotslash-argument-comment-lint-config.json + DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL: ${{ secrets.DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL }} + run: | + if ! curl -sS -f -o /dev/null -X POST "$DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL"; then + echo "::warning title=developers.openai.com deploy hook failed::Vercel deploy hook POST failed for ${GITHUB_REF_NAME}" + exit 1 + fi # Publish to npm using OIDC authentication. # July 31, 2025: https://github.blog/changelog/2025-07-31-npm-trusted-publishing-with-oidc-is-generally-available/ # 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 @@ -1305,37 +1166,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 @@ -1344,223 +1204,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 - - deploy-dev-website: - name: Trigger developers.openai.com deploy - needs: release - # Only trigger the deploy for a stable signed release. - # The deploy updates developers.openai.com with the new config schema json file. - if: >- - ${{ - !cancelled() && - needs.release.result == 'success' && - needs.release.outputs.sign_macos == 'true' && - !contains(needs.release.outputs.version, '-') - }} - runs-on: ubuntu-latest - continue-on-error: true - permissions: {} - environment: - name: dev-website-vercel-deploy - deployment: false - - steps: - - name: Trigger developers.openai.com deploy - continue-on-error: true - env: - DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL: ${{ secrets.DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL }} - run: | - if ! curl -sS -f -o /dev/null -X POST "$DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL"; then - echo "::warning title=developers.openai.com deploy hook failed::Vercel deploy hook POST failed for ${GITHUB_REF_NAME}" - exit 1 - fi - - 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 00000000000..66a76aa4d93 --- /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 00000000000..0347e6f1b3d --- /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 00000000000..2071611dacc --- /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 00000000000..22c277c21a7 --- /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 00000000000..fae3abcbab4 --- /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 00000000000..f315a5b9182 --- /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 00000000000..5787e894582 --- /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 00000000000..2acdba9025a --- /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 00000000000..3e35fa42eab --- /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 00000000000..519a0635a36 --- /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 00000000000..8694480b6a3 --- /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 00000000000..6990f323ec4 --- /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 00000000000..194773d45be --- /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 00000000000..02581fdebce --- /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 0538cb6eef1c75f4943c3d8a9c5daef67b3f1e64 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 28 May 2026 22:01:32 +0000 Subject: [PATCH 031/342] Prepare Termux rust-v0.135.0 --- .github/termux-release.json | 15 + .github/workflows/rust-release.yml | 1840 +++++++---------- .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, 2498 insertions(+), 1079 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 00000000000..d15ef96c638 --- /dev/null +++ b/.github/termux-release.json @@ -0,0 +1,15 @@ +{ + "upstream_repo": "openai/codex", + "upstream_tag": "rust-v0.135.0", + "upstream_name": "0.135.0", + "upstream_html_url": "https://github.com/openai/codex/releases/tag/rust-v0.135.0", + "upstream_target": "main", + "upstream_release_id": "331012967", + "upstream_prerelease": false, + "release_train": "0.135.0", + "release_branch": "release/0.135.0", + "work_branch": "upstream/rust-v0.135.0", + "patch_branch": "wallentx/termux-target", + "patch_source_sha": "d0bdfe5f71d54eb164e28714a2047134f4ac71d0", + "termux_tag": "rust-v0.135.0-termux" +} diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index c55337ecfe6..78162e86714 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -4,119 +4,108 @@ # 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 - -concurrency: - group: ${{ github.workflow }} - cancel-in-progress: true + - 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 jobs: + cancel-superseded-pr-runs: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + permissions: + actions: write + contents: read + steps: + - name: Cancel older runs for this PR branch + env: + GH_TOKEN: ${{ github.token }} + HEAD_BRANCH: ${{ github.event.pull_request.head.ref }} + CURRENT_RUN_ID: ${{ github.run_id }} + shell: bash + run: | + set -euo pipefail + + echo "Cancelling older ${GITHUB_WORKFLOW} runs for ${HEAD_BRANCH}" + gh run list \ + --repo "${GITHUB_REPOSITORY}" \ + --workflow "${GITHUB_WORKFLOW}" \ + --limit 100 \ + --json databaseId,event,headBranch,status,url \ + --jq ' + .[] + | select(.event == "pull_request") + | select(.headBranch == env.HEAD_BRANCH) + | select(.databaseId < (env.CURRENT_RUN_ID | tonumber)) + | select(.status == "queued" or .status == "in_progress" or .status == "waiting" or .status == "requested" or .status == "pending") + | "\(.databaseId) \(.url)" + ' \ + | while read -r run_id run_url; do + [[ -n "${run_id}" ]] || continue + echo "Cancelling superseded run ${run_id}: ${run_url}" + gh run cancel "${run_id}" --repo "${GITHUB_REPOSITORY}" || true + done + 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 +120,200 @@ 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') && + (github.event_name != 'pull_request' || needs.cancel-superseded-pr-runs.result == 'success') && + needs.select-build-matrix.result == 'success' + needs: + - cancel-superseded-pr-runs + - 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 +328,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 +507,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 +561,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 +585,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[@]}" - - - 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 +657,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 +672,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 +703,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 +717,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 +783,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 +793,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 +1047,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 +1082,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 +1093,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 +1156,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 +1166,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 +1204,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 00000000000..66a76aa4d93 --- /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 00000000000..0347e6f1b3d --- /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 00000000000..2071611dacc --- /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 00000000000..22c277c21a7 --- /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 7816101b608..0fdf4ce7c8e 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -118,7 +118,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.135.0-alpha.2" +version = "0.135.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 00000000000..fae3abcbab4 --- /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 00000000000..f315a5b9182 --- /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 00000000000..5787e894582 --- /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 00000000000..2acdba9025a --- /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 00000000000..3e35fa42eab --- /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 00000000000..519a0635a36 --- /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 00000000000..8694480b6a3 --- /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 00000000000..6990f323ec4 --- /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 00000000000..194773d45be --- /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 00000000000..02581fdebce --- /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 97a43e213d75b62be7c88eb8a5ca465f9772de7b Mon Sep 17 00:00:00 2001 From: "unemployabot[bot]" <277198073+unemployabot[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 00:33:44 -0500 Subject: [PATCH 032/342] checkpoint: into wallentx/termux-target from release/0.136.0 @ 1e6e8b4b5d85 (#176) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(linux-sandbox): preserve shell cleanup on interruption (#22729) ## Why Interrupted `shell_command` calls can race with the outer tool-dispatch cancellation path. When that happens, the runtime future may be dropped before the spawned process gets a chance to run `SIGTERM` cleanup. For bwrapd-backed Linux sandbox commands, that can leave synthetic protected-path mount bookkeeping such as `.git/.codex` registrations under `/tmp` behind after a TUI interruption. The relevant cancellation points are the outer dispatch race in [`core/src/tools/parallel.rs`](https://github.com/openai/codex/blob/bd184ba84703cc924921ed883f0cf17d3dba60ff/codex-rs/core/src/tools/parallel.rs#L91-L132) and the process shutdown logic in [`core/src/exec.rs`](https://github.com/openai/codex/blob/bd184ba84703cc924921ed883f0cf17d3dba60ff/codex-rs/core/src/exec.rs#L1367-L1393). ## What changed - Keep `shell_command` dispatch alive long enough for the runtime to finish cancellation cleanup instead of immediately returning the synthetic aborted response. - Fold shell-turn cancellation into the existing `ExecExpiration` path in [`core/src/tools/runtimes/shell.rs`](https://github.com/openai/codex/blob/bd184ba84703cc924921ed883f0cf17d3dba60ff/codex-rs/core/src/tools/runtimes/shell.rs#L267-L274), so cancellation and timeout behavior stay centralized. - On cancellation, send `SIGTERM` first, wait briefly for cleanup to run, then hard-kill any remaining descendants in the original process group. - Treat `ESRCH` as an already-gone process-group cleanup case in `codex-utils-pty`, which keeps best-effort teardown from surfacing a stale-process race as an error. ## Verification - `cargo test -p codex-core cancellation` - Added regression coverage for: - `shell_tool_cancellation_waits_for_runtime_cleanup` - `process_exec_tool_call_cancellation_allows_sigterm_cleanup` * feat(tui): add OSC 8 web links to rich content (#24472) ## Why Wrapped URLs in rich TUI output, especially URLs rendered inside Markdown tables, are split across terminal rows. In terminals that support OSC 8 hyperlinks, treating each visible fragment as part of the complete destination enables reliable open-link and copy-link actions even after table layout wraps the URL. This addresses the semantic-link portion of #12200 and the behavior described in https://github.com/openai/codex/issues/12200#issuecomment-4535452980. It does not change ordinary drag-selection across bordered table rows. ## What Changed - Added shared TUI OSC 8 support that validates `http://` and `https://` destinations, sanitizes terminal payloads, and applies metadata separately from visible line width/layout. - Added semantic web-link annotations to assistant and proposed-plan Markdown, including explicit web links and bare web URLs in prose and table cells while excluding code and non-web Markdown destinations. - Preserved complete URL targets through table wrapping, narrow pipe fallback, streaming, transcript overlay rendering, history insertion, and resize replay. - Routed intentional Codex-owned links in notices, status/setup/app-link, feedback, onboarding, MCP/plugin help, memories, and update surfaces through the shared hyperlink handling. ## How to Test 1. Run Codex in a terminal with OSC 8 link support, such as Ghostty, and request an assistant response containing a Markdown table whose last column contains a long `https://` URL. 2. Make the terminal narrow enough for the URL to wrap across multiple bordered table rows. 3. Use the terminal's open-link or copy-link action on more than one wrapped URL fragment and confirm each fragment resolves to the complete original URL. 4. Resize the terminal after the table is rendered and repeat the link action to confirm the destination survives scrollback replay. 5. Open the transcript overlay while rich output is present and confirm web links remain interactive there. 6. As a regression check, render inline/fenced code containing URL text and a Markdown link such as `[https://example.com](mailto:support@example.com)`; confirm these do not acquire a web OSC 8 destination. Targeted automated coverage exercised Markdown links and exclusions, wrapped and pipe-fallback tables, streaming/transcript overlay propagation, status-link truncation, and rendered word-wrapping cell alignment. `just test -p codex-tui` was also run; it passed the hyperlink coverage and reproduced two unrelated existing guardian feature-flag test failures. * feat(tui): render cramped markdown tables as key-value records [2 of 2] (#24636) ## Stack - **Base: #24489 [1 of 2]** - render markdown tables in app style. - **Current: #24636 [2 of 2]** - render cramped markdown tables as key/value records. Review this PR against `fcoury/app-style-markdown-tables`; it contains only the fallback behavior for cramped tables. ## Why The row-separated markdown table rendering in #24489 remains readable while columns have usable room. Once long links or multiple prose-heavy columns are compressed into narrow allocations, however, the grid can turn words and paths into tall vertical strips that are difficult to scan. In those cases the content matters more than preserving the grid shape. ## What Changed

Normal

CleanShot 2026-05-27 at 14 32 57

Narrow

CleanShot 2026-05-27 at 14 33 12

Very narrow

CleanShot 2026-05-27 at 14 33 47
- Detect tables whose grid allocation causes systemic token fragmentation or starves multiple prose-heavy columns. - Render those tables as repeated key/value records instead of retaining an unreadable grid. - Use aligned label/value records when there is useful horizontal room, and switch to a stacked narrow-record layout where each label is followed by a full-width value when width is especially constrained. - Preserve the themed label color, rich inline formatting, links, and the existing grid presentation for tables that remain readable. - Add snapshot coverage for path-heavy narrow tables, prose-heavy issue tables, systemic compact fragmentation, and a control case that should continue to render as a grid. ## How to Test 1. Start Codex from this branch and render a normal multi-column markdown table at a comfortable terminal width. Confirm it still appears as the styled row-separated grid from #24489. 2. Render a table containing a long linked record identifier or file-like value, then narrow the terminal until the grid would split the value into vertical fragments. Confirm it switches to key/value records, with labels above values at very narrow widths. 3. Render a table with multiple prose-heavy columns, such as an issue summary table with `Issue`, `Activity`, `Complexity`, and `Why start`. Confirm a cramped width switches to records rather than wrapping several columns into hard-to-read strips. 4. Render a compact table where only one value wraps mildly. Confirm it stays in grid form rather than switching prematurely. ## Validation - Ran `just test -p codex-tui` while developing the fallback and reviewed/accepted the intended new markdown-render snapshots. The command still reports two unrelated existing guardian feature-flag test failures outside this diff. - Ran `just fix -p codex-tui` and `just fmt` after the Rust changes were complete. - `just argument-comment-lint` cannot reach source linting locally because Bazel fails while resolving LLVM sanitizer headers; touched positional literal callsites were inspected manually and annotated where needed. * Allow API-key auth for remote exec-server registration (#24666) ## Overview Allow remote `codex exec-server` registration to use existing API-key auth while restricting where those credentials can be sent. - Accept `CodexAuth::ApiKey` for the normal `--remote` registration path. - Restrict API-key remote registration to HTTPS `openai.com` and `openai.org` hosts and subdomains, with explicit HTTP loopback support for local development. - Disable registry registration redirects so credentials cannot be forwarded to an unvalidated destination. - Retain `--use-agent-identity-auth` as the explicit Agent Identity path. - Document remote registration using `CODEX_API_KEY`. ## Big picture Callers can now provide an API key directly to `exec-server` registration without first establishing ChatGPT login state: ```sh CODEX_API_KEY="$OPENAI_API_KEY" \ codex exec-server \ --remote "https://.openai.org/api" \ --environment-id "$ENVIRONMENT_ID" ``` ## Validation - `cargo fmt --all` (`just fmt` is not installed on this host) - `cargo test -p codex-cli -p codex-exec-server` * Update rmcp to 1.7.0 (#24763) WIll make it easier to uprev when the new draft spec is supported. Also updates reqwest where needed for compatibility but doesn't update it everywhere since this is already a large diff. The new version of rmcp handles certain kinds of authentication failures differently, this patch includes support for identifying the failing scope in a WWW-Authenticate header. * [codex] Fix hyperlink-aware key-value table rendering (#24825) ## Why The key/value markdown table renderer added in #24636 still operates on `Line` values, while table cells and rendered table output now carry `HyperlinkLine`. That mismatch breaks `codex-tui` compilation on `main` and would risk losing semantic web-link annotations if corrected by flattening the values. ## What changed - Make key/value record rendering wrap and emit `HyperlinkLine` values consistently with the existing grid renderer. - Remap wrapped hyperlink ranges and shift them when value content is prefixed by record-mode indentation or labels. - Add focused coverage verifying key/value fallback output preserves web-link destinations. ## Verification - `just test -p codex-tui -E 'test(key_value_table_keeps_web_annotations) | test(/table_renders_(key_value_records_when_compact_fragmentation_is_systemic_snapshot|stacked_key_value_records_when_path_column_becomes_too_narrow_snapshot|records_when_multiple_prose_columns_are_starved_snapshot)/)'` * [codex] Rename Python SDK AppServerConfig to CodexConfig (#24800) ## Why `AppServerConfig` is exported as part of the ergonomic Python SDK surface and passed to `Codex(...)` and `AsyncCodex(...)`. That name exposes the underlying app-server transport at the same layer where users are configuring the Codex client. `CodexConfig` makes the common callsite read naturally and names the object it configures. ## What changed - Renamed the public configuration dataclass from `AppServerConfig` to `CodexConfig`. - Updated `Codex`, `AsyncCodex`, and the transport clients to accept `CodexConfig`. - Updated binary-resolution messages, package exports, docs, examples, and related coverage to use the new public name. ## API impact ```python from openai_codex import Codex, CodexConfig with Codex(config=CodexConfig(codex_bin="/path/to/codex")) as codex: ... ``` Callers should now import and construct `CodexConfig`; `AppServerConfig` is no longer part of the Python SDK surface. ## Validation - `uv run --frozen --extra dev ruff check src/openai_codex scripts examples tests` - Tests are deferred to online CI for this PR. * [codex] Remove redundant SQLite dynamic tool storage (#24819) ## Why Dynamic tools are defined at thread start and already stored in rollout `SessionMeta`, which restores resumed and forked sessions. Persisting the same tools through SQLite creates a second runtime persistence path that is unnecessary prework for the explicit namespace refactor. ## What changed - Restore missing thread-start dynamic tools directly from rollout history, including when SQLite is enabled. - Remove SQLite dynamic-tool reads, writes, backfill, and thread metadata patch plumbing. - Add SQLite-enabled resume integration coverage that verifies a rollout-defined dynamic tool is still sent after resume. ## Compatibility The existing `thread_dynamic_tools` table is intentionally not dropped even though it's now unused. Older Codex binaries are allowed to open databases migrated by newer binaries and still reference this table; dropping it would break that mixed-version path. See [here](https://github.com/openai/codex/blob/main/codex-rs/state/src/migrations.rs#L10-L11). ## Verification - `just test -p codex-state -p codex-rollout -p codex-thread-store` - `just test -p codex-core --test all resume_restores_dynamic_tools_from_rollout_with_sqlite_enabled` * [codex] Add independent beta release for the Python SDK (#24828) ## Why `openai-codex` needs a beta release lifecycle without requiring beta releases of its pinned runtime package. Previously, SDK staging rewrote its runtime dependency to the SDK version, which made an SDK-only beta impossible. ## What changed - Set the initial SDK beta version to `0.1.0b1` and pin it to published stable `openai-codex-cli-bin==0.132.0`. - Decoupled SDK release staging from runtime versioning so it preserves the reviewed exact runtime pin. - Added a `python-v*` tag workflow that builds and publishes only `openai-codex` through PyPI trusted publishing. - Removed the Beta classifier from runtime package metadata for future runtime publications. - Regenerated protocol-derived SDK models from the selected stable runtime package. `0.132.0` is the newest stable runtime admitted by the checked-in dependency date fence and retains the Linux wheel family currently used by SDK CI. ## Release setup Before pushing `python-v0.1.0b1`, configure PyPI trusted publishing for the `openai-codex` project with workflow `python-sdk-release.yml`, environment `pypi`, and job `publish-python-sdk`. ## Validation - `uv run --frozen --extra dev ruff check src/openai_codex scripts examples tests` - Parsed `.github/workflows/python-sdk-release.yml` with PyYAML. - Built staged release artifacts locally: `openai_codex-0.1.0b1-py3-none-any.whl` and `openai_codex-0.1.0b1.tar.gz`. - Verified wheel metadata pins `openai-codex-cli-bin==0.132.0`. - Tests are deferred to online CI for this PR. * [codex] Prepare Python SDK beta documentation and package metadata (#24836) ## Why The initial public `openai-codex` beta should read and install like a normal published Python package before a release tag is created. This follows merged PR #24828, which establishes the independent SDK beta release plumbing and exact runtime dependency. ## What changed - Rewrote `sdk/python/README.md` as a compact PyPI-facing beta package page: published installation, one quickstart, short login examples, built-in help, and links to deeper guides. - Updated the getting-started guide, API reference, FAQ, and examples index to present the published beta consistently without repeating onboarding in the package landing page or reference page. - Made `pip install openai-codex` the primary install path while beta releases are the only published SDK releases, with `--pre` documented for opting into prereleases after a stable release exists. - Added curated `help()` / `pydoc` docstrings across the public API and generated public convenience methods through `scripts/update_sdk_artifacts.py`. - Declared the repository `Apache-2.0` license expression and Documentation URL in package metadata, without introducing a duplicated SDK-local license file. - Kept the source distribution focused on installable package material (`src/openai_codex`, `README.md`, and `pyproject.toml`); the repository docs and runnable examples remain linked from the PyPI README. - Built release artifacts in an Alpine container on the Ubuntu runner, matching Python SDK CI and allowing type generation to install the published `musllinux` runtime wheel. - Added `twine check --strict` to the release workflow so malformed PyPI metadata or rendered README content fails before publishing. - Added focused SDK assertions for beta metadata, the exact runtime pin, source distribution contents, and the built-in Python documentation surface. ## Validation - Ran `uv run --frozen --extra dev ruff check scripts/update_sdk_artifacts.py src/openai_codex tests/test_public_api_signatures.py tests/test_artifact_workflow_and_binaries.py` before the final README-only reductions and review-fix follow-ups. - Built `openai_codex-0.1.0b1-py3-none-any.whl` and `openai_codex-0.1.0b1.tar.gz` before the final README-only reductions and review-fix follow-ups. - Ran `python -m twine check --strict` on both built artifacts before the final README-only reductions and review-fix follow-ups. - Verified artifact metadata reports `Apache-2.0` without a duplicated SDK-local license file. - Verified `inspect.getdoc(...)` resolves documentation for the package, `Codex`, `CodexConfig`, and key generated thread methods. - Rebased the documentation/readiness change onto merged PR #24828 without changing the intended SDK or workflow file contents. - Final verification is delegated to online CI for this PR. * Treat refresh_token_reused 400s as relogin-required (#24830) ## Summary - classify known refresh-token terminal failures from `/oauth/token` as permanent even when the backend returns `400` - preserve the existing relogin-required message for `refresh_token_reused` instead of retrying and collapsing into a generic cloud requirements error - add regression coverage for `400 refresh_token_reused` ## Testing - `just fmt` - `cargo test -p codex-login` * [codex] Simplify Python SDK install guidance (#24866) ## Summary - Remove the exact-version install snippet from the PyPI-facing Python SDK README. - Remove the release-selection explanation so the install section presents the standard `pip install openai-codex` path directly. ## Validation - Not run locally; relying on online CI for this documentation-only change. * [codex] Remove Python SDK language classifiers (#24868) ## Summary - Remove the Python language classifiers from the Python SDK package metadata. - Keep `requires-python = ">=3.10"` as the package's interpreter compatibility constraint. - Avoid presenting a curated version-support list in PyPI metadata. ## Validation - Not run locally; relying on online CI for this metadata-only change. ## Release - Land this change before publishing the next Python SDK beta. * [codex] Remove Python SDK beta warning note (#24870) ## Summary - Remove the beta warning callout from the PyPI-facing Python SDK README. - Keep the existing Beta title and install/usage guidance unchanged. ## Validation - Not run locally; relying on online CI for this documentation-only change. ## Release - Land this change before publishing the next Python SDK beta. * [codex] Stage Python SDK beta versions from release tags (#24872) ## Summary - Treat `sdk/python` as a development template with source version `0.0.0-dev`, matching the existing Python runtime packaging pattern. - Have `python-v*` tags supply the published SDK beta version through the existing `stage-sdk --sdk-version` path. - Remove the workflow check requiring a source version bump for each beta release and remove its now-unused host Python setup step. - Keep the reviewed runtime dependency pin at `openai-codex-cli-bin==0.132.0`. - Remove beta-number-specific documentation so it does not need editing for each publish. ## Why The package staging script already writes the release version into the artifact. Requiring the checked-in SDK template version to match every tag adds release-only source churn without changing the package users receive. ## Validation - Not run locally; relying on online CI for this workflow and metadata change. ## Release After this PR lands, publish the next beta by pushing tag `python-v0.1.0b2` from merged `main`. * Move memories root setup out of core config (#24758) ## Why Config loading should not create or write-authorize the memories root just because memory support exists. Memory startup is the code path that actually materializes that tree. ## What - Stop creating the memories root during Config load and remove it from legacy workspace-write projections. - Grant the memories root read access only when the memories feature and use_memories are enabled. - Create the memories root inside memories startup before seeding extension instructions. - Update config and startup tests around the ownership boundary. ## Tests - just fmt - just fix -p codex-core - just fix -p codex-memories-write - just test -p codex-core memory_tool_makes_memories_root_readable_without_creating_or_widening_writes workspace_write_includes_configured_writable_root_once_without_memories_root permission_profile_override_keeps_memories_root_out_of_legacy_projection permissions_profiles_allow_direct_write_roots_outside_workspace_root default_permissions_profile_populates_runtime_sandbox_policy - just test -p codex-memories-write memories_startup_creates_memory_root Note: a broader just test -p codex-core run is not clean in this sandbox; it hit missing test_stdio_server plus seatbelt, realtime, and environment-sensitive failures. The changed config tests above pass. * Stabilize Guardian client cache key handling (#24891) Split from the Guardian prompt cache key change. This PR only updates codex-rs/core/src/client.rs. Validation was not run per request; this branch is expected to rely on the companion split PRs. * Export Guardian prompt cache key helper (#24892) Split from the Guardian prompt cache key change. This PR only updates codex-rs/core/src/guardian/mod.rs. Validation was not run per request; this branch is expected to rely on the companion split PRs. * Add Guardian review prompt cache key (#24893) Split from the Guardian prompt cache key change. This PR only updates codex-rs/core/src/guardian/review_session.rs. Validation was not run per request; this branch is expected to rely on the companion split PRs. * Assert Guardian prompt cache key reuse (#24894) Split from the Guardian prompt cache key change. This PR only updates codex-rs/core/src/guardian/tests.rs. Validation was not run per request; this branch is expected to rely on the companion split PRs. * Thread Guardian cache key through session (#24895) Split from the Guardian prompt cache key change. This PR only updates codex-rs/core/src/session/session.rs. Validation was not run per request; this branch is expected to rely on the companion split PRs. * Use stable Guardian prompt cache keys (#24803) ## Why Guardian review sessions are reusable across forks when their `GuardianReviewSessionReuseKey` is unchanged, but the underlying Responses request was still using the child thread ID as `prompt_cache_key`. That meant forked Guardian reviews that should share cache context produced different cache keys, reducing prompt cache reuse and weakening the reuse invariant. ## What Changed - Adds a `ModelClient` prompt cache key override and uses it for `ResponsesApiRequest.prompt_cache_key`. - Computes Guardian review cache keys as `guardian:`, scoped to the parent thread plus the reuse-sensitive Guardian config. - Wires session construction to apply that override only for Guardian sub-agent sessions. ## Testing - Added coverage that Guardian cache keys are stable for the same parent/reuse key, change when either the parent thread or reuse key changes, fit within the Responses API length limit, and are absent for non-Guardian sessions. - Extended the parallel review test to assert forked Guardian reviews send the same `prompt_cache_key`. * [codex] Fix Guardian argument comment lint (#24902) ## Summary - Add the required `/*parent_thread_id*/` argument comment at the Guardian review session test callsite flagged by CI. ## Validation - `just fmt` - Not run: clippy/tests, per request; CI will cover them. * Fix memories namespace for Responses API tools (#24898) ## Why Dedicated memories tools are exposed through a Responses API namespace tool. The namespace itself has to be a valid tool identifier, so `memories/` can fail validation before the model ever gets a chance to call the memory tools. ## What changed - Changed `MEMORY_TOOLS_NAMESPACE` from `memories/` to `memories`. - Added `memory_tool_namespace_matches_responses_api_identifier` so the namespace stays non-empty and limited to Responses-safe identifier characters. ## Verification - Added unit coverage for the namespace identifier shape in `codex-rs/ext/memories/src/tests.rs`. * Add Guardian review metrics (#24897) ## Why Guardian reviews already emit analytics events, but we do not expose aggregate OpenTelemetry metrics for review volume, latency, token usage, or terminal outcomes. That makes it harder to monitor Guardian behavior during rollouts and to compare review outcomes by source, action type, session kind, model, and failure mode. ## What Changed - Added Guardian review metric names for count, total duration, time to first token, and token usage in `codex-rs/otel`. - Added `core/src/guardian/metrics.rs` to convert `GuardianReviewAnalyticsResult` into sanitized metric tags covering decision, terminal status, failure reason, approval request source, reviewed action, session kind, risk/outcome, model, reasoning effort, and context/truncation state. - Emitted the new metrics from `track_guardian_review` for each terminal Guardian review result. ## Testing - Added `guardian_review_metrics_record_counts_durations_and_token_usage`, which verifies the emitted count, duration, TTFT, token usage histograms, and tag set through the in-memory metrics exporter. * [codex-cli] Refresh near-expiry ChatGPT access tokens before requests (#23546) ## Summary - refresh managed ChatGPT auth during auth resolution when its access token is inside ChatGPT web's five-minute near-expiry window - cover refresh-window decisions while preserving the existing expired-token refresh path ## Why Codex already resolves managed ChatGPT auth before outbound requests and refreshes expired access tokens there. This change adjusts the existing predicate to refresh a still-valid access token once it is within the same five-minute refresh window used by ChatGPT web, avoiding a request with a token about to expire. A cross-process serialization follow-up was explored in #24663 and closed for now; we do not currently suspect cross-process refresh races are a root cause of the refresh errors under investigation. External-token, API-key, and Agent Identity auth modes remain unchanged. ## Validation - `bazel test //codex-rs/login:login-all-test` - `just fmt` runs Rust formatting successfully, then its Python SDK Ruff step cannot install `openai-codex-cli-bin==0.131.0a4` on this Linux environment because no compatible wheel is published. * Add thread start contributor facts (#24915) Summary: add session source and persistent-state availability to ThreadStartInput; populate them from session init; update existing goal test harness constructors. Tests: just fmt; git diff --check. No full tests or clippy run per request. * Add turn error lifecycle contributor (#24916) Summary - Add TurnErrorInput and TurnLifecycleContributor::on_turn_error to the extension API. - Emit the turn-error lifecycle from core turn error paths, including usage limit failures. - Add direct lifecycle coverage for the emitted error facts and stores. Tests - just fmt - git diff --check - Not run: full tests or clippy (per instructions) * [codex] Store pending response items directly (#24865) * [codex] Update OpenAI Docs skill (#24914) ## Summary - update the bundled `openai-docs` system skill to match the latest `openai-docs-plus` content from `skills-internal` - add the cached Codex manual fetch helper and expand the skill routing for Codex self-knowledge - keep the stable local skill identity and labels as `openai-docs` ## Why The built-in OpenAI Docs skill needed to reflect the current upstream guidance from `skills-internal` while preserving the local system-skill name used by Codex. ## Impact Codex now ships the newer OpenAI Docs skill behavior for Codex self-knowledge and manual-first documentation lookups. ## Validation - `just test -p codex-skills` - exact directory diff against transformed `skills-internal` `origin/main` was clean * Add app-server startup benchmark crate (#24651) ## Summary - Add a new `app-server-start-bench` crate to measure app-server startup performance - Wire the benchmark into the workspace and Bazel build so it can be run consistently - Update lockfiles and repo automation to account for the new package * Gate goal tools by thread eligibility (#24925) ## Why Goal tools create and update goal state for a persistent thread. The extension was only checking whether goals were enabled before advertising those tools, which meant they could be surfaced in contexts that should not receive thread goal controls: ephemeral threads without persistent thread state and review subagents. Those sessions can still run the goal extension lifecycle, but the thread tools should only be visible when the current thread can safely use them. ## What changed - Adds a `GoalRuntimeConfig` that separates goal enablement from whether goal tools are available for the current thread. - Computes tool eligibility on thread start from `persistent_thread_state_available` and `SessionSource`, hiding tools for review subagents. - Uses `GoalRuntimeHandle::tools_visible()` when contributing thread tools so enabled runtime state does not automatically imply tool exposure. - Adds backend coverage for hiding goal tools on ephemeral threads and review subagents. ## Testing - Added `goal_tools_hidden_for_ephemeral_threads`. - Added `goal_tools_hidden_for_review_subagents`. * Remove libubsan CI workaround (#24782) It seems that this was added to allow rustc to load proc macros that had been compiled with UBSan enabled, which zig does for debug and `ReleaseSafe` builds. When zig drives the link of the final binary it knows to include the ubsan runtime, but our zig-built artifacts are being linked into a binary whose linking rustc drives. This removes the libubsan workaround we have and replaces it with `-fno-sanitize=undefined` passed to zig. The new argument is passed at the end of zig's args so should take precedence over any earlier arguments from the script's caller. * extension-api: add TurnItemEmitter to tool calls (#24813) ## Why Extension-contributed tools need to emit visible turn items through Codex's normal event and persistence pipeline. ## What - Add `TurnItemEmitter` to extension `ToolCall`s and route the core implementation through `Session::emit_turn_item_*`. - Hold weak session and turn references so retained tool calls cannot keep host state alive. - Provide a no-op emitter for extension test callers. ## Test Plan - `just test -p codex-core -E 'test(passes_turn_fields_and_scoped_turn_item_emitter_to_extension_call)'` --------- Co-authored-by: jif-oai * feat(app-server): include turns page on thread resume (#23534) ## Summary The client currently calls `thread/resume` to establish live updates and immediately follows it with `thread/turns/list` to hydrate recent turns. This lets `thread/resume` return that page directly, eliminating a round trip and the ordering/deduplication gap between the two calls. Experimental clients opt in with `initialTurnsPage: { limit, sortDirection, itemsView }`. The response returns `initialTurnsPage` as a `TurnsPage`, including cursors for paging further back in history. Keeping the controls in a nested opt-in object provides the useful `thread/turns/list` knobs without spreading page-specific parameters across `thread/resume`. ## Verification - `just fmt` - `just write-app-server-schema --experimental` - `just write-app-server-schema` - `cargo test -p codex-app-server-protocol` - `cargo test -p codex-app-server thread_resume_initial_turns_page_matches_requested_turns_list_page --tests` - `cargo test -p codex-app-server thread_resume_rejoins_running_thread_even_with_override_mismatch --tests` - `just fix -p codex-app-server-protocol -p codex-app-server` * Expose MCP server info as part of server status (#24698) # Summary Expose MCP server info via App Server (when available) so apps can render a richer MCP experience * Reap stale multi-agent slots (#24903) ## Summary - Let `close_agent` clean up an agent that is still registered in `AgentRegistry` even when its underlying thread is already missing. - Preserve the explicit-close boundary: for known stale thread-spawn agents, mark the persisted spawn edge `Closed`, then treat `ThreadNotFound` / `InternalAgentDied` as a successful close so the registry slot can be released. - Add a regression for MultiAgentV2 task-name targets where `close_agent("worker")` succeeds after the worker thread has already disappeared. ## Motivation A worker can disappear from `ThreadManager` while its metadata still exists in the root `AgentRegistry`. Before this change, the close tool failed while trying to subscribe to the missing thread status, so it never reached the cleanup path that releases the registered agent slot. With `agents.max_threads = 1`, an explicit close of that stale task-name agent could fail and leave the session unable to spawn a replacement. ## Scope This PR intentionally does not add automatic stale-agent reaping to `spawn_agent`, `resume_agent`, or `list_agents`. A thread being missing from `ThreadManager` is not the same as an explicit close: persisted open spawn edges are still the durable source of truth for resume and task-name ownership until `close_agent` is called. ## Validation - `just test -p codex-core -E 'test(multi_agent_v2_close_agent_reaps_stale_task_name_target) | test(resume_agent_from_rollout_reopens_open_descendants_after_manager_shutdown)'` - `just fix -p codex-core` * Fix extension turn item emitter test event ordering (#24936) ## Why PR #24813 added extension `TurnItemEmitter` coverage and introduced a test that records a conversation history item before asserting extension-emitted turn item events. `record_conversation_items()` also emits a `RawResponseItem` event to observers. The test was reading from the same event receiver and expected the next event to be `ItemStarted`, so the test failed reliably once the setup history item was present. ## What Changed Update `passes_turn_fields_and_scoped_turn_item_emitter_to_extension_call` to consume and assert the expected setup `RawResponseItem` before checking the extension `ItemStarted`, `WebSearchBegin`, `ItemCompleted`, and `WebSearchEnd` events. This is test-only and does not change extension runtime behavior. ## Verification - `cargo nextest run --no-fail-fast -p codex-core tools::handlers::extension_tools::tests::passes_turn_fields_and_scoped_turn_item_emitter_to_extension_call` * [codex] Support ui visibility meta for tools (#24700) ## Summary Adds support for the same ui.visibility metadata as resources [spec](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx#resource-discovery) * chore: add GPT-5.5 to the Amazon Bedrock catalog (#24701) ## Summary Amazon Bedrock should expose GPT-5.5 alongside GPT-5.4, and the Bedrock GPT entries should stay aligned with the canonical bundled OpenAI model metadata instead of carrying a separate hand-written copy that can drift over time. This change will be merged when the model is online. This change: - Adds the Bedrock Mantle model id for `openai.gpt-5.5`. - Builds the Bedrock GPT-5.5 and GPT-5.4 catalog entries from the bundled OpenAI model catalog, then overrides the Bedrock-facing slug, explicit priority, and Bedrock-specific context windows. - Hardcodes both `context_window` and `max_context_window` to `272000` for Bedrock GPT-5.5 and GPT-5.4. - Keeps `openai.gpt-5.5` as the default Bedrock model ahead of `openai.gpt-5.4` and the Bedrock OSS models. * TUI: Unified mentions tweaks + polish mentions rendering (#23363) This change keeps unified @mentions behind the mentions_v2 gate, moves the flag to under-development, and polishes mention rendering/history behavior. It also adds a few small improvements to the mentions feature around mention rendering and history round-tripping for plugin/tool mentions in message edit scenarios. Plugin selections now insert `@` mentions with better casing, and saved history preserves the visible sigil so recalled messages look the same as what the user typed. - Preserves `@` sigils when encoding/decoding mention history for tool/plugin paths. - Improves plugin mention insertion so display names/casing are reflected more cleanly in the composer. - Update composer to render user-entered plugin mentions in the same color as the mentions menu. ALso applies to recalled/edited messages. - Left/right arrows no longer switch unified-mention search modes after an @mention has already been accepted (Ex: arrowing left through a composed message that contains @mentions). - Keeps bound mentions stable around punctuation, so accepted `@` mentions do not reopen the popup and punctuated `$` mentions still persist to cross-session history. **Steps to test** - Ensure mentions_v2 is enabled through configuration or `--enable mentions_v2` - Type `@` in the TUI composer and verify filesystem/plugin/skill results are displayed in the unified mentions menu. - Select a plugin mention from the `@` popup and confirm the inserted text is an `@...` mention with casing, then recall/edit the message and confirm it still renders as `@...`. - Mention a skill and verify that skills still insert as `$skill` mentions rather than `@` mentions. - Verify punctuated mentions such as `@plugin.` and `($skill)` keep their bound mention behavior across editing and history recall. * Revert "Add app-server startup benchmark crate" (#24937) Reverts openai/codex#24651, broke musl job https://github.com/openai/codex/actions/runs/26585495205/job/78330166927 * Wire task completion into thread-idle lifecycle (#24928) ## Why #24744 introduced the thread idle lifecycle hook so idle continuation can be owned by lifecycle contributors instead of hard-coded goal runtime plumbing. Task completion still called `goal_runtime_apply(GoalRuntimeEvent::MaybeContinueIfIdle)` directly, so the post-turn idle transition remained goal-specific and did not notify generic thread lifecycle contributors. ## What Changed - Add `Session::emit_thread_idle_lifecycle_if_idle()` to gate idle emission on both no active turn and no queued trigger-turn mailbox work. - Call that helper when a task clears the active turn, replacing the direct `GoalRuntimeEvent::MaybeContinueIfIdle` path. - Cover the behavior with `codex-core` session tests for emitting after task completion and suppressing idle emission while trigger-turn mailbox work is pending. ## Verification - New tests in `core/src/session/tests.rs` exercise the idle lifecycle emission and trigger-turn mailbox guard. * Add feature-gated standalone image generation extension (#24723) ## Why Add a standalone image generation path that can be exercised independently of hosted Responses image generation, while retaining the hosted tool as fallback unless the extension is actually available to the model. ## What changed - Added the `codex-image-generation-extension` crate with standalone generate/edit execution, prior-image selection for edits, model-visible image output, and local generated-image persistence. - Installed the extension in app-server behind the disabled-by-default `imagegenext` feature and backend eligibility checks. - Updated core tool planning so eligible `image_gen.imagegen` exposure replaces hosted `image_generation`, while unavailable configurations retain hosted fallback. - Added coverage for extension behavior, edit history reuse, feature gating, auth eligibility, and hosted-tool replacement. - The extension is installed through app-server only in this PR; other execution paths retain hosted image generation because hosted replacement occurs only when the standalone executor is actually registered and model-visible. - The initial extension contract intentionally fixes the image model to `gpt-image-2` and uses automatic image parameters. - Native generated-image history/card parity and rollout persistence cleanup are intentionally deferred follow-up work. ## Validation - `just test -p codex-image-generation-extension` - `just test -p codex-features` - `just test -p codex-core hosted_tools_follow_provider_auth_model_and_config_gates` - `just test -p codex-app-server` - `just fix -p codex-image-generation-extension -p codex-features -p codex-core -p codex-app-server` - `just fmt` - `just bazel-lock-update` - `just bazel-lock-check` --------- Co-authored-by: jif-oai * Move Bazel Windows jobs onto codex-runners (#24952) The codex-windows runner group should be much faster than the default GHA runners. Since bazel jobs on windows are frequently the long pole for PRs checks, this will hopefully get people landing a bit faster. * Add `codex app-server --stdio` alias (#24940) ## Summary - Add `--stdio` as a direct alias for `codex app-server --listen stdio://`. - Keep `--stdio` and `--listen` mutually exclusive. - Update the app-server README to document both forms. * fix(tui): prevent repository-configured code execution in /diff (#24954) ## Why `/diff` is intended to display working-tree changes, but its Git invocations honored repository-selected executable helpers. A repository could configure diff/text conversion helpers, clean/process filters, `core.fsmonitor`, or `post-index-change` hooks that execute when a user runs `/diff`. Fixes [PSEC-4395](https://linear.app/openai/issue/PSEC-4395/codex-cli-diff-executes-repository-selected-diff-helpers). ## What Changed - Pass `--no-textconv` and `--no-ext-diff` for tracked and untracked diff generation. - Discover configured `filter..clean` and `.process` entries, then neutralize the selected drivers through structured `GIT_CONFIG_KEY_*` / `GIT_CONFIG_VALUE_*` overrides, including driver names containing `=`. - Run all `/diff` Git probes with `core.fsmonitor=false` and a null `core.hooksPath`. - Use short submodule reporting while ignoring dirty submodule worktrees, since inspecting a checked-out submodule for dirtiness can execute filters from that child repository. This intentionally omits dirty-only submodule markers in order to preserve the non-executing security boundary. - Add real-Git marker tests covering filters, fsmonitor, hooks, and configured helpers inside checked-out submodules. ## How to Test 1. In a repository with ordinary tracked and untracked edits, run `/diff`. 2. Confirm the normal working-tree diff is shown for top-level files. 3. Run the targeted tests below; they configure executable marker helpers for repository filters, fsmonitor, hooks, and a checked-out submodule, then verify `/diff` does not invoke them. 4. Confirm a dirty-only submodule does not cause Codex to enter the submodule and execute its configured helper. Targeted tests: - `just test -p codex-tui get_git_diff_` Validation note: `just test -p codex-tui` runs the new coverage, but this worktree currently also has two unrelated failing guardian tests: `app::tests::update_feature_flags_disabling_guardian_clears_review_policy_and_restores_default` and `app::tests::update_feature_flags_disabling_guardian_clears_manual_review_policy_without_history`. * [codex] Handle PowerShell UTF-8 setup failures (#24949) Fixes #12496. ## Why Windows sandboxed PowerShell commands can run under `ConstrainedLanguage` on some machines, especially enterprise-managed Windows environments. In that mode, our PowerShell command prelude could fail before every command because it directly assigned `[Console]::OutputEncoding` to UTF-8. The actual user command still ran, but Codex surfaced noisy `Cannot set property. Property setting is supported only on core types in this language mode.` output for every shell call. ## What Changed - Makes the PowerShell UTF-8 output encoding prelude best-effort by wrapping the assignment in `try { ... } catch {}`. - Keeps the existing UTF-8 behavior when PowerShell allows the assignment. - Adds focused tests for adding the prelude and avoiding duplicate prelude insertion. ## Validation - `cargo fmt -p codex-shell-command` - `cargo check -p codex-shell-command` - `git diff --check` - Verified a local `ConstrainedLanguage` PowerShell probe prints only the command output with no property-setting error. - Verified `codex exec` from a temporary `chcp 437` context reports `utf-8` / `65001` and preserves non-ASCII output (`café`, `漢字`). * [codex] Remove Bedrock OSS models from catalog (#24960) Remove the GPT OSS 120B and 20B entries from the Amazon Bedrock static model catalog, as they are no longer supported. * runtime: prepend zsh fork bin dir to PATH (#23768) ## Why #23756 makes packaged Codex builds include and default to the bundled zsh fork. The important reason to put that fork's directory at the front of `PATH` is to keep executable-level escalation working after a command leaves the original shell and later re-enters zsh through `env`. The expected chain is: 1. The zsh fork runs the top-level shell command. 2. That command launches another program, such as `python3`, while inheriting the `EXEC_WRAPPER` environment and the escalation socket fd. 3. That program spawns a shell script whose shebang is `#!/usr/bin/env zsh` rather than `#!/bin/zsh`, and it does not close the escalation fd. 4. `/usr/bin/env` resolves `zsh` through `PATH`, so it must find the packaged zsh fork before the system zsh. 5. Commands inside that nested script are intercepted by the zsh fork and can still request escalation from Codex. If `PATH` resolves `zsh` to the system shell instead, the nested script loses zsh-fork exec interception. Commands that should request escalation can then run only in the original sandbox, or fail there, without Codex ever receiving the approval request. Shell snapshots make this slightly more subtle: a snapshot can restore an older `PATH` after the child shell starts. This PR treats the zsh fork `PATH` prepend as an explicit environment override so snapshot wrapping preserves it. ## What Changed - Added shared zsh-fork runtime helpers that prepend the configured zsh executable parent directory to `PATH` without duplicate entries. - Applied the zsh fork `PATH` prepend to both zsh-fork `shell_command` launches and unified-exec zsh-fork launches before sandbox command construction. - Kept the shell-command zsh-fork backend API narrow: it derives the configured zsh path from session services and rebuilds its sandbox environment from `req.env`, rather than accepting a second, competing environment map or a separately threaded bin dir. - Kept Unix-only zsh-fork `PATH` mutation out of Windows clippy-visible mutability. - Added coverage for duplicate `PATH` entries, for preserving the zsh fork prepend through shell snapshot wrapping, and for the nested `python3` -> `#!/usr/bin/env zsh` escalation flow. ## Testing - `just fmt` - `just fix -p codex-core` I left final test validation to CI after the latest review-comment cleanup. Before that cleanup, `just test -p codex-core zsh_fork` passed locally for the zsh-fork-focused tests. * Release 0.136.0-alpha.1 * Seed Termux release automation * Termux rust-v0.136.0-alpha.1 (#175) * Release 0.132.0-alpha.1 * ## 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 * Apply Termux compatibility patch * Disable realtime audio on Android builds (cherry picked from commit 337303c72c5c624386937c5f2aa9dc3a8dcfa2b4) * Update Termux v8 dependency * Release 0.133.0-alpha.1 * Seed Termux release automation * Prepare Termux rust-v0.132.0 * Seed Termux release automation * Prepare Termux rust-v0.133.0-alpha.1 * Release 0.133.0-alpha.3 * Seed Termux release automation * Prepare Termux rust-v0.133.0-alpha.3 * ## 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 * Seed Termux release automation * Prepare Termux rust-v0.133.0 * Release 0.134.0-alpha.2 * Seed Termux release automation * Prepare Termux rust-v0.134.0-alpha.2 * Release 0.134.0-alpha.3 * Seed Termux release automation * Prepare Termux rust-v0.134.0-alpha.3 * ## New Features - Added search across local conversation history, including case-insensitive content matches with result previews. (#23519, #23921) - Made `--profile` the primary profile selector across CLI, TUI permissions, and sandbox flows, with legacy profile configs rejected through migration guidance. (#23708, #23883, #23890, #24051, #24055, #24059, #24067, #24110) - Improved MCP setup with per-server environment targeting and OAuth options for streamable HTTP servers. (#23583, #24120) - Made connector tool schemas more reliable by preserving local `$ref`/`$defs` structures and compacting oversized schemas before exposure. (#23357, #23904) - Let read-only MCP tools run concurrently when they advertise `readOnlyHint`. (#23750) - Added richer extension and hook context, including conversation history for extension tools and subagent identity in hook inputs. (#22882, #23963) ## Bug Fixes - Improved remote reliability by reconnecting stale exec-server websocket clients, retrying remote control immediately after auth recovery, and retrying remote compaction v2 streams. (#23867, #23775, #23951) - Fixed Windows TUI rendering corruption by restoring virtual terminal mode before drawing. (#24082) - Displayed workspace-specific usage-limit messages for credit and spend-cap failures. (#24114) - Allowed plugin skills to reuse shared plugin-level icon assets. (#23776) - Preserved active permission profile metadata when syncing auto-review runtime settings. (#23956) - Ensured Node-based tools honor Codex’s managed network proxy environment. (#23905) ## Documentation - Documented the curl and PowerShell installer paths in the README. (#24106) - Updated developer docs to prefer `just test` over direct `cargo test` for repo-local test runs. (#23910) - Added profile migration documentation links to relevant config errors. (#23879) ## Chores - Simplified release packaging around canonical native artifacts, reusable DotSlash fetching, and a new macOS x64 zsh artifact. (#23833, #23836, #24129, #24165) - Added release-build support for Codex-produced V8 artifacts. (#23934) - Added image re-encoding benchmarks and connector-style JSON schema policy fixtures. (#23935, #24152) - Improved tracing and analytics for websocket requests, turn starts, and remote compaction v2. (#23581, #23980, #24146) ## Changelog Full Changelog: https://github.com/openai/codex/compare/rust-v0.133.0...rust-v0.134.0 - #23581 Trace logical websocket request after untraced warmup @jif-oai - #23718 [codex] Steer budget-limited goal extension turns @jif-oai - #23861 fix: cargo lock @jif-oai - #23728 feat: retain remote compaction truncation parity in v2 @jif-oai - #23870 Make tool executor specs mandatory @jif-oai - #23882 [codex] Stabilize subagent start hook test @jif-oai - #23876 refactor: centralize tool exposure planning @jif-oai - #23879 chore: link doc in profile error messages @jif-oai - #23883 cli: rename profile v2 flag to --profile @jif-oai - #23835 docs: add description to codex-cli/package.json @bolinfest - #23583 Route MCP servers through explicit environments @starr-openai - #23886 cli: remove legacy profile v1 plumbing @jif-oai - #23708 tui: plumb permission profile selection @viyatb-oai - #23833 packaging: move rg manifest out of npm bin @bolinfest - #23796 Improve `/goal` error messages for ephemeral sessions @etraut-openai - #23867 Reconnect disconnected exec-server websocket clients with fresh sessions @starr-openai - #23792 TUI: skip goal replace prompt for completed goals @etraut-openai - #23519 [codex] Add rollout-backed thread content search @fc-oai - #22552 Remove plugin hooks feature flag @abhinav-oai - #23836 npm: remove legacy package artifact synthesis @bolinfest - #23921 [codex] Make thread search case-insensitive @fc-oai - #23775 fix(remote-control): retry after auth recovery @apanasenko-oai - #22882 Add subagent identity to hook inputs @abhinav-oai - #22915 [3 of 4] tui: route feature and memory toggles through app server @etraut-openai - #23776 fix: Allow plugin skills to share plugin-level icon assets @xl-openai - #23860 Add Bedrock Mantle GovCloud region @CHARLESPALEN-OAI - #23956 Fix auto-review permission profile override @etraut-openai - #23357 feat: support local refs and defs in tool input schemas @celia-oai - #23963 Expose conversation history to extension tools @sayan-oai - #23904 feat: best-effort compact large tool schemas @celia-oai - #23750 Allow parallel MCP tool calls when annotated readOnly @anp-oai - #23905 [codex] Enable Node env proxy for managed network proxy @rreichel3-oai - #23890 mcp: surface profile migration guidance under --profile @jif-oai - #24051 config: remove legacy profile v1 resolution @jif-oai - #24055 config: remove legacy profile write paths @jif-oai - #24057 Avoid config snapshots in live agent subtree traversal @jif-oai - #24061 otel: drop legacy profile usage telemetry @jif-oai - #24059 fix: reject legacy profile selectors @jif-oai - #23934 ci: Use codex produced v8 artifacts for release builds @cconger - #24099 fix(app-server): fix optional bool annotations @owenlin0 - #23910 Prefer `just test` over `cargo test` in docs @anp-oai - #23951 retry remote compaction v2 requests @rhan-oai - #24081 tui: make `codex-tui.log` opt-in @jif-oai - #24102 cli: infer host sandbox backend @bolinfest - #24067 app-server: drop legacy profile config surface @jif-oai - #23736 Add new enterprise requirement gate @adams-oai - #24117 [codex] Use rolling files for Windows sandbox logs @iceweasel-oai - #24106 docs: update README.md to mention curl-based installer @bolinfest - #24082 fix(tui): restore Windows VT before TUI renders @fcoury-oai - #24110 cli: support --profile for codex sandbox @bolinfest - #23980 Add trace_id to TurnStartedEvent @mchen-oai - #24120 Support OAuth options in codex mcp add @mzeng-openai - #23989 Add typed Images client to codex-api @won-openai - #24146 [codex-analytics] split compaction v2 analytics implementation @rhan-oai - #24129 package: factor DotSlash executable fetching @bolinfest - #24151 [codex] Use TurnInput for session task input @pakrym-oai - #23935 [codex] Add image re-encoding benchmarks @anp-oai - #24152 chore: add JSON schema policy fixture coverage @celia-oai - #24157 [codex] Remove external client session reset plumbing @pakrym-oai - #24114 Display workspace usage limit error copy from response header @dhruvgupta-oai - #24165 release: build macOS x64 zsh artifact @bolinfest * Seed Termux release automation * Prepare Termux rust-v0.134.0 * Release 0.135.0-alpha.2 * Seed Termux release automation * Prepare Termux rust-v0.135.0-alpha.2 * ## New Features - `codex doctor` now reports richer environment, Git, terminal, app-server, and thread inventory diagnostics for support cases. (#24261, #24311, #24305) - `/status` shows remote connection details and server version when the TUI is connected over a remote transport. (#24420) - Vim mode gained text-object editing, improved word/line-end behavior, and a configurable interrupt-turn binding. (#24382, #24380, #24766) - `/permissions` now understands named permission profiles and displays configured custom profiles. (#21559) - Packaged Codex builds can discover and use the bundled patched zsh helper across supported macOS and Linux targets. (#23756, #24171) - The Python SDK now exposes friendly `Sandbox` presets for thread and turn APIs. (#24772) ## Bug Fixes - Markdown tables and multiline lists render more readably in the TUI, with better column sizing and app-style table formatting. (#24489, #24346, #24351) - TUI output is more stable on macOS and Zellij, avoiding stderr/composer corruption and raw-output overlap. (#24459, #24479, #24593) - Slash-command completion now preserves existing draft text for commands that accept inline arguments. (#23950) - Older tmux/iTerm control-mode sessions no longer lose normal `Ctrl-C` handling from unsupported keyboard enhancement setup. (#24371) - App mentions now exclude inaccessible or disabled apps instead of offering unusable `$` suggestions. (#24625) - Resume flows now include non-interactive exec sessions when requested and honor cwd overrides for idle cached threads. (#24503, #24528) ## Documentation - Clarified image-viewing tool detail behavior and removed stale TUI composer documentation references. (#23949, #24641) - Updated Python SDK docs, examples, and notebook content to use the new sandbox preset API. (#24772) ## Chores - Updated Rust toolchain pins and SQLx/SQLite dependencies. (#24684, #24728) - Moved memory runtime state into a dedicated SQLite database. (#24591) - Removed remaining legacy config-profile consumers and routed more TUI config/plugin state through app-server-owned APIs. (#24076, #24254, #24255, #24265, #24266, #24257) - Centralized Responses retry handling and MCP tool naming logic to reduce duplicated internal plumbing. (#24131, #21576) ## Changelog Full Changelog: https://github.com/openai/codex/compare/rust-v0.134.0...rust-v0.135.0 - #24164 fix(remote-control): cap reconnect backoff @apanasenko-oai - #23756 package: include zsh fork in Codex package @bolinfest - #23757 Default function tools into tool hooks @abhinav-oai - #24171 package: add x64 macOS codex-zsh artifact @bolinfest - #24159 code-mode: merge stored values by key @cconger - #23983 fix: plugin bundle archive handling for upload and install @xl-openai - #24261 feat(doctor): add environment diagnostics @fcoury-oai - #24311 Report app-server version in codex doctor @etraut-openai - #24314 tui: label compact rate-limit percentages @etraut-openai - #24420 Show remote connection details in /status @etraut-openai - #24317 Respect hook trust bypass during TUI startup @etraut-openai - #24254 TUI config cleanup: oss_provider @etraut-openai - #24255 TUI config cleanup: trusted projects @etraut-openai - #24265 TUI config cleanup: MCP inventory @etraut-openai - #24305 Add doctor thread inventory audit @etraut-openai - #24346 fix(tui): improve markdown table column allocation @fcoury-oai - #24351 fix(tui): improve multiline markdown list readability @fcoury-oai - #24459 fix(tui): prevent macos stderr from corrupting composer @fcoury-oai - #24479 fix(process-hardening): preserve macos malloc diagnostics @fcoury-oai - #24474 Log rollout writer OS errors @etraut-openai - #24076 chore: stop consuming legacy config profiles @jif-oai - #24131 centralize Responses retry policy @rhan-oai - #23858 [wip] goal shift @jif-oai - #24555 chore: drop orphaned codex memories MCP crate @jif-oai - #24558 chore: move memory prompt builder into extension @jif-oai - #24562 Add ad-hoc memory note tool @jif-oai - #24567 Wire metrics client into memories extension @jif-oai - #24588 fix: drop flake @jif-oai - #24583 Add memory tool call metrics to memories extension @jif-oai - #24586 Wire app-server extension event sink @jif-oai - #24532 Use thread config for TUI MCP inventory @etraut-openai - #24105 [codex] Make active turn task singular @pakrym-oai - #21576 Move MCP tool naming mode into manager @pakrym-oai - #24503 tui: include exec sessions in resume list @etraut-openai - #24600 feat: gate dedicated memories tools in config @jif-oai - #21559 tui: add named permission profile picker @viyatb-oai - #24608 feat: add manual and remote_v2 tags to compaction metric @jif-oai - #24611 test: clean up apply_patch allow-session artifact @jif-oai - #24609 Remove reserved namespaces dedup @pakrym-oai - #23964 Move slash input logic out of chat composer @canvrno-oai - #24615 Add goal extension telemetry parity @jif-oai - #24371 fix(tui): avoid modifyOtherKeys for unknown tmux formats @fcoury-oai - #24626 fix: restore goal accounting after thread resume @jif-oai - #24591 Move memory state to a dedicated SQLite DB @jif-oai - #23823 standalone websearch extension @sayan-oai - #24593 fix(tui): keep raw output above composer in zellij @fcoury-oai - #24625 tui: keep inaccessible apps out of mentions @canvrno-oai - #24154 Add experimental turn additional context @pakrym-oai - #24473 fix(remote-control): surface websocket task stalls @apanasenko-oai - #24528 Respect resume cwd overrides for idle cached threads @etraut-openai - #24160 Add forked_from_thread_id turn metadata @owenlin0 - #24646 make direct only allowed caller for standalone websearch @sayan-oai - #23949 Clarify view_image tool description @fjord-oai - #24266 TUI config cleanup: plugin mentions @etraut-openai - #24320 Avoid repeated marketplace upgrades for alternate layouts @etraut-openai - #23813 windows-sandbox: remove SandboxPolicy runner plumbing @bolinfest - #24652 [codex] remove plain image wrapper spans @pakrym-oai - #24623 Attach Windows sandbox log to feedback reports @iceweasel-oai - #24644 Restore legacy image detail values @rhan-oai - #24655 [codex-analytics] add grouped session id to runtime events @marksteinbrick-oai - #24658 [codex] Remove obsolete goal continuation turn marker @pakrym-oai - #24660 fix: dont compact standalone websearch schema @sayan-oai - #24667 fix(core): instrument stalled tool-listing handoff @apanasenko-oai - #24684 Uprev Rust toolchain pins to 1.95.0 @anp-oai - #21567 fix: add noninteractive install script mode @efrazer-oai - #24707 Allow runtime enablement for remote plugins @xl-openai - #24714 fix(auto-review) skip legacy notify for auto review threads @dylan-hurd-oai - #24690 Revert "Add Bedrock Mantle GovCloud region (#23860)" @celia-oai - #24628 feat: handle goal usage limits in goal extension @jif-oai - #24746 Fix guardian review test user input @jif-oai - #24744 feat: add thread idle lifecycle hook @jif-oai - #24751 Drop startup context when truncating forked rollouts @jif-oai - #24257 TUI config cleanup: plugin marketplace @etraut-openai - #24380 fix(tui): complete vim word-end and line-end behavior @fcoury-oai - #24728 Bump SQLx to pick up newer bundled SQLite @jif-oai - #24637 fix: run standalone updates noninteractively @efrazer-oai - #24778 make vercel webhook url an env secret @sayan-oai - #23950 fix: Preserve draft text when completing argument-taking slash commands @canvrno-oai - #24641 [codex] Remove stale composer narrative doc references @canvrno-oai - #24368 [codex] add compaction metadata to turn headers @ningyi-oai - #24772 [codex] Add friendly Python SDK sandbox presets @aibrahim-oai - #24382 feat(tui): add vim text object bindings @fcoury-oai - #24766 feat(tui): make turn interruption keybind configurable @fcoury-oai - #24489 feat(tui): render markdown tables in app style [1 of 2] @fcoury-oai - #24713 chore: enable namespace tools for Bedrock @celia-oai * Seed Termux release automation * Prepare Termux rust-v0.135.0 * Prepare Termux rust-v0.136.0-alpha.1 --------- Co-authored-by: Shijie Rao Co-authored-by: wallentx Co-authored-by: William Allen Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Celia Chen * checkpoint: prepare release/0.136.0 for wallentx/termux-target --------- Co-authored-by: viyatb-oai Co-authored-by: Felipe Coury Co-authored-by: Steve Coffey Co-authored-by: Adam Perry @ OpenAI Co-authored-by: sayan-oai Co-authored-by: Ahmed Ibrahim Co-authored-by: alexsong-oai Co-authored-by: jif-oai Co-authored-by: cooper-oai Co-authored-by: pakrym-oai Co-authored-by: Vaibhav Srivastav Co-authored-by: Brent Traut Co-authored-by: Gabriel Peal Co-authored-by: Michael Bolin Co-authored-by: Celia Chen Co-authored-by: canvrno-oai Co-authored-by: Won Park Co-authored-by: iceweasel-oai Co-authored-by: Shijie Rao Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: unemployabot[bot] <277198073+unemployabot[bot]@users.noreply.github.com> Co-authored-by: wallentx Co-authored-by: William Allen --- MODULE.bazel.lock | 13 +- codex-rs/Cargo.lock | 248 +++++-- codex-rs/Cargo.toml | 6 +- .../analytics/src/analytics_client_tests.rs | 1 + codex-rs/analytics/src/client_tests.rs | 1 + .../schema/json/ClientRequest.json | 36 + .../codex_app_server_protocol.schemas.json | 113 +++ .../codex_app_server_protocol.v2.schemas.json | 113 +++ .../json/v2/ListMcpServerStatusResponse.json | 51 ++ .../schema/json/v2/ThreadResumeParams.json | 68 ++ .../schema/json/v2/ThreadResumeResponse.json | 26 + .../schema/typescript/McpServerInfo.ts | 9 + .../schema/typescript/index.ts | 1 + .../schema/typescript/v2/McpServerStatus.ts | 3 +- .../v2/ThreadResumeInitialTurnsPageParams.ts | 19 + .../schema/typescript/v2/TurnsPage.ts | 6 + .../schema/typescript/v2/index.ts | 2 + .../src/protocol/v2/mcp.rs | 3 + .../src/protocol/v2/tests.rs | 156 ++++ .../src/protocol/v2/thread.rs | 43 ++ codex-rs/app-server/Cargo.toml | 1 + codex-rs/app-server/README.md | 24 +- codex-rs/app-server/src/extensions.rs | 3 +- codex-rs/app-server/src/request_processors.rs | 1 + .../src/request_processors/mcp_processor.rs | 2 + .../request_processors/thread_lifecycle.rs | 23 +- .../request_processors/thread_processor.rs | 172 +++-- .../thread_processor_tests.rs | 1 + .../thread_resume_redaction.rs | 12 +- codex-rs/app-server/src/thread_state.rs | 2 + .../app-server/tests/suite/v2/app_list.rs | 72 +- .../app-server/tests/suite/v2/mcp_resource.rs | 37 +- .../tests/suite/v2/mcp_server_elicitation.rs | 7 +- .../tests/suite/v2/mcp_server_status.rs | 22 +- .../app-server/tests/suite/v2/mcp_tool.rs | 5 +- .../tests/suite/v2/plugin_install.rs | 5 +- .../app-server/tests/suite/v2/plugin_read.rs | 5 +- .../tests/suite/v2/remote_thread_store.rs | 1 - .../app-server/tests/suite/v2/thread_read.rs | 72 ++ .../tests/suite/v2/thread_resume.rs | 121 +-- codex-rs/cli/Cargo.toml | 1 + codex-rs/cli/src/main.rs | 139 +++- codex-rs/codex-mcp/src/codex_apps.rs | 81 +- codex-rs/codex-mcp/src/connection_manager.rs | 73 +- .../codex-mcp/src/connection_manager_tests.rs | 209 +++++- codex-rs/codex-mcp/src/lib.rs | 1 + codex-rs/codex-mcp/src/mcp/mod.rs | 13 +- codex-rs/codex-mcp/src/rmcp_client.rs | 124 ++-- codex-rs/core/config.schema.json | 6 + codex-rs/core/src/agent/control.rs | 62 +- codex-rs/core/src/agent/control_tests.rs | 54 -- codex-rs/core/src/client.rs | 18 +- codex-rs/core/src/codex_thread.rs | 85 +-- codex-rs/core/src/compact.rs | 2 +- codex-rs/core/src/config/config_tests.rs | 91 ++- codex-rs/core/src/config/mod.rs | 87 +-- codex-rs/core/src/connectors_tests.rs | 38 +- codex-rs/core/src/exec.rs | 53 +- codex-rs/core/src/exec_tests.rs | 109 +++ codex-rs/core/src/goals.rs | 33 +- codex-rs/core/src/guardian/metrics.rs | 418 +++++++++++ codex-rs/core/src/guardian/mod.rs | 2 + codex-rs/core/src/guardian/review.rs | 24 +- codex-rs/core/src/guardian/review_session.rs | 59 +- codex-rs/core/src/guardian/tests.rs | 34 +- codex-rs/core/src/hook_runtime.rs | 7 +- codex-rs/core/src/mcp_tool_call_tests.rs | 14 +- codex-rs/core/src/mcp_tool_exposure.rs | 8 +- codex-rs/core/src/mcp_tool_exposure_test.rs | 105 ++- codex-rs/core/src/session/handlers.rs | 15 +- codex-rs/core/src/session/inject.rs | 50 ++ codex-rs/core/src/session/input_queue.rs | 59 +- codex-rs/core/src/session/mod.rs | 117 +-- codex-rs/core/src/session/session.rs | 8 + codex-rs/core/src/session/tests.rs | 374 ++++++++-- codex-rs/core/src/session/turn.rs | 24 +- codex-rs/core/src/tasks/lifecycle.rs | 36 + codex-rs/core/src/tasks/mod.rs | 36 +- codex-rs/core/src/tasks/review.rs | 2 +- codex-rs/core/src/tasks/user_shell.rs | 31 +- codex-rs/core/src/thread_manager.rs | 13 - codex-rs/core/src/tools/code_mode/mod.rs | 4 +- .../src/tools/handlers/extension_tools.rs | 116 ++- codex-rs/core/src/tools/handlers/mcp.rs | 16 +- .../list_mcp_resource_templates.rs | 7 +- .../mcp_resource/list_mcp_resources.rs | 7 +- .../mcp_resource/read_mcp_resource.rs | 8 +- .../src/tools/handlers/mcp_search_tests.rs | 17 +- .../handlers/multi_agents/close_agent.rs | 12 +- .../src/tools/handlers/multi_agents_tests.rs | 121 +++ .../handlers/multi_agents_v2/close_agent.rs | 12 +- codex-rs/core/src/tools/handlers/shell.rs | 4 + .../src/tools/handlers/shell/shell_command.rs | 6 + .../core/src/tools/handlers/tool_search.rs | 16 +- codex-rs/core/src/tools/parallel.rs | 193 ++++- codex-rs/core/src/tools/registry.rs | 71 +- codex-rs/core/src/tools/router.rs | 6 + codex-rs/core/src/tools/router_tests.rs | 18 +- codex-rs/core/src/tools/runtimes/mod.rs | 40 + codex-rs/core/src/tools/runtimes/mod_tests.rs | 93 +++ codex-rs/core/src/tools/runtimes/shell.rs | 19 +- .../tools/runtimes/shell/unix_escalation.rs | 4 +- .../core/src/tools/runtimes/unified_exec.rs | 17 +- codex-rs/core/src/tools/spec_plan.rs | 39 +- codex-rs/core/src/tools/spec_plan_tests.rs | 26 +- .../core/tests/common/apps_test_server.rs | 58 +- codex-rs/core/tests/suite/approvals.rs | 162 ++++ codex-rs/core/tests/suite/code_mode.rs | 90 +++ codex-rs/core/tests/suite/search_tool.rs | 78 ++ codex-rs/core/tests/suite/sqlite_state.rs | 137 +++- codex-rs/exec-server/README.md | 10 + codex-rs/exec-server/src/remote.rs | 39 +- .../ext/extension-api/src/contributors.rs | 4 + .../src/contributors/thread_lifecycle.rs | 5 + .../src/contributors/turn_lifecycle.rs | 15 + codex-rs/ext/extension-api/src/lib.rs | 5 + codex-rs/ext/goal/src/extension.rs | 15 +- codex-rs/ext/goal/src/runtime.rs | 15 +- .../ext/goal/tests/goal_extension_backend.rs | 59 ++ codex-rs/ext/image-generation/BUILD.bazel | 9 + codex-rs/ext/image-generation/Cargo.toml | 37 + .../image-generation/imagegen_description.md | 11 + codex-rs/ext/image-generation/src/backend.rs | 60 ++ .../ext/image-generation/src/extension.rs | 99 +++ codex-rs/ext/image-generation/src/lib.rs | 8 + codex-rs/ext/image-generation/src/tests.rs | 341 +++++++++ codex-rs/ext/image-generation/src/tool.rs | 395 ++++++++++ codex-rs/ext/memories/src/lib.rs | 2 +- codex-rs/ext/memories/src/metrics.rs | 2 +- codex-rs/ext/memories/src/tests.rs | 17 + codex-rs/features/src/lib.rs | 14 +- codex-rs/features/src/tests.rs | 14 + codex-rs/login/src/auth/manager.rs | 19 +- codex-rs/login/tests/suite/auth_refresh.rs | 163 +++++ codex-rs/mcp-server/src/codex_tool_config.rs | 37 +- codex-rs/mcp-server/src/codex_tool_runner.rs | 19 +- codex-rs/mcp-server/src/message_processor.rs | 105 +-- codex-rs/mcp-server/src/outgoing_message.rs | 2 +- .../mcp-server/tests/common/mcp_process.rs | 48 +- codex-rs/memories/write/src/start.rs | 4 + codex-rs/memories/write/src/startup_tests.rs | 30 + codex-rs/model-provider-info/src/lib.rs | 1 + .../src/amazon_bedrock/catalog.rs | 189 ++--- codex-rs/model-provider/src/provider.rs | 11 +- codex-rs/otel/src/metrics/names.rs | 4 + codex-rs/protocol/src/mcp.rs | 12 + codex-rs/rmcp-client/Cargo.toml | 5 +- .../rmcp-client/src/bin/rmcp_test_server.rs | 16 +- .../rmcp-client/src/bin/test_stdio_server.rs | 51 +- .../src/bin/test_streamable_http_server.rs | 43 +- .../rmcp-client/src/http_client_adapter.rs | 28 +- .../http_client_adapter/www_authenticate.rs | 233 ++++++ .../www_authenticate_tests.rs | 124 ++++ codex-rs/rmcp-client/src/oauth.rs | 10 +- .../rmcp-client/src/perform_oauth_login.rs | 18 +- codex-rs/rmcp-client/src/rmcp_client.rs | 35 +- .../tests/process_group_cleanup.rs | 25 +- codex-rs/rmcp-client/tests/resources.rs | 42 +- .../tests/streamable_http_recovery.rs | 87 ++- .../tests/streamable_http_test_support.rs | 54 +- codex-rs/rollout/src/metadata.rs | 20 - codex-rs/rollout/src/state_db.rs | 47 -- codex-rs/shell-command/src/powershell.rs | 38 +- .../src/assets/samples/openai-docs/SKILL.md | 115 ++- .../samples/openai-docs/agents/openai.yaml | 4 +- .../scripts/fetch-codex-manual.mjs | 598 +++++++++++++++ codex-rs/state/src/runtime.rs | 1 - codex-rs/state/src/runtime/threads.rs | 102 --- .../src/local/update_thread_metadata.rs | 9 - .../thread-store/src/thread_metadata_sync.rs | 7 - codex-rs/thread-store/src/types.rs | 6 - codex-rs/tools/src/lib.rs | 4 + codex-rs/tools/src/mcp_tool_tests.rs | 16 +- codex-rs/tools/src/responses_api_tests.rs | 16 +- codex-rs/tools/src/tool_call.rs | 58 +- .../tests/json_schema_policy_fixtures.rs | 16 +- codex-rs/tui/src/app.rs | 4 +- codex-rs/tui/src/app/background_requests.rs | 2 + codex-rs/tui/src/app/resize_reflow.rs | 45 +- codex-rs/tui/src/app/tests.rs | 12 +- codex-rs/tui/src/app_backtrack.rs | 14 +- codex-rs/tui/src/app_server_session.rs | 1 + codex-rs/tui/src/bottom_pane/app_link_view.rs | 11 +- codex-rs/tui/src/bottom_pane/chat_composer.rs | 692 ++++++++++++++++-- .../bottom_pane/chat_composer/draft_state.rs | 1 + .../src/bottom_pane/chat_composer_history.rs | 96 ++- codex-rs/tui/src/bottom_pane/feedback_view.rs | 4 +- .../src/bottom_pane/memories_settings_view.rs | 1 + .../bottom_pane/mentions_v2/search_catalog.rs | 106 ++- codex-rs/tui/src/bottom_pane/mod.rs | 4 +- ...at_mentions_render_with_plugin_accent.snap | 6 + codex-rs/tui/src/chatwidget.rs | 23 +- .../tui/src/chatwidget/input_submission.rs | 1 + .../chatwidget/tests/composer_submission.rs | 3 + .../tui/src/chatwidget/tests/plan_mode.rs | 1 + .../chatwidget/tests/popups_and_settings.rs | 2 +- .../tui/src/chatwidget/tests/review_mode.rs | 1 + .../src/chatwidget/tests/slash_commands.rs | 3 + codex-rs/tui/src/get_git_diff.rs | 493 +++++++++++-- codex-rs/tui/src/history_cell/base.rs | 61 ++ codex-rs/tui/src/history_cell/mcp.rs | 9 +- codex-rs/tui/src/history_cell/messages.rs | 67 +- codex-rs/tui/src/history_cell/mod.rs | 39 +- codex-rs/tui/src/history_cell/notices.rs | 16 + codex-rs/tui/src/history_cell/plans.rs | 57 +- codex-rs/tui/src/history_cell/tests.rs | 43 ++ codex-rs/tui/src/insert_history.rs | 71 +- codex-rs/tui/src/lib.rs | 1 + codex-rs/tui/src/markdown.rs | 19 +- codex-rs/tui/src/markdown_render.rs | 541 +++++++++++--- .../src/markdown_render/table_key_value.rs | 283 +++++++ codex-rs/tui/src/markdown_render_tests.rs | 89 ++- codex-rs/tui/src/mention_codec.rs | 347 ++++++++- codex-rs/tui/src/onboarding/auth.rs | 60 +- codex-rs/tui/src/pager_overlay.rs | 94 ++- ...one_compact_record_fragments_snapshot.snap | 12 + ...ct_fragmentation_is_systemic_snapshot.snap | 29 + ...le_prose_columns_are_starved_snapshot.snap | 36 + ...th_column_becomes_too_narrow_snapshot.snap | 40 + codex-rs/tui/src/status/card.rs | 39 +- codex-rs/tui/src/status/tests.rs | 19 + codex-rs/tui/src/streaming/controller.rs | 150 ++-- codex-rs/tui/src/streaming/mod.rs | 16 +- codex-rs/tui/src/terminal_hyperlinks.rs | 627 ++++++++++++++++ codex-rs/tui/src/tui.rs | 17 +- codex-rs/tui/src/update_prompt.rs | 7 +- codex-rs/utils/pty/src/process_group.rs | 8 +- sdk/python-runtime/pyproject.toml | 1 - sdk/python/README.md | 132 +--- sdk/python/docs/api-reference.md | 17 +- sdk/python/docs/faq.md | 21 +- sdk/python/docs/getting-started.md | 156 ++-- sdk/python/examples/README.md | 34 +- sdk/python/examples/_bootstrap.py | 6 +- sdk/python/pyproject.toml | 26 +- sdk/python/scripts/update_sdk_artifacts.py | 84 +-- sdk/python/src/openai_codex/__init__.py | 26 +- sdk/python/src/openai_codex/_inputs.py | 10 + sdk/python/src/openai_codex/_login.py | 16 +- .../src/openai_codex/_message_router.py | 4 +- sdk/python/src/openai_codex/_run.py | 6 +- sdk/python/src/openai_codex/api.py | 67 +- sdk/python/src/openai_codex/async_client.py | 18 +- sdk/python/src/openai_codex/client.py | 38 +- sdk/python/src/openai_codex/errors.py | 34 +- .../src/openai_codex/generated/v2_all.py | 306 ++++---- sdk/python/src/openai_codex/types.py | 2 +- sdk/python/tests/app_server_harness.py | 6 +- sdk/python/tests/test_app_server_login.py | 4 +- sdk/python/tests/test_app_server_streaming.py | 2 +- .../test_artifact_workflow_and_binaries.py | 156 ++-- .../tests/test_async_client_behavior.py | 6 +- sdk/python/tests/test_client_rpc_methods.py | 18 +- sdk/python/tests/test_contract_generation.py | 2 +- .../tests/test_public_api_runtime_behavior.py | 2 +- .../tests/test_public_api_signatures.py | 48 +- sdk/python/uv.lock | 25 +- 257 files changed, 11887 insertions(+), 2975 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/McpServerInfo.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeInitialTurnsPageParams.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/TurnsPage.ts create mode 100644 codex-rs/core/src/guardian/metrics.rs create mode 100644 codex-rs/core/src/session/inject.rs create mode 100644 codex-rs/ext/image-generation/BUILD.bazel create mode 100644 codex-rs/ext/image-generation/Cargo.toml create mode 100644 codex-rs/ext/image-generation/imagegen_description.md create mode 100644 codex-rs/ext/image-generation/src/backend.rs create mode 100644 codex-rs/ext/image-generation/src/extension.rs create mode 100644 codex-rs/ext/image-generation/src/lib.rs create mode 100644 codex-rs/ext/image-generation/src/tests.rs create mode 100644 codex-rs/ext/image-generation/src/tool.rs create mode 100644 codex-rs/rmcp-client/src/http_client_adapter/www_authenticate.rs create mode 100644 codex-rs/rmcp-client/src/http_client_adapter/www_authenticate_tests.rs create mode 100644 codex-rs/skills/src/assets/samples/openai-docs/scripts/fetch-codex-manual.mjs create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__plugin_at_mentions_render_with_plugin_accent.snap create mode 100644 codex-rs/tui/src/markdown_render/table_key_value.rs create mode 100644 codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__table_keeps_grid_when_only_one_compact_record_fragments_snapshot.snap create mode 100644 codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__table_renders_key_value_records_when_compact_fragmentation_is_systemic_snapshot.snap create mode 100644 codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__table_renders_records_when_multiple_prose_columns_are_starved_snapshot.snap create mode 100644 codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__table_renders_stacked_key_value_records_when_path_column_becomes_too_narrow_snapshot.snap create mode 100644 codex-rs/tui/src/terminal_hyperlinks.rs diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index d5ef0b241ca..f541a2c7bf3 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -1153,8 +1153,12 @@ "jiff-tzdb-platform_0.1.3": "{\"dependencies\":[{\"name\":\"jiff-tzdb\",\"req\":\"^0.1.4\"}],\"features\":{}}", "jiff-tzdb_0.1.6": "{\"dependencies\":[],\"features\":{}}", "jiff_0.2.23": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.81\"},{\"features\":[\"serde\"],\"kind\":\"dev\",\"name\":\"chrono\",\"req\":\"^0.4.38\"},{\"kind\":\"dev\",\"name\":\"chrono-tz\",\"req\":\"^0.10.0\"},{\"kind\":\"dev\",\"name\":\"hifitime\",\"req\":\"^3.9.0\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"kind\":\"dev\",\"name\":\"humantime\",\"req\":\"^2.1.0\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.39.0\"},{\"name\":\"jiff-static\",\"req\":\"=0.2.23\",\"target\":\"cfg(any())\"},{\"name\":\"jiff-static\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"jiff-tzdb\",\"optional\":true,\"req\":\"^0.1.6\"},{\"name\":\"jiff-tzdb-platform\",\"optional\":true,\"req\":\"^0.1.3\",\"target\":\"cfg(any(windows, target_family = \\\"wasm\\\"))\"},{\"name\":\"js-sys\",\"optional\":true,\"req\":\"^0.3.50\",\"target\":\"cfg(all(any(target_arch = \\\"wasm32\\\", target_arch = \\\"wasm64\\\"), target_os = \\\"unknown\\\"))\"},{\"default_features\":false,\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.21\"},{\"kind\":\"dev\",\"name\":\"log\",\"req\":\"^0.4.21\"},{\"default_features\":false,\"name\":\"portable-atomic\",\"req\":\"^1.10.0\",\"target\":\"cfg(not(target_has_atomic = \\\"ptr\\\"))\"},{\"default_features\":false,\"name\":\"portable-atomic-util\",\"req\":\"^0.2.4\",\"target\":\"cfg(not(target_has_atomic = \\\"ptr\\\"))\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.203\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.221\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.117\"},{\"kind\":\"dev\",\"name\":\"serde_yaml\",\"req\":\"^0.9.34\"},{\"kind\":\"dev\",\"name\":\"tabwriter\",\"req\":\"^1.4.0\"},{\"features\":[\"local-offset\",\"macros\",\"parsing\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3.36\"},{\"kind\":\"dev\",\"name\":\"time-tz\",\"req\":\"^2.0.0\"},{\"kind\":\"dev\",\"name\":\"tzfile\",\"req\":\"^0.1.3\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.5.0\"},{\"name\":\"wasm-bindgen\",\"optional\":true,\"req\":\"^0.2.70\",\"target\":\"cfg(all(any(target_arch = \\\"wasm32\\\", target_arch = \\\"wasm64\\\"), target_os = \\\"unknown\\\"))\"},{\"default_features\":false,\"features\":[\"Win32_Foundation\",\"Win32_System_Time\"],\"name\":\"windows-sys\",\"optional\":true,\"req\":\">=0.52.0, <=0.61\",\"target\":\"cfg(windows)\"}],\"features\":{\"alloc\":[\"serde_core?/alloc\",\"portable-atomic-util/alloc\"],\"default\":[\"std\",\"tz-system\",\"tz-fat\",\"tzdb-bundle-platform\",\"tzdb-zoneinfo\",\"tzdb-concatenated\",\"perf-inline\"],\"js\":[\"dep:wasm-bindgen\",\"dep:js-sys\"],\"logging\":[\"dep:log\"],\"perf-inline\":[],\"serde\":[\"dep:serde_core\"],\"static\":[\"static-tz\",\"jiff-static?/tzdb\"],\"static-tz\":[\"dep:jiff-static\"],\"std\":[\"alloc\",\"log?/std\",\"serde_core?/std\"],\"tz-fat\":[\"jiff-static?/tz-fat\"],\"tz-system\":[\"std\",\"dep:windows-sys\"],\"tzdb-bundle-always\":[\"dep:jiff-tzdb\",\"alloc\"],\"tzdb-bundle-platform\":[\"dep:jiff-tzdb-platform\",\"alloc\"],\"tzdb-concatenated\":[\"std\"],\"tzdb-zoneinfo\":[\"std\"]}}", + "jni-macros_0.22.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"javac\",\"req\":\"^0.1.0\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"kind\":\"build\",\"name\":\"rustc_version\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"rusty-fork\",\"req\":\"^0.3.0\"},{\"name\":\"simd_cesu8\",\"req\":\"^1.0.1\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2.0\"},{\"kind\":\"dev\",\"name\":\"thiserror\",\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1\"}],\"features\":{}}", + "jni-sys-macros_0.4.1": "{\"dependencies\":[{\"name\":\"quote\",\"req\":\"^1\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{}}", "jni-sys_0.3.0": "{\"dependencies\":[],\"features\":{}}", + "jni-sys_0.4.1": "{\"dependencies\":[{\"name\":\"jni-sys-macros\",\"req\":\"^0.4.1\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1\"}],\"features\":{}}", "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\"]}}", + "jni_0.22.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"assert_matches\",\"req\":\"^1.5.0\"},{\"kind\":\"dev\",\"name\":\"bytemuck\",\"req\":\"^1.13.0\",\"target\":\"cfg(windows)\"},{\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"name\":\"combine\",\"req\":\"^4.1.0\"},{\"features\":[\"html_reports\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.7\"},{\"name\":\"java-locator\",\"optional\":true,\"req\":\"^0.1.3\",\"target\":\"cfg(not(target_os = \\\"android\\\"))\"},{\"kind\":\"dev\",\"name\":\"javac\",\"req\":\"^0.1.0\"},{\"name\":\"jni-macros\",\"req\":\"=0.22.4\"},{\"name\":\"jni-sys\",\"req\":\"^0.4.1\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1\"},{\"name\":\"libloading\",\"optional\":true,\"req\":\"^0.8\",\"target\":\"cfg(not(target_os = \\\"android\\\"))\"},{\"name\":\"log\",\"req\":\"^0.4.4\"},{\"kind\":\"dev\",\"name\":\"rusty-fork\",\"req\":\"^0.3.0\"},{\"name\":\"simd_cesu8\",\"req\":\"^1.1.1\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1\"},{\"name\":\"thiserror\",\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1\"},{\"kind\":\"build\",\"name\":\"walkdir\",\"req\":\"^2\"},{\"name\":\"windows-link\",\"req\":\"^0.2\",\"target\":\"cfg(windows)\"},{\"features\":[\"Win32_System_Threading\",\"Win32_Foundation\"],\"kind\":\"dev\",\"name\":\"windows-sys\",\"req\":\"^0.61\",\"target\":\"cfg(windows)\"}],\"features\":{\"_cfg_test\":[],\"default\":[],\"invocation\":[\"dep:java-locator\",\"dep: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\"]}}", @@ -1430,12 +1434,13 @@ "regex-syntax_0.8.8": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.3.0\"}],\"features\":{\"arbitrary\":[\"dep:arbitrary\"],\"default\":[\"std\",\"unicode\"],\"std\":[],\"unicode\":[\"unicode-age\",\"unicode-bool\",\"unicode-case\",\"unicode-gencat\",\"unicode-perl\",\"unicode-script\",\"unicode-segment\"],\"unicode-age\":[],\"unicode-bool\":[],\"unicode-case\":[],\"unicode-gencat\":[],\"unicode-perl\":[],\"unicode-script\":[],\"unicode-segment\":[]}}", "regex_1.12.3": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aho-corasick\",\"optional\":true,\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.69\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"default_features\":false,\"features\":[\"atty\",\"humantime\",\"termcolor\"],\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.9.3\"},{\"default_features\":false,\"name\":\"memchr\",\"optional\":true,\"req\":\"^2.6.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"},{\"default_features\":false,\"features\":[\"alloc\",\"syntax\",\"meta\",\"nfa-pikevm\"],\"name\":\"regex-automata\",\"req\":\"^0.4.12\"},{\"default_features\":false,\"name\":\"regex-syntax\",\"req\":\"^0.8.5\"},{\"kind\":\"dev\",\"name\":\"regex-test\",\"req\":\"^0.1.0\"}],\"features\":{\"default\":[\"std\",\"perf\",\"unicode\",\"regex-syntax/default\"],\"logging\":[\"aho-corasick?/logging\",\"memchr?/logging\",\"regex-automata/logging\"],\"pattern\":[],\"perf\":[\"perf-cache\",\"perf-dfa\",\"perf-onepass\",\"perf-backtrack\",\"perf-inline\",\"perf-literal\"],\"perf-backtrack\":[\"regex-automata/nfa-backtrack\"],\"perf-cache\":[],\"perf-dfa\":[\"regex-automata/hybrid\"],\"perf-dfa-full\":[\"regex-automata/dfa-build\",\"regex-automata/dfa-search\"],\"perf-inline\":[\"regex-automata/perf-inline\"],\"perf-literal\":[\"dep:aho-corasick\",\"dep:memchr\",\"regex-automata/perf-literal\"],\"perf-onepass\":[\"regex-automata/dfa-onepass\"],\"std\":[\"aho-corasick?/std\",\"memchr?/std\",\"regex-automata/std\",\"regex-syntax/std\"],\"unicode\":[\"unicode-age\",\"unicode-bool\",\"unicode-case\",\"unicode-gencat\",\"unicode-perl\",\"unicode-script\",\"unicode-segment\",\"regex-automata/unicode\",\"regex-syntax/unicode\"],\"unicode-age\":[\"regex-automata/unicode-age\",\"regex-syntax/unicode-age\"],\"unicode-bool\":[\"regex-automata/unicode-bool\",\"regex-syntax/unicode-bool\"],\"unicode-case\":[\"regex-automata/unicode-case\",\"regex-syntax/unicode-case\"],\"unicode-gencat\":[\"regex-automata/unicode-gencat\",\"regex-syntax/unicode-gencat\"],\"unicode-perl\":[\"regex-automata/unicode-perl\",\"regex-automata/unicode-word-boundary\",\"regex-syntax/unicode-perl\"],\"unicode-script\":[\"regex-automata/unicode-script\",\"regex-syntax/unicode-script\"],\"unicode-segment\":[\"regex-automata/unicode-segment\",\"regex-syntax/unicode-segment\"],\"unstable\":[\"pattern\"],\"use_std\":[\"std\"]}}", "reqwest_0.12.28": "{\"dependencies\":[{\"name\":\"base64\",\"req\":\"^0.22\"},{\"kind\":\"dev\",\"name\":\"brotli_crate\",\"package\":\"brotli\",\"req\":\"^8\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"bytes\",\"req\":\"^1.2\"},{\"name\":\"cookie_crate\",\"optional\":true,\"package\":\"cookie\",\"req\":\"^0.18.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"cookie_store\",\"optional\":true,\"req\":\"^0.22.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"encoding_rs\",\"optional\":true,\"req\":\"^0.8\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"kind\":\"dev\",\"name\":\"flate2\",\"req\":\"^1.0.13\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"futures-channel\",\"optional\":true,\"req\":\"^0.3\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.28\"},{\"default_features\":false,\"name\":\"futures-util\",\"optional\":true,\"req\":\"^0.3.28\"},{\"default_features\":false,\"features\":[\"std\",\"alloc\"],\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.28\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"h2\",\"optional\":true,\"req\":\"^0.4\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"h3\",\"optional\":true,\"req\":\"^0.0.8\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"h3-quinn\",\"optional\":true,\"req\":\"^0.0.10\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"tokio\"],\"name\":\"hickory-resolver\",\"optional\":true,\"req\":\"^0.25\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"http\",\"req\":\"^1.1\"},{\"name\":\"http-body\",\"req\":\"^1\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"http-body-util\",\"req\":\"^0.1.2\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"http1\",\"client\"],\"name\":\"hyper\",\"req\":\"^1.1\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"http1\",\"http2\",\"client\",\"server\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1.1.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"http1\",\"tls12\"],\"name\":\"hyper-rustls\",\"optional\":true,\"req\":\"^0.27.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"hyper-tls\",\"optional\":true,\"req\":\"^0.6\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"http1\",\"client\",\"client-legacy\",\"client-proxy\",\"tokio\"],\"name\":\"hyper-util\",\"req\":\"^0.1.12\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"http1\",\"http2\",\"client\",\"client-legacy\",\"server-auto\",\"server-graceful\",\"tokio\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1.12\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"js-sys\",\"req\":\"^0.3.77\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0\"},{\"name\":\"log\",\"req\":\"^0.4.17\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"mime\",\"optional\":true,\"req\":\"^0.3.16\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"name\":\"mime_guess\",\"optional\":true,\"req\":\"^2.0\"},{\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\",\"req\":\"^0.2.10\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"kind\":\"dev\",\"name\":\"num_cpus\",\"req\":\"^1.0\"},{\"name\":\"once_cell\",\"optional\":true,\"req\":\"^1.18\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"percent-encoding\",\"req\":\"^2.3\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.11\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"rustls\",\"runtime-tokio\"],\"name\":\"quinn\",\"optional\":true,\"req\":\"^0.11.1\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"std\",\"tls12\"],\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.4\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"rustls-native-certs\",\"optional\":true,\"req\":\"^0.8.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"std\"],\"name\":\"rustls-pki-types\",\"optional\":true,\"req\":\"^1.9.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"serde\",\"req\":\"^1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"serde_json\",\"req\":\"^1.0\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"serde_urlencoded\",\"req\":\"^0.7.1\"},{\"features\":[\"futures\"],\"name\":\"sync_wrapper\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"net\",\"time\"],\"name\":\"tokio\",\"req\":\"^1.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"tokio-native-tls\",\"optional\":true,\"req\":\"^0.3.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"tls12\"],\"name\":\"tokio-rustls\",\"optional\":true,\"req\":\"^0.26\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"io\"],\"name\":\"tokio-util\",\"optional\":true,\"req\":\"^0.7.9\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"retry\",\"timeout\",\"util\"],\"name\":\"tower\",\"req\":\"^0.5.2\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"limit\"],\"kind\":\"dev\",\"name\":\"tower\",\"req\":\"^0.5.2\"},{\"default_features\":false,\"features\":[\"follow-redirect\"],\"name\":\"tower-http\",\"req\":\"^0.6.8\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"tower-service\",\"req\":\"^0.3\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"url\",\"req\":\"^2.4\"},{\"name\":\"wasm-bindgen\",\"req\":\"^0.2.89\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"features\":[\"serde-serialize\"],\"kind\":\"dev\",\"name\":\"wasm-bindgen\",\"req\":\"^0.2.89\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"wasm-bindgen-futures\",\"req\":\"^0.4.18\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"wasm-streams\",\"optional\":true,\"req\":\"^0.4\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"features\":[\"AbortController\",\"AbortSignal\",\"Headers\",\"Request\",\"RequestInit\",\"RequestMode\",\"Response\",\"Window\",\"FormData\",\"Blob\",\"BlobPropertyBag\",\"ServiceWorkerGlobalScope\",\"RequestCredentials\",\"File\",\"ReadableStream\",\"RequestCache\"],\"name\":\"web-sys\",\"req\":\"^0.3.28\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"webpki-roots\",\"optional\":true,\"req\":\"^1\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"kind\":\"dev\",\"name\":\"zstd_crate\",\"package\":\"zstd\",\"req\":\"^0.13\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"}],\"features\":{\"__rustls\":[\"dep:hyper-rustls\",\"dep:tokio-rustls\",\"dep:rustls\",\"__tls\"],\"__rustls-ring\":[\"hyper-rustls?/ring\",\"tokio-rustls?/ring\",\"rustls?/ring\",\"quinn?/ring\"],\"__tls\":[\"dep:rustls-pki-types\",\"tokio/io-util\"],\"blocking\":[\"dep:futures-channel\",\"futures-channel?/sink\",\"dep:futures-util\",\"futures-util?/io\",\"futures-util?/sink\",\"tokio/sync\"],\"brotli\":[\"tower-http/decompression-br\"],\"charset\":[\"dep:encoding_rs\",\"dep:mime\"],\"cookies\":[\"dep:cookie_crate\",\"dep:cookie_store\"],\"default\":[\"default-tls\",\"charset\",\"http2\",\"system-proxy\"],\"default-tls\":[\"dep:hyper-tls\",\"dep:native-tls-crate\",\"__tls\",\"dep:tokio-native-tls\"],\"deflate\":[\"tower-http/decompression-deflate\"],\"gzip\":[\"tower-http/decompression-gzip\"],\"hickory-dns\":[\"dep:hickory-resolver\",\"dep:once_cell\"],\"http2\":[\"h2\",\"hyper/http2\",\"hyper-util/http2\",\"hyper-rustls?/http2\"],\"http3\":[\"rustls-tls-manual-roots\",\"dep:h3\",\"dep:h3-quinn\",\"dep:quinn\",\"tokio/macros\"],\"json\":[\"dep:serde_json\"],\"macos-system-configuration\":[\"system-proxy\"],\"multipart\":[\"dep:mime_guess\",\"dep:futures-util\"],\"native-tls\":[\"default-tls\"],\"native-tls-alpn\":[\"native-tls\",\"native-tls-crate?/alpn\",\"hyper-tls?/alpn\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate?/vendored\"],\"rustls-tls\":[\"rustls-tls-webpki-roots\"],\"rustls-tls-manual-roots\":[\"rustls-tls-manual-roots-no-provider\",\"__rustls-ring\"],\"rustls-tls-manual-roots-no-provider\":[\"__rustls\"],\"rustls-tls-native-roots\":[\"rustls-tls-native-roots-no-provider\",\"__rustls-ring\"],\"rustls-tls-native-roots-no-provider\":[\"dep:rustls-native-certs\",\"hyper-rustls?/native-tokio\",\"__rustls\"],\"rustls-tls-no-provider\":[\"rustls-tls-manual-roots-no-provider\"],\"rustls-tls-webpki-roots\":[\"rustls-tls-webpki-roots-no-provider\",\"__rustls-ring\"],\"rustls-tls-webpki-roots-no-provider\":[\"dep:webpki-roots\",\"hyper-rustls?/webpki-tokio\",\"__rustls\"],\"socks\":[],\"stream\":[\"tokio/fs\",\"dep:futures-util\",\"dep:tokio-util\",\"dep:wasm-streams\"],\"system-proxy\":[\"hyper-util/client-proxy-system\"],\"trust-dns\":[],\"zstd\":[\"tower-http/decompression-zstd\"]}}", + "reqwest_0.13.4": "{\"dependencies\":[{\"name\":\"base64\",\"req\":\"^0.22\"},{\"kind\":\"dev\",\"name\":\"brotli_crate\",\"package\":\"brotli\",\"req\":\"^8\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"name\":\"bytes\",\"req\":\"^1.2\"},{\"name\":\"cookie_crate\",\"optional\":true,\"package\":\"cookie\",\"req\":\"^0.18.0\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"name\":\"cookie_store\",\"optional\":true,\"req\":\"^0.22.0\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"name\":\"encoding_rs\",\"optional\":true,\"req\":\"^0.8\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"kind\":\"dev\",\"name\":\"flate2\",\"req\":\"^1.0.13\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"name\":\"futures-channel\",\"optional\":true,\"req\":\"^0.3\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.28\"},{\"default_features\":false,\"name\":\"futures-util\",\"optional\":true,\"req\":\"^0.3.28\"},{\"default_features\":false,\"features\":[\"std\",\"alloc\"],\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.28\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"name\":\"h2\",\"optional\":true,\"req\":\"^0.4\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"name\":\"h3\",\"optional\":true,\"req\":\"^0.0.8\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"name\":\"h3-quinn\",\"optional\":true,\"req\":\"^0.0.10\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"features\":[\"tokio\"],\"name\":\"hickory-resolver\",\"optional\":true,\"req\":\"^0.26\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"name\":\"http\",\"req\":\"^1.1\"},{\"name\":\"http-body\",\"req\":\"^1\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"name\":\"http-body-util\",\"req\":\"^0.1.2\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"features\":[\"http1\",\"client\"],\"name\":\"hyper\",\"req\":\"^1.1\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"default_features\":false,\"features\":[\"http1\",\"http2\",\"client\",\"server\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1.1.0\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"default_features\":false,\"features\":[\"http1\",\"tls12\"],\"name\":\"hyper-rustls\",\"optional\":true,\"req\":\"^0.27.0\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"name\":\"hyper-tls\",\"optional\":true,\"req\":\"^0.6\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"features\":[\"http1\",\"client\",\"client-legacy\",\"client-proxy\",\"tokio\"],\"name\":\"hyper-util\",\"req\":\"^0.1.12\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"features\":[\"http1\",\"http2\",\"client\",\"client-legacy\",\"server-auto\",\"server-graceful\",\"tokio\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1.12\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"name\":\"js-sys\",\"req\":\"^0.3.77\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\")))\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0\"},{\"name\":\"log\",\"req\":\"^0.4.17\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"name\":\"mime\",\"optional\":true,\"req\":\"^0.3.16\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"default_features\":false,\"name\":\"mime_guess\",\"optional\":true,\"req\":\"^2.0\"},{\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\",\"req\":\"^0.2.16\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"kind\":\"dev\",\"name\":\"num_cpus\",\"req\":\"^1.0\"},{\"name\":\"once_cell\",\"optional\":true,\"req\":\"^1.18\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"name\":\"percent-encoding\",\"req\":\"^2.3\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.11\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"default_features\":false,\"features\":[\"runtime-tokio\"],\"name\":\"quinn\",\"optional\":true,\"req\":\"^0.11.1\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"default_features\":false,\"features\":[\"std\",\"tls12\"],\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.4\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"features\":[\"std\"],\"name\":\"rustls-pki-types\",\"optional\":true,\"req\":\"^1.9.0\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"name\":\"rustls-platform-verifier\",\"optional\":true,\"req\":\">=0.6.0, <0.8.0\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"serde_urlencoded\",\"optional\":true,\"req\":\"^0.7.1\"},{\"features\":[\"futures\"],\"name\":\"sync_wrapper\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"net\",\"time\"],\"name\":\"tokio\",\"req\":\"^1.0\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"default_features\":false,\"features\":[\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"name\":\"tokio-native-tls\",\"optional\":true,\"req\":\"^0.3.0\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"default_features\":false,\"features\":[\"tls12\"],\"name\":\"tokio-rustls\",\"optional\":true,\"req\":\"^0.26\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"default_features\":false,\"features\":[\"io\"],\"name\":\"tokio-util\",\"optional\":true,\"req\":\"^0.7.9\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"default_features\":false,\"features\":[\"retry\",\"timeout\",\"util\"],\"name\":\"tower\",\"req\":\"^0.5.2\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"default_features\":false,\"features\":[\"limit\"],\"kind\":\"dev\",\"name\":\"tower\",\"req\":\"^0.5.2\"},{\"default_features\":false,\"features\":[\"follow-redirect\"],\"name\":\"tower-http\",\"req\":\"^0.6.8\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"name\":\"tower-service\",\"req\":\"^0.3\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"name\":\"url\",\"req\":\"^2.4\"},{\"name\":\"wasm-bindgen\",\"req\":\"^0.2.89\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\")))\"},{\"features\":[\"serde-serialize\"],\"kind\":\"dev\",\"name\":\"wasm-bindgen\",\"req\":\"^0.2.89\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\")))\"},{\"name\":\"wasm-bindgen-futures\",\"req\":\"^0.4.18\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\")))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\")))\"},{\"name\":\"wasm-streams\",\"optional\":true,\"req\":\"^0.5\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\")))\"},{\"features\":[\"AbortController\",\"AbortSignal\",\"Headers\",\"Request\",\"RequestInit\",\"RequestMode\",\"Response\",\"Window\",\"FormData\",\"Blob\",\"BlobPropertyBag\",\"ServiceWorkerGlobalScope\",\"RequestCredentials\",\"File\",\"ReadableStream\",\"RequestCache\"],\"name\":\"web-sys\",\"req\":\"^0.3.28\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\")))\"},{\"kind\":\"dev\",\"name\":\"zstd_crate\",\"package\":\"zstd\",\"req\":\"^0.13\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"}],\"features\":{\"__native-tls\":[\"dep:hyper-tls\",\"dep:native-tls-crate\",\"__tls\",\"dep:tokio-native-tls\"],\"__native-tls-alpn\":[\"native-tls-crate?/alpn\",\"hyper-tls?/alpn\"],\"__rustls\":[\"dep:hyper-rustls\",\"dep:tokio-rustls\",\"dep:rustls\",\"__tls\"],\"__rustls-aws-lc-rs\":[\"hyper-rustls?/aws-lc-rs\",\"tokio-rustls?/aws-lc-rs\",\"rustls?/aws-lc-rs\",\"quinn?/rustls-aws-lc-rs\"],\"__tls\":[\"dep:rustls-pki-types\",\"tokio/io-util\"],\"blocking\":[\"dep:futures-channel\",\"futures-channel?/sink\",\"dep:futures-util\",\"futures-util?/io\",\"futures-util?/sink\",\"tokio/sync\"],\"brotli\":[\"tower-http/decompression-br\"],\"charset\":[\"dep:encoding_rs\",\"dep:mime\"],\"cookies\":[\"dep:cookie_crate\",\"dep:cookie_store\"],\"default\":[\"default-tls\",\"charset\",\"http2\",\"system-proxy\"],\"default-tls\":[\"rustls\"],\"deflate\":[\"tower-http/decompression-deflate\"],\"form\":[\"dep:serde\",\"dep:serde_urlencoded\"],\"gzip\":[\"tower-http/decompression-gzip\"],\"hickory-dns\":[\"dep:hickory-resolver\",\"dep:once_cell\"],\"http2\":[\"dep:h2\",\"hyper/http2\",\"hyper-util/http2\",\"hyper-rustls?/http2\"],\"http3\":[\"rustls\",\"dep:h3\",\"dep:h3-quinn\",\"dep:quinn\",\"tokio/macros\"],\"json\":[\"dep:serde\",\"dep:serde_json\"],\"multipart\":[\"dep:mime_guess\",\"dep:futures-util\"],\"native-tls\":[\"__native-tls\",\"__native-tls-alpn\"],\"native-tls-no-alpn\":[\"__native-tls\"],\"native-tls-vendored\":[\"__native-tls\",\"native-tls-crate?/vendored\",\"__native-tls-alpn\"],\"native-tls-vendored-no-alpn\":[\"__native-tls\",\"native-tls-crate?/vendored\"],\"query\":[\"dep:serde\",\"dep:serde_urlencoded\"],\"rustls\":[\"__rustls-aws-lc-rs\",\"dep:rustls-platform-verifier\",\"__rustls\"],\"rustls-no-provider\":[\"dep:rustls-platform-verifier\",\"__rustls\"],\"socks\":[],\"stream\":[\"tokio/fs\",\"dep:futures-util\",\"dep:tokio-util\",\"dep:wasm-streams\"],\"system-proxy\":[\"hyper-util/client-proxy-system\"],\"zstd\":[\"tower-http/decompression-zstd\"]}}", "resb_0.1.2": "{\"dependencies\":[{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2.0.0\"},{\"default_features\":false,\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.17\"},{\"name\":\"nom\",\"optional\":true,\"req\":\"^7.0.0\"},{\"default_features\":false,\"name\":\"potential_utf\",\"req\":\"^0.1.3\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde_core\",\"req\":\"^1.0.220\"}],\"features\":{\"default\":[],\"logging\":[\"dep:log\"],\"serialize\":[\"std\"],\"std\":[],\"text\":[\"dep:indexmap\",\"dep:nom\",\"std\"]}}", "resolv-conf_0.7.6": "{\"dependencies\":[],\"features\":{\"system\":[]}}", "rfc6979_0.4.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.3\"},{\"default_features\":false,\"features\":[\"reset\"],\"name\":\"hmac\",\"req\":\"^0.12\"},{\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.10\"},{\"default_features\":false,\"name\":\"subtle\",\"req\":\"^2\"}],\"features\":{}}", "ring_0.17.14": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.2.8\"},{\"default_features\":false,\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"name\":\"getrandom\",\"req\":\"^0.2.10\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.148\",\"target\":\"cfg(all(any(all(target_arch = \\\"aarch64\\\", target_endian = \\\"little\\\"), all(target_arch = \\\"arm\\\", target_endian = \\\"little\\\")), any(target_os = \\\"android\\\", target_os = \\\"linux\\\")))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.155\",\"target\":\"cfg(all(all(target_arch = \\\"aarch64\\\", target_endian = \\\"little\\\"), target_vendor = \\\"apple\\\", any(target_os = \\\"ios\\\", target_os = \\\"macos\\\", target_os = \\\"tvos\\\", target_os = \\\"visionos\\\", target_os = \\\"watchos\\\")))\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.148\",\"target\":\"cfg(any(unix, windows, target_os = \\\"wasi\\\"))\"},{\"name\":\"untrusted\",\"req\":\"^0.9\"},{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.37\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"unknown\\\"))\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_Threading\"],\"name\":\"windows-sys\",\"req\":\"^0.52\",\"target\":\"cfg(all(all(target_arch = \\\"aarch64\\\", target_endian = \\\"little\\\"), target_os = \\\"windows\\\"))\"}],\"features\":{\"alloc\":[],\"default\":[\"alloc\",\"dev_urandom_fallback\"],\"dev_urandom_fallback\":[],\"less-safe-getrandom-custom-or-rdrand\":[],\"less-safe-getrandom-espidf\":[],\"slow_tests\":[],\"std\":[\"alloc\"],\"test_logging\":[],\"unstable-testing-arm-no-hw\":[],\"unstable-testing-arm-no-neon\":[],\"wasm32_unknown_unknown_js\":[\"getrandom/js\"]}}", - "rmcp-macros_0.15.0": "{\"dependencies\":[{\"name\":\"darling\",\"req\":\"^0.23\"},{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{}}", - "rmcp_0.15.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0\"},{\"name\":\"async-trait\",\"req\":\"^0.1.89\"},{\"kind\":\"dev\",\"name\":\"async-trait\",\"req\":\"^0.1\"},{\"name\":\"axum\",\"optional\":true,\"req\":\"^0.8\"},{\"name\":\"base64\",\"optional\":true,\"req\":\"^0.22\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"serde\",\"clock\",\"std\",\"oldtime\"],\"name\":\"chrono\",\"req\":\"^0.4.38\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"features\":[\"serde\"],\"name\":\"chrono\",\"req\":\"^0.4.38\",\"target\":\"cfg(not(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\")))\"},{\"name\":\"futures\",\"req\":\"^0.3\"},{\"name\":\"http\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"http-body\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"http-body-util\",\"optional\":true,\"req\":\"^0.1\"},{\"default_features\":false,\"features\":[\"reqwest\"],\"name\":\"oauth2\",\"optional\":true,\"req\":\"^5.0\"},{\"name\":\"pastey\",\"optional\":true,\"req\":\"^0.2.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2\"},{\"features\":[\"tokio1\"],\"name\":\"process-wrap\",\"optional\":true,\"req\":\"^9.0\"},{\"name\":\"rand\",\"optional\":true,\"req\":\"^0.9\"},{\"default_features\":false,\"features\":[\"json\",\"stream\"],\"name\":\"reqwest\",\"optional\":true,\"req\":\"^0.12\"},{\"name\":\"rmcp-macros\",\"optional\":true,\"req\":\"^0.15.0\"},{\"features\":[\"chrono04\"],\"name\":\"schemars\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"chrono04\"],\"kind\":\"dev\",\"name\":\"schemars\",\"req\":\"^1.1.0\"},{\"features\":[\"derive\",\"rc\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"sse-stream\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"thiserror\",\"req\":\"^2\"},{\"features\":[\"sync\",\"macros\",\"rt\",\"time\"],\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"name\":\"tokio-stream\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"tokio-util\",\"req\":\"^0.7\"},{\"name\":\"tower-service\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"tracing\",\"req\":\"^0.1\"},{\"features\":[\"env-filter\",\"std\",\"fmt\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3\"},{\"name\":\"url\",\"optional\":true,\"req\":\"^2.4\"},{\"features\":[\"v4\"],\"name\":\"uuid\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"__reqwest\":[\"dep:reqwest\"],\"auth\":[\"dep:oauth2\",\"__reqwest\",\"dep:url\"],\"client\":[\"dep:tokio-stream\"],\"client-side-sse\":[\"dep:sse-stream\",\"dep:http\"],\"default\":[\"base64\",\"macros\",\"server\"],\"elicitation\":[\"dep:url\"],\"macros\":[\"dep:rmcp-macros\",\"dep:pastey\"],\"reqwest\":[\"__reqwest\",\"reqwest?/rustls-tls\"],\"reqwest-native-tls\":[\"__reqwest\",\"reqwest?/native-tls\"],\"reqwest-tls-no-provider\":[\"__reqwest\",\"reqwest?/rustls-tls-no-provider\"],\"schemars\":[\"dep:schemars\"],\"server\":[\"transport-async-rw\",\"dep:schemars\",\"dep:pastey\"],\"server-side-http\":[\"uuid\",\"dep:rand\",\"dep:tokio-stream\",\"dep:http\",\"dep:http-body\",\"dep:http-body-util\",\"dep:bytes\",\"dep:sse-stream\",\"dep:axum\",\"tower\"],\"tower\":[\"dep:tower-service\"],\"transport-async-rw\":[\"tokio/io-util\",\"tokio-util/codec\"],\"transport-child-process\":[\"transport-async-rw\",\"tokio/process\",\"dep:process-wrap\"],\"transport-io\":[\"transport-async-rw\",\"tokio/io-std\"],\"transport-streamable-http-client\":[\"client-side-sse\",\"transport-worker\"],\"transport-streamable-http-client-reqwest\":[\"transport-streamable-http-client\",\"__reqwest\"],\"transport-streamable-http-server\":[\"transport-streamable-http-server-session\",\"server-side-http\",\"transport-worker\"],\"transport-streamable-http-server-session\":[\"transport-async-rw\",\"dep:tokio-stream\"],\"transport-worker\":[\"dep:tokio-stream\"]}}", + "rmcp-macros_1.7.0": "{\"dependencies\":[{\"name\":\"darling\",\"req\":\"^0.23\"},{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{\"local\":[]}}", + "rmcp_1.7.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0\"},{\"name\":\"async-trait\",\"req\":\"^0.1.89\"},{\"kind\":\"dev\",\"name\":\"async-trait\",\"req\":\"^0.1\"},{\"default_features\":false,\"features\":[\"http1\",\"tokio\"],\"kind\":\"dev\",\"name\":\"axum\",\"req\":\"^0.8\"},{\"name\":\"base64\",\"optional\":true,\"req\":\"^0.22\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"serde\",\"clock\",\"std\",\"oldtime\"],\"name\":\"chrono\",\"req\":\"^0.4.38\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"default_features\":false,\"features\":[\"serde\",\"now\"],\"name\":\"chrono\",\"req\":\"^0.4.38\",\"target\":\"cfg(not(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\")))\"},{\"name\":\"futures\",\"req\":\"^0.3\"},{\"name\":\"http\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"http-body\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"http-body-util\",\"optional\":true,\"req\":\"^0.1\"},{\"features\":[\"client\",\"http1\"],\"name\":\"hyper\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"server\",\"http1\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1\"},{\"features\":[\"tokio\"],\"name\":\"hyper-util\",\"optional\":true,\"req\":\"^0.1\"},{\"features\":[\"tokio\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1\"},{\"name\":\"jsonwebtoken\",\"optional\":true,\"req\":\"^10\"},{\"default_features\":false,\"name\":\"oauth2\",\"optional\":true,\"req\":\"^5.0\"},{\"name\":\"pastey\",\"optional\":true,\"req\":\"^0.2.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2\"},{\"features\":[\"tokio1\"],\"name\":\"process-wrap\",\"optional\":true,\"req\":\"^9.0\"},{\"name\":\"rand\",\"optional\":true,\"req\":\"^0.10\"},{\"default_features\":false,\"features\":[\"json\",\"stream\"],\"name\":\"reqwest\",\"optional\":true,\"req\":\"^0.13.2\"},{\"name\":\"rmcp-macros\",\"optional\":true,\"req\":\"^1.7.0\"},{\"features\":[\"chrono04\"],\"name\":\"schemars\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"chrono04\"],\"kind\":\"dev\",\"name\":\"schemars\",\"req\":\"^1.1.0\"},{\"features\":[\"derive\",\"rc\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"sse-stream\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"thiserror\",\"req\":\"^2\"},{\"features\":[\"sync\",\"macros\",\"rt\",\"time\"],\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"name\":\"tokio-stream\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"tokio-util\",\"req\":\"^0.7\"},{\"name\":\"tower-service\",\"optional\":true,\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"tower-service\",\"req\":\"^0.3\"},{\"name\":\"tracing\",\"req\":\"^0.1\"},{\"features\":[\"env-filter\",\"std\",\"fmt\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3\"},{\"name\":\"url\",\"optional\":true,\"req\":\"^2.4\"},{\"kind\":\"dev\",\"name\":\"url\",\"req\":\"^2.4\"},{\"features\":[\"v4\"],\"name\":\"uuid\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"which\",\"optional\":true,\"req\":\"^8\"}],\"features\":{\"__reqwest\":[\"dep:reqwest\"],\"auth\":[\"dep:oauth2\",\"__reqwest\",\"dep:url\"],\"auth-client-credentials-jwt\":[\"auth\",\"dep:jsonwebtoken\",\"uuid\"],\"client\":[\"dep:tokio-stream\"],\"client-side-sse\":[\"dep:sse-stream\",\"dep:http\"],\"default\":[\"base64\",\"macros\",\"server\"],\"elicitation\":[\"dep:url\"],\"local\":[\"rmcp-macros?/local\"],\"macros\":[\"dep:rmcp-macros\",\"dep:pastey\"],\"reqwest\":[\"__reqwest\",\"reqwest?/rustls\"],\"reqwest-native-tls\":[\"__reqwest\",\"reqwest?/native-tls\"],\"reqwest-tls-no-provider\":[\"__reqwest\",\"reqwest?/rustls-no-provider\"],\"schemars\":[\"dep:schemars\"],\"server\":[\"transport-async-rw\",\"dep:schemars\",\"dep:pastey\"],\"server-side-http\":[\"uuid\",\"dep:rand\",\"dep:tokio-stream\",\"dep:http\",\"dep:http-body\",\"dep:http-body-util\",\"dep:bytes\",\"dep:sse-stream\",\"tower\"],\"tower\":[\"dep:tower-service\"],\"transport-async-rw\":[\"tokio/io-util\",\"tokio-util/codec\"],\"transport-child-process\":[\"transport-async-rw\",\"tokio/process\",\"dep:process-wrap\"],\"transport-io\":[\"transport-async-rw\",\"tokio/io-std\"],\"transport-streamable-http-client\":[\"client-side-sse\",\"transport-worker\"],\"transport-streamable-http-client-reqwest\":[\"transport-streamable-http-client\",\"__reqwest\"],\"transport-streamable-http-client-unix-socket\":[\"transport-streamable-http-client\",\"dep:hyper\",\"dep:hyper-util\",\"dep:http-body-util\",\"dep:http\",\"dep:bytes\",\"tokio/net\"],\"transport-streamable-http-server\":[\"transport-streamable-http-server-session\",\"server-side-http\",\"transport-worker\"],\"transport-streamable-http-server-session\":[\"transport-async-rw\",\"dep:tokio-stream\"],\"transport-worker\":[\"dep:tokio-stream\"],\"which-command\":[\"transport-child-process\",\"dep:which\"]}}", "rtrb_0.3.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"crossbeam-utils\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.10\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "rust-embed-impl_8.11.0": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"name\":\"rust-embed-utils\",\"req\":\"^8.11.0\"},{\"name\":\"shellexpand\",\"optional\":true,\"req\":\"^3\"},{\"default_features\":false,\"features\":[\"derive\",\"parsing\",\"proc-macro\",\"printing\"],\"name\":\"syn\",\"req\":\"^2\"},{\"name\":\"walkdir\",\"req\":\"^2.3.1\"}],\"features\":{\"compression\":[],\"debug-embed\":[],\"deterministic-timestamps\":[],\"include-exclude\":[\"rust-embed-utils/include-exclude\"],\"interpolate-folder-path\":[\"shellexpand\"],\"mime-guess\":[\"rust-embed-utils/mime-guess\"]}}", "rust-embed-utils_8.11.0": "{\"dependencies\":[{\"name\":\"globset\",\"optional\":true,\"req\":\"^0.4.8\"},{\"name\":\"mime_guess\",\"optional\":true,\"req\":\"^2.0.4\"},{\"name\":\"sha2\",\"req\":\"^0.10.5\"},{\"name\":\"walkdir\",\"req\":\"^2.3.1\"}],\"features\":{\"debug-embed\":[],\"include-exclude\":[\"globset\"],\"mime-guess\":[\"mime_guess\"]}}", @@ -1452,6 +1457,8 @@ "rustix_1.1.4": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bitflags\",\"req\":\"^2.4.0\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4\",\"target\":\"cfg(all(criterion, not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"kind\":\"dev\",\"name\":\"flate2\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.182\",\"target\":\"cfg(all(not(windows), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.182\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.171\"},{\"default_features\":false,\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(all(not(windows), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(windows)\"},{\"default_features\":false,\"name\":\"libc_errno\",\"optional\":true,\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\"},{\"default_features\":false,\"features\":[\"general\",\"ioctl\",\"no_std\"],\"name\":\"linux-raw-sys\",\"req\":\"^0.12\",\"target\":\"cfg(all(any(target_os = \\\"linux\\\", target_os = \\\"android\\\"), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"features\":[\"auxvec\",\"general\",\"errno\",\"ioctl\",\"no_std\",\"elf\"],\"name\":\"linux-raw-sys\",\"req\":\"^0.12\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"kind\":\"dev\",\"name\":\"memoffset\",\"req\":\"^0.9.0\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.20.3\",\"target\":\"cfg(windows)\"},{\"name\":\"rustc-std-workspace-alloc\",\"optional\":true,\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^2.0.0\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.5.0\"},{\"features\":[\"Win32_Foundation\",\"Win32_Networking_WinSock\"],\"name\":\"windows-sys\",\"req\":\">=0.52, <0.62\",\"target\":\"cfg(windows)\"}],\"features\":{\"all-apis\":[\"event\",\"fs\",\"io_uring\",\"mm\",\"mount\",\"net\",\"param\",\"pipe\",\"process\",\"pty\",\"rand\",\"runtime\",\"shm\",\"stdio\",\"system\",\"termios\",\"thread\",\"time\"],\"alloc\":[],\"default\":[\"std\"],\"event\":[],\"fs\":[],\"io_uring\":[\"event\",\"fs\",\"net\",\"thread\",\"linux-raw-sys/io_uring\"],\"linux_4_11\":[],\"linux_5_1\":[\"linux_4_11\"],\"linux_5_11\":[\"linux_5_1\"],\"linux_latest\":[\"linux_5_11\"],\"mm\":[],\"mount\":[],\"net\":[\"linux-raw-sys/net\",\"linux-raw-sys/netlink\",\"linux-raw-sys/if_ether\",\"linux-raw-sys/xdp\"],\"param\":[],\"pipe\":[],\"process\":[\"linux-raw-sys/prctl\"],\"pty\":[\"fs\"],\"rand\":[],\"runtime\":[\"linux-raw-sys/prctl\"],\"rustc-dep-of-std\":[\"core\",\"rustc-std-workspace-alloc\",\"linux-raw-sys/rustc-dep-of-std\",\"bitflags/rustc-dep-of-std\"],\"shm\":[\"fs\"],\"std\":[\"bitflags/std\",\"alloc\",\"libc?/std\",\"libc_errno?/std\"],\"stdio\":[],\"system\":[\"linux-raw-sys/system\"],\"termios\":[],\"thread\":[\"linux-raw-sys/prctl\"],\"time\":[],\"try_close\":[],\"use-explicitly-provided-auxv\":[],\"use-libc\":[\"libc_errno\",\"libc\"],\"use-libc-auxv\":[]}}", "rustls-native-certs_0.8.3": "{\"dependencies\":[{\"name\":\"openssl-probe\",\"req\":\"^0.2\",\"target\":\"cfg(all(unix, not(target_os = \\\"macos\\\")))\"},{\"features\":[\"std\"],\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.10\"},{\"kind\":\"dev\",\"name\":\"ring\",\"req\":\"^0.17\"},{\"kind\":\"dev\",\"name\":\"rustls\",\"req\":\"^0.23\"},{\"kind\":\"dev\",\"name\":\"rustls-webpki\",\"req\":\"^0.103\"},{\"name\":\"schannel\",\"req\":\"^0.1\",\"target\":\"cfg(windows)\"},{\"name\":\"security-framework\",\"req\":\"^3\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^3\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.5\"},{\"kind\":\"dev\",\"name\":\"untrusted\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"webpki-roots\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.18\"}],\"features\":{}}", "rustls-pki-types_1.14.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"crabgrind\",\"req\":\"=0.1.9\",\"target\":\"cfg(all(target_os = \\\"linux\\\", target_arch = \\\"x86_64\\\"))\"},{\"name\":\"web-time\",\"optional\":true,\"req\":\"^1\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"alloc\":[\"dep:zeroize\"],\"default\":[\"alloc\"],\"std\":[\"alloc\"],\"web\":[\"web-time\"]}}", + "rustls-platform-verifier-android_0.1.1": "{\"dependencies\":[],\"features\":{}}", + "rustls-platform-verifier_0.7.0": "{\"dependencies\":[{\"name\":\"android_logger\",\"optional\":true,\"req\":\"^0.15\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"name\":\"base64\",\"optional\":true,\"req\":\"^0.22\"},{\"name\":\"core-foundation\",\"req\":\"^0.10\",\"target\":\"cfg(any(target_vendor = \\\"apple\\\"))\"},{\"name\":\"core-foundation-sys\",\"req\":\"^0.8\",\"target\":\"cfg(any(target_vendor = \\\"apple\\\"))\"},{\"default_features\":false,\"name\":\"jni\",\"req\":\"^0.22\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"default_features\":false,\"name\":\"jni\",\"optional\":true,\"req\":\"^0.22.4\"},{\"name\":\"log\",\"req\":\"^0.4\"},{\"name\":\"once_cell\",\"req\":\"^1.9\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"name\":\"once_cell\",\"optional\":true,\"req\":\"^1.9\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rustls\",\"req\":\"^0.23.27\"},{\"default_features\":false,\"features\":[\"ring\"],\"kind\":\"dev\",\"name\":\"rustls\",\"req\":\"^0.23\"},{\"name\":\"rustls-native-certs\",\"req\":\"^0.8\",\"target\":\"cfg(all(unix, not(target_os = \\\"android\\\"), not(target_vendor = \\\"apple\\\"), not(target_arch = \\\"wasm32\\\")))\"},{\"name\":\"rustls-platform-verifier-android\",\"req\":\"^0.1.0\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"name\":\"security-framework\",\"req\":\"^3.5.0\",\"target\":\"cfg(any(target_vendor = \\\"apple\\\"))\"},{\"name\":\"security-framework-sys\",\"req\":\"^2.15\",\"target\":\"cfg(any(target_vendor = \\\"apple\\\"))\"},{\"default_features\":false,\"name\":\"webpki\",\"package\":\"rustls-webpki\",\"req\":\"^0.103\",\"target\":\"cfg(all(unix, not(target_os = \\\"android\\\"), not(target_vendor = \\\"apple\\\"), not(target_arch = \\\"wasm32\\\")))\"},{\"default_features\":false,\"name\":\"webpki\",\"package\":\"rustls-webpki\",\"req\":\"^0.103\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"default_features\":false,\"name\":\"webpki\",\"package\":\"rustls-webpki\",\"req\":\"^0.103\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"name\":\"webpki-root-certs\",\"req\":\"^1\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"kind\":\"dev\",\"name\":\"webpki-root-certs\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"Win32_Foundation\",\"Win32_Security_Cryptography\"],\"name\":\"windows-sys\",\"req\":\">=0.52.0, <0.62.0\",\"target\":\"cfg(windows)\"}],\"features\":{\"cert-logging\":[\"base64\"],\"dbg\":[],\"docsrs\":[\"jni\",\"once_cell\"],\"ffi-testing\":[\"android_logger\",\"rustls/ring\"]}}", "rustls-webpki_0.103.13": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aws-lc-rs\",\"optional\":true,\"req\":\"^1.14\"},{\"kind\":\"dev\",\"name\":\"base64\",\"req\":\"^0.22\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"kind\":\"dev\",\"name\":\"bzip2\",\"req\":\"^0.6\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.17.2\"},{\"default_features\":false,\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.12\"},{\"default_features\":false,\"features\":[\"aws_lc_rs\"],\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.14.2\"},{\"default_features\":false,\"name\":\"ring\",\"optional\":true,\"req\":\"^0.17\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"untrusted\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.18.1\"}],\"features\":{\"alloc\":[\"ring?/alloc\",\"pki-types/alloc\"],\"aws-lc-rs\":[\"dep:aws-lc-rs\",\"aws-lc-rs/aws-lc-sys\",\"aws-lc-rs/prebuilt-nasm\"],\"aws-lc-rs-fips\":[\"dep:aws-lc-rs\",\"aws-lc-rs/fips\"],\"aws-lc-rs-unstable\":[\"aws-lc-rs\",\"aws-lc-rs/unstable\"],\"default\":[\"std\"],\"ring\":[\"dep:ring\"],\"std\":[\"alloc\",\"pki-types/std\"]}}", "rustls_0.23.36": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aws-lc-rs\",\"optional\":true,\"req\":\"^1.14\"},{\"kind\":\"dev\",\"name\":\"base64\",\"req\":\"^0.22\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"brotli\",\"optional\":true,\"req\":\"^8\"},{\"name\":\"brotli-decompressor\",\"optional\":true,\"req\":\"^5.0.0\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"default_features\":false,\"features\":[\"default-hasher\",\"inline-more\"],\"name\":\"hashbrown\",\"optional\":true,\"req\":\"^0.15\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.8\"},{\"kind\":\"dev\",\"name\":\"log\",\"req\":\"^0.4.8\"},{\"kind\":\"dev\",\"name\":\"macro_rules_attribute\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"num-bigint\",\"req\":\"^0.4.4\"},{\"default_features\":false,\"features\":[\"alloc\",\"race\"],\"name\":\"once_cell\",\"req\":\"^1.16\"},{\"features\":[\"alloc\"],\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.12\"},{\"default_features\":false,\"features\":[\"pem\",\"aws_lc_rs\"],\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.14\"},{\"name\":\"ring\",\"optional\":true,\"req\":\"^0.17\"},{\"kind\":\"build\",\"name\":\"rustversion\",\"optional\":true,\"req\":\"^1.0.6\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"subtle\",\"req\":\"^2.5.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3.6\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"webpki\",\"package\":\"rustls-webpki\",\"req\":\"^0.103.5\"},{\"kind\":\"dev\",\"name\":\"webpki-roots\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.17\"},{\"name\":\"zeroize\",\"req\":\"^1.8\"},{\"name\":\"zlib-rs\",\"optional\":true,\"req\":\"^0.5\"}],\"features\":{\"aws-lc-rs\":[\"aws_lc_rs\"],\"aws_lc_rs\":[\"dep:aws-lc-rs\",\"webpki/aws-lc-rs\",\"aws-lc-rs/aws-lc-sys\",\"aws-lc-rs/prebuilt-nasm\"],\"brotli\":[\"dep:brotli\",\"dep:brotli-decompressor\",\"std\"],\"custom-provider\":[],\"default\":[\"aws_lc_rs\",\"logging\",\"prefer-post-quantum\",\"std\",\"tls12\"],\"fips\":[\"aws_lc_rs\",\"aws-lc-rs?/fips\",\"webpki/aws-lc-rs-fips\"],\"logging\":[\"log\"],\"prefer-post-quantum\":[\"aws_lc_rs\"],\"read_buf\":[\"rustversion\",\"std\"],\"ring\":[\"dep:ring\",\"webpki/ring\"],\"std\":[\"webpki/std\",\"pki-types/std\",\"once_cell/std\"],\"tls12\":[],\"zlib\":[\"dep:zlib-rs\"]}}", "rustversion_1.0.22": "{\"dependencies\":[{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.49\"}],\"features\":{}}", @@ -1525,6 +1532,7 @@ "signal-hook_0.3.18": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^0.7\"},{\"name\":\"signal-hook-registry\",\"req\":\"^1.4\"}],\"features\":{\"channel\":[],\"default\":[\"channel\",\"iterator\"],\"extended-siginfo\":[\"channel\",\"iterator\",\"extended-siginfo-raw\"],\"extended-siginfo-raw\":[\"cc\"],\"iterator\":[\"channel\"]}}", "signature_2.2.0": "{\"dependencies\":[{\"name\":\"derive\",\"optional\":true,\"package\":\"signature_derive\",\"req\":\"^2\"},{\"default_features\":false,\"name\":\"digest\",\"optional\":true,\"req\":\"^0.10.6\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"rand_core\",\"optional\":true,\"req\":\"^0.6.4\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.10\"}],\"features\":{\"alloc\":[],\"std\":[\"alloc\",\"rand_core?/std\"]}}", "simd-adler32_0.3.8": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"adler\",\"req\":\"^1.0.2\"},{\"kind\":\"dev\",\"name\":\"adler32\",\"req\":\"^1.2.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"}],\"features\":{\"const-generics\":[],\"default\":[\"std\",\"const-generics\"],\"nightly\":[],\"std\":[]}}", + "simd_cesu8_1.1.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"cesu8\",\"req\":\"^1.1.0\"},{\"features\":[\"html_reports\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.1\"},{\"kind\":\"build\",\"name\":\"rustc_version\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"name\":\"simdutf8\",\"req\":\"^0.1.4\"}],\"features\":{\"bench\":[],\"default\":[\"std\"],\"nightly\":[],\"std\":[\"simdutf8/std\"]}}", "simdutf8_0.1.5": "{\"dependencies\":[],\"features\":{\"aarch64_neon\":[],\"aarch64_neon_prefetch\":[],\"default\":[\"std\"],\"hints\":[],\"public_imp\":[],\"std\":[]}}", "similar_2.7.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bstr\",\"optional\":true,\"req\":\"^1.5.0\"},{\"kind\":\"dev\",\"name\":\"console\",\"req\":\"^0.15.0\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.10.0\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.130\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.68\"},{\"name\":\"unicode-segmentation\",\"optional\":true,\"req\":\"^1.7.1\"},{\"name\":\"web-time\",\"optional\":true,\"req\":\"^1.1\"}],\"features\":{\"bytes\":[\"bstr\",\"text\"],\"default\":[\"text\"],\"inline\":[\"text\"],\"text\":[],\"unicode\":[\"text\",\"unicode-segmentation\",\"bstr?/unicode\",\"bstr?/std\"],\"wasm32_web_time\":[\"web-time\"]}}", "simple_asn1_0.6.4": "{\"dependencies\":[{\"default_features\":false,\"name\":\"num-bigint\",\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"num-traits\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.4\"},{\"default_features\":false,\"name\":\"thiserror\",\"req\":\"^2\"},{\"default_features\":false,\"features\":[\"formatting\",\"macros\",\"parsing\"],\"name\":\"time\",\"req\":\"^0.3.47\"},{\"default_features\":false,\"features\":[\"formatting\",\"macros\",\"parsing\",\"quickcheck\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3\"}],\"features\":{}}", @@ -1719,6 +1727,7 @@ "wasm-encoder_0.244.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.58\"},{\"default_features\":false,\"name\":\"leb128fmt\",\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.2.0\"},{\"default_features\":false,\"features\":[\"simd\",\"simd\"],\"name\":\"wasmparser\",\"optional\":true,\"req\":\"^0.244.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"wasmprinter\",\"req\":\"^0.244.0\"}],\"features\":{\"component-model\":[\"wasmparser?/component-model\"],\"default\":[\"std\",\"component-model\"],\"std\":[\"wasmparser?/std\"]}}", "wasm-metadata_0.244.0": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0.58\"},{\"name\":\"auditable-serde\",\"optional\":true,\"req\":\"^0.8.0\"},{\"features\":[\"derive\"],\"name\":\"clap\",\"optional\":true,\"req\":\"^4.0.0\"},{\"name\":\"flate2\",\"optional\":true,\"req\":\"^1.1.0\"},{\"default_features\":false,\"features\":[\"serde\"],\"name\":\"indexmap\",\"req\":\"^2.7.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.166\"},{\"name\":\"serde_derive\",\"optional\":true,\"req\":\"^1.0.166\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"spdx\",\"optional\":true,\"req\":\"^0.10.1\"},{\"name\":\"url\",\"optional\":true,\"req\":\"^2.0.0\"},{\"default_features\":false,\"features\":[\"std\",\"component-model\"],\"name\":\"wasm-encoder\",\"req\":\"^0.244.0\"},{\"default_features\":false,\"features\":[\"simd\",\"std\",\"component-model\",\"hash-collections\"],\"name\":\"wasmparser\",\"req\":\"^0.244.0\"}],\"features\":{\"default\":[\"oci\",\"serde\"],\"oci\":[\"dep:auditable-serde\",\"dep:flate2\",\"dep:url\",\"dep:spdx\",\"dep:serde_json\",\"serde\"],\"serde\":[\"dep:serde_derive\",\"dep:serde\"]}}", "wasm-streams_0.4.2": "{\"dependencies\":[{\"features\":[\"io\",\"sink\"],\"name\":\"futures-util\",\"req\":\"^0.3.31\"},{\"features\":[\"futures\"],\"kind\":\"dev\",\"name\":\"gloo-timers\",\"req\":\"^0.3.0\"},{\"name\":\"js-sys\",\"req\":\"^0.3.72\"},{\"kind\":\"dev\",\"name\":\"pin-project\",\"req\":\"^1\"},{\"features\":[\"macros\",\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"name\":\"wasm-bindgen\",\"req\":\"^0.2.95\"},{\"name\":\"wasm-bindgen-futures\",\"req\":\"^0.4.45\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.45\"},{\"features\":[\"AbortSignal\",\"QueuingStrategy\",\"ReadableStream\",\"ReadableStreamType\",\"ReadableWritablePair\",\"ReadableStreamByobReader\",\"ReadableStreamReaderMode\",\"ReadableStreamReadResult\",\"ReadableStreamByobRequest\",\"ReadableStreamDefaultReader\",\"ReadableByteStreamController\",\"ReadableStreamGetReaderOptions\",\"ReadableStreamDefaultController\",\"StreamPipeOptions\",\"TransformStream\",\"TransformStreamDefaultController\",\"Transformer\",\"UnderlyingSink\",\"UnderlyingSource\",\"WritableStream\",\"WritableStreamDefaultController\",\"WritableStreamDefaultWriter\"],\"name\":\"web-sys\",\"req\":\"^0.3.72\"},{\"features\":[\"console\",\"AbortSignal\",\"ErrorEvent\",\"PromiseRejectionEvent\",\"Response\",\"ReadableStream\",\"Window\"],\"kind\":\"dev\",\"name\":\"web-sys\",\"req\":\"^0.3.72\"}],\"features\":{}}", + "wasm-streams_0.5.0": "{\"dependencies\":[{\"features\":[\"io\",\"sink\"],\"name\":\"futures-util\",\"req\":\"^0.3.31\"},{\"features\":[\"futures\"],\"kind\":\"dev\",\"name\":\"gloo-timers\",\"req\":\"^0.3.0\"},{\"name\":\"js-sys\",\"req\":\"^0.3.85\"},{\"kind\":\"dev\",\"name\":\"pin-project\",\"req\":\"^1\"},{\"features\":[\"macros\",\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"name\":\"wasm-bindgen\",\"req\":\"^0.2.108\"},{\"name\":\"wasm-bindgen-futures\",\"req\":\"^0.4.58\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.58\"},{\"features\":[\"AbortSignal\",\"QueuingStrategy\",\"ReadableStream\",\"ReadableStreamType\",\"ReadableWritablePair\",\"ReadableStreamByobReader\",\"ReadableStreamReaderMode\",\"ReadableStreamReadResult\",\"ReadableStreamByobRequest\",\"ReadableStreamDefaultReader\",\"ReadableByteStreamController\",\"ReadableStreamGetReaderOptions\",\"ReadableStreamDefaultController\",\"StreamPipeOptions\",\"TransformStream\",\"TransformStreamDefaultController\",\"Transformer\",\"UnderlyingSink\",\"UnderlyingSource\",\"WritableStream\",\"WritableStreamDefaultController\",\"WritableStreamDefaultWriter\"],\"name\":\"web-sys\",\"req\":\"^0.3.85\"},{\"features\":[\"console\",\"AbortSignal\",\"ErrorEvent\",\"PromiseRejectionEvent\",\"Response\",\"ReadableStream\",\"Window\"],\"kind\":\"dev\",\"name\":\"web-sys\",\"req\":\"^0.3.85\"}],\"features\":{}}", "wasmparser_0.244.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.58\"},{\"name\":\"bitflags\",\"req\":\"^2.4.1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.1\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"default_features\":false,\"features\":[\"default-hasher\"],\"name\":\"hashbrown\",\"optional\":true,\"req\":\"^0.15.2\"},{\"default_features\":false,\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2.7.0\"},{\"kind\":\"dev\",\"name\":\"log\",\"req\":\"^0.4.17\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.13.0\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.3\"},{\"default_features\":false,\"name\":\"semver\",\"optional\":true,\"req\":\"^1.0.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.166\"}],\"features\":{\"component-model\":[\"dep:semver\"],\"default\":[\"std\",\"validate\",\"serde\",\"features\",\"component-model\",\"hash-collections\",\"simd\"],\"features\":[],\"hash-collections\":[\"dep:hashbrown\",\"dep:indexmap\"],\"prefer-btree-collections\":[],\"serde\":[\"dep:serde\",\"indexmap?/serde\",\"hashbrown?/serde\"],\"simd\":[],\"std\":[\"indexmap?/std\"],\"validate\":[]}}", "wayland-backend_0.3.12": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"concat-idents\",\"req\":\"^1.1\"},{\"name\":\"downcast-rs\",\"req\":\"^1.2\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"raw-window-handle\",\"optional\":true,\"req\":\"^0.5.0\"},{\"features\":[\"event\",\"fs\",\"net\",\"process\"],\"name\":\"rustix\",\"req\":\"^1.0.2\"},{\"name\":\"rwh_06\",\"optional\":true,\"package\":\"raw-window-handle\",\"req\":\"^0.6.0\"},{\"name\":\"scoped-tls\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"scoped-tls\",\"req\":\"^1.0\"},{\"features\":[\"union\",\"const_generics\",\"const_new\"],\"name\":\"smallvec\",\"req\":\"^1.9\"},{\"name\":\"wayland-sys\",\"req\":\"^0.31.8\"}],\"features\":{\"client_system\":[\"wayland-sys/client\",\"dep:scoped-tls\"],\"dlopen\":[\"wayland-sys/dlopen\"],\"server_system\":[\"wayland-sys/server\",\"dep:scoped-tls\"]}}", "wayland-client_0.31.12": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"futures-channel\",\"req\":\"^0.3.16\"},{\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4\"},{\"features\":[\"event\"],\"name\":\"rustix\",\"req\":\"^1.0.2\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.2\"},{\"name\":\"wayland-backend\",\"req\":\"^0.3.12\"},{\"name\":\"wayland-scanner\",\"req\":\"^0.31.8\"}],\"features\":{}}", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 94b8bc1be61..405eadcedef 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1147,7 +1147,6 @@ dependencies = [ "axum-core", "base64 0.22.1", "bytes", - "form_urlencoded", "futures-util", "http 1.4.0", "http-body 1.0.1", @@ -1163,7 +1162,6 @@ dependencies = [ "serde_core", "serde_json", "serde_path_to_error", - "serde_urlencoded", "sha1 0.10.6", "sync_wrapper", "tokio", @@ -1171,7 +1169,6 @@ dependencies = [ "tower", "tower-layer", "tower-service", - "tracing", ] [[package]] @@ -1190,7 +1187,6 @@ dependencies = [ "sync_wrapper", "tower-layer", "tower-service", - "tracing", ] [[package]] @@ -1830,7 +1826,7 @@ dependencies = [ "jsonwebtoken", "pretty_assertions", "rand 0.9.3", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "sha2 0.10.9", @@ -1884,7 +1880,7 @@ dependencies = [ "http 1.4.0", "pretty_assertions", "regex-lite", - "reqwest", + "reqwest 0.12.28", "schemars 0.8.22", "serde", "serde_json", @@ -1932,6 +1928,7 @@ dependencies = [ "codex-git-utils", "codex-guardian", "codex-hooks", + "codex-image-generation-extension", "codex-login", "codex-mcp", "codex-memories-extension", @@ -1963,7 +1960,7 @@ dependencies = [ "opentelemetry", "opentelemetry_sdk", "pretty_assertions", - "reqwest", + "reqwest 0.12.28", "rmcp", "serde", "serde_json", @@ -2026,7 +2023,7 @@ dependencies = [ "futures", "libc", "pretty_assertions", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "sha2 0.10.9", @@ -2196,7 +2193,7 @@ dependencies = [ "codex-model-provider", "codex-protocol", "pretty_assertions", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", ] @@ -2311,6 +2308,7 @@ dependencies = [ "tracing-appender", "tracing-subscriber", "unicode-segmentation", + "url", "which 8.0.0", "windows-sys 0.52.0", ] @@ -2331,7 +2329,7 @@ dependencies = [ "pretty_assertions", "rand 0.9.3", "rcgen", - "reqwest", + "reqwest 0.12.28", "rustls", "rustls-native-certs", "rustls-pki-types", @@ -2393,7 +2391,7 @@ dependencies = [ "owo-colors", "pretty_assertions", "ratatui", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "supports-color 3.0.2", @@ -2604,7 +2602,7 @@ dependencies = [ "pretty_assertions", "rand 0.9.3", "regex-lite", - "reqwest", + "reqwest 0.12.28", "rmcp", "serde", "serde_json", @@ -2677,7 +2675,7 @@ dependencies = [ "flate2", "libc", "pretty_assertions", - "reqwest", + "reqwest 0.12.28", "semver", "serde", "serde_json", @@ -2806,7 +2804,7 @@ dependencies = [ "http 1.4.0", "pretty_assertions", "prost 0.14.3", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "serial_test", @@ -3045,6 +3043,31 @@ dependencies = [ "uuid", ] +[[package]] +name = "codex-install-context" +version = "0.135.0-alpha.2" +dependencies = [ + "async-trait", + "base64 0.22.1", + "codex-api", + "codex-core", + "codex-extension-api", + "codex-features", + "codex-login", + "codex-model-provider", + "codex-model-provider-info", + "codex-protocol", + "codex-tools", + "http 1.4.0", + "pretty_assertions", + "schemars 0.8.22", + "serde", + "serde_json", + "tempfile", + "tokio", + "tracing", +] + [[package]] name = "codex-install-context" version = "0.135.0-alpha.2" @@ -3093,7 +3116,7 @@ version = "0.135.0-alpha.2" dependencies = [ "codex-core", "codex-model-provider-info", - "reqwest", + "reqwest 0.12.28", "serde_json", "tokio", "tracing", @@ -3127,7 +3150,7 @@ dependencies = [ "pretty_assertions", "rand 0.9.3", "regex-lite", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "serial_test", @@ -3391,7 +3414,7 @@ dependencies = [ "codex-model-provider-info", "futures", "pretty_assertions", - "reqwest", + "reqwest 0.12.28", "semver", "serde_json", "tokio", @@ -3419,7 +3442,7 @@ dependencies = [ "opentelemetry_sdk", "os_info", "pretty_assertions", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "strum_macros 0.28.0", @@ -3471,7 +3494,7 @@ dependencies = [ "landlock", "pretty_assertions", "quick-xml", - "reqwest", + "reqwest 0.12.28", "schemars 0.8.22", "seccompiler", "serde", @@ -3519,7 +3542,7 @@ dependencies = [ "ctor 0.6.3", "libc", "pretty_assertions", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "tiny_http", @@ -3547,7 +3570,7 @@ dependencies = [ "keyring", "oauth2", "pretty_assertions", - "reqwest", + "reqwest 0.13.4", "rmcp", "serde", "serde_json", @@ -3881,7 +3904,7 @@ dependencies = [ "ratatui", "ratatui-macros", "regex-lite", - "reqwest", + "reqwest 0.12.28", "rmcp", "serde", "serde_json", @@ -4456,7 +4479,7 @@ dependencies = [ "opentelemetry_sdk", "pretty_assertions", "regex-lite", - "reqwest", + "reqwest 0.12.28", "serde_json", "shlex", "similar", @@ -4501,7 +4524,7 @@ dependencies = [ "core-foundation-sys", "coreaudio-rs", "dasp_sample", - "jni", + "jni 0.21.1", "js-sys", "libc", "mach2", @@ -8245,19 +8268,68 @@ dependencies = [ "cesu8", "cfg-if", "combine", - "jni-sys", + "jni-sys 0.3.0", "log", "thiserror 1.0.69", "walkdir", "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.114", +] + [[package]] name = "jni-sys" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.114", +] + [[package]] name = "jobserver" version = "0.1.34" @@ -8466,7 +8538,7 @@ source = "git+https://github.com/juberti-oai/rust-sdks.git?rev=e2d1d1d230c6fc9df dependencies = [ "cxx", "glib", - "jni", + "jni 0.21.1", "js-sys", "lazy_static", "livekit-protocol", @@ -8907,7 +8979,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" dependencies = [ "bitflags 2.10.0", - "jni-sys", + "jni-sys 0.3.0", "log", "ndk-sys", "num_enum", @@ -8926,7 +8998,7 @@ version = "0.5.0+25.2.9519653" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" dependencies = [ - "jni-sys", + "jni-sys 0.3.0", ] [[package]] @@ -9209,7 +9281,7 @@ dependencies = [ "getrandom 0.2.17", "http 1.4.0", "rand 0.8.5", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "serde_path_to_error", @@ -9404,7 +9476,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb" dependencies = [ - "jni", + "jni 0.21.1", "ndk", "ndk-context", "num-derive", @@ -9570,7 +9642,7 @@ dependencies = [ "bytes", "http 1.4.0", "opentelemetry", - "reqwest", + "reqwest 0.12.28", ] [[package]] @@ -9585,7 +9657,7 @@ dependencies = [ "opentelemetry-proto", "opentelemetry_sdk", "prost 0.14.3", - "reqwest", + "reqwest 0.12.28", "serde_json", "thiserror 2.0.18", "tokio", @@ -10407,6 +10479,7 @@ version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ + "aws-lc-rs", "bytes", "getrandom 0.3.4", "lru-slab", @@ -11082,11 +11155,51 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wasm-streams 0.4.2", "web-sys", "webpki-roots", ] +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.5.0", + "web-sys", +] + [[package]] name = "resb" version = "0.1.2" @@ -11129,12 +11242,11 @@ dependencies = [ [[package]] name = "rmcp" -version = "0.15.0" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bef41ebc9ebed2c1b1d90203e9d1756091e8a00bbc3107676151f39868ca0ee" +checksum = "0810a9f717d9828f475fe1f629f4c305c8464b7f496c3a854b58d29e65f4058e" dependencies = [ "async-trait", - "axum", "base64 0.22.1", "bytes", "chrono", @@ -11146,8 +11258,8 @@ dependencies = [ "pastey", "pin-project-lite", "process-wrap", - "rand 0.9.3", - "reqwest", + "rand 0.10.1", + "reqwest 0.13.4", "rmcp-macros", "schemars 1.2.1", "serde", @@ -11165,9 +11277,9 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "0.15.0" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e88ad84b8b6237a934534a62b379a5be6388915663c0cc598ceb9b3292bbbfe" +checksum = "6aefac48c364756e97f04c0401ba3231e8607882c7c1d92da0437dc16307904d" dependencies = [ "darling 0.23.0", "proc-macro2", @@ -11331,6 +11443,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework 3.5.1", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.103.13" @@ -11668,7 +11807,7 @@ checksum = "2f925d575b468e88b079faf590a8dd0c9c99e2ec29e9bab663ceb8b45056312f" dependencies = [ "httpdate", "native-tls", - "reqwest", + "reqwest 0.12.28", "sentry-actix", "sentry-backtrace", "sentry-contexts", @@ -12121,6 +12260,16 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + [[package]] name = "simdutf8" version = "0.1.5" @@ -14111,6 +14260,19 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasmparser" version = "0.244.0" @@ -14220,7 +14382,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00f1243ef785213e3a32fa0396093424a3a6ea566f9948497e5a2309261a4c97" dependencies = [ "core-foundation 0.10.1", - "jni", + "jni 0.21.1", "log", "ndk-context", "objc2", @@ -14269,7 +14431,7 @@ dependencies = [ "anyhow", "fs2", "regex", - "reqwest", + "reqwest 0.12.28", "scratch", "semver", "zip 0.6.6", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 0fdf4ce7c8e..96ff6a8fb82 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -47,6 +47,7 @@ members = [ "ext/extension-api", "ext/goal", "ext/guardian", + "ext/image-generation", "ext/memories", "ext/web-search", "external-agent-migration", @@ -118,7 +119,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.135.0" +version = "0.136.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 @@ -166,6 +167,7 @@ codex-execpolicy = { path = "execpolicy" } codex-extension-api = { path = "ext/extension-api" } codex-goal-extension = { path = "ext/goal" } codex-guardian = { path = "ext/guardian" } +codex-image-generation-extension = { path = "ext/image-generation" } codex-external-agent-migration = { path = "external-agent-migration" } codex-external-agent-sessions = { path = "external-agent-sessions" } codex-experimental-api-macros = { path = "codex-experimental-api-macros" } @@ -344,7 +346,7 @@ rcgen = { version = "0.14.7", default-features = false, features = [ regex = "1.12.3" regex-lite = "0.1.8" reqwest = { version = "0.12", features = ["cookies"] } -rmcp = { version = "0.15.0", default-features = false } +rmcp = { version = "1.7.0", default-features = false } runfiles = { git = "https://github.com/dzbarsky/rules_rust", rev = "b56cbaa8465e74127f1ea216f813cd377295ad81" } rustls = { version = "0.23", default-features = false, features = [ "ring", diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index fa64b46d7a1..48379cc39b4 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -263,6 +263,7 @@ fn sample_thread_resume_response_with_source( sandbox: AppServerSandboxPolicy::DangerFullAccess, active_permission_profile: None, reasoning_effort: None, + initial_turns_page: None, }) } diff --git a/codex-rs/analytics/src/client_tests.rs b/codex-rs/analytics/src/client_tests.rs index c6a928afa3f..d0f8fc40e28 100644 --- a/codex-rs/analytics/src/client_tests.rs +++ b/codex-rs/analytics/src/client_tests.rs @@ -172,6 +172,7 @@ fn sample_thread_resume_response() -> ClientResponsePayload { sandbox: AppServerSandboxPolicy::DangerFullAccess, active_permission_profile: None, reasoning_effort: None, + initial_turns_page: None, }) } diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 63e8fbde1a1..a09d793bfef 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -3543,6 +3543,42 @@ } ] }, + "ThreadResumeInitialTurnsPageParams": { + "properties": { + "itemsView": { + "anyOf": [ + { + "$ref": "#/definitions/TurnItemsView" + }, + { + "type": "null" + } + ], + "description": "How much item detail to include for each returned turn; defaults to summary." + }, + "limit": { + "description": "Optional turn page size.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "sortDirection": { + "anyOf": [ + { + "$ref": "#/definitions/SortDirection" + }, + { + "type": "null" + } + ], + "description": "Optional turn pagination direction; defaults to descending." + } + }, + "type": "object" + }, "ThreadResumeParams": { "description": "There are three ways to resume a thread: 1. By thread_id: load the thread from disk by thread_id and resume it. 2. By history: instantiate the thread from memory and resume it. 3. By path: load the thread from disk by path and resume it.\n\nFor non-running threads, the precedence is: history > non-empty path > thread_id. If using history or a non-empty path for a non-running thread, the thread_id param will be ignored.\n\nIf thread_id identifies a running thread, app-server rejoins that thread and treats a non-empty path as a consistency check against the active rollout path. Empty string path values are treated as absent.\n\nPrefer using thread_id whenever possible.", "properties": { 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 28dc006b5e5..c5ce0f86229 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 @@ -10964,6 +10964,47 @@ "title": "McpResourceReadResponse", "type": "object" }, + "McpServerInfo": { + "description": "Presentation metadata advertised by an initialized MCP server.", + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "icons": { + "items": true, + "type": [ + "array", + "null" + ] + }, + "name": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "version": { + "type": "string" + }, + "websiteUrl": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "name", + "version" + ], + "type": "object" + }, "McpServerMigration": { "properties": { "name": { @@ -11074,6 +11115,16 @@ }, "type": "array" }, + "serverInfo": { + "anyOf": [ + { + "$ref": "#/definitions/v2/McpServerInfo" + }, + { + "type": "null" + } + ] + }, "tools": { "additionalProperties": { "$ref": "#/definitions/v2/Tool" @@ -17150,6 +17201,42 @@ "title": "ThreadRealtimeTranscriptDoneNotification", "type": "object" }, + "ThreadResumeInitialTurnsPageParams": { + "properties": { + "itemsView": { + "anyOf": [ + { + "$ref": "#/definitions/v2/TurnItemsView" + }, + { + "type": "null" + } + ], + "description": "How much item detail to include for each returned turn; defaults to summary." + }, + "limit": { + "description": "Optional turn page size.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "sortDirection": { + "anyOf": [ + { + "$ref": "#/definitions/v2/SortDirection" + }, + { + "type": "null" + } + ], + "description": "Optional turn pagination direction; defaults to descending." + } + }, + "type": "object" + }, "ThreadResumeParams": { "$schema": "http://json-schema.org/draft-07/schema#", "description": "There are three ways to resume a thread: 1. By thread_id: load the thread from disk by thread_id and resume it. 2. By history: instantiate the thread from memory and resume it. 3. By path: load the thread from disk by path and resume it.\n\nFor non-running threads, the precedence is: history > non-empty path > thread_id. If using history or a non-empty path for a non-running thread, the thread_id param will be ignored.\n\nIf thread_id identifies a running thread, app-server rejoins that thread and treats a non-empty path as a consistency check against the active rollout path. Empty string path values are treated as absent.\n\nPrefer using thread_id whenever possible.", @@ -18488,6 +18575,32 @@ "title": "TurnSteerResponse", "type": "object" }, + "TurnsPage": { + "properties": { + "backwardsCursor": { + "type": [ + "string", + "null" + ] + }, + "data": { + "items": { + "$ref": "#/definitions/v2/Turn" + }, + "type": "array" + }, + "nextCursor": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "data" + ], + "type": "object" + }, "UserInput": { "oneOf": [ { 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 d1c59e98558..92134a2df3c 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 @@ -7493,6 +7493,47 @@ "title": "McpResourceReadResponse", "type": "object" }, + "McpServerInfo": { + "description": "Presentation metadata advertised by an initialized MCP server.", + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "icons": { + "items": true, + "type": [ + "array", + "null" + ] + }, + "name": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "version": { + "type": "string" + }, + "websiteUrl": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "name", + "version" + ], + "type": "object" + }, "McpServerMigration": { "properties": { "name": { @@ -7603,6 +7644,16 @@ }, "type": "array" }, + "serverInfo": { + "anyOf": [ + { + "$ref": "#/definitions/McpServerInfo" + }, + { + "type": "null" + } + ] + }, "tools": { "additionalProperties": { "$ref": "#/definitions/Tool" @@ -14974,6 +15025,42 @@ "title": "ThreadRealtimeTranscriptDoneNotification", "type": "object" }, + "ThreadResumeInitialTurnsPageParams": { + "properties": { + "itemsView": { + "anyOf": [ + { + "$ref": "#/definitions/TurnItemsView" + }, + { + "type": "null" + } + ], + "description": "How much item detail to include for each returned turn; defaults to summary." + }, + "limit": { + "description": "Optional turn page size.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "sortDirection": { + "anyOf": [ + { + "$ref": "#/definitions/SortDirection" + }, + { + "type": "null" + } + ], + "description": "Optional turn pagination direction; defaults to descending." + } + }, + "type": "object" + }, "ThreadResumeParams": { "$schema": "http://json-schema.org/draft-07/schema#", "description": "There are three ways to resume a thread: 1. By thread_id: load the thread from disk by thread_id and resume it. 2. By history: instantiate the thread from memory and resume it. 3. By path: load the thread from disk by path and resume it.\n\nFor non-running threads, the precedence is: history > non-empty path > thread_id. If using history or a non-empty path for a non-running thread, the thread_id param will be ignored.\n\nIf thread_id identifies a running thread, app-server rejoins that thread and treats a non-empty path as a consistency check against the active rollout path. Empty string path values are treated as absent.\n\nPrefer using thread_id whenever possible.", @@ -16312,6 +16399,32 @@ "title": "TurnSteerResponse", "type": "object" }, + "TurnsPage": { + "properties": { + "backwardsCursor": { + "type": [ + "string", + "null" + ] + }, + "data": { + "items": { + "$ref": "#/definitions/Turn" + }, + "type": "array" + }, + "nextCursor": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "data" + ], + "type": "object" + }, "UserInput": { "oneOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ListMcpServerStatusResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ListMcpServerStatusResponse.json index fc181c2702e..0dc2f5e2e4b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ListMcpServerStatusResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ListMcpServerStatusResponse.json @@ -10,6 +10,47 @@ ], "type": "string" }, + "McpServerInfo": { + "description": "Presentation metadata advertised by an initialized MCP server.", + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "icons": { + "items": true, + "type": [ + "array", + "null" + ] + }, + "name": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "version": { + "type": "string" + }, + "websiteUrl": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "name", + "version" + ], + "type": "object" + }, "McpServerStatus": { "properties": { "authStatus": { @@ -30,6 +71,16 @@ }, "type": "array" }, + "serverInfo": { + "anyOf": [ + { + "$ref": "#/definitions/McpServerInfo" + }, + { + "type": "null" + } + ] + }, "tools": { "additionalProperties": { "$ref": "#/definitions/Tool" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json index 58b85d41a4c..364cb36617d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -983,6 +983,74 @@ "danger-full-access" ], "type": "string" + }, + "SortDirection": { + "enum": [ + "asc", + "desc" + ], + "type": "string" + }, + "ThreadResumeInitialTurnsPageParams": { + "properties": { + "itemsView": { + "anyOf": [ + { + "$ref": "#/definitions/TurnItemsView" + }, + { + "type": "null" + } + ], + "description": "How much item detail to include for each returned turn; defaults to summary." + }, + "limit": { + "description": "Optional turn page size.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "sortDirection": { + "anyOf": [ + { + "$ref": "#/definitions/SortDirection" + }, + { + "type": "null" + } + ], + "description": "Optional turn pagination direction; defaults to descending." + } + }, + "type": "object" + }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "enum": [ + "notLoaded" + ], + "type": "string" + }, + { + "description": "`items` contains only a display summary for this turn.", + "enum": [ + "summary" + ], + "type": "string" + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "enum": [ + "full" + ], + "type": "string" + } + ] } }, "description": "There are three ways to resume a thread: 1. By thread_id: load the thread from disk by thread_id and resume it. 2. By history: instantiate the thread from memory and resume it. 3. By path: load the thread from disk by path and resume it.\n\nFor non-running threads, the precedence is: history > non-empty path > thread_id. If using history or a non-empty path for a non-running thread, the thread_id param will be ignored.\n\nIf thread_id identifies a running thread, app-server rejoins that thread and treats a non-empty path as a consistency check against the active rollout path. Empty string path values are treated as absent.\n\nPrefer using thread_id whenever possible.", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index b4fa181c886..8f76893af8f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -1995,6 +1995,32 @@ ], "type": "string" }, + "TurnsPage": { + "properties": { + "backwardsCursor": { + "type": [ + "string", + "null" + ] + }, + "data": { + "items": { + "$ref": "#/definitions/Turn" + }, + "type": "array" + }, + "nextCursor": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "data" + ], + "type": "object" + }, "UserInput": { "oneOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/McpServerInfo.ts b/codex-rs/app-server-protocol/schema/typescript/McpServerInfo.ts new file mode 100644 index 00000000000..a3f6b0e1428 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/McpServerInfo.ts @@ -0,0 +1,9 @@ +// 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 { JsonValue } from "./serde_json/JsonValue"; + +/** + * Presentation metadata advertised by an initialized MCP server. + */ +export type McpServerInfo = { name: string, title: string | null, version: string, description: string | null, icons: Array | null, websiteUrl: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 8be75af546f..458d2e43b9a 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -42,6 +42,7 @@ export type { InternalSessionSource } from "./InternalSessionSource"; export type { LocalShellAction } from "./LocalShellAction"; export type { LocalShellExecAction } from "./LocalShellExecAction"; export type { LocalShellStatus } from "./LocalShellStatus"; +export type { McpServerInfo } from "./McpServerInfo"; export type { MessagePhase } from "./MessagePhase"; export type { ModeKind } from "./ModeKind"; export type { NetworkPolicyAmendment } from "./NetworkPolicyAmendment"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/McpServerStatus.ts b/codex-rs/app-server-protocol/schema/typescript/v2/McpServerStatus.ts index 430494e2687..d2e99ce96fd 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/McpServerStatus.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/McpServerStatus.ts @@ -1,9 +1,10 @@ // 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 { McpServerInfo } from "../McpServerInfo"; import type { Resource } from "../Resource"; import type { ResourceTemplate } from "../ResourceTemplate"; import type { Tool } from "../Tool"; import type { McpAuthStatus } from "./McpAuthStatus"; -export type McpServerStatus = { name: string, tools: { [key in string]?: Tool }, resources: Array, resourceTemplates: Array, authStatus: McpAuthStatus, }; +export type McpServerStatus = { name: string, serverInfo: McpServerInfo | null, tools: { [key in string]?: Tool }, resources: Array, resourceTemplates: Array, authStatus: McpAuthStatus, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeInitialTurnsPageParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeInitialTurnsPageParams.ts new file mode 100644 index 00000000000..2dbcd97807b --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeInitialTurnsPageParams.ts @@ -0,0 +1,19 @@ +// 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 { SortDirection } from "./SortDirection"; +import type { TurnItemsView } from "./TurnItemsView"; + +export type ThreadResumeInitialTurnsPageParams = { +/** + * Optional turn page size. + */ +limit?: number | null, +/** + * Optional turn pagination direction; defaults to descending. + */ +sortDirection?: SortDirection | null, +/** + * How much item detail to include for each returned turn; defaults to summary. + */ +itemsView?: TurnItemsView | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnsPage.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnsPage.ts new file mode 100644 index 00000000000..e91865aeeb1 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnsPage.ts @@ -0,0 +1,6 @@ +// 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 { Turn } from "./Turn"; + +export type TurnsPage = { data: Array, nextCursor: string | null, backwardsCursor: string | 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 2a4220cb605..0b7316fc4fd 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -398,6 +398,7 @@ export type { ThreadRealtimeStartTransport } from "./ThreadRealtimeStartTranspor export type { ThreadRealtimeStartedNotification } from "./ThreadRealtimeStartedNotification"; export type { ThreadRealtimeTranscriptDeltaNotification } from "./ThreadRealtimeTranscriptDeltaNotification"; export type { ThreadRealtimeTranscriptDoneNotification } from "./ThreadRealtimeTranscriptDoneNotification"; +export type { ThreadResumeInitialTurnsPageParams } from "./ThreadResumeInitialTurnsPageParams"; export type { ThreadResumeParams } from "./ThreadResumeParams"; export type { ThreadResumeResponse } from "./ThreadResumeResponse"; export type { ThreadRollbackParams } from "./ThreadRollbackParams"; @@ -450,6 +451,7 @@ export type { TurnStartedNotification } from "./TurnStartedNotification"; export type { TurnStatus } from "./TurnStatus"; export type { TurnSteerParams } from "./TurnSteerParams"; export type { TurnSteerResponse } from "./TurnSteerResponse"; +export type { TurnsPage } from "./TurnsPage"; export type { UserInput } from "./UserInput"; export type { WarningNotification } from "./WarningNotification"; export type { WebSearchAction } from "./WebSearchAction"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs b/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs index 5f92e830599..ae61f12b2d0 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs @@ -2,6 +2,7 @@ use super::shared::v2_enum_from_core; use codex_protocol::approvals::ElicitationRequest as CoreElicitationRequest; use codex_protocol::items::McpToolCallError as CoreMcpToolCallError; use codex_protocol::mcp::CallToolResult as CoreMcpCallToolResult; +use codex_protocol::mcp::McpServerInfo; use codex_protocol::mcp::Resource as McpResource; pub use codex_protocol::mcp::ResourceContent as McpResourceContent; use codex_protocol::mcp::ResourceTemplate as McpResourceTemplate; @@ -53,6 +54,7 @@ pub enum McpServerStatusDetail { #[ts(export_to = "v2/")] pub struct McpServerStatus { pub name: String, + pub server_info: Option, pub tools: std::collections::HashMap, pub resources: Vec, pub resource_templates: Vec, @@ -690,6 +692,7 @@ impl From for rmcp::model::CreateElicitatio Self { action: value.action.into(), content: value.content, + meta: None, } } } 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 c2b4f24b51d..90273cd8681 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -11,6 +11,7 @@ use codex_protocol::items::TurnItem; use codex_protocol::items::UserMessageItem; use codex_protocol::items::WebSearchItem; use codex_protocol::mcp::CallToolResult; +use codex_protocol::mcp::McpServerInfo; use codex_protocol::memory_citation::MemoryCitation as CoreMemoryCitation; use codex_protocol::memory_citation::MemoryCitationEntry as CoreMemoryCitationEntry; use codex_protocol::models::AdditionalPermissionProfile as CoreAdditionalPermissionProfile; @@ -111,6 +112,85 @@ fn thread_turns_list_params_accepts_items_view() { assert_eq!(params.items_view, Some(TurnItemsView::NotLoaded)); } +#[test] +fn thread_resume_params_accept_turns_page_bootstrap() { + let params = serde_json::from_value::(json!({ + "threadId": "thr_123", + "initialTurnsPage": { + "limit": 25, + "sortDirection": "asc", + "itemsView": "full", + }, + })) + .expect("thread resume params should deserialize"); + + assert_eq!(params.thread_id, "thr_123"); + assert_eq!( + params.initial_turns_page, + Some(ThreadResumeInitialTurnsPageParams { + limit: Some(25), + sort_direction: Some(SortDirection::Asc), + items_view: Some(TurnItemsView::Full), + }) + ); +} + +#[test] +fn thread_resume_response_round_trips_initial_turns_page() { + let response = ThreadResumeResponse { + thread: Thread { + id: "thr_123".to_string(), + session_id: "thr_123".to_string(), + forked_from_id: None, + preview: String::new(), + ephemeral: false, + model_provider: "openai".to_string(), + created_at: 1, + updated_at: 1, + status: ThreadStatus::Idle, + path: None, + cwd: absolute_path("tmp"), + cli_version: "0.0.0".to_string(), + source: SessionSource::Exec, + thread_source: None, + agent_nickname: None, + agent_role: None, + git_info: None, + name: None, + turns: Vec::new(), + }, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + service_tier: None, + cwd: absolute_path("tmp"), + runtime_workspace_roots: Vec::new(), + instruction_sources: Vec::new(), + approval_policy: AskForApproval::OnFailure, + approvals_reviewer: ApprovalsReviewer::User, + sandbox: SandboxPolicy::DangerFullAccess, + active_permission_profile: None, + reasoning_effort: None, + initial_turns_page: Some(TurnsPage { + data: Vec::new(), + next_cursor: Some("cursor_next".to_string()), + backwards_cursor: Some("cursor_back".to_string()), + }), + }; + + let value = serde_json::to_value(&response).expect("serialize thread resume response"); + assert_eq!( + value.get("initialTurnsPage"), + Some(&json!({ + "data": [], + "nextCursor": "cursor_next", + "backwardsCursor": "cursor_back", + })) + ); + let decoded = serde_json::from_value::(value) + .expect("deserialize thread resume response"); + assert_eq!(decoded, response); +} + #[test] fn thread_turns_items_list_round_trips() { let params = ThreadTurnsItemsListParams { @@ -1699,6 +1779,7 @@ fn mcp_server_elicitation_response_round_trips_rmcp_result() { content: Some(json!({ "confirmed": true, })), + meta: None, }; let v2_response = McpServerElicitationRequestResponse::from(rmcp_result.clone()); @@ -1923,6 +2004,80 @@ fn mcp_server_elicitation_response_serializes_nullable_content() { ); } +#[test] +fn mcp_server_status_serializes_absent_server_info_as_null() { + let response = ListMcpServerStatusResponse { + data: vec![McpServerStatus { + name: "not-ready".to_string(), + server_info: None, + tools: HashMap::new(), + resources: Vec::new(), + resource_templates: Vec::new(), + auth_status: McpAuthStatus::Unsupported, + }], + next_cursor: None, + }; + + assert_eq!( + serde_json::to_value(response).expect("response should serialize"), + json!({ + "data": [{ + "name": "not-ready", + "serverInfo": null, + "tools": {}, + "resources": [], + "resourceTemplates": [], + "authStatus": "unsupported", + }], + "nextCursor": null, + }) + ); +} + +#[test] +fn mcp_server_status_serializes_absent_server_info_metadata_as_null() { + let response = ListMcpServerStatusResponse { + data: vec![McpServerStatus { + name: "initialized".to_string(), + server_info: Some(McpServerInfo { + name: "lookup-server".to_string(), + title: None, + version: "1.0.0".to_string(), + description: None, + icons: None, + website_url: None, + }), + tools: HashMap::new(), + resources: Vec::new(), + resource_templates: Vec::new(), + auth_status: McpAuthStatus::Unsupported, + }], + next_cursor: None, + }; + + assert_eq!( + serde_json::to_value(response).expect("response should serialize"), + json!({ + "data": [{ + "name": "initialized", + "serverInfo": { + "name": "lookup-server", + "title": null, + "version": "1.0.0", + "description": null, + "icons": null, + "websiteUrl": null, + }, + "tools": {}, + "resources": [], + "resourceTemplates": [], + "authStatus": "unsupported", + }], + "nextCursor": null, + }) + ); +} + #[test] fn sandbox_policy_round_trips_workspace_write_access() { let v2_policy = SandboxPolicy::WorkspaceWrite { @@ -3405,6 +3560,7 @@ fn thread_lifecycle_responses_default_missing_optional_fields() { assert_eq!(fork.instruction_sources, Vec::::new()); assert_eq!(start.active_permission_profile, None); assert_eq!(resume.active_permission_profile, None); + assert_eq!(resume.initial_turns_page, None); assert_eq!(fork.active_permission_profile, None); } 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 4971d5f4b8a..8157fce69fc 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -397,6 +397,11 @@ pub struct ThreadResumeParams { #[experimental("thread/resume.excludeTurns")] #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub exclude_turns: bool, + /// When present, include a `thread/turns/list` page in the resume response + /// so clients can bootstrap recent turns without a second request. + #[experimental("thread/resume.initialTurnsPage")] + #[ts(optional = nullable)] + pub initial_turns_page: Option, /// 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. @@ -435,6 +440,44 @@ pub struct ThreadResumeResponse { #[serde(default)] pub active_permission_profile: Option, pub reasoning_effort: Option, + /// `thread/turns/list` page returned when requested by `initialTurnsPage`. + #[experimental("thread/resume.initialTurnsPage")] + #[serde(default)] + pub initial_turns_page: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadResumeInitialTurnsPageParams { + /// Optional turn page size. + #[ts(optional = nullable)] + pub limit: Option, + /// Optional turn pagination direction; defaults to descending. + #[ts(optional = nullable)] + pub sort_direction: Option, + /// How much item detail to include for each returned turn; defaults to summary. + #[ts(optional = nullable)] + pub items_view: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnsPage { + pub data: Vec, + pub next_cursor: Option, + pub backwards_cursor: Option, +} + +impl From for TurnsPage { + fn from(response: ThreadTurnsListResponse) -> Self { + Self { + data: response.data, + next_cursor: response.next_cursor, + backwards_cursor: response.backwards_cursor, + } + } } #[derive( diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index 09e3197b77b..d1a69783ad6 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -54,6 +54,7 @@ codex-backend-client = { workspace = true } codex-file-search = { workspace = true } codex-chatgpt = { workspace = true } codex-login = { workspace = true } +codex-image-generation-extension = { workspace = true } codex-memories-extension = { workspace = true } codex-web-search-extension = { workspace = true } codex-memories-write = { workspace = true } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 88edc22f9e0..25f300e5d22 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -23,7 +23,7 @@ Similar to [MCP](https://modelcontextprotocol.io/), `codex app-server` supports Supported transports: -- stdio (`--listen stdio://`, default): newline-delimited JSON (JSONL) +- stdio (`--stdio` or `--listen stdio://`, default): newline-delimited JSON (JSONL) - websocket (`--listen ws://IP:PORT`): one JSON-RPC message per websocket text frame (**experimental / unsupported**) - unix socket (`--listen unix://` or `--listen unix://PATH`): websocket connections over `$CODEX_HOME/app-server-control/app-server-control.sock` or a custom socket path, using the standard HTTP Upgrade handshake - off (`--listen off`): do not expose a local transport @@ -216,7 +216,7 @@ Example with notification opt-out: - `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes. - `tool/requestUserInput` — prompt the user with 1–3 short questions for a tool call and return their answers (experimental). - `config/mcpServer/reload` — reload MCP server config from disk and queue a refresh for loaded threads (applied on each thread's next active turn); returns `{}`. Use this after editing `config.toml` without restarting the server. -- `mcpServerStatus/list` — enumerate configured MCP servers with their tools and auth status, plus resources/resource templates for `full` detail; supports optional `threadId` and cursor+limit pagination. If `threadId` is omitted, the server reads from the latest global config directly. If `detail` is omitted, the server defaults to `full`. +- `mcpServerStatus/list` — enumerate configured MCP servers with their tools, auth status, server info, plus resources/resource templates for `full` detail; supports optional `threadId` and cursor+limit pagination. If `threadId` is omitted, the server reads from the latest global config directly. If `detail` is omitted, the server defaults to `full`. - `mcpServer/resource/read` — read a resource from a configured MCP server by optional `threadId`, `server`, and `uri`, returning text/blob resource `contents`. If `threadId` is omitted, the server reads from the latest MCP config directly. - `mcpServer/tool/call` — call a tool on a thread's configured MCP server by `threadId`, `server`, `tool`, optional `arguments`, and optional `_meta`, returning the MCP tool result. - `windowsSandbox/setupStart` — start Windows sandbox setup for the selected mode (`elevated` or `unelevated`); accepts an optional absolute `cwd` to target setup for a specific workspace, returns `{ started: true }` immediately, and later emits `windowsSandbox/setupCompleted`. @@ -281,6 +281,8 @@ To continue a stored session, call `thread/resume` with the `thread.id` you prev By default, `thread/resume` includes the reconstructed turn history in `thread.turns`. Experimental clients can pass `excludeTurns: true` to return only thread metadata and live resume state, then call `thread/turns/list` separately if they want to page the turn history over the network. In that mode the server also skips replaying restored `thread/tokenUsage/updated`, which avoids rebuilding turns just to attribute historical usage. +Experimental clients that want the live resume subscription plus a turns page in one round trip can pass `initialTurnsPage`. It accepts the same `limit`, `sortDirection`, and `itemsView` controls as `thread/turns/list`; omitted controls use its defaults. The response includes `initialTurnsPage` with `nextCursor` and `backwardsCursor` for follow-up pagination. + By default, resume uses the latest persisted `model` and `reasoningEffort` values associated with the thread. Supplying any of `model`, `modelProvider`, `config.model`, or `config.model_reasoning_effort` disables that persisted fallback and uses the explicit overrides plus normal config resolution instead. Example: @@ -297,6 +299,24 @@ Example: "excludeTurns": true } } { "id": 12, "result": { "thread": { "id": "thr_123", "turns": [], … } } } + +{ "method": "thread/resume", "id": 13, "params": { + "threadId": "thr_123", + "excludeTurns": true, + "initialTurnsPage": { + "limit": 20, + "sortDirection": "desc", + "itemsView": "summary" + } +} } +{ "id": 13, "result": { + "thread": { "id": "thr_123", "turns": [], … }, + "initialTurnsPage": { + "data": [ ... ], + "nextCursor": "older-turns-cursor-or-null", + "backwardsCursor": "newer-turns-cursor-or-null" + } +} } ``` To branch from a stored session, call `thread/fork` with the `thread.id`. This creates a new thread id and emits a `thread/started` notification for it. The returned `thread.sessionId` identifies the current live session tree root. Root threads use their own `thread.id` as `thread.sessionId`; stored threads that are not loaded also report their own `thread.id`, because resuming one makes it the root of a new live session tree. When the source history includes persisted token usage, the server also emits `thread/tokenUsage/updated` for the new thread immediately after the response. If the source thread is actively running, the fork snapshots it as if the current turn had been interrupted first. Pass `ephemeral: true` when the fork should stay in-memory only: diff --git a/codex-rs/app-server/src/extensions.rs b/codex-rs/app-server/src/extensions.rs index 1246da7b371..7b2673ca068 100644 --- a/codex-rs/app-server/src/extensions.rs +++ b/codex-rs/app-server/src/extensions.rs @@ -31,7 +31,8 @@ where let mut builder = ExtensionRegistryBuilder::::with_event_sink(event_sink); codex_guardian::install(&mut builder, guardian_agent_spawner); codex_memories_extension::install(&mut builder, codex_otel::global()); - codex_web_search_extension::install(&mut builder, auth_manager); + codex_web_search_extension::install(&mut builder, auth_manager.clone()); + codex_image_generation_extension::install(&mut builder, auth_manager); Arc::new(builder.build()) } diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 66ab8e3a1da..8408a3f67fb 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -213,6 +213,7 @@ use codex_app_server_protocol::ThreadRealtimeStartResponse; use codex_app_server_protocol::ThreadRealtimeStartTransport; use codex_app_server_protocol::ThreadRealtimeStopParams; use codex_app_server_protocol::ThreadRealtimeStopResponse; +use codex_app_server_protocol::ThreadResumeInitialTurnsPageParams; use codex_app_server_protocol::ThreadResumeParams; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadRollbackParams; diff --git a/codex-rs/app-server/src/request_processors/mcp_processor.rs b/codex-rs/app-server/src/request_processors/mcp_processor.rs index 22d5a4326a4..ae62e2e7855 100644 --- a/codex-rs/app-server/src/request_processors/mcp_processor.rs +++ b/codex-rs/app-server/src/request_processors/mcp_processor.rs @@ -276,6 +276,7 @@ impl McpRequestProcessor { .await; let McpServerStatusSnapshot { + server_infos, tools_by_server, resources, resource_templates, @@ -315,6 +316,7 @@ impl McpRequestProcessor { .iter() .map(|name| McpServerStatus { name: name.clone(), + server_info: server_infos.get(name).cloned(), tools: tools_by_server.get(name).cloned().unwrap_or_default(), resources: resources.get(name).cloned().unwrap_or_default(), resource_templates: resource_templates.get(name).cloned().unwrap_or_default(), diff --git a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs index 985baac91ad..f72cfb5a626 100644 --- a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs +++ b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs @@ -565,8 +565,28 @@ pub(super) async fn handle_pending_thread_resume_request( has_live_in_progress_turn, ); let token_usage_thread = pending.include_turns.then(|| thread.clone()); + let mut initial_turns_page = if let Some(params) = pending.initial_turns_page.as_ref() { + match super::thread_processor::build_thread_resume_initial_turns_page( + &pending.history_items, + thread.status.clone(), + has_live_in_progress_turn, + active_turn, + params, + ) { + Ok(page) => Some(page), + Err(error) => { + outgoing.send_error(request_id, error).await; + return; + } + } + } else { + None + }; if pending.redact_resume_payloads { - redact_thread_resume_payloads(&mut thread); + redact_thread_resume_payloads(&mut thread.turns); + if let Some(initial_turns_page) = initial_turns_page.as_mut() { + redact_thread_resume_payloads(&mut initial_turns_page.data); + } } { @@ -635,6 +655,7 @@ pub(super) async fn handle_pending_thread_resume_request( sandbox, active_permission_profile, reasoning_effort, + initial_turns_page, }; outgoing.send_response(request_id, response).await; // Match cold resume: metadata-only resume should attach the listener without diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index 5a2e1a4de8d..939c3df5190 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -2241,8 +2241,6 @@ impl ThreadRequestProcessor { sort_direction, items_view, } = params; - let items_view = items_view.unwrap_or(TurnItemsView::Summary); - let thread_uuid = ThreadId::from_string(&thread_id) .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?; @@ -2270,60 +2268,20 @@ impl ThreadRequestProcessor { } else { None }; - let mut turns = reconstruct_thread_turns_for_turns_list( + build_thread_turns_page_response( &items, self.thread_watch_manager .loaded_status_for_thread(&thread_uuid.to_string()) .await, has_live_running_thread, active_turn, - ); - for turn in &mut turns { - match items_view { - TurnItemsView::NotLoaded => { - turn.items.clear(); - turn.items_view = TurnItemsView::NotLoaded; - } - TurnItemsView::Summary => { - let first_user_message = turn - .items - .iter() - .find(|item| matches!(item, ThreadItem::UserMessage { .. })) - .cloned(); - let final_agent_message = turn - .items - .iter() - .rev() - .find(|item| matches!(item, ThreadItem::AgentMessage { .. })) - .cloned(); - turn.items = match (first_user_message, final_agent_message) { - (Some(user_message), Some(agent_message)) - if user_message.id() != agent_message.id() => - { - vec![user_message, agent_message] - } - (Some(user_message), _) => vec![user_message], - (None, Some(agent_message)) => vec![agent_message], - (None, None) => Vec::new(), - }; - turn.items_view = TurnItemsView::Summary; - } - TurnItemsView::Full => { - turn.items_view = TurnItemsView::Full; - } - } - } - let page = paginate_thread_turns( - turns, - cursor.as_deref(), - limit, - sort_direction.unwrap_or(SortDirection::Desc), - )?; - Ok(ThreadTurnsListResponse { - data: page.turns, - next_cursor: page.next_cursor, - backwards_cursor: page.backwards_cursor, - }) + ThreadTurnsPageOptions { + cursor: cursor.as_deref(), + limit, + sort_direction: sort_direction.unwrap_or(SortDirection::Desc), + items_view: items_view.unwrap_or(TurnItemsView::Summary), + }, + ) } async fn load_thread_turns_list_history( @@ -2531,6 +2489,7 @@ impl ThreadRequestProcessor { developer_instructions, personality, exclude_turns, + initial_turns_page, persist_extended_history: _persist_extended_history, } = params; let include_turns = !exclude_turns; @@ -2685,8 +2644,28 @@ impl ThreadRequestProcessor { config_snapshot.active_permission_profile, ); let token_usage_thread = include_turns.then(|| thread.clone()); + let mut initial_turns_page = if let Some(params) = initial_turns_page.as_ref() { + match build_thread_resume_initial_turns_page( + &response_history.get_rollout_items(), + thread.status.clone(), + /*has_live_running_thread*/ false, + /*active_turn*/ None, + params, + ) { + Ok(page) => Some(page), + Err(error) => { + self.outgoing.send_error(request_id, error).await; + return Ok(()); + } + } + } else { + None + }; if redact_resume_payloads { - redact_thread_resume_payloads(&mut thread); + redact_thread_resume_payloads(&mut thread.turns); + if let Some(initial_turns_page) = initial_turns_page.as_mut() { + redact_thread_resume_payloads(&mut initial_turns_page.data); + } } let response = ThreadResumeResponse { @@ -2702,6 +2681,7 @@ impl ThreadRequestProcessor { sandbox, active_permission_profile, reasoning_effort: session_configured.reasoning_effort, + initial_turns_page, }; let connection_id = request_id.connection_id; @@ -2926,6 +2906,7 @@ impl ThreadRequestProcessor { emit_thread_goal_update, thread_goal_state_db, include_turns: !params.exclude_turns, + initial_turns_page: params.initial_turns_page.clone(), redact_resume_payloads, }), ); @@ -3706,6 +3687,95 @@ fn parse_thread_turns_cursor(cursor: &str) -> Result { + cursor: Option<&'a str>, + limit: Option, + sort_direction: SortDirection, + items_view: TurnItemsView, +} + +fn build_thread_turns_page_response( + items: &[RolloutItem], + loaded_status: ThreadStatus, + has_live_running_thread: bool, + active_turn: Option, + options: ThreadTurnsPageOptions<'_>, +) -> Result { + let mut turns = reconstruct_thread_turns_for_turns_list( + items, + loaded_status, + has_live_running_thread, + active_turn, + ); + apply_thread_turns_items_view(&mut turns, options.items_view); + let page = paginate_thread_turns(turns, options.cursor, options.limit, options.sort_direction)?; + Ok(ThreadTurnsListResponse { + data: page.turns, + next_cursor: page.next_cursor, + backwards_cursor: page.backwards_cursor, + }) +} + +pub(super) fn build_thread_resume_initial_turns_page( + items: &[RolloutItem], + loaded_status: ThreadStatus, + has_live_running_thread: bool, + active_turn: Option, + params: &ThreadResumeInitialTurnsPageParams, +) -> Result { + build_thread_turns_page_response( + items, + loaded_status, + has_live_running_thread, + active_turn, + ThreadTurnsPageOptions { + cursor: None, + limit: params.limit, + sort_direction: params.sort_direction.unwrap_or(SortDirection::Desc), + items_view: params.items_view.unwrap_or(TurnItemsView::Summary), + }, + ) + .map(Into::into) +} + +fn apply_thread_turns_items_view(turns: &mut [Turn], items_view: TurnItemsView) { + for turn in turns { + match items_view { + TurnItemsView::NotLoaded => { + turn.items.clear(); + turn.items_view = TurnItemsView::NotLoaded; + } + TurnItemsView::Summary => { + let first_user_message = turn + .items + .iter() + .find(|item| matches!(item, ThreadItem::UserMessage { .. })) + .cloned(); + let final_agent_message = turn + .items + .iter() + .rev() + .find(|item| matches!(item, ThreadItem::AgentMessage { .. })) + .cloned(); + turn.items = match (first_user_message, final_agent_message) { + (Some(user_message), Some(agent_message)) + if user_message.id() != agent_message.id() => + { + vec![user_message, agent_message] + } + (Some(user_message), _) => vec![user_message], + (None, Some(agent_message)) => vec![agent_message], + (None, None) => Vec::new(), + }; + turn.items_view = TurnItemsView::Summary; + } + TurnItemsView::Full => { + turn.items_view = TurnItemsView::Full; + } + } + } +} + fn reconstruct_thread_turns_for_turns_list( items: &[RolloutItem], loaded_status: ThreadStatus, 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 ab8f52ffc02..2fa95b3d237 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 @@ -652,6 +652,7 @@ mod thread_processor_behavior_tests { developer_instructions: None, personality: None, exclude_turns: false, + initial_turns_page: None, persist_extended_history: false, }; let config_snapshot = ThreadConfigSnapshot { diff --git a/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs b/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs index b1e86088420..b265e3a632f 100644 --- a/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs +++ b/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs @@ -1,6 +1,6 @@ use codex_app_server_protocol::McpToolCallResult; -use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::Turn; use serde_json::Value as JsonValue; // Temporary bandaid for remote clients: thread/resume can include large MCP and @@ -14,8 +14,8 @@ pub(super) fn should_redact_thread_resume_payloads(client_name: Option<&str>) -> client_name.is_some_and(|client_name| CHATGPT_REMOTE_CLIENT_NAMES.contains(&client_name)) } -pub(super) fn redact_thread_resume_payloads(thread: &mut Thread) { - for turn in &mut thread.turns { +pub(super) fn redact_thread_resume_payloads(turns: &mut [Turn]) { + for turn in turns { turn.items.retain_mut(|item| match item { ThreadItem::McpToolCall { arguments, @@ -55,8 +55,8 @@ mod tests { use codex_app_server_protocol::McpToolCallError; use codex_app_server_protocol::McpToolCallStatus; use codex_app_server_protocol::SessionSource; + use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadStatus; - use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnItemsView; use codex_app_server_protocol::TurnStatus; use codex_utils_absolute_path::test_support::PathBufExt; @@ -100,7 +100,7 @@ mod tests { }, ]); - redact_thread_resume_payloads(&mut thread); + redact_thread_resume_payloads(&mut thread.turns); assert_eq!(thread.turns[0].items.len(), 2); assert_eq!( @@ -146,7 +146,7 @@ mod tests { duration_ms: Some(8), }]); - redact_thread_resume_payloads(&mut thread); + redact_thread_resume_payloads(&mut thread.turns); assert_eq!( thread.turns[0].items[0], diff --git a/codex-rs/app-server/src/thread_state.rs b/codex-rs/app-server/src/thread_state.rs index 7f864265b2d..2d932f03266 100644 --- a/codex-rs/app-server/src/thread_state.rs +++ b/codex-rs/app-server/src/thread_state.rs @@ -35,6 +35,8 @@ pub(crate) struct PendingThreadResumeRequest { pub(crate) emit_thread_goal_update: bool, pub(crate) thread_goal_state_db: Option, pub(crate) include_turns: bool, + pub(crate) initial_turns_page: + Option, pub(crate) redact_resume_payloads: bool, } diff --git a/codex-rs/app-server/tests/suite/v2/app_list.rs b/codex-rs/app-server/tests/suite/v2/app_list.rs index 395dff56683..039fffe5cdf 100644 --- a/codex-rs/app-server/tests/suite/v2/app_list.rs +++ b/codex-rs/app-server/tests/suite/v2/app_list.rs @@ -316,6 +316,73 @@ connectors = false Ok(()) } +#[tokio::test] +async fn list_apps_keeps_apps_with_app_only_tools_accessible() -> Result<()> { + let connectors = vec![AppInfo { + id: "beta".to_string(), + name: "Beta".to_string(), + description: Some("Beta connector".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: None, + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + }]; + let mut app_only_tool = connector_tool("beta", "Beta App")?; + app_only_tool + .meta + .as_mut() + .expect("connector tool should include metadata") + .0 + .insert("ui".to_string(), json!({ "visibility": ["app"] })); + let tools = vec![app_only_tool]; + let (server_url, server_handle) = + start_apps_server_with_delays(connectors, tools, Duration::ZERO, Duration::ZERO).await?; + + let codex_home = TempDir::new()?; + write_connectors_config(codex_home.path(), &server_url)?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-app-only") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_apps_list_request(AppsListParams { + limit: None, + cursor: None, + thread_id: None, + force_refetch: true, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let AppsListResponse { data, next_cursor } = to_response(response)?; + + assert_eq!(data.len(), 1); + assert_eq!(data[0].id, "beta"); + assert!(data[0].is_accessible); + assert!(next_cursor.is_none()); + + server_handle.abort(); + let _ = server_handle.await; + Ok(()) +} + #[tokio::test] async fn list_apps_reports_is_enabled_from_config() -> Result<()> { let connectors = vec![AppInfo { @@ -1432,10 +1499,7 @@ impl AppsServerControl { impl ServerHandler for AppListMcpServer { fn get_info(&self) -> ServerInfo { - ServerInfo { - capabilities: ServerCapabilities::builder().enable_tools().build(), - ..ServerInfo::default() - } + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) } fn list_tools( diff --git a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs index bddf57f6662..db16aded206 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs @@ -288,11 +288,8 @@ struct ResourceAppsMcpServer; impl ServerHandler for ResourceAppsMcpServer { fn get_info(&self) -> ServerInfo { - ServerInfo { - protocol_version: ProtocolVersion::V_2025_06_18, - capabilities: ServerCapabilities::builder().enable_resources().build(), - ..ServerInfo::default() - } + ServerInfo::new(ServerCapabilities::builder().enable_resources().build()) + .with_protocol_version(ProtocolVersion::V_2025_06_18) } async fn read_resource( @@ -308,21 +305,19 @@ impl ServerHandler for ResourceAppsMcpServer { )); } - Ok(ReadResourceResult { - contents: vec![ - ResourceContents::TextResourceContents { - uri: TEST_RESOURCE_URI.to_string(), - mime_type: Some("text/markdown".to_string()), - text: TEST_RESOURCE_TEXT.to_string(), - meta: None, - }, - ResourceContents::BlobResourceContents { - uri: TEST_BLOB_RESOURCE_URI.to_string(), - mime_type: Some("application/octet-stream".to_string()), - blob: TEST_RESOURCE_BLOB.to_string(), - meta: None, - }, - ], - }) + Ok(ReadResourceResult::new(vec![ + ResourceContents::TextResourceContents { + uri: TEST_RESOURCE_URI.to_string(), + mime_type: Some("text/markdown".to_string()), + text: TEST_RESOURCE_TEXT.to_string(), + meta: None, + }, + ResourceContents::BlobResourceContents { + uri: TEST_BLOB_RESOURCE_URI.to_string(), + mime_type: Some("application/octet-stream".to_string()), + blob: TEST_RESOURCE_BLOB.to_string(), + meta: None, + }, + ])) } } diff --git a/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs b/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs index 13ebe0b99c8..9c415a4c9e5 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs @@ -308,11 +308,8 @@ struct ElicitationAppsMcpServer; impl ServerHandler for ElicitationAppsMcpServer { fn get_info(&self) -> ServerInfo { - ServerInfo { - protocol_version: rmcp::model::ProtocolVersion::V_2025_06_18, - capabilities: ServerCapabilities::builder().enable_tools().build(), - ..ServerInfo::default() - } + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + .with_protocol_version(rmcp::model::ProtocolVersion::V_2025_06_18) } async fn list_tools( diff --git a/codex-rs/app-server/tests/suite/v2/mcp_server_status.rs b/codex-rs/app-server/tests/suite/v2/mcp_server_status.rs index bc9839b5303..32f98a1738e 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_server_status.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_server_status.rs @@ -20,6 +20,7 @@ use codex_core::config::set_project_trust_level; use codex_protocol::config_types::TrustLevel; use pretty_assertions::assert_eq; use rmcp::handler::server::ServerHandler; +use rmcp::model::Implementation; use rmcp::model::JsonObject; use rmcp::model::ListResourceTemplatesResult; use rmcp::model::ListResourcesResult; @@ -99,6 +100,13 @@ url = "{mcp_server_url}/mcp" .map(|tool| tool.name.as_str()), Some("look-up.raw") ); + assert_eq!( + status + .server_info + .as_ref() + .and_then(|info| info.title.as_deref()), + Some("Lookup Server") + ); mcp_server_handle.abort(); let _ = mcp_server_handle.await; @@ -205,10 +213,9 @@ struct McpStatusServer { impl ServerHandler for McpStatusServer { fn get_info(&self) -> ServerInfo { - ServerInfo { - capabilities: ServerCapabilities::builder().enable_tools().build(), - ..ServerInfo::default() - } + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()).with_server_info( + Implementation::new("lookup-server", "1.0.0").with_title("Lookup Server"), + ) } async fn list_tools( @@ -244,13 +251,12 @@ struct SlowInventoryServer { impl ServerHandler for SlowInventoryServer { fn get_info(&self) -> ServerInfo { - ServerInfo { - capabilities: ServerCapabilities::builder() + ServerInfo::new( + ServerCapabilities::builder() .enable_tools() .enable_resources() .build(), - ..ServerInfo::default() - } + ) } async fn list_tools( diff --git a/codex-rs/app-server/tests/suite/v2/mcp_tool.rs b/codex-rs/app-server/tests/suite/v2/mcp_tool.rs index a21dd0d9f0c..eff9ec9603a 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_tool.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_tool.rs @@ -537,10 +537,7 @@ struct ToolAppsMcpServer; impl ServerHandler for ToolAppsMcpServer { fn get_info(&self) -> ServerInfo { - ServerInfo { - capabilities: ServerCapabilities::builder().enable_tools().build(), - ..ServerInfo::default() - } + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) } async fn list_tools( diff --git a/codex-rs/app-server/tests/suite/v2/plugin_install.rs b/codex-rs/app-server/tests/suite/v2/plugin_install.rs index 9d17e8747fd..69c1017a227 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_install.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_install.rs @@ -1122,10 +1122,7 @@ struct PluginInstallMcpServer { impl ServerHandler for PluginInstallMcpServer { fn get_info(&self) -> ServerInfo { - ServerInfo { - capabilities: ServerCapabilities::builder().enable_tools().build(), - ..ServerInfo::default() - } + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) } fn list_tools( diff --git a/codex-rs/app-server/tests/suite/v2/plugin_read.rs b/codex-rs/app-server/tests/suite/v2/plugin_read.rs index 2a5d3d887a4..26a85481660 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_read.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_read.rs @@ -1768,10 +1768,7 @@ struct PluginReadMcpServer { impl ServerHandler for PluginReadMcpServer { fn get_info(&self) -> ServerInfo { - ServerInfo { - capabilities: ServerCapabilities::builder().enable_tools().build(), - ..ServerInfo::default() - } + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) } fn list_tools( diff --git a/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs b/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs index 9787868f5c6..c4e8fb924d2 100644 --- a/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs +++ b/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs @@ -228,7 +228,6 @@ fn assert_no_local_persistence_artifacts(codex_home: &Path) -> Result<()> { BTreeSet::from([ "config.toml".to_string(), "installation_id".to_string(), - "memories".to_string(), "skills".to_string(), ]), "non-local thread persistence should not create unexpected files in codex_home" diff --git a/codex-rs/app-server/tests/suite/v2/thread_read.rs b/codex-rs/app-server/tests/suite/v2/thread_read.rs index c8e3e179fb2..5ba9fa9d2df 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_read.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_read.rs @@ -24,6 +24,7 @@ use codex_app_server_protocol::ThreadListResponse; use codex_app_server_protocol::ThreadNameUpdatedNotification; use codex_app_server_protocol::ThreadReadParams; use codex_app_server_protocol::ThreadReadResponse; +use codex_app_server_protocol::ThreadResumeInitialTurnsPageParams; use codex_app_server_protocol::ThreadResumeParams; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadSetNameParams; @@ -631,6 +632,77 @@ async fn thread_read_can_return_archived_threads_by_id() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_resume_initial_turns_page_matches_requested_turns_list_page() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let filename_ts = "2025-01-05T12-00-00"; + let conversation_id = create_fake_rollout_with_text_elements( + codex_home.path(), + filename_ts, + "2025-01-05T12:00:00Z", + "first", + vec![], + Some("mock_provider"), + /*git_info*/ None, + )?; + let rollout_path = rollout_path(codex_home.path(), filename_ts, &conversation_id); + append_user_message(rollout_path.as_path(), "2025-01-05T12:01:00Z", "second")?; + append_user_message(rollout_path.as_path(), "2025-01-05T12:02:00Z", "third")?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let turns_list_id = mcp + .send_thread_turns_list_request(ThreadTurnsListParams { + thread_id: conversation_id.clone(), + cursor: None, + limit: Some(2), + sort_direction: Some(SortDirection::Asc), + items_view: Some(TurnItemsView::NotLoaded), + }) + .await?; + let turns_list_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turns_list_id)), + ) + .await??; + let expected_page = to_response::(turns_list_resp)?; + + let resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: conversation_id, + exclude_turns: true, + initial_turns_page: Some(ThreadResumeInitialTurnsPageParams { + limit: Some(2), + sort_direction: Some(SortDirection::Asc), + items_view: Some(TurnItemsView::NotLoaded), + }), + ..Default::default() + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let ThreadResumeResponse { + thread, + initial_turns_page, + .. + } = to_response::(resume_resp)?; + + assert!(thread.turns.is_empty()); + assert_eq!( + initial_turns_page, + Some(codex_app_server_protocol::TurnsPage::from(expected_page)) + ); + + Ok(()) +} + #[tokio::test] async fn thread_turns_list_rejects_cursor_when_anchor_turn_is_rolled_back() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; 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 bb2de2cfbb0..2b85427247f 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -37,6 +37,7 @@ use codex_app_server_protocol::ThreadMetadataGitInfoUpdateParams; use codex_app_server_protocol::ThreadMetadataUpdateParams; use codex_app_server_protocol::ThreadReadParams; use codex_app_server_protocol::ThreadReadResponse; +use codex_app_server_protocol::ThreadResumeInitialTurnsPageParams; use codex_app_server_protocol::ThreadResumeParams; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadSource; @@ -530,48 +531,59 @@ async fn thread_resume_returns_rollout_history() -> Result<()> { #[tokio::test] async fn thread_resume_redacts_payloads_for_chatgpt_remote_clients() -> Result<()> { for client_name in ["codex_chatgpt_android_remote", "codex_chatgpt_ios_remote"] { - let remote_thread = resume_redaction_fixture(Some(client_name)).await?; - let remote_turn = remote_thread + let remote_resume = resume_redaction_fixture(Some(client_name)).await?; + let remote_turn = remote_resume + .thread .turns .first() .expect("remote resume should include a turn"); - let remote_mcp_item = remote_turn - .items - .iter() - .find(|item| matches!(item, ThreadItem::McpToolCall { .. })) - .expect("remote resume should include redacted MCP item"); - let ThreadItem::McpToolCall { - arguments, - result, - error, - .. - } = remote_mcp_item - else { - unreachable!("matched MCP item"); - }; - assert_eq!(arguments, &json!("[redacted]")); - let result = result.as_ref().expect("redacted MCP result"); - assert_eq!( - result.content, - vec![json!({ - "type": "text", - "text": "[redacted]", - })] - ); - assert_eq!(result.structured_content, None); - assert_eq!(result.meta, None); - assert_eq!(error, &None); - assert!( - !remote_turn + let remote_page_turn = remote_resume + .initial_turns_page + .as_ref() + .expect("remote resume should include the requested initial turns page") + .data + .first() + .expect("remote initial turns page should include a turn"); + for remote_turn in [remote_turn, remote_page_turn] { + let remote_mcp_item = remote_turn .items .iter() - .any(|item| matches!(item, ThreadItem::ImageGeneration { .. })), - "remote resume should drop image generation items for {client_name}" - ); + .find(|item| matches!(item, ThreadItem::McpToolCall { .. })) + .expect("remote resume should include redacted MCP item"); + let ThreadItem::McpToolCall { + arguments, + result, + error, + .. + } = remote_mcp_item + else { + unreachable!("matched MCP item"); + }; + assert_eq!(arguments, &json!("[redacted]")); + let result = result.as_ref().expect("redacted MCP result"); + assert_eq!( + result.content, + vec![json!({ + "type": "text", + "text": "[redacted]", + })] + ); + assert_eq!(result.structured_content, None); + assert_eq!(result.meta, None); + assert_eq!(error, &None); + assert!( + !remote_turn + .items + .iter() + .any(|item| matches!(item, ThreadItem::ImageGeneration { .. })), + "remote resume should drop image generation items for {client_name}" + ); + } } - let normal_thread = resume_redaction_fixture(Some("some_other_client")).await?; - let normal_turn = normal_thread + let normal_resume = resume_redaction_fixture(Some("some_other_client")).await?; + let normal_turn = normal_resume + .thread .turns .first() .expect("normal resume should include a turn"); @@ -616,9 +628,7 @@ async fn thread_resume_redacts_payloads_for_chatgpt_remote_clients() -> Result<( Ok(()) } -async fn resume_redaction_fixture( - client_name: Option<&str>, -) -> Result { +async fn resume_redaction_fixture(client_name: Option<&str>) -> Result { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; @@ -658,6 +668,11 @@ async fn resume_redaction_fixture( let resume_id = mcp .send_thread_resume_request(ThreadResumeParams { thread_id: conversation_id, + initial_turns_page: Some(ThreadResumeInitialTurnsPageParams { + limit: None, + sort_direction: None, + items_view: Some(TurnItemsView::Full), + }), ..Default::default() }) .await?; @@ -666,8 +681,7 @@ async fn resume_redaction_fixture( mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), ) .await??; - let ThreadResumeResponse { thread, .. } = to_response::(resume_resp)?; - Ok(thread) + to_response::(resume_resp) } fn append_resume_redaction_history( @@ -2448,11 +2462,13 @@ async fn thread_resume_rejoins_running_thread_even_with_override_mismatch() -> R ..Default::default() }) .await?; - timeout( + let running_turn_resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, primary.read_stream_until_response_message(RequestId::Integer(running_turn_id)), ) .await??; + let TurnStartResponse { turn: running_turn } = + to_response::(running_turn_resp)?; timeout( DEFAULT_READ_TIMEOUT, primary.read_stream_until_notification_message("turn/started"), @@ -2464,6 +2480,11 @@ async fn thread_resume_rejoins_running_thread_even_with_override_mismatch() -> R thread_id: thread.id.clone(), model: Some("not-the-running-model".to_string()), cwd: Some("/tmp".to_string()), + initial_turns_page: Some(ThreadResumeInitialTurnsPageParams { + limit: None, + sort_direction: None, + items_view: None, + }), ..Default::default() }) .await?; @@ -2472,9 +2493,23 @@ async fn thread_resume_rejoins_running_thread_even_with_override_mismatch() -> R primary.read_stream_until_response_message(RequestId::Integer(resume_id)), ) .await??; - let ThreadResumeResponse { thread, model, .. } = - to_response::(resume_resp)?; + let ThreadResumeResponse { + thread, + model, + initial_turns_page, + .. + } = to_response::(resume_resp)?; assert_eq!(model, "gpt-5.4"); + let initial_turns_page = initial_turns_page.expect("resume should include initial turns page"); + let resumed_running_turn = initial_turns_page + .data + .first() + .expect("resume page should include the running turn"); + assert_eq!(resumed_running_turn.id, running_turn.id); + assert_eq!(resumed_running_turn.items_view, TurnItemsView::Summary); + assert_eq!(resumed_running_turn.status, TurnStatus::InProgress); + assert!(initial_turns_page.backwards_cursor.is_some()); + assert_eq!(initial_turns_page.next_cursor, None); // The running-thread resume response is queued onto the thread listener task. // If the in-flight turn completes before that queued command runs, the response // can legitimately observe the thread as idle. diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index 1a1b852667e..6a4d78fbc74 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -83,6 +83,7 @@ tracing = { workspace = true } tracing-appender = { workspace = true } tracing-subscriber = { workspace = true } unicode-segmentation = { workspace = true } +url = { workspace = true } which = { workspace = true } [target.'cfg(target_os = "windows")'.dependencies] diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 856273354c9..8dfe6dc33f4 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -452,6 +452,10 @@ struct AppServerCommand { )] listen: codex_app_server::AppServerTransport, + /// Use stdio as the transport (equivalent to `--listen stdio://`). + #[arg(long = "stdio", conflicts_with = "listen")] + stdio: bool, + /// Enable remote control for this app-server process. #[arg(long = "remote-control", hide = true)] remote_control: bool, @@ -965,6 +969,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { subcommand, strict_config: app_server_strict_config, listen, + stdio, remote_control, analytics_default_enabled, auth, @@ -978,7 +983,11 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { )?; match subcommand { None => { - let transport = listen; + let transport = if stdio { + codex_app_server::AppServerTransport::Stdio + } else { + listen + }; let auth = auth.try_into_settings()?; let runtime_options = codex_app_server::AppServerRuntimeOptions { remote_control_enabled: remote_control, @@ -1486,7 +1495,8 @@ async fn run_exec_server_command( .ok_or_else(|| anyhow::anyhow!("--environment-id is required when --remote is set"))?; let config = load_exec_server_config(root_config_overrides, strict_config).await?; let auth_provider = - load_exec_server_remote_auth_provider(&config, cmd.use_agent_identity_auth).await?; + load_exec_server_remote_auth_provider(&config, &base_url, cmd.use_agent_identity_auth) + .await?; let mut remote_config = codex_exec_server::RemoteEnvironmentConfig::new( base_url, environment_id, @@ -1516,6 +1526,7 @@ async fn run_exec_server_command( async fn load_exec_server_remote_auth_provider( config: &codex_core::config::Config, + base_url: &str, use_agent_identity_auth: bool, ) -> anyhow::Result { if use_agent_identity_auth { @@ -1530,19 +1541,61 @@ async fn load_exec_server_remote_auth_provider( let auth = load_exec_server_remote_auth( config, - "remote exec-server registration requires ChatGPT authentication; run `codex login` first", + "remote exec-server registration requires ChatGPT authentication or API key authentication; run `codex login` or set CODEX_API_KEY", ) .await?; - if !auth.is_chatgpt_auth() { + if !is_supported_exec_server_remote_auth(&auth) { anyhow::bail!( - "remote exec-server registration requires ChatGPT authentication; API key and Agent Identity auth are not supported" + "remote exec-server registration requires ChatGPT authentication or API key authentication; Agent Identity auth requires --use-agent-identity-auth" ); } + if auth.is_api_key_auth() { + validate_api_key_remote_host(base_url)?; + } + Ok(codex_model_provider::auth_provider_from_auth(&auth)) } +fn is_supported_exec_server_remote_auth(auth: &CodexAuth) -> bool { + auth.is_chatgpt_auth() || auth.is_api_key_auth() +} + +fn validate_api_key_remote_host(base_url: &str) -> anyhow::Result<()> { + let url = url::Url::parse(base_url) + .map_err(|err| anyhow::anyhow!("invalid remote exec-server registration URL: {err}"))?; + let host = url.host().ok_or_else(|| { + anyhow::anyhow!("remote exec-server registration URL must include a host") + })?; + + let is_loopback = match &host { + url::Host::Domain(host) => host.eq_ignore_ascii_case("localhost"), + url::Host::Ipv4(ip) => ip.is_loopback(), + url::Host::Ipv6(ip) => ip.is_loopback(), + }; + let is_openai_host = match &host { + url::Host::Domain(host) => ["openai.com", "openai.org"].into_iter().any(|domain| { + host.eq_ignore_ascii_case(domain) + || host.to_ascii_lowercase().ends_with(&format!(".{domain}")) + }), + _ => false, + }; + let is_allowed = match url.scheme() { + "https" => is_loopback || is_openai_host, + "http" => is_loopback, + _ => false, + }; + + if !is_allowed { + anyhow::bail!( + "remote exec-server API-key authentication is restricted to HTTPS openai.com and openai.org hosts and subdomains or loopback hosts" + ); + } + + Ok(()) +} + async fn load_exec_server_config( root_config_overrides: &CliConfigOverrides, strict_config: bool, @@ -2168,6 +2221,63 @@ mod tests { use codex_tui::TokenUsage; use pretty_assertions::assert_eq; + #[test] + fn exec_server_remote_auth_accepts_api_key_auth() { + let auth = CodexAuth::from_api_key("sk-test"); + + assert!(is_supported_exec_server_remote_auth(&auth)); + } + + #[test] + fn exec_server_remote_api_key_auth_accepts_https_openai_domains() { + for base_url in [ + "https://openai.com/api", + "https://service.openai.com/api", + "https://openai.org/api", + "https://service.openai.org/api", + ] { + assert!(validate_api_key_remote_host(base_url).is_ok()); + } + } + + #[test] + fn exec_server_remote_api_key_auth_accepts_http_loopback() { + for base_url in [ + "http://localhost:8098/api", + "http://127.0.0.1:8098/api", + "http://[::1]:8098/api", + ] { + assert!(validate_api_key_remote_host(base_url).is_ok()); + } + } + + #[test] + fn exec_server_remote_api_key_auth_rejects_http_openai_domain() { + for base_url in [ + "http://service.openai.com/api", + "http://service.openai.org/api", + ] { + let error = validate_api_key_remote_host(base_url) + .expect_err("reject plaintext OpenAI destination"); + + assert_eq!( + error.to_string(), + "remote exec-server API-key authentication is restricted to HTTPS openai.com and openai.org hosts and subdomains or loopback hosts" + ); + } + } + + #[test] + fn exec_server_remote_api_key_auth_rejects_suffix_spoof() { + let error = validate_api_key_remote_host("https://service.openai.org.evil.example/api") + .expect_err("reject suffix spoof"); + + assert_eq!( + error.to_string(), + "remote exec-server API-key authentication is restricted to HTTPS openai.com and openai.org hosts and subdomains or loopback hosts" + ); + } + fn finalize_resume_from_args(args: &[&str]) -> TuiCli { let cli = MultitoolCli::try_parse_from(args).expect("parse"); let MultitoolCli { @@ -3096,6 +3206,25 @@ mod tests { ); } + #[test] + fn app_server_stdio_flag_parses() { + let app_server = app_server_from_args(["codex", "app-server", "--stdio"].as_ref()); + assert!(app_server.stdio); + } + + #[test] + fn app_server_stdio_flag_conflicts_with_listen() { + let err = MultitoolCli::try_parse_from([ + "codex", + "app-server", + "--stdio", + "--listen", + "stdio://", + ]) + .expect_err("--stdio and --listen should be rejected together"); + assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict); + } + #[test] fn app_server_listen_unix_socket_url_parses() { let app_server = diff --git a/codex-rs/codex-mcp/src/codex_apps.rs b/codex-rs/codex-mcp/src/codex_apps.rs index eb88ffa6b1b..3196a143e8e 100644 --- a/codex-rs/codex-mcp/src/codex_apps.rs +++ b/codex-rs/codex-mcp/src/codex_apps.rs @@ -12,7 +12,9 @@ use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::runtime::emit_duration; use crate::tools::MCP_TOOLS_CACHE_WRITE_DURATION_METRIC; use crate::tools::ToolInfo; +use anyhow::Context; use codex_login::CodexAuth; +use codex_protocol::mcp::McpServerInfo; use codex_utils_plugins::mcp_connector::is_connector_id_allowed; use codex_utils_plugins::mcp_connector::sanitize_name; use serde::Deserialize; @@ -20,8 +22,6 @@ use serde::Serialize; use sha1::Digest; use sha1::Sha1; -pub(crate) const CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION: u8 = 3; - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CodexAppsToolsCacheKey { pub(crate) account_id: Option, @@ -44,11 +44,19 @@ pub(crate) struct CodexAppsToolsCacheContext { } impl CodexAppsToolsCacheContext { - pub(crate) fn cache_path(&self) -> PathBuf { + pub(crate) fn tools_cache_path(&self) -> PathBuf { + self.cache_path_in(CODEX_APPS_TOOLS_CACHE_DIR) + } + + pub(crate) fn server_info_cache_path(&self) -> PathBuf { + self.cache_path_in(CODEX_APPS_SERVER_INFO_CACHE_DIR) + } + + fn cache_path_in(&self, cache_dir: &str) -> PathBuf { let user_key_json = serde_json::to_string(&self.user_key).unwrap_or_default(); let user_key_hash = sha1_hex(&user_key_json); self.codex_home - .join(CODEX_APPS_TOOLS_CACHE_DIR) + .join(cache_dir) .join(format!("{user_key_hash}.json")) } } @@ -136,6 +144,7 @@ pub(crate) fn normalize_codex_apps_callable_namespace( pub(crate) fn write_cached_codex_apps_tools_if_needed( server_name: &str, cache_context: Option<&CodexAppsToolsCacheContext>, + server_info: &McpServerInfo, tools: &[ToolInfo], ) { if server_name != CODEX_APPS_MCP_SERVER_NAME { @@ -145,6 +154,9 @@ pub(crate) fn write_cached_codex_apps_tools_if_needed( if let Some(cache_context) = cache_context { let cache_write_start = Instant::now(); write_cached_codex_apps_tools(cache_context, tools); + if let Err(err) = write_cached_codex_apps_server_info(cache_context, server_info) { + tracing::warn!("failed to write Codex Apps server info cache: {err:#}"); + } emit_duration( MCP_TOOLS_CACHE_WRITE_DURATION_METRIC, cache_write_start.elapsed(), @@ -169,6 +181,17 @@ pub(crate) fn load_startup_cached_codex_apps_tools_snapshot( } } +pub(crate) fn load_startup_cached_codex_apps_server_info( + server_name: &str, + cache_context: Option<&CodexAppsToolsCacheContext>, +) -> Option { + if server_name != CODEX_APPS_MCP_SERVER_NAME { + return None; + } + + load_cached_codex_apps_server_info(cache_context?) +} + #[cfg(test)] pub(crate) fn read_cached_codex_apps_tools( cache_context: &CodexAppsToolsCacheContext, @@ -182,7 +205,7 @@ pub(crate) fn read_cached_codex_apps_tools( pub(crate) fn load_cached_codex_apps_tools( cache_context: &CodexAppsToolsCacheContext, ) -> CachedCodexAppsToolsLoad { - let cache_path = cache_context.cache_path(); + let cache_path = cache_context.tools_cache_path(); let bytes = match std::fs::read(cache_path) { Ok(bytes) => bytes, Err(err) if err.kind() == std::io::ErrorKind::NotFound => { @@ -204,7 +227,7 @@ pub(crate) fn write_cached_codex_apps_tools( cache_context: &CodexAppsToolsCacheContext, tools: &[ToolInfo], ) { - let cache_path = cache_context.cache_path(); + let cache_path = cache_context.tools_cache_path(); if let Some(parent) = cache_path.parent() && std::fs::create_dir_all(parent).is_err() { @@ -220,6 +243,42 @@ pub(crate) fn write_cached_codex_apps_tools( let _ = std::fs::write(cache_path, bytes); } +pub(crate) fn load_cached_codex_apps_server_info( + cache_context: &CodexAppsToolsCacheContext, +) -> Option { + let bytes = std::fs::read(cache_context.server_info_cache_path()).ok()?; + let cache: CodexAppsServerInfoDiskCache = serde_json::from_slice(&bytes).ok()?; + (cache.schema_version == CODEX_APPS_SERVER_INFO_CACHE_SCHEMA_VERSION) + .then_some(cache.server_info) +} + +fn write_cached_codex_apps_server_info( + cache_context: &CodexAppsToolsCacheContext, + server_info: &McpServerInfo, +) -> anyhow::Result<()> { + let cache_path = cache_context.server_info_cache_path(); + if let Some(parent) = cache_path.parent() { + std::fs::create_dir_all(parent).with_context(|| { + format!( + "failed to create Codex Apps server info cache directory `{}`", + parent.display() + ) + })?; + } + let bytes = serde_json::to_vec_pretty(&CodexAppsServerInfoDiskCache { + schema_version: CODEX_APPS_SERVER_INFO_CACHE_SCHEMA_VERSION, + server_info: server_info.clone(), + }) + .context("failed to serialize Codex Apps server info cache")?; + std::fs::write(&cache_path, bytes).with_context(|| { + format!( + "failed to write Codex Apps server info cache `{}`", + cache_path.display() + ) + })?; + Ok(()) +} + pub(crate) fn filter_disallowed_codex_apps_tools(tools: Vec) -> Vec { tools .into_iter() @@ -237,7 +296,17 @@ struct CodexAppsToolsDiskCache { tools: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize)] +struct CodexAppsServerInfoDiskCache { + schema_version: u8, + server_info: McpServerInfo, +} + const CODEX_APPS_TOOLS_CACHE_DIR: &str = "cache/codex_apps_tools"; +pub(crate) const CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION: u8 = 3; + +const CODEX_APPS_SERVER_INFO_CACHE_DIR: &str = "cache/codex_apps_server_info"; +const CODEX_APPS_SERVER_INFO_CACHE_SCHEMA_VERSION: u8 = 1; fn sha1_hex(s: &str) -> String { let mut hasher = Sha1::new(); diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index 00d8adca2e3..d15a2e15004 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -9,6 +9,7 @@ use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; +use std::sync::atomic::Ordering; use std::time::Duration; use std::time::Instant; @@ -44,6 +45,7 @@ use codex_config::McpServerTransportConfig; use codex_config::types::OAuthCredentialsStoreMode; use codex_login::CodexAuth; use codex_protocol::mcp::CallToolResult; +use codex_protocol::mcp::McpServerInfo; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::Event; @@ -62,6 +64,7 @@ use rmcp::model::ReadResourceResult; use rmcp::model::RequestId; use rmcp::model::Resource; use rmcp::model::ResourceTemplate; +use serde_json::Value as JsonValue; use tokio::task::JoinSet; use tokio_util::sync::CancellationToken; use tracing::Instrument; @@ -70,6 +73,34 @@ use tracing::trace; use tracing::trace_span; use tracing::warn; +const MCP_UI_META_KEY: &str = "ui"; +const MCP_UI_VISIBILITY_META_KEY: &str = "visibility"; +const MCP_UI_MODEL_VISIBILITY: &str = "model"; + +/// Returns whether a tool may be included in model-facing tool declarations. +/// +/// Tools without visibility metadata remain visible. +/// Tools with visibility metadata are hidden unless they explicitly include `model`. +/// +/// +pub fn tool_is_model_visible(tool: &ToolInfo) -> bool { + let Some(visibility) = tool + .tool + .meta + .as_deref() + .and_then(|meta| meta.get(MCP_UI_META_KEY)) + .and_then(JsonValue::as_object) + .and_then(|ui| ui.get(MCP_UI_VISIBILITY_META_KEY)) + .and_then(JsonValue::as_array) + else { + return true; + }; + + visibility + .iter() + .any(|target| target.as_str() == Some(MCP_UI_MODEL_VISIBILITY)) +} + /// A thin wrapper around a set of running [`RmcpClient`] instances. pub struct McpConnectionManager { clients: HashMap, @@ -385,13 +416,13 @@ impl McpConnectionManager { pub async fn list_all_tools(&self) -> Vec { let mut tools = Vec::new(); for (server_name, managed_client) in &self.clients { - let has_startup_snapshot = managed_client.startup_snapshot.is_some(); + let has_cached_tool_info_snapshot = managed_client.cached_tool_info_snapshot.is_some(); let startup_complete = managed_client .startup_complete .load(std::sync::atomic::Ordering::Acquire); trace!( server_name = %server_name, - has_startup_snapshot, + has_cached_tool_info_snapshot, startup_complete, "waiting for MCP server tools while building tool list" ); @@ -400,7 +431,7 @@ impl McpConnectionManager { .instrument(trace_span!( "list_tools_for_server", server_name = %server_name, - has_startup_snapshot, + has_cached_tool_info_snapshot, startup_complete )) .await @@ -421,6 +452,31 @@ impl McpConnectionManager { normalize_tools_for_model_with_prefix(tools, self.prefix_mcp_tool_names) } + /// Returns presentation metadata without waiting for uncached clients still initializing. + /// Cached values will be used if available and the server is still starting up. + pub async fn list_available_server_infos(&self) -> HashMap { + let mut server_infos = HashMap::new(); + for (server_name, client) in &self.clients { + if !client.startup_complete.load(Ordering::Acquire) { + if let Some(server_info) = client.cached_server_info.clone() { + server_infos.insert(server_name.clone(), server_info); + } + continue; + } + match client.client().await { + Ok(managed_client) => { + server_infos.insert(server_name.clone(), managed_client.server_info); + } + Err(_) => { + if let Some(server_info) = client.cached_server_info.clone() { + server_infos.insert(server_name.clone(), server_info); + } + } + } + } + server_infos + } + /// Force-refresh codex apps tools by bypassing the in-process cache. /// /// On success, the refreshed tools replace the cache contents and the @@ -456,6 +512,7 @@ impl McpConnectionManager { write_cached_codex_apps_tools_if_needed( CODEX_APPS_MCP_SERVER_NAME, managed_client.codex_apps_tools_cache_context.as_ref(), + &managed_client.server_info, &tools, ); emit_duration( @@ -510,9 +567,8 @@ impl McpConnectionManager { let mut cursor: Option = None; loop { - let params = cursor.as_ref().map(|next| PaginatedRequestParams { - meta: None, - cursor: Some(next.clone()), + let params = cursor.as_ref().map(|next| { + PaginatedRequestParams::default().with_cursor(Some(next.clone())) }); let response = match client.list_resources(params, timeout).await { Ok(result) => result, @@ -576,9 +632,8 @@ impl McpConnectionManager { let mut cursor: Option = None; loop { - let params = cursor.as_ref().map(|next| PaginatedRequestParams { - meta: None, - cursor: Some(next.clone()), + let params = cursor.as_ref().map(|next| { + PaginatedRequestParams::default().with_cursor(Some(next.clone())) }); let response = match client.list_resource_templates(params, timeout).await { Ok(result) => result, diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index 86eb703e7a7..60a0026eeb2 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -1,9 +1,11 @@ use super::*; use crate::codex_apps::CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION; use crate::codex_apps::CodexAppsToolsCacheContext; +use crate::codex_apps::load_startup_cached_codex_apps_server_info; use crate::codex_apps::load_startup_cached_codex_apps_tools_snapshot; use crate::codex_apps::read_cached_codex_apps_tools; use crate::codex_apps::write_cached_codex_apps_tools; +use crate::codex_apps::write_cached_codex_apps_tools_if_needed; use crate::declared_openai_file_input_param_names; use crate::elicitation::ElicitationRequestManager; use crate::elicitation::elicitation_is_rejected_by_policy; @@ -20,6 +22,7 @@ use codex_config::Constrained; use codex_config::McpServerConfig; use codex_exec_server::EnvironmentManager; use codex_protocol::ToolName; +use codex_protocol::mcp::McpServerInfo; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::GranularApprovalConfig; use codex_protocol::protocol::McpAuthStatus; @@ -44,17 +47,11 @@ fn create_test_tool(server_name: &str, tool_name: &str) -> ToolInfo { callable_name: tool_name.to_string(), callable_namespace: server_name.to_string(), namespace_description: None, - tool: Tool { - name: tool_name.to_string().into(), - title: None, - description: Some(format!("Test tool: {tool_name}").into()), - input_schema: Arc::new(JsonObject::default()), - output_schema: None, - annotations: None, - execution: None, - icons: None, - meta: None, - }, + tool: Tool::new( + tool_name.to_string(), + format!("Test tool: {tool_name}"), + Arc::new(JsonObject::default()), + ), connector_id: None, connector_name: None, plugin_display_names: Vec::new(), @@ -88,6 +85,17 @@ fn create_codex_apps_tools_cache_context( } } +fn create_test_server_info(title: &str) -> McpServerInfo { + McpServerInfo { + name: "codex-apps".to_string(), + title: Some(title.to_string()), + version: "1.0.0".to_string(), + description: None, + icons: None, + website_url: None, + } +} + fn model_tool_names(tools: &[ToolInfo]) -> HashSet { tools .iter() @@ -588,8 +596,8 @@ fn codex_apps_tools_cache_is_scoped_per_user() { assert_eq!(read_user_1[0].callable_name, "one"); assert_eq!(read_user_2[0].callable_name, "two"); assert_ne!( - cache_context_user_1.cache_path(), - cache_context_user_2.cache_path(), + cache_context_user_1.tools_cache_path(), + cache_context_user_2.tools_cache_path(), "each user should get an isolated cache file" ); } @@ -633,7 +641,7 @@ fn codex_apps_tools_cache_is_ignored_when_schema_version_mismatches() { Some("account-one"), Some("user-one"), ); - let cache_path = cache_context.cache_path(); + let cache_path = cache_context.tools_cache_path(); if let Some(parent) = cache_path.parent() { std::fs::create_dir_all(parent).expect("create parent"); } @@ -655,7 +663,7 @@ fn codex_apps_tools_cache_is_ignored_when_json_is_invalid() { Some("account-one"), Some("user-one"), ); - let cache_path = cache_context.cache_path(); + let cache_path = cache_context.tools_cache_path(); if let Some(parent) = cache_path.parent() { std::fs::create_dir_all(parent).expect("create parent"); } @@ -676,21 +684,112 @@ fn startup_cached_codex_apps_tools_loads_from_disk_cache() { CODEX_APPS_MCP_SERVER_NAME, "calendar_search", )]; - write_cached_codex_apps_tools(&cache_context, &cached_tools); + let server_info = create_test_server_info("Codex Apps"); + write_cached_codex_apps_tools_if_needed( + CODEX_APPS_MCP_SERVER_NAME, + Some(&cache_context), + &server_info, + &cached_tools, + ); - let startup_snapshot = load_startup_cached_codex_apps_tools_snapshot( + let startup_tools = load_startup_cached_codex_apps_tools_snapshot( + CODEX_APPS_MCP_SERVER_NAME, + Some(&cache_context), + ) + .expect("expected startup snapshot to load from cache"); + let cached_server_info = load_startup_cached_codex_apps_server_info( CODEX_APPS_MCP_SERVER_NAME, Some(&cache_context), ); - let startup_tools = startup_snapshot.expect("expected startup snapshot to load from cache"); assert_eq!(startup_tools.len(), 1); assert_eq!(startup_tools[0].server_name, CODEX_APPS_MCP_SERVER_NAME); assert_eq!(startup_tools[0].callable_name, "calendar_search"); + assert_eq!(cached_server_info, Some(server_info)); +} + +#[test] +fn startup_cached_codex_apps_tools_loads_without_server_info_cache() { + let codex_home = tempdir().expect("tempdir"); + let cache_context = create_codex_apps_tools_cache_context( + codex_home.path().to_path_buf(), + Some("account-one"), + Some("user-one"), + ); + let cache_path = cache_context.tools_cache_path(); + if let Some(parent) = cache_path.parent() { + std::fs::create_dir_all(parent).expect("create parent"); + } + let bytes = serde_json::to_vec_pretty(&serde_json::json!({ + "schema_version": CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION, + "tools": [create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "calendar_search")], + })) + .expect("serialize"); + std::fs::write(cache_path, bytes).expect("write"); + + let startup_tools = load_startup_cached_codex_apps_tools_snapshot( + CODEX_APPS_MCP_SERVER_NAME, + Some(&cache_context), + ) + .expect("legacy startup snapshot should remain available"); + let cached_server_info = load_startup_cached_codex_apps_server_info( + CODEX_APPS_MCP_SERVER_NAME, + Some(&cache_context), + ); + + assert_eq!(startup_tools.len(), 1); + assert_eq!(startup_tools[0].callable_name, "calendar_search"); + assert_eq!(cached_server_info, None); +} + +#[test] +fn codex_apps_server_info_cache_survives_legacy_tools_cache_write() { + let codex_home = tempdir().expect("tempdir"); + let cache_context = create_codex_apps_tools_cache_context( + codex_home.path().to_path_buf(), + Some("account-one"), + Some("user-one"), + ); + let server_info = create_test_server_info("Codex Apps"); + write_cached_codex_apps_tools_if_needed( + CODEX_APPS_MCP_SERVER_NAME, + Some(&cache_context), + &server_info, + &[create_test_tool( + CODEX_APPS_MCP_SERVER_NAME, + "calendar_search", + )], + ); + + let cache_path = cache_context.tools_cache_path(); + if let Some(parent) = cache_path.parent() { + std::fs::create_dir_all(parent).expect("create parent"); + } + let bytes = serde_json::to_vec_pretty(&serde_json::json!({ + "schema_version": CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION - 1, + "tools": [create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "calendar_search")], + })) + .expect("serialize"); + std::fs::write(cache_path, bytes).expect("write legacy tools cache"); + + assert_eq!( + load_startup_cached_codex_apps_server_info( + CODEX_APPS_MCP_SERVER_NAME, + Some(&cache_context), + ), + Some(server_info) + ); + assert!( + load_startup_cached_codex_apps_tools_snapshot( + CODEX_APPS_MCP_SERVER_NAME, + Some(&cache_context), + ) + .is_none() + ); } #[tokio::test] -async fn list_all_tools_uses_startup_snapshot_while_client_is_pending() { +async fn list_all_tools_uses_cached_tool_info_snapshot_while_client_is_pending() { let startup_tools = vec![create_test_tool( CODEX_APPS_MCP_SERVER_NAME, "calendar_create_event", @@ -709,7 +808,8 @@ async fn list_all_tools_uses_startup_snapshot_while_client_is_pending() { CODEX_APPS_MCP_SERVER_NAME.to_string(), AsyncManagedClient { client: pending_client, - startup_snapshot: Some(startup_tools), + cached_tool_info_snapshot: Some(startup_tools), + cached_server_info: None, startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), cancel_token: CancellationToken::new(), @@ -728,6 +828,43 @@ async fn list_all_tools_uses_startup_snapshot_while_client_is_pending() { assert_eq!(tool.callable_name, "calendar_create_event"); } +#[tokio::test] +async fn list_available_server_infos_uses_cache_while_client_is_pending() { + let pending_client = futures::future::pending::>() + .boxed() + .shared(); + let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); + let permission_profile = Constrained::allow_any(PermissionProfile::default()); + let mut manager = McpConnectionManager::new_uninitialized( + &approval_policy, + &permission_profile, + /*prefix_mcp_tool_names*/ true, + ); + let server_info = create_test_server_info("Codex Apps"); + manager.clients.insert( + CODEX_APPS_MCP_SERVER_NAME.to_string(), + AsyncManagedClient { + client: pending_client, + cached_tool_info_snapshot: Some(Vec::new()), + cached_server_info: Some(server_info.clone()), + startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), + tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), + cancel_token: CancellationToken::new(), + }, + ); + + let timeout_result = tokio::time::timeout( + Duration::from_millis(10), + manager.list_available_server_infos(), + ) + .await; + let server_infos = timeout_result.expect("server info lookup should not block on startup"); + assert_eq!( + server_infos.get(CODEX_APPS_MCP_SERVER_NAME), + Some(&server_info) + ); +} + #[tokio::test] async fn list_all_tools_accepts_canonical_namespaced_tool_names() { let startup_tools = vec![create_test_tool("rmcp", "echo")]; @@ -745,7 +882,8 @@ async fn list_all_tools_accepts_canonical_namespaced_tool_names() { "rmcp".to_string(), AsyncManagedClient { client: pending_client, - startup_snapshot: Some(startup_tools), + cached_tool_info_snapshot: Some(startup_tools), + cached_server_info: None, startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), cancel_token: CancellationToken::new(), @@ -787,7 +925,8 @@ async fn list_all_tools_applies_legacy_mcp_prefix_by_default() { "rmcp".to_string(), AsyncManagedClient { client: pending_client, - startup_snapshot: Some(startup_tools), + cached_tool_info_snapshot: Some(startup_tools), + cached_server_info: None, startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), cancel_token: CancellationToken::new(), @@ -813,7 +952,7 @@ async fn list_all_tools_applies_legacy_mcp_prefix_by_default() { } #[tokio::test] -async fn list_all_tools_blocks_while_client_is_pending_without_startup_snapshot() { +async fn list_all_tools_blocks_while_client_is_pending_without_cached_tool_info_snapshot() { let pending_client = futures::future::pending::>() .boxed() .shared(); @@ -828,7 +967,8 @@ async fn list_all_tools_blocks_while_client_is_pending_without_startup_snapshot( CODEX_APPS_MCP_SERVER_NAME.to_string(), AsyncManagedClient { client: pending_client, - startup_snapshot: None, + cached_tool_info_snapshot: None, + cached_server_info: None, startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), cancel_token: CancellationToken::new(), @@ -841,7 +981,7 @@ async fn list_all_tools_blocks_while_client_is_pending_without_startup_snapshot( } #[tokio::test] -async fn list_all_tools_does_not_block_when_startup_snapshot_cache_hit_is_empty() { +async fn list_all_tools_does_not_block_when_cached_tool_info_snapshot_is_empty() { let pending_client = futures::future::pending::>() .boxed() .shared(); @@ -856,7 +996,8 @@ async fn list_all_tools_does_not_block_when_startup_snapshot_cache_hit_is_empty( CODEX_APPS_MCP_SERVER_NAME.to_string(), AsyncManagedClient { client: pending_client, - startup_snapshot: Some(Vec::new()), + cached_tool_info_snapshot: Some(Vec::new()), + cached_server_info: None, startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), cancel_token: CancellationToken::new(), @@ -870,11 +1011,12 @@ async fn list_all_tools_does_not_block_when_startup_snapshot_cache_hit_is_empty( } #[tokio::test] -async fn list_all_tools_uses_startup_snapshot_when_client_startup_fails() { +async fn list_all_tools_uses_cached_tool_info_snapshot_when_client_startup_fails() { let startup_tools = vec![create_test_tool( CODEX_APPS_MCP_SERVER_NAME, "calendar_create_event", )]; + let server_info = create_test_server_info("Codex Apps"); let failed_client = futures::future::ready::>(Err( StartupOutcomeError::Failed { error: "startup failed".to_string(), @@ -894,7 +1036,8 @@ async fn list_all_tools_uses_startup_snapshot_when_client_startup_fails() { CODEX_APPS_MCP_SERVER_NAME.to_string(), AsyncManagedClient { client: failed_client, - startup_snapshot: Some(startup_tools), + cached_tool_info_snapshot: Some(startup_tools), + cached_server_info: Some(server_info.clone()), startup_complete, tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), cancel_token: CancellationToken::new(), @@ -911,6 +1054,13 @@ async fn list_all_tools_uses_startup_snapshot_when_client_startup_fails() { .expect("tool from startup cache"); assert_eq!(tool.server_name, CODEX_APPS_MCP_SERVER_NAME); assert_eq!(tool.callable_name, "calendar_create_event"); + assert_eq!( + manager + .list_available_server_infos() + .await + .get(CODEX_APPS_MCP_SERVER_NAME), + Some(&server_info) + ); } #[tokio::test] @@ -941,7 +1091,8 @@ async fn list_all_tools_adds_server_metadata_to_cached_tools() { server_name.to_string(), AsyncManagedClient { client: pending_client, - startup_snapshot: Some(startup_tools), + cached_tool_info_snapshot: Some(startup_tools), + cached_server_info: None, startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), cancel_token: CancellationToken::new(), diff --git a/codex-rs/codex-mcp/src/lib.rs b/codex-rs/codex-mcp/src/lib.rs index 7b45bcc7a78..83419175d45 100644 --- a/codex-rs/codex-mcp/src/lib.rs +++ b/codex-rs/codex-mcp/src/lib.rs @@ -1,4 +1,5 @@ pub use connection_manager::McpConnectionManager; +pub use connection_manager::tool_is_model_visible; pub use elicitation::ElicitationReviewRequest; pub use elicitation::ElicitationReviewer; pub use elicitation::ElicitationReviewerHandle; diff --git a/codex-rs/codex-mcp/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs index 407ece427eb..51a6f186860 100644 --- a/codex-rs/codex-mcp/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -25,6 +25,7 @@ use codex_config::types::ApprovalsReviewer; use codex_config::types::OAuthCredentialsStoreMode; use codex_login::CodexAuth; use codex_plugin::PluginCapabilitySummary; +use codex_protocol::mcp::McpServerInfo; use codex_protocol::mcp::Resource; use codex_protocol::mcp::ResourceTemplate; use codex_protocol::mcp::Tool; @@ -300,13 +301,7 @@ pub async fn read_mcp_resource( .await; let result = manager - .read_resource( - server, - ReadResourceRequestParams { - meta: None, - uri: uri.to_string(), - }, - ) + .read_resource(server, ReadResourceRequestParams::new(uri)) .await; cancel_token.cancel(); result @@ -314,6 +309,7 @@ pub async fn read_mcp_resource( #[derive(Debug, Clone)] pub struct McpServerStatusSnapshot { + pub server_infos: HashMap, pub tools_by_server: HashMap>, pub resources: HashMap>, pub resource_templates: HashMap>, @@ -333,6 +329,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail( let tool_plugin_provenance = tool_plugin_provenance(config); if mcp_servers.is_empty() { return McpServerStatusSnapshot { + server_infos: HashMap::new(), tools_by_server: HashMap::new(), resources: HashMap::new(), resource_templates: HashMap::new(), @@ -605,6 +602,7 @@ async fn collect_mcp_server_status_snapshot_from_manager( } }, ); + let server_infos = mcp_connection_manager.list_available_server_infos().await; let mut tools_by_server = HashMap::>::new(); for tool_info in tools { @@ -620,6 +618,7 @@ async fn collect_mcp_server_status_snapshot_from_manager( } McpServerStatusSnapshot { + server_infos, tools_by_server, resources: convert_mcp_resources(resources), resource_templates: convert_mcp_resource_templates(resource_templates), diff --git a/codex-rs/codex-mcp/src/rmcp_client.rs b/codex-rs/codex-mcp/src/rmcp_client.rs index c75895842b2..78078720e7e 100644 --- a/codex-rs/codex-mcp/src/rmcp_client.rs +++ b/codex-rs/codex-mcp/src/rmcp_client.rs @@ -20,6 +20,7 @@ use crate::codex_apps::CachedCodexAppsToolsLoad; use crate::codex_apps::CodexAppsToolsCacheContext; use crate::codex_apps::filter_disallowed_codex_apps_tools; use crate::codex_apps::load_cached_codex_apps_tools; +use crate::codex_apps::load_startup_cached_codex_apps_server_info; use crate::codex_apps::load_startup_cached_codex_apps_tools_snapshot; use crate::codex_apps::normalize_codex_apps_callable_name; use crate::codex_apps::normalize_codex_apps_callable_namespace; @@ -47,6 +48,7 @@ use codex_config::McpServerTransportConfig; use codex_config::types::OAuthCredentialsStoreMode; use codex_exec_server::HttpClient; use codex_exec_server::ReqwestHttpClient; +use codex_protocol::mcp::McpServerInfo; use codex_protocol::protocol::Event; use codex_rmcp_client::ExecutorStdioServerLauncher; use codex_rmcp_client::LocalStdioServerLauncher; @@ -85,6 +87,7 @@ const UNTRUSTED_CONNECTOR_META_KEYS: &[&str] = &[ #[derive(Clone)] pub(crate) struct ManagedClient { pub(crate) client: Arc, + pub(crate) server_info: McpServerInfo, pub(crate) tools: Vec, pub(crate) tool_filter: ToolFilter, pub(crate) tool_timeout: Option, @@ -123,7 +126,8 @@ impl ManagedClient { #[derive(Clone)] pub(crate) struct AsyncManagedClient { pub(crate) client: Shared>>, - pub(crate) startup_snapshot: Option>, + pub(crate) cached_tool_info_snapshot: Option>, + pub(crate) cached_server_info: Option, pub(crate) startup_complete: Arc, pub(crate) tool_plugin_provenance: Arc, pub(crate) cancel_token: CancellationToken, @@ -150,11 +154,16 @@ impl AsyncManagedClient { .configured_config() .map(ToolFilter::from_config) .unwrap_or_default(); - let startup_snapshot = load_startup_cached_codex_apps_tools_snapshot( + let cached_tool_info_snapshot = load_startup_cached_codex_apps_tools_snapshot( &server_name, codex_apps_tools_cache_context.as_ref(), - ) - .map(|tools| filter_tools(tools, &tool_filter)); + ); + let cached_tool_info_snapshot = + cached_tool_info_snapshot.map(|tools| filter_tools(tools, &tool_filter)); + let cached_server_info = load_startup_cached_codex_apps_server_info( + &server_name, + codex_apps_tools_cache_context.as_ref(), + ); let startup_tool_filter = tool_filter; let startup_complete = Arc::new(AtomicBool::new(false)); let startup_complete_for_fut = Arc::clone(&startup_complete); @@ -207,7 +216,7 @@ impl AsyncManagedClient { outcome }; let client = fut.boxed().shared(); - if startup_snapshot.is_some() { + if cached_tool_info_snapshot.is_some() { let startup_task = client.clone(); tokio::spawn(async move { let _ = startup_task.await; @@ -216,7 +225,8 @@ impl AsyncManagedClient { Self { client, - startup_snapshot, + cached_tool_info_snapshot, + cached_server_info, startup_complete, tool_plugin_provenance, cancel_token, @@ -238,9 +248,9 @@ impl AsyncManagedClient { } } - fn startup_snapshot_while_initializing(&self) -> Option> { + fn cached_tool_info_snapshot_while_initializing(&self) -> Option> { if !self.startup_complete.load(Ordering::Acquire) { - return self.startup_snapshot.clone(); + return self.cached_tool_info_snapshot.clone(); } None } @@ -298,12 +308,13 @@ impl AsyncManagedClient { }; // Keep cache payloads raw; plugin provenance is resolved per-session at read time. - let tools = if let Some(startup_tools) = self.startup_snapshot_while_initializing() { + let tools = if let Some(startup_tools) = self.cached_tool_info_snapshot_while_initializing() + { Some(startup_tools) } else { match self.client().await { Ok(client) => Some(client.listed_tools()), - Err(_) => self.startup_snapshot.clone(), + Err(_) => self.cached_tool_info_snapshot.clone(), } }; tools.map(annotate_tools) @@ -470,26 +481,13 @@ async fn start_server_task( codex_apps_tools_cache_context, client_elicitation_capability, } = params; - let params = InitializeRequestParams { - meta: None, - capabilities: ClientCapabilities { - experimental: None, - extensions: None, - roots: None, - sampling: None, - elicitation: Some(client_elicitation_capability), - tasks: None, - }, - client_info: Implementation { - name: "codex-mcp-client".to_owned(), - version: env!("CARGO_PKG_VERSION").to_owned(), - title: Some("Codex".into()), - description: None, - icons: None, - website_url: None, - }, - protocol_version: ProtocolVersion::V_2025_06_18, - }; + let mut capabilities = ClientCapabilities::default(); + capabilities.elicitation = Some(client_elicitation_capability); + let params = InitializeRequestParams::new( + capabilities, + Implementation::new("codex-mcp-client", env!("CARGO_PKG_VERSION")).with_title("Codex"), + ) + .with_protocol_version(ProtocolVersion::V_2025_06_18); let send_elicitation = elicitation_requests.make_sender(server_name.clone(), tx_event); @@ -519,9 +517,11 @@ async fn start_server_task( fetch_start.elapsed(), &[], ); + let server_info = mcp_server_info_from_implementation(initialize_result.server_info); write_cached_codex_apps_tools_if_needed( &server_name, codex_apps_tools_cache_context.as_ref(), + &server_info, &tools, ); if server_name == CODEX_APPS_MCP_SERVER_NAME { @@ -535,6 +535,7 @@ async fn start_server_task( let managed = ManagedClient { client: Arc::clone(&client), + server_info, tools, tool_timeout: Some(tool_timeout), tool_filter, @@ -546,6 +547,22 @@ async fn start_server_task( Ok(managed) } +fn mcp_server_info_from_implementation(server_info: Implementation) -> McpServerInfo { + McpServerInfo { + name: server_info.name, + title: server_info.title, + version: server_info.version, + description: server_info.description, + icons: server_info.icons.map(|icons| { + icons + .into_iter() + .filter_map(|icon| serde_json::to_value(icon).ok()) + .collect() + }), + website_url: server_info.website_url, + } +} + struct StartServerTaskParams { startup_timeout: Option, // TODO: cancel_token should handle this. tool_timeout: Duration, @@ -647,32 +664,27 @@ mod tests { use rmcp::model::Meta; fn tool_with_connector_meta() -> RmcpTool { - RmcpTool { - name: "capture_file_upload".to_string().into(), - title: None, - description: Some("test tool".to_string().into()), - input_schema: Arc::new(JsonObject::default()), - output_schema: None, - annotations: None, - execution: None, - icons: None, - meta: Some(Meta( - serde_json::json!({ - "connector_id": "connector_gmail", - "connector_name": "Gmail", - "connector_display_name": "Gmail", - "connector_description": "Mail connector", - "connectorDescription": "Mail connector", - "connectorFutureField": "future connector metadata", - "CONNECTOR_UPPERCASE": "uppercase connector metadata", - "openai/fileParams": ["file"], - "custom": "kept" - }) - .as_object() - .expect("object") - .clone(), - )), - } + RmcpTool::new( + "capture_file_upload", + "test tool", + Arc::new(JsonObject::default()), + ) + .with_meta(Meta( + serde_json::json!({ + "connector_id": "connector_gmail", + "connector_name": "Gmail", + "connector_display_name": "Gmail", + "connector_description": "Mail connector", + "connectorDescription": "Mail connector", + "connectorFutureField": "future connector metadata", + "CONNECTOR_UPPERCASE": "uppercase connector metadata", + "openai/fileParams": ["file"], + "custom": "kept" + }) + .as_object() + .expect("object") + .clone(), + )) } #[test] diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index ab855d733bd..1dd6bda8a41 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -473,6 +473,9 @@ "image_generation": { "type": "boolean" }, + "imagegenext": { + "type": "boolean" + }, "in_app_browser": { "type": "boolean" }, @@ -4564,6 +4567,9 @@ "image_generation": { "type": "boolean" }, + "imagegenext": { + "type": "boolean" + }, "in_app_browser": { "type": "boolean" }, diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 8f25074d76f..1091761f79b 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -704,22 +704,6 @@ impl AgentControl { result } - /// Append a prebuilt message to an existing agent thread outside the normal user-input path. - #[cfg(test)] - pub(crate) async fn append_message( - &self, - agent_id: ThreadId, - message: ResponseItem, - ) -> CodexResult { - let state = self.upgrade()?; - self.handle_thread_request_result( - agent_id, - &state, - state.append_message(agent_id, message).await, - ) - .await - } - pub(crate) async fn send_inter_agent_communication( &self, agent_id: ThreadId, @@ -788,15 +772,45 @@ impl AgentControl { /// agent and any live descendants reached from the in-memory tree. pub(crate) async fn close_agent(&self, agent_id: ThreadId) -> CodexResult { let state = self.upgrade()?; - if let Ok(thread) = state.get_thread(agent_id).await - && let Some(state_db_ctx) = thread.state_db() - && let Err(err) = state_db_ctx - .set_thread_spawn_edge_status(agent_id, DirectionalThreadSpawnEdgeStatus::Closed) - .await - { - warn!("failed to persist thread-spawn edge status for {agent_id}: {err}"); + let known_agent = self.state.agent_metadata_for_thread(agent_id).is_some(); + match state.get_thread(agent_id).await { + Ok(thread) => { + if let Some(state_db_ctx) = thread.state_db() + && let Err(err) = state_db_ctx + .set_thread_spawn_edge_status( + agent_id, + DirectionalThreadSpawnEdgeStatus::Closed, + ) + .await + { + warn!("failed to persist thread-spawn edge status for {agent_id}: {err}"); + } + } + Err(CodexErr::ThreadNotFound(_)) if known_agent => { + if let Some(state_db_ctx) = state.state_db() + && let Err(err) = state_db_ctx + .set_thread_spawn_edge_status( + agent_id, + DirectionalThreadSpawnEdgeStatus::Closed, + ) + .await + { + return Err(CodexErr::Fatal(format!( + "failed to persist stale thread-spawn edge status for {agent_id}: {err}" + ))); + } + } + Err(CodexErr::ThreadNotFound(_)) => {} + Err(err) => { + warn!("failed to inspect agent before close {agent_id}: {err}"); + } + } + match Box::pin(self.shutdown_agent_tree(agent_id)).await { + Err(CodexErr::ThreadNotFound(_)) | Err(CodexErr::InternalAgentDied) if known_agent => { + Ok(String::new()) + } + result => result, } - Box::pin(self.shutdown_agent_tree(agent_id)).await } /// Shut down `agent_id` and any live descendants reachable from the in-memory spawn tree. diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index b5ba938ef7e..43a6e47bfdb 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -518,60 +518,6 @@ async fn send_inter_agent_communication_without_turn_queues_message_without_trig )); } -#[tokio::test] -async fn append_message_records_assistant_message() { - let harness = AgentControlHarness::new().await; - let (thread_id, thread) = harness.start_thread().await; - let message = - "author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: hello from tests"; - - let submission_id = harness - .control - .append_message( - thread_id, - ResponseItem::Message { - id: None, - role: "assistant".to_string(), - content: vec![ContentItem::InputText { - text: message.to_string(), - }], - phase: None, - }, - ) - .await - .expect("append_message should succeed"); - assert!(!submission_id.is_empty()); - - timeout(Duration::from_secs(5), async { - loop { - let history_items = thread - .codex - .session - .clone_history() - .await - .raw_items() - .to_vec(); - let recorded = history_items.iter().any(|item| { - matches!( - item, - ResponseItem::Message { role, content, .. } - if role == "assistant" - && content.iter().any(|content_item| matches!( - content_item, - ContentItem::InputText { text } if text == message - )) - ) - }); - if recorded { - break; - } - sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("assistant message should be recorded"); -} - #[tokio::test] async fn spawn_agent_creates_thread_and_sends_prompt() { let harness = AgentControlHarness::new().await; diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 6c87400eddd..f47be864fb5 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -218,6 +218,7 @@ impl RequestRouteTelemetry { #[derive(Debug, Clone)] pub struct ModelClient { state: Arc, + prompt_cache_key_override: Option, } /// A turn-scoped streaming session created from a [`ModelClient`]. @@ -352,9 +353,24 @@ impl ModelClient { disable_websockets: AtomicBool::new(false), cached_websocket_session: StdMutex::new(WebsocketSession::default()), }), + prompt_cache_key_override: None, } } + pub(crate) fn with_prompt_cache_key_override( + mut self, + prompt_cache_key_override: Option, + ) -> Self { + self.prompt_cache_key_override = prompt_cache_key_override; + self + } + + fn prompt_cache_key(&self) -> String { + self.prompt_cache_key_override + .clone() + .unwrap_or_else(|| self.state.thread_id.to_string()) + } + /// Creates a fresh turn-scoped streaming session. /// /// This constructor does not perform network I/O itself; the session opens a websocket lazily @@ -749,7 +765,7 @@ impl ModelClient { &prompt.output_schema, prompt.output_schema_strict, ); - let prompt_cache_key = Some(self.state.thread_id.to_string()); + let prompt_cache_key = Some(self.prompt_cache_key()); let service_tier = model_info.service_tier_for_request(service_tier); let request = ResponsesApiRequest { model: model_info.slug.clone(), diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 1113a12c8aa..ce8000de6d8 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -260,7 +260,12 @@ impl CodexThread { &self, items: Vec, ) -> Result<(), Vec> { - self.codex.session.inject_response_items(items).await + let response_items = items.iter().cloned().map(ResponseItem::from).collect(); + self.codex + .session + .inject_if_running(response_items) + .await + .map_err(|_| items) } pub async fn set_app_server_client_info( @@ -366,58 +371,16 @@ impl CodexThread { /// Records a user-role session-prefix message without creating a new user turn boundary. pub(crate) async fn inject_user_message_without_turn(&self, message: String) { - let message = ResponseItem::Message { + let item = ResponseItem::Message { id: None, role: "user".to_string(), content: vec![ContentItem::InputText { text: message }], phase: None, }; - let pending_item = match pending_message_input_item(&message) { - Ok(pending_item) => pending_item, - Err(err) => { - debug_assert!(false, "session-prefix message append should succeed: {err}"); - return; - } - }; - if self - .codex - .session - .inject_response_items(vec![pending_item]) - .await - .is_err() - { - let turn_context = self.codex.session.new_default_turn().await; - self.codex - .session - .record_conversation_items(turn_context.as_ref(), &[message]) - .await; - } - } - - /// Append a prebuilt message to the thread history without treating it as a user turn. - /// - /// If the thread already has an active turn, the message is queued as pending input for that - /// turn. Otherwise it is queued at session scope and a regular turn is started so the agent - /// can consume that pending input through the normal turn pipeline. - #[cfg(test)] - pub(crate) async fn append_message(&self, message: ResponseItem) -> CodexResult { - let submission_id = uuid::Uuid::new_v4().to_string(); - let pending_item = pending_message_input_item(&message)?; - if let Err(items) = self - .codex + self.codex .session - .inject_response_items(vec![pending_item]) - .await - { - self.codex - .session - .input_queue - .queue_response_items_for_next_turn(items) - .await; - self.codex.session.maybe_start_turn_for_pending_work().await; - } - - Ok(submission_id) + .inject_no_new_turn(vec![item], /*current_turn_context*/ None) + .await; } /// Append raw Responses API items to the thread's model-visible history. @@ -437,7 +400,7 @@ impl CodexThread { } self.codex .session - .record_conversation_items(turn_context.as_ref(), &items) + .inject_no_new_turn(items, Some(turn_context.as_ref())) .await; self.codex.session.flush_rollout().await?; Ok(()) @@ -540,13 +503,7 @@ impl CodexThread { let result = self .codex .session - .read_resource( - server, - ReadResourceRequestParams { - meta: None, - uri: uri.to_string(), - }, - ) + .read_resource(server, ReadResourceRequestParams::new(uri)) .await?; Ok(serde_json::to_value(result)?) @@ -604,21 +561,3 @@ impl CodexThread { Ok(*guard) } } - -fn pending_message_input_item(message: &ResponseItem) -> CodexResult { - match message { - ResponseItem::Message { - role, - content, - phase, - .. - } => Ok(ResponseInputItem::Message { - role: role.clone(), - content: content.clone(), - phase: phase.clone(), - }), - _ => Err(CodexErr::InvalidRequest( - "append_message only supports ResponseItem::Message".to_string(), - )), - } -} diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 1a6256ad76d..fd388b273cf 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -568,7 +568,7 @@ async fn drain_to_completed( }; match event { Ok(ResponseEvent::OutputItemDone(item)) => { - sess.record_into_history(std::slice::from_ref(&item), turn_context) + sess.record_conversation_items(turn_context, std::slice::from_ref(&item)) .await; } Ok(ResponseEvent::ServerReasoningIncluded(included)) => { diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index e8866aab3a8..95010356251 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -1554,7 +1554,6 @@ async fn default_permissions_profile_populates_runtime_sandbox_policy() -> std:: .await?; let cwd_root = cwd.path().abs(); - let memories_root = codex_home.path().join("memories").abs(); assert_eq!( config.permissions.file_system_sandbox_policy(), FileSystemSandboxPolicy::restricted(vec![ @@ -1576,18 +1575,12 @@ async fn default_permissions_profile_populates_runtime_sandbox_policy() -> std:: }, access: FileSystemAccessMode::Read, }, - FileSystemSandboxEntry { - path: FileSystemPath::Path { - path: memories_root.clone(), - }, - access: FileSystemAccessMode::Write, - }, ]), ); assert_eq!( &config.legacy_sandbox_policy(), &SandboxPolicy::WorkspaceWrite { - writable_roots: vec![memories_root], + writable_roots: vec![], network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -1816,7 +1809,7 @@ async fn managed_unrestricted_permission_profile_still_enables_network_requireme } #[tokio::test] -async fn permission_profile_override_applies_runtime_roots_to_legacy_projection() +async fn permission_profile_override_keeps_memories_root_out_of_legacy_projection() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; @@ -1851,7 +1844,7 @@ async fn permission_profile_override_applies_runtime_roots_to_legacy_projection( let memories_root = codex_home.path().join("memories").abs(); assert!( - config + !config .permissions .file_system_sandbox_policy() .can_write_path_with_cwd(memories_root.as_path(), cwd.path()) @@ -1859,7 +1852,7 @@ async fn permission_profile_override_applies_runtime_roots_to_legacy_projection( assert_eq!( &config.legacy_sandbox_policy(), &SandboxPolicy::WorkspaceWrite { - writable_roots: vec![memories_root], + writable_roots: vec![], network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -2757,9 +2750,6 @@ async fn permissions_profiles_allow_direct_write_roots_outside_workspace_root() description: Some("Workspace access.".to_string()), }] ); - let memories_root = AbsolutePathBuf::from_absolute_path(std::fs::canonicalize( - codex_home.path().join("memories"), - )?)?; assert!( config .permissions @@ -2769,7 +2759,7 @@ async fn permissions_profiles_allow_direct_write_roots_outside_workspace_root() assert_eq!( &config.legacy_sandbox_policy(), &SandboxPolicy::WorkspaceWrite { - writable_roots: vec![external_write_path, memories_root], + writable_roots: vec![external_write_path], network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -4514,13 +4504,15 @@ async fn sqlite_home_defaults_to_codex_home_for_workspace_write() -> std::io::Re } #[tokio::test] -async fn workspace_write_always_includes_memories_root_once() -> std::io::Result<()> { +async fn workspace_write_includes_configured_writable_root_once_without_memories_root() +-> std::io::Result<()> { let codex_home = TempDir::new()?; let memories_root = codex_home.path().join("memories"); + let writable_root = codex_home.path().join("writable").abs(); let config = Config::load_from_base_config_with_overrides( ConfigToml { sandbox_workspace_write: Some(SandboxWorkspaceWrite { - writable_roots: vec![memories_root.abs()], + writable_roots: vec![writable_root.clone(), writable_root.clone()], ..Default::default() }), ..Default::default() @@ -4540,21 +4532,22 @@ async fn workspace_write_always_includes_memories_root_once() -> std::io::Result } } else { assert!( - memories_root.is_dir(), - "expected memories root directory to exist at {}", + !memories_root.exists(), + "expected config load not to create memories root at {}", memories_root.display() ); let expected_memories_root = memories_root.abs(); match &config.legacy_sandbox_policy() { SandboxPolicy::WorkspaceWrite { writable_roots, .. } => { + assert!(!writable_roots.contains(&expected_memories_root)); assert_eq!( writable_roots .iter() - .filter(|root| **root == expected_memories_root) + .filter(|root| **root == writable_root) .count(), 1, "expected single writable root entry for {}", - expected_memories_root.display() + writable_root.display() ); } other => panic!("expected workspace-write policy, got {other:?}"), @@ -4564,6 +4557,62 @@ async fn workspace_write_always_includes_memories_root_once() -> std::io::Result Ok(()) } +#[tokio::test] +async fn memory_tool_makes_memories_root_readable_without_creating_or_widening_writes() +-> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + let memories_root = codex_home.path().join("memories"); + let memories_root_abs = memories_root.abs(); + + let config = Config::load_from_base_config_with_overrides( + ConfigToml { + features: Some(FeaturesToml::from(BTreeMap::from([( + "memories".to_string(), + true, + )]))), + sandbox_workspace_write: Some(SandboxWorkspaceWrite { + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + ..Default::default() + }), + ..Default::default() + }, + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + sandbox_mode: Some(SandboxMode::WorkspaceWrite), + ..Default::default() + }, + codex_home.abs(), + ) + .await?; + + assert!( + !memories_root.exists(), + "expected config load not to create memories root at {}", + memories_root.display() + ); + let file_system_policy = config.permissions.file_system_sandbox_policy(); + assert!(file_system_policy.can_read_path_with_cwd(memories_root_abs.as_path(), cwd.path())); + assert!(!file_system_policy.can_write_path_with_cwd(memories_root_abs.as_path(), cwd.path())); + + if cfg!(target_os = "windows") { + match &config.legacy_sandbox_policy() { + SandboxPolicy::ReadOnly { .. } => {} + other => panic!("expected read-only policy on Windows, got {other:?}"), + } + } else { + match &config.legacy_sandbox_policy() { + SandboxPolicy::WorkspaceWrite { writable_roots, .. } => { + assert!(!writable_roots.contains(&memories_root_abs)); + } + other => panic!("expected workspace-write policy, got {other:?}"), + } + } + + Ok(()) +} + #[tokio::test] async fn config_defaults_to_file_cli_auth_store_mode() -> 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 3115921c2d9..618520b7bc5 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -2631,9 +2631,8 @@ impl Config { Some(WindowsSandboxModeToml::Unelevated) => WindowsSandboxLevel::RestrictedToken, None => WindowsSandboxLevel::from_features(&features), }; + let memories_config: MemoriesConfig = cfg.memories.clone().unwrap_or_default().into(); let memories_root = memory_root(&codex_home); - std::fs::create_dir_all(&memories_root)?; - let internal_writable_roots = vec![memories_root]; let profiles_are_active = effective_permission_selection.profiles_are_active( default_permissions_override.as_deref(), @@ -2701,8 +2700,8 @@ impl Config { file_system_sandbox_policy, mut active_permission_profile, mut profile_workspace_roots, - ) = if let Some(mut permission_profile) = permission_profile { - let (mut file_system_sandbox_policy, network_sandbox_policy) = + ) = if let Some(permission_profile) = permission_profile { + let (file_system_sandbox_policy, _network_sandbox_policy) = permission_profile.to_runtime_permissions(); let configured_network_proxy_config = if profile_allows_configured_network_proxy(&permission_profile) @@ -2726,30 +2725,6 @@ impl Config { } else { NetworkProxyConfig::default() }; - let materialized_file_system_sandbox_policy = file_system_sandbox_policy - .clone() - .materialize_project_roots_with_workspace_roots(&workspace_roots); - let materialized_permission_profile = - PermissionProfile::from_runtime_permissions_with_enforcement( - permission_profile.enforcement(), - &materialized_file_system_sandbox_policy, - network_sandbox_policy, - ); - let sandbox_policy = compatibility_sandbox_policy_for_permission_profile( - &materialized_permission_profile, - &materialized_file_system_sandbox_policy, - network_sandbox_policy, - resolved_cwd.as_path(), - ); - if matches!(sandbox_policy, SandboxPolicy::WorkspaceWrite { .. }) { - file_system_sandbox_policy = file_system_sandbox_policy - .with_additional_legacy_workspace_writable_roots(&internal_writable_roots); - permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement( - permission_profile.enforcement(), - &file_system_sandbox_policy, - network_sandbox_policy, - ); - } ( configured_network_proxy_config, permission_profile, @@ -2794,7 +2769,7 @@ impl Config { dedupe_absolute_paths(&mut configured_workspace_roots); file_system_sandbox_policy = file_system_sandbox_policy .with_materialized_project_roots_for_workspace_roots(&configured_workspace_roots); - let mut permission_profile = if let Some(permission_profile) = + let permission_profile = if let Some(permission_profile) = builtin_permission_profile(default_permissions, builtin_workspace_write_settings) { permission_profile @@ -2804,30 +2779,6 @@ impl Config { network_sandbox_policy, ) }; - let materialized_file_system_sandbox_policy = file_system_sandbox_policy - .clone() - .materialize_project_roots_with_workspace_roots(&workspace_roots); - let materialized_permission_profile = - PermissionProfile::from_runtime_permissions_with_enforcement( - permission_profile.enforcement(), - &materialized_file_system_sandbox_policy, - network_sandbox_policy, - ); - let sandbox_policy = compatibility_sandbox_policy_for_permission_profile( - &materialized_permission_profile, - &materialized_file_system_sandbox_policy, - network_sandbox_policy, - resolved_cwd.as_path(), - ); - if matches!(sandbox_policy, SandboxPolicy::WorkspaceWrite { .. }) { - file_system_sandbox_policy = file_system_sandbox_policy - .with_additional_legacy_workspace_writable_roots(&internal_writable_roots); - permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement( - permission_profile.enforcement(), - &file_system_sandbox_policy, - network_sandbox_policy, - ); - } let active_permission_profile = if using_implicit_builtin_profile && default_permissions == BUILT_IN_WORKSPACE_PROFILE && cfg.sandbox_workspace_write.is_some() @@ -2885,29 +2836,8 @@ impl Config { ); permission_profile = PermissionProfile::read_only(); } - let (mut file_system_sandbox_policy, network_sandbox_policy) = + let (file_system_sandbox_policy, _network_sandbox_policy) = permission_profile.to_runtime_permissions(); - let materialized_file_system_sandbox_policy = permission_profile - .clone() - .materialize_project_roots_with_workspace_roots(&workspace_roots) - .file_system_sandbox_policy(); - if matches!(permission_profile.enforcement(), SandboxEnforcement::Managed) - && materialized_file_system_sandbox_policy.can_write_path_with_cwd( - resolved_cwd.as_path(), - resolved_cwd.as_path(), - ) - && !materialized_file_system_sandbox_policy.has_full_disk_write_access() - { - // Keep Codex runtime write access while storing the runtime - // workspace roots separately on the thread. - file_system_sandbox_policy = file_system_sandbox_policy - .with_additional_legacy_workspace_writable_roots(&internal_writable_roots); - permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement( - permission_profile.enforcement(), - &file_system_sandbox_policy, - network_sandbox_policy, - ); - } ( configured_network_proxy_config, permission_profile, @@ -3324,11 +3254,14 @@ impl Config { network_requirements, &network_permission_profile, )?; - let helper_readable_roots = get_readable_roots_required_for_codex_runtime( + let mut helper_readable_roots = get_readable_roots_required_for_codex_runtime( &codex_home, zsh_path.as_ref(), main_execve_wrapper_exe.as_ref(), ); + if features.enabled(Feature::MemoryTool) && memories_config.use_memories { + helper_readable_roots.push(memories_root); + } let effective_permission_profile = constrained_permission_profile.value.get().clone(); let (mut effective_file_system_sandbox_policy, effective_network_sandbox_policy) = effective_permission_profile.to_runtime_permissions(); @@ -3438,7 +3371,7 @@ impl Config { agent_max_threads, agent_max_depth, agent_roles, - memories: cfg.memories.unwrap_or_default().into(), + memories: memories_config, agent_job_max_runtime_seconds, agent_interrupt_message_enabled, codex_home, diff --git a/codex-rs/core/src/connectors_tests.rs b/codex-rs/core/src/connectors_tests.rs index cce24916794..3733d78c380 100644 --- a/codex-rs/core/src/connectors_tests.rs +++ b/codex-rs/core/src/connectors_tests.rs @@ -31,13 +31,13 @@ use std::sync::Arc; use tempfile::tempdir; fn annotations(destructive_hint: Option, open_world_hint: Option) -> ToolAnnotations { - ToolAnnotations { + ToolAnnotations::from_raw( + /*title*/ None, + /*read_only_hint*/ None, destructive_hint, - idempotent_hint: None, + /*idempotent_hint*/ None, open_world_hint, - read_only_hint: None, - title: None, - } + ) } fn app(id: &str) -> AppInfo { @@ -63,17 +63,7 @@ fn plugin_names(names: &[&str]) -> Vec { } fn test_tool_definition(tool_name: &str) -> Tool { - Tool { - name: tool_name.to_string().into(), - title: None, - description: None, - input_schema: Arc::new(JsonObject::default()), - output_schema: None, - annotations: None, - execution: None, - icons: None, - meta: None, - } + Tool::new_with_raw(tool_name.to_string(), None, Arc::new(JsonObject::default())) } fn codex_app_tool( @@ -243,17 +233,11 @@ fn accessible_connectors_from_mcp_tools_preserves_description() { callable_name: "calendar_create_event".to_string(), callable_namespace: "mcp__codex_apps__calendar".to_string(), namespace_description: Some("Plan events".to_string()), - tool: Tool { - name: "calendar_create_event".to_string().into(), - title: None, - description: Some("Create a calendar event".into()), - input_schema: Arc::new(JsonObject::default()), - output_schema: None, - annotations: None, - execution: None, - icons: None, - meta: None, - }, + tool: Tool::new( + "calendar_create_event", + "Create a calendar event", + Arc::new(JsonObject::default()), + ), connector_id: Some("calendar".to_string()), connector_name: Some("Calendar".to_string()), plugin_display_names: Vec::new(), diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 5cec3b49bb0..a485047a325 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -56,6 +56,7 @@ const SIGKILL_CODE: i32 = 9; const TIMEOUT_CODE: i32 = 64; const EXIT_CODE_SIGNAL_BASE: i32 = 128; // conventional shell: 128 + signal const EXEC_TIMEOUT_EXIT_CODE: i32 = 124; // conventional timeout exit code +const CANCELLATION_TERMINATION_GRACE_PERIOD: Duration = Duration::from_millis(50); // I/O buffer sizing const READ_CHUNK_SIZE: usize = 8192; // bytes per read @@ -1358,15 +1359,49 @@ async fn consume_output( (exit_status, false) } outcome = &mut expiration_wait => { - kill_child_process_group(&mut child)?; - child.start_kill()?; - let timed_out = matches!(outcome, Some(ExecExpirationOutcome::TimedOut)); - let exit_status = if timed_out { - synthetic_exit_status(EXIT_CODE_SIGNAL_BASE + TIMEOUT_CODE) - } else { - synthetic_exit_status_for_code(/*code*/ 1) - }; - (exit_status, timed_out) + match outcome { + Some(ExecExpirationOutcome::TimedOut) => { + kill_child_process_group(&mut child)?; + child.start_kill()?; + ( + synthetic_exit_status(EXIT_CODE_SIGNAL_BASE + TIMEOUT_CODE), + true, + ) + } + Some(ExecExpirationOutcome::Cancelled) => { + // Let TERM-aware processes run cleanup briefly, then kill any + // remaining members of the original process group. + let process_group_id = child.id(); + let should_escalate = if let Some(process_group_id) = process_group_id { + codex_utils_pty::process_group::terminate_process_group(process_group_id)? + } else { + false + }; + match tokio::time::timeout( + CANCELLATION_TERMINATION_GRACE_PERIOD, + child.wait(), + ) + .await + { + Ok(status) => { + status?; + if should_escalate + && let Some(process_group_id) = process_group_id + { + codex_utils_pty::process_group::kill_process_group( + process_group_id, + )?; + } + } + Err(_) => { + kill_child_process_group(&mut child)?; + child.start_kill()?; + } + } + (synthetic_exit_status_for_code(/*code*/ 1), false) + } + None => unreachable!("expiration wait only resolves while expiration is active"), + } } _ = tokio::signal::ctrl_c() => { kill_child_process_group(&mut child)?; diff --git a/codex-rs/core/src/exec_tests.rs b/codex-rs/core/src/exec_tests.rs index 23db019bb9b..504bac622ef 100644 --- a/codex-rs/core/src/exec_tests.rs +++ b/codex-rs/core/src/exec_tests.rs @@ -1128,6 +1128,115 @@ async fn process_exec_tool_call_respects_cancellation_token() -> Result<()> { Ok(()) } +#[cfg(unix)] +#[tokio::test] +async fn process_exec_tool_call_cancellation_allows_sigterm_cleanup() -> Result<()> { + let temp_dir = tempfile::TempDir::new()?; + let ready_marker = temp_dir.path().join("ready"); + let cleanup_marker = temp_dir.path().join("cleanup"); + let descendant_pid_marker = temp_dir.path().join("descendant-pid"); + // The parent handles TERM and records cleanup, while a TERM-ignoring child + // proves cancellation still escalates any survivors in the process group. + let command = vec![ + "/bin/sh".to_string(), + "-c".to_string(), + r#"(trap '' TERM; sleep 60) & +printf '%s' "$!" > "$DESCENDANT_PID_MARKER" +trap 'printf cleaned > "$CLEANUP_MARKER"; exit 0' TERM +printf ready > "$READY_MARKER" +while :; do sleep 1; done"# + .to_string(), + ]; + let cwd = codex_utils_absolute_path::AbsolutePathBuf::current_dir()?; + let mut env: HashMap = std::env::vars().collect(); + env.insert( + "READY_MARKER".to_string(), + ready_marker.to_string_lossy().into_owned(), + ); + env.insert( + "CLEANUP_MARKER".to_string(), + cleanup_marker.to_string_lossy().into_owned(), + ); + env.insert( + "DESCENDANT_PID_MARKER".to_string(), + descendant_pid_marker.to_string_lossy().into_owned(), + ); + let cancel_token = CancellationToken::new(); + let cancel_tx = cancel_token.clone(); + tokio::spawn(async move { + for _ in 0..50 { + if ready_marker.exists() { + cancel_tx.cancel(); + return; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + cancel_tx.cancel(); + }); + let params = ExecParams { + command, + cwd: cwd.clone(), + expiration: ExecExpiration::DefaultTimeout.with_cancellation(cancel_token), + capture_policy: ExecCapturePolicy::ShellTool, + env, + network: None, + sandbox_permissions: SandboxPermissions::UseDefault, + windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, + windows_sandbox_private_desktop: false, + justification: None, + arg0: None, + }; + + let result = timeout( + Duration::from_secs(5), + process_exec_tool_call( + params, + &PermissionProfile::Disabled, + &cwd, + &None, + /*use_legacy_landlock*/ false, + /*stdout_stream*/ None, + ), + ) + .await + .expect("cancellation should stop the process promptly"); + let output = result.expect("cancellation should return a non-timeout exec result"); + assert!(!output.timed_out); + assert_eq!( + std::fs::read_to_string(cleanup_marker)?, + "cleaned", + "SIGTERM cleanup trap should run before cancellation falls back to a hard kill" + ); + let descendant_pid = std::fs::read_to_string(descendant_pid_marker)? + .parse::() + .map_err(|error| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("failed to parse descendant pid: {error}"), + ) + })?; + let mut killed = false; + for _ in 0..20 { + if unsafe { libc::kill(descendant_pid, 0) } == -1 + && let Some(libc::ESRCH) = std::io::Error::last_os_error().raw_os_error() + { + killed = true; + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + if !killed { + unsafe { + libc::kill(descendant_pid, libc::SIGKILL); + } + } + assert!( + killed, + "TERM-ignoring descendant process with pid {descendant_pid} is still alive" + ); + Ok(()) +} + #[cfg(unix)] fn long_running_command() -> Vec { vec![ diff --git a/codex-rs/core/src/goals.rs b/codex-rs/core/src/goals.rs index c8e76f7d41b..ece020385ab 100644 --- a/codex-rs/core/src/goals.rs +++ b/codex-rs/core/src/goals.rs @@ -26,7 +26,7 @@ use codex_otel::GOAL_TOKEN_COUNT_METRIC; use codex_otel::GOAL_USAGE_LIMITED_METRIC; use codex_protocol::ThreadId; use codex_protocol::config_types::ModeKind; -use codex_protocol::models::ResponseInputItem; +use codex_protocol::models::ResponseItem; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ThreadGoal; use codex_protocol::protocol::ThreadGoalStatus; @@ -177,7 +177,7 @@ pub(crate) struct GoalRuntimeState { struct GoalContinuationCandidate { goal_id: String, - items: Vec, + items: Vec, } impl GoalRuntimeState { @@ -686,7 +686,7 @@ impl Session { .await; if let Some(goal) = goal_for_steering { let item = goal_context_input_item(objective_updated_prompt(&goal)); - if self.inject_response_items(vec![item]).await.is_err() { + if self.inject_if_running(vec![item]).await.is_err() { tracing::debug!( "skipping objective-updated goal steering because no turn is active" ); @@ -1074,7 +1074,7 @@ impl Session { .await; if should_steer_budget_limit { let item = budget_limit_steering_item(&goal); - if self.inject_response_items(vec![item]).await.is_err() { + if self.inject_if_running(vec![item]).await.is_err() { tracing::debug!("skipping budget-limit goal steering because no turn is active"); } *self.goal_runtime.budget_limit_reported_goal_id.lock().await = Some(goal_id); @@ -1332,7 +1332,7 @@ impl Session { candidate .items .into_iter() - .map(TurnInput::ResponseInputItem) + .map(TurnInput::ResponseItem) .collect(), ) .await; @@ -1371,14 +1371,6 @@ impl Session { tracing::debug!("skipping active goal continuation because a turn is already active"); return None; } - if self - .input_queue - .has_queued_response_items_for_next_turn() - .await - { - tracing::debug!("skipping active goal continuation because queued input exists"); - return None; - } if self.input_queue.has_trigger_turn_mailbox_items().await { tracing::debug!( "skipping active goal continuation because trigger-turn mailbox input is pending" @@ -1416,10 +1408,6 @@ impl Session { return None; } if self.active_turn.lock().await.is_some() - || self - .input_queue - .has_queued_response_items_for_next_turn() - .await || self.input_queue.has_trigger_turn_mailbox_items().await { tracing::debug!("skipping active goal continuation because pending work appeared"); @@ -1604,12 +1592,12 @@ fn escape_xml_text(input: &str) -> String { .replace('>', ">") } -fn budget_limit_steering_item(goal: &ThreadGoal) -> ResponseInputItem { +fn budget_limit_steering_item(goal: &ThreadGoal) -> ResponseItem { goal_context_input_item(budget_limit_prompt(goal)) } -fn goal_context_input_item(prompt: String) -> ResponseInputItem { - GoalContext::new(prompt).into_response_input_item() +fn goal_context_input_item(prompt: String) -> ResponseItem { + ResponseItem::from(GoalContext::new(prompt).into_response_input_item()) } pub(crate) fn protocol_goal_from_state(goal: codex_state::ThreadGoal) -> ThreadGoal { @@ -1678,7 +1666,7 @@ mod tests { use codex_protocol::ThreadId; use codex_protocol::config_types::ModeKind; use codex_protocol::models::ContentItem; - use codex_protocol::models::ResponseInputItem; + use codex_protocol::models::ResponseItem; use codex_protocol::protocol::ThreadGoal; use codex_protocol::protocol::ThreadGoalStatus; use codex_protocol::protocol::TokenUsage; @@ -1807,7 +1795,8 @@ mod tests { assert_eq!( item, - ResponseInputItem::Message { + ResponseItem::Message { + id: None, role: "user".to_string(), content: vec![ContentItem::InputText { text: "\nContinue working.\n".to_string(), diff --git a/codex-rs/core/src/guardian/metrics.rs b/codex-rs/core/src/guardian/metrics.rs new file mode 100644 index 00000000000..9b9d35a5260 --- /dev/null +++ b/codex-rs/core/src/guardian/metrics.rs @@ -0,0 +1,418 @@ +use std::time::Duration; + +use codex_analytics::GuardianApprovalRequestSource; +use codex_analytics::GuardianReviewAnalyticsResult; +use codex_analytics::GuardianReviewDecision; +use codex_analytics::GuardianReviewFailureReason; +use codex_analytics::GuardianReviewSessionKind; +use codex_analytics::GuardianReviewTerminalStatus; +use codex_analytics::GuardianReviewedAction; +use codex_otel::GUARDIAN_REVIEW_COUNT_METRIC; +use codex_otel::GUARDIAN_REVIEW_DURATION_METRIC; +use codex_otel::GUARDIAN_REVIEW_TOKEN_USAGE_METRIC; +use codex_otel::GUARDIAN_REVIEW_TTFT_DURATION_METRIC; +use codex_otel::SessionTelemetry; +use codex_otel::sanitize_metric_tag_value; +use codex_protocol::protocol::GuardianAssessmentOutcome; +use codex_protocol::protocol::GuardianRiskLevel; +use codex_protocol::protocol::GuardianUserAuthorization; +use codex_protocol::protocol::TokenUsage; + +pub(crate) fn emit_guardian_review_metrics( + session_telemetry: &SessionTelemetry, + result: &GuardianReviewAnalyticsResult, + approval_request_source: GuardianApprovalRequestSource, + reviewed_action: &GuardianReviewedAction, + completion_latency_ms: u64, +) { + let tags = guardian_review_metric_tags(result, approval_request_source, reviewed_action); + let tag_refs: Vec<(&str, &str)> = tags + .iter() + .map(|(key, value)| (*key, value.as_str())) + .collect(); + + session_telemetry.counter(GUARDIAN_REVIEW_COUNT_METRIC, /*inc*/ 1, &tag_refs); + session_telemetry.record_duration( + GUARDIAN_REVIEW_DURATION_METRIC, + Duration::from_millis(completion_latency_ms), + &tag_refs, + ); + + if let Some(time_to_first_token_ms) = result.time_to_first_token_ms { + session_telemetry.record_duration( + GUARDIAN_REVIEW_TTFT_DURATION_METRIC, + Duration::from_millis(time_to_first_token_ms), + &tag_refs, + ); + } + + if let Some(token_usage) = result.token_usage.as_ref() { + emit_guardian_token_usage_histograms(session_telemetry, token_usage, tags); + } +} + +fn emit_guardian_token_usage_histograms( + session_telemetry: &SessionTelemetry, + token_usage: &TokenUsage, + base_tags: Vec<(&'static str, String)>, +) { + for (token_type, value) in [ + ("total", token_usage.total_tokens.max(0)), + ("input", token_usage.input_tokens.max(0)), + ("cached_input", token_usage.cached_input()), + ("non_cached_input", token_usage.non_cached_input()), + ("output", token_usage.output_tokens.max(0)), + ( + "reasoning_output", + token_usage.reasoning_output_tokens.max(0), + ), + ] { + let mut tags = base_tags.clone(); + tags.push(("token_type", token_type.to_string())); + let tag_refs: Vec<(&str, &str)> = tags + .iter() + .map(|(key, value)| (*key, value.as_str())) + .collect(); + session_telemetry.histogram(GUARDIAN_REVIEW_TOKEN_USAGE_METRIC, value, &tag_refs); + } +} + +fn guardian_review_metric_tags( + result: &GuardianReviewAnalyticsResult, + approval_request_source: GuardianApprovalRequestSource, + reviewed_action: &GuardianReviewedAction, +) -> Vec<(&'static str, String)> { + vec![ + ("decision", decision_tag(result.decision).to_string()), + ( + "terminal_status", + terminal_status_tag(result.terminal_status).to_string(), + ), + ( + "failure_reason", + failure_reason_tag(result.failure_reason).to_string(), + ), + ( + "approval_request_source", + approval_request_source_tag(approval_request_source).to_string(), + ), + ("action", reviewed_action_tag(reviewed_action).to_string()), + ( + "session_kind", + session_kind_tag(result.guardian_session_kind).to_string(), + ), + ( + "had_prior_review_context", + optional_bool_tag(result.had_prior_review_context).to_string(), + ), + ( + "reviewed_action_truncated", + bool_tag(result.reviewed_action_truncated).to_string(), + ), + ("risk_level", risk_level_tag(result.risk_level).to_string()), + ( + "user_authorization", + user_authorization_tag(result.user_authorization).to_string(), + ), + ("outcome", outcome_tag(result.outcome).to_string()), + ( + "guardian_model", + result + .guardian_model + .as_deref() + .map(sanitize_metric_tag_value) + .unwrap_or_else(|| "none".to_string()), + ), + ( + "guardian_reasoning_effort", + result + .guardian_reasoning_effort + .as_deref() + .map(sanitize_metric_tag_value) + .unwrap_or_else(|| "none".to_string()), + ), + ] +} + +fn decision_tag(decision: GuardianReviewDecision) -> &'static str { + match decision { + GuardianReviewDecision::Approved => "approved", + GuardianReviewDecision::Denied => "denied", + GuardianReviewDecision::Aborted => "aborted", + } +} + +fn terminal_status_tag(status: GuardianReviewTerminalStatus) -> &'static str { + match status { + GuardianReviewTerminalStatus::Approved => "approved", + GuardianReviewTerminalStatus::Denied => "denied", + GuardianReviewTerminalStatus::Aborted => "aborted", + GuardianReviewTerminalStatus::TimedOut => "timed_out", + GuardianReviewTerminalStatus::FailedClosed => "failed_closed", + } +} + +fn failure_reason_tag(reason: Option) -> &'static str { + match reason { + Some(GuardianReviewFailureReason::Timeout) => "timeout", + Some(GuardianReviewFailureReason::Cancelled) => "cancelled", + Some(GuardianReviewFailureReason::PromptBuildError) => "prompt_build_error", + Some(GuardianReviewFailureReason::SessionError) => "session_error", + Some(GuardianReviewFailureReason::ParseError) => "parse_error", + None => "none", + } +} + +fn approval_request_source_tag(source: GuardianApprovalRequestSource) -> &'static str { + match source { + GuardianApprovalRequestSource::MainTurn => "main_turn", + GuardianApprovalRequestSource::DelegatedSubagent => "delegated_subagent", + } +} + +fn reviewed_action_tag(action: &GuardianReviewedAction) -> &'static str { + match action { + GuardianReviewedAction::Shell { .. } => "shell", + GuardianReviewedAction::UnifiedExec { .. } => "unified_exec", + GuardianReviewedAction::Execve { .. } => "execve", + GuardianReviewedAction::ApplyPatch {} => "apply_patch", + GuardianReviewedAction::NetworkAccess { .. } => "network_access", + GuardianReviewedAction::McpToolCall { .. } => "mcp_tool_call", + GuardianReviewedAction::RequestPermissions {} => "request_permissions", + } +} + +fn session_kind_tag(kind: Option) -> &'static str { + match kind { + Some(GuardianReviewSessionKind::TrunkNew) => "trunk_new", + Some(GuardianReviewSessionKind::TrunkReused) => "trunk_reused", + Some(GuardianReviewSessionKind::EphemeralForked) => "ephemeral_forked", + None => "none", + } +} + +fn optional_bool_tag(value: Option) -> &'static str { + match value { + Some(true) => "true", + Some(false) => "false", + None => "unknown", + } +} + +fn bool_tag(value: bool) -> &'static str { + if value { "true" } else { "false" } +} + +fn risk_level_tag(risk_level: Option) -> &'static str { + match risk_level { + Some(GuardianRiskLevel::Low) => "low", + Some(GuardianRiskLevel::Medium) => "medium", + Some(GuardianRiskLevel::High) => "high", + Some(GuardianRiskLevel::Critical) => "critical", + None => "none", + } +} + +fn user_authorization_tag(user_authorization: Option) -> &'static str { + match user_authorization { + Some(GuardianUserAuthorization::Unknown) => "unknown", + Some(GuardianUserAuthorization::Low) => "low", + Some(GuardianUserAuthorization::Medium) => "medium", + Some(GuardianUserAuthorization::High) => "high", + None => "none", + } +} + +fn outcome_tag(outcome: Option) -> &'static str { + match outcome { + Some(GuardianAssessmentOutcome::Allow) => "allow", + Some(GuardianAssessmentOutcome::Deny) => "deny", + None => "none", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use codex_otel::MetricsClient; + use codex_otel::MetricsConfig; + use codex_protocol::ThreadId; + use codex_protocol::protocol::SessionSource; + use opentelemetry::KeyValue; + use opentelemetry_sdk::metrics::InMemoryMetricExporter; + use opentelemetry_sdk::metrics::data::AggregatedMetrics; + use opentelemetry_sdk::metrics::data::Metric; + use opentelemetry_sdk::metrics::data::MetricData; + use opentelemetry_sdk::metrics::data::ResourceMetrics; + use pretty_assertions::assert_eq; + use std::collections::BTreeMap; + + fn test_session_telemetry() -> SessionTelemetry { + let exporter = InMemoryMetricExporter::default(); + let metrics = MetricsClient::new( + MetricsConfig::in_memory("test", "codex-core", env!("CARGO_PKG_VERSION"), exporter) + .with_runtime_reader(), + ) + .expect("in-memory metrics client"); + SessionTelemetry::new( + ThreadId::new(), + "gpt-5.4", + "gpt-5.4", + /*account_id*/ None, + /*account_email*/ None, + /*auth_mode*/ None, + "test_originator".to_string(), + /*log_user_prompts*/ false, + "tty".to_string(), + SessionSource::Cli, + ) + .with_metrics_without_metadata_tags(metrics) + } + + fn find_metric<'a>(resource_metrics: &'a ResourceMetrics, name: &str) -> &'a Metric { + for scope_metrics in resource_metrics.scope_metrics() { + for metric in scope_metrics.metrics() { + if metric.name() == name { + return metric; + } + } + } + panic!("metric {name} missing"); + } + + fn attributes_to_map<'a>( + attributes: impl Iterator, + ) -> BTreeMap { + attributes + .map(|kv| (kv.key.as_str().to_string(), kv.value.as_str().to_string())) + .collect() + } + + fn counter_point( + resource_metrics: &ResourceMetrics, + name: &str, + ) -> (BTreeMap, u64) { + let metric = find_metric(resource_metrics, name); + match metric.data() { + AggregatedMetrics::U64(data) => match data { + MetricData::Sum(sum) => { + let points: Vec<_> = sum.data_points().collect(); + assert_eq!(points.len(), 1); + let point = points[0]; + (attributes_to_map(point.attributes()), point.value()) + } + _ => panic!("unexpected counter aggregation"), + }, + _ => panic!("unexpected counter data type"), + } + } + + fn histogram_sums(resource_metrics: &ResourceMetrics, name: &str) -> BTreeMap { + let metric = find_metric(resource_metrics, name); + match metric.data() { + AggregatedMetrics::F64(data) => match data { + MetricData::Histogram(histogram) => histogram + .data_points() + .map(|point| { + let attrs = attributes_to_map(point.attributes()); + ( + attrs + .get("token_type") + .cloned() + .unwrap_or_else(|| "sample".to_string()), + point.sum() as u64, + ) + }) + .collect(), + _ => panic!("unexpected histogram aggregation"), + }, + _ => panic!("unexpected histogram data type"), + } + } + + #[test] + fn guardian_review_metrics_record_counts_durations_and_token_usage() { + let session_telemetry = test_session_telemetry(); + let result = GuardianReviewAnalyticsResult { + decision: GuardianReviewDecision::Approved, + terminal_status: GuardianReviewTerminalStatus::Approved, + risk_level: Some(GuardianRiskLevel::Low), + user_authorization: Some(GuardianUserAuthorization::High), + outcome: Some(GuardianAssessmentOutcome::Allow), + guardian_session_kind: Some(GuardianReviewSessionKind::TrunkReused), + guardian_model: Some("gpt-5.4 guardian".to_string()), + guardian_reasoning_effort: Some("low".to_string()), + had_prior_review_context: Some(true), + reviewed_action_truncated: true, + token_usage: Some(TokenUsage { + input_tokens: 10, + cached_input_tokens: 4, + output_tokens: 3, + reasoning_output_tokens: 2, + total_tokens: 15, + }), + time_to_first_token_ms: Some(123), + ..GuardianReviewAnalyticsResult::without_session() + }; + + emit_guardian_review_metrics( + &session_telemetry, + &result, + GuardianApprovalRequestSource::DelegatedSubagent, + &GuardianReviewedAction::NetworkAccess { + protocol: codex_protocol::approvals::NetworkApprovalProtocol::Https, + port: 443, + }, + /*completion_latency_ms*/ 456, + ); + + let snapshot = session_telemetry + .snapshot_metrics() + .expect("runtime metrics snapshot"); + let (attrs, value) = counter_point(&snapshot, GUARDIAN_REVIEW_COUNT_METRIC); + + assert_eq!(value, 1); + assert_eq!( + attrs, + BTreeMap::from([ + ("action".to_string(), "network_access".to_string()), + ( + "approval_request_source".to_string(), + "delegated_subagent".to_string() + ), + ("decision".to_string(), "approved".to_string()), + ("failure_reason".to_string(), "none".to_string()), + ("guardian_model".to_string(), "gpt-5.4_guardian".to_string()), + ("guardian_reasoning_effort".to_string(), "low".to_string()), + ("had_prior_review_context".to_string(), "true".to_string()), + ("outcome".to_string(), "allow".to_string()), + ("reviewed_action_truncated".to_string(), "true".to_string()), + ("risk_level".to_string(), "low".to_string()), + ("session_kind".to_string(), "trunk_reused".to_string()), + ("terminal_status".to_string(), "approved".to_string()), + ("user_authorization".to_string(), "high".to_string()), + ]) + ); + + assert_eq!( + histogram_sums(&snapshot, GUARDIAN_REVIEW_TOKEN_USAGE_METRIC), + BTreeMap::from([ + ("cached_input".to_string(), 4), + ("input".to_string(), 10), + ("non_cached_input".to_string(), 6), + ("output".to_string(), 3), + ("reasoning_output".to_string(), 2), + ("total".to_string(), 15), + ]) + ); + assert_eq!( + histogram_sums(&snapshot, GUARDIAN_REVIEW_DURATION_METRIC), + BTreeMap::from([("sample".to_string(), 456)]) + ); + assert_eq!( + histogram_sums(&snapshot, GUARDIAN_REVIEW_TTFT_DURATION_METRIC), + BTreeMap::from([("sample".to_string(), 123)]) + ); + } +} diff --git a/codex-rs/core/src/guardian/mod.rs b/codex-rs/core/src/guardian/mod.rs index 058c19d008e..f5c6fe52319 100644 --- a/codex-rs/core/src/guardian/mod.rs +++ b/codex-rs/core/src/guardian/mod.rs @@ -12,6 +12,7 @@ //! 4. Apply the guardian's explicit allow/deny outcome. mod approval_request; +mod metrics; mod prompt; mod review; mod review_session; @@ -40,6 +41,7 @@ pub(crate) use review::review_approval_request_with_cancel; pub(crate) use review::routes_approval_to_guardian; pub(crate) use review::spawn_approval_request_review; pub(crate) use review_session::GuardianReviewSessionManager; +pub(crate) use review_session::prompt_cache_key_override_for_review_session; pub(crate) const GUARDIAN_REVIEW_TIMEOUT: Duration = Duration::from_secs(90); pub(crate) const GUARDIAN_REVIEWER_NAME: &str = "guardian"; diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index 7df7a96928a..db4343448fe 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -4,6 +4,7 @@ use codex_analytics::GuardianReviewDecision; use codex_analytics::GuardianReviewFailureReason; use codex_analytics::GuardianReviewTerminalStatus; use codex_analytics::GuardianReviewTrackContext; +use codex_analytics::GuardianReviewedAction; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; @@ -36,6 +37,7 @@ use super::approval_request::guardian_assessment_action; use super::approval_request::guardian_request_target_item_id; use super::approval_request::guardian_request_turn_id; use super::approval_request::guardian_reviewed_action; +use super::metrics::emit_guardian_review_metrics; use super::prompt::guardian_output_schema; use super::prompt::parse_guardian_assessment; use super::review_session::GuardianReviewSessionOutcome; @@ -162,9 +164,18 @@ pub(crate) fn is_guardian_reviewer_source( fn track_guardian_review( session: &Session, tracking: &GuardianReviewTrackContext, + approval_request_source: GuardianApprovalRequestSource, + reviewed_action: &GuardianReviewedAction, result: GuardianReviewAnalyticsResult, completed_at_ms: u64, ) { + emit_guardian_review_metrics( + &session.services.session_telemetry, + &result, + approval_request_source, + reviewed_action, + completed_at_ms.saturating_sub(tracking.started_at_ms), + ); session .services .analytics_events_client @@ -244,13 +255,14 @@ async fn run_guardian_review( let target_item_id = guardian_request_target_item_id(&request).map(str::to_string); let assessment_turn_id = guardian_request_turn_id(&request, &turn.sub_id).to_string(); let action_summary = guardian_assessment_action(&request); + let reviewed_action = guardian_reviewed_action(&request); let review_tracking = GuardianReviewTrackContext::new( session.conversation_id.to_string(), assessment_turn_id.clone(), review_id.clone(), target_item_id.clone(), approval_request_source, - guardian_reviewed_action(&request), + reviewed_action.clone(), GUARDIAN_REVIEW_TIMEOUT.as_millis() as u64, ); let started_at_ms = review_tracking.started_at_ms.try_into().unwrap_or_default(); @@ -281,6 +293,8 @@ async fn run_guardian_review( track_guardian_review( session.as_ref(), &review_tracking, + approval_request_source, + &reviewed_action, GuardianReviewAnalyticsResult { decision: GuardianReviewDecision::Aborted, terminal_status: GuardianReviewTerminalStatus::Aborted, @@ -330,6 +344,8 @@ async fn run_guardian_review( track_guardian_review( session.as_ref(), &review_tracking, + approval_request_source, + &reviewed_action, GuardianReviewAnalyticsResult { decision: if approved { GuardianReviewDecision::Approved @@ -361,6 +377,8 @@ async fn run_guardian_review( track_guardian_review( session.as_ref(), &review_tracking, + approval_request_source, + &reviewed_action, GuardianReviewAnalyticsResult { decision: GuardianReviewDecision::Denied, terminal_status: GuardianReviewTerminalStatus::TimedOut, @@ -402,6 +420,8 @@ async fn run_guardian_review( track_guardian_review( session.as_ref(), &review_tracking, + approval_request_source, + &reviewed_action, GuardianReviewAnalyticsResult { decision: GuardianReviewDecision::Aborted, terminal_status: GuardianReviewTerminalStatus::Aborted, @@ -446,6 +466,8 @@ async fn run_guardian_review( track_guardian_review( session.as_ref(), &review_tracking, + approval_request_source, + &reviewed_action, GuardianReviewAnalyticsResult { decision: GuardianReviewDecision::Denied, terminal_status: GuardianReviewTerminalStatus::FailedClosed, diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 67f733e0bae..f52792cb608 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -7,6 +7,7 @@ use std::time::Duration; use anyhow::anyhow; use codex_analytics::GuardianReviewAnalyticsResult; use codex_analytics::GuardianReviewSessionKind; +use codex_protocol::ThreadId; use codex_protocol::config_types::AutoCompactTokenLimitScope; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; @@ -20,6 +21,7 @@ use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::Op; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::TokenUsage; use serde_json::Value; @@ -183,6 +185,20 @@ impl GuardianReviewSessionReuseKey { } } +pub(crate) fn prompt_cache_key_override_for_review_session( + session_source: &SessionSource, + parent_thread_id: Option, +) -> Option { + let SessionSource::SubAgent(SubAgentSource::Other(name)) = session_source else { + return None; + }; + if name != GUARDIAN_REVIEWER_NAME { + return None; + } + let parent_thread_id = parent_thread_id?; + Some(format!("guardian:{parent_thread_id}")) +} + impl GuardianReviewSession { async fn shutdown(&self) { self.cancel_token.cancel(); @@ -774,12 +790,11 @@ async fn run_review_on_session( } async fn append_guardian_followup_reminder(review_session: &GuardianReviewSession) { - let turn_context = review_session.codex.session.new_default_turn().await; let reminder: ResponseItem = ContextualUserFragment::into(GuardianFollowupReviewReminder); review_session .codex .session - .record_conversation_items(turn_context.as_ref(), std::slice::from_ref(&reminder)) + .inject_no_new_turn(vec![reminder], /*current_turn_context*/ None) .await; } @@ -1160,6 +1175,46 @@ mod tests { ); } + #[tokio::test] + async fn guardian_prompt_cache_key_is_scoped_to_parent_thread() { + let session_source = + SessionSource::SubAgent(SubAgentSource::Other(GUARDIAN_REVIEWER_NAME.to_string())); + let parent_thread_id = ThreadId::new(); + let key = + prompt_cache_key_override_for_review_session(&session_source, Some(parent_thread_id)) + .expect("guardian prompt cache key"); + + assert_eq!(key, format!("guardian:{parent_thread_id}")); + assert!( + key.len() <= 64, + "guardian prompt cache key should fit the Responses API limit" + ); + assert_eq!( + key, + prompt_cache_key_override_for_review_session(&session_source, Some(parent_thread_id)) + .expect("same guardian prompt cache key") + ); + assert_ne!( + key, + prompt_cache_key_override_for_review_session(&session_source, Some(ThreadId::new())) + .expect("different parent guardian prompt cache key") + ); + assert_eq!( + None, + prompt_cache_key_override_for_review_session( + &SessionSource::Cli, + Some(parent_thread_id) + ) + ); + assert_eq!( + None, + prompt_cache_key_override_for_review_session( + &session_source, + /*parent_thread_id*/ None + ) + ); + } + #[tokio::test] async fn guardian_review_session_compact_scope_change_invalidates_cached_session() { let parent_config = crate::config::test_config().await; diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 00f0d9be385..6df3b69f61a 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -191,7 +191,8 @@ async fn guardian_test_session_and_turn_with_base_url( async fn seed_guardian_parent_history(session: &Arc, turn: &Arc) { session - .record_into_history( + .record_conversation_items( + turn.as_ref(), &[ ResponseItem::Message { id: None, @@ -225,7 +226,6 @@ async fn seed_guardian_parent_history(session: &Arc, turn: &Arc anyh let (session, turn) = guardian_test_session_and_turn_with_base_url("http://localhost").await; seed_guardian_parent_history(&session, &turn).await; session - .record_into_history( + .record_conversation_items( + turn.as_ref(), &[ ResponseItem::Message { id: None, @@ -381,7 +382,6 @@ async fn build_guardian_prompt_delta_mode_preserves_original_numbering() -> anyh phase: None, }, ], - turn.as_ref(), ) .await; @@ -516,7 +516,8 @@ async fn build_guardian_prompt_stale_delta_version_falls_back_to_full_prompt() - ) .await; session - .record_into_history( + .record_conversation_items( + turn.as_ref(), &[ ResponseItem::Message { id: None, @@ -535,7 +536,6 @@ async fn build_guardian_prompt_stale_delta_version_falls_back_to_full_prompt() - phase: None, }, ], - turn.as_ref(), ) .await; @@ -1469,7 +1469,8 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow: ) .await; session - .record_into_history( + .record_conversation_items( + turn.as_ref(), &[ ResponseItem::Message { id: None, @@ -1488,7 +1489,6 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow: phase: None, }, ], - turn.as_ref(), ) .await; let second_request = GuardianApprovalRequest::Shell { @@ -1513,7 +1513,8 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow: ) .await; session - .record_into_history( + .record_conversation_items( + turn.as_ref(), &[ ResponseItem::Message { id: None, @@ -1532,7 +1533,6 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow: phase: None, }, ], - turn.as_ref(), ) .await; let third_request = GuardianApprovalRequest::Shell { @@ -2005,7 +2005,8 @@ async fn guardian_parallel_reviews_fork_from_last_committed_trunk_history() -> a ReviewDecision::Approved ); session - .record_into_history( + .record_conversation_items( + turn.as_ref(), &[ ResponseItem::Message { id: None, @@ -2024,7 +2025,6 @@ async fn guardian_parallel_reviews_fork_from_last_committed_trunk_history() -> a phase: None, }, ], - turn.as_ref(), ) .await; @@ -2072,7 +2072,8 @@ async fn guardian_parallel_reviews_fork_from_last_committed_trunk_history() -> a "second guardian request was not observed" ); session - .record_into_history( + .record_conversation_items( + turn.as_ref(), &[ ResponseItem::Message { id: None, @@ -2091,7 +2092,6 @@ async fn guardian_parallel_reviews_fork_from_last_committed_trunk_history() -> a phase: None, }, ], - turn.as_ref(), ) .await; @@ -2106,7 +2106,13 @@ async fn guardian_parallel_reviews_fork_from_last_committed_trunk_history() -> a assert_eq!(third_decision, ReviewDecision::Approved); let requests = server.requests().await; assert_eq!(requests.len(), 3); + let second_request_body = serde_json::from_slice::(&requests[1])?; let third_request_body = serde_json::from_slice::(&requests[2])?; + assert_eq!( + second_request_body["prompt_cache_key"], + third_request_body["prompt_cache_key"], + "forked guardian review should reuse the trunk guardian prompt cache key" + ); let third_request_body_text = third_request_body.to_string(); assert!( third_request_body_text.contains("first guardian rationale"), diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index 6452e9d5ad6..56d51412cfe 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -522,7 +522,7 @@ pub(crate) async fn inspect_pending_input( ) .await } - TurnInput::ResponseInputItem(_) => HookRuntimeOutcome { + TurnInput::ResponseItem(_) => HookRuntimeOutcome { should_stop: false, additional_contexts: Vec::new(), }, @@ -540,9 +540,8 @@ pub(crate) async fn record_pending_input( sess.record_user_prompt_and_emit_turn_item(turn_context.as_ref(), content.as_slice()) .await; } - TurnInput::ResponseInputItem(input) => { - let response_item = ResponseItem::from(input); - sess.record_conversation_items(turn_context, std::slice::from_ref(&response_item)) + TurnInput::ResponseItem(item) => { + sess.record_conversation_items(turn_context, std::slice::from_ref(&item)) .await; } } diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index de5276b26e6..534c2f01182 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -56,13 +56,13 @@ fn annotations( destructive: Option, open_world: Option, ) -> ToolAnnotations { - ToolAnnotations { - destructive_hint: destructive, - idempotent_hint: None, - open_world_hint: open_world, - read_only_hint: read_only, - title: None, - } + ToolAnnotations::from_raw( + /*title*/ None, + read_only, + destructive, + /*idempotent_hint*/ None, + open_world, + ) } fn approval_metadata( diff --git a/codex-rs/core/src/mcp_tool_exposure.rs b/codex-rs/core/src/mcp_tool_exposure.rs index e58be65f9cd..5dc66af7340 100644 --- a/codex-rs/core/src/mcp_tool_exposure.rs +++ b/codex-rs/core/src/mcp_tool_exposure.rs @@ -3,6 +3,7 @@ use std::collections::HashSet; use codex_features::Feature; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; use codex_mcp::ToolInfo as McpToolInfo; +use codex_mcp::tool_is_model_visible; use crate::config::Config; use crate::connectors; @@ -51,7 +52,9 @@ pub(crate) fn build_mcp_tool_exposure( fn filter_non_codex_apps_mcp_tools_only(mcp_tools: &[McpToolInfo]) -> Vec { mcp_tools .iter() - .filter(|tool| tool.server_name != CODEX_APPS_MCP_SERVER_NAME) + .filter(|tool| { + tool.server_name != CODEX_APPS_MCP_SERVER_NAME && tool_is_model_visible(tool) + }) .cloned() .collect() } @@ -72,6 +75,9 @@ fn filter_codex_apps_mcp_tools( if tool.server_name != CODEX_APPS_MCP_SERVER_NAME { return false; } + if !tool_is_model_visible(tool) { + return false; + } let Some(connector_id) = tool.connector_id.as_deref() else { return false; }; diff --git a/codex-rs/core/src/mcp_tool_exposure_test.rs b/codex-rs/core/src/mcp_tool_exposure_test.rs index 367baaf4a09..4918f768aa6 100644 --- a/codex-rs/core/src/mcp_tool_exposure_test.rs +++ b/codex-rs/core/src/mcp_tool_exposure_test.rs @@ -7,6 +7,7 @@ use codex_mcp::ToolInfo; use codex_tools::ToolName; use pretty_assertions::assert_eq; use rmcp::model::JsonObject; +use rmcp::model::Meta; use rmcp::model::Tool; use super::*; @@ -46,17 +47,11 @@ fn make_mcp_tool( callable_name: callable_name.to_string(), callable_namespace: callable_namespace.to_string(), namespace_description: None, - tool: Tool { - name: tool_name.to_string().into(), - title: None, - description: Some(format!("Test tool: {tool_name}").into()), - input_schema: Arc::new(JsonObject::default()), - output_schema: None, - annotations: None, - execution: None, - icons: None, - meta: None, - }, + tool: Tool::new( + tool_name.to_string(), + format!("Test tool: {tool_name}"), + Arc::new(JsonObject::default()), + ), connector_id: connector_id.map(str::to_string), connector_name: connector_name.map(str::to_string), plugin_display_names: Vec::new(), @@ -86,6 +81,16 @@ fn tool_names(tools: &[ToolInfo]) -> HashSet { .collect() } +fn with_visibility(mut tool: ToolInfo, visibility: &[&str]) -> ToolInfo { + tool.tool.meta = Some(Meta( + serde_json::json!({ "ui": { "visibility": visibility } }) + .as_object() + .expect("metadata object") + .clone(), + )); + tool +} + #[tokio::test] async fn directly_exposes_small_effective_tool_sets() { let config = test_config().await; @@ -99,6 +104,84 @@ async fn directly_exposes_small_effective_tool_sets() { assert!(exposure.deferred_tools.is_none()); } +#[tokio::test] +async fn excludes_tools_hidden_from_model_exposure() { + let config = test_config().await; + let visible_tool = make_mcp_tool( + "rmcp", + "visible_tool", + "mcp__rmcp", + "visible_tool", + /*connector_id*/ None, + /*connector_name*/ None, + ); + let hidden_tool = with_visibility( + make_mcp_tool( + "rmcp", + "hidden_tool", + "mcp__rmcp", + "hidden_tool", + /*connector_id*/ None, + /*connector_name*/ None, + ), + &["app"], + ); + let empty_visibility_tool = with_visibility( + make_mcp_tool( + "rmcp", + "empty_visibility_tool", + "mcp__rmcp", + "empty_visibility_tool", + /*connector_id*/ None, + /*connector_name*/ None, + ), + &[], + ); + let visible_app_tool = with_visibility( + make_mcp_tool( + CODEX_APPS_MCP_SERVER_NAME, + "calendar_read", + "mcp__codex_apps__calendar", + "read", + Some("calendar"), + Some("Calendar"), + ), + &["app", "model"], + ); + let hidden_app_tool = with_visibility( + make_mcp_tool( + CODEX_APPS_MCP_SERVER_NAME, + "calendar_open", + "mcp__codex_apps__calendar", + "open", + Some("calendar"), + Some("Calendar"), + ), + &["app"], + ); + let mcp_tools = vec![ + visible_tool.clone(), + hidden_tool, + empty_visibility_tool, + visible_app_tool.clone(), + hidden_app_tool, + ]; + let connectors = vec![make_connector("calendar", "Calendar")]; + + let exposure = build_mcp_tool_exposure( + &mcp_tools, + Some(connectors.as_slice()), + &config, + /*search_tool_enabled*/ false, + ); + + assert_eq!( + tool_names(&exposure.direct_tools), + tool_names(&[visible_tool, visible_app_tool]) + ); + assert!(exposure.deferred_tools.is_none()); +} + #[tokio::test] async fn searches_large_effective_tool_sets() { let config = test_config().await; diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index cf46ecba9ac..0a72d272340 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -27,6 +27,7 @@ use crate::tasks::UserShellCommandTask; use crate::tasks::execute_user_shell_command; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseInputItem; +use codex_protocol::models::ResponseItem; use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::ErrorEvent; use codex_protocol::protocol::Event; @@ -255,7 +256,8 @@ pub(super) async fn user_input_or_turn_inner( }; let mut task_input = additional_context_input .into_iter() - .map(TurnInput::ResponseInputItem) + .map(ResponseItem::from) + .map(TurnInput::ResponseItem) .collect::>(); if !items.is_empty() { task_input.push(TurnInput::UserInput(items)); @@ -899,17 +901,14 @@ Do not assume this also authorizes similar operations with different payloads. Approved action: {approved_action_json}"#, ); - let items = vec![ResponseInputItem::Message { + let items = vec![ResponseItem::from(ResponseInputItem::Message { role: "developer".to_string(), content: vec![ContentItem::InputText { text }], phase: None, - }]; + })]; - if let Err(items) = sess.inject_response_items(items).await { - sess.input_queue - .queue_response_items_for_next_turn(items) - .await; - } + sess.inject_no_new_turn(items, /*current_turn_context*/ None) + .await; } pub(super) fn submission_dispatch_span(sub: &Submission) -> tracing::Span { diff --git a/codex-rs/core/src/session/inject.rs b/codex-rs/core/src/session/inject.rs new file mode 100644 index 00000000000..4a7189bbd65 --- /dev/null +++ b/codex-rs/core/src/session/inject.rs @@ -0,0 +1,50 @@ +use super::input_queue::TurnInput; +use super::session::Session; +use super::turn_context::TurnContext; +use codex_protocol::models::ResponseItem; + +impl Session { + /// Returns the input if there is no active turn to inject into. + #[expect( + clippy::await_holding_invalid_type, + reason = "active turn checks and turn state updates must remain atomic" + )] + pub async fn inject_if_running( + &self, + input: Vec, + ) -> Result<(), Vec> { + let mut active = self.active_turn.lock().await; + match active.as_mut() { + Some(active_turn) => { + self.input_queue + .extend_pending_input_for_turn_state( + active_turn.turn_state.as_ref(), + input.into_iter().map(TurnInput::ResponseItem).collect(), + ) + .await; + Ok(()) + } + None => Err(input), + } + } + + /// Injects items into active work, or records them without starting a turn. + pub(crate) async fn inject_no_new_turn( + &self, + items: Vec, + current_turn_context: Option<&TurnContext>, + ) { + let Err(items) = self.inject_if_running(items).await else { + return; + }; + let default_turn_context; + let turn_context = match current_turn_context { + Some(turn_context) => turn_context, + None => { + default_turn_context = self.new_default_turn().await; + default_turn_context.as_ref() + } + }; + self.record_conversation_items(turn_context, &items).await; + } +} diff --git a/codex-rs/core/src/session/input_queue.rs b/codex-rs/core/src/session/input_queue.rs index 620c410eb81..e317ba57a3b 100644 --- a/codex-rs/core/src/session/input_queue.rs +++ b/codex-rs/core/src/session/input_queue.rs @@ -1,7 +1,7 @@ use crate::state::ActiveTurn; use crate::state::MailboxDeliveryPhase; use crate::state::TurnState; -use codex_protocol::models::ResponseInputItem; +use codex_protocol::models::ResponseItem; use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::user_input::UserInput; use std::collections::VecDeque; @@ -12,7 +12,7 @@ use tokio::sync::watch; #[derive(Clone, Debug, PartialEq)] pub(crate) enum TurnInput { UserInput(Vec), - ResponseInputItem(ResponseInputItem), + ResponseItem(ResponseItem), } /// Turn-local pending input storage owned by the input queue flow. @@ -25,8 +25,6 @@ pub(crate) struct TurnInputQueue { pub(crate) struct InputQueue { mailbox_tx: watch::Sender<()>, mailbox_pending_mails: Mutex>, - - idle_pending_input: Mutex>, } impl InputQueue { @@ -35,7 +33,6 @@ impl InputQueue { Self { mailbox_tx, mailbox_pending_mails: Mutex::new(VecDeque::new()), - idle_pending_input: Mutex::new(Vec::new()), } } @@ -70,31 +67,15 @@ impl InputQueue { .any(|mail| mail.trigger_turn) } - pub(crate) async fn drain_mailbox_input_items(&self) -> Vec { + pub(crate) async fn drain_mailbox_input_items(&self) -> Vec { self.mailbox_pending_mails .lock() .await .drain(..) - .map(|mail| mail.to_response_input_item()) + .map(|mail| ResponseItem::from(mail.to_response_input_item())) .collect() } - pub(crate) async fn queue_response_items_for_next_turn(&self, items: Vec) { - if items.is_empty() { - return; - } - - self.idle_pending_input.lock().await.extend(items); - } - - pub(crate) async fn take_queued_response_items_for_next_turn(&self) -> Vec { - std::mem::take(&mut *self.idle_pending_input.lock().await) - } - - pub(crate) async fn has_queued_response_items_for_next_turn(&self) -> bool { - !self.idle_pending_input.lock().await.is_empty() - } - pub(crate) async fn turn_state_for_sub_id( &self, active_turn: &Mutex>, @@ -181,32 +162,6 @@ impl InputQueue { turn_state.lock().await.pending_input.items.split_off(0) } - #[expect( - clippy::await_holding_invalid_type, - reason = "active turn checks and turn state updates must remain atomic" - )] - pub(crate) async fn inject_response_items( - &self, - active_turn: &Mutex>, - input: Vec, - ) -> Result<(), Vec> { - let mut active = active_turn.lock().await; - match active.as_mut() { - Some(active_turn) => { - self.extend_pending_input_for_turn_state( - active_turn.turn_state.as_ref(), - input - .into_iter() - .map(TurnInput::ResponseInputItem) - .collect(), - ) - .await; - Ok(()) - } - None => Err(input), - } - } - #[expect( clippy::await_holding_invalid_type, reason = "active turn checks and turn state updates must remain atomic" @@ -235,7 +190,7 @@ impl InputQueue { .drain_mailbox_input_items() .await .into_iter() - .map(TurnInput::ResponseInputItem); + .map(TurnInput::ResponseItem); if pending_input.is_empty() { mailbox_items.collect() } else { @@ -345,8 +300,8 @@ mod tests { assert_eq!( input_queue.drain_mailbox_input_items().await, vec![ - mail_one.to_response_input_item(), - mail_two.to_response_input_item() + ResponseItem::from(mail_one.to_response_input_item()), + ResponseItem::from(mail_two.to_response_input_item()) ] ); assert!(!input_queue.has_pending_mailbox_items().await); diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 245047c925a..63316356646 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -196,6 +196,7 @@ use codex_protocol::exec_output::StreamOutput; mod config_lock; mod handlers; +mod inject; mod input_queue; mod mcp; mod multi_agents; @@ -324,7 +325,6 @@ use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; use codex_protocol::config_types::WindowsSandboxLevel; -use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; @@ -549,37 +549,9 @@ impl Codex { .or_else(|| conversation_history.get_base_instructions().map(|s| s.text)) .unwrap_or_else(|| model_info.get_model_instructions(config.personality)); - // Respect thread-start tools. When missing (resumed/forked threads), read from the db - // first, then fall back to rollout-file tools. - let persisted_tools = if dynamic_tools.is_empty() { - let thread_id = match &conversation_history { - InitialHistory::Resumed(resumed) => Some(resumed.conversation_id), - InitialHistory::Forked(_) => conversation_history.forked_from_id(), - InitialHistory::New | InitialHistory::Cleared => None, - }; - match thread_id { - Some(thread_id) => { - let state_db_ctx = if config.ephemeral { - None - } else if let Some(local_store) = - thread_store.as_any().downcast_ref::() - { - local_store.state_db().await - } else { - None - }; - state_db::get_dynamic_tools(state_db_ctx.as_deref(), thread_id, "codex_spawn") - .await - } - None => None, - } - } else { - None - }; + // Dynamic tools are defined at thread start and persisted in rollout session metadata. let dynamic_tools = if dynamic_tools.is_empty() { - persisted_tools - .or_else(|| conversation_history.get_dynamic_tools()) - .unwrap_or_default() + conversation_history.get_dynamic_tools().unwrap_or_default() } else { dynamic_tools }; @@ -1870,27 +1842,11 @@ impl Session { warn!("execpolicy amendment for {sub_id} had no command prefix"); return; }; - let fragment = ApprovedCommandPrefixSaved::new(prefixes); - let text = fragment.render(); - let message: ResponseItem = ContextualUserFragment::into(fragment); - - if let Some(turn_context) = self.turn_context_for_sub_id(sub_id).await { - self.record_conversation_items(&turn_context, std::slice::from_ref(&message)) - .await; - return; - } - - if self - .inject_response_items(vec![ResponseInputItem::Message { - role: "developer".to_string(), - content: vec![ContentItem::InputText { text }], - phase: None, - }]) - .await - .is_err() - { - warn!("no active turn found to record execpolicy amendment message for {sub_id}"); - } + let message: ResponseItem = + ContextualUserFragment::into(ApprovedCommandPrefixSaved::new(prefixes)); + let turn_context = self.turn_context_for_sub_id(sub_id).await; + self.inject_no_new_turn(vec![message], turn_context.as_deref()) + .await; } pub(crate) async fn persist_network_policy_amendment( @@ -1967,27 +1923,10 @@ impl Session { sub_id: &str, amendment: &NetworkPolicyAmendment, ) { - let fragment = NetworkRuleSaved::new(amendment); - let text = fragment.render(); - let message: ResponseItem = ContextualUserFragment::into(fragment); - - if let Some(turn_context) = self.turn_context_for_sub_id(sub_id).await { - self.record_conversation_items(&turn_context, std::slice::from_ref(&message)) - .await; - return; - } - - if self - .inject_response_items(vec![ResponseInputItem::Message { - role: "developer".to_string(), - content: vec![ContentItem::InputText { text }], - phase: None, - }]) - .await - .is_err() - { - warn!("no active turn found to record network policy amendment message for {sub_id}"); - } + let message: ResponseItem = ContextualUserFragment::into(NetworkRuleSaved::new(amendment)); + let turn_context = self.turn_context_for_sub_id(sub_id).await; + self.inject_no_new_turn(vec![message], turn_context.as_deref()) + .await; } /// Emit an exec approval request event and await the user's decision. @@ -2538,28 +2477,21 @@ impl Session { } } - /// Records input items: always append to conversation history and - /// persist these response items to rollout. + /// Records conversation items: append to history, persist to rollout, and + /// notify clients observing raw response items. pub(crate) async fn record_conversation_items( &self, turn_context: &TurnContext, items: &[ResponseItem], ) { - self.record_into_history(items, turn_context).await; + { + let mut state = self.state.lock().await; + state.record_items(items.iter(), turn_context.truncation_policy); + } self.persist_rollout_response_items(items).await; self.send_raw_response_items(turn_context, items).await; } - /// Append ResponseItems to the in-memory conversation history only. - pub(crate) async fn record_into_history( - &self, - items: &[ResponseItem], - turn_context: &TurnContext, - ) { - let mut state = self.state.lock().await; - state.record_items(items.iter(), turn_context.truncation_policy); - } - async fn maybe_warn_on_server_model_mismatch( self: &Arc, turn_context: &Arc, @@ -3220,7 +3152,8 @@ impl Session { let mut pending_input = additional_context_input .into_iter() - .map(TurnInput::ResponseInputItem) + .map(ResponseItem::from) + .map(TurnInput::ResponseItem) .collect::>(); pending_input.push(TurnInput::UserInput(input)); self.input_queue @@ -3232,16 +3165,6 @@ impl Session { Ok(active_turn_id.clone()) } - /// Returns the input if there was no task running to inject into. - pub async fn inject_response_items( - &self, - input: Vec, - ) -> Result<(), Vec> { - self.input_queue - .inject_response_items(&self.active_turn, input) - .await - } - pub(crate) async fn record_memory_citation_for_turn(&self, sub_id: &str) { let turn_state = self .input_queue diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 0823e93666b..c9e1807fe6f 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -966,6 +966,8 @@ impl Session { for contributor in extensions.thread_lifecycle_contributors() { contributor.on_thread_start(codex_extension_api::ThreadStartInput { config: config.as_ref(), + session_source: &session_configuration.session_source, + persistent_thread_state_available: state_db_ctx.is_some(), session_store: &session_extension_data, thread_store: &thread_extension_data, }).await; @@ -1034,6 +1036,12 @@ impl Session { config.features.enabled(Feature::RuntimeMetrics), Self::build_model_client_beta_features_header(config.as_ref()), attestation_provider, + ) + .with_prompt_cache_key_override( + crate::guardian::prompt_cache_key_override_for_review_session( + &session_configuration.session_source, + session_configuration.forked_from_thread_id, + ), ), code_mode_service: crate::tools::code_mode::CodeModeService::new(), environment_manager, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 5cb8f438ab5..a596ff3e790 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -103,9 +103,9 @@ use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Settings; use codex_protocol::models::BaseInstructions; use codex_protocol::models::ContentItem; -use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::CompactedItem; use codex_protocol::protocol::ConversationAudioParams; use codex_protocol::protocol::CreditsSnapshot; @@ -398,6 +398,12 @@ async fn interrupting_regular_turn_waiting_on_startup_prewarm_emits_turn_aborted sess.abort_all_tasks(TurnAbortReason::Interrupted).await; + let marker_evt = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) + .await + .expect("expected turn aborted marker event") + .expect("channel open"); + assert!(matches!(marker_evt.msg, EventMsg::RawResponseItem(_))); + let second = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) .await .expect("expected turn aborted event") @@ -1772,7 +1778,7 @@ async fn recompute_token_usage_uses_session_base_instructions() { let item = user_message("hello"); session - .record_into_history(std::slice::from_ref(&item), &turn_context) + .record_conversation_items(&turn_context, std::slice::from_ref(&item)) .await; let history = session.clone_history().await; @@ -2037,6 +2043,85 @@ async fn turn_start_lifecycle_exposes_turn_metadata_and_token_baseline() { assert_eq!(vec![expected], actual); } +#[tokio::test] +async fn turn_error_lifecycle_exposes_error_and_stores() { + struct SessionTurnErrorMarker; + struct ThreadTurnErrorMarker; + + #[derive(Debug, PartialEq, Eq)] + struct RecordedTurnError { + session_level_id: String, + thread_level_id: String, + turn_level_id: String, + turn_id: String, + error: CodexErrorInfo, + saw_session_store: bool, + saw_thread_store: bool, + } + + struct TurnErrorRecorder { + records: Arc>>, + } + + #[async_trait::async_trait] + impl codex_extension_api::TurnLifecycleContributor for TurnErrorRecorder { + async fn on_turn_error(&self, input: codex_extension_api::TurnErrorInput<'_>) { + self.records + .lock() + .expect("turn error records lock") + .push(RecordedTurnError { + session_level_id: input.session_store.level_id().to_string(), + thread_level_id: input.thread_store.level_id().to_string(), + turn_level_id: input.turn_store.level_id().to_string(), + turn_id: input.turn_id.to_string(), + error: input.error, + saw_session_store: input + .session_store + .get::() + .is_some(), + saw_thread_store: input.thread_store.get::().is_some(), + }); + } + } + + let (mut session, turn_context) = make_session_and_context().await; + let records = Arc::new(std::sync::Mutex::new(Vec::new())); + let mut builder = codex_extension_api::ExtensionRegistryBuilder::::new(); + builder.turn_lifecycle_contributor(Arc::new(TurnErrorRecorder { + records: Arc::clone(&records), + })); + session.services.extensions = Arc::new(builder.build()); + session + .services + .session_extension_data + .insert(SessionTurnErrorMarker); + session + .services + .thread_extension_data + .insert(ThreadTurnErrorMarker); + + let expected = RecordedTurnError { + session_level_id: session.session_id().to_string(), + thread_level_id: session.conversation_id.to_string(), + turn_level_id: turn_context.sub_id.clone(), + turn_id: turn_context.sub_id.clone(), + error: CodexErrorInfo::UsageLimitExceeded, + saw_session_store: true, + saw_thread_store: true, + }; + + session + .emit_turn_error_lifecycle(&turn_context, CodexErrorInfo::UsageLimitExceeded) + .await; + + let actual = records + .lock() + .expect("turn error records lock") + .drain(..) + .collect::>(); + assert_eq!(vec![expected], actual); +} + #[tokio::test] async fn config_change_contributor_observes_effective_config_changes() { struct SessionConfigMarker; @@ -2547,7 +2632,7 @@ async fn thread_rollback_fails_without_persisted_thread_history() { let (sess, tc, rx) = make_session_and_context_with_rx().await; let initial_context = sess.build_initial_context(tc.as_ref()).await; - sess.record_into_history(&initial_context, tc.as_ref()) + sess.record_conversation_items(tc.as_ref(), &initial_context) .await; handlers::thread_rollback(&sess, "sub-1".to_string(), /*num_turns*/ 1).await; @@ -2920,7 +3005,7 @@ async fn thread_rollback_fails_when_turn_in_progress() { let (sess, tc, rx) = make_session_and_context_with_rx().await; let initial_context = sess.build_initial_context(tc.as_ref()).await; - sess.record_into_history(&initial_context, tc.as_ref()) + sess.record_conversation_items(tc.as_ref(), &initial_context) .await; *sess.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); @@ -2941,7 +3026,7 @@ async fn thread_rollback_fails_when_num_turns_is_zero() { let (sess, tc, rx) = make_session_and_context_with_rx().await; let initial_context = sess.build_initial_context(tc.as_ref()).await; - sess.record_into_history(&initial_context, tc.as_ref()) + sess.record_conversation_items(tc.as_ref(), &initial_context) .await; handlers::thread_rollback(&sess, "sub-1".to_string(), /*num_turns*/ 0).await; @@ -7531,7 +7616,7 @@ async fn record_context_updates_and_set_reference_context_item_reinjects_full_co phase: None, }; session - .record_into_history(std::slice::from_ref(&compacted_summary), &turn_context) + .record_conversation_items(&turn_context, std::slice::from_ref(&compacted_summary)) .await; session .record_context_updates_and_set_reference_context_item(&turn_context) @@ -7809,6 +7894,29 @@ async fn realtime_conversation_list_voices_emits_builtin_list() { ); } +#[derive(Clone, Copy)] +struct CompletingTask; + +impl SessionTask for CompletingTask { + fn kind(&self) -> TaskKind { + TaskKind::Regular + } + + fn span_name(&self) -> &'static str { + "session_task.completing" + } + + async fn run( + self: Arc, + _session: Arc, + _ctx: Arc, + _input: Vec, + _cancellation_token: CancellationToken, + ) -> Option { + None + } +} + #[derive(Clone, Copy)] struct NeverEndingTask { kind: TaskKind, @@ -7962,7 +8070,7 @@ async fn guardian_helper_review_interrupts_after_three_consecutive_denials() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_log::test] -async fn abort_regular_task_emits_turn_aborted_only() { +async fn abort_regular_task_emits_marker_before_turn_aborted() { let (sess, tc, rx) = make_session_and_context_with_rx().await; let input = vec![TurnInput::UserInput(vec![UserInput::Text { text: "hello".to_string(), @@ -7980,8 +8088,13 @@ async fn abort_regular_task_emits_turn_aborted_only() { sess.abort_all_tasks(TurnAbortReason::Interrupted).await; - // Interrupts persist a model-visible `` marker into history, but there is no - // separate client-visible event for that marker (only `EventMsg::TurnAborted`). + // Interrupts surface the model-visible `` marker before the abort event. + let marker_evt = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) + .await + .expect("timeout waiting for marker event") + .expect("event"); + assert!(matches!(marker_evt.msg, EventMsg::RawResponseItem(_))); + let evt = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) .await .expect("timeout waiting for event") @@ -7995,7 +8108,7 @@ async fn abort_regular_task_emits_turn_aborted_only() { } #[tokio::test] -async fn abort_gracefully_emits_turn_aborted_only() { +async fn abort_gracefully_emits_marker_before_turn_aborted() { let (sess, tc, rx) = make_session_and_context_with_rx().await; let input = vec![TurnInput::UserInput(vec![UserInput::Text { text: "hello".to_string(), @@ -8013,8 +8126,13 @@ async fn abort_gracefully_emits_turn_aborted_only() { sess.abort_all_tasks(TurnAbortReason::Interrupted).await; - // Even if tasks handle cancellation gracefully, interrupts still result in `TurnAborted` - // being the only client-visible signal. + // Gracefully cancelled tasks surface the model-visible marker before the abort event too. + let marker_evt = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) + .await + .expect("timeout waiting for marker event") + .expect("event"); + assert!(matches!(marker_evt.msg, EventMsg::RawResponseItem(_))); + let evt = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) .await .expect("timeout waiting for event") @@ -8143,6 +8261,86 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() )); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn task_finish_emits_thread_idle_lifecycle_after_active_turn_clears() { + struct ThreadIdleRecorder { + calls: Arc, + idle_tx: async_channel::Sender<()>, + expected_thread_id: ThreadId, + } + + #[async_trait::async_trait] + impl codex_extension_api::ThreadLifecycleContributor for ThreadIdleRecorder { + async fn on_thread_idle(&self, input: codex_extension_api::ThreadIdleInput<'_>) { + assert_eq!( + self.expected_thread_id.to_string(), + input.thread_store.level_id() + ); + self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + self.idle_tx.send(()).await.expect("idle receiver open"); + } + } + + let (mut session, turn_context) = make_session_and_context().await; + let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let (idle_tx, idle_rx) = async_channel::bounded(1); + let mut builder = codex_extension_api::ExtensionRegistryBuilder::::new(); + builder.thread_lifecycle_contributor(Arc::new(ThreadIdleRecorder { + calls: Arc::clone(&calls), + idle_tx, + expected_thread_id: session.conversation_id, + })); + session.services.extensions = Arc::new(builder.build()); + + let session = Arc::new(session); + session + .spawn_task(Arc::new(turn_context), Vec::new(), CompletingTask) + .await; + + timeout(StdDuration::from_secs(2), idle_rx.recv()) + .await + .expect("thread idle lifecycle") + .expect("idle receiver open"); + assert_eq!(1, calls.load(std::sync::atomic::Ordering::SeqCst)); + assert!(session.active_turn.lock().await.is_none()); +} + +#[tokio::test] +async fn thread_idle_lifecycle_waits_for_trigger_turn_mailbox_work() { + struct ThreadIdleRecorder { + calls: Arc, + } + + #[async_trait::async_trait] + impl codex_extension_api::ThreadLifecycleContributor for ThreadIdleRecorder { + async fn on_thread_idle(&self, _input: codex_extension_api::ThreadIdleInput<'_>) { + self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + } + } + + let (mut session, _turn_context) = make_session_and_context().await; + let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let mut builder = codex_extension_api::ExtensionRegistryBuilder::::new(); + builder.thread_lifecycle_contributor(Arc::new(ThreadIdleRecorder { + calls: Arc::clone(&calls), + })); + session.services.extensions = Arc::new(builder.build()); + session + .input_queue + .enqueue_mailbox_communication(InterAgentCommunication::new( + AgentPath::root(), + AgentPath::root(), + Vec::new(), + "pending trigger".to_string(), + /*trigger_turn*/ true, + )) + .await; + + session.emit_thread_idle_lifecycle_if_idle().await; + + assert_eq!(0, calls.load(std::sync::atomic::Ordering::SeqCst)); +} + #[tokio::test] async fn steer_input_requires_active_turn() { let (sess, _tc, _rx) = make_session_and_context_with_rx().await; @@ -8283,66 +8481,11 @@ async fn steer_input_returns_active_turn_id() { assert!(sess.input_queue.has_pending_input(&sess.active_turn).await); } -#[tokio::test] -async fn queued_response_items_for_next_turn_move_into_next_active_turn() { - let (sess, tc, _rx) = make_session_and_context_with_rx().await; - let queued_item = ResponseInputItem::Message { - role: "assistant".to_string(), - content: vec![ContentItem::InputText { - text: "queued before wake".to_string(), - }], - phase: None, - }; - - sess.input_queue - .queue_response_items_for_next_turn(vec![queued_item.clone()]) - .await; - - sess.spawn_task( - Arc::clone(&tc), - Vec::new(), - NeverEndingTask { - kind: TaskKind::Regular, - listen_to_cancellation_token: false, - }, - ) - .await; - - assert_eq!( - sess.input_queue.get_pending_input(&sess.active_turn).await, - vec![TurnInput::ResponseInputItem(queued_item)] - ); -} - -#[tokio::test] -async fn idle_interrupt_does_not_wake_queued_next_turn_items() { - let (sess, _tc, _rx) = make_session_and_context_with_rx().await; - let queued_item = ResponseInputItem::Message { - role: "assistant".to_string(), - content: vec![ContentItem::InputText { - text: "queued before interrupt".to_string(), - }], - phase: None, - }; - - sess.input_queue - .queue_response_items_for_next_turn(vec![queued_item]) - .await; - - sess.abort_all_tasks(TurnAbortReason::Interrupted).await; - - assert!(sess.active_turn.lock().await.is_none()); - assert!( - sess.input_queue - .has_queued_response_items_for_next_turn() - .await - ); -} - #[tokio::test] async fn abort_empty_active_turn_preserves_pending_input() { let (sess, _tc, _rx) = make_session_and_context_with_rx().await; - let pending_item = ResponseInputItem::Message { + let pending_item = ResponseItem::Message { + id: None, role: "user".to_string(), content: vec![ContentItem::InputText { text: "late pending input".to_string(), @@ -8357,7 +8500,7 @@ async fn abort_empty_active_turn_preserves_pending_input() { sess.input_queue .extend_pending_input_for_turn_state( turn_state.as_ref(), - vec![TurnInput::ResponseInputItem(pending_item.clone())], + vec![TurnInput::ResponseItem(pending_item.clone())], ) .await; @@ -8368,7 +8511,7 @@ async fn abort_empty_active_turn_preserves_pending_input() { sess.input_queue .take_pending_input_for_turn_state(turn_state.as_ref()) .await, - vec![TurnInput::ResponseInputItem(pending_item)] + vec![TurnInput::ResponseItem(pending_item)] ); } @@ -8794,7 +8937,7 @@ async fn budget_limited_accounting_steers_active_turn_without_aborting() -> anyh .await?; let pending_input = sess.input_queue.get_pending_input(&sess.active_turn).await; - let [TurnInput::ResponseInputItem(ResponseInputItem::Message { role, content, .. })] = + let [TurnInput::ResponseItem(ResponseItem::Message { role, content, .. })] = pending_input.as_slice() else { panic!("expected one budget-limit steering message, got {pending_input:#?}"); @@ -9021,7 +9164,7 @@ async fn external_objective_change_steers_active_turn() -> anyhow::Result<()> { pending_input.iter().any(|item| { matches!( item, - TurnInput::ResponseInputItem(ResponseInputItem::Message { role, content, .. }) + TurnInput::ResponseItem(ResponseItem::Message { role, content, .. }) if role == "user" && content.iter().any(|content| matches!( content, @@ -9245,9 +9388,9 @@ async fn queue_only_mailbox_mail_waits_for_next_turn_after_answer_boundary() { assert_eq!( sess.input_queue.get_pending_input(&sess.active_turn).await, - vec![TurnInput::ResponseInputItem( + vec![TurnInput::ResponseItem(ResponseItem::from( communication.to_response_input_item() - )], + ))], ); } @@ -9332,7 +9475,7 @@ async fn steered_input_reopens_mailbox_delivery_for_current_turn() { text: "follow up".to_string(), text_elements: Vec::new(), }]), - TurnInput::ResponseInputItem(communication.to_response_input_item()), + TurnInput::ResponseItem(ResponseItem::from(communication.to_response_input_item())), ], ); } @@ -9386,7 +9529,7 @@ async fn stale_defer_mailbox_delivery_does_not_override_steered_input() { text: "follow up".to_string(), text_elements: Vec::new(), }]), - TurnInput::ResponseInputItem(communication.to_response_input_item()), + TurnInput::ResponseItem(ResponseItem::from(communication.to_response_input_item())), ], ); } @@ -9441,9 +9584,9 @@ async fn tool_calls_reopen_mailbox_delivery_for_current_turn() { assert!(output.tool_future.is_some()); assert_eq!( sess.input_queue.get_pending_input(&sess.active_turn).await, - vec![TurnInput::ResponseInputItem( + vec![TurnInput::ResponseItem(ResponseItem::from( communication.to_response_input_item() - )], + ))], ); } @@ -9501,8 +9644,7 @@ async fn abort_review_task_emits_exited_then_aborted_and_records_history() { ); let history = sess.clone_history().await; - // The `` marker is silent in the event stream, so verify it is still - // recorded in history for the model. + // Verify the `` marker is still recorded in history for the model. assert!( history.raw_items().iter().any(|item| { let ResponseItem::Message { role, content, .. } = item else { @@ -10131,6 +10273,76 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() { ExecApprovalRequirement::Skip { .. } )); } + +#[cfg(unix)] +#[tokio::test] +async fn shell_tool_cancellation_waits_for_runtime_cleanup() -> anyhow::Result<()> { + let session = make_session_with_config(|config| { + let cwd = config.cwd.clone(); + config + .permissions + .set_legacy_sandbox_policy(SandboxPolicy::DangerFullAccess, cwd.as_path()) + .expect("test setup should allow sandbox policy"); + }) + .await?; + let turn_context = session.new_default_turn().await; + let session = Arc::new(session); + let turn_context = Arc::new(turn_context); + let temp_dir = tempfile::TempDir::new()?; + let ready_marker = temp_dir.path().join("ready"); + let cleanup_marker = temp_dir.path().join("cleanup"); + // Interrupt after the shell starts, then verify dispatch waits for its TERM cleanup trap. + let command = format!( + r#"trap 'printf cleaned > "{}"; exit 0' TERM +printf ready > "{}" +while :; do sleep 1; done"#, + cleanup_marker.display(), + ready_marker.display(), + ); + let item = ResponseItem::FunctionCall { + id: None, + name: "shell_command".to_string(), + namespace: None, + arguments: serde_json::json!({ + "command": command, + "timeout_ms": 60_000, + }) + .to_string(), + call_id: "shell-cleanup-call".to_string(), + }; + let call = ToolRouter::build_tool_call(item)? + .expect("shell command response item should build a tool call"); + let cancellation_token = CancellationToken::new(); + let cancellation_tx = cancellation_token.clone(); + let handle = tokio::spawn( + test_tool_runtime(Arc::clone(&session), Arc::clone(&turn_context)) + .handle_tool_call(call, cancellation_token), + ); + + let mut ready = false; + for _ in 0..50 { + if ready_marker.exists() { + ready = true; + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + if !ready { + cancellation_tx.cancel(); + let _ = timeout(Duration::from_secs(5), handle).await; + anyhow::bail!("shell command should reach the ready marker"); + } + + cancellation_tx.cancel(); + timeout(Duration::from_secs(5), handle) + .await + .expect("cancelled shell tool should finish promptly") + .expect("shell tool task should join") + .expect("cancelled shell tool should return a response item"); + assert_eq!(std::fs::read_to_string(cleanup_marker)?, "cleaned"); + Ok(()) +} + #[tokio::test] async fn unified_exec_rejects_escalated_permissions_when_policy_not_on_request() { use crate::sandboxing::SandboxPermissions; diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index fe20b76e4d6..477e38c81b7 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -145,7 +145,10 @@ pub(crate) async fn run_turn( // diffs/full reinjection + user input) and trigger compaction preemptively // when they would push the thread over the compaction threshold. if let Err(err) = run_pre_sampling_compact(&sess, &turn_context, &mut client_session).await { - if err.to_codex_protocol_error() == CodexErrorInfo::UsageLimitExceeded + let error = err.to_codex_protocol_error(); + sess.emit_turn_error_lifecycle(turn_context.as_ref(), error.clone()) + .await; + if error == CodexErrorInfo::UsageLimitExceeded && let Err(err) = sess .goal_runtime_apply(GoalRuntimeEvent::UsageLimitReached { turn_context: turn_context.as_ref(), @@ -295,7 +298,10 @@ pub(crate) async fn run_turn( ) .await { - if err.to_codex_protocol_error() == CodexErrorInfo::UsageLimitExceeded + let error = err.to_codex_protocol_error(); + sess.emit_turn_error_lifecycle(turn_context.as_ref(), error.clone()) + .await; + if error == CodexErrorInfo::UsageLimitExceeded && let Err(err) = sess .goal_runtime_apply(GoalRuntimeEvent::UsageLimitReached { turn_context: turn_context.as_ref(), @@ -374,17 +380,23 @@ pub(crate) async fn run_turn( } } + let error = CodexErrorInfo::BadRequest; + sess.emit_turn_error_lifecycle(turn_context.as_ref(), error.clone()) + .await; let event = EventMsg::Error(ErrorEvent { message: "Invalid image in your last message. Please remove it and try again." .to_string(), - codex_error_info: Some(CodexErrorInfo::BadRequest), + codex_error_info: Some(error), }); sess.send_event(&turn_context, event).await; break; } Err(e) => { info!("Turn error: {e:#}"); - if e.to_codex_protocol_error() == CodexErrorInfo::UsageLimitExceeded + let error = e.to_codex_protocol_error(); + sess.emit_turn_error_lifecycle(turn_context.as_ref(), error.clone()) + .await; + if error == CodexErrorInfo::UsageLimitExceeded && let Err(err) = sess .goal_runtime_apply(GoalRuntimeEvent::UsageLimitReached { turn_context: turn_context.as_ref(), @@ -446,7 +458,7 @@ async fn build_skills_and_plugins( .iter() .filter_map(|item| match item { TurnInput::UserInput(content) => Some(content.as_slice()), - TurnInput::ResponseInputItem(_) => None, + TurnInput::ResponseItem(_) => None, }) .flatten() .cloned() @@ -599,7 +611,7 @@ async fn track_turn_resolved_config_analytics( .iter() .filter_map(|item| match item { TurnInput::UserInput(content) => Some(content.as_slice()), - TurnInput::ResponseInputItem(_) => None, + TurnInput::ResponseItem(_) => None, }) .flatten() .filter(|item| { diff --git a/codex-rs/core/src/tasks/lifecycle.rs b/codex-rs/core/src/tasks/lifecycle.rs index 8b934175cf9..782cc8c7ded 100644 --- a/codex-rs/core/src/tasks/lifecycle.rs +++ b/codex-rs/core/src/tasks/lifecycle.rs @@ -1,4 +1,5 @@ use codex_extension_api::ExtensionData; +use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TurnAbortReason; @@ -37,6 +38,23 @@ impl Session { } } + pub(crate) async fn emit_thread_idle_lifecycle_if_idle(&self) { + if self.active_turn.lock().await.is_some() + || self.input_queue.has_trigger_turn_mailbox_items().await + { + return; + } + + for contributor in self.services.extensions.thread_lifecycle_contributors() { + contributor + .on_thread_idle(codex_extension_api::ThreadIdleInput { + session_store: &self.services.session_extension_data, + thread_store: &self.services.thread_extension_data, + }) + .await; + } + } + pub(super) async fn emit_turn_abort_lifecycle( &self, reason: TurnAbortReason, @@ -53,4 +71,22 @@ impl Session { .await; } } + + pub(crate) async fn emit_turn_error_lifecycle( + &self, + turn_context: &TurnContext, + error: CodexErrorInfo, + ) { + for contributor in self.services.extensions.turn_lifecycle_contributors() { + contributor + .on_turn_error(codex_extension_api::TurnErrorInput { + turn_id: turn_context.sub_id.as_str(), + error: error.clone(), + session_store: &self.services.session_extension_data, + thread_store: &self.services.thread_extension_data, + turn_store: turn_context.extension_data.as_ref(), + }) + .await; + } + } } diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index aa161c12745..38a8a962760 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -44,7 +44,6 @@ use codex_otel::TURN_TOKEN_USAGE_METRIC; use codex_otel::TURN_TOOL_CALL_METRIC; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnAbortedEvent; @@ -347,11 +346,7 @@ impl Session { { warn!("failed to apply goal runtime turn-start event: {err}"); } - let queued_response_items = self - .input_queue - .take_queued_response_items_for_next_turn() - .await; - let mailbox_items = self.input_queue.get_pending_input(&self.active_turn).await; + let pending_items = self.input_queue.get_pending_input(&self.active_turn).await; let turn_state = { let mut active = self.active_turn.lock().await; let turn = active.get_or_insert_with(ActiveTurn::default); @@ -359,11 +354,6 @@ impl Session { Arc::clone(&turn.turn_state) }; turn_state.lock().await.token_usage_at_turn_start = token_usage_at_turn_start.clone(); - let mut pending_items = queued_response_items - .into_iter() - .map(TurnInput::ResponseInputItem) - .collect::>(); - pending_items.extend(mailbox_items); self.input_queue .extend_pending_input_for_turn_state(turn_state.as_ref(), pending_items) .await; @@ -452,8 +442,7 @@ impl Session { /// Starts a regular turn when the session is idle and pending work is waiting. /// - /// Pending work currently includes queued next-turn items and mailbox mail marked with - /// `trigger_turn`. + /// Pending work currently includes mailbox mail marked with `trigger_turn`. /// /// This helper generates a fresh sub-id for the synthetic turn before delegating to the /// explicit-sub-id variant. @@ -465,18 +454,13 @@ impl Session { /// Starts a regular turn with the provided sub-id when pending work should wake an idle /// session. /// - /// The turn is created only when there are queued next-turn items or mailbox mail marked with - /// `trigger_turn`, and only if the session is currently idle. + /// The turn is created only when there is mailbox mail marked with `trigger_turn`, and only + /// if the session is currently idle. pub(crate) async fn maybe_start_turn_for_pending_work_with_sub_id( self: &Arc, sub_id: String, ) { - if !self - .input_queue - .has_queued_response_items_for_next_turn() - .await - && !self.input_queue.has_trigger_turn_mailbox_items().await - { + if !self.input_queue.has_trigger_turn_mailbox_items().await { return; } @@ -809,6 +793,7 @@ impl Session { { warn!("failed to apply goal runtime maybe-continue event: {err}"); } + self.emit_thread_idle_lifecycle_if_idle().await; } async fn take_active_turn(&self) -> Option { @@ -859,10 +844,11 @@ impl Session { InterruptedTurnHistoryMarker::from_config(task.turn_context.config.as_ref()), ) { - self.record_into_history(std::slice::from_ref(&marker), task.turn_context.as_ref()) - .await; - self.persist_rollout_items(&[RolloutItem::ResponseItem(marker)]) - .await; + self.record_conversation_items( + task.turn_context.as_ref(), + std::slice::from_ref(&marker), + ) + .await; // Ensure the marker is durably visible before emitting TurnAborted: some clients // synchronously re-read the rollout on receipt of the abort event. if let Err(err) = self.flush_rollout().await { diff --git a/codex-rs/core/src/tasks/review.rs b/codex-rs/core/src/tasks/review.rs index ff7f38a7eae..514bb13e4cf 100644 --- a/codex-rs/core/src/tasks/review.rs +++ b/codex-rs/core/src/tasks/review.rs @@ -73,7 +73,7 @@ impl SessionTask for ReviewTask { for item in input { match item { TurnInput::UserInput(mut content) => user_input.append(&mut content), - TurnInput::ResponseInputItem(_) => {} + TurnInput::ResponseItem(_) => {} } } diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 396aecbeea8..816a18805aa 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -38,8 +38,6 @@ use super::SessionTask; use super::SessionTaskContext; use crate::session::session::Session; use codex_protocol::models::PermissionProfile; -use codex_protocol::models::ResponseInputItem; -use codex_protocol::models::ResponseItem; const USER_SHELL_TIMEOUT_MS: u64 = 60 * 60 * 1000; // 1 hour @@ -361,30 +359,7 @@ async fn persist_user_shell_output( return; } - let response_input_item = match output_item { - ResponseItem::Message { - role, - content, - phase, - .. - } => ResponseInputItem::Message { - role, - content, - phase, - }, - _ => unreachable!("user shell command output record should always be a message"), - }; - - if let Err(items) = session - .inject_response_items(vec![response_input_item]) - .await - { - let response_items = items - .into_iter() - .map(ResponseItem::from) - .collect::>(); - session - .record_conversation_items(turn_context, &response_items) - .await; - } + session + .inject_no_new_turn(vec![output_item], Some(turn_context)) + .await; } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index d209026b62f..3eec8315b1d 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -33,8 +33,6 @@ use codex_protocol::ThreadId; use codex_protocol::config_types::CollaborationModeMask; use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; -#[cfg(test)] -use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelPreset; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; @@ -1016,17 +1014,6 @@ impl ThreadManagerState { thread.submit(op).await } - #[cfg(test)] - /// Append a prebuilt message to a thread by ID outside the normal user-input path. - pub(crate) async fn append_message( - &self, - thread_id: ThreadId, - message: ResponseItem, - ) -> CodexResult { - let thread = self.get_thread(thread_id).await?; - thread.append_message(message).await - } - /// Remove a thread from the manager by ID, returning it when present. pub(crate) async fn remove_thread(&self, thread_id: &ThreadId) -> Option> { self.threads.write().await.remove(thread_id) diff --git a/codex-rs/core/src/tools/code_mode/mod.rs b/codex-rs/core/src/tools/code_mode/mod.rs index 09d58ea8f3f..abf4b9bbab2 100644 --- a/codex-rs/core/src/tools/code_mode/mod.rs +++ b/codex-rs/core/src/tools/code_mode/mod.rs @@ -13,7 +13,7 @@ use codex_code_mode::CodeModeTurnHost; use codex_code_mode::RuntimeResponse; use codex_protocol::models::FunctionCallOutputContentItem; use codex_protocol::models::FunctionCallOutputPayload; -use codex_protocol::models::ResponseInputItem; +use codex_protocol::models::ResponseItem; use serde_json::Value as JsonValue; use tokio_util::sync::CancellationToken; @@ -134,7 +134,7 @@ impl CodeModeTurnHost for CoreTurnHost { } self.exec .session - .inject_response_items(vec![ResponseInputItem::CustomToolCallOutput { + .inject_if_running(vec![ResponseItem::CustomToolCallOutput { call_id, name: Some(PUBLIC_TOOL_NAME.to_string()), output: FunctionCallOutputPayload::from_text(text), diff --git a/codex-rs/core/src/tools/handlers/extension_tools.rs b/codex-rs/core/src/tools/handlers/extension_tools.rs index 8c9f55c4600..d04a6c520ef 100644 --- a/codex-rs/core/src/tools/handlers/extension_tools.rs +++ b/codex-rs/core/src/tools/handlers/extension_tools.rs @@ -1,11 +1,18 @@ use std::sync::Arc; +use std::sync::Weak; +use codex_protocol::items::TurnItem; use codex_tools::ConversationHistory; +use codex_tools::ExtensionTurnItem; use codex_tools::ToolCall as ExtensionToolCall; use codex_tools::ToolName; use codex_tools::ToolSpec; +use codex_tools::TurnItemEmissionFuture; +use codex_tools::TurnItemEmitter; use crate::function_tool::FunctionCallError; +use crate::session::session::Session; +use crate::session::turn_context::TurnContext; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; @@ -52,6 +59,39 @@ impl CoreToolRuntime for ExtensionToolAdapter { } } +struct CoreTurnItemEmitter { + session: Weak, + turn: Weak, +} + +fn extension_turn_item(item: ExtensionTurnItem) -> TurnItem { + match item { + ExtensionTurnItem::WebSearch(item) => TurnItem::WebSearch(item), + } +} + +impl TurnItemEmitter for CoreTurnItemEmitter { + fn emit_started<'a>(&'a self, item: ExtensionTurnItem) -> TurnItemEmissionFuture<'a> { + Box::pin(async move { + let (Some(session), Some(turn)) = (self.session.upgrade(), self.turn.upgrade()) else { + return; + }; + let item = extension_turn_item(item); + session.emit_turn_item_started(turn.as_ref(), &item).await; + }) + } + + fn emit_completed<'a>(&'a self, item: ExtensionTurnItem) -> TurnItemEmissionFuture<'a> { + Box::pin(async move { + let (Some(session), Some(turn)) = (self.session.upgrade(), self.turn.upgrade()) else { + return; + }; + let item = extension_turn_item(item); + session.emit_turn_item_completed(turn.as_ref(), item).await; + }) + } +} + async fn to_extension_call(invocation: &ToolInvocation) -> ExtensionToolCall { let conversation_history = ConversationHistory::new(invocation.session.clone_history().await.into_raw_items()); @@ -61,6 +101,10 @@ async fn to_extension_call(invocation: &ToolInvocation) -> ExtensionToolCall { tool_name: invocation.tool_name.clone(), truncation_policy: invocation.turn.truncation_policy, conversation_history, + turn_item_emitter: Arc::new(CoreTurnItemEmitter { + session: Arc::downgrade(&invocation.session), + turn: Arc::downgrade(&invocation.turn), + }), payload: invocation.payload.clone(), } } @@ -69,8 +113,13 @@ async fn to_extension_call(invocation: &ToolInvocation) -> ExtensionToolCall { mod tests { use std::sync::Arc; + use codex_protocol::items::TurnItem; + use codex_protocol::items::WebSearchItem; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; + use codex_protocol::models::WebSearchAction; + use codex_protocol::protocol::EventMsg; + use codex_tools::ExtensionTurnItem; use pretty_assertions::assert_eq; use serde_json::json; use tokio::sync::Mutex; @@ -147,6 +196,16 @@ mod tests { &self, call: codex_tools::ToolCall, ) -> Result, codex_tools::FunctionCallError> { + let item = ExtensionTurnItem::WebSearch(WebSearchItem { + id: call.call_id.clone(), + query: "rust trait object".to_string(), + action: WebSearchAction::Search { + query: Some("rust trait object".to_string()), + queries: None, + }, + }); + call.turn_item_emitter.emit_started(item.clone()).await; + call.turn_item_emitter.emit_completed(item).await; *self.captured_call.lock().await = Some(call); Ok(Box::new(codex_tools::JsonToolOutput::new( json!({ "ok": true }), @@ -191,12 +250,14 @@ mod tests { } #[tokio::test] - async fn passes_turn_fields_to_extension_call() { + async fn passes_turn_fields_and_scoped_turn_item_emitter_to_extension_call() { let captured_call = Arc::new(Mutex::new(None)); let handler = ExtensionToolAdapter::new(Arc::new(CapturingExtensionExecutor { captured_call: Arc::clone(&captured_call), })); - let (session, turn) = crate::session::tests::make_session_and_context().await; + let (session, turn, rx) = crate::session::tests::make_session_and_context_with_rx().await; + let weak_session = Arc::downgrade(&session); + let weak_turn = Arc::downgrade(&turn); let turn_id = turn.sub_id.clone(); let truncation_policy = turn.truncation_policy; let history_item = ResponseItem::Message { @@ -208,11 +269,16 @@ mod tests { phase: None, }; session - .record_into_history(std::slice::from_ref(&history_item), &turn) + .record_conversation_items(&turn, std::slice::from_ref(&history_item)) .await; + let raw_history_event = rx.recv().await.expect("history raw response item event"); + let EventMsg::RawResponseItem(raw_history_item) = raw_history_event.msg else { + panic!("expected raw response item event"); + }; + assert_eq!(raw_history_item.item, history_item); let invocation = ToolInvocation { - session: session.into(), - turn: turn.into(), + session, + turn, cancellation_token: tokio_util::sync::CancellationToken::new(), tracker: Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())), call_id: "call-extension".to_string(), @@ -228,6 +294,8 @@ mod tests { .expect("extension call should succeed"); let captured_call = captured_call.lock().await.clone().expect("captured call"); + assert!(weak_session.upgrade().is_none()); + assert!(weak_turn.upgrade().is_none()); assert_eq!(captured_call.turn_id, turn_id); assert_eq!(captured_call.call_id, "call-extension"); assert_eq!( @@ -245,5 +313,43 @@ mod tests { } payload => panic!("expected function payload, got {payload:?}"), } + + let started = rx.recv().await.expect("item started event"); + let EventMsg::ItemStarted(started) = started.msg else { + panic!("expected item started event"); + }; + let TurnItem::WebSearch(started_item) = started.item else { + panic!("expected web search item"); + }; + let begin = rx.recv().await.expect("legacy web search begin event"); + let EventMsg::WebSearchBegin(begin) = begin.msg else { + panic!("expected legacy web search begin event"); + }; + let completed = rx.recv().await.expect("item completed event"); + let EventMsg::ItemCompleted(completed) = completed.msg else { + panic!("expected item completed event"); + }; + let TurnItem::WebSearch(completed_item) = completed.item else { + panic!("expected web search item"); + }; + let end = rx.recv().await.expect("legacy web search end event"); + let EventMsg::WebSearchEnd(end) = end.msg else { + panic!("expected legacy web search end event"); + }; + + let expected = WebSearchItem { + id: "call-extension".to_string(), + query: "rust trait object".to_string(), + action: WebSearchAction::Search { + query: Some("rust trait object".to_string()), + queries: None, + }, + }; + assert_eq!(started_item, expected); + assert_eq!(completed_item, expected); + assert_eq!(begin.call_id, expected.id); + assert_eq!(end.call_id, expected.id); + assert_eq!(end.query, expected.query); + assert_eq!(end.action, expected.action); } } diff --git a/codex-rs/core/src/tools/handlers/mcp.rs b/codex-rs/core/src/tools/handlers/mcp.rs index 09eba1730dc..8f18d5b55e7 100644 --- a/codex-rs/core/src/tools/handlers/mcp.rs +++ b/codex-rs/core/src/tools/handlers/mcp.rs @@ -522,19 +522,13 @@ mod tests { callable_name: tool_name.to_string(), callable_namespace: callable_namespace.to_string(), namespace_description: None, - tool: rmcp::model::Tool { - name: tool_name.to_string().into(), - title: None, - description: None, - input_schema: Arc::new(rmcp::model::object(serde_json::json!({ + tool: rmcp::model::Tool::new_with_raw( + tool_name.to_string(), + None, + Arc::new(rmcp::model::object(serde_json::json!({ "type": "object", }))), - output_schema: None, - annotations: None, - execution: None, - icons: None, - meta: None, - }, + ), connector_id: None, connector_name: None, plugin_display_names: Vec::new(), diff --git a/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resource_templates.rs b/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resource_templates.rs index a0996fb37b1..b1e82aa8a6d 100644 --- a/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resource_templates.rs +++ b/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resource_templates.rs @@ -82,10 +82,9 @@ impl ToolExecutor for ListMcpResourceTemplatesHandler { let payload_result: Result = async { if let Some(server_name) = server.clone() { - let params = cursor.clone().map(|value| PaginatedRequestParams { - meta: None, - cursor: Some(value), - }); + let params = cursor + .clone() + .map(|value| PaginatedRequestParams::default().with_cursor(Some(value))); let result = session .list_resource_templates(&server_name, params) .await diff --git a/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resources.rs b/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resources.rs index b9699b330e1..c2cdec8b157 100644 --- a/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resources.rs +++ b/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resources.rs @@ -82,10 +82,9 @@ impl ToolExecutor for ListMcpResourcesHandler { let payload_result: Result = async { if let Some(server_name) = server.clone() { - let params = cursor.clone().map(|value| PaginatedRequestParams { - meta: None, - cursor: Some(value), - }); + let params = cursor + .clone() + .map(|value| PaginatedRequestParams::default().with_cursor(Some(value))); let result = session .list_resources(&server_name, params) .await diff --git a/codex-rs/core/src/tools/handlers/mcp_resource/read_mcp_resource.rs b/codex-rs/core/src/tools/handlers/mcp_resource/read_mcp_resource.rs index d1b73322c02..126c5d85e80 100644 --- a/codex-rs/core/src/tools/handlers/mcp_resource/read_mcp_resource.rs +++ b/codex-rs/core/src/tools/handlers/mcp_resource/read_mcp_resource.rs @@ -78,13 +78,7 @@ impl ToolExecutor for ReadMcpResourceHandler { let payload_result: Result = async { let result = session - .read_resource( - &server, - ReadResourceRequestParams { - meta: None, - uri: uri.clone(), - }, - ) + .read_resource(&server, ReadResourceRequestParams::new(uri.clone())) .await .map_err(|err| { FunctionCallError::RespondToModel(format!("resources/read failed: {err:#}")) diff --git a/codex-rs/core/src/tools/handlers/mcp_search_tests.rs b/codex-rs/core/src/tools/handlers/mcp_search_tests.rs index 588254ae27a..8ddfa93d848 100644 --- a/codex-rs/core/src/tools/handlers/mcp_search_tests.rs +++ b/codex-rs/core/src/tools/handlers/mcp_search_tests.rs @@ -50,11 +50,10 @@ fn tool_info() -> ToolInfo { callable_name: "_create_event".to_string(), callable_namespace: "mcp__calendar__".to_string(), namespace_description: Some("Plan events.".to_string()), - tool: rmcp::model::Tool { - name: "createEvent".to_string().into(), - title: Some("Create event".to_string()), - description: Some("Create a calendar event.".to_string().into()), - input_schema: Arc::new(rmcp::model::object(json!({ + tool: rmcp::model::Tool::new( + "createEvent", + "Create a calendar event.", + Arc::new(rmcp::model::object(json!({ "type": "object", "properties": { "start_time": { "type": "string" }, @@ -62,12 +61,8 @@ fn tool_info() -> ToolInfo { }, "additionalProperties": false }))), - output_schema: None, - annotations: None, - execution: None, - icons: None, - meta: None, - }, + ) + .with_title("Create event"), connector_id: None, connector_name: Some("Calendar".to_string()), plugin_display_names: vec![" Calendar plugin ".to_string(), " ".to_string()], diff --git a/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs index de069854f10..bcfa5231b9d 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs @@ -1,6 +1,7 @@ use super::*; use crate::tools::handlers::multi_agents_spec::create_close_agent_tool_v1; use crate::turn_timing::now_unix_timestamp_ms; +use codex_protocol::error::CodexErr; use codex_tools::ToolSpec; pub(crate) struct Handler; @@ -36,11 +37,9 @@ async fn handle_close_agent( let arguments = function_arguments(payload)?; let args: CloseAgentArgs = parse_arguments(&arguments)?; let agent_id = parse_agent_id_target(&args.target)?; - let receiver_agent = session - .services - .agent_control - .get_agent_metadata(agent_id) - .unwrap_or_default(); + let receiver_agent = session.services.agent_control.get_agent_metadata(agent_id); + let known_agent = receiver_agent.is_some(); + let receiver_agent = receiver_agent.unwrap_or_default(); session .send_event( &turn, @@ -60,6 +59,9 @@ async fn handle_close_agent( .await { Ok(mut status_rx) => status_rx.borrow_and_update().clone(), + Err(CodexErr::ThreadNotFound(_)) if known_agent => { + session.services.agent_control.get_status(agent_id).await + } Err(err) => { let status = session.services.agent_control.get_status(agent_id).await; session diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 68b19adea9e..164d5410e0b 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -52,6 +52,7 @@ use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnAbortedEvent; use codex_protocol::protocol::TurnCompleteEvent; use codex_protocol::user_input::UserInput; +use codex_state::DirectionalThreadSpawnEdgeStatus; use core_test_support::TempDirExt; use pretty_assertions::assert_eq; use serde::Deserialize; @@ -3751,6 +3752,126 @@ async fn multi_agent_v2_close_agent_accepts_task_name_target() { ); } +#[tokio::test] +async fn multi_agent_v2_close_agent_reaps_stale_task_name_target() { + let (mut session, mut turn) = make_session_and_context().await; + let mut config = (*turn.config).clone(); + config.agent_max_threads = Some(1); + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::Sqlite) + .expect("test config should allow sqlite"); + let state_db = init_state_db(&config) + .await + .expect("sqlite state db should initialize"); + 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(), + Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), + Some(state_db.clone()), + ); + let root = manager + .start_thread(config.clone()) + .await + .expect("root thread should start"); + session.services.agent_control = manager.agent_control(); + session.conversation_id = root.thread_id; + turn.config = Arc::new(config.clone()); + + let session = Arc::new(session); + let turn = Arc::new(turn); + SpawnAgentHandlerV2::default() + .handle(invocation( + session.clone(), + turn.clone(), + "spawn_agent", + function_payload(json!({ + "message": "inspect this repo", + "task_name": "worker" + })), + )) + .await + .expect("spawn_agent should succeed"); + + let agent_id = session + .services + .agent_control + .resolve_agent_reference(session.conversation_id, &turn.session_source, "worker") + .await + .expect("worker path should resolve"); + let stale_thread = manager + .remove_thread(&agent_id) + .await + .expect("worker thread should be loaded before removal"); + stale_thread + .submit(Op::Shutdown {}) + .await + .expect("removed worker thread should still accept shutdown"); + stale_thread.wait_until_terminated().await; + + let output = CloseAgentHandlerV2 + .handle(invocation( + session.clone(), + turn.clone(), + "close_agent", + function_payload(json!({"target": "worker"})), + )) + .await + .expect("close_agent should reap stale v2 task names"); + let (content, success) = expect_text_output(output); + let result: close_agent::CloseAgentResult = + serde_json::from_str(&content).expect("close_agent result should be json"); + assert_eq!(result.previous_status, AgentStatus::NotFound); + assert_eq!(success, Some(true)); + + let open_children = state_db + .list_thread_spawn_children_with_status( + root.thread_id, + DirectionalThreadSpawnEdgeStatus::Open, + ) + .await + .expect("open children should load"); + assert_eq!(open_children, Vec::::new()); + let closed_children = state_db + .list_thread_spawn_children_with_status( + root.thread_id, + DirectionalThreadSpawnEdgeStatus::Closed, + ) + .await + .expect("closed children should load"); + assert_eq!(closed_children, vec![agent_id]); + + SpawnAgentHandlerV2::default() + .handle(invocation( + session.clone(), + turn.clone(), + "spawn_agent", + function_payload(json!({ + "message": "inspect this repo again", + "task_name": "replacement" + })), + )) + .await + .expect("spawn_agent should succeed after stale close releases the slot"); + let replacement_id = session + .services + .agent_control + .resolve_agent_reference(session.conversation_id, &turn.session_source, "replacement") + .await + .expect("replacement path should resolve"); + let _ = session + .services + .agent_control + .shutdown_live_agent(replacement_id) + .await + .expect("replacement should shut down"); +} + #[tokio::test] async fn multi_agent_v2_close_agent_rejects_root_target_and_id() { let (mut session, mut turn) = make_session_and_context().await; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs index 088e5310623..15502572a50 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs @@ -1,6 +1,7 @@ use super::*; use crate::tools::handlers::multi_agents_spec::create_close_agent_tool_v2; use crate::turn_timing::now_unix_timestamp_ms; +use codex_protocol::error::CodexErr; use codex_tools::ToolSpec; pub(crate) struct Handler; @@ -36,11 +37,9 @@ async fn handle_close_agent( let arguments = function_arguments(payload)?; let args: CloseAgentArgs = parse_arguments(&arguments)?; let agent_id = resolve_agent_target(&session, &turn, &args.target).await?; - let receiver_agent = session - .services - .agent_control - .get_agent_metadata(agent_id) - .unwrap_or_default(); + let receiver_agent = session.services.agent_control.get_agent_metadata(agent_id); + let known_agent = receiver_agent.is_some(); + let receiver_agent = receiver_agent.unwrap_or_default(); if receiver_agent .agent_path .as_ref() @@ -69,6 +68,9 @@ async fn handle_close_agent( .await { Ok(mut status_rx) => status_rx.borrow_and_update().clone(), + Err(CodexErr::ThreadNotFound(_)) if known_agent => { + session.services.agent_control.get_status(agent_id).await + } Err(err) => { let status = session.services.agent_control.get_status(agent_id).await; session diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 09eb29b1e40..c890e0d8dac 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -2,6 +2,7 @@ use codex_features::Feature; use codex_protocol::models::ShellCommandToolCallParams; use serde_json::Value as JsonValue; use std::sync::Arc; +use tokio_util::sync::CancellationToken; use crate::exec::ExecParams; use crate::exec_policy::ExecApprovalRequest; @@ -44,6 +45,7 @@ fn shell_command_payload_command(payload: &ToolPayload) -> Option { struct RunExecLikeArgs { tool_name: ToolName, exec_params: ExecParams, + cancellation_token: CancellationToken, hook_command: String, shell_type: Option, additional_permissions: Option, @@ -59,6 +61,7 @@ async fn run_exec_like(args: RunExecLikeArgs) -> Result Result for ShellCommandHandler { let ToolInvocation { session, turn, + cancellation_token, tracker, call_id, payload, @@ -185,6 +186,7 @@ impl ToolExecutor for ShellCommandHandler { run_exec_like(RunExecLikeArgs { tool_name, exec_params, + cancellation_token, hook_command: params.command, shell_type, additional_permissions: params.additional_permissions.clone(), @@ -205,6 +207,10 @@ impl CoreToolRuntime for ShellCommandHandler { matches!(payload, ToolPayload::Function { .. }) } + fn waits_for_runtime_cancellation(&self) -> bool { + true + } + fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option { shell_command_payload_command(&invocation.payload).map(|command| PreToolUsePayload { tool_name: HookToolName::bash(), diff --git a/codex-rs/core/src/tools/handlers/tool_search.rs b/codex-rs/core/src/tools/handlers/tool_search.rs index 107723aa159..442d77ce8dc 100644 --- a/codex-rs/core/src/tools/handlers/tool_search.rs +++ b/codex-rs/core/src/tools/handlers/tool_search.rs @@ -258,21 +258,15 @@ mod tests { callable_name: tool_name.to_string(), callable_namespace: format!("mcp__{server_name}"), namespace_description: None, - tool: Tool { - name: tool_name.to_string().into(), - title: None, - description: Some(format!("{description_prefix} desktop tool").into()), - input_schema: Arc::new(rmcp::model::object(serde_json::json!({ + tool: Tool::new( + tool_name.to_string(), + format!("{description_prefix} desktop tool"), + Arc::new(rmcp::model::object(serde_json::json!({ "type": "object", "properties": {}, "additionalProperties": false, }))), - output_schema: None, - annotations: None, - execution: None, - icons: None, - meta: None, - }, + ), connector_id: None, connector_name: None, plugin_display_names: Vec::new(), diff --git a/codex-rs/core/src/tools/parallel.rs b/codex-rs/core/src/tools/parallel.rs index bac63613299..7db1ec96436 100644 --- a/codex-rs/core/src/tools/parallel.rs +++ b/codex-rs/core/src/tools/parallel.rs @@ -92,6 +92,7 @@ impl ToolCallRuntime { let tracker = Arc::clone(&self.tracker); let lock = Arc::clone(&self.parallel_execution); let invocation_cancellation_token = cancellation_token.clone(); + let wait_for_runtime_cancellation = self.router.tool_waits_for_runtime_cancellation(&call); let started = Instant::now(); let abort_session = Arc::clone(&session); let abort_source = source.clone(); @@ -140,23 +141,35 @@ impl ToolCallRuntime { } else { let secs = started.elapsed().as_secs_f32().max(0.1); abort_dispatch_span.record("aborted", true); - handle.abort(); - match handle.await { - Ok(result) => result, - Err(err) if err.is_cancelled() => { - let response = Self::aborted_response(&call, secs); - notify_tool_aborted( - abort_session.as_ref(), - abort_turn.as_ref(), - call.call_id.as_str(), - &call.tool_name, - abort_source, - ) - .await; - Ok(response) + if wait_for_runtime_cancellation { + if terminal_outcome_reached.swap(true, Ordering::AcqRel) { + return handle.await.map_err(Self::tool_task_join_error)?; + } + // The abort owns the terminal outcome; await only so + // the runtime can finish process teardown. + match handle.await { + Ok(_) => {} + Err(err) if err.is_cancelled() => {} + Err(err) => return Err(Self::tool_task_join_error(err)), + } + } else { + handle.abort(); + match handle.await { + Ok(result) => return result, + Err(err) if err.is_cancelled() => {} + Err(err) => return Err(Self::tool_task_join_error(err)), } - Err(err) => Err(Self::tool_task_join_error(err)), } + let response = Self::aborted_response(&call, secs); + notify_tool_aborted( + abort_session.as_ref(), + abort_turn.as_ref(), + call.call_id.as_str(), + &call.tool_name, + abort_source, + ) + .await; + Ok(response) } }, } @@ -274,6 +287,85 @@ mod tests { impl CoreToolRuntime for ImmediateHandler {} + struct CancellationCleanupHandler { + tool_name: codex_tools::ToolName, + started: std::sync::Mutex>>, + cleanup_started: std::sync::Mutex>>, + allow_cleanup: Arc, + } + + #[async_trait::async_trait] + impl ToolExecutor for CancellationCleanupHandler { + fn tool_name(&self) -> codex_tools::ToolName { + self.tool_name.clone() + } + + fn spec(&self) -> codex_tools::ToolSpec { + codex_tools::ToolSpec::Function(codex_tools::ResponsesApiTool { + name: self.tool_name.name.clone(), + description: "Cancellation cleanup test tool.".to_string(), + strict: false, + defer_loading: None, + parameters: codex_tools::JsonSchema::default(), + output_schema: None, + }) + } + + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { + let started = self + .started + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take(); + if let Some(started) = started { + let _ = started.send(()); + } + invocation.cancellation_token.cancelled().await; + let cleanup_started = self + .cleanup_started + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take(); + if let Some(cleanup_started) = cleanup_started { + let _ = cleanup_started.send(()); + } + self.allow_cleanup.notified().await; + Ok(Box::new(FunctionToolOutput::from_text( + "cleanup complete".to_string(), + Some(false), + ))) + } + } + + impl CoreToolRuntime for CancellationCleanupHandler { + fn waits_for_runtime_cancellation(&self) -> bool { + true + } + } + + struct FinishRecorder { + records: Arc>>, + } + + impl codex_extension_api::ToolLifecycleContributor for FinishRecorder { + fn on_tool_finish<'a>( + &'a self, + input: codex_extension_api::ToolFinishInput<'a>, + ) -> codex_extension_api::ToolLifecycleFuture<'a> { + let records = Arc::clone(&self.records); + let outcome = input.outcome; + Box::pin(async move { + records + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(outcome); + }) + } + } + struct BlockingFinishContributor { records: Arc>>, finish_started: std::sync::Mutex>>, @@ -375,4 +467,75 @@ mod tests { Ok(()) } + + #[tokio::test] + async fn cancellation_waiting_for_runtime_cleanup_emits_only_aborted_lifecycle() + -> anyhow::Result<()> { + let (mut session, turn_context) = crate::session::tests::make_session_and_context().await; + let records = Arc::new(std::sync::Mutex::new(Vec::new())); + let mut builder = + codex_extension_api::ExtensionRegistryBuilder::::new(); + builder.tool_lifecycle_contributor(Arc::new(FinishRecorder { + records: Arc::clone(&records), + })); + session.services.extensions = Arc::new(builder.build()); + + let session = Arc::new(session); + let turn_context = Arc::new(turn_context); + let tool_name = codex_tools::ToolName::plain("cleanup_tool"); + let (started_tx, started_rx) = oneshot::channel(); + let (cleanup_started_tx, cleanup_started_rx) = oneshot::channel(); + let allow_cleanup = Arc::new(Notify::new()); + let handler = Arc::new(CancellationCleanupHandler { + tool_name: tool_name.clone(), + started: std::sync::Mutex::new(Some(started_tx)), + cleanup_started: std::sync::Mutex::new(Some(cleanup_started_tx)), + allow_cleanup: Arc::clone(&allow_cleanup), + }) as Arc; + let router = Arc::new(ToolRouter::from_parts( + ToolRegistry::from_tools([handler]), + Vec::new(), + )); + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); + let runtime = ToolCallRuntime::new(router, session, turn_context, tracker); + let cancellation_token = CancellationToken::new(); + let call = ToolCall { + tool_name, + call_id: "call-1".to_string(), + payload: ToolPayload::Function { + arguments: "{}".to_string(), + }, + }; + + let response_task = + tokio::spawn(runtime.handle_tool_call(call, cancellation_token.clone())); + started_rx.await.expect("handler should start"); + cancellation_token.cancel(); + cleanup_started_rx + .await + .expect("handler should start cleanup"); + tokio::time::sleep(Duration::from_millis(10)).await; + allow_cleanup.notify_one(); + + let response = tokio::time::timeout(Duration::from_secs(1), response_task) + .await + .expect("timed out waiting for tool response") + .expect("tool response task should join")?; + let ResponseInputItem::FunctionCallOutput { output, .. } = response else { + anyhow::bail!("cancelled tool should return function output"); + }; + let FunctionCallOutputBody::Text(text) = output.body else { + anyhow::bail!("cancelled tool output should be text"); + }; + assert!(text.contains("aborted by user")); + + let actual = records + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .drain(..) + .collect::>(); + assert_eq!(vec![ToolCallOutcome::Aborted], actual); + + Ok(()) + } } diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index a08d5c78343..f0fdf424a9e 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -57,6 +57,12 @@ pub(crate) trait CoreToolRuntime: ToolExecutor { ) } + /// Whether cancellation should let the handler finish teardown before the + /// host returns an aborted tool response. + fn waits_for_runtime_cancellation(&self) -> bool { + false + } + fn telemetry_tags<'a>( &'a self, _invocation: &'a ToolInvocation, @@ -284,6 +290,10 @@ impl CoreToolRuntime for ExposureOverride { self.handler.matches_kind(payload) } + fn waits_for_runtime_cancellation(&self) -> bool { + self.handler.waits_for_runtime_cancellation() + } + fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option { self.handler.pre_tool_use_payload(invocation) } @@ -381,6 +391,11 @@ impl ToolRegistry { Some(tool.supports_parallel_tool_calls()) } + pub(crate) fn waits_for_runtime_cancellation(&self, name: &ToolName) -> Option { + let tool = self.tool(name)?; + Some(tool.waits_for_runtime_cancellation()) + } + #[allow(dead_code)] pub(crate) async fn dispatch_any( &self, @@ -497,10 +512,12 @@ impl ToolRegistry { PreToolUseHookResult::Blocked(message) => { let err = FunctionCallError::RespondToModel(message); dispatch_trace.record_failed(&err); - if let Some(terminal_outcome_reached) = &terminal_outcome_reached { - terminal_outcome_reached.store(true, Ordering::Release); - } - notify_tool_finish(&invocation, ToolCallOutcome::Blocked).await; + notify_tool_finish_if_unclaimed( + &invocation, + terminal_outcome_reached.as_deref(), + ToolCallOutcome::Blocked, + ) + .await; return Err(err); } PreToolUseHookResult::Continue { @@ -511,11 +528,9 @@ impl ToolRegistry { } Err(err) => { dispatch_trace.record_failed(&err); - if let Some(terminal_outcome_reached) = &terminal_outcome_reached { - terminal_outcome_reached.store(true, Ordering::Release); - } - notify_tool_finish( + notify_tool_finish_if_unclaimed( &invocation, + terminal_outcome_reached.as_deref(), ToolCallOutcome::Failed { handler_executed: false, }, @@ -638,18 +653,21 @@ impl ToolRegistry { handler_executed: true, }, }; - if let Some(terminal_outcome_reached) = &terminal_outcome_reached { - terminal_outcome_reached.store(true, Ordering::Release); - } - notify_tool_finish(&invocation, lifecycle_outcome).await; - - if let Err(err) = invocation - .session - .goal_runtime_apply(GoalRuntimeEvent::ToolCompleted { - turn_context: invocation.turn.as_ref(), - tool_name: tool_name.name.as_str(), - }) - .await + let finished = notify_tool_finish_if_unclaimed( + &invocation, + terminal_outcome_reached.as_deref(), + lifecycle_outcome, + ) + .await; + + if finished + && let Err(err) = invocation + .session + .goal_runtime_apply(GoalRuntimeEvent::ToolCompleted { + turn_context: invocation.turn.as_ref(), + tool_name: tool_name.name.as_str(), + }) + .await { warn!("failed to account thread goal progress after tool call: {err}"); } @@ -676,6 +694,19 @@ impl ToolRegistry { } } +async fn notify_tool_finish_if_unclaimed( + invocation: &ToolInvocation, + terminal_outcome_reached: Option<&AtomicBool>, + outcome: ToolCallOutcome, +) -> bool { + if terminal_outcome_reached.is_some_and(|reached| reached.swap(true, Ordering::AcqRel)) { + return false; + } + + notify_tool_finish(invocation, outcome).await; + true +} + async fn handle_any_tool( tool: &dyn CoreToolRuntime, invocation: ToolInvocation, diff --git a/codex-rs/core/src/tools/router.rs b/codex-rs/core/src/tools/router.rs index cfd47768904..a095499ea09 100644 --- a/codex-rs/core/src/tools/router.rs +++ b/codex-rs/core/src/tools/router.rs @@ -86,6 +86,12 @@ impl ToolRouter { .unwrap_or(false) } + pub fn tool_waits_for_runtime_cancellation(&self, call: &ToolCall) -> bool { + self.registry + .waits_for_runtime_cancellation(&call.tool_name) + .unwrap_or(false) + } + #[instrument(level = "trace", skip_all, err)] pub fn build_tool_call(item: ResponseItem) -> Result, FunctionCallError> { match item { diff --git a/codex-rs/core/src/tools/router_tests.rs b/codex-rs/core/src/tools/router_tests.rs index 9d685e53b6e..cece7fc1713 100644 --- a/codex-rs/core/src/tools/router_tests.rs +++ b/codex-rs/core/src/tools/router_tests.rs @@ -306,19 +306,13 @@ fn mcp_tool_info( callable_name: tool_name.to_string(), callable_namespace: callable_namespace.to_string(), namespace_description: None, - tool: rmcp::model::Tool { - name: tool_name.to_string().into(), - title: None, - description: Some("Test MCP tool".to_string().into()), - input_schema: Arc::new(rmcp::model::object(json!({ + tool: rmcp::model::Tool::new( + tool_name.to_string(), + "Test MCP tool", + Arc::new(rmcp::model::object(json!({ "type": "object", }))), - output_schema: None, - annotations: None, - execution: None, - icons: None, - meta: None, - }, + ), connector_id: None, connector_name: None, plugin_display_names: Vec::new(), @@ -338,7 +332,7 @@ async fn extension_tool_executors_are_model_visible_and_dispatchable() -> anyhow phase: None, }; session - .record_into_history(std::slice::from_ref(&history_item), &turn) + .record_conversation_items(&turn, std::slice::from_ref(&history_item)) .await; let router = ToolRouter::from_turn_context( diff --git a/codex-rs/core/src/tools/runtimes/mod.rs b/codex-rs/core/src/tools/runtimes/mod.rs index bba1c572ef4..45e1754da91 100644 --- a/codex-rs/core/src/tools/runtimes/mod.rs +++ b/codex-rs/core/src/tools/runtimes/mod.rs @@ -22,6 +22,8 @@ use codex_sandboxing::SandboxCommand; use codex_sandboxing::SandboxType; use codex_utils_absolute_path::AbsolutePathBuf; use std::collections::HashMap; +#[cfg(unix)] +use std::path::Path; pub(crate) mod apply_patch; pub(crate) mod shell; @@ -70,6 +72,44 @@ pub(crate) fn exec_env_for_sandbox_permissions( env } +#[cfg(unix)] +fn prepend_path_entry(env: &mut HashMap, path_entry: &str) -> String { + let updated_path = match env.get("PATH") { + Some(path) if !path.is_empty() => std::iter::once(path_entry) + .chain(path.split(':').filter(|entry| *entry != path_entry)) + .collect::>() + .join(":"), + _ => path_entry.to_string(), + }; + env.insert("PATH".to_string(), updated_path.clone()); + updated_path +} + +#[cfg(unix)] +pub(crate) fn prepend_zsh_fork_bin_to_path( + env: &mut HashMap, + shell_zsh_path: &Path, +) -> Option { + let zsh_bin_dir = shell_zsh_path + .parent() + .map(|path| path.to_string_lossy().to_string())?; + Some(prepend_path_entry(env, &zsh_bin_dir)) +} + +#[cfg(unix)] +pub(crate) fn apply_zsh_fork_path_prepend( + env: &mut HashMap, + explicit_env_overrides: &mut HashMap, + shell_zsh_path: &Path, +) { + let Some(updated_path) = prepend_zsh_fork_bin_to_path(env, shell_zsh_path) else { + return; + }; + // Snapshot wrapping restores explicit overrides after sourcing the shell + // snapshot, so capture this PATH override there as well. + explicit_env_overrides.insert("PATH".to_string(), updated_path); +} + pub(crate) fn disable_powershell_profile_for_elevated_windows_sandbox( command: &[String], shell_type: Option<&ShellType>, diff --git a/codex-rs/core/src/tools/runtimes/mod_tests.rs b/codex-rs/core/src/tools/runtimes/mod_tests.rs index fd10f222421..539bbb90deb 100644 --- a/codex-rs/core/src/tools/runtimes/mod_tests.rs +++ b/codex-rs/core/src/tools/runtimes/mod_tests.rs @@ -143,6 +143,48 @@ async fn explicit_escalation_prepares_exec_without_managed_network() -> anyhow:: Ok(()) } +#[cfg(unix)] +#[test] +fn apply_zsh_fork_path_prepend_uses_shell_parent() { + let mut env = HashMap::from([("PATH".to_string(), "/usr/bin:/bin".to_string())]); + let mut explicit_env_overrides = HashMap::new(); + + apply_zsh_fork_path_prepend( + &mut env, + &mut explicit_env_overrides, + PathBuf::from("/package/codex-resources/zsh/bin/zsh").as_path(), + ); + + let expected = "/package/codex-resources/zsh/bin:/usr/bin:/bin"; + assert_eq!(env.get("PATH").map(String::as_str), Some(expected)); + assert_eq!( + explicit_env_overrides.get("PATH").map(String::as_str), + Some(expected) + ); +} + +#[cfg(unix)] +#[test] +fn apply_zsh_fork_path_prepend_moves_existing_shell_parent_to_front() { + let mut env = HashMap::from([( + "PATH".to_string(), + "/usr/bin:/package/codex-resources/zsh/bin:/bin:/package/codex-resources/zsh/bin" + .to_string(), + )]); + let mut explicit_env_overrides = HashMap::new(); + + apply_zsh_fork_path_prepend( + &mut env, + &mut explicit_env_overrides, + PathBuf::from("/package/codex-resources/zsh/bin/zsh").as_path(), + ); + + assert_eq!( + env.get("PATH").map(String::as_str), + Some("/package/codex-resources/zsh/bin:/usr/bin:/bin") + ); +} + #[test] fn explicit_escalation_keeps_user_proxy_env_without_codex_marker() { let env = HashMap::from([ @@ -818,6 +860,57 @@ fn maybe_wrap_shell_lc_with_snapshot_applies_explicit_path_override() { assert_eq!(String::from_utf8_lossy(&output.stdout), "/worktree/bin"); } +#[cfg(unix)] +#[test] +fn maybe_wrap_shell_lc_with_snapshot_preserves_zsh_fork_path_prepend() { + let dir = tempdir().expect("create temp dir"); + let snapshot_path = dir.path().join("snapshot.sh"); + std::fs::write( + &snapshot_path, + "# Snapshot file\nexport PATH='/snapshot/bin'\n", + ) + .expect("write snapshot"); + let session_shell = shell_with_snapshot( + ShellType::Bash, + "/bin/bash", + snapshot_path.abs(), + dir.path().abs(), + ); + let command = vec![ + "/bin/bash".to_string(), + "-lc".to_string(), + "printf '%s' \"$PATH\"".to_string(), + ]; + let zsh_path = dir + .path() + .join("codex-resources") + .join("zsh") + .join("bin") + .join("zsh"); + let zsh_bin_dir = zsh_path.parent().expect("zsh path should have parent"); + let mut env = HashMap::from([("PATH".to_string(), "/worktree/bin".to_string())]); + let mut explicit_env_overrides = HashMap::new(); + apply_zsh_fork_path_prepend(&mut env, &mut explicit_env_overrides, zsh_path.as_path()); + let rewritten = maybe_wrap_shell_lc_with_snapshot( + &command, + &session_shell, + &dir.path().abs(), + &explicit_env_overrides, + &env, + ); + let output = Command::new(&rewritten[0]) + .args(&rewritten[1..]) + .env("PATH", env.get("PATH").expect("PATH should be set")) + .output() + .expect("run rewritten command"); + + assert!(output.status.success(), "command failed: {output:?}"); + assert_eq!( + String::from_utf8_lossy(&output.stdout), + format!("{}:/worktree/bin", zsh_bin_dir.display()) + ); +} + #[test] fn maybe_wrap_shell_lc_with_snapshot_does_not_embed_override_values_in_argv() { let dir = tempdir().expect("create temp dir"); diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index 62512677732..8b8fe0450ed 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -20,6 +20,8 @@ use crate::shell::ShellType; use crate::tools::flat_tool_name; use crate::tools::network_approval::NetworkApprovalMode; use crate::tools::network_approval::NetworkApprovalSpec; +#[cfg(unix)] +use crate::tools::runtimes::apply_zsh_fork_path_prepend; use crate::tools::runtimes::build_sandbox_command; use crate::tools::runtimes::disable_powershell_profile_for_elevated_windows_sandbox; use crate::tools::runtimes::exec_env_for_sandbox_permissions; @@ -44,6 +46,7 @@ use codex_shell_command::powershell::prefix_powershell_script_with_utf8; use codex_utils_absolute_path::AbsolutePathBuf; use futures::future::BoxFuture; use std::collections::HashMap; +use tokio_util::sync::CancellationToken; #[derive(Clone, Debug)] pub struct ShellRequest { @@ -52,6 +55,7 @@ pub struct ShellRequest { pub hook_command: String, pub cwd: AbsolutePathBuf, pub timeout_ms: Option, + pub cancellation_token: CancellationToken, pub env: HashMap, pub explicit_env_overrides: HashMap, pub network: Option, @@ -232,11 +236,23 @@ impl ToolRuntime for ShellRuntime { let managed_network = managed_network_for_sandbox_permissions(req.network.as_ref(), req.sandbox_permissions); let env = exec_env_for_sandbox_permissions(&req.env, req.sandbox_permissions); + let explicit_env_overrides = req.explicit_env_overrides.clone(); + #[cfg(unix)] + let (env, explicit_env_overrides) = { + let mut env = env; + let mut explicit_env_overrides = explicit_env_overrides; + if self.backend == ShellRuntimeBackend::ShellCommandZshFork + && let Some(shell_zsh_path) = ctx.session.services.shell_zsh_path.as_deref() + { + apply_zsh_fork_path_prepend(&mut env, &mut explicit_env_overrides, shell_zsh_path); + } + (env, explicit_env_overrides) + }; let command = maybe_wrap_shell_lc_with_snapshot( &req.command, session_shell.as_ref(), &req.cwd, - &req.explicit_env_overrides, + &explicit_env_overrides, &env, ); let command = disable_powershell_profile_for_elevated_windows_sandbox( @@ -265,6 +281,7 @@ impl ToolRuntime for ShellRuntime { let command = build_sandbox_command(&command, &req.cwd, &env, req.additional_permissions.clone())?; let mut expiration: crate::exec::ExecExpiration = req.timeout_ms.into(); + expiration = expiration.with_cancellation(req.cancellation_token.clone()); if let Some(cancellation) = attempt.network_denial_cancellation_token.clone() { expiration = expiration.with_cancellation(cancellation); } diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index fef8db5ca9e..998a1c02f03 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -16,6 +16,7 @@ use crate::sandboxing::SandboxPermissions; use crate::shell::ShellType; use crate::tools::runtimes::build_sandbox_command; use crate::tools::runtimes::exec_env_for_sandbox_permissions; +use crate::tools::runtimes::prepend_zsh_fork_bin_to_path; use crate::tools::sandboxing::PermissionRequestPayload; use crate::tools::sandboxing::SandboxAttempt; use crate::tools::sandboxing::ToolCtx; @@ -116,7 +117,8 @@ pub(super) async fn try_run_zsh_fork( return Ok(None); } - let env = exec_env_for_sandbox_permissions(&req.env, req.sandbox_permissions); + let mut env = exec_env_for_sandbox_permissions(&req.env, req.sandbox_permissions); + prepend_zsh_fork_bin_to_path(&mut env, shell_zsh_path); let command = build_sandbox_command(command, &req.cwd, &env, req.additional_permissions.clone())?; let options = ExecOptions { diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index c613f198e0e..01054a0369b 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -17,6 +17,8 @@ use crate::shell::ShellType; use crate::tools::flat_tool_name; use crate::tools::network_approval::NetworkApprovalMode; use crate::tools::network_approval::NetworkApprovalSpec; +#[cfg(unix)] +use crate::tools::runtimes::apply_zsh_fork_path_prepend; use crate::tools::runtimes::build_sandbox_command; use crate::tools::runtimes::disable_powershell_profile_for_elevated_windows_sandbox; use crate::tools::runtimes::exec_env_for_sandbox_permissions; @@ -261,6 +263,19 @@ impl<'a> ToolRuntime for UnifiedExecRunt if let Some(network) = managed_network { network.apply_to_env(&mut env); } + let explicit_env_overrides = req.explicit_env_overrides.clone(); + #[cfg(unix)] + let explicit_env_overrides = { + let mut explicit_env_overrides = explicit_env_overrides; + if let UnifiedExecShellMode::ZshFork(zsh_fork_config) = &self.shell_mode { + apply_zsh_fork_path_prepend( + &mut env, + &mut explicit_env_overrides, + zsh_fork_config.shell_zsh_path.as_path(), + ); + } + explicit_env_overrides + }; let environment_is_remote = req.environment.is_remote(); let command = if environment_is_remote { base_command.to_vec() @@ -269,7 +284,7 @@ impl<'a> ToolRuntime for UnifiedExecRunt base_command, session_shell.as_ref(), &req.cwd, - &req.explicit_env_overrides, + &explicit_env_overrides, &env, ) }; diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 2610bee95c8..bb7baf4bd85 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -85,6 +85,8 @@ use std::sync::Arc; use tracing::warn; const MULTI_AGENT_V2_NAMESPACE_DESCRIPTION: &str = "Tools for spawning and managing sub-agents."; +const IMAGE_GEN_NAMESPACE: &str = "image_gen"; +const IMAGEGEN_TOOL_NAME: &str = "imagegen"; type PlannedRuntime = Arc; @@ -257,7 +259,9 @@ fn hosted_model_tool_specs(context: &CoreToolPlanContext<'_>) -> Vec { }) { specs.push(web_search_tool); } - if image_generation_tool_enabled(turn_context) { + if image_generation_tool_enabled(turn_context) + && !standalone_image_generation_available(turn_context, context.extension_tool_executors) + { specs.push(create_image_generation_tool("png")); } specs @@ -316,21 +320,41 @@ fn agent_jobs_worker_tools_enabled(turn_context: &TurnContext) -> bool { } fn image_generation_tool_enabled(turn_context: &TurnContext) -> bool { + image_generation_runtime_enabled(turn_context) + && turn_context + .features + .get() + .enabled(Feature::ImageGeneration) +} + +fn image_generation_runtime_enabled(turn_context: &TurnContext) -> bool { turn_context .auth_manager .as_deref() .is_some_and(AuthManager::current_auth_uses_codex_backend) && turn_context.provider.capabilities().image_generation - && turn_context - .features - .get() - .enabled(Feature::ImageGeneration) && turn_context .model_info .input_modalities .contains(&InputModality::Image) } +fn standalone_image_generation_model_visible(turn_context: &TurnContext) -> bool { + image_generation_runtime_enabled(turn_context) + && turn_context.features.get().enabled(Feature::ImageGenExt) + && namespace_tools_enabled(turn_context) +} + +fn standalone_image_generation_available( + turn_context: &TurnContext, + extension_tools: &[Arc>], +) -> bool { + standalone_image_generation_model_visible(turn_context) + && extension_tools.iter().any(|executor| { + executor.tool_name() == ToolName::namespaced(IMAGE_GEN_NAMESPACE, IMAGEGEN_TOOL_NAME) + }) +} + fn wait_agent_timeout_options(turn_context: &TurnContext) -> WaitAgentTimeoutOptions { if multi_agent_v2_enabled(turn_context) { return WaitAgentTimeoutOptions { @@ -839,6 +863,11 @@ fn append_extension_tool_executors( for executor in executors.iter().cloned() { let tool_name = executor.tool_name(); + if tool_name == ToolName::namespaced(IMAGE_GEN_NAMESPACE, IMAGEGEN_TOOL_NAME) + && !standalone_image_generation_model_visible(turn_context) + { + continue; + } if !reserved_tool_names.insert(tool_name.clone()) { warn!("Skipping extension tool `{tool_name}`: tool already registered"); continue; diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index a2f12d1c016..a274f934c49 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -302,21 +302,15 @@ fn mcp_tool(server: &str, namespace: &str, name: &str) -> ToolInfo { callable_name: name.to_string(), callable_namespace: namespace.to_string(), namespace_description: Some(format!("Tools from {server}.")), - tool: rmcp::model::Tool { - name: name.to_string().into(), - title: None, - description: Some(format!("{name} test tool").into()), - input_schema: Arc::new(rmcp::model::object(json!({ + tool: rmcp::model::Tool::new( + name.to_string(), + format!("{name} test tool"), + Arc::new(rmcp::model::object(json!({ "type": "object", "properties": {}, "additionalProperties": false, }))), - output_schema: None, - annotations: None, - execution: None, - icons: None, - meta: None, - }, + ), connector_id: None, connector_name: None, plugin_display_names: Vec::new(), @@ -966,6 +960,16 @@ async fn hosted_tools_follow_provider_auth_model_and_config_gates() { .await; image_generation.assert_visible_contains(&["image_generation"]); + let extension_flag_without_imagegen_tool = probe(|turn| { + use_chatgpt_auth(turn); + set_feature(turn, Feature::ImageGeneration, /*enabled*/ true); + set_feature(turn, Feature::ImageGenExt, /*enabled*/ true); + turn.model_info.input_modalities = vec![InputModality::Image]; + }) + .await; + extension_flag_without_imagegen_tool.assert_visible_contains(&["image_generation"]); + extension_flag_without_imagegen_tool.assert_visible_lacks(&["image_gen"]); + let live_web_search = probe(|turn| { set_web_search_mode(turn, WebSearchMode::Live); turn.model_info.web_search_tool_type = WebSearchToolType::TextAndImage; diff --git a/codex-rs/core/tests/common/apps_test_server.rs b/codex-rs/core/tests/common/apps_test_server.rs index 2f2875867b7..7f7873e30b7 100644 --- a/codex-rs/core/tests/common/apps_test_server.rs +++ b/codex-rs/core/tests/common/apps_test_server.rs @@ -27,12 +27,15 @@ const SERVER_NAME: &str = "codex-apps-test"; const SERVER_VERSION: &str = "1.0.0"; const SEARCHABLE_TOOL_COUNT: usize = 100; const CALENDAR_CREATE_EVENT_TOOL_NAME: &str = "calendar_create_event"; +const CALENDAR_APP_ONLY_TOOL_NAME: &str = "calendar_app_only_action"; pub const CALENDAR_EXTRACT_TEXT_TOOL_NAME: &str = "calendar_extract_text"; const CALENDAR_LIST_EVENTS_TOOL_NAME: &str = "calendar_list_events"; pub const DIRECT_CALENDAR_CREATE_EVENT_TOOL: &str = "mcp__codex_apps__calendar__create_event"; +pub const DIRECT_CALENDAR_APP_ONLY_TOOL: &str = "mcp__codex_apps__calendar__app_only_action"; pub const DIRECT_CALENDAR_LIST_EVENTS_TOOL: &str = "mcp__codex_apps__calendar__list_events"; pub const DIRECT_CALENDAR_EXTRACT_TEXT_TOOL: &str = "mcp__codex_apps__calendar__extract_text"; pub const SEARCH_CALENDAR_NAMESPACE: &str = "mcp__codex_apps__calendar"; +pub const SEARCH_CALENDAR_APP_ONLY_TOOL: &str = "_app_only_action"; pub const SEARCH_CALENDAR_CREATE_TOOL: &str = "_create_event"; pub const SEARCH_CALENDAR_EXTRACT_TEXT_TOOL: &str = "_extract_text"; pub const SEARCH_CALENDAR_LIST_TOOL: &str = "_list_events"; @@ -49,6 +52,12 @@ pub struct AppsTestServer { pub chatgpt_base_url: String, } +#[derive(Clone, Copy)] +pub enum AppsTestToolLoading { + Direct, + Searchable, +} + impl AppsTestServer { pub async fn mount(server: &MockServer) -> Result { Self::mount_with_connector_name(server, CONNECTOR_NAME).await @@ -62,6 +71,7 @@ impl AppsTestServer { CONNECTOR_NAME.to_string(), CONNECTOR_DESCRIPTION.to_string(), /*searchable*/ true, + /*include_app_only_tool*/ false, ) .await; Ok(Self { @@ -80,6 +90,26 @@ impl AppsTestServer { connector_name.to_string(), CONNECTOR_DESCRIPTION.to_string(), /*searchable*/ false, + /*include_app_only_tool*/ false, + ) + .await; + Ok(Self { + chatgpt_base_url: server.uri(), + }) + } + + pub async fn mount_with_app_only_tool( + server: &MockServer, + tool_loading: AppsTestToolLoading, + ) -> Result { + mount_oauth_metadata(server).await; + mount_connectors_directory(server).await; + mount_streamable_http_json_rpc( + server, + CONNECTOR_NAME.to_string(), + CONNECTOR_DESCRIPTION.to_string(), + matches!(tool_loading, AppsTestToolLoading::Searchable), + /*include_app_only_tool*/ true, ) .await; Ok(Self { @@ -136,7 +166,7 @@ fn apps_tool_call_id(body: &Value) -> Option<&str> { .as_str() } -async fn recorded_apps_tool_calls(server: &MockServer) -> Vec { +pub async fn recorded_apps_tool_calls(server: &MockServer) -> Vec { server .received_requests() .await @@ -233,6 +263,7 @@ async fn mount_streamable_http_json_rpc( connector_name: String, connector_description: String, searchable: bool, + include_app_only_tool: bool, ) { Mock::given(method("POST")) .and(path_regex("^/api/codex/apps/?$")) @@ -240,6 +271,7 @@ async fn mount_streamable_http_json_rpc( connector_name, connector_description, searchable, + include_app_only_tool, }) .mount(server) .await; @@ -249,6 +281,7 @@ struct CodexAppsJsonRpcResponder { connector_name: String, connector_description: String, searchable: bool, + include_app_only_tool: bool, } impl Respond for CodexAppsJsonRpcResponder { @@ -419,6 +452,29 @@ impl Respond for CodexAppsJsonRpcResponder { })); } } + if self.include_app_only_tool + && let Some(tools) = response + .pointer_mut("/result/tools") + .and_then(Value::as_array_mut) + { + tools.push(json!({ + "name": CALENDAR_APP_ONLY_TOOL_NAME, + "description": "Open a calendar app-only action.", + "inputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "_meta": { + "connector_id": CONNECTOR_ID, + "connector_name": self.connector_name.clone(), + "connector_description": self.connector_description.clone(), + "ui": { + "visibility": ["app"] + } + } + })); + } ResponseTemplate::new(200).set_body_json(response) } "tools/call" => { diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index e16a4a14a20..2a5f8fc43f3 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -46,6 +46,8 @@ use serde_json::Value; use serde_json::json; use std::env; use std::fs; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -2528,6 +2530,166 @@ async fn spawned_subagent_execpolicy_amendment_propagates_to_parent_session() -> Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[cfg(unix)] +async fn env_zsh_script_spawned_by_python_can_request_escalation_under_zsh_fork() -> Result<()> { + skip_if_no_network!(Ok(())); + + let Some(runtime) = zsh_fork_runtime("zsh-fork env zsh nested escalation test")? else { + return Ok(()); + }; + + let approval_policy = AskForApproval::OnRequest; + let permission_profile = restrictive_workspace_write_profile(); + let outside_dir = tempfile::tempdir_in(std::env::current_dir()?)?; + let outside_path = outside_dir.path().join("zsh-fork-env-zsh-escalated.txt"); + let outside_path_arg = shlex::try_join([outside_path.to_string_lossy().as_ref()])?; + let rules = r#"prefix_rule(pattern=["touch"], decision="prompt")"#.to_string(); + + let server = start_mock_server().await; + let outside_path_for_hook = outside_path.clone(); + let test = build_zsh_fork_test( + &server, + runtime, + approval_policy, + permission_profile.clone(), + move |home| { + let _ = fs::remove_file(&outside_path_for_hook); + let rules_dir = home.join("rules"); + fs::create_dir_all(&rules_dir).unwrap(); + fs::write(rules_dir.join("default.rules"), &rules).unwrap(); + }, + ) + .await?; + + let script_path = test.cwd.path().join("runs-under-env-zsh"); + fs::write( + &script_path, + format!( + "#!/usr/bin/env zsh\ntouch {outside_path_arg}\nprint -r -- nested-env-zsh-complete\n" + ), + )?; + let mut script_permissions = fs::metadata(&script_path)?.permissions(); + script_permissions.set_mode(0o755); + fs::set_permissions(&script_path, script_permissions)?; + + let script_literal = serde_json::to_string(script_path.to_string_lossy().as_ref())?; + let python_script = format!( + "import subprocess; subprocess.run([{script_literal}], check=True, close_fds=False)" + ); + let command = shlex::try_join(["python3", "-c", python_script.as_str()])?; + + let call_id = "zsh-fork-env-zsh-nested-escalation"; + let event = shell_event( + call_id, + &command, + /*timeout_ms*/ 30_000, + SandboxPermissions::UseDefault, + )?; + let _ = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-zsh-fork-env-zsh-1"), + event, + ev_completed("resp-zsh-fork-env-zsh-1"), + ]), + ) + .await; + let results = mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-zsh-fork-env-zsh-1", "done"), + ev_completed("resp-zsh-fork-env-zsh-2"), + ]), + ) + .await; + + let session_model = test.session_configured.model.clone(); + let (sandbox_policy, permission_profile) = + turn_permission_fields(permission_profile, test.cwd.path()); + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "run nested env zsh script through python".into(), + text_elements: Vec::new(), + }], + environments: None, + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(test.cwd.path().to_path_buf()), + approval_policy: Some(approval_policy), + approvals_reviewer: Some(ApprovalsReviewer::User), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: session_model, + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, + }) + .await?; + + let approval_event = wait_for_event_with_timeout( + &test.codex, + |event| { + matches!( + event, + EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_) + ) + }, + Duration::from_secs(10), + ) + .await; + let EventMsg::ExecApprovalRequest(approval) = approval_event else { + panic!("expected nested zsh script to request approval before completion"); + }; + assert!( + approval.command.iter().any(|arg| arg.ends_with("/touch")) + && approval + .command + .iter() + .any(|arg| arg == outside_path.to_string_lossy().as_ref()), + "expected approval for nested touch command, got: {:?}", + approval.command + ); + + test.codex + .submit(Op::ExecApproval { + id: approval.effective_approval_id(), + turn_id: None, + decision: ReviewDecision::Approved, + }) + .await?; + + wait_for_completion(&test).await; + + let result = parse_result(&results.single_request().function_call_output(call_id)); + assert_eq!( + result.exit_code.unwrap_or(0), + 0, + "nested env zsh script should complete successfully: {}", + result.stdout + ); + assert!( + result.stdout.contains("nested-env-zsh-complete"), + "nested script did not report completion: {}", + result.stdout + ); + assert!( + outside_path.exists(), + "approved nested touch should create the out-of-workspace file" + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[cfg(unix)] async fn matched_prefix_rule_runs_unsandboxed_under_zsh_fork() -> Result<()> { diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 90daf219632..cbf6993bc9d 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -18,6 +18,10 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use codex_protocol::user_input::UserInput; use core_test_support::apps_test_server::AppsTestServer; +use core_test_support::apps_test_server::AppsTestToolLoading; +use core_test_support::apps_test_server::DIRECT_CALENDAR_APP_ONLY_TOOL; +use core_test_support::apps_test_server::recorded_apps_tool_calls; +use core_test_support::apps_test_server::search_capable_apps_builder; use core_test_support::assert_regex_match; use core_test_support::responses; use core_test_support::responses::ResponseMock; @@ -495,6 +499,92 @@ if (!tool) { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn app_only_tools_are_not_visible_or_runnable_by_code_mode_model() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let apps_server = + AppsTestServer::mount_with_app_only_tool(&server, AppsTestToolLoading::Searchable).await?; + let code = format!( + r#" +const visibleTool = ALL_TOOLS.find(({{ name }}) => name === {visible_tool_name:?}); +const tool = ALL_TOOLS.find(({{ name }}) => name === {tool_name:?}); +let error = null; +try {{ + await tools[{tool_name:?}]({{}}); +}} catch (caught) {{ + error = String(caught); +}} +text(JSON.stringify({{ + visibleListed: visibleTool !== undefined, + listed: tool !== undefined, + callable: typeof tools[{tool_name:?}] === "function", + error, +}})); +"#, + visible_tool_name = "mcp__codex_apps__calendar_timezone_option_99", + tool_name = DIRECT_CALENDAR_APP_ONLY_TOOL, + ); + + responses::mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_custom_tool_call("call-1", "exec", &code), + ev_completed("resp-1"), + ]), + ) + .await; + let second_mock = responses::mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ) + .await; + + let mut builder = search_capable_apps_builder(apps_server.chatgpt_base_url.clone()) + .with_config(|config| { + config + .features + .enable(Feature::CodeMode) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::CodeModeOnly) + .expect("test config should allow feature update"); + }); + let test = builder.build(&server).await?; + test.submit_turn("try to call the app-only calendar tool through exec") + .await?; + + let req = second_mock.single_request(); + let (output, success) = custom_tool_output_body_and_success(&req, "call-1"); + assert_ne!( + success, + Some(false), + "code mode visibility check should complete successfully: {output}" + ); + let parsed: Value = serde_json::from_str(&output)?; + assert_eq!(parsed["visibleListed"], true); + assert_eq!(parsed["listed"], false); + assert_eq!(parsed["callable"], false); + assert!( + parsed["error"] + .as_str() + .is_some_and(|error| error.contains("is not a function")), + "app-only code mode call should fail before MCP dispatch: {parsed:?}" + ); + assert!( + recorded_apps_tool_calls(&server).await.is_empty(), + "app-only code mode call should not reach the MCP server" + ); + + Ok(()) +} + #[cfg_attr(windows, ignore = "no exec_command on Windows")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn code_mode_only_can_call_nested_tools() -> Result<()> { diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index e9ffeb62ef7..c548ded95c2 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -17,16 +17,20 @@ use codex_protocol::protocol::McpInvocation; use codex_protocol::protocol::Op; use codex_protocol::user_input::UserInput; use core_test_support::apps_test_server::AppsTestServer; +use core_test_support::apps_test_server::AppsTestToolLoading; use core_test_support::apps_test_server::CALENDAR_CREATE_EVENT_MCP_APP_RESOURCE_URI; use core_test_support::apps_test_server::CALENDAR_CREATE_EVENT_RESOURCE_URI; use core_test_support::apps_test_server::DIRECT_CALENDAR_CREATE_EVENT_TOOL as CALENDAR_CREATE_TOOL; use core_test_support::apps_test_server::DIRECT_CALENDAR_LIST_EVENTS_TOOL as CALENDAR_LIST_TOOL; +use core_test_support::apps_test_server::SEARCH_CALENDAR_APP_ONLY_TOOL; use core_test_support::apps_test_server::SEARCH_CALENDAR_CREATE_TOOL; use core_test_support::apps_test_server::SEARCH_CALENDAR_LIST_TOOL; use core_test_support::apps_test_server::SEARCH_CALENDAR_NAMESPACE; +use core_test_support::apps_test_server::apps_enabled_builder; use core_test_support::apps_test_server::configure_search_capable_apps; use core_test_support::apps_test_server::configure_search_capable_model; use core_test_support::apps_test_server::recorded_apps_tool_call_by_call_id; +use core_test_support::apps_test_server::recorded_apps_tool_calls; use core_test_support::apps_test_server::search_capable_apps_builder as configured_builder; use core_test_support::responses::ResponsesRequest; use core_test_support::responses::ev_assistant_message; @@ -216,6 +220,80 @@ async fn always_defer_feature_hides_small_app_tool_sets() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn app_only_tools_are_not_visible_or_runnable_by_direct_model_calls() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let apps_server = + AppsTestServer::mount_with_app_only_tool(&server, AppsTestToolLoading::Direct).await?; + let call_id = "app-only-direct-call"; + let mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call_with_namespace( + call_id, + SEARCH_CALENDAR_NAMESPACE, + SEARCH_CALENDAR_APP_ONLY_TOOL, + "{}", + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + + let mut builder = apps_enabled_builder(apps_server.chatgpt_base_url.clone()); + let test = builder.build(&server).await?; + test.submit_turn_with_approval_and_permission_profile( + "Try to call the app-only calendar tool.", + AskForApproval::Never, + PermissionProfile::Disabled, + ) + .await?; + + let requests = mock.requests(); + assert!( + namespace_child_tool( + &requests[0].body_json(), + SEARCH_CALENDAR_NAMESPACE, + SEARCH_CALENDAR_CREATE_TOOL + ) + .is_some(), + "visible tool from the app-only tool's connector should be declared" + ); + assert!( + namespace_child_tool( + &requests[0].body_json(), + SEARCH_CALENDAR_NAMESPACE, + SEARCH_CALENDAR_APP_ONLY_TOOL + ) + .is_none(), + "app-only tool should not be declared to a direct model" + ); + assert!( + requests[1] + .function_call_output(call_id) + .get("output") + .and_then(Value::as_str) + .is_some_and(|output| output.contains("unsupported call")), + "forced app-only direct call should not dispatch" + ); + assert!( + recorded_apps_tool_calls(&server).await.is_empty(), + "forced app-only direct call should not reach the MCP server" + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn app_search_sources_are_hidden_for_api_key_auth() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index 99b7fb0ec61..8cbc46e1b59 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -93,6 +93,104 @@ async fn new_thread_is_recorded_in_state_db() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn resume_restores_dynamic_tools_from_rollout_with_sqlite_enabled() -> Result<()> { + let server = start_mock_server().await; + let mock = mount_sse_sequence( + &server, + vec![ + responses::sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), + responses::sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]), + ], + ) + .await; + + let dynamic_tool = DynamicToolSpec { + namespace: None, + name: "resume_lookup".to_string(), + description: "Look up a value after resume.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { "query": { "type": "string" } }, + "required": ["query"], + "additionalProperties": false, + }), + defer_loading: false, + }; + let mut builder = test_codex().with_config(|config| { + config + .features + .enable(Feature::Sqlite) + .expect("test config should allow feature update"); + }); + let base_test = builder.build(&server).await?; + let started = base_test + .thread_manager + .start_thread_with_tools( + base_test.config.clone(), + vec![dynamic_tool.clone()], + /*persist_extended_history*/ false, + ) + .await?; + let rollout_path = started + .session_configured + .rollout_path + .clone() + .expect("rollout path"); + + started + .thread + .submit(Op::UserInput { + environments: None, + items: vec![UserInput::Text { + text: "persist this thread".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: Default::default(), + }) + .await?; + wait_for_event(&started.thread, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + let mut resume_builder = test_codex().with_config(|config| { + config + .features + .enable(Feature::Sqlite) + .expect("test config should allow feature update"); + }); + let resumed = resume_builder + .resume(&server, base_test.home.clone(), rollout_path) + .await?; + resumed.submit_turn("use the restored tool").await?; + + let requests = mock.requests(); + assert_eq!(requests.len(), 2); + let resumed_body = requests[1].body_json(); + let tools = resumed_body + .get("tools") + .and_then(serde_json::Value::as_array) + .expect("resumed request tools"); + let restored_tool = tools + .iter() + .find(|tool| tool.get("name") == Some(&json!(dynamic_tool.name.as_str()))) + .expect("dynamic tool should be restored from rollout metadata"); + assert_eq!( + restored_tool.get("description"), + Some(&json!(dynamic_tool.description.as_str())) + ); + assert_eq!( + restored_tool.get("parameters"), + Some(&dynamic_tool.input_schema) + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn backfill_scans_existing_rollouts() -> Result<()> { let server = start_mock_server().await; @@ -102,32 +200,6 @@ async fn backfill_scans_existing_rollouts() -> Result<()> { let rollout_rel_path = format!("sessions/2026/01/27/rollout-2026-01-27T12-00-00-{uuid}.jsonl"); let rollout_rel_path_for_hook = rollout_rel_path.clone(); - let dynamic_tools = vec![ - DynamicToolSpec { - namespace: Some("codex_app".to_string()), - name: "geo_lookup".to_string(), - description: "lookup a city".to_string(), - input_schema: json!({ - "type": "object", - "required": ["city"], - "properties": { "city": { "type": "string" } } - }), - defer_loading: true, - }, - DynamicToolSpec { - namespace: None, - name: "weather_lookup".to_string(), - description: "lookup weather".to_string(), - input_schema: json!({ - "type": "object", - "required": ["zip"], - "properties": { "zip": { "type": "string" } } - }), - defer_loading: false, - }, - ]; - let dynamic_tools_for_hook = dynamic_tools.clone(); - let mut builder = test_codex() .with_pre_build_hook(move |codex_home| { let rollout_path = codex_home.join(&rollout_rel_path_for_hook); @@ -150,7 +222,7 @@ async fn backfill_scans_existing_rollouts() -> Result<()> { agent_role: None, model_provider: None, base_instructions: None, - dynamic_tools: Some(dynamic_tools_for_hook), + dynamic_tools: None, memory_mode: None, }, git: None, @@ -217,17 +289,6 @@ async fn backfill_scans_existing_rollouts() -> Result<()> { assert_eq!(metadata.model_provider, default_provider); assert!(metadata.first_user_message.is_some()); - let mut stored_tools = None; - for _ in 0..40 { - stored_tools = db.get_dynamic_tools(thread_id).await?; - if stored_tools.is_some() { - break; - } - tokio::time::sleep(Duration::from_millis(25)).await; - } - let stored_tools = stored_tools.expect("dynamic tools should be stored"); - assert_eq!(stored_tools, dynamic_tools); - Ok(()) } diff --git a/codex-rs/exec-server/README.md b/codex-rs/exec-server/README.md index 468ac205f72..8fa1a9eb75c 100644 --- a/codex-rs/exec-server/README.md +++ b/codex-rs/exec-server/README.md @@ -32,6 +32,16 @@ Agent Identity JWT in `CODEX_ACCESS_TOKEN` can opt into that auth path with `--use-agent-identity-auth`; Codex then registers an Agent task and sends the derived AgentAssertion headers on the registry request. +Alternatively, API users can instead use `CODEX_API_KEY`; +Codex sends it as a bearer token on the registration request. For example: + +```sh +CODEX_API_KEY="$OPENAI_API_KEY" \ +codex exec-server \ + --remote ... \ + --environment-id "$ENVIRONMENT_ID" +``` + Wire framing: - local websocket: one JSON-RPC message per websocket frame diff --git a/codex-rs/exec-server/src/remote.rs b/codex-rs/exec-server/src/remote.rs index de09d94e7c6..ae7242377f4 100644 --- a/codex-rs/exec-server/src/remote.rs +++ b/codex-rs/exec-server/src/remote.rs @@ -38,7 +38,9 @@ impl EnvironmentRegistryClient { Ok(Self { base_url, auth_provider, - http: reqwest::Client::new(), + http: reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build()?, }) } @@ -312,6 +314,41 @@ mod tests { ); } + #[tokio::test] + async fn register_environment_does_not_follow_redirects_with_auth_headers() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/cloud/environment/environment-requested/register")) + .and(header("authorization", "Bearer registry-token")) + .respond_with( + ResponseTemplate::new(302) + .insert_header("location", format!("{}/redirect-target", server.uri())), + ) + .mount(&server) + .await; + Mock::given(path("/redirect-target")) + .and(header("authorization", "Bearer registry-token")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&server) + .await; + let client = EnvironmentRegistryClient::new(server.uri(), static_registry_auth_provider()) + .expect("client"); + + let error = client + .register_environment("environment-requested") + .await + .expect_err("redirect response should not be followed"); + + assert!(matches!( + error, + ExecServerError::EnvironmentRegistryHttp { + status: StatusCode::FOUND, + .. + } + )); + } + #[test] fn debug_output_redacts_auth_provider() { let config = RemoteEnvironmentConfig::new( diff --git a/codex-rs/ext/extension-api/src/contributors.rs b/codex-rs/ext/extension-api/src/contributors.rs index fda295b7f78..71007e38175 100644 --- a/codex-rs/ext/extension-api/src/contributors.rs +++ b/codex-rs/ext/extension-api/src/contributors.rs @@ -26,6 +26,7 @@ pub use tool_lifecycle::ToolFinishInput; pub use tool_lifecycle::ToolLifecycleFuture; pub use tool_lifecycle::ToolStartInput; pub use turn_lifecycle::TurnAbortInput; +pub use turn_lifecycle::TurnErrorInput; pub use turn_lifecycle::TurnStartInput; pub use turn_lifecycle::TurnStopInput; @@ -79,6 +80,9 @@ pub trait TurnLifecycleContributor: Send + Sync { /// Called after the host aborts a running turn. async fn on_turn_abort(&self, _input: TurnAbortInput<'_>) {} + + /// Called when the host observes an error for a running turn. + async fn on_turn_error(&self, _input: TurnErrorInput<'_>) {} } /// Contributor for host-owned configuration changes. diff --git a/codex-rs/ext/extension-api/src/contributors/thread_lifecycle.rs b/codex-rs/ext/extension-api/src/contributors/thread_lifecycle.rs index 07c7e7fa81c..5fbd1562d21 100644 --- a/codex-rs/ext/extension-api/src/contributors/thread_lifecycle.rs +++ b/codex-rs/ext/extension-api/src/contributors/thread_lifecycle.rs @@ -1,9 +1,14 @@ use crate::ExtensionData; +use codex_protocol::protocol::SessionSource; /// Input supplied when the host starts a runtime for a thread. pub struct ThreadStartInput<'a, C> { /// Host configuration visible at thread start. pub config: &'a C, + /// Source that created the session for this thread. + pub session_source: &'a SessionSource, + /// Whether persistent thread-scoped state is available for this thread. + pub persistent_thread_state_available: bool, /// Store scoped to the host session runtime. pub session_store: &'a ExtensionData, /// Store scoped to this thread runtime. diff --git a/codex-rs/ext/extension-api/src/contributors/turn_lifecycle.rs b/codex-rs/ext/extension-api/src/contributors/turn_lifecycle.rs index bbd3ae8f393..e2c9e4bf7bc 100644 --- a/codex-rs/ext/extension-api/src/contributors/turn_lifecycle.rs +++ b/codex-rs/ext/extension-api/src/contributors/turn_lifecycle.rs @@ -1,4 +1,5 @@ use codex_protocol::config_types::CollaborationMode; +use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TurnAbortReason; @@ -41,3 +42,17 @@ pub struct TurnAbortInput<'a> { /// Store scoped to this turn runtime. pub turn_store: &'a ExtensionData, } + +/// Input supplied when the host observes an error for a turn. +pub struct TurnErrorInput<'a> { + /// Stable host-owned turn identifier. + pub turn_id: &'a str, + /// Error surfaced by the host for this turn. + pub error: CodexErrorInfo, + /// Store scoped to the host session runtime. + pub session_store: &'a ExtensionData, + /// Store scoped to this thread runtime. + pub thread_store: &'a ExtensionData, + /// Store scoped to this turn runtime. + pub turn_store: &'a ExtensionData, +} diff --git a/codex-rs/ext/extension-api/src/lib.rs b/codex-rs/ext/extension-api/src/lib.rs index 80ca67e2905..4705581c8a7 100644 --- a/codex-rs/ext/extension-api/src/lib.rs +++ b/codex-rs/ext/extension-api/src/lib.rs @@ -11,8 +11,10 @@ pub use capabilities::NoopResponseItemInjector; pub use capabilities::ResponseItemInjectionFuture; pub use capabilities::ResponseItemInjector; pub use codex_tools::ConversationHistory; +pub use codex_tools::ExtensionTurnItem; pub use codex_tools::FunctionCallError; pub use codex_tools::JsonToolOutput; +pub use codex_tools::NoopTurnItemEmitter; pub use codex_tools::ResponsesApiTool; pub use codex_tools::ToolCall; pub use codex_tools::ToolExecutor; @@ -20,6 +22,8 @@ pub use codex_tools::ToolName; pub use codex_tools::ToolOutput; pub use codex_tools::ToolPayload; pub use codex_tools::ToolSpec; +pub use codex_tools::TurnItemEmissionFuture; +pub use codex_tools::TurnItemEmitter; pub use codex_tools::parse_tool_input_schema; pub use codex_tools::parse_tool_input_schema_without_compaction; pub use contributors::ApprovalReviewContributor; @@ -41,6 +45,7 @@ pub use contributors::ToolLifecycleContributor; pub use contributors::ToolLifecycleFuture; pub use contributors::ToolStartInput; pub use contributors::TurnAbortInput; +pub use contributors::TurnErrorInput; pub use contributors::TurnItemContributor; pub use contributors::TurnLifecycleContributor; pub use contributors::TurnStartInput; diff --git a/codex-rs/ext/goal/src/extension.rs b/codex-rs/ext/goal/src/extension.rs index 68068acbb4e..5421d17290e 100644 --- a/codex-rs/ext/goal/src/extension.rs +++ b/codex-rs/ext/goal/src/extension.rs @@ -22,6 +22,8 @@ use codex_extension_api::TurnStartInput; use codex_extension_api::TurnStopInput; use codex_otel::MetricsClient; use codex_protocol::ThreadId; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::ThreadGoalStatus; use codex_protocol::protocol::TokenUsageInfo; @@ -29,6 +31,7 @@ use crate::accounting::BudgetLimitedGoalDisposition; use crate::accounting::GoalAccountingState; use crate::events::GoalEventEmitter; use crate::metrics::GoalMetrics; +use crate::runtime::GoalRuntimeConfig; use crate::runtime::GoalRuntimeHandle; use crate::spec::UPDATE_GOAL_TOOL_NAME; use crate::steering::budget_limit_steering_item; @@ -85,6 +88,11 @@ where { async fn on_thread_start(&self, input: ThreadStartInput<'_, C>) { let enabled = (self.goals_enabled)(input.config); + let tools_available_for_thread = input.persistent_thread_state_available + && !matches!( + input.session_source, + SessionSource::SubAgent(SubAgentSource::Review) + ); input .thread_store .insert(GoalExtensionConfig::from_enabled(enabled)); @@ -102,7 +110,10 @@ where self.metrics.clone(), self.thread_manager.clone(), accounting_state, - enabled, + GoalRuntimeConfig { + enabled, + tools_available_for_thread, + }, ) }); runtime.set_enabled(enabled); @@ -330,7 +341,7 @@ where let Some(runtime) = goal_runtime_handle(thread_store) else { return Vec::new(); }; - if !runtime.is_enabled() { + if !runtime.tools_visible() { return Vec::new(); } diff --git a/codex-rs/ext/goal/src/runtime.rs b/codex-rs/ext/goal/src/runtime.rs index 16a4ff6a2f7..d78eda3de5f 100644 --- a/codex-rs/ext/goal/src/runtime.rs +++ b/codex-rs/ext/goal/src/runtime.rs @@ -20,6 +20,11 @@ pub struct GoalRuntimeHandle { inner: Arc, } +pub(crate) struct GoalRuntimeConfig { + pub(crate) enabled: bool, + pub(crate) tools_available_for_thread: bool, +} + struct GoalRuntimeInner { thread_id: ThreadId, state_dbs: Arc, @@ -28,6 +33,7 @@ struct GoalRuntimeInner { thread_manager: Weak, accounting_state: Arc, enabled: AtomicBool, + tools_available_for_thread: bool, } pub(crate) struct AccountedGoalProgress { @@ -66,7 +72,7 @@ impl GoalRuntimeHandle { metrics: GoalMetrics, thread_manager: Weak, accounting_state: Arc, - enabled: bool, + config: GoalRuntimeConfig, ) -> Self { Self { inner: Arc::new(GoalRuntimeInner { @@ -76,7 +82,8 @@ impl GoalRuntimeHandle { metrics, thread_manager, accounting_state, - enabled: AtomicBool::new(enabled), + enabled: AtomicBool::new(config.enabled), + tools_available_for_thread: config.tools_available_for_thread, }), } } @@ -89,6 +96,10 @@ impl GoalRuntimeHandle { self.inner.enabled.load(Ordering::Relaxed) } + pub(crate) fn tools_visible(&self) -> bool { + self.is_enabled() && self.inner.tools_available_for_thread + } + pub(crate) fn thread_id(&self) -> ThreadId { self.inner.thread_id } diff --git a/codex-rs/ext/goal/tests/goal_extension_backend.rs b/codex-rs/ext/goal/tests/goal_extension_backend.rs index 584f7362b72..7524d40fe1e 100644 --- a/codex-rs/ext/goal/tests/goal_extension_backend.rs +++ b/codex-rs/ext/goal/tests/goal_extension_backend.rs @@ -8,6 +8,7 @@ use codex_extension_api::ExtensionData; use codex_extension_api::ExtensionEventSink; use codex_extension_api::ExtensionRegistryBuilder; use codex_extension_api::FunctionCallError; +use codex_extension_api::NoopTurnItemEmitter; use codex_extension_api::ThreadResumeInput; use codex_extension_api::ThreadStartInput; use codex_extension_api::ToolCall; @@ -28,6 +29,7 @@ use codex_protocol::config_types::Settings; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::ThreadGoalStatus; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; @@ -83,6 +85,38 @@ async fn installed_goal_tools_create_goal_and_fill_empty_preview() -> anyhow::Re Ok(()) } +#[tokio::test] +async fn goal_tools_hidden_for_ephemeral_threads() -> anyhow::Result<()> { + let runtime = test_runtime().await?; + let thread_id = test_thread_id()?; + let tools = installed_tools_with_start( + runtime, + thread_id, + SessionSource::Cli, + /*persistent_thread_state_available*/ false, + ) + .await; + + assert_eq!(Vec::::new(), tool_names(&tools)); + Ok(()) +} + +#[tokio::test] +async fn goal_tools_hidden_for_review_subagents() -> anyhow::Result<()> { + let runtime = test_runtime().await?; + let thread_id = test_thread_id()?; + let tools = installed_tools_with_start( + runtime, + thread_id, + SessionSource::SubAgent(SubAgentSource::Review), + /*persistent_thread_state_available*/ true, + ) + .await; + + assert_eq!(Vec::::new(), tool_names(&tools)); + Ok(()) +} + #[tokio::test] async fn installed_goal_tools_reject_duplicate_goal_creation() -> anyhow::Result<()> { let runtime = test_runtime().await?; @@ -877,6 +911,21 @@ async fn thread_resume_rehydrates_active_goal_idle_accounting() -> anyhow::Resul async fn installed_tools( runtime: Arc, thread_id: ThreadId, +) -> Vec>> { + installed_tools_with_start( + runtime, + thread_id, + SessionSource::Cli, + /*persistent_thread_state_available*/ true, + ) + .await +} + +async fn installed_tools_with_start( + runtime: Arc, + thread_id: ThreadId, + session_source: SessionSource, + persistent_thread_state_available: bool, ) -> Vec>> { let mut builder = ExtensionRegistryBuilder::<()>::new(); install_with_backend( @@ -893,6 +942,8 @@ async fn installed_tools( contributor .on_thread_start(ThreadStartInput { config: &(), + session_source: &session_source, + persistent_thread_state_available, session_store: &session_store, thread_store: &thread_store, }) @@ -906,6 +957,10 @@ async fn installed_tools( .collect() } +fn tool_names(tools: &[Arc>]) -> Vec { + tools.iter().map(|tool| tool.tool_name().name).collect() +} + struct GoalExtensionHarness { registry: codex_extension_api::ExtensionRegistry<()>, session_store: ExtensionData, @@ -930,10 +985,13 @@ impl GoalExtensionHarness { let registry = builder.build(); let session_store = ExtensionData::new("session-1"); let thread_store = ExtensionData::new(thread_id.to_string()); + let session_source = SessionSource::Cli; for contributor in registry.thread_lifecycle_contributors() { contributor .on_thread_start(ThreadStartInput { config: &(), + session_source: &session_source, + persistent_thread_state_available: true, session_store: &session_store, thread_store: &thread_store, }) @@ -1064,6 +1122,7 @@ fn tool_call(tool_name: &str, call_id: &str, arguments: serde_json::Value) -> To tool_name: codex_extension_api::ToolName::plain(tool_name), truncation_policy: TruncationPolicy::Bytes(1024), conversation_history: codex_extension_api::ConversationHistory::default(), + turn_item_emitter: Arc::new(NoopTurnItemEmitter), payload: ToolPayload::Function { arguments: arguments.to_string(), }, diff --git a/codex-rs/ext/image-generation/BUILD.bazel b/codex-rs/ext/image-generation/BUILD.bazel new file mode 100644 index 00000000000..5ed05a5dc82 --- /dev/null +++ b/codex-rs/ext/image-generation/BUILD.bazel @@ -0,0 +1,9 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "image-generation", + crate_name = "codex_image_generation_extension", + compile_data = [ + "imagegen_description.md", + ], +) diff --git a/codex-rs/ext/image-generation/Cargo.toml b/codex-rs/ext/image-generation/Cargo.toml new file mode 100644 index 00000000000..d4f2cb50dee --- /dev/null +++ b/codex-rs/ext/image-generation/Cargo.toml @@ -0,0 +1,37 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-image-generation-extension" +version.workspace = true + +[lib] +name = "codex_image_generation_extension" +path = "src/lib.rs" +doctest = false + +[lints] +workspace = true + +[dependencies] +async-trait = { workspace = true } +base64 = { workspace = true } +codex-api = { workspace = true } +codex-core = { workspace = true } +codex-extension-api = { workspace = true } +codex-features = { workspace = true } +codex-login = { workspace = true } +codex-model-provider = { workspace = true } +codex-model-provider-info = { workspace = true } +codex-protocol = { workspace = true } +codex-tools = { workspace = true } +http = { workspace = true } +schemars = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +tokio = { workspace = true, features = ["fs"] } +tracing = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tempfile = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/codex-rs/ext/image-generation/imagegen_description.md b/codex-rs/ext/image-generation/imagegen_description.md new file mode 100644 index 00000000000..7ae6ecb5fa9 --- /dev/null +++ b/codex-rs/ext/image-generation/imagegen_description.md @@ -0,0 +1,11 @@ +The `image_gen.imagegen` tool enables image generation from descriptions and editing of existing images based on specific instructions. Use it when: + +- The user requests an image based on a scene description, such as a diagram, portrait, comic, meme, or any other visual. +- The user wants to modify an attached or previously generated image with specific changes, including adding or removing elements, altering colors, improving quality/resolution, or transforming the style (e.g., cartoon, oil painting). + +Guidelines: +- Set `action` to `generate` when the user asks for a brand new image. +- Set `action` to `edit` when the user asks to modify an existing image from the conversation history. +- Directly generate the image without reconfirmation or clarification. +- After each image generation, do not mention anything related to download. Do not summarize the image. Do not ask followup question. Do not say ANYTHING after you generate an image. +- Always use this tool for image editing unless the user explicitly requests otherwise. Do not use the `python` tool for image editing unless specifically instructed. diff --git a/codex-rs/ext/image-generation/src/backend.rs b/codex-rs/ext/image-generation/src/backend.rs new file mode 100644 index 00000000000..9b837772c7b --- /dev/null +++ b/codex-rs/ext/image-generation/src/backend.rs @@ -0,0 +1,60 @@ +use codex_api::ImageEditRequest; +use codex_api::ImageGenerationRequest; +use codex_api::ImageResponse; +use codex_api::ImagesClient; +use codex_api::ReqwestTransport; +use codex_login::default_client::build_reqwest_client; +use codex_model_provider::SharedModelProvider; +use http::HeaderMap; + +#[derive(Clone)] +pub(crate) struct CodexImagesBackend { + provider: SharedModelProvider, +} + +impl CodexImagesBackend { + /// Creates a backend that sends image requests through the active model provider. + pub(crate) fn new(provider: SharedModelProvider) -> Self { + Self { provider } + } + + /// Resolves the provider and auth required for the current image API request. + async fn client(&self) -> Result, String> { + let provider = self + .provider + .api_provider() + .await + .map_err(|err| err.to_string())?; + let auth = self + .provider + .api_auth() + .await + .map_err(|err| err.to_string())?; + Ok(ImagesClient::new( + ReqwestTransport::new(build_reqwest_client()), + provider, + auth, + )) + } + + /// Sends a standalone image generation request through the configured Images client. + pub(crate) async fn generate( + &self, + request: ImageGenerationRequest, + ) -> Result { + self.client() + .await? + .generate(&request, HeaderMap::new()) + .await + .map_err(|err| err.to_string()) + } + + /// Sends a standalone image edit request through the configured Images client. + pub(crate) async fn edit(&self, request: ImageEditRequest) -> Result { + self.client() + .await? + .edit(&request, HeaderMap::new()) + .await + .map_err(|err| err.to_string()) + } +} diff --git a/codex-rs/ext/image-generation/src/extension.rs b/codex-rs/ext/image-generation/src/extension.rs new file mode 100644 index 00000000000..8f0b09f8cbd --- /dev/null +++ b/codex-rs/ext/image-generation/src/extension.rs @@ -0,0 +1,99 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use codex_core::config::Config; +use codex_extension_api::ConfigContributor; +use codex_extension_api::ExtensionData; +use codex_extension_api::ExtensionRegistryBuilder; +use codex_extension_api::ThreadLifecycleContributor; +use codex_extension_api::ThreadStartInput; +use codex_extension_api::ToolCall; +use codex_extension_api::ToolContributor; +use codex_extension_api::ToolExecutor; +use codex_features::Feature; +use codex_login::AuthManager; +use codex_model_provider::create_model_provider; +use codex_model_provider_info::ModelProviderInfo; + +use crate::backend::CodexImagesBackend; +use crate::tool::ImageGenerationTool; +use crate::tool::generated_image_output_dir; + +#[derive(Clone)] +struct ImageGenerationExtension { + auth_manager: Arc, +} + +#[derive(Clone)] +struct ImageGenerationExtensionConfig { + enabled: bool, + provider: ModelProviderInfo, + codex_home: PathBuf, +} + +impl From<&Config> for ImageGenerationExtensionConfig { + /// Resolves whether standalone image generation should be available for a thread. + fn from(config: &Config) -> Self { + Self { + enabled: config.features.enabled(Feature::ImageGenExt) + && config.model_provider.is_openai(), + provider: config.model_provider.clone(), + codex_home: config.codex_home.to_path_buf(), + } + } +} + +#[async_trait::async_trait] +impl ThreadLifecycleContributor for ImageGenerationExtension { + /// Seeds image-generation availability when a thread begins. + async fn on_thread_start(&self, input: ThreadStartInput<'_, Config>) { + input + .thread_store + .insert(ImageGenerationExtensionConfig::from(input.config)); + } +} + +impl ConfigContributor for ImageGenerationExtension { + /// Refreshes image-generation availability after thread configuration changes. + fn on_config_changed( + &self, + _session_store: &ExtensionData, + thread_store: &ExtensionData, + _previous_config: &Config, + new_config: &Config, + ) { + thread_store.insert(ImageGenerationExtensionConfig::from(new_config)); + } +} + +impl ToolContributor for ImageGenerationExtension { + /// Creates the image-generation tool exposed by this installed extension. + fn tools( + &self, + _session_store: &ExtensionData, + thread_store: &ExtensionData, + ) -> Vec>> { + let Some(config) = thread_store.get::() else { + return Vec::new(); + }; + if !config.enabled || !self.auth_manager.current_auth_uses_codex_backend() { + return Vec::new(); + } + + vec![Arc::new(ImageGenerationTool::new( + CodexImagesBackend::new(create_model_provider( + config.provider.clone(), + Some(self.auth_manager.clone()), + )), + generated_image_output_dir(&config.codex_home, thread_store.level_id()), + ))] + } +} + +/// Installs the feature-gated standalone image-generation extension contributors. +pub fn install(registry: &mut ExtensionRegistryBuilder, auth_manager: Arc) { + let extension = Arc::new(ImageGenerationExtension { auth_manager }); + registry.thread_lifecycle_contributor(extension.clone()); + registry.config_contributor(extension.clone()); + registry.tool_contributor(extension); +} diff --git a/codex-rs/ext/image-generation/src/lib.rs b/codex-rs/ext/image-generation/src/lib.rs new file mode 100644 index 00000000000..63f1ab2482c --- /dev/null +++ b/codex-rs/ext/image-generation/src/lib.rs @@ -0,0 +1,8 @@ +mod backend; +mod extension; +mod tool; + +pub use extension::install; + +pub(crate) const IMAGE_GEN_NAMESPACE: &str = "image_gen"; +pub(crate) const IMAGEGEN_TOOL_NAME: &str = "imagegen"; diff --git a/codex-rs/ext/image-generation/src/tests.rs b/codex-rs/ext/image-generation/src/tests.rs new file mode 100644 index 00000000000..1b56270ed32 --- /dev/null +++ b/codex-rs/ext/image-generation/src/tests.rs @@ -0,0 +1,341 @@ +use codex_api::ImageBackground; +use codex_api::ImageEditRequest; +use codex_api::ImageGenerationRequest; +use codex_api::ImageQuality; +use codex_api::ImageUrl; +use codex_extension_api::ToolOutput; +use codex_extension_api::ToolPayload; +use codex_extension_api::ToolSpec; +use codex_protocol::models::ContentItem; +use codex_protocol::models::DEFAULT_IMAGE_DETAIL; +use codex_protocol::models::FunctionCallOutputBody; +use codex_protocol::models::FunctionCallOutputContentItem; +use codex_protocol::models::FunctionCallOutputPayload; +use codex_protocol::models::ResponseInputItem; +use codex_protocol::models::ResponseItem; +use codex_tools::ResponsesApiNamespaceTool; +use pretty_assertions::assert_eq; + +use super::GeneratedImageOutput; +use super::ImageRequest; +use super::ImagegenAction; +use super::ImagegenArgs; +use super::generated_image_output_dir; +use super::imagegen_tool_spec; +use super::persist_generated_image; +use super::request_for_action; +use crate::IMAGE_GEN_NAMESPACE; +use crate::IMAGEGEN_TOOL_NAME; + +const RESULT: &str = "cG5n"; + +#[test] +fn uses_reserved_image_gen_namespace() { + let ToolSpec::Namespace(spec) = imagegen_tool_spec() else { + panic!("imagegen should advertise a namespace tool"); + }; + assert_eq!(spec.name, IMAGE_GEN_NAMESPACE); + let ResponsesApiNamespaceTool::Function(function) = &spec.tools[0]; + assert_eq!(function.name, IMAGEGEN_TOOL_NAME); +} + +#[test] +fn generate_uses_fixed_request_defaults() { + assert_eq!( + request_for_action(&args(ImagegenAction::Generate, "paint a moonlit lake"), &[]) + .expect("generation request should build"), + ImageRequest::Generate(ImageGenerationRequest { + prompt: "paint a moonlit lake".to_string(), + background: Some(ImageBackground::Auto), + model: "gpt-image-2".to_string(), + n: None, + quality: Some(ImageQuality::Auto), + size: Some("auto".to_string()), + }) + ); +} + +#[tokio::test] +async fn generated_output_returns_image_input_and_persists_artifact() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let output_hint = persist_generated_image(tempdir.path(), "call-1", RESULT) + .await + .expect("generated image should persist"); + let output = GeneratedImageOutput { + result: RESULT.to_string(), + output_hint: Some(output_hint), + }; + + let ResponseInputItem::FunctionCallOutput { + output: response_output, + .. + } = output.to_response_item("call-1", &function_payload()) + else { + panic!("imagegen should return function tool output"); + }; + let FunctionCallOutputBody::ContentItems(content_items) = response_output.body else { + panic!("imagegen output should contain generated image bytes"); + }; + assert_eq!( + content_items, + vec![ + FunctionCallOutputContentItem::InputImage { + image_url: format!("data:image/png;base64,{RESULT}"), + detail: Some(DEFAULT_IMAGE_DETAIL), + }, + FunctionCallOutputContentItem::InputText { + text: format!( + "Generated images are saved to {} as {} by default.\n\ + If you need to use a generated image at another path, copy it and leave the original in place unless the user explicitly asks you to delete it.", + tempdir.path().display(), + tempdir.path().join("call-1.png").display(), + ), + }, + ] + ); + assert_eq!( + std::fs::read(tempdir.path().join("call-1.png")).expect("saved generated image"), + b"png" + ); +} + +#[test] +fn edit_matches_context_selector_for_generated_images_after_latest_user_anchor() { + let history = vec![ + generated_item("g1"), + generated_item("g2"), + generated_item("g3"), + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ + ContentItem::InputImage { + image_url: "data:image/png;base64,u1".to_string(), + detail: None, + }, + ContentItem::InputImage { + image_url: "data:image/png;base64,u2".to_string(), + detail: None, + }, + ], + phase: None, + }, + generated_item("g4"), + generated_item("g5"), + generated_item("g6"), + generated_item("g7"), + ]; + + assert_eq!( + edit_request("change the lighting", &history), + expected_edit_request( + "change the lighting", + &[ + "data:image/png;base64,u1", + "data:image/png;base64,u2", + "data:image/png;base64,g5", + "data:image/png;base64,g6", + "data:image/png;base64,g7", + ] + ) + ); +} + +#[test] +fn edit_preserves_a_generated_image_when_user_anchor_fills_the_limit() { + let history = vec![ + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: ["a", "b", "c", "d", "e"] + .into_iter() + .map(|image| ContentItem::InputImage { + image_url: format!("data:image/png;base64,{image}"), + detail: None, + }) + .collect(), + phase: None, + }, + generated_item("generated"), + ]; + + assert_eq!( + edit_request("edit the last generated image", &history), + expected_edit_request( + "edit the last generated image", + &[ + "data:image/png;base64,b", + "data:image/png;base64,c", + "data:image/png;base64,d", + "data:image/png;base64,e", + "data:image/png;base64,generated", + ] + ) + ); +} + +#[test] +fn edit_uses_latest_user_upload_before_a_text_only_follow_up() { + let history = vec![ + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputImage { + image_url: "data:image/png;base64,user".to_string(), + detail: None, + }], + phase: None, + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "edit this image".to_string(), + }], + phase: None, + }, + ]; + + assert_eq!( + edit_request("change the lighting", &history), + expected_edit_request("change the lighting", &["data:image/png;base64,user"]) + ); +} + +#[test] +fn edit_reuses_images_from_prior_standalone_imagegen_calls() { + let history = vec![ + ResponseItem::FunctionCall { + id: None, + name: IMAGEGEN_TOOL_NAME.to_string(), + namespace: Some(IMAGE_GEN_NAMESPACE.to_string()), + arguments: "{}".to_string(), + call_id: "imagegen-1".to_string(), + }, + generated_function_output("imagegen-1", "standalone"), + ]; + + assert_eq!( + edit_request("change the lighting", &history), + expected_edit_request("change the lighting", &["data:image/png;base64,standalone"]) + ); +} + +#[test] +fn edit_keeps_newest_standalone_generated_images_when_over_limit() { + let history = (1..=6) + .flat_map(|index| { + let call_id = format!("imagegen-{index}"); + vec![ + ResponseItem::FunctionCall { + id: None, + name: IMAGEGEN_TOOL_NAME.to_string(), + namespace: Some(IMAGE_GEN_NAMESPACE.to_string()), + arguments: "{}".to_string(), + call_id: call_id.clone(), + }, + generated_function_output(&call_id, &index.to_string()), + ] + }) + .collect::>(); + + assert_eq!( + edit_request("change the lighting", &history), + expected_edit_request( + "change the lighting", + &[ + "data:image/png;base64,2", + "data:image/png;base64,3", + "data:image/png;base64,4", + "data:image/png;base64,5", + "data:image/png;base64,6", + ] + ) + ); +} + +#[test] +fn edit_without_image_history_returns_tool_error() { + let error = request_for_action(&args(ImagegenAction::Edit, "change the lighting"), &[]) + .expect_err("edit should require image context"); + + assert_eq!( + error.to_string(), + "image edit requested without any usable image in conversation history" + ); +} + +#[test] +fn generated_image_output_dir_is_scoped_to_sanitized_thread_id() { + assert_eq!( + generated_image_output_dir(std::path::Path::new("/tmp/codex-home"), "thread/1"), + std::path::PathBuf::from("/tmp/codex-home/generated_images/thread_1") + ); +} + +fn args(action: ImagegenAction, prompt: &str) -> ImagegenArgs { + ImagegenArgs { + prompt: prompt.to_string(), + action, + } +} + +fn edit_request(prompt: &str, history: &[ResponseItem]) -> ImageEditRequest { + let ImageRequest::Edit(request) = + request_for_action(&args(ImagegenAction::Edit, prompt), history) + .expect("edit request should build") + else { + panic!("expected edit request"); + }; + request +} + +fn expected_edit_request(prompt: &str, images: &[&str]) -> ImageEditRequest { + ImageEditRequest { + images: images + .iter() + .map(|image_url| ImageUrl { + image_url: (*image_url).to_string(), + }) + .collect(), + prompt: prompt.to_string(), + background: Some(ImageBackground::Auto), + model: "gpt-image-2".to_string(), + n: None, + quality: Some(ImageQuality::Auto), + size: Some("auto".to_string()), + } +} + +fn generated_item(result: &str) -> ResponseItem { + ResponseItem::ImageGenerationCall { + id: format!("id-{result}"), + status: "completed".to_string(), + revised_prompt: None, + result: result.to_string(), + } +} + +fn generated_function_output(call_id: &str, result: &str) -> ResponseItem { + ResponseItem::FunctionCallOutput { + call_id: call_id.to_string(), + output: FunctionCallOutputPayload { + body: FunctionCallOutputBody::ContentItems(vec![ + FunctionCallOutputContentItem::InputImage { + image_url: format!("data:image/png;base64,{result}"), + detail: Some(DEFAULT_IMAGE_DETAIL), + }, + FunctionCallOutputContentItem::InputText { + text: "generated image save hint".to_string(), + }, + ]), + success: Some(true), + }, + } +} + +fn function_payload() -> ToolPayload { + ToolPayload::Function { + arguments: "{}".to_string(), + } +} diff --git a/codex-rs/ext/image-generation/src/tool.rs b/codex-rs/ext/image-generation/src/tool.rs new file mode 100644 index 00000000000..fa1614cd47d --- /dev/null +++ b/codex-rs/ext/image-generation/src/tool.rs @@ -0,0 +1,395 @@ +use std::path::Path; +use std::path::PathBuf; + +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use codex_api::ImageBackground; +use codex_api::ImageEditRequest; +use codex_api::ImageGenerationRequest; +use codex_api::ImageQuality; +use codex_api::ImageUrl; +use codex_extension_api::FunctionCallError; +use codex_extension_api::ToolCall; +use codex_extension_api::ToolExecutor; +use codex_extension_api::ToolName; +use codex_extension_api::ToolOutput; +use codex_extension_api::ToolPayload; +use codex_extension_api::ToolSpec; +use codex_extension_api::parse_tool_input_schema; +use codex_protocol::models::ContentItem; +use codex_protocol::models::DEFAULT_IMAGE_DETAIL; +use codex_protocol::models::FunctionCallOutputBody; +use codex_protocol::models::FunctionCallOutputContentItem; +use codex_protocol::models::FunctionCallOutputPayload; +use codex_protocol::models::ResponseInputItem; +use codex_protocol::models::ResponseItem; +use codex_tools::ResponsesApiNamespace; +use codex_tools::ResponsesApiNamespaceTool; +use codex_tools::ResponsesApiTool; +use codex_tools::ToolExposure; +use codex_tools::default_namespace_description; +use schemars::JsonSchema; +use schemars::r#gen::SchemaSettings; +use serde::Deserialize; +use serde_json::Map; +use serde_json::Value; + +use crate::IMAGE_GEN_NAMESPACE; +use crate::IMAGEGEN_TOOL_NAME; +use crate::backend::CodexImagesBackend; + +const IMAGE_MODEL: &str = "gpt-image-2"; +const MAX_EDIT_IMAGES: usize = 5; +const IMAGEGEN_DESCRIPTION: &str = include_str!("../imagegen_description.md"); +const GENERATED_IMAGE_ARTIFACTS_DIR: &str = "generated_images"; + +#[derive(Clone)] +pub(crate) struct ImageGenerationTool { + backend: CodexImagesBackend, + output_dir: PathBuf, +} + +impl ImageGenerationTool { + /// Creates an image-generation tool backed by an image API executor. + pub(crate) fn new(backend: CodexImagesBackend, output_dir: PathBuf) -> Self { + Self { + backend, + output_dir, + } + } +} + +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +struct ImagegenArgs { + prompt: String, + action: ImagegenAction, +} + +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +enum ImagegenAction { + Generate, + Edit, +} + +#[async_trait::async_trait] +impl ToolExecutor for ImageGenerationTool { + /// Keeps the tool in the existing image-generation Responses namespace. + fn tool_name(&self) -> ToolName { + ToolName::namespaced(IMAGE_GEN_NAMESPACE, IMAGEGEN_TOOL_NAME) + } + + /// Advertises the model contract: a rewritten prompt and semantic action. + fn spec(&self) -> ToolSpec { + imagegen_tool_spec() + } + + /// Keeps this model-facing tool out of the nested code-mode tool surface. + fn exposure(&self) -> ToolExposure { + ToolExposure::DirectModelOnly + } + + /// Executes the selected image operation and returns the completed image result. + async fn handle(&self, call: ToolCall) -> Result, FunctionCallError> { + let args = parse_args(&call)?; + let request = request_for_action(&args, call.conversation_history.items())?; + + let response = match request { + ImageRequest::Generate(request) => self.backend.generate(request).await, + ImageRequest::Edit(request) => self.backend.edit(request).await, + } + .map_err(|err| { + FunctionCallError::RespondToModel(format!("image generation failed: {err}")) + })?; + let Some(result) = response.data.into_iter().next().map(|data| data.b64_json) else { + return Err(FunctionCallError::RespondToModel( + "image generation returned no image data".to_string(), + )); + }; + let output_hint = + match persist_generated_image(&self.output_dir, &call.call_id, &result).await { + Ok(output_hint) => Some(output_hint), + Err(err) => { + tracing::warn!( + call_id = %call.call_id, + output_dir = %self.output_dir.display(), + "failed to save generated image: {err}" + ); + None + } + }; + Ok(Box::new(GeneratedImageOutput { + result, + output_hint, + })) + } +} + +#[derive(Debug, PartialEq)] +enum ImageRequest { + Generate(ImageGenerationRequest), + Edit(ImageEditRequest), +} + +/// Maps the model-selected action to the fixed image API request parameters. +fn request_for_action( + args: &ImagegenArgs, + history: &[ResponseItem], +) -> Result { + match args.action { + ImagegenAction::Generate => Ok(ImageRequest::Generate(ImageGenerationRequest { + prompt: args.prompt.clone(), + background: Some(ImageBackground::Auto), + model: IMAGE_MODEL.to_string(), + n: None, + quality: Some(ImageQuality::Auto), + size: Some("auto".to_string()), + })), + ImagegenAction::Edit => { + let images = edit_images(history); + if images.is_empty() { + return Err(FunctionCallError::RespondToModel( + "image edit requested without any usable image in conversation history" + .to_string(), + )); + } + Ok(ImageRequest::Edit(ImageEditRequest { + images, + prompt: args.prompt.clone(), + background: Some(ImageBackground::Auto), + model: IMAGE_MODEL.to_string(), + n: None, + quality: Some(ImageQuality::Auto), + size: Some("auto".to_string()), + })) + } + } +} + +/// Selects edit context using the hosted imagegen anchor and truncation behavior. +fn edit_images(history: &[ResponseItem]) -> Vec { + let latest_uploaded_images = history.iter().enumerate().rev().find_map(|(index, item)| { + let ResponseItem::Message { role, content, .. } = item else { + return None; + }; + if role != "user" { + return None; + } + let images = content + .iter() + .filter_map(|item| match item { + ContentItem::InputImage { image_url, .. } => Some(ImageUrl { + image_url: image_url.clone(), + }), + ContentItem::InputText { .. } | ContentItem::OutputText { .. } => None, + }) + .collect::>(); + (!images.is_empty()).then_some((index, images)) + }); + let (user_images, follow_up_start) = latest_uploaded_images + .map_or_else(|| (Vec::new(), 0), |(index, images)| (images, index + 1)); + let mut generated_images = Vec::new(); + for item in &history[follow_up_start..] { + match item { + ResponseItem::ImageGenerationCall { result, .. } if !result.is_empty() => { + generated_images.push(ImageUrl { + image_url: format!("data:image/png;base64,{result}"), + }); + } + ResponseItem::FunctionCallOutput { call_id, output } + if history.iter().any(|item| { + matches!( + item, + ResponseItem::FunctionCall { + name, + namespace: Some(namespace), + call_id: function_call_id, + .. + } if function_call_id == call_id + && name == IMAGEGEN_TOOL_NAME + && namespace == IMAGE_GEN_NAMESPACE + ) + }) => + { + generated_images.extend(output.content_items().into_iter().flatten().filter_map( + |item| match item { + FunctionCallOutputContentItem::InputImage { image_url, .. } => { + Some(ImageUrl { + image_url: image_url.clone(), + }) + } + FunctionCallOutputContentItem::InputText { .. } + | FunctionCallOutputContentItem::EncryptedContent { .. } => None, + }, + )); + } + ResponseItem::Message { .. } + | ResponseItem::Reasoning { .. } + | ResponseItem::LocalShellCall { .. } + | ResponseItem::FunctionCall { .. } + | ResponseItem::ToolSearchCall { .. } + | ResponseItem::FunctionCallOutput { .. } + | ResponseItem::CustomToolCall { .. } + | ResponseItem::CustomToolCallOutput { .. } + | ResponseItem::ToolSearchOutput { .. } + | ResponseItem::WebSearchCall { .. } + | ResponseItem::ImageGenerationCall { .. } + | ResponseItem::Compaction { .. } + | ResponseItem::CompactionTrigger + | ResponseItem::ContextCompaction { .. } + | ResponseItem::Other => {} + } + } + truncate_images(user_images, generated_images) +} + +/// Truncates edit inputs while preserving the newest generated image when possible. +fn truncate_images( + mut user_images: Vec, + mut generated_images: Vec, +) -> Vec { + let mut excess = (user_images.len() + generated_images.len()).saturating_sub(MAX_EDIT_IMAGES); + let drop_generated = excess.min(generated_images.len().saturating_sub(1)); + generated_images.drain(..drop_generated); + excess -= drop_generated; + let drop_user = excess.min(user_images.len()); + user_images.drain(..drop_user); + excess -= drop_user; + generated_images.drain(..excess); + + user_images.extend(generated_images); + user_images +} + +/// Parses the strict model-facing arguments for an image-generation call. +fn parse_args(call: &ToolCall) -> Result { + serde_json::from_str(call.function_arguments()?) + .map_err(|err| FunctionCallError::RespondToModel(err.to_string())) +} + +/// Resolves where generated images for one thread are persisted by the extension. +pub(crate) fn generated_image_output_dir(codex_home: &Path, thread_id: &str) -> PathBuf { + codex_home + .join(GENERATED_IMAGE_ARTIFACTS_DIR) + .join(sanitize_path_component(thread_id)) +} + +fn generated_image_output_path(output_dir: &Path, call_id: &str) -> PathBuf { + output_dir.join(format!("{}.png", sanitize_path_component(call_id))) +} + +fn sanitize_path_component(value: &str) -> String { + let sanitized: String = value + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { + ch + } else { + '_' + } + }) + .collect(); + if sanitized.is_empty() { + "generated_image".to_string() + } else { + sanitized + } +} + +async fn persist_generated_image( + output_dir: &Path, + call_id: &str, + result: &str, +) -> Result { + let bytes = BASE64_STANDARD + .decode(result.trim().as_bytes()) + .map_err(|err| format!("invalid image generation payload: {err}"))?; + tokio::fs::create_dir_all(output_dir) + .await + .map_err(|err| err.to_string())?; + tokio::fs::write(generated_image_output_path(output_dir, call_id), bytes) + .await + .map_err(|err| err.to_string())?; + + Ok(format!( + "Generated images are saved to {} as {} by default.\n\ + If you need to use a generated image at another path, copy it and leave the original in place unless the user explicitly asks you to delete it.", + output_dir.display(), + generated_image_output_path(output_dir, call_id).display(), + )) +} + +/// Builds the namespace function schema exposed to the model. +fn imagegen_tool_spec() -> ToolSpec { + let mut schema_value = serde_json::to_value( + SchemaSettings::draft2019_09() + .with(|settings| settings.inline_subschemas = true) + .into_generator() + .into_root_schema_for::(), + ) + .unwrap_or_else(|err| panic!("imagegen schema should serialize: {err}")); + let Value::Object(ref mut schema) = schema_value else { + unreachable!("imagegen root schema must be an object"); + }; + let mut input_schema = Map::new(); + for key in ["properties", "required", "type", "additionalProperties"] { + if let Some(value) = schema.remove(key) { + input_schema.insert(key.to_string(), value); + } + } + ToolSpec::Namespace(ResponsesApiNamespace { + name: IMAGE_GEN_NAMESPACE.to_string(), + description: default_namespace_description(IMAGE_GEN_NAMESPACE), + tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool { + name: IMAGEGEN_TOOL_NAME.to_string(), + description: IMAGEGEN_DESCRIPTION.to_string(), + strict: false, + parameters: parse_tool_input_schema(&Value::Object(input_schema)) + .unwrap_or_else(|err| panic!("imagegen input schema should parse: {err}")), + output_schema: None, + defer_loading: None, + })], + }) +} + +struct GeneratedImageOutput { + result: String, + output_hint: Option, +} + +impl ToolOutput for GeneratedImageOutput { + /// Avoids copying image bytes into tool-call telemetry. + fn log_preview(&self) -> String { + "[generated image]".to_string() + } + + /// Reports a completed images request as successful tool execution. + fn success_for_logging(&self) -> bool { + true + } + + /// Returns generated bytes and persisted-artifact context for the model's follow-up response. + fn to_response_item(&self, call_id: &str, _payload: &ToolPayload) -> ResponseInputItem { + let mut content = vec![FunctionCallOutputContentItem::InputImage { + image_url: format!("data:image/png;base64,{}", self.result), + detail: Some(DEFAULT_IMAGE_DETAIL), + }]; + if let Some(output_hint) = &self.output_hint { + content.push(FunctionCallOutputContentItem::InputText { + text: output_hint.clone(), + }); + } + ResponseInputItem::FunctionCallOutput { + call_id: call_id.to_string(), + output: FunctionCallOutputPayload { + body: FunctionCallOutputBody::ContentItems(content), + success: Some(true), + }, + } + } +} + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; diff --git a/codex-rs/ext/memories/src/lib.rs b/codex-rs/ext/memories/src/lib.rs index d17c4c4956b..25120c7a3ae 100644 --- a/codex-rs/ext/memories/src/lib.rs +++ b/codex-rs/ext/memories/src/lib.rs @@ -15,7 +15,7 @@ pub(crate) const MAX_SEARCH_RESULTS: usize = 200; pub(crate) const DEFAULT_READ_MAX_TOKENS: usize = 20_000; pub(crate) const MEMORY_TOOL_DEVELOPER_INSTRUCTIONS_SUMMARY_TOKEN_LIMIT: usize = 2_500; -pub(crate) const MEMORY_TOOLS_NAMESPACE: &str = "memories/"; +pub(crate) const MEMORY_TOOLS_NAMESPACE: &str = "memories"; pub(crate) const ADD_AD_HOC_NOTE_TOOL_NAME: &str = "add_ad_hoc_note"; pub(crate) const LIST_TOOL_NAME: &str = "list"; pub(crate) const READ_TOOL_NAME: &str = "read"; diff --git a/codex-rs/ext/memories/src/metrics.rs b/codex-rs/ext/memories/src/metrics.rs index 610a3cb9ed2..443156b7248 100644 --- a/codex-rs/ext/memories/src/metrics.rs +++ b/codex-rs/ext/memories/src/metrics.rs @@ -15,7 +15,7 @@ pub(crate) fn record_tool_call( return; }; - let tool = format!("{MEMORY_TOOLS_NAMESPACE}{operation}"); + let tool = format!("{MEMORY_TOOLS_NAMESPACE}/{operation}"); let _ = metrics_client.counter( MEMORIES_TOOL_CALL_METRIC, /*inc*/ 1, diff --git a/codex-rs/ext/memories/src/tests.rs b/codex-rs/ext/memories/src/tests.rs index fbb23bd7ac6..f2d79c850c4 100644 --- a/codex-rs/ext/memories/src/tests.rs +++ b/codex-rs/ext/memories/src/tests.rs @@ -4,6 +4,7 @@ use std::sync::Arc; use codex_extension_api::ContextContributor; use codex_extension_api::ExtensionData; use codex_extension_api::ExtensionRegistryBuilder; +use codex_extension_api::NoopTurnItemEmitter; use codex_extension_api::PromptSlot; use codex_extension_api::ToolCall; use codex_extension_api::ToolContributor; @@ -22,6 +23,16 @@ use crate::extension::MemoriesExtension; use crate::extension::MemoriesExtensionConfig; use crate::local::LocalMemoriesBackend; +#[test] +fn memory_tool_namespace_matches_responses_api_identifier() { + assert!(!crate::MEMORY_TOOLS_NAMESPACE.is_empty()); + assert!( + crate::MEMORY_TOOLS_NAMESPACE + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + ); +} + #[test] fn tools_are_not_contributed_without_thread_config() { let extension = MemoriesExtension::default(); @@ -202,6 +213,7 @@ async fn add_ad_hoc_note_tool_creates_note_file() { tool_name: memory_tool_name(crate::ADD_AD_HOC_NOTE_TOOL_NAME), truncation_policy: TruncationPolicy::Bytes(1024), conversation_history: codex_extension_api::ConversationHistory::default(), + turn_item_emitter: Arc::new(NoopTurnItemEmitter), payload: payload.clone(), }) .await @@ -243,6 +255,7 @@ async fn add_ad_hoc_note_tool_rejects_paths_as_filenames() { tool_name: memory_tool_name(crate::ADD_AD_HOC_NOTE_TOOL_NAME), truncation_policy: TruncationPolicy::Bytes(1024), conversation_history: codex_extension_api::ConversationHistory::default(), + turn_item_emitter: Arc::new(NoopTurnItemEmitter), payload, }) .await; @@ -285,6 +298,7 @@ async fn read_tool_reads_memory_file() { tool_name: memory_tool_name(crate::READ_TOOL_NAME), truncation_policy: TruncationPolicy::Bytes(1024), conversation_history: codex_extension_api::ConversationHistory::default(), + turn_item_emitter: Arc::new(NoopTurnItemEmitter), payload: payload.clone(), }) .await @@ -330,6 +344,7 @@ async fn search_tool_accepts_multiple_queries() { tool_name: memory_tool_name(crate::SEARCH_TOOL_NAME), truncation_policy: TruncationPolicy::Bytes(1024), conversation_history: codex_extension_api::ConversationHistory::default(), + turn_item_emitter: Arc::new(NoopTurnItemEmitter), payload: payload.clone(), }) .await @@ -401,6 +416,7 @@ async fn search_tool_accepts_windowed_all_match_mode() { tool_name: memory_tool_name(crate::SEARCH_TOOL_NAME), truncation_policy: TruncationPolicy::Bytes(1024), conversation_history: codex_extension_api::ConversationHistory::default(), + turn_item_emitter: Arc::new(NoopTurnItemEmitter), payload: payload.clone(), }) .await @@ -452,6 +468,7 @@ async fn search_tool_rejects_legacy_single_query() { tool_name: memory_tool_name(crate::SEARCH_TOOL_NAME), truncation_policy: TruncationPolicy::Bytes(1024), conversation_history: codex_extension_api::ConversationHistory::default(), + turn_item_emitter: Arc::new(NoopTurnItemEmitter), payload, }) .await; diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 391e33e77de..116db0a5b50 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -170,6 +170,8 @@ pub enum Feature { ExternalMigration, /// Allow the model to invoke the built-in image generation tool. ImageGeneration, + /// Replace hosted image generation with the standalone image-generation extension. + ImageGenExt, /// Allow prompting and installing missing MCP dependencies. SkillMcpDependencyInstall, /// Removed compatibility flag for deleted skill env var dependency prompting. @@ -1053,6 +1055,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Stable, default_enabled: true, }, + FeatureSpec { + id: Feature::ImageGenExt, + key: "imagegenext", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::SkillMcpDependencyInstall, key: "skill_mcp_dependency_install", @@ -1068,11 +1076,7 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::MentionsV2, key: "mentions_v2", - stage: Stage::Experimental { - name: "Mentions v2", - menu_description: "Use a unified @ mention popup for files, folders, apps, plugins, and skills.", - announcement: "", - }, + stage: Stage::UnderDevelopment, default_enabled: false, }, FeatureSpec { diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index e1f48745f9d..5d7087e90bd 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -244,6 +244,13 @@ fn image_generation_is_stable_and_enabled_by_default() { assert_eq!(Feature::ImageGeneration.default_enabled(), true); } +#[test] +fn image_generation_extension_is_under_development_and_disabled_by_default() { + assert_eq!(Feature::ImageGenExt.stage(), Stage::UnderDevelopment); + assert_eq!(Feature::ImageGenExt.default_enabled(), false); + assert_eq!(feature_for_key("imagegenext"), Some(Feature::ImageGenExt)); +} + #[test] fn use_legacy_landlock_config_records_deprecation_notice() { let mut entries = BTreeMap::new(); @@ -304,6 +311,13 @@ fn auth_elicitation_is_under_development() { ); } +#[test] +fn mentions_v2_is_under_development_and_disabled_by_default() { + assert_eq!(Feature::MentionsV2.stage(), Stage::UnderDevelopment); + assert_eq!(Feature::MentionsV2.default_enabled(), false); + assert_eq!(feature_for_key("mentions_v2"), Some(Feature::MentionsV2)); +} + #[test] fn remote_control_is_removed_and_disabled_by_default() { assert_eq!(Feature::RemoteControl.stage(), Stage::Removed); diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index d94c48165dc..22e2c9f3b9e 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -84,6 +84,7 @@ struct ChatgptAuthState { } const TOKEN_REFRESH_INTERVAL: i64 = 8; +const CHATGPT_ACCESS_TOKEN_REFRESH_WINDOW_MINUTES: i64 = 5; const REFRESH_TOKEN_EXPIRED_MESSAGE: &str = "Your access token could not be refreshed because your refresh token has expired. Please log out and sign in again."; const REFRESH_TOKEN_REUSED_MESSAGE: &str = "Your access token could not be refreshed because your refresh token was already used. Please log out and sign in again."; @@ -844,8 +845,8 @@ async fn request_chatgpt_token_refresh( } else { let body = response.text().await.unwrap_or_default(); tracing::error!("Failed to refresh token: {status}: {body}"); - if status == StatusCode::UNAUTHORIZED { - let failed = classify_refresh_token_failure(&body); + let failed = classify_refresh_token_failure(&body); + if status == StatusCode::UNAUTHORIZED || failed.reason != RefreshTokenFailedReason::Other { Err(RefreshTokenError::Permanent(failed)) } else { let message = try_parse_error_message(&body); @@ -871,7 +872,7 @@ fn classify_refresh_token_failure(body: &str) -> RefreshTokenFailedError { tracing::warn!( backend_code = normalized_code.as_deref(), backend_body = body, - "Encountered unknown 401 response while refreshing token" + "Encountered unknown response while refreshing token" ); } @@ -1421,15 +1422,15 @@ impl AuthManager { } /// Current cached auth (clone). May be `None` if not logged in or load failed. - /// For stale managed ChatGPT auth, first performs a guarded reload and then - /// refreshes only if the on-disk auth is unchanged. + /// For managed ChatGPT auth that needs a proactive refresh, first performs + /// a guarded reload and then refreshes only if the on-disk auth is unchanged. pub async fn auth(&self) -> Option { if let Some(auth) = self.resolve_external_api_key_auth().await { return Some(auth); } let auth = self.auth_cached()?; - if Self::is_stale_for_proactive_refresh(&auth) + if Self::should_refresh_proactively(&auth) && let Err(err) = self.refresh_token().await { tracing::error!("Failed to refresh token: {}", err); @@ -1808,7 +1809,7 @@ impl AuthManager { ) } - fn is_stale_for_proactive_refresh(auth: &CodexAuth) -> bool { + fn should_refresh_proactively(auth: &CodexAuth) -> bool { let chatgpt_auth = match auth { CodexAuth::Chatgpt(chatgpt_auth) => chatgpt_auth, _ => return false, @@ -1821,7 +1822,9 @@ impl AuthManager { if let Some(tokens) = auth_dot_json.tokens.as_ref() && let Ok(Some(expires_at)) = parse_jwt_expiration(&tokens.access_token) { - return expires_at <= Utc::now(); + return expires_at + <= Utc::now() + + chrono::Duration::minutes(CHATGPT_ACCESS_TOKEN_REFRESH_WINDOW_MINUTES); } let last_refresh = match auth_dot_json.last_refresh { Some(last_refresh) => last_refresh, diff --git a/codex-rs/login/tests/suite/auth_refresh.rs b/codex-rs/login/tests/suite/auth_refresh.rs index afdf466d095..83897a0d38b 100644 --- a/codex-rs/login/tests/suite/auth_refresh.rs +++ b/codex-rs/login/tests/suite/auth_refresh.rs @@ -158,6 +158,102 @@ async fn refresh_token_refreshes_when_auth_is_unchanged() -> Result<()> { Ok(()) } +#[serial_test::serial(auth_refresh)] +#[tokio::test] +async fn auth_refreshes_when_access_token_is_near_expiry() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/oauth/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": "new-access-token", + "refresh_token": "new-refresh-token" + }))) + .expect(1) + .mount(&server) + .await; + + let ctx = RefreshTokenTestContext::new(&server).await?; + let initial_last_refresh = Utc::now(); + let near_expiry_access_token = access_token_with_expiration(Utc::now() + Duration::minutes(4)); + let initial_tokens = build_tokens(&near_expiry_access_token, INITIAL_REFRESH_TOKEN); + let initial_auth = AuthDotJson { + auth_mode: Some(AuthMode::Chatgpt), + openai_api_key: None, + tokens: Some(initial_tokens.clone()), + last_refresh: Some(initial_last_refresh), + agent_identity: None, + }; + ctx.write_auth(&initial_auth).await?; + + let cached_auth = ctx + .auth_manager + .auth() + .await + .context("auth should be cached")?; + + let refreshed_tokens = TokenData { + access_token: "new-access-token".to_string(), + refresh_token: "new-refresh-token".to_string(), + ..initial_tokens.clone() + }; + let cached = cached_auth + .get_token_data() + .context("token data should refresh")?; + assert_eq!(cached, refreshed_tokens); + let stored = ctx.load_auth()?; + let tokens = stored.tokens.as_ref().context("tokens should exist")?; + assert_eq!(tokens, &refreshed_tokens); + let refreshed_at = stored + .last_refresh + .as_ref() + .context("last_refresh should be recorded")?; + assert!( + *refreshed_at >= initial_last_refresh, + "last_refresh should advance" + ); + + server.verify().await; + Ok(()) +} + +#[serial_test::serial(auth_refresh)] +#[tokio::test] +async fn auth_skips_access_token_outside_refresh_window() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = MockServer::start().await; + let ctx = RefreshTokenTestContext::new(&server).await?; + let initial_last_refresh = Utc::now(); + let fresh_access_token = access_token_with_expiration(Utc::now() + Duration::minutes(6)); + let initial_tokens = build_tokens(&fresh_access_token, INITIAL_REFRESH_TOKEN); + let initial_auth = AuthDotJson { + auth_mode: Some(AuthMode::Chatgpt), + openai_api_key: None, + tokens: Some(initial_tokens.clone()), + last_refresh: Some(initial_last_refresh), + agent_identity: None, + }; + ctx.write_auth(&initial_auth).await?; + + let cached_auth = ctx + .auth_manager + .auth() + .await + .context("auth should be cached")?; + + let cached = cached_auth + .get_token_data() + .context("token data should remain cached")?; + assert_eq!(cached, initial_tokens); + assert_eq!(ctx.load_auth()?, initial_auth); + let requests = server.received_requests().await.unwrap_or_default(); + assert!(requests.is_empty(), "expected no refresh token requests"); + + Ok(()) +} + #[serial_test::serial(auth_refresh)] #[tokio::test] async fn refresh_token_skips_refresh_when_auth_changed() -> Result<()> { @@ -625,6 +721,73 @@ async fn refresh_token_does_not_retry_after_permanent_failure() -> Result<()> { Ok(()) } +#[serial_test::serial(auth_refresh)] +#[tokio::test] +async fn refresh_token_does_not_retry_after_bad_request_reused_failure() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/oauth/token")) + .respond_with(ResponseTemplate::new(400).set_body_json(json!({ + "error": { + "code": "refresh_token_reused" + } + }))) + .expect(1) + .mount(&server) + .await; + + let ctx = RefreshTokenTestContext::new(&server).await?; + let initial_last_refresh = Utc::now() - Duration::days(1); + let initial_tokens = build_tokens(INITIAL_ACCESS_TOKEN, INITIAL_REFRESH_TOKEN); + let initial_auth = AuthDotJson { + auth_mode: Some(AuthMode::Chatgpt), + openai_api_key: None, + tokens: Some(initial_tokens.clone()), + last_refresh: Some(initial_last_refresh), + agent_identity: None, + }; + ctx.write_auth(&initial_auth).await?; + + let first_err = ctx + .auth_manager + .refresh_token() + .await + .err() + .context("first refresh should fail")?; + assert_eq!( + first_err.failed_reason(), + Some(RefreshTokenFailedReason::Exhausted) + ); + + let second_err = ctx + .auth_manager + .refresh_token() + .await + .err() + .context("second refresh should fail without retrying")?; + assert_eq!( + second_err.failed_reason(), + Some(RefreshTokenFailedReason::Exhausted) + ); + + let stored = ctx.load_auth()?; + assert_eq!(stored, initial_auth); + let cached_auth = ctx + .auth_manager + .auth() + .await + .context("auth should remain cached")?; + let cached = cached_auth + .get_token_data() + .context("token data should remain cached")?; + assert_eq!(cached, initial_tokens); + + server.verify().await; + Ok(()) +} + #[serial_test::serial(auth_refresh)] #[tokio::test] async fn refresh_token_reloads_changed_auth_after_permanent_failure() -> Result<()> { diff --git a/codex-rs/mcp-server/src/codex_tool_config.rs b/codex-rs/mcp-server/src/codex_tool_config.rs index 9c9a3da53cb..f90686dbea1 100644 --- a/codex-rs/mcp-server/src/codex_tool_config.rs +++ b/codex-rs/mcp-server/src/codex_tool_config.rs @@ -116,20 +116,13 @@ pub(crate) fn create_tool_for_codex_tool_call_param() -> Tool { let input_schema = create_tool_input_schema(schema, "Codex tool schema should serialize"); - Tool { - name: "codex".into(), - title: Some("Codex".to_string()), + Tool::new( + "codex", + "Run a Codex session. Accepts configuration parameters matching the Codex Config struct.", input_schema, - output_schema: Some(codex_tool_output_schema()), - description: Some( - "Run a Codex session. Accepts configuration parameters matching the Codex Config struct." - .into(), - ), - annotations: None, - execution: None, - icons: None, - meta: None, - } + ) + .with_title("Codex") + .with_raw_output_schema(codex_tool_output_schema()) } fn codex_tool_output_schema() -> Arc { @@ -242,19 +235,13 @@ pub(crate) fn create_tool_for_codex_tool_call_reply_param() -> Tool { let input_schema = create_tool_input_schema(schema, "Codex reply tool schema should serialize"); - Tool { - name: "codex-reply".into(), - title: Some("Codex Reply".to_string()), + Tool::new( + "codex-reply", + "Continue a Codex conversation by providing the thread id and prompt.", input_schema, - output_schema: Some(codex_tool_output_schema()), - description: Some( - "Continue a Codex conversation by providing the thread id and prompt.".into(), - ), - annotations: None, - execution: None, - icons: None, - meta: None, - } + ) + .with_title("Codex Reply") + .with_raw_output_schema(codex_tool_output_schema()) } fn create_tool_input_schema( diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 78714d1eaa0..62615fd16c5 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -44,12 +44,10 @@ pub(crate) fn create_call_tool_result_with_thread_id( "threadId": thread_id, "content": content_text, }); - CallToolResult { - content, - is_error, - structured_content: Some(structured_content), - meta: None, - } + let mut result = CallToolResult::success(content); + result.is_error = is_error; + result.structured_content = Some(structured_content); + result } /// Run a complete Codex session and stream events back to the client. @@ -71,12 +69,9 @@ pub async fn run_codex_tool_session( } = match thread_manager.start_thread(config.clone()).await { Ok(res) => res, Err(e) => { - let result = CallToolResult { - content: vec![Content::text(format!("Failed to start Codex session: {e}"))], - is_error: Some(true), - structured_content: None, - meta: None, - }; + let result = CallToolResult::error(vec![Content::text(format!( + "Failed to start Codex session: {e}" + ))]); outgoing.send_response(id.clone(), result).await; return; } diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index 9e536d930cf..3123bcf335d 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -27,7 +27,6 @@ use rmcp::model::JsonRpcRequest; use rmcp::model::JsonRpcResponse; use rmcp::model::RequestId; use rmcp::model::ServerCapabilities; -use rmcp::model::ToolsCapability; use serde_json::json; use tokio::sync::Mutex; use tokio::task; @@ -218,14 +217,8 @@ impl MessageProcessor { *suffix = Some(user_agent_suffix); } - let server_info = Implementation { - name: "codex-mcp-server".to_string(), - title: Some("Codex".to_string()), - version: env!("CARGO_PKG_VERSION").to_string(), - description: None, - icons: None, - website_url: None, - }; + let server_info = + Implementation::new("codex-mcp-server", env!("CARGO_PKG_VERSION")).with_title("Codex"); // Preserve Codex's existing non-spec `serverInfo.user_agent` field. let mut server_info_value = match serde_json::to_value(&server_info) { @@ -247,17 +240,14 @@ impl MessageProcessor { obj.insert("user_agent".to_string(), json!(get_codex_user_agent())); } - let mut result_value = match serde_json::to_value(InitializeResult { - capabilities: ServerCapabilities { - tools: Some(ToolsCapability { - list_changed: Some(true), - }), - ..Default::default() - }, - instructions: None, - protocol_version: params.protocol_version.clone(), - server_info, - }) { + let capabilities = ServerCapabilities::builder() + .enable_tools() + .enable_tool_list_changed() + .build(); + let result = InitializeResult::new(capabilities) + .with_protocol_version(params.protocol_version.clone()) + .with_server_info(server_info); + let mut result_value = match serde_json::to_value(result) { Ok(value) => value, Err(err) => { self.outgoing @@ -345,12 +335,9 @@ impl MessageProcessor { .await } _ => { - let result = CallToolResult { - content: vec![rmcp::model::Content::text(format!("Unknown tool '{name}'"))], - structured_content: None, - is_error: Some(true), - meta: None, - }; + let result = CallToolResult::error(vec![rmcp::model::Content::text(format!( + "Unknown tool '{name}'" + ))]); self.outgoing.send_response(id, result).await; } } @@ -367,40 +354,25 @@ impl MessageProcessor { Ok(tool_cfg) => match tool_cfg.into_config(self.arg0_paths.clone()).await { Ok(cfg) => cfg, Err(e) => { - let result = CallToolResult { - content: vec![rmcp::model::Content::text(format!( - "Failed to load Codex configuration from overrides: {e}" - ))], - structured_content: None, - is_error: Some(true), - meta: None, - }; + let result = CallToolResult::error(vec![rmcp::model::Content::text( + format!("Failed to load Codex configuration from overrides: {e}"), + )]); self.outgoing.send_response(id, result).await; return; } }, Err(e) => { - let result = CallToolResult { - content: vec![rmcp::model::Content::text(format!( - "Failed to parse configuration for Codex tool: {e}" - ))], - structured_content: None, - is_error: Some(true), - meta: None, - }; + let result = CallToolResult::error(vec![rmcp::model::Content::text(format!( + "Failed to parse configuration for Codex tool: {e}" + ))]); self.outgoing.send_response(id, result).await; return; } }, None => { - let result = CallToolResult { - content: vec![rmcp::model::Content::text( - "Missing arguments for codex tool-call; the `prompt` field is required.", - )], - structured_content: None, - is_error: Some(true), - meta: None, - }; + let result = CallToolResult::error(vec![rmcp::model::Content::text( + "Missing arguments for codex tool-call; the `prompt` field is required.", + )]); self.outgoing.send_response(id, result).await; return; } @@ -441,14 +413,9 @@ impl MessageProcessor { Ok(params) => params, Err(e) => { tracing::error!("Failed to parse Codex tool call reply parameters: {e}"); - let result = CallToolResult { - content: vec![rmcp::model::Content::text(format!( - "Failed to parse configuration for Codex tool: {e}" - ))], - structured_content: None, - is_error: Some(true), - meta: None, - }; + let result = CallToolResult::error(vec![rmcp::model::Content::text(format!( + "Failed to parse configuration for Codex tool: {e}" + ))]); self.outgoing.send_response(request_id, result).await; return; } @@ -457,14 +424,9 @@ impl MessageProcessor { tracing::error!( "Missing arguments for codex-reply tool-call; the `thread_id` and `prompt` fields are required." ); - let result = CallToolResult { - content: vec![rmcp::model::Content::text( - "Missing arguments for codex-reply tool-call; the `thread_id` and `prompt` fields are required.", - )], - structured_content: None, - is_error: Some(true), - meta: None, - }; + let result = CallToolResult::error(vec![rmcp::model::Content::text( + "Missing arguments for codex-reply tool-call; the `thread_id` and `prompt` fields are required.", + )]); self.outgoing.send_response(request_id, result).await; return; } @@ -474,14 +436,9 @@ impl MessageProcessor { Ok(id) => id, Err(e) => { tracing::error!("Failed to parse thread_id: {e}"); - let result = CallToolResult { - content: vec![rmcp::model::Content::text(format!( - "Failed to parse thread_id: {e}" - ))], - structured_content: None, - is_error: Some(true), - meta: None, - }; + let result = CallToolResult::error(vec![rmcp::model::Content::text(format!( + "Failed to parse thread_id: {e}" + ))]); self.outgoing.send_response(request_id, result).await; return; } diff --git a/codex-rs/mcp-server/src/outgoing_message.rs b/codex-rs/mcp-server/src/outgoing_message.rs index b2882643ce7..e6ecdd441bb 100644 --- a/codex-rs/mcp-server/src/outgoing_message.rs +++ b/codex-rs/mcp-server/src/outgoing_message.rs @@ -169,7 +169,7 @@ impl From for OutgoingJsonRpcMessage { } Error(OutgoingError { id, error }) => JsonRpcMessage::Error(JsonRpcError { jsonrpc: JsonRpcVersion2_0, - id, + id: Some(id), error, }), } diff --git a/codex-rs/mcp-server/tests/common/mcp_process.rs b/codex-rs/mcp-server/tests/common/mcp_process.rs index f50f25f49ff..007d2c11cca 100644 --- a/codex-rs/mcp-server/tests/common/mcp_process.rs +++ b/codex-rs/mcp-server/tests/common/mcp_process.rs @@ -114,31 +114,18 @@ impl McpProcess { pub async fn initialize(&mut self) -> anyhow::Result<()> { let request_id = self.next_request_id.fetch_add(1, Ordering::Relaxed); - let params = InitializeRequestParams { - meta: None, - capabilities: ClientCapabilities { - elicitation: Some(ElicitationCapability { - form: Some(FormElicitationCapability { - schema_validation: None, - }), - url: None, - }), - experimental: None, - extensions: None, - roots: None, - sampling: None, - tasks: None, - }, - client_info: Implementation { - name: "elicitation test".into(), - title: Some("Elicitation Test".into()), - version: "0.0.0".into(), - description: None, - icons: None, - website_url: None, - }, - protocol_version: ProtocolVersion::V_2025_03_26, - }; + let mut capabilities = ClientCapabilities::default(); + capabilities.elicitation = Some(ElicitationCapability { + form: Some(FormElicitationCapability { + schema_validation: None, + }), + url: None, + }); + let params = InitializeRequestParams::new( + capabilities, + Implementation::new("elicitation test", "0.0.0").with_title("Elicitation Test"), + ) + .with_protocol_version(ProtocolVersion::V_2025_03_26); let params_value = serde_json::to_value(params)?; self.send_jsonrpc_message(JsonRpcMessage::Request(JsonRpcRequest { @@ -203,15 +190,12 @@ impl McpProcess { &mut self, params: CodexToolCallParam, ) -> anyhow::Result { - let codex_tool_call_params = CallToolRequestParams { - meta: None, - name: "codex".into(), - arguments: Some(match serde_json::to_value(params)? { + let codex_tool_call_params = CallToolRequestParams::new("codex").with_arguments( + match serde_json::to_value(params)? { serde_json::Value::Object(map) => map, _ => unreachable!("params serialize to object"), - }), - task: None, - }; + }, + ); self.send_request( "tools/call", Some(serde_json::to_value(codex_tool_call_params)?), diff --git a/codex-rs/memories/write/src/start.rs b/codex-rs/memories/write/src/start.rs index 007f5f8bbcc..809bf775b10 100644 --- a/codex-rs/memories/write/src/start.rs +++ b/codex-rs/memories/write/src/start.rs @@ -50,6 +50,10 @@ pub fn start_memories_startup_task( tokio::spawn(async move { let root = memory_root(&config.codex_home); + if let Err(err) = tokio::fs::create_dir_all(&root).await { + warn!("failed creating memories root: {err}"); + return; + } if let Err(err) = seed_extension_instructions(&root).await { warn!("failed seeding memory extension instructions: {err}"); } diff --git a/codex-rs/memories/write/src/startup_tests.rs b/codex-rs/memories/write/src/startup_tests.rs index 1a735b3668a..bd0a9904e65 100644 --- a/codex-rs/memories/write/src/startup_tests.rs +++ b/codex-rs/memories/write/src/startup_tests.rs @@ -26,6 +26,21 @@ use tempfile::TempDir; use tokio::time::Duration; use tokio::time::Instant; +#[tokio::test] +async fn memories_startup_creates_memory_root() -> anyhow::Result<()> { + let server = start_mock_server().await; + let home = Arc::new(TempDir::new()?); + let memory_root = home.path().join("memories"); + let test = build_test_codex(&server, home).await?; + + assert!(!memory_root.exists()); + trigger_memories_startup(&test).await; + wait_for_dir(&memory_root).await?; + + shutdown_test_codex(&test).await?; + Ok(()) +} + #[tokio::test] async fn memories_startup_phase2_tracks_workspace_diff_across_runs() -> anyhow::Result<()> { let server = start_mock_server().await; @@ -408,6 +423,21 @@ async fn wait_for_file_removed(path: &Path) -> anyhow::Result<()> { } } +async fn wait_for_dir(path: &Path) -> anyhow::Result<()> { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + if tokio::fs::try_exists(path).await? && path.is_dir() { + return Ok(()); + } + assert!( + Instant::now() < deadline, + "timed out waiting for {} to be created", + path.display() + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + async fn wait_for_request(mock: &ResponseMock, expected_count: usize) -> Vec { let deadline = Instant::now() + Duration::from_secs(10); loop { diff --git a/codex-rs/model-provider-info/src/lib.rs b/codex-rs/model-provider-info/src/lib.rs index 65f71851d69..c20a51a3aa6 100644 --- a/codex-rs/model-provider-info/src/lib.rs +++ b/codex-rs/model-provider-info/src/lib.rs @@ -37,6 +37,7 @@ pub const OPENAI_PROVIDER_ID: &str = "openai"; pub const CHATGPT_CODEX_BASE_URL: &str = "https://chatgpt.com/backend-api/codex"; const AMAZON_BEDROCK_PROVIDER_NAME: &str = "Amazon Bedrock"; pub const AMAZON_BEDROCK_PROVIDER_ID: &str = "amazon-bedrock"; +pub const AMAZON_BEDROCK_GPT_5_5_MODEL_ID: &str = "openai.gpt-5.5"; pub const AMAZON_BEDROCK_GPT_5_4_MODEL_ID: &str = "openai.gpt-5.4"; pub const AMAZON_BEDROCK_DEFAULT_BASE_URL: &str = "https://bedrock-mantle.us-east-1.api.aws/openai/v1"; diff --git a/codex-rs/model-provider/src/amazon_bedrock/catalog.rs b/codex-rs/model-provider/src/amazon_bedrock/catalog.rs index 8fec7f835e4..97a817fd21b 100644 --- a/codex-rs/model-provider/src/amazon_bedrock/catalog.rs +++ b/codex-rs/model-provider/src/amazon_bedrock/catalog.rs @@ -1,149 +1,46 @@ use codex_model_provider_info::AMAZON_BEDROCK_GPT_5_4_MODEL_ID; -use codex_models_manager::model_info::BASE_INSTRUCTIONS; -use codex_protocol::config_types::ReasoningSummary; -use codex_protocol::config_types::ServiceTier; -use codex_protocol::config_types::Verbosity; -use codex_protocol::openai_models::ApplyPatchToolType; -use codex_protocol::openai_models::ConfigShellToolType; -use codex_protocol::openai_models::InputModality; +use codex_model_provider_info::AMAZON_BEDROCK_GPT_5_5_MODEL_ID; +use codex_models_manager::bundled_models_response; use codex_protocol::openai_models::ModelInfo; -use codex_protocol::openai_models::ModelServiceTier; -use codex_protocol::openai_models::ModelVisibility; use codex_protocol::openai_models::ModelsResponse; -use codex_protocol::openai_models::ReasoningEffort; -use codex_protocol::openai_models::ReasoningEffortPreset; -use codex_protocol::openai_models::SPEED_TIER_FAST; -use codex_protocol::openai_models::TruncationPolicyConfig; -use codex_protocol::openai_models::WebSearchToolType; -const GPT_OSS_CONTEXT_WINDOW: i64 = 128_000; -const GPT_5_4_CONTEXT_WINDOW: i64 = 272_000; -const GPT_5_4_MAX_CONTEXT_WINDOW: i64 = 1_000_000; +const GPT_5_BEDROCK_CONTEXT_WINDOW: i64 = 272_000; +const GPT_5_5_OPENAI_MODEL_ID: &str = "gpt-5.5"; +const GPT_5_4_OPENAI_MODEL_ID: &str = "gpt-5.4"; pub(crate) fn static_model_catalog() -> ModelsResponse { ModelsResponse { models: vec![ - gpt_5_4_cmb_bedrock_model(/*priority*/ 0), - bedrock_oss_model( - "openai.gpt-oss-120b", - "GPT OSS 120B on Bedrock", - /*priority*/ 1, + gpt_5_bedrock_model( + GPT_5_5_OPENAI_MODEL_ID, + AMAZON_BEDROCK_GPT_5_5_MODEL_ID, + /*priority*/ 0, ), - bedrock_oss_model( - "openai.gpt-oss-20b", - "GPT OSS 20B on Bedrock", - /*priority*/ 2, + gpt_5_bedrock_model( + GPT_5_4_OPENAI_MODEL_ID, + AMAZON_BEDROCK_GPT_5_4_MODEL_ID, + /*priority*/ 1, ), ], } } -fn gpt_5_4_cmb_bedrock_model(priority: i32) -> ModelInfo { - ModelInfo { - slug: AMAZON_BEDROCK_GPT_5_4_MODEL_ID.to_string(), - display_name: "gpt-5.4".to_string(), - description: Some("Strong model for everyday coding.".to_string()), - default_reasoning_level: Some(ReasoningEffort::Medium), - supported_reasoning_levels: gpt_5_4_cmb_reasoning_levels(), - shell_type: ConfigShellToolType::ShellCommand, - visibility: ModelVisibility::List, - supported_in_api: true, - priority, - additional_speed_tiers: Vec::new(), - service_tiers: vec![ModelServiceTier { - id: ServiceTier::Fast.request_value().to_string(), - name: SPEED_TIER_FAST.to_string(), - description: "Fastest inference with increased plan usage".to_string(), - }], - default_service_tier: None, - availability_nux: None, - upgrade: None, - base_instructions: BASE_INSTRUCTIONS.to_string(), - model_messages: None, - supports_reasoning_summaries: true, - default_reasoning_summary: ReasoningSummary::None, - support_verbosity: true, - default_verbosity: Some(Verbosity::Medium), - apply_patch_tool_type: Some(ApplyPatchToolType::Freeform), - web_search_tool_type: WebSearchToolType::TextAndImage, - truncation_policy: TruncationPolicyConfig::tokens(/*limit*/ 10_000), - supports_parallel_tool_calls: true, - supports_image_detail_original: true, - context_window: Some(GPT_5_4_CONTEXT_WINDOW), - max_context_window: Some(GPT_5_4_MAX_CONTEXT_WINDOW), - auto_compact_token_limit: None, - effective_context_window_percent: 95, - experimental_supported_tools: Vec::new(), - input_modalities: vec![InputModality::Text, InputModality::Image], - used_fallback_model_metadata: false, - supports_search_tool: true, - } -} - -fn bedrock_oss_model(slug: &str, display_name: &str, priority: i32) -> ModelInfo { - ModelInfo { - slug: slug.to_string(), - display_name: display_name.to_string(), - description: Some(display_name.to_string()), - default_reasoning_level: Some(ReasoningEffort::Medium), - supported_reasoning_levels: vec![ - reasoning_effort_preset(ReasoningEffort::Low), - reasoning_effort_preset(ReasoningEffort::Medium), - reasoning_effort_preset(ReasoningEffort::High), - ], - shell_type: ConfigShellToolType::ShellCommand, - visibility: ModelVisibility::List, - supported_in_api: true, - priority, - additional_speed_tiers: Vec::new(), - service_tiers: Vec::new(), - default_service_tier: None, - availability_nux: None, - upgrade: None, - base_instructions: BASE_INSTRUCTIONS.to_string(), - model_messages: None, - supports_reasoning_summaries: true, - default_reasoning_summary: ReasoningSummary::None, - support_verbosity: false, - default_verbosity: None, - apply_patch_tool_type: None, - web_search_tool_type: WebSearchToolType::Text, - truncation_policy: TruncationPolicyConfig::tokens(/*limit*/ 10_000), - supports_parallel_tool_calls: true, - supports_image_detail_original: false, - context_window: Some(GPT_OSS_CONTEXT_WINDOW), - max_context_window: Some(GPT_OSS_CONTEXT_WINDOW), - auto_compact_token_limit: None, - effective_context_window_percent: 95, - experimental_supported_tools: Vec::new(), - input_modalities: vec![InputModality::Text], - used_fallback_model_metadata: false, - supports_search_tool: false, - } -} - -fn gpt_5_4_cmb_reasoning_levels() -> Vec { - vec![ - reasoning_effort_preset(ReasoningEffort::Minimal), - reasoning_effort_preset(ReasoningEffort::Low), - reasoning_effort_preset(ReasoningEffort::Medium), - reasoning_effort_preset(ReasoningEffort::High), - ] +fn gpt_5_bedrock_model(openai_slug: &str, bedrock_slug: &str, priority: i32) -> ModelInfo { + let mut model = bundled_openai_model(openai_slug); + model.slug = bedrock_slug.to_string(); + model.priority = priority; + model.context_window = Some(GPT_5_BEDROCK_CONTEXT_WINDOW); + model.max_context_window = Some(GPT_5_BEDROCK_CONTEXT_WINDOW); + model } -fn reasoning_effort_preset(effort: ReasoningEffort) -> ReasoningEffortPreset { - ReasoningEffortPreset { - effort, - description: match effort { - ReasoningEffort::None => "No reasoning", - ReasoningEffort::Minimal => "Minimal reasoning", - ReasoningEffort::Low => "Fast responses with lighter reasoning", - ReasoningEffort::Medium => "Balances speed and reasoning depth for everyday tasks", - ReasoningEffort::High => "Greater reasoning depth for complex problems", - ReasoningEffort::XHigh => "Extra high reasoning depth for complex problems", - } - .to_string(), - } +fn bundled_openai_model(slug: &str) -> ModelInfo { + bundled_models_response() + .unwrap_or_else(|err| panic!("bundled models.json should parse: {err}")) + .models + .into_iter() + .find(|model| model.slug == slug) + .unwrap_or_else(|| panic!("bundled models.json should include {slug}")) } #[cfg(test)] @@ -156,24 +53,38 @@ mod tests { fn catalog_uses_mantle_model_ids_as_slugs() { let catalog = static_model_catalog(); - assert_eq!(catalog.models.len(), 3); - assert_eq!(catalog.models[0].slug, AMAZON_BEDROCK_GPT_5_4_MODEL_ID); - assert_eq!(catalog.models[1].slug, "openai.gpt-oss-120b"); - assert_eq!(catalog.models[2].slug, "openai.gpt-oss-20b"); + assert_eq!(catalog.models.len(), 2); + assert_eq!(catalog.models[0].slug, AMAZON_BEDROCK_GPT_5_5_MODEL_ID); + assert_eq!(catalog.models[1].slug, AMAZON_BEDROCK_GPT_5_4_MODEL_ID); } #[test] - fn gpt_5_4_cmb_advertises_only_bedrock_supported_reasoning_levels() { + fn gpt_5_bedrock_models_use_bedrock_context_window() { let catalog = static_model_catalog(); - let cmb_model = catalog + let gpt_5_5 = catalog + .models + .iter() + .find(|model| model.slug == AMAZON_BEDROCK_GPT_5_5_MODEL_ID) + .expect("Bedrock catalog should include GPT-5.5"); + let gpt_5_4 = catalog .models .iter() .find(|model| model.slug == AMAZON_BEDROCK_GPT_5_4_MODEL_ID) - .expect("Bedrock catalog should include GPT-5.4 CMB"); + .expect("Bedrock catalog should include GPT-5.4"); assert_eq!( - cmb_model.supported_reasoning_levels, - gpt_5_4_cmb_reasoning_levels() + (gpt_5_5.context_window, gpt_5_5.max_context_window), + ( + Some(GPT_5_BEDROCK_CONTEXT_WINDOW), + Some(GPT_5_BEDROCK_CONTEXT_WINDOW) + ) + ); + assert_eq!( + (gpt_5_4.context_window, gpt_5_4.max_context_window), + ( + Some(GPT_5_BEDROCK_CONTEXT_WINDOW), + Some(GPT_5_BEDROCK_CONTEXT_WINDOW) + ) ); } } diff --git a/codex-rs/model-provider/src/provider.rs b/codex-rs/model-provider/src/provider.rs index 1ef7c22962b..f5e5ab2ff5e 100644 --- a/codex-rs/model-provider/src/provider.rs +++ b/codex-rs/model-provider/src/provider.rs @@ -507,14 +507,7 @@ mod tests { .map(|model| model.slug.as_str()) .collect::>(); - assert_eq!( - model_ids, - vec![ - "openai.gpt-5.4", - "openai.gpt-oss-120b", - "openai.gpt-oss-20b" - ] - ); + assert_eq!(model_ids, vec!["openai.gpt-5.5", "openai.gpt-5.4"]); let default_model = manager .list_models(RefreshStrategy::Online) @@ -523,7 +516,7 @@ mod tests { .find(|preset| preset.is_default) .expect("Bedrock catalog should have a default model"); - assert_eq!(default_model.model, "openai.gpt-5.4"); + assert_eq!(default_model.model, "openai.gpt-5.5"); } #[tokio::test] diff --git a/codex-rs/otel/src/metrics/names.rs b/codex-rs/otel/src/metrics/names.rs index 817545c87d2..d2093ed3f78 100644 --- a/codex-rs/otel/src/metrics/names.rs +++ b/codex-rs/otel/src/metrics/names.rs @@ -28,6 +28,10 @@ pub const TURN_NETWORK_PROXY_METRIC: &str = "codex.turn.network_proxy"; pub const TURN_MEMORY_METRIC: &str = "codex.turn.memory"; pub const TURN_TOOL_CALL_METRIC: &str = "codex.turn.tool.call"; pub const TURN_TOKEN_USAGE_METRIC: &str = "codex.turn.token_usage"; +pub const GUARDIAN_REVIEW_COUNT_METRIC: &str = "codex.guardian.review"; +pub const GUARDIAN_REVIEW_DURATION_METRIC: &str = "codex.guardian.review.duration_ms"; +pub const GUARDIAN_REVIEW_TTFT_DURATION_METRIC: &str = "codex.guardian.review.ttft.duration_ms"; +pub const GUARDIAN_REVIEW_TOKEN_USAGE_METRIC: &str = "codex.guardian.review.token_usage"; pub const GOAL_CREATED_METRIC: &str = "codex.goal.created"; pub const GOAL_RESUMED_METRIC: &str = "codex.goal.resumed"; pub const GOAL_COMPLETED_METRIC: &str = "codex.goal.completed"; diff --git a/codex-rs/protocol/src/mcp.rs b/codex-rs/protocol/src/mcp.rs index f6e69743b92..a1916d424c9 100644 --- a/codex-rs/protocol/src/mcp.rs +++ b/codex-rs/protocol/src/mcp.rs @@ -26,6 +26,18 @@ impl std::fmt::Display for RequestId { } } +/// Presentation metadata advertised by an initialized MCP server. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct McpServerInfo { + pub name: String, + pub title: Option, + pub version: String, + pub description: Option, + pub icons: Option>, + pub website_url: Option, +} + /// Definition for a tool the client can call. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] #[serde(rename_all = "camelCase")] diff --git a/codex-rs/rmcp-client/Cargo.toml b/codex-rs/rmcp-client/Cargo.toml index 0efd8a4178a..63c6d74e4a5 100644 --- a/codex-rs/rmcp-client/Cargo.toml +++ b/codex-rs/rmcp-client/Cargo.toml @@ -11,6 +11,7 @@ workspace = true anyhow = "1" axum = { workspace = true, default-features = false, features = [ "http1", + "json", "tokio", ] } base64 = { workspace = true } @@ -26,10 +27,10 @@ bytes = { workspace = true } futures = { workspace = true, default-features = false, features = ["std"] } keyring = { workspace = true, features = ["crypto-rust"] } oauth2 = "5" -reqwest = { version = "0.12", default-features = false, features = [ +reqwest = { version = "0.13", default-features = false, features = [ "json", "stream", - "rustls-tls", + "rustls", ] } rmcp = { workspace = true, default-features = false, features = [ "auth", diff --git a/codex-rs/rmcp-client/src/bin/rmcp_test_server.rs b/codex-rs/rmcp-client/src/bin/rmcp_test_server.rs index 5bf32124d0f..6911afb2116 100644 --- a/codex-rs/rmcp-client/src/bin/rmcp_test_server.rs +++ b/codex-rs/rmcp-client/src/bin/rmcp_test_server.rs @@ -79,13 +79,12 @@ struct EchoArgs { impl ServerHandler for TestToolServer { fn get_info(&self) -> ServerInfo { - ServerInfo { - capabilities: ServerCapabilities::builder() + ServerInfo::new( + ServerCapabilities::builder() .enable_tools() .enable_tool_list_changed() .build(), - ..ServerInfo::default() - } + ) } fn list_tools( @@ -130,12 +129,9 @@ impl ServerHandler for TestToolServer { "env": env_snapshot.get(env_name), }); - Ok(CallToolResult { - content: Vec::new(), - structured_content: Some(structured_content), - is_error: Some(false), - meta: None, - }) + let mut result = CallToolResult::success(Vec::new()); + result.structured_content = Some(structured_content); + Ok(result) } other => Err(McpError::invalid_params( format!("unknown tool: {other}"), 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 50657ab182b..4407a27eeb3 100644 --- a/codex-rs/rmcp-client/src/bin/test_stdio_server.rs +++ b/codex-rs/rmcp-client/src/bin/test_stdio_server.rs @@ -407,11 +407,8 @@ impl ServerHandler for TestToolServer { JsonObject::new(), )])); - ServerInfo { - instructions: Some("Use these tools to exercise the rmcp test server.".to_string()), - capabilities, - ..ServerInfo::default() - } + ServerInfo::new(capabilities) + .with_instructions("Use these tools to exercise the rmcp test server.") } fn list_tools( @@ -462,14 +459,14 @@ impl ServerHandler for TestToolServer { _context: rmcp::service::RequestContext, ) -> Result { if uri == MEMO_URI { - Ok(ReadResourceResult { - contents: vec![ResourceContents::TextResourceContents { + Ok(ReadResourceResult::new(vec![ + ResourceContents::TextResourceContents { uri, mime_type: Some("text/plain".to_string()), text: Self::memo_text().to_string(), meta: None, - }], - }) + }, + ])) } else { Err(McpError::resource_not_found( "resource_not_found", @@ -484,22 +481,14 @@ impl ServerHandler for TestToolServer { context: rmcp::service::RequestContext, ) -> Result { match request.name.as_ref() { - "sandbox_meta" => Ok(CallToolResult { - content: Vec::new(), - structured_content: Some(serde_json::Value::Object(context.meta.0)), - is_error: Some(false), - meta: None, - }), + "sandbox_meta" => Ok(Self::structured_result(serde_json::Value::Object( + context.meta.0, + ))), "cwd" => { let cwd = std::env::current_dir() .map(|path| path.to_string_lossy().into_owned()) .map_err(|err| McpError::internal_error(err.to_string(), None))?; - Ok(CallToolResult { - content: Vec::new(), - structured_content: Some(json!({ "cwd": cwd })), - is_error: Some(false), - meta: None, - }) + Ok(Self::structured_result(json!({ "cwd": cwd }))) } "echo" | "echo-tool" => { let args: EchoArgs = match request.arguments { @@ -522,12 +511,7 @@ impl ServerHandler for TestToolServer { "env": env_snapshot.get(env_name), }); - Ok(CallToolResult { - content: Vec::new(), - structured_content: Some(structured_content), - is_error: Some(false), - meta: None, - }) + Ok(Self::structured_result(structured_content)) } "image" => { // Read a data URL (e.g. data:image/png;base64,AAA...) from env and convert to @@ -677,12 +661,13 @@ impl TestToolServer { sleep(Duration::from_millis(delay)).await; } - Ok(CallToolResult { - content: Vec::new(), - structured_content: Some(json!({ "result": "ok" })), - is_error: Some(false), - meta: None, - }) + Ok(Self::structured_result(json!({ "result": "ok" }))) + } + + fn structured_result(value: serde_json::Value) -> CallToolResult { + let mut result = CallToolResult::success(Vec::new()); + result.structured_content = Some(value); + result } } diff --git a/codex-rs/rmcp-client/src/bin/test_streamable_http_server.rs b/codex-rs/rmcp-client/src/bin/test_streamable_http_server.rs index d1c22f430c9..2384d394736 100644 --- a/codex-rs/rmcp-client/src/bin/test_streamable_http_server.rs +++ b/codex-rs/rmcp-client/src/bin/test_streamable_http_server.rs @@ -11,12 +11,14 @@ use axum::body::Body; use axum::extract::Json; use axum::extract::State; use axum::http::HeaderMap; +use axum::http::HeaderValue; use axum::http::Method; use axum::http::Request; use axum::http::StatusCode; use axum::http::header::AUTHORIZATION; use axum::http::header::CONTENT_TYPE; use axum::http::header::HOST; +use axum::http::header::WWW_AUTHENTICATE; use axum::middleware; use axum::middleware::Next; use axum::response::Response; @@ -72,12 +74,17 @@ struct SessionFailureState { struct ArmedFailure { status: StatusCode, remaining: usize, + /// Raw `WWW-Authenticate` challenge header field values returned with the failure. + www_authenticate_headers: Vec, } #[derive(Debug, Deserialize)] struct ArmSessionPostFailureRequest { status: u16, remaining: usize, + /// Raw `WWW-Authenticate` challenge header field values to add to the failure. + #[serde(default)] + www_authenticate_headers: Vec, } #[derive(Deserialize)] @@ -174,14 +181,13 @@ async fn main() -> Result<(), Box> { impl ServerHandler for TestToolServer { fn get_info(&self) -> ServerInfo { - ServerInfo { - capabilities: ServerCapabilities::builder() + ServerInfo::new( + ServerCapabilities::builder() .enable_tools() .enable_tool_list_changed() .enable_resources() .build(), - ..ServerInfo::default() - } + ) } fn list_tools( @@ -232,14 +238,14 @@ impl ServerHandler for TestToolServer { _context: rmcp::service::RequestContext, ) -> Result { if uri == MEMO_URI { - Ok(ReadResourceResult { - contents: vec![ResourceContents::TextResourceContents { + Ok(ReadResourceResult::new(vec![ + ResourceContents::TextResourceContents { uri, mime_type: Some("text/plain".to_string()), text: Self::memo_text().to_string(), meta: None, - }], - }) + }, + ])) } else { Err(McpError::resource_not_found( "resource_not_found", @@ -274,12 +280,9 @@ impl ServerHandler for TestToolServer { "env": env_snapshot.get("MCP_TEST_VALUE"), }); - Ok(CallToolResult { - content: Vec::new(), - structured_content: Some(structured_content), - is_error: Some(false), - meta: None, - }) + let mut result = CallToolResult::success(Vec::new()); + result.structured_content = Some(structured_content); + Ok(result) } other => Err(McpError::invalid_params( format!("unknown tool: {other}"), @@ -405,12 +408,18 @@ async fn arm_session_post_failure( Json(request): Json, ) -> Result { let status = StatusCode::from_u16(request.status).map_err(|_| StatusCode::BAD_REQUEST)?; + let www_authenticate_headers = request + .www_authenticate_headers + .into_iter() + .map(|value| HeaderValue::from_str(&value).map_err(|_| StatusCode::BAD_REQUEST)) + .collect::, _>>()?; let armed_failure = if request.remaining == 0 { None } else { Some(ArmedFailure { status, remaining: request.remaining, + www_authenticate_headers, }) }; *state.armed_failure.lock().await = armed_failure; @@ -436,6 +445,7 @@ async fn fail_session_post_when_armed( { failure.remaining -= 1; let status = failure.status; + let www_authenticate_headers = failure.www_authenticate_headers.clone(); if failure.remaining == 0 { *armed_failure = None; } @@ -443,6 +453,11 @@ async fn fail_session_post_when_armed( "forced session failure with status {status}" ))); *response.status_mut() = status; + for www_authenticate_header in www_authenticate_headers { + response + .headers_mut() + .append(WWW_AUTHENTICATE, www_authenticate_header); + } return response; } } diff --git a/codex-rs/rmcp-client/src/http_client_adapter.rs b/codex-rs/rmcp-client/src/http_client_adapter.rs index a1e6680e60d..0a5643aabf8 100644 --- a/codex-rs/rmcp-client/src/http_client_adapter.rs +++ b/codex-rs/rmcp-client/src/http_client_adapter.rs @@ -7,6 +7,7 @@ //! - a local HTTP client that issues requests from the orchestrator, or //! - a remote HTTP client that forwards requests to the remote runtime +use std::collections::HashMap; use std::io; use std::sync::Arc; @@ -29,12 +30,17 @@ use reqwest::header::HeaderName; use rmcp::model::ClientJsonRpcMessage; use rmcp::model::ServerJsonRpcMessage; use rmcp::transport::streamable_http_client::AuthRequiredError; +use rmcp::transport::streamable_http_client::InsufficientScopeError; use rmcp::transport::streamable_http_client::StreamableHttpClient; use rmcp::transport::streamable_http_client::StreamableHttpError; use rmcp::transport::streamable_http_client::StreamableHttpPostResponse; use sse_stream::Sse; use sse_stream::SseStream; +mod www_authenticate; + +use self::www_authenticate::insufficient_scope_challenge; + const EVENT_STREAM_MIME_TYPE: &str = "text/event-stream"; const JSON_MIME_TYPE: &str = "application/json"; const HEADER_SESSION_ID: &str = "Mcp-Session-Id"; @@ -80,8 +86,10 @@ impl StreamableHttpClient for StreamableHttpClientAdapter { message: ClientJsonRpcMessage, session_id: Option>, auth_token: Option, + custom_headers: HashMap, ) -> std::result::Result> { let mut headers = self.default_headers.clone(); + headers.extend(custom_headers); self.add_auth_headers(&mut headers); insert_header( &mut headers, @@ -137,9 +145,19 @@ impl StreamableHttpClient for StreamableHttpClientAdapter { && let Some(header) = response_header(&response.headers, reqwest::header::WWW_AUTHENTICATE) { - return Err(StreamableHttpError::AuthRequired(AuthRequiredError { - www_authenticate_header: header, - })); + return Err(StreamableHttpError::AuthRequired(AuthRequiredError::new( + header, + ))); + } + if response.status == StatusCode::FORBIDDEN.as_u16() + && let Some(challenge) = insufficient_scope_challenge(&response.headers) + { + return Err(StreamableHttpError::InsufficientScope( + InsufficientScopeError::new( + challenge.www_authenticate_header, + challenge.required_scope, + ), + )); } if matches!( StatusCode::from_u16(response.status).ok(), @@ -177,8 +195,10 @@ impl StreamableHttpClient for StreamableHttpClientAdapter { uri: Arc, session: Arc, auth_token: Option, + custom_headers: HashMap, ) -> std::result::Result<(), StreamableHttpError> { let mut headers = self.default_headers.clone(); + headers.extend(custom_headers); self.add_auth_headers(&mut headers); if let Some(auth_token) = auth_token { insert_header( @@ -227,11 +247,13 @@ impl StreamableHttpClient for StreamableHttpClientAdapter { session_id: Arc, last_event_id: Option, auth_token: Option, + custom_headers: HashMap, ) -> std::result::Result< BoxStream<'static, std::result::Result>, StreamableHttpError, > { let mut headers = self.default_headers.clone(); + headers.extend(custom_headers); self.add_auth_headers(&mut headers); insert_header( &mut headers, diff --git a/codex-rs/rmcp-client/src/http_client_adapter/www_authenticate.rs b/codex-rs/rmcp-client/src/http_client_adapter/www_authenticate.rs new file mode 100644 index 00000000000..3c122c3bb78 --- /dev/null +++ b/codex-rs/rmcp-client/src/http_client_adapter/www_authenticate.rs @@ -0,0 +1,233 @@ +use codex_exec_server::HttpHeader; +use reqwest::header::WWW_AUTHENTICATE; + +#[derive(Debug, PartialEq, Eq)] +pub(super) struct InsufficientScopeChallenge { + pub(super) www_authenticate_header: String, + pub(super) required_scope: Option, +} + +#[derive(Debug, PartialEq, Eq)] +struct BearerInsufficientScope { + required_scope: Option, +} + +type AuthParameter<'a> = (&'a str, Option); +type ChallengeStart<'a> = (&'a str, Option>); + +#[derive(Default)] +enum Parameter { + #[default] + Missing, + Value(String), + Invalid, +} + +#[derive(Default)] +struct BearerChallenge { + error: Parameter, + scope: Parameter, +} + +impl BearerChallenge { + fn add_parameter(&mut self, name: &str, value: Option) { + let parameter = if name.eq_ignore_ascii_case("error") { + &mut self.error + } else if name.eq_ignore_ascii_case("scope") { + &mut self.scope + } else { + return; + }; + + *parameter = match (&*parameter, value) { + (Parameter::Missing, Some(value)) => Parameter::Value(value), + (Parameter::Missing, None) | (Parameter::Value(_), _) | (Parameter::Invalid, _) => { + Parameter::Invalid + } + }; + } + + fn into_insufficient_scope(self) -> Option { + match self.error { + Parameter::Value(error) if error == "insufficient_scope" => { + Some(BearerInsufficientScope { + required_scope: match self.scope { + Parameter::Value(scope) if valid_scope(&scope) => Some(scope), + Parameter::Missing | Parameter::Value(_) | Parameter::Invalid => None, + }, + }) + } + Parameter::Missing | Parameter::Value(_) | Parameter::Invalid => None, + } + } +} + +/// Finds a Bearer insufficient-scope challenge among all `WWW-Authenticate` +/// response header field values. +pub(super) fn insufficient_scope_challenge( + headers: &[HttpHeader], +) -> Option { + headers + .iter() + .filter(|header| header.name.eq_ignore_ascii_case(WWW_AUTHENTICATE.as_str())) + .find_map(|header| { + parse_bearer_insufficient_scope(&header.value).map(|challenge| { + InsufficientScopeChallenge { + www_authenticate_header: header.value.clone(), + required_scope: challenge.required_scope, + } + }) + }) +} + +/// Parses a Bearer `WWW-Authenticate` challenge with an `insufficient_scope` +/// error and extracts its optional required scope. +/// +/// RFC 9110 section 11.2 defines challenge parameters as `auth-param` values +/// whose values are either `token` or `quoted-string`. Quoted strings use HTTP +/// syntax rather than JSON: section 5.6.4 requires recipients to replace each +/// `quoted-pair` with its escaped octet. +/// +/// RFC 6750 section 3 permits `scope` in the Bearer challenge at most once. +/// After HTTP quoted-string processing, each scope token can contain `%x21`, +/// `%x23-5B`, or `%x5D-7E`, with `%x20` separating multiple tokens. Therefore +/// returned scopes cannot contain `"` or `\`, even when those characters occur +/// in the header encoding. +/// +/// RMCP has related parsing logic, but it is private to that crate. +fn parse_bearer_insufficient_scope(header: &str) -> Option { + let segments = split_unquoted_segments(header)?; + let mut bearer_challenge: Option = None; + + for segment in segments { + if let Some((name, value)) = parse_auth_param(segment) { + if let Some(challenge) = bearer_challenge.as_mut() { + challenge.add_parameter(name, value); + } + continue; + } + + if let Some(challenge) = bearer_challenge + .take() + .and_then(BearerChallenge::into_insufficient_scope) + { + return Some(challenge); + } + + let (scheme, parameter) = parse_challenge_start(segment)?; + if scheme.eq_ignore_ascii_case("Bearer") { + let mut challenge = BearerChallenge::default(); + if let Some((name, value)) = parameter { + challenge.add_parameter(name, value); + } + bearer_challenge = Some(challenge); + } + } + + bearer_challenge.and_then(BearerChallenge::into_insufficient_scope) +} + +fn parse_challenge_start(segment: &str) -> Option> { + let segment = segment.trim(); + let parameter_start = segment.find(char::is_whitespace); + let (scheme, parameter) = match parameter_start { + Some(parameter_start) => ( + &segment[..parameter_start], + parse_auth_param(&segment[parameter_start..]), + ), + None => (segment, None), + }; + + is_http_token(scheme).then_some((scheme, parameter)) +} + +fn parse_auth_param(segment: &str) -> Option> { + let (name, value) = segment.trim().split_once('=')?; + let name = name.trim(); + is_http_token(name).then_some((name, parse_auth_param_value(value.trim()))) +} + +fn parse_auth_param_value(value: &str) -> Option { + if let Some(quoted_value) = value.strip_prefix('"') { + let quoted_value = quoted_value.strip_suffix('"')?; + let mut decoded = String::with_capacity(quoted_value.len()); + let mut characters = quoted_value.chars(); + while let Some(character) = characters.next() { + if character == '\\' { + decoded.push(characters.next()?); + } else { + decoded.push(character); + } + } + Some(decoded) + } else { + is_http_token(value).then(|| value.to_string()) + } +} + +fn split_unquoted_segments(header: &str) -> Option> { + let mut segments = Vec::new(); + let mut segment_start = 0; + let mut in_quotes = false; + let mut escaped = false; + + for (position, character) in header.char_indices() { + if escaped { + escaped = false; + continue; + } + match character { + '\\' if in_quotes => escaped = true, + '"' => in_quotes = !in_quotes, + ',' | ';' if !in_quotes => { + segments.push(&header[segment_start..position]); + segment_start = position + character.len_utf8(); + } + _ => {} + } + } + + if in_quotes || escaped { + None + } else { + segments.push(&header[segment_start..]); + Some(segments) + } +} + +fn valid_scope(scope: &str) -> bool { + scope.split(' ').all(|token| { + !token.is_empty() + && token + .bytes() + .all(|byte| matches!(byte, b'!' | b'#'..=b'[' | b']'..=b'~')) + }) +} + +fn is_http_token(value: &str) -> bool { + !value.is_empty() + && value.bytes().all(|byte| { + byte.is_ascii_alphanumeric() + || matches!( + byte, + b'!' | b'#' + | b'$' + | b'%' + | b'&' + | b'\'' + | b'*' + | b'+' + | b'-' + | b'.' + | b'^' + | b'_' + | b'`' + | b'|' + | b'~' + ) + }) +} + +#[cfg(test)] +#[path = "www_authenticate_tests.rs"] +mod tests; diff --git a/codex-rs/rmcp-client/src/http_client_adapter/www_authenticate_tests.rs b/codex-rs/rmcp-client/src/http_client_adapter/www_authenticate_tests.rs new file mode 100644 index 00000000000..6696655d884 --- /dev/null +++ b/codex-rs/rmcp-client/src/http_client_adapter/www_authenticate_tests.rs @@ -0,0 +1,124 @@ +use codex_exec_server::HttpHeader; +use pretty_assertions::assert_eq; + +use super::BearerInsufficientScope; +use super::InsufficientScopeChallenge; +use super::insufficient_scope_challenge; +use super::parse_bearer_insufficient_scope; + +#[test] +fn extracts_scope_from_bearer_insufficient_scope_challenges() { + let cases = [ + ( + r#"Bearer error="insufficient_scope", scope="files:read files:write""#, + "files:read files:write", + ), + ( + r#"Bearer error="insufficient_scope", ScOpE = "files:read""#, + "files:read", + ), + ( + r#"Bearer scope="read:data", error="insufficient_scope""#, + "read:data", + ), + (r#"Bearer error="insufficient_scope", scope=read"#, "read"), + ( + r#"Bearer error="insufficient_scope", scope="files:read\ files:write""#, + "files:read files:write", + ), + ( + r#"Bearer error="insufficient_scope", error_description="request scope=admin, not \"root\"", scope="files:read""#, + "files:read", + ), + ( + r#"Basic realm="example", Bearer error="insufficient_scope", scope="files:read""#, + "files:read", + ), + ( + r#"Newauth scope="wrong", Bearer error="insufficient_scope", scope="files:read""#, + "files:read", + ), + ]; + + for (header, expected_scope) in cases { + assert_eq!( + parse_bearer_insufficient_scope(header), + Some(BearerInsufficientScope { + required_scope: Some(expected_scope.to_string()), + }), + "header: {header}" + ); + } +} + +#[test] +fn does_not_treat_other_bearer_errors_as_insufficient_scope() { + assert_eq!( + parse_bearer_insufficient_scope(r#"Bearer error="invalid_token", scope="files:read""#), + None + ); +} + +#[test] +fn rejects_invalid_or_ambiguous_scope_parameters() { + let cases = [ + r#"Bearer error="insufficient_scope", scope="#, + r#"Bearer error="insufficient_scope", scope="read\"write""#, + r#"Bearer error="insufficient_scope", scope="read\\write""#, + r#"Bearer error="insufficient_scope", scope="read write""#, + r#"Bearer error="insufficient_scope", scope=read:data"#, + r#"Bearer error="insufficient_scope", scope=files:read files:write"#, + r#"Bearer error="insufficient_scope", scope=read=value"#, + r#"Bearer error="insufficient_scope", scope="read", scope="write""#, + ]; + + for header in cases { + assert_eq!( + parse_bearer_insufficient_scope(header), + Some(BearerInsufficientScope { + required_scope: None, + }), + "header: {header}" + ); + } +} + +#[test] +fn ignores_scope_text_outside_a_scope_parameter() { + let cases = [ + r#"Bearer error_description="request scope=admin""#, + r#"Bearer resource_scope="admin""#, + r#"Bearer "scope=admin""#, + r#"Bearer error_description="unterminated scope=admin"#, + ]; + + for header in cases { + assert_eq!( + parse_bearer_insufficient_scope(header), + None, + "header: {header}" + ); + } +} + +#[test] +fn selects_bearer_challenge_from_a_later_www_authenticate_field_value() { + let headers = vec![ + HttpHeader { + name: "www-authenticate".to_string(), + value: r#"Basic realm="example""#.to_string(), + }, + HttpHeader { + name: "WWW-Authenticate".to_string(), + value: r#"Bearer error="insufficient_scope", scope="files:read""#.to_string(), + }, + ]; + + assert_eq!( + insufficient_scope_challenge(&headers), + Some(InsufficientScopeChallenge { + www_authenticate_header: headers[1].value.clone(), + required_scope: Some("files:read".to_string()), + }) + ); +} diff --git a/codex-rs/rmcp-client/src/oauth.rs b/codex-rs/rmcp-client/src/oauth.rs index 5adfae93476..e23eee84bee 100644 --- a/codex-rs/rmcp-client/src/oauth.rs +++ b/codex-rs/rmcp-client/src/oauth.rs @@ -21,12 +21,12 @@ use anyhow::Error; use anyhow::Result; use codex_config::types::OAuthCredentialsStoreMode; use oauth2::AccessToken; -use oauth2::EmptyExtraTokenFields; use oauth2::RefreshToken; use oauth2::Scope; use oauth2::TokenResponse; use oauth2::basic::BasicTokenType; use rmcp::transport::auth::OAuthTokenResponse; +use rmcp::transport::auth::VendorExtraTokenFields; use serde::Deserialize; use serde::Serialize; use serde_json::Value; @@ -403,7 +403,7 @@ fn load_oauth_tokens_from_file(server_name: &str, url: &str) -> Result) -> String { diff --git a/codex-rs/rmcp-client/src/rmcp_client.rs b/codex-rs/rmcp-client/src/rmcp_client.rs index 5079570f3d1..90b09d724c3 100644 --- a/codex-rs/rmcp-client/src/rmcp_client.rs +++ b/codex-rs/rmcp-client/src/rmcp_client.rs @@ -12,7 +12,7 @@ use std::time::Instant; use anyhow::Result; use anyhow::anyhow; use codex_api::SharedAuthProvider; -use codex_client::build_reqwest_client_with_custom_ca; +use codex_client::maybe_build_rustls_client_config_with_custom_ca; use codex_config::types::McpServerEnvVar; use codex_exec_server::HttpClient; use futures::FutureExt; @@ -249,6 +249,7 @@ impl From for CreateElicitationResult { Self { action: value.action, content: value.content, + meta: None, } } } @@ -568,29 +569,22 @@ impl RmcpClient { } None => None, }; - let rmcp_params = CallToolRequestParams { - meta: None, - name: name.into(), - arguments, - task: None, - }; + let mut rmcp_params = CallToolRequestParams::new(name); + rmcp_params.arguments = arguments; let result = self .run_service_operation("tools/call", timeout, move |service| { let rmcp_params = rmcp_params.clone(); let meta = meta.clone(); async move { + let mut options = rmcp::service::PeerRequestOptions::no_options(); + options.meta = meta; let result = service .peer() .send_request_with_option( - ClientRequest::CallToolRequest(rmcp::model::CallToolRequest { - method: Default::default(), - params: rmcp_params, - extensions: Default::default(), - }), - rmcp::service::PeerRequestOptions { - timeout: None, - meta, - }, + ClientRequest::CallToolRequest(rmcp::model::CallToolRequest::new( + rmcp_params, + )), + options, ) .await? .await_response() @@ -1019,8 +1013,11 @@ async fn create_oauth_transport_and_runtime( StreamableHttpClientTransport>, OAuthPersistor, )> { - let builder = apply_default_headers(reqwest::Client::builder(), &default_headers); - let oauth_metadata_client = build_reqwest_client_with_custom_ca(builder)?; + let mut builder = apply_default_headers(reqwest::Client::builder(), &default_headers); + if let Some(tls_config) = maybe_build_rustls_client_config_with_custom_ca()? { + builder = builder.tls_backend_preconfigured(tls_config.as_ref().clone()); + } + let oauth_metadata_client = builder.build()?; // TODO(aibrahim): teach OAuth bootstrap and refresh to use the same // shared HTTP client abstraction instead of always creating the local // reqwest metadata client here. @@ -1037,7 +1034,7 @@ async fn create_oauth_transport_and_runtime( let manager = match oauth_state { OAuthState::Authorized(manager) => manager, OAuthState::Unauthorized(manager) => manager, - OAuthState::Session(_) | OAuthState::AuthorizedHttpClient(_) => { + _ => { return Err(anyhow!("unexpected OAuth state during client setup")); } }; diff --git a/codex-rs/rmcp-client/tests/process_group_cleanup.rs b/codex-rs/rmcp-client/tests/process_group_cleanup.rs index 10d1e8ec74e..ac446233f81 100644 --- a/codex-rs/rmcp-client/tests/process_group_cleanup.rs +++ b/codex-rs/rmcp-client/tests/process_group_cleanup.rs @@ -25,26 +25,11 @@ fn stdio_server_bin() -> Result { } fn init_params() -> InitializeRequestParams { - InitializeRequestParams { - meta: None, - capabilities: ClientCapabilities { - experimental: None, - extensions: None, - roots: None, - sampling: None, - elicitation: None, - tasks: None, - }, - client_info: Implementation { - name: "codex-test".into(), - version: "0.0.0-test".into(), - title: Some("Codex rmcp shutdown test".into()), - description: None, - icons: None, - website_url: None, - }, - protocol_version: ProtocolVersion::V_2025_06_18, - } + InitializeRequestParams::new( + ClientCapabilities::default(), + Implementation::new("codex-test", "0.0.0-test").with_title("Codex rmcp shutdown test"), + ) + .with_protocol_version(ProtocolVersion::V_2025_06_18) } fn process_exists(pid: u32) -> bool { diff --git a/codex-rs/rmcp-client/tests/resources.rs b/codex-rs/rmcp-client/tests/resources.rs index f2e4c49911b..ef7c2f7cdf8 100644 --- a/codex-rs/rmcp-client/tests/resources.rs +++ b/codex-rs/rmcp-client/tests/resources.rs @@ -28,31 +28,18 @@ fn stdio_server_bin() -> Result { } fn init_params() -> InitializeRequestParams { - InitializeRequestParams { - meta: None, - capabilities: ClientCapabilities { - experimental: None, - extensions: None, - roots: None, - sampling: None, - elicitation: Some(ElicitationCapability { - form: Some(FormElicitationCapability { - schema_validation: None, - }), - url: None, - }), - tasks: None, - }, - client_info: Implementation { - name: "codex-test".into(), - version: "0.0.0-test".into(), - title: Some("Codex rmcp resource test".into()), - description: None, - icons: None, - website_url: None, - }, - protocol_version: ProtocolVersion::V_2025_06_18, - } + let mut capabilities = ClientCapabilities::default(); + capabilities.elicitation = Some(ElicitationCapability { + form: Some(FormElicitationCapability { + schema_validation: None, + }), + url: None, + }); + InitializeRequestParams::new( + capabilities, + Implementation::new("codex-test", "0.0.0-test").with_title("Codex rmcp resource test"), + ) + .with_protocol_version(ProtocolVersion::V_2025_06_18) } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] @@ -132,10 +119,7 @@ async fn rmcp_client_can_list_and_read_resources() -> anyhow::Result<()> { let read = client .read_resource( - ReadResourceRequestParams { - meta: None, - uri: RESOURCE_URI.to_string(), - }, + ReadResourceRequestParams::new(RESOURCE_URI), Some(Duration::from_secs(5)), ) .await?; diff --git a/codex-rs/rmcp-client/tests/streamable_http_recovery.rs b/codex-rs/rmcp-client/tests/streamable_http_recovery.rs index 4be21f6cfc1..087d3d00df6 100644 --- a/codex-rs/rmcp-client/tests/streamable_http_recovery.rs +++ b/codex-rs/rmcp-client/tests/streamable_http_recovery.rs @@ -16,7 +16,13 @@ async fn streamable_http_404_session_expiry_recovers_and_retries_once() -> anyho let warmup = call_echo_tool(&client, "warmup").await?; assert_eq!(warmup, expected_echo_result("warmup")); - arm_session_post_failure(&base_url, /*status*/ 404, /*remaining*/ 1).await?; + arm_session_post_failure( + &base_url, + /*status*/ 404, + /*remaining*/ 1, + /*www_authenticate_headers*/ &[], + ) + .await?; let recovered = call_echo_tool(&client, "recovered").await?; assert_eq!(recovered, expected_echo_result("recovered")); @@ -32,7 +38,13 @@ async fn streamable_http_401_does_not_trigger_recovery() -> anyhow::Result<()> { let warmup = call_echo_tool(&client, "warmup").await?; assert_eq!(warmup, expected_echo_result("warmup")); - arm_session_post_failure(&base_url, /*status*/ 401, /*remaining*/ 2).await?; + arm_session_post_failure( + &base_url, + /*status*/ 401, + /*remaining*/ 2, + /*www_authenticate_headers*/ &[], + ) + .await?; let first_error = call_echo_tool(&client, "unauthorized").await.unwrap_err(); assert!(first_error.to_string().contains("401")); @@ -45,6 +57,61 @@ async fn streamable_http_401_does_not_trigger_recovery() -> anyhow::Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn streamable_http_403_scope_challenge_returns_insufficient_scope() -> anyhow::Result<()> { + let (_server, base_url) = spawn_streamable_http_server().await?; + let client = create_client(&base_url).await?; + + let warmup = call_echo_tool(&client, "warmup").await?; + assert_eq!(warmup, expected_echo_result("warmup")); + + arm_session_post_failure( + &base_url, + /*status*/ 403, + /*remaining*/ 1, + /*www_authenticate_headers*/ + &[r#"Bearer error="insufficient_scope", scope="files:read files:write""#], + ) + .await?; + + let error = call_echo_tool(&client, "forbidden").await.unwrap_err(); + assert!( + error.to_string().contains("Insufficient scope"), + "expected insufficient-scope transport error, got: {error:#}" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn streamable_http_403_finds_bearer_challenge_in_later_header_value() -> anyhow::Result<()> { + let (_server, base_url) = spawn_streamable_http_server().await?; + let client = create_client(&base_url).await?; + + let warmup = call_echo_tool(&client, "warmup").await?; + assert_eq!(warmup, expected_echo_result("warmup")); + + arm_session_post_failure( + &base_url, + /*status*/ 403, + /*remaining*/ 1, + /*www_authenticate_headers*/ + &[ + r#"Basic realm="example""#, + r#"Bearer error="insufficient_scope", scope="files:read""#, + ], + ) + .await?; + + let error = call_echo_tool(&client, "forbidden").await.unwrap_err(); + assert!( + error.to_string().contains("Insufficient scope"), + "expected insufficient-scope transport error, got: {error:#}" + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn streamable_http_404_recovery_only_retries_once() -> anyhow::Result<()> { let (_server, base_url) = spawn_streamable_http_server().await?; @@ -53,7 +120,13 @@ async fn streamable_http_404_recovery_only_retries_once() -> anyhow::Result<()> let warmup = call_echo_tool(&client, "warmup").await?; assert_eq!(warmup, expected_echo_result("warmup")); - arm_session_post_failure(&base_url, /*status*/ 404, /*remaining*/ 2).await?; + arm_session_post_failure( + &base_url, + /*status*/ 404, + /*remaining*/ 2, + /*www_authenticate_headers*/ &[], + ) + .await?; let error = call_echo_tool(&client, "double-404").await.unwrap_err(); assert!( @@ -77,7 +150,13 @@ async fn streamable_http_non_session_failure_does_not_trigger_recovery() -> anyh let warmup = call_echo_tool(&client, "warmup").await?; assert_eq!(warmup, expected_echo_result("warmup")); - arm_session_post_failure(&base_url, /*status*/ 500, /*remaining*/ 2).await?; + arm_session_post_failure( + &base_url, + /*status*/ 500, + /*remaining*/ 2, + /*www_authenticate_headers*/ &[], + ) + .await?; let first_error = call_echo_tool(&client, "server-error").await.unwrap_err(); assert!(first_error.to_string().contains("500")); diff --git a/codex-rs/rmcp-client/tests/streamable_http_test_support.rs b/codex-rs/rmcp-client/tests/streamable_http_test_support.rs index cfff33ab43c..822acef1a26 100644 --- a/codex-rs/rmcp-client/tests/streamable_http_test_support.rs +++ b/codex-rs/rmcp-client/tests/streamable_http_test_support.rs @@ -50,43 +50,27 @@ fn streamable_http_server_bin() -> Result { } fn init_params() -> InitializeRequestParams { - InitializeRequestParams { - meta: None, - capabilities: ClientCapabilities { - experimental: None, - extensions: None, - roots: None, - sampling: None, - elicitation: Some(ElicitationCapability { - form: Some(FormElicitationCapability { - schema_validation: None, - }), - url: None, - }), - tasks: None, - }, - client_info: Implementation { - name: "codex-test".into(), - version: "0.0.0-test".into(), - title: Some("Codex rmcp recovery test".into()), - description: None, - icons: None, - website_url: None, - }, - protocol_version: ProtocolVersion::V_2025_06_18, - } + let mut capabilities = ClientCapabilities::default(); + capabilities.elicitation = Some(ElicitationCapability { + form: Some(FormElicitationCapability { + schema_validation: None, + }), + url: None, + }); + InitializeRequestParams::new( + capabilities, + Implementation::new("codex-test", "0.0.0-test").with_title("Codex rmcp recovery test"), + ) + .with_protocol_version(ProtocolVersion::V_2025_06_18) } pub(crate) fn expected_echo_result(message: &str) -> CallToolResult { - CallToolResult { - content: Vec::new(), - structured_content: Some(json!({ - "echo": format!("ECHOING: {message}"), - "env": null, - })), - is_error: Some(false), - meta: None, - } + let mut result = CallToolResult::success(Vec::new()); + result.structured_content = Some(json!({ + "echo": format!("ECHOING: {message}"), + "env": null, + })); + result } pub(crate) async fn create_client(base_url: &str) -> anyhow::Result { @@ -178,12 +162,14 @@ pub(crate) async fn arm_session_post_failure( base_url: &str, status: u16, remaining: usize, + www_authenticate_headers: &[&str], ) -> anyhow::Result<()> { let response = reqwest::Client::new() .post(format!("{base_url}{SESSION_POST_FAILURE_CONTROL_PATH}")) .json(&json!({ "status": status, "remaining": remaining, + "www_authenticate_headers": www_authenticate_headers, })) .send() .await?; diff --git a/codex-rs/rollout/src/metadata.rs b/codex-rs/rollout/src/metadata.rs index 2dd2df3a419..00c6e3e6374 100644 --- a/codex-rs/rollout/src/metadata.rs +++ b/codex-rs/rollout/src/metadata.rs @@ -1,6 +1,5 @@ use crate::ARCHIVED_SESSIONS_SUBDIR; use crate::SESSIONS_SUBDIR; -use crate::list; use crate::list::parse_timestamp_uuid_from_filename; use crate::recorder::RolloutRecorder; use crate::state_db::normalize_cwd_for_state_db; @@ -286,25 +285,6 @@ pub(crate) async fn backfill_sessions_with_lease( continue; } stats.upserted = stats.upserted.saturating_add(1); - if let Ok(meta_line) = list::read_session_meta_line(&rollout.path).await { - if let Err(err) = runtime - .persist_dynamic_tools( - meta_line.meta.id, - meta_line.meta.dynamic_tools.as_deref(), - ) - .await - { - warn!( - "failed to backfill dynamic tools {}: {err}", - rollout.path.display() - ); - } - } else { - warn!( - "failed to read session meta for dynamic tools {}", - rollout.path.display() - ); - } } } Err(err) => { diff --git a/codex-rs/rollout/src/state_db.rs b/codex-rs/rollout/src/state_db.rs index ea087e5c6d2..2b6153484d4 100644 --- a/codex-rs/rollout/src/state_db.rs +++ b/codex-rs/rollout/src/state_db.rs @@ -8,7 +8,6 @@ use crate::sqlite_metrics; use chrono::DateTime; use chrono::Utc; use codex_protocol::ThreadId; -use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; pub use codex_state::LogEntry; @@ -454,37 +453,6 @@ pub async fn find_rollout_path_by_id( }) } -/// Get dynamic tools for a thread id using SQLite. -pub async fn get_dynamic_tools( - context: Option<&codex_state::StateRuntime>, - thread_id: ThreadId, - stage: &str, -) -> Option> { - let ctx = context?; - match ctx.get_dynamic_tools(thread_id).await { - Ok(tools) => tools, - Err(err) => { - warn!("state db get_dynamic_tools failed during {stage}: {err}"); - None - } - } -} - -/// Persist dynamic tools for a thread id using SQLite, if none exist yet. -pub async fn persist_dynamic_tools( - context: Option<&codex_state::StateRuntime>, - thread_id: ThreadId, - tools: Option<&[DynamicToolSpec]>, - stage: &str, -) { - let Some(ctx) = context else { - return; - }; - if let Err(err) = ctx.persist_dynamic_tools(thread_id, tools).await { - warn!("state db persist_dynamic_tools failed during {stage}: {err}"); - } -} - pub async fn mark_thread_memory_mode_polluted( context: Option<&codex_state::StateRuntime>, thread_id: ThreadId, @@ -570,21 +538,6 @@ pub async fn reconcile_rollout( "state db reconcile_rollout memory_mode update failed {}: {err}", rollout_path.display() ); - return; - } - if let Ok(meta_line) = crate::list::read_session_meta_line(rollout_path).await { - persist_dynamic_tools( - Some(ctx), - meta_line.meta.id, - meta_line.meta.dynamic_tools.as_deref(), - "reconcile_rollout", - ) - .await; - } else { - warn!( - "state db reconcile_rollout missing session meta {}", - rollout_path.display() - ); } } diff --git a/codex-rs/shell-command/src/powershell.rs b/codex-rs/shell-command/src/powershell.rs index f7b294c0f8b..e8c9a500c40 100644 --- a/codex-rs/shell-command/src/powershell.rs +++ b/codex-rs/shell-command/src/powershell.rs @@ -8,8 +8,9 @@ use crate::shell_detect::detect_shell_type; const POWERSHELL_FLAGS: &[&str] = &["-nologo", "-noprofile", "-command", "-c"]; -/// Prefixed command for powershell shell calls to force UTF-8 console output. -pub const UTF8_OUTPUT_PREFIX: &str = "[Console]::OutputEncoding=[System.Text.Encoding]::UTF8;\n"; +/// Prefixed command for powershell shell calls to request UTF-8 console output. +pub const UTF8_OUTPUT_PREFIX: &str = + "try { [Console]::OutputEncoding=[System.Text.Encoding]::UTF8 } catch {}\n"; pub fn prefix_powershell_script_with_utf8(command: &[String]) -> Vec { let Some((_, script)) = extract_powershell_command(command) else { @@ -151,9 +152,11 @@ fn is_powershellish_executable_available(powershell_or_pwsh_exe: &std::path::Pat #[cfg(test)] mod tests { + use super::UTF8_OUTPUT_PREFIX; use super::extract_powershell_command; #[cfg(windows)] use super::parse_powershell_command_into_plain_commands; + use super::prefix_powershell_script_with_utf8; #[test] fn extracts_basic_powershell_command() { @@ -202,6 +205,37 @@ mod tests { assert_eq!(script, "Get-ChildItem | Select-String foo"); } + #[test] + fn prefixes_powershell_command_with_best_effort_utf8() { + let cmd = vec![ + "powershell".to_string(), + "-Command".to_string(), + "Write-Host hi".to_string(), + ]; + + let prefixed = prefix_powershell_script_with_utf8(&cmd); + + assert_eq!( + prefixed, + vec![ + "powershell".to_string(), + "-Command".to_string(), + format!("{UTF8_OUTPUT_PREFIX}Write-Host hi"), + ] + ); + } + + #[test] + fn does_not_duplicate_utf8_prefix() { + let cmd = vec![ + "powershell".to_string(), + "-Command".to_string(), + format!("{UTF8_OUTPUT_PREFIX}Write-Host hi"), + ]; + + assert_eq!(prefix_powershell_script_with_utf8(&cmd), cmd); + } + #[cfg(windows)] #[test] fn parses_plain_powershell_commands() { diff --git a/codex-rs/skills/src/assets/samples/openai-docs/SKILL.md b/codex-rs/skills/src/assets/samples/openai-docs/SKILL.md index 4994fd8802b..30526bd9d82 100644 --- a/codex-rs/skills/src/assets/samples/openai-docs/SKILL.md +++ b/codex-rs/skills/src/assets/samples/openai-docs/SKILL.md @@ -1,12 +1,12 @@ --- name: "openai-docs" -description: "Use when the user asks how to build with OpenAI products or APIs and needs up-to-date official documentation with citations, help choosing the latest model for a use case, or model upgrade and prompt-upgrade guidance; prioritize OpenAI docs MCP tools, use bundled references only as helper context, and restrict any fallback browsing to official OpenAI domains." +description: "Use when the user asks how to build with OpenAI products or APIs, asks about Codex itself or choosing Codex surfaces, needs up-to-date official documentation with citations, help choosing the latest model for a use case, or model upgrade and prompt-upgrade guidance; use OpenAI docs MCP tools for non-Codex docs questions, use the Codex manual helper first for broad Codex self-knowledge, and restrict fallback browsing to official OpenAI domains." --- # OpenAI Docs -Provide authoritative, current guidance from OpenAI developer docs using the developers.openai.com MCP server. Always prioritize the developer docs MCP tools over web.run for OpenAI-related questions. This skill also owns model selection, API model migration, and prompt-upgrade guidance. Only if the MCP server is installed and returns no meaningful results should you fall back to web search. +Provide authoritative, current guidance from OpenAI developer docs using the developers.openai.com MCP server. "Docs MCP" means `mcp__openaiDeveloperDocs__search_openai_docs` and `mcp__openaiDeveloperDocs__fetch_openai_doc`; for API reference, schema, parameter, or required-field questions, also use `mcp__openaiDeveloperDocs__get_openapi_spec` when available. Official-domain web search is fallback after those tools are unavailable or unhelpful. Broad Codex questions use the manual helper before Docs MCP. This skill also owns model selection, API model migration, and prompt-upgrade guidance. ## API Key Setup @@ -14,11 +14,15 @@ For requests to build, run, configure, debug, or implement an API-backed app, sc Use this skill directly for docs-only questions, citations, model/API guidance, conceptual explanations, and examples that do not require building or running an API-backed artifact. -## Quick start +## Workflow Configuration -- Use `mcp__openaiDeveloperDocs__search_openai_docs` to find the most relevant doc pages. -- Use `mcp__openaiDeveloperDocs__fetch_openai_doc` to pull exact sections and quote/paraphrase accurately. -- Use `mcp__openaiDeveloperDocs__list_openai_docs` only when you need to browse or discover pages without a clear query. +### Source Priority + +- For Codex self-knowledge, use the Codex source route below; it owns when to use the manual helper, Docs MCP, or bounded uncertainty. +- For non-Codex OpenAI docs questions, use `mcp__openaiDeveloperDocs__search_openai_docs` to find the most relevant doc pages. +- For non-Codex OpenAI docs questions, fetch the relevant page with `mcp__openaiDeveloperDocs__fetch_openai_doc` before answering. If search is noisy, run a narrower Docs MCP search; when any plausible official OpenAI docs URL is known or found, try fetching that URL through Docs MCP before relying on web-search content. +- For API reference, schema, parameter, or required-field questions, use `mcp__openaiDeveloperDocs__get_openapi_spec` when available to verify the API shape alongside the relevant guide or reference page. +- Use `mcp__openaiDeveloperDocs__list_openai_docs` only when you need to browse or discover non-Codex pages without a clear query. - For model-selection, "latest model", or default-model questions, fetch `https://developers.openai.com/api/docs/guides/latest-model.md` first. If that is unavailable, load `references/latest-model.md`. - For model upgrades or prompt upgrades, run `node scripts/resolve-latest-model-info.js` only when the target is latest/current/default or otherwise unspecified; otherwise preserve the explicitly requested target. - Preserve explicit target requests: if the user names a target model like "migrate to GPT-5.4", keep that requested target even if `latest-model.md` names a newer model. Mention newer guidance only as optional. @@ -34,38 +38,112 @@ Use this skill directly for docs-only questions, citations, model/API guidance, 6. Realtime API: Build low-latency, multimodal experiences including natural speech-to-speech conversations. 7. Agents SDK: A toolkit for building agentic apps where a model can use tools and context, hand off to other agents, stream partial results, and keep a full trace. +## Codex self-knowledge + +Use this path for questions about Codex itself: configuring, extending, operating, troubleshooting, local state, product surfaces, or where Codex behavior should live. A codebase merely mentioning a plugin, skill, hook, MCP server, browser, or automation is not enough. For generic software tasks, answer the software task directly; if asked whether Codex self-knowledge applies, answer that meta question briefly and continue the requested artifact. + +### Source Route + +The Codex manual is the first source for broad Codex synthesis. Treat the manual and Docs MCP as different lanes, not interchangeable official-doc sources. For published-user Codex product answers, the source route is complete: the manual, Docs MCP when this route calls for it, official OpenAI web fallback, and callable capabilities surfaced in the current session when the question is about that capability. Knowledge bases outside developers.openai.com are outside this route for public product answers. + +For broad Codex behavior, setup, customization, skills, plugins, MCP, hooks, `AGENTS.md`, automations, surfaces, local state, or system-map questions: + +1. Reuse a same-thread manual and outline path when it is still fresh. +2. Otherwise run the skill-local helper first in normal writable sessions. Skip it without trying only when the session is explicitly read-only, shell execution is unavailable, or visible policy shows no allowed temp cache. +3. By default, the helper chooses the first usable temp cache dir in this order: `$TMPDIR/openai-docs-cache`, `%TEMP%\openai-docs-cache`, `%TMP%\openai-docs-cache`, `/private/tmp/openai-docs-cache`, then `/tmp/openai-docs-cache`. Workspace-only write access is not enough for this temp cache. +4. Run the helper directly unless you need to override the cache dir. The helper falls back to `curl` when native `fetch` is unavailable or when proxy env vars are present, so no shell-specific proxy prefix is required. Resolve `` to this skill's actual directory; in copied local eval workdirs this is usually `.codex/skills/openai-docs`: + +```bash +node /scripts/fetch-codex-manual.mjs +``` + +If you need to override the cache dir, pass `--cache-dir `. On Windows, the helper checks `%TEMP%` and `%TMP%` automatically; in PowerShell, `$env:TEMP\\openai-docs-cache` is a typical explicit override. + +Treat helper availability as established by explicit read-only/no-shell policy or an actual command result. A guessed sandbox or guessed helper failure is not enough to switch to Docs MCP or web lookup; after an actual helper command failure, continue to the narrowest official next source below. + +The helper verifies freshness, writes `codex-manual.md`, and emits `codex-manual.outline.md`. The outline maps source pages and headings to line ranges; use it to choose the relevant manual section, then read or search targeted manual sections for Codex product facts. Use the skill directory to locate and run the helper; after the helper succeeds, use the returned manual and outline paths as the search scope for Codex product facts and term coverage checks. + +Reuse the same-thread manual and outline paths for follow-up Codex questions. Refresh first when the manual was fetched more than about a day ago, the path is unusable, the path came from another thread or uncertain provenance, or likely-current information is missing and staleness is plausible. + +For questions about whether the manual is current enough to rely on now, run the helper when temp caching is allowed and base the answer on its returned status, manual path, and outline path. + +If the manual resolves a Codex claim, answer from it and stop expanding sources for that claim; continue the user's broader task if the docs lookup was only one dependency. Manual source pages and known anchors are enough citation support for manual-covered material. + +If the helper is skipped because the session is read-only, has no shell execution, or has no allowed temp cache, the next source is Docs MCP: call `mcp__openaiDeveloperDocs__search_openai_docs`, then `mcp__openaiDeveloperDocs__fetch_openai_doc` for a relevant hit before any web fallback. + +If a user names a Codex term or mode that a fresh manual does not use, search the manual for obvious adjacent concepts, then answer that the exact term is not documented and use the closest documented terminology. If the prompt asks how that term maps to Codex behavior, resolve the mapping from adjacent manual sections. If the exact term remains material or likely current after that manual pass, use one narrow Docs MCP search/fetch before bounded uncertainty; otherwise, the source lookup for that terminology or mapping claim is complete. + +Use the narrowest official next source only when the manual is unavailable, the helper fails, temp caching is not allowed, another material claim is missing or likely stale, or the user explicitly needs a page-specific citation. Prefer one specific Docs MCP search and, if it returns a clearly relevant page, one fetch; for unresolved Codex capability names, acronyms, scheduling terms, or exact error text, this Docs MCP step is the next source before web search. After the manual plus any permitted Docs MCP gap-fill, resolve remaining gaps as bounded uncertainty. Use official-domain web fallback only after that Docs MCP path is unavailable or unhelpful. If the claim is still not established, stop with bounded uncertainty. If official docs/manual conflict with a callable capability already surfaced in the current session, state the conflict and prefer verified current-session behavior for that environment. + +For undocumented or private-looking model slugs, product mode labels, entitlement labels, account access paths, or rollout names, answer from current public docs and bounded uncertainty. Those labels are not a reason to leave the public source route. + +For support-style diagnostics, prefer a layer-by-layer answer from the manual over provider-specific web lookups: installed/enabled plugin, bundled app or connector authorization, MCP setup, workspace/admin policy, restart or new-thread expectations, then support or feedback if still unresolved. + +If the source route still does not establish a claim, return bounded uncertainty or route to support, an admin, or product feedback instead of widening the investigation. + +For unresolved product terminology, answer from the manual plus the allowed official next source. If those sources do not establish the term, answer with bounded uncertainty from those sources. + +### Surface Map + +When Codex nouns or durable-instruction surfaces overlap, recommend the smallest surface that matches the scope: + +- Prompt or thread context -> one-off task constraints. +- `AGENTS.md` -> durable repo conventions, commands, verification steps, and review expectations; closer nested files apply under their subtree. +- Project `.codex/config.toml` -> trusted-repo Codex settings such as sandbox, MCP, hooks, model, or reasoning defaults. +- Global config or global guidance -> personal defaults across repos. +- Skill -> reusable task workflow with references or scripts. +- Plugin -> installable bundle with skills plus commands, tools, MCP config, hooks, assets, apps, or marketplace metadata. +- MCP server or app connector -> live external data/actions or authorized private app/workspace data. Use connectors for private Google Docs, Calendar, Slack, GitHub, Notion, and similar data instead of web search or model memory. +- Automation -> scheduled checks, reminders, monitors, or follow-up work; use a thread heartbeat when continuity in an existing thread matters. +- Hook -> lifecycle enforcement around tool calls, commands, or file edits. + +Split mixed-scope requests instead of forcing one answer. Example: "always do X, but only for this PR" defaults to prompt/thread context for the current run; use `AGENTS.md` or project config only if it should persist, hooks only for mechanical enforcement, and automations only for scheduled or follow-up work. + +Use this quick product map when needed: CLI is terminal-first local repo work; IDE extension is editor-attached coding; Codex app is desktop planning, review, and interactive work; cloud/web is hosted parallel/offloaded work; Browser Use/in-app browser is Codex-controlled web testing; Chrome extension uses the user's Chrome profile; Computer Use controls desktop apps and OS UI. Keep `config.toml` defaults, `requirements.toml` constraints, and managed/admin policy separate. + +### Boundaries And Output + +- API key auth does not imply ChatGPT, cloud task, or connector access. For plugin/app/auth failures, check bundle availability, plugin installed/enabled state, connector/app authorization, MCP setup, restart/refresh expectations, workspace policy, and per-surface availability before answering. +- Sandbox or network denials need scoped escalation with a clear justification. Destructive commands, writes outside the workspace, or broad access changes require explicit approval. +- Memory can provide user preference or context, but explicit prompt instructions win and memory is not a source for current external facts. +- For affirmative surface-selection answers, use this shape: recommendation, why, what to avoid, and the manual/source evidence used. +- When page-specific Codex citations are actually needed, these anchors often fit: `concepts/customization#agents-guidance` for `AGENTS.md`, `concepts/customization#skills` for skills, `plugins/build#plugin-structure` for plugins, `concepts/customization#mcp` for MCP, `config-advanced#hooks` for hooks, `app/automations#thread-automations` for thread automations, and `config-reference#configtoml` for config. + ## If MCP server is missing If MCP tools fail or no OpenAI docs resources are available: 1. Run the install command yourself: `codex mcp add openaiDeveloperDocs --url https://developers.openai.com/mcp` -2. If it fails due to permissions/sandboxing, immediately retry the same command with escalated permissions and include a 1-sentence justification for approval. Do not ask the user to run it yet. -3. Only if the escalated attempt fails, ask the user to run the install command. +2. If it fails due to permissions/sandboxing, immediately retry the same command with escalated permissions and include a 1-sentence justification for approval. +3. Ask the user to run the install command only if the escalated attempt fails. 4. Ask the user to restart Codex. 5. Re-run the doc search/fetch after restart. ## Workflow 1. Clarify whether the request is general docs lookup, model selection, a model-string upgrade, prompt-upgrade guidance, or broader API/provider migration. -2. For model-selection or upgrade requests, prefer current remote docs over bundled references when the user asks for latest/current/default guidance. +2. For Codex self-knowledge requests, follow the Codex self-knowledge source procedure above. +3. For model-selection or upgrade requests, prefer current remote docs over bundled references when the user asks for latest/current/default guidance. - Fetch `https://developers.openai.com/api/docs/guides/latest-model.md`. - Find the latest model ID and explicit migration or prompt-guidance links. - Prefer explicit links from the latest-model page over derived URLs. - - For explicit named-model requests, preserve the requested model target and do not silently retarget to the latest model. Mention newer remote guidance only as optional. + - For explicit named-model requests, preserve the requested model target. Mention newer remote guidance only as optional. - For dynamic latest/current/default upgrades, run `node scripts/resolve-latest-model-info.js`, then fetch both returned guide URLs directly when possible. - If direct guide fetch fails, use the developer-docs MCP tools or official OpenAI-domain search to find the same guide content. - If remote docs are unavailable, use bundled fallback references and say that fallback guidance was used. -3. For model upgrades, keep changes narrow: update active OpenAI API model defaults and directly related prompts only when safe. -4. Leave historical docs, examples, eval baselines, fixtures, provider comparisons, provider registries, pricing tables, alias defaults, low-cost fallback paths, and ambiguous older model usage unchanged unless the user explicitly asks to upgrade them. -5. Do not perform SDK, tooling, IDE, plugin, shell, auth, or provider-environment migrations as part of a model-and-prompt upgrade. -6. If an upgrade needs API-surface changes, schema rewiring, tool-handler changes, or implementation work beyond a literal model-string replacement and prompt edits, report it as blocked or confirmation-needed. -7. For general docs lookup, search docs with a precise query, fetch the best page and exact section needed, and answer with concise citations. +4. For model upgrades, keep changes narrow: update active OpenAI API model defaults and directly related prompts only when safe. +5. Leave historical docs, examples, eval baselines, fixtures, provider comparisons, provider registries, pricing tables, alias defaults, low-cost fallback paths, and ambiguous older model usage unchanged unless the user explicitly asks to upgrade them. +6. Keep SDK, tooling, IDE, plugin, shell, auth, and provider-environment migrations out of a model-and-prompt upgrade unless the user explicitly asks for them. +7. If an upgrade needs API-surface changes, schema rewiring, tool-handler changes, or implementation work beyond a literal model-string replacement and prompt edits, report it as blocked or confirmation-needed. +8. For general docs lookup, search docs with a precise query, fetch the best page and exact section needed, and answer with concise citations. ## Reference map Read only what you need: - `https://developers.openai.com/api/docs/guides/latest-model.md` -> current model-selection and "best/latest/current model" questions. +- `scripts/fetch-codex-manual.mjs` -> current Codex manual fetch, verification, local temp cache, and outline generation. +- `https://developers.openai.com/codex/codex-manual.md` -> current Codex self-knowledge synthesis, including setup, customization, skills, plugins, MCP, hooks, `AGENTS.md`, automations, and surface behavior; normally access it through the helper path and targeted file reads when temp caching is available. - `references/latest-model.md` -> bundled fallback for model-selection and "best/latest/current model" questions. - `references/upgrade-guide.md` -> bundled fallback for model upgrade and upgrade-planning requests. - `references/prompting-guide.md` -> bundled fallback for prompt rewrites and prompt-behavior upgrades. @@ -73,16 +151,17 @@ Read only what you need: ## Quality rules - Treat OpenAI docs as the source of truth; avoid speculation. +- For Codex self-knowledge, follow the source route above instead of relying on remembered behavior. - Keep migration changes narrow and behavior-preserving. - Prefer prompt-only upgrades when possible. -- Do not invent pricing, availability, parameters, API changes, or breaking changes. +- Avoid inventing pricing, availability, parameters, API changes, or breaking changes. - Keep quotes short and within policy limits; prefer paraphrase with citations. - If multiple pages differ, call out the difference and cite both. -- If official docs and repo behavior disagree, state the conflict and stop before making broad edits. +- If official docs and verified callable current-session behavior disagree, state the conflict before making broad claims or edits. - If docs do not cover the user’s need, say so and offer next steps. ## Tooling notes -- Always use MCP doc tools before any web search for OpenAI-related questions. +- Use MCP doc tools before web search for OpenAI-related markdown docs. The Codex manual flow is the exception: follow the Codex self-knowledge source procedure for broad Codex synthesis. - If the MCP server is installed but returns no meaningful results, then use web search as a fallback. - When falling back to web search, restrict to official OpenAI domains (developers.openai.com, platform.openai.com) and cite sources. diff --git a/codex-rs/skills/src/assets/samples/openai-docs/agents/openai.yaml b/codex-rs/skills/src/assets/samples/openai-docs/agents/openai.yaml index d056abcad78..8bbf03c2065 100644 --- a/codex-rs/skills/src/assets/samples/openai-docs/agents/openai.yaml +++ b/codex-rs/skills/src/assets/samples/openai-docs/agents/openai.yaml @@ -1,9 +1,9 @@ interface: display_name: "OpenAI Docs" - short_description: "Reference docs, choose models, and migrate OpenAI API integrations" + short_description: "Reference OpenAI docs, Codex self-knowledge, and model migration guidance" icon_small: "./assets/openai-small.svg" icon_large: "./assets/openai.png" - default_prompt: "Use OpenAI Docs for official docs lookup, model selection, model migration, and prompt-upgrade work." + default_prompt: "Use OpenAI Docs for official docs lookup, questions about Codex itself or Codex surfaces, model selection, model migration, and prompt-upgrade work." dependencies: tools: diff --git a/codex-rs/skills/src/assets/samples/openai-docs/scripts/fetch-codex-manual.mjs b/codex-rs/skills/src/assets/samples/openai-docs/scripts/fetch-codex-manual.mjs new file mode 100644 index 00000000000..b2605520dc5 --- /dev/null +++ b/codex-rs/skills/src/assets/samples/openai-docs/scripts/fetch-codex-manual.mjs @@ -0,0 +1,598 @@ +#!/usr/bin/env node +import { + access, + mkdir, + readFile, + rename, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { constants as fsConstants } from "node:fs"; +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import path from "node:path"; +import process from "node:process"; +import { pathToFileURL } from "node:url"; +import { inspect, promisify } from "node:util"; + +const DEFAULT_MANUAL_URL = "https://developers.openai.com/codex/codex-manual.md"; +const DEFAULT_CACHE_DIR_NAME = "openai-docs-cache"; +const CACHE_FILE_NAME = "codex-manual.md"; +const OUTLINE_FILE_NAME = "codex-manual.outline.md"; +const HASH_HEADER = "x-content-sha256"; +const USER_AGENT = "codex-openai-docs"; +const execFileAsync = promisify(execFile); + +class ManualFetchError extends Error { + constructor(message, options) { + super(message, options); + this.name = "ManualFetchError"; + } +} + +const sha256 = (value) => createHash("sha256").update(value).digest("hex"); + +const withTimeout = async (promiseFactory, timeoutMs) => { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + return await promiseFactory(controller.signal); + } finally { + clearTimeout(timeout); + } +}; + +const proxyConfigured = () => + process.env.HTTP_PROXY || + process.env.HTTPS_PROXY || + process.env.http_proxy || + process.env.https_proxy; + +const responseHeaders = (headers) => ({ + get(name) { + return headers.get(name.toLowerCase()) ?? null; + }, +}); + +const makeResponse = ({ body, headers, status }) => ({ + headers: responseHeaders(headers), + ok: status >= 200 && status < 300, + status, + async text() { + return body; + }, +}); + +const parseCurlHeaders = (rawHeaders) => { + const normalized = rawHeaders.replace(/\r\n/g, "\n").trim(); + const blocks = normalized.split(/\n\n+/).filter(Boolean); + const headerBlock = [...blocks] + .reverse() + .find((block) => block.startsWith("HTTP/")); + + if (!headerBlock) { + throw new ManualFetchError("curl did not return HTTP response headers."); + } + + const [statusLine, ...lines] = headerBlock.split("\n"); + const statusMatch = /^HTTP\/\S+\s+(\d{3})/.exec(statusLine); + if (!statusMatch) { + throw new ManualFetchError( + `Could not parse HTTP status from curl response: ${statusLine}` + ); + } + + const headers = new Map(); + lines.forEach((line) => { + const separator = line.indexOf(":"); + if (separator === -1) return; + const name = line.slice(0, separator).trim().toLowerCase(); + const value = line.slice(separator + 1).trim(); + headers.set(name, value); + }); + + return { + headers, + status: Number(statusMatch[1]), + }; +}; + +const tempFilePath = (cacheDir, suffix) => + path.join( + cacheDir, + `.fetch-codex-manual-${process.pid}-${Date.now()}-${Math.random() + .toString(16) + .slice(2)}${suffix}` + ); + +const requestManualWithCurl = async (url, { cacheDir, method, timeoutMs }) => { + const headerPath = tempFilePath(cacheDir, ".headers"); + const bodyPath = tempFilePath(cacheDir, ".body"); + const curlNames = + process.platform === "win32" ? ["curl.exe", "curl"] : ["curl"]; + const args = [ + "--silent", + "--show-error", + "--location", + "--dump-header", + headerPath, + "--output", + bodyPath, + "--user-agent", + USER_AGENT, + "--max-time", + String(Math.max(1, Math.ceil(timeoutMs / 1000))), + ]; + + if (method === "HEAD") { + args.push("--head"); + } else { + args.push("--request", method); + } + args.push(url); + + let lastError; + for (const curlName of curlNames) { + try { + await execFileAsync(curlName, args, { windowsHide: true }); + const [rawHeaders, body] = await Promise.all([ + readFile(headerPath, "utf8"), + readFile(bodyPath, "utf8"), + ]); + const { headers, status } = parseCurlHeaders(rawHeaders); + return makeResponse({ body, headers, status }); + } catch (error) { + lastError = error; + if (error?.code !== "ENOENT") break; + } finally { + await Promise.all([ + rm(headerPath, { force: true }), + rm(bodyPath, { force: true }), + ]); + } + } + + if (lastError?.code === "ENOENT") { + throw new ManualFetchError("curl is unavailable in this environment.", { + cause: lastError, + }); + } + throw new ManualFetchError(`${method} ${url} could not be fetched.`, { + cause: lastError, + }); +}; + +const requestManualWithFetch = async (url, { method, timeoutMs }) => { + if (typeof fetch !== "function") { + throw new ManualFetchError( + "Native fetch is unavailable in this Node runtime." + ); + } + + return withTimeout( + (signal) => + fetch(url, { + method, + headers: { "User-Agent": USER_AGENT }, + signal, + }), + timeoutMs + ); +}; + +const requestManual = async (url, { cacheDir, method, timeoutMs }) => { + const preferCurl = Boolean(proxyConfigured()) || typeof fetch !== "function"; + const transports = preferCurl + ? [ + () => requestManualWithCurl(url, { cacheDir, method, timeoutMs }), + () => requestManualWithFetch(url, { method, timeoutMs }), + ] + : [ + () => requestManualWithFetch(url, { method, timeoutMs }), + () => requestManualWithCurl(url, { cacheDir, method, timeoutMs }), + ]; + + let lastError; + for (const transport of transports) { + try { + const response = await transport(); + if (!response.ok) { + throw new ManualFetchError( + `${method} ${url} failed with HTTP ${response.status}.` + ); + } + return response; + } catch (error) { + lastError = error; + } + } + + throw new ManualFetchError(`${method} ${url} could not be fetched.`, { + cause: lastError, + }); +}; + +const readHeaderSha = (response) => { + const value = response.headers.get(HASH_HEADER); + if (!value || !/^[a-f0-9]{64}$/i.test(value)) { + throw new ManualFetchError(`Manual response is missing ${HASH_HEADER}.`); + } + return value.toLowerCase(); +}; + +const nearestExistingParent = async (target) => { + let current = target; + while (true) { + try { + const info = await stat(current); + return info.isDirectory() ? current : null; + } catch (error) { + if (error?.code !== "ENOENT") return null; + } + + const parent = path.dirname(current); + if (parent === current) return null; + current = parent; + } +}; + +const usableCacheDir = async (cacheDir) => { + if (!cacheDir) return null; + const resolved = path.resolve(cacheDir); + + try { + const info = await stat(resolved); + if (!info.isDirectory()) return null; + } catch (error) { + if (error?.code !== "ENOENT") return null; + } + + const parent = await nearestExistingParent(resolved); + if (!parent) return null; + + try { + await access(parent, fsConstants.W_OK | fsConstants.X_OK); + } catch { + return null; + } + + return resolved; +}; + +const defaultCacheDirCandidates = () => { + const candidates = []; + const seen = new Set(); + const pushCandidate = (candidate) => { + if (!candidate || seen.has(candidate)) return; + seen.add(candidate); + candidates.push(candidate); + }; + + [process.env.TMPDIR, process.env.TEMP, process.env.TMP].forEach((baseDir) => { + if (baseDir) { + pushCandidate(path.join(baseDir, DEFAULT_CACHE_DIR_NAME)); + } + }); + + if (process.platform !== "win32") { + pushCandidate(`/private/tmp/${DEFAULT_CACHE_DIR_NAME}`); + pushCandidate(`/tmp/${DEFAULT_CACHE_DIR_NAME}`); + } + + return candidates; +}; + +const resolveCacheDir = async (cacheDir) => { + if (cacheDir) { + return usableCacheDir(cacheDir); + } + + for (const candidate of defaultCacheDirCandidates()) { + const usable = await usableCacheDir(candidate); + if (usable) return usable; + } + + return null; +}; + +const cacheFilePath = (cacheDir) => path.join(cacheDir, CACHE_FILE_NAME); + +const outlineFilePath = (cacheDir) => path.join(cacheDir, OUTLINE_FILE_NAME); + +const manualLines = (manual) => { + const lines = manual.replace(/\r\n/g, "\n").split("\n"); + if (lines[lines.length - 1] === "") lines.pop(); + return lines; +}; + +const sectionTitle = (rawTitle) => + rawTitle.replace(/\s+#+\s*$/, "").replace(/\s+/g, " ").trim(); + +const buildOutline = (manual) => { + const lines = manualLines(manual); + const headings = []; + let inFence = false; + + lines.forEach((line, index) => { + if (/^\s*(```|~~~)/.test(line)) { + inFence = !inFence; + return; + } + if (inFence) return; + + const match = /^(#{1,6})\s+(.+?)\s*$/.exec(line); + if (!match) return; + + const level = match[1].length; + if (level < 2 || level > 3) return; + + headings.push({ + level, + title: sectionTitle(match[2]), + startLine: index + 1, + endLine: lines.length, + }); + }); + + for (let index = 0; index < headings.length; index += 1) { + const heading = headings[index]; + const nextPeer = headings + .slice(index + 1) + .find((candidate) => candidate.level <= heading.level); + if (nextPeer) { + heading.endLine = nextPeer.startLine - 1; + } + } + + if (headings.length === 0) { + return { + headingCount: 0, + lineCount: lines.length, + text: "No markdown headings found.", + }; + } + + const minLevel = Math.min(...headings.map((heading) => heading.level)); + return { + headingCount: headings.length, + lineCount: lines.length, + text: headings + .map((heading) => { + const indent = " ".repeat(heading.level - minLevel); + return `${indent}- ${heading.title} (lines ${heading.startLine}-${heading.endLine})`; + }) + .join("\n"), + }; +}; + +const outlineMarkdown = (outline) => `# Codex Manual Outline\n\n${outline.text}\n`; + +const manualStatusLine = (status) => + status.cacheStatus === "hit" + ? "Manual status: local manual was already current." + : "Manual status: local manual was updated."; + +const formatResult = ({ status, outlineText }) => + [ + `Manual path: ${status.manualPath}`, + `Outline path: ${status.outlinePath}`, + manualStatusLine(status), + "", + outlineText, + ].join("\n"); + +const readCachedManual = async (cacheDir, expectedSha256) => { + try { + const manual = await readFile(cacheFilePath(cacheDir), "utf8"); + return sha256(manual) === expectedSha256 ? manual : null; + } catch { + return null; + } +}; + +const writeCachedManual = async (cacheDir, manual) => { + await mkdir(cacheDir, { recursive: true }); + const tmpPath = tempFilePath(cacheDir, `.${CACHE_FILE_NAME}.tmp`); + await writeFile(tmpPath, manual, "utf8"); + await rename(tmpPath, cacheFilePath(cacheDir)); +}; + +const writeOutline = async (cacheDir, outlineText) => { + await mkdir(cacheDir, { recursive: true }); + const tmpPath = tempFilePath(cacheDir, `.${OUTLINE_FILE_NAME}.tmp`); + await writeFile(tmpPath, outlineText, "utf8"); + await rename(tmpPath, outlineFilePath(cacheDir)); +}; + +const fetchCodexManual = async ({ + manualUrl = DEFAULT_MANUAL_URL, + cacheDir, + timeoutMs = 30000, +} = {}) => { + const resolvedCacheDir = await resolveCacheDir(cacheDir); + if (!resolvedCacheDir) { + throw new ManualFetchError( + "Manual cache directory is unavailable; pass --cache-dir to override or use OpenAI Docs MCP fallback." + ); + } + await mkdir(resolvedCacheDir, { recursive: true }); + + const headResponse = await requestManual(manualUrl, { + cacheDir: resolvedCacheDir, + method: "HEAD", + timeoutMs, + }); + const expectedSha256 = readHeaderSha(headResponse); + const manualPath = cacheFilePath(resolvedCacheDir); + const outlinePath = outlineFilePath(resolvedCacheDir); + const checkedAt = new Date().toISOString(); + + const cachedManual = await readCachedManual(resolvedCacheDir, expectedSha256); + if (cachedManual !== null) { + const outline = buildOutline(cachedManual); + const outlineText = outlineMarkdown(outline); + await writeOutline(resolvedCacheDir, outlineText); + + return { + outlineText, + status: { + manualUrl, + headerSha256: expectedSha256, + fetchedManualSha256: expectedSha256, + manualHashMatches: true, + cacheStatus: "hit", + cacheDir: resolvedCacheDir, + manualPath, + outlinePath, + checkedAt, + lineCount: outline.lineCount, + headingCount: outline.headingCount, + }, + }; + } + + const getResponse = await requestManual(manualUrl, { + cacheDir: resolvedCacheDir, + method: "GET", + timeoutMs, + }); + const getHeaderSha256 = readHeaderSha(getResponse); + if (getHeaderSha256 !== expectedSha256) { + throw new ManualFetchError( + `${HASH_HEADER} changed between HEAD and GET for ${manualUrl}.` + ); + } + + const manualText = await getResponse.text(); + const actualSha256 = sha256(manualText); + const manualHashMatches = actualSha256 === expectedSha256; + if (!manualHashMatches) { + throw new ManualFetchError( + `${HASH_HEADER} did not match the fetched manual body for ${manualUrl}.` + ); + } + + await writeCachedManual(resolvedCacheDir, manualText); + const outline = buildOutline(manualText); + const outlineText = outlineMarkdown(outline); + await writeOutline(resolvedCacheDir, outlineText); + + return { + outlineText, + status: { + manualUrl, + headerSha256: expectedSha256, + fetchedManualSha256: actualSha256, + manualHashMatches, + cacheStatus: "updated", + cacheDir: resolvedCacheDir, + manualPath, + outlinePath, + checkedAt, + lineCount: outline.lineCount, + headingCount: outline.headingCount, + }, + }; +}; + +const parseArgs = (argv) => { + const args = { + manualUrl: DEFAULT_MANUAL_URL, + cacheDir: undefined, + timeoutMs: 30000, + statusJson: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--manual-url") { + args.manualUrl = argv[++index]; + } else if (arg === "--cache-dir") { + args.cacheDir = argv[++index]; + } else if (arg === "--timeout-ms") { + args.timeoutMs = Number(argv[++index]); + } else if (arg === "--status-json") { + args.statusJson = true; + } else { + throw new ManualFetchError(`Unknown argument: ${arg}`); + } + } + + if (!args.manualUrl) { + throw new ManualFetchError("--manual-url cannot be empty."); + } + if (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0) { + throw new ManualFetchError("--timeout-ms must be a positive number."); + } + + return args; +}; + +const main = async () => { + const args = parseArgs(process.argv.slice(2)); + const { outlineText, status } = await fetchCodexManual(args); + + process.stdout.write(formatResult({ status, outlineText })); + + if (args.statusJson) { + console.error(JSON.stringify(status)); + } +}; + +const envProxyHint = () => { + if (proxyConfigured()) { + return "Hint: proxy env vars are present. This helper prefers `curl` in proxied sessions; if requests still fail, verify `curl` is installed and the proxy configuration is valid."; + } + if (typeof fetch !== "function") { + return "Hint: native fetch is unavailable in this Node runtime. Install `curl` or use a newer Node version to fetch the manual."; + } + if (process.platform === "win32") { + return "Hint: on Windows, pass a cache dir under `%TEMP%` or `%TMP%`."; + } + return null; +}; + +const formatErrorDetails = (error) => { + const details = inspect(error, { + breakLength: 120, + colors: false, + compact: false, + depth: 8, + }); + if (!error?.cause) { + return details; + } + + return `${details}\n\nCause:\n${inspect(error.cause, { + breakLength: 120, + colors: false, + compact: false, + depth: 8, + })}`; +}; + +const isCliEntrypoint = () => { + const entrypoint = process.argv[1]; + if (!entrypoint) { + return false; + } + + return pathToFileURL(entrypoint).href === import.meta.url; +}; + +if (isCliEntrypoint()) { + main().catch((error) => { + console.error(`Error: ${error.message}`); + const hint = envProxyHint(); + if (hint) { + console.error(hint); + } + console.error(""); + console.error("Details:"); + console.error(formatErrorDetails(error)); + process.exitCode = 1; + }); +} + +export { DEFAULT_MANUAL_URL, fetchCodexManual }; diff --git a/codex-rs/state/src/runtime.rs b/codex-rs/state/src/runtime.rs index ba41021290c..b08545d7a62 100644 --- a/codex-rs/state/src/runtime.rs +++ b/codex-rs/state/src/runtime.rs @@ -33,7 +33,6 @@ use crate::telemetry::DbTelemetry; use chrono::DateTime; use chrono::Utc; use codex_protocol::ThreadId; -use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::protocol::RolloutItem; use log::LevelFilter; use serde_json::Value; diff --git a/codex-rs/state/src/runtime/threads.rs b/codex-rs/state/src/runtime/threads.rs index 63bfe09f54f..c67056ab549 100644 --- a/codex-rs/state/src/runtime/threads.rs +++ b/codex-rs/state/src/runtime/threads.rs @@ -74,40 +74,6 @@ WHERE id = ? AND preview = '' Ok(result.rows_affected() > 0) } - /// Get dynamic tools for a thread, if present. - pub async fn get_dynamic_tools( - &self, - thread_id: ThreadId, - ) -> anyhow::Result>> { - let rows = sqlx::query( - r#" -SELECT namespace, name, description, input_schema, defer_loading -FROM thread_dynamic_tools -WHERE thread_id = ? -ORDER BY position ASC - "#, - ) - .bind(thread_id.to_string()) - .fetch_all(self.pool.as_ref()) - .await?; - if rows.is_empty() { - return Ok(None); - } - let mut tools = Vec::with_capacity(rows.len()); - for row in rows { - let input_schema: String = row.try_get("input_schema")?; - let input_schema = serde_json::from_str::(input_schema.as_str())?; - tools.push(DynamicToolSpec { - namespace: row.try_get("namespace")?, - name: row.try_get("name")?, - description: row.try_get("description")?, - input_schema, - defer_loading: row.try_get("defer_loading")?, - }); - } - Ok(Some(tools)) - } - /// Persist or replace the directional parent-child edge for a spawned thread. pub async fn upsert_thread_spawn_edge( &self, @@ -821,54 +787,6 @@ ON CONFLICT(id) DO UPDATE SET Ok(()) } - /// Persist dynamic tools for a thread if none have been stored yet. - /// - /// Dynamic tools are defined at thread start and should not change afterward. - /// This only writes the first time we see tools for a given thread. - pub async fn persist_dynamic_tools( - &self, - thread_id: ThreadId, - tools: Option<&[DynamicToolSpec]>, - ) -> anyhow::Result<()> { - let Some(tools) = tools else { - return Ok(()); - }; - if tools.is_empty() { - return Ok(()); - } - let thread_id = thread_id.to_string(); - let mut tx = self.pool.begin().await?; - for (idx, tool) in tools.iter().enumerate() { - let position = i64::try_from(idx).unwrap_or(i64::MAX); - let input_schema = serde_json::to_string(&tool.input_schema)?; - sqlx::query( - r#" -INSERT INTO thread_dynamic_tools ( - thread_id, - position, - namespace, - name, - description, - input_schema, - defer_loading -) VALUES (?, ?, ?, ?, ?, ?, ?) -ON CONFLICT(thread_id, position) DO NOTHING - "#, - ) - .bind(thread_id.as_str()) - .bind(position) - .bind(tool.namespace.as_deref()) - .bind(tool.name.as_str()) - .bind(tool.description.as_str()) - .bind(input_schema) - .bind(tool.defer_loading) - .execute(&mut *tx) - .await?; - } - tx.commit().await?; - Ok(()) - } - /// Apply rollout items incrementally using the underlying database. pub async fn apply_rollout_items( &self, @@ -898,8 +816,6 @@ ON CONFLICT(thread_id, position) DO NOTHING if let Some(updated_at) = updated_at { metadata.updated_at = updated_at; } - // Keep the thread upsert before dynamic tools to satisfy the foreign key constraint: - // thread_dynamic_tools.thread_id -> threads.id. let upsert_result = if existing_metadata.is_none() { self.upsert_thread_with_creation_memory_mode(&metadata, new_thread_memory_mode) .await @@ -914,14 +830,6 @@ ON CONFLICT(thread_id, position) DO NOTHING { return Err(err); } - let dynamic_tools = extract_dynamic_tools(items); - if let Some(dynamic_tools) = dynamic_tools - && let Err(err) = self - .persist_dynamic_tools(builder.id, dynamic_tools.as_deref()) - .await - { - return Err(err); - } Ok(()) } @@ -1039,16 +947,6 @@ SELECT ); } -pub(super) fn extract_dynamic_tools(items: &[RolloutItem]) -> Option>> { - items.iter().find_map(|item| match item { - RolloutItem::SessionMeta(meta_line) => Some(meta_line.meta.dynamic_tools.clone()), - RolloutItem::ResponseItem(_) - | RolloutItem::Compacted(_) - | RolloutItem::TurnContext(_) - | RolloutItem::EventMsg(_) => None, - }) -} - pub(super) fn extract_memory_mode(items: &[RolloutItem]) -> Option { items.iter().rev().find_map(|item| match item { RolloutItem::SessionMeta(meta_line) => meta_line.meta.memory_mode.clone(), diff --git a/codex-rs/thread-store/src/local/update_thread_metadata.rs b/codex-rs/thread-store/src/local/update_thread_metadata.rs index 16f9f79e06d..612328f4c7f 100644 --- a/codex-rs/thread-store/src/local/update_thread_metadata.rs +++ b/codex-rs/thread-store/src/local/update_thread_metadata.rs @@ -309,14 +309,6 @@ async fn apply_metadata_update( message: format!("failed to update memory mode for {thread_id}: {err}"), })?; } - if let Some(dynamic_tools) = patch.dynamic_tools { - state_db - .persist_dynamic_tools(thread_id, Some(dynamic_tools.as_slice())) - .await - .map_err(|err| ThreadStoreError::Internal { - message: format!("failed to update dynamic tools for {thread_id}: {err}"), - })?; - } Ok(()) } .await @@ -393,7 +385,6 @@ fn has_observed_metadata_facts(patch: &ThreadMetadataPatch) -> bool { || patch.sandbox_policy.is_some() || patch.token_usage.is_some() || patch.first_user_message.is_some() - || patch.dynamic_tools.is_some() } fn enum_to_string(value: &T) -> String { diff --git a/codex-rs/thread-store/src/thread_metadata_sync.rs b/codex-rs/thread-store/src/thread_metadata_sync.rs index edc78a36c29..408c792da8e 100644 --- a/codex-rs/thread-store/src/thread_metadata_sync.rs +++ b/codex-rs/thread-store/src/thread_metadata_sync.rs @@ -61,8 +61,6 @@ impl ThreadMetadataSync { } else { None }; - let dynamic_tools = - (!params.dynamic_tools.is_empty()).then(|| params.dynamic_tools.clone()); let update = ThreadMetadataPatch { model_provider: Some(params.metadata.model_provider.clone()), created_at: Some(created_at), @@ -76,7 +74,6 @@ impl ThreadMetadataSync { cli_version: Some(env!("CARGO_PKG_VERSION").to_string()), git_info: git_info.map(git_info_patch_from_observation), memory_mode: Some(params.metadata.memory_mode), - dynamic_tools, ..Default::default() }; Self { @@ -228,9 +225,6 @@ impl ThreadMetadataSync { { update.memory_mode = Some(memory_mode); } - if let Some(dynamic_tools) = meta_line.meta.dynamic_tools.clone() { - update.dynamic_tools = Some(dynamic_tools); - } } RolloutItem::TurnContext(turn_ctx) => { if !self.cwd_seen && !turn_ctx.cwd.as_os_str().is_empty() { @@ -365,7 +359,6 @@ fn update_has_metadata_facts(update: &ThreadMetadataPatch) -> bool { || update.first_user_message.is_some() || update.git_info.is_some() || update.memory_mode.is_some() - || update.dynamic_tools.is_some() } fn git_info_patch_from_observation(git_info: GitInfo) -> GitInfoPatch { diff --git a/codex-rs/thread-store/src/types.rs b/codex-rs/thread-store/src/types.rs index ca75ca1b69e..1e26e5dc772 100644 --- a/codex-rs/thread-store/src/types.rs +++ b/codex-rs/thread-store/src/types.rs @@ -529,8 +529,6 @@ pub struct ThreadMetadataPatch { pub git_info: Option, /// Thread memory behavior. pub memory_mode: Option, - /// Dynamic tools available to this thread. - pub dynamic_tools: Option>, } impl ThreadMetadataPatch { @@ -608,9 +606,6 @@ impl ThreadMetadataPatch { if next.memory_mode.is_some() { self.memory_mode = next.memory_mode; } - if next.dynamic_tools.is_some() { - self.dynamic_tools = next.dynamic_tools; - } } pub fn is_empty(&self) -> bool { @@ -636,7 +631,6 @@ impl ThreadMetadataPatch { && self.first_user_message.is_none() && self.git_info.is_none() && self.memory_mode.is_none() - && self.dynamic_tools.is_none() } } diff --git a/codex-rs/tools/src/lib.rs b/codex-rs/tools/src/lib.rs index 4c0fb44a7cd..91fa5830a32 100644 --- a/codex-rs/tools/src/lib.rs +++ b/codex-rs/tools/src/lib.rs @@ -62,7 +62,11 @@ 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::ExtensionTurnItem; +pub use tool_call::NoopTurnItemEmitter; pub use tool_call::ToolCall; +pub use tool_call::TurnItemEmissionFuture; +pub use tool_call::TurnItemEmitter; pub use tool_config::ShellCommandBackendConfig; pub use tool_config::ToolEnvironmentMode; pub use tool_config::ToolUserShellType; diff --git a/codex-rs/tools/src/mcp_tool_tests.rs b/codex-rs/tools/src/mcp_tool_tests.rs index 5a945263293..98f8885a951 100644 --- a/codex-rs/tools/src/mcp_tool_tests.rs +++ b/codex-rs/tools/src/mcp_tool_tests.rs @@ -6,17 +6,11 @@ use pretty_assertions::assert_eq; use std::collections::BTreeMap; fn mcp_tool(name: &str, description: &str, input_schema: serde_json::Value) -> rmcp::model::Tool { - rmcp::model::Tool { - name: name.to_string().into(), - title: None, - description: Some(description.to_string().into()), - input_schema: std::sync::Arc::new(rmcp::model::object(input_schema)), - output_schema: None, - annotations: None, - execution: None, - icons: None, - meta: None, - } + rmcp::model::Tool::new( + name.to_string(), + description.to_string(), + std::sync::Arc::new(rmcp::model::object(input_schema)), + ) } #[test] diff --git a/codex-rs/tools/src/responses_api_tests.rs b/codex-rs/tools/src/responses_api_tests.rs index 3ce13ebfe8c..e549d9969f7 100644 --- a/codex-rs/tools/src/responses_api_tests.rs +++ b/codex-rs/tools/src/responses_api_tests.rs @@ -87,11 +87,10 @@ fn dynamic_tool_to_responses_api_tool_preserves_defer_loading() { #[test] fn mcp_tool_to_deferred_responses_api_tool_sets_defer_loading() { - let tool = rmcp::model::Tool { - name: "lookup_order".to_string().into(), - title: None, - description: Some("Look up an order".to_string().into()), - input_schema: std::sync::Arc::new(rmcp::model::object(json!({ + let tool = rmcp::model::Tool::new( + "lookup_order", + "Look up an order", + std::sync::Arc::new(rmcp::model::object(json!({ "type": "object", "properties": { "order_id": {"type": "string"} @@ -99,12 +98,7 @@ fn mcp_tool_to_deferred_responses_api_tool_sets_defer_loading() { "required": ["order_id"], "additionalProperties": false, }))), - output_schema: None, - annotations: None, - execution: None, - icons: None, - meta: None, - }; + ); assert_eq!( mcp_tool_to_deferred_responses_api_tool( diff --git a/codex-rs/tools/src/tool_call.rs b/codex-rs/tools/src/tool_call.rs index 32d428648fc..3c04e216a6a 100644 --- a/codex-rs/tools/src/tool_call.rs +++ b/codex-rs/tools/src/tool_call.rs @@ -1,8 +1,11 @@ use crate::FunctionCallError; use crate::ToolName; use crate::ToolPayload; +use codex_protocol::items::WebSearchItem; use codex_protocol::models::ResponseItem; use codex_utils_output_truncation::TruncationPolicy; +use std::future::Future; +use std::pin::Pin; use std::sync::Arc; /// Raw response history snapshot available when an extension tool is invoked. @@ -23,17 +26,70 @@ impl ConversationHistory { } } +/// Future returned when an extension tool emits a visible turn-item lifecycle event. +pub type TurnItemEmissionFuture<'a> = Pin + Send + 'a>>; + +/// Visible turn items that an extension fully owns and may emit as-is. +/// +/// Add only item kinds that require no additional host finalization before +/// persistence or client delivery. Richer items need a host-owned publish path. +#[derive(Clone, Debug, PartialEq)] +pub enum ExtensionTurnItem { + WebSearch(WebSearchItem), +} + +/// Host-provided capability for extension tools to emit finalized visible turn items. +/// +/// Implementations route lifecycle events through the host's normal item event +/// pipeline, including any persistence and client delivery owned by the host. +pub trait TurnItemEmitter: Send + Sync { + /// Emits the beginning of one visible turn item. + fn emit_started<'a>(&'a self, item: ExtensionTurnItem) -> TurnItemEmissionFuture<'a>; + + /// Emits the completion of one visible turn item. + fn emit_completed<'a>(&'a self, item: ExtensionTurnItem) -> TurnItemEmissionFuture<'a>; +} + +/// Turn-item emitter used when a caller does not expose visible item emission. +#[derive(Debug, Default, Clone, Copy)] +pub struct NoopTurnItemEmitter; + +impl TurnItemEmitter for NoopTurnItemEmitter { + fn emit_started<'a>(&'a self, _item: ExtensionTurnItem) -> TurnItemEmissionFuture<'a> { + Box::pin(std::future::ready(())) + } + + fn emit_completed<'a>(&'a self, _item: ExtensionTurnItem) -> TurnItemEmissionFuture<'a> { + Box::pin(std::future::ready(())) + } +} + // TODO: this is temporary and will disappear in the next PR (as we make codex-extension-api generic on Invocation. -#[derive(Clone, Debug)] +#[derive(Clone)] pub struct ToolCall { pub turn_id: String, pub call_id: String, pub tool_name: ToolName, pub truncation_policy: TruncationPolicy, pub conversation_history: ConversationHistory, + pub turn_item_emitter: Arc, pub payload: ToolPayload, } +impl std::fmt::Debug for ToolCall { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ToolCall") + .field("turn_id", &self.turn_id) + .field("call_id", &self.call_id) + .field("tool_name", &self.tool_name) + .field("truncation_policy", &self.truncation_policy) + .field("conversation_history", &self.conversation_history) + .field("turn_item_emitter", &"") + .field("payload", &self.payload) + .finish() + } +} + impl ToolCall { pub fn function_arguments(&self) -> Result<&str, FunctionCallError> { match &self.payload { diff --git a/codex-rs/tools/tests/json_schema_policy_fixtures.rs b/codex-rs/tools/tests/json_schema_policy_fixtures.rs index 1e244ade1ed..71ea94a5d90 100644 --- a/codex-rs/tools/tests/json_schema_policy_fixtures.rs +++ b/codex-rs/tools/tests/json_schema_policy_fixtures.rs @@ -201,17 +201,11 @@ fn convert_fixture_tool( .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, - }; + let tool = rmcp::model::Tool::new( + name.to_string(), + fixture_tool.description.clone(), + Arc::new(input_schema), + ); mcp_tool_to_responses_api_tool(&ToolName::namespaced(&fixture.source, name), &tool) .unwrap_or_else(|err| panic!("convert {name} from {}: {err}", fixture.source)) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 8c89c9153be..bd0cc455480 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -473,7 +473,7 @@ struct SessionSummary { #[derive(Debug, Default)] struct InitialHistoryReplayBuffer { - retained_lines: VecDeque>, + retained_lines: VecDeque, render_from_transcript_tail: bool, } @@ -498,7 +498,7 @@ pub(crate) struct App { // Pager overlay state (Transcript or Static like Diff) pub(crate) overlay: Option, - pub(crate) deferred_history_lines: Vec>, + pub(crate) deferred_history_lines: Vec, has_emitted_history_lines: bool, transcript_reflow: TranscriptReflowState, initial_history_replay_buffer: Option, diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index b3e1edb9018..d90f511d6f9 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -1061,6 +1061,7 @@ mod tests { let statuses = vec![ McpServerStatus { name: "docs".to_string(), + server_info: None, tools: HashMap::from([( "list".to_string(), Tool { @@ -1080,6 +1081,7 @@ mod tests { }, McpServerStatus { name: "disabled".to_string(), + server_info: None, tools: HashMap::new(), resources: Vec::new(), resource_templates: Vec::new(), diff --git a/codex-rs/tui/src/app/resize_reflow.rs b/codex-rs/tui/src/app/resize_reflow.rs index 6ef17212665..34e25697e2f 100644 --- a/codex-rs/tui/src/app/resize_reflow.rs +++ b/codex-rs/tui/src/app/resize_reflow.rs @@ -27,11 +27,12 @@ use super::InitialHistoryReplayBuffer; use crate::history_cell; use crate::history_cell::HistoryCell; use crate::insert_history::HistoryLineWrapPolicy; +use crate::terminal_hyperlinks::HyperlinkLine; use crate::transcript_reflow::TRANSCRIPT_REFLOW_DEBOUNCE; use crate::tui; struct ReflowCellDisplay { - lines: Vec>, + lines: Vec, is_stream_continuation: bool, } @@ -41,7 +42,7 @@ struct ReflowCellDisplay { /// already-wrapped rows. Callers should keep treating `transcript_cells` as the source of truth; the /// rows here are a transient render product for a single terminal width. pub(super) struct ReflowRenderResult { - pub(super) lines: Vec>, + pub(super) lines: Vec, } pub(super) fn trailing_run_start(transcript_cells: &[Arc]) -> usize { @@ -75,12 +76,12 @@ impl App { &mut self, cell: &dyn HistoryCell, width: u16, - ) -> Vec> { + ) -> Vec { let mut display = - cell.display_lines_for_mode(width, self.chat_widget.history_render_mode()); + cell.display_hyperlink_lines_for_mode(width, self.chat_widget.history_render_mode()); if !display.is_empty() && !cell.is_stream_continuation() { if self.has_emitted_history_lines { - display.insert(0, Line::from("")); + display.insert(/*index*/ 0, HyperlinkLine::new(Line::from(""))); } else { self.has_emitted_history_lines = true; } @@ -101,7 +102,10 @@ impl App { if self.overlay.is_some() { self.deferred_history_lines.extend(display); } else { - tui.insert_history_lines_with_wrap_policy(display, self.history_line_wrap_policy()); + tui.insert_history_hyperlink_lines_with_wrap_policy( + display, + self.history_line_wrap_policy(), + ); } } @@ -153,14 +157,20 @@ impl App { let width = tui.terminal.last_known_screen_size.width; let reflowed_lines = self.render_transcript_lines_for_reflow(width).lines; if !reflowed_lines.is_empty() { - tui.insert_history_lines(reflowed_lines); + tui.insert_history_hyperlink_lines_with_wrap_policy( + reflowed_lines, + self.history_line_wrap_policy(), + ); } } return; } let retained_lines = buffer.retained_lines.into_iter().collect::>(); - tui.insert_history_lines_with_wrap_policy(retained_lines, self.history_line_wrap_policy()); + tui.insert_history_hyperlink_lines_with_wrap_policy( + retained_lines, + self.history_line_wrap_policy(), + ); } pub(super) fn insert_history_cell_lines_with_initial_replay_buffer( @@ -190,7 +200,10 @@ impl App { } else if self.overlay.is_some() { self.deferred_history_lines.extend(display); } else { - tui.insert_history_lines_with_wrap_policy(display, self.history_line_wrap_policy()); + tui.insert_history_hyperlink_lines_with_wrap_policy( + display, + self.history_line_wrap_policy(), + ); } } } @@ -210,7 +223,7 @@ impl App { /// here would make copy, transcript overlay, and future replay paths disagree about history. pub(super) fn buffer_initial_history_replay_display_lines( buffer: &mut InitialHistoryReplayBuffer, - display: Vec>, + display: Vec, max_rows: usize, ) { buffer.retained_lines.extend(display); @@ -437,7 +450,7 @@ impl App { self.deferred_history_lines.clear(); if !reflowed_lines.is_empty() { - tui.insert_history_lines_with_wrap_policy( + tui.insert_history_hyperlink_lines_with_wrap_policy( reflowed_lines, self.history_line_wrap_policy(), ); @@ -462,7 +475,8 @@ impl App { while start > 0 { start -= 1; let cell = self.transcript_cells[start].clone(); - let lines = cell.display_lines_for_mode(width, self.chat_widget.history_render_mode()); + let lines = cell + .display_hyperlink_lines_for_mode(width, self.chat_widget.history_render_mode()); rendered_rows += lines.len(); cell_displays.push_front(ReflowCellDisplay { lines, @@ -482,7 +496,10 @@ impl App { start -= 1; let cell = self.transcript_cells[start].clone(); cell_displays.push_front(ReflowCellDisplay { - lines: cell.display_lines_for_mode(width, self.chat_widget.history_render_mode()), + lines: cell.display_hyperlink_lines_for_mode( + width, + self.chat_widget.history_render_mode(), + ), is_stream_continuation: cell.is_stream_continuation(), }); } @@ -492,7 +509,7 @@ impl App { for display in cell_displays { if !display.lines.is_empty() && !display.is_stream_continuation { if has_emitted_history_lines { - reflowed_lines.push(Line::from("")); + reflowed_lines.push(HyperlinkLine::new(Line::from(""))); } else { has_emitted_history_lines = true; } diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index c2138ba31ae..d0c39b91ca6 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -147,6 +147,7 @@ async fn handle_mcp_inventory_result_respects_origin_thread() { app.handle_mcp_inventory_result( Ok(vec![McpServerStatus { name: "docs".to_string(), + server_info: None, tools: HashMap::new(), resources: Vec::new(), resource_templates: Vec::new(), @@ -3906,8 +3907,9 @@ fn plain_line_cell(text: impl Into) -> Arc { Arc::new(PlainHistoryCell::new(vec![Line::from(text.into())])) as Arc } -fn rendered_line_text(line: &Line<'static>) -> String { - line.spans +fn rendered_line_text(line: &crate::terminal_hyperlinks::HyperlinkLine) -> String { + line.line + .spans .iter() .map(|span| span.content.as_ref()) .collect() @@ -4019,7 +4021,7 @@ async fn initial_replay_buffer_keeps_recent_rows_when_row_cap_present() { app.initial_history_replay_buffer .as_mut() .expect("initial replay buffer active"), - vec![Line::from(format!("line {index}"))], + vec![Line::from(format!("line {index}")).into()], /*max_rows*/ 3, ); } @@ -4925,7 +4927,7 @@ async fn queued_rollback_syncs_overlay_and_clears_deferred_history() { app.transcript_cells.clone(), app.keymap.pager.clone(), )); - app.deferred_history_lines = vec![Line::from("stale buffered line")]; + app.deferred_history_lines = vec![Line::from("stale buffered line").into()]; app.backtrack.overlay_preview_active = true; app.backtrack.nth_user_message = 1; @@ -5459,7 +5461,7 @@ async fn clear_only_ui_reset_preserves_chat_session_state() { app.transcript_cells.clone(), crate::keymap::RuntimeKeymap::defaults().pager, )); - app.deferred_history_lines = vec![Line::from("stale buffered line")]; + app.deferred_history_lines = vec![Line::from("stale buffered line").into()]; app.has_emitted_history_lines = true; app.backtrack.primed = true; app.backtrack.overlay_preview_active = true; diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 625f8d0841b..075a4a5ae2d 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -245,7 +245,10 @@ impl App { let was_backtrack = self.backtrack.overlay_preview_active; if !self.deferred_history_lines.is_empty() { let lines = std::mem::take(&mut self.deferred_history_lines); - tui.insert_history_lines_with_wrap_policy(lines, self.history_line_wrap_policy()); + tui.insert_history_hyperlink_lines_with_wrap_policy( + lines, + self.history_line_wrap_policy(), + ); } self.overlay = None; self.backtrack.overlay_preview_active = false; @@ -263,8 +266,11 @@ impl App { .chat_widget .history_wrap_width(tui.terminal.last_known_screen_size.width); for cell in &self.transcript_cells { - tui.insert_history_lines_with_wrap_policy( - cell.display_lines_for_mode(width, self.chat_widget.history_render_mode()), + tui.insert_history_hyperlink_lines_with_wrap_policy( + cell.display_hyperlink_lines_for_mode( + width, + self.chat_widget.history_render_mode(), + ), self.history_line_wrap_policy(), ); } @@ -399,7 +405,7 @@ impl App { tui.draw(u16::MAX, |frame| { let width = frame.area().width.max(1); t.sync_live_tail(width, active_key, |w| { - chat_widget.active_cell_transcript_lines(w) + chat_widget.active_cell_transcript_hyperlink_lines(w) }); t.render(frame.area(), frame.buffer); })?; diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index aa2d5a645b2..79f8e274b67 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -2300,6 +2300,7 @@ mod tests { .into(), active_permission_profile: None, reasoning_effort: None, + initial_turns_page: None, }; let started = started_thread_from_resume_response( diff --git a/codex-rs/tui/src/bottom_pane/app_link_view.rs b/codex-rs/tui/src/bottom_pane/app_link_view.rs index 7fe180a16fe..bbd7f355c36 100644 --- a/codex-rs/tui/src/bottom_pane/app_link_view.rs +++ b/codex-rs/tui/src/bottom_pane/app_link_view.rs @@ -814,6 +814,7 @@ impl crate::render::renderable::Renderable for AppLinkView { Paragraph::new(lines) .wrap(Wrap { trim: false }) .render(inner, buf); + crate::terminal_hyperlinks::mark_url_hyperlink(buf, inner, &self.url); if actions_area.height > 0 { let actions_area = Rect { @@ -1005,7 +1006,10 @@ mod tests { if symbol.is_empty() { ' ' } else { - symbol.chars().next().unwrap_or(' ') + crate::terminal_hyperlinks::strip_osc8(symbol) + .chars() + .next() + .unwrap_or(' ') } }) .collect::() @@ -1325,7 +1329,10 @@ mod tests { if symbol.is_empty() { ' ' } else { - symbol.chars().next().unwrap_or(' ') + crate::terminal_hyperlinks::strip_osc8(symbol) + .chars() + .next() + .unwrap_or(' ') } }) .collect::() diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index e62cab9e865..21208de946d 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -256,7 +256,6 @@ use std::path::PathBuf; use std::time::Duration; use std::time::Instant; -#[cfg(test)] use ratatui::style::Color; /// If the pasted content exceeds this number of characters, replace it with a @@ -590,6 +589,7 @@ impl ChatComposer { pub fn set_mentions_v2_enabled(&mut self, enabled: bool) { self.mentions_v2_enabled = enabled; + self.history.set_at_mention_restore_enabled(enabled); self.sync_popups(); } @@ -609,11 +609,13 @@ impl ChatComposer { pub(crate) fn take_mention_bindings(&mut self) -> Vec { let elements = self.current_mention_elements(); let mut ordered = Vec::new(); - for (id, mention) in elements { + for (id, sigil, mention) in elements { if let Some(binding) = self.draft.mention_bindings.remove(&id) + && binding.sigil == sigil && binding.mention == mention { ordered.push(MentionBinding { + sigil: binding.sigil, mention: binding.mention, path: binding.path, }); @@ -1195,7 +1197,7 @@ impl ChatComposer { /// /// This is the "fresh draft" path: it clears pending paste payloads and /// mention link targets. Callers restoring a previously submitted draft - /// that must keep `$name -> path` resolution should use + /// that must keep sigiled mention target resolution should use /// [`Self::set_text_content_with_mention_bindings`] instead. pub(crate) fn set_text_content( &mut self, @@ -1649,7 +1651,7 @@ impl ChatComposer { result } - /// Return true if either the slash-command popup or the file-search popup is active. + /// Return true if any popup or history search is active. pub(crate) fn popup_active(&self) -> bool { self.history_search.is_some() || self.popups.active() } @@ -1802,7 +1804,6 @@ impl ChatComposer { KeyEvent { code: KeyCode::Esc, .. } => { - // Hide popup without modifying text, remember token to avoid immediate reopen. if let Some(tok) = Self::current_at_token(&self.draft.textarea) { self.popups.dismissed_file_token = Some(tok); } @@ -1827,24 +1828,17 @@ impl ChatComposer { }; let sel_path = sel.to_string_lossy().to_string(); - // If selected path looks like an image (png/jpeg), attach as image instead of inserting text. - let is_image = Self::is_image_path(&sel_path); - if is_image { - // Determine dimensions; if that fails fall back to normal path insertion. + if Self::is_image_path(&sel_path) { let path_buf = PathBuf::from(&sel_path); match image::image_dimensions(&path_buf) { Ok((width, height)) => { tracing::debug!("selected image dimensions={}x{}", width, height); - // Remove the current @token (mirror logic from insert_selected_path without inserting text) - // using the flat text and byte-offset cursor API. let cursor_offset = self.draft.textarea.cursor(); let text = self.draft.textarea.text(); - // Clamp to a valid char boundary to avoid panics when slicing. let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset); let before_cursor = &text[..safe_cursor]; let after_cursor = &text[safe_cursor..]; - // Determine token boundaries in the full text. let start_idx = before_cursor .char_indices() .rfind(|(_, c)| c.is_whitespace()) @@ -1861,17 +1855,14 @@ impl ChatComposer { self.draft.textarea.set_cursor(start_idx); self.attach_image(path_buf); - // Add a trailing space to keep typing fluid. self.draft.textarea.insert_str(" "); } Err(err) => { tracing::trace!("image dimensions lookup failed: {err}"); - // Fallback to plain path insertion if metadata read fails. self.insert_selected_path(&sel_path); } } } else { - // Non-image: inserting file path. self.insert_selected_path(&sel_path); } self.popups.active = ActivePopup::None; @@ -1881,6 +1872,7 @@ impl ChatComposer { } } + /// Handle key events when the legacy skill mention popup is visible. fn handle_key_event_with_skill_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { if self.handle_shortcut_overlay_key(&key_event) { return (InputResult::None, true); @@ -1962,6 +1954,7 @@ impl ChatComposer { return (InputResult::None, true); } self.footer.mode = reset_mode_after_activity(self.footer.mode); + let can_switch_search_mode = self.current_editable_at_token().is_some(); let ActivePopup::MentionV2(popup) = &mut self.popups.active else { unreachable!(); @@ -1969,6 +1962,7 @@ impl ChatComposer { let mut selected: Option = None; let mut close_popup = false; + let mut submit_without_popup = false; let result = match key_event { KeyEvent { @@ -1999,16 +1993,24 @@ impl ChatComposer { modifiers: KeyModifiers::NONE, .. } => { - popup.previous_search_mode(); - (InputResult::None, true) + if can_switch_search_mode { + popup.previous_search_mode(); + (InputResult::None, true) + } else { + self.handle_input_basic(key_event) + } } KeyEvent { code: KeyCode::Right, modifiers: KeyModifiers::NONE, .. } => { - popup.next_search_mode(); - (InputResult::None, true) + if can_switch_search_mode { + popup.next_search_mode(); + (InputResult::None, true) + } else { + self.handle_input_basic(key_event) + } } KeyEvent { code: KeyCode::Esc, .. @@ -2021,14 +2023,19 @@ impl ChatComposer { } KeyEvent { code: KeyCode::Tab, .. + } => { + selected = popup.selected(); + close_popup = true; + (InputResult::None, true) } - | KeyEvent { + KeyEvent { code: KeyCode::Enter, modifiers: KeyModifiers::NONE, .. } => { selected = popup.selected(); close_popup = true; + submit_without_popup = selected.is_none(); (InputResult::None, true) } input => self.handle_input_basic(input), @@ -2046,6 +2053,9 @@ impl ChatComposer { } } self.popups.active = ActivePopup::None; + if submit_without_popup { + return self.handle_key_event_without_popup(key_event); + } } result @@ -2241,14 +2251,14 @@ impl ChatComposer { /// second `@` in `@scope/pkg@latest`), keep treating the surrounding /// whitespace-delimited token as the active token rather than starting a /// new token at that nested prefix. - /// - If the token under the cursor starts with `prefix`, that token is - /// returned without the leading prefix. When `allow_empty` is true, a - /// lone prefix character yields `Some(String::new())` to surface hints. - fn current_prefixed_token( + /// - If the token under the cursor starts with `prefix`, its byte range and + /// text without the leading prefix are returned. When `allow_empty` is + /// true, a lone prefix character yields `Some(String::new())` to surface hints. + fn current_prefixed_token_range( textarea: &TextArea, prefix: char, allow_empty: bool, - ) -> Option { + ) -> Option<(Range, String)> { let cursor_offset = textarea.cursor(); let text = textarea.text(); @@ -2321,15 +2331,17 @@ impl ChatComposer { let left_match = token_left.filter(|t| t.starts_with(prefix)); let right_match = token_right.filter(|t| t.starts_with(prefix)); - let left_prefixed = left_match.map(|t| t[prefix.len_utf8()..].to_string()); - let right_prefixed = right_match.map(|t| t[prefix.len_utf8()..].to_string()); + let left_prefixed = + left_match.map(|t| (start_left..end_left, t[prefix.len_utf8()..].to_string())); + let right_prefixed = + right_match.map(|t| (start_right..end_right, t[prefix.len_utf8()..].to_string())); if at_whitespace { if right_prefixed.is_some() { return right_prefixed; } if token_left.is_some_and(|t| t == prefix_str) { - return allow_empty.then(String::new); + return allow_empty.then(|| (start_left..end_left, String::new())); } return left_prefixed; } @@ -2347,6 +2359,14 @@ impl ChatComposer { left_prefixed.or(right_prefixed) } + fn current_prefixed_token( + textarea: &TextArea, + prefix: char, + allow_empty: bool, + ) -> Option { + Self::current_prefixed_token_range(textarea, prefix, allow_empty).map(|(_, token)| token) + } + /// Extract the `@token` that the cursor is currently positioned on, if any. /// /// The returned string **does not** include the leading `@`. @@ -2354,11 +2374,48 @@ impl ChatComposer { Self::current_prefixed_token(textarea, '@', /*allow_empty*/ false) } + fn current_editable_at_token_with_options(&self, allow_empty: bool) -> Option { + let (range, token) = + Self::current_prefixed_token_range(&self.draft.textarea, '@', allow_empty)?; + if self + .draft + .textarea + .element_id_for_exact_range(range.clone()) + .is_some() + { + return None; + } + + let name_len = token + .as_bytes() + .iter() + .take_while(|byte| is_mention_name_char(**byte)) + .count(); + let mention_end = range.start + '@'.len_utf8() + name_len; + if name_len > 0 + && mention_end < range.end + && ends_plaintext_at_mention(self.draft.textarea.text().as_bytes(), mention_end) + && self + .draft + .textarea + .element_id_for_exact_range(range.start..mention_end) + .is_some() + { + return None; + } + + Some(token) + } + + fn current_editable_at_token(&self) -> Option { + self.current_editable_at_token_with_options(/*allow_empty*/ false) + } + fn current_mentions_v2_token(&self) -> Option { if !self.mentions_v2_enabled { return None; } - Self::current_prefixed_token(&self.draft.textarea, '@', /*allow_empty*/ true) + self.current_editable_at_token_with_options(/*allow_empty*/ true) } fn current_mention_token(&self) -> Option { @@ -2441,12 +2498,13 @@ impl ChatComposer { self.draft.textarea.set_cursor(start_idx); let id = self.draft.textarea.insert_element(insert_text); - if let (Some(path), Some(mention)) = - (path, Self::mention_name_from_insert_text(insert_text)) + if let (Some(path), Some((sigil, mention))) = + (path, Self::mention_token_from_insert_text(insert_text)) { self.draft.mention_bindings.insert( id, ComposerMentionBinding { + sigil, mention, path: path.to_string(), }, @@ -2460,8 +2518,12 @@ impl ChatComposer { self.draft.textarea.set_cursor(new_cursor); } - fn mention_name_from_insert_text(insert_text: &str) -> Option { - let name = insert_text.strip_prefix('$')?; + fn mention_token_from_insert_text(insert_text: &str) -> Option<(char, String)> { + let sigil = insert_text.chars().next()?; + if !matches!(sigil, '$' | '@') { + return None; + } + let name = &insert_text[sigil.len_utf8()..]; if name.is_empty() { return None; } @@ -2470,31 +2532,33 @@ impl ChatComposer { .iter() .all(|byte| is_mention_name_char(*byte)) { - Some(name.to_string()) + Some((sigil, name.to_string())) } else { None } } - fn current_mention_elements(&self) -> Vec<(u64, String)> { + fn current_mention_elements(&self) -> Vec<(u64, char, String)> { self.draft .textarea .text_element_snapshots() .into_iter() .filter_map(|snapshot| { - Self::mention_name_from_insert_text(snapshot.text.as_str()) - .map(|mention| (snapshot.id, mention)) + Self::mention_token_from_insert_text(snapshot.text.as_str()) + .map(|(sigil, mention)| (snapshot.id, sigil, mention)) }) .collect() } fn snapshot_mention_bindings(&self) -> Vec { let mut ordered = Vec::new(); - for (id, mention) in self.current_mention_elements() { + for (id, sigil, mention) in self.current_mention_elements() { if let Some(binding) = self.draft.mention_bindings.get(&id) + && binding.sigil == sigil && binding.mention == mention { ordered.push(MentionBinding { + sigil: binding.sigil, mention: binding.mention.clone(), path: binding.path.clone(), }); @@ -2512,7 +2576,7 @@ impl ChatComposer { let text = self.draft.textarea.text().to_string(); let mut scan_from = 0usize; for binding in mention_bindings { - let token = format!("${}", binding.mention); + let token = format!("{}{}", binding.sigil, binding.mention); let Some(range) = find_next_mention_token_range(text.as_str(), token.as_str(), scan_from) else { @@ -2531,6 +2595,7 @@ impl ChatComposer { self.draft.mention_bindings.insert( id, ComposerMentionBinding { + sigil: binding.sigil, mention: binding.mention, path: binding.path, }, @@ -2540,6 +2605,21 @@ impl ChatComposer { } } + fn plugin_at_mention_highlights(&self) -> Vec<(Range, Style)> { + self.draft + .textarea + .text_element_snapshots() + .into_iter() + .filter_map(|snapshot| { + let binding = self.draft.mention_bindings.get(&snapshot.id)?; + if !binding.path.starts_with("plugin://") || !snapshot.text.starts_with('@') { + return None; + } + Some((snapshot.range, Style::default().fg(Color::Magenta))) + }) + .collect() + } + /// Prepare text for submission/queuing. Returns None if submission should be suppressed. /// On success, clears pending paste payloads because placeholders have been expanded. /// @@ -3411,7 +3491,7 @@ impl ChatComposer { let file_token = if self.mentions_v2_enabled { None } else { - Self::current_at_token(&self.draft.textarea) + self.current_editable_at_token() }; let browsing_history = self .history @@ -3537,10 +3617,8 @@ impl ChatComposer { } } - /// Synchronize `self.file_search_popup` with the current text in the textarea. - /// Note this is only called when the active popup is NOT Command. + /// Synchronize the legacy file-search popup with the current `@` token. fn sync_file_search_popup(&mut self, query: String) { - // If user dismissed popup for this exact query, don't reopen until text changes. if self.popups.dismissed_file_token.as_ref() == Some(&query) { return; } @@ -3901,16 +3979,46 @@ fn is_mention_name_char(byte: u8) -> bool { matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_' | b'-') } +fn ends_plaintext_at_mention(bytes: &[u8], index: usize) -> bool { + bytes.get(index).is_none_or(|byte| { + byte.is_ascii_whitespace() + || *byte == b'.' + && bytes.get(index + 1).is_none_or(|next| { + next.is_ascii_whitespace() + || !next.is_ascii_alphanumeric() && *next != b'_' && *next != b'-' + }) + || !matches!(*byte, b'.' | b'/' | b'\\') + && !byte.is_ascii_alphanumeric() + && *byte != b'_' + && *byte != b'-' + }) +} + +fn starts_plaintext_at_mention(text: &str, index: usize) -> bool { + if index == 0 { + return true; + } + + text.get(..index) + .and_then(|prefix| prefix.chars().next_back()) + .is_some_and(|ch| ch.is_whitespace() || !is_mention_name_char_char(ch)) +} + +fn is_mention_name_char_char(ch: char) -> bool { + ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-') +} + fn find_next_mention_token_range(text: &str, token: &str, from: usize) -> Option> { if token.is_empty() || from >= text.len() { return None; } let bytes = text.as_bytes(); let token_bytes = token.as_bytes(); + let sigil = *token_bytes.first()?; let mut index = from; while index < bytes.len() { - if bytes[index] != b'$' { + if bytes[index] != sigil { index += 1; continue; } @@ -3924,10 +4032,24 @@ fn find_next_mention_token_range(text: &str, token: &str, from: usize) -> Option continue; } - if bytes - .get(end) - .is_none_or(|byte| !is_mention_name_char(*byte)) - { + // Fix for restored `@` mentions: rebinding must not attach to embedded substrings such + // as email addresses, while preserving the existing `$` mention matching behavior. + let starts_plaintext_mention = if sigil == b'@' { + starts_plaintext_at_mention(text, index) + } else { + true + }; + // Fix for restored `@` mentions: mirror history encoding's trailing boundary so path-like + // text such as `@sample/pkg` is not rebound as the plain `@sample` mention. + let ends_plaintext_mention = if sigil == b'@' { + ends_plaintext_at_mention(bytes, end) + } else { + bytes + .get(end) + .is_none_or(|byte| !is_mention_name_char(*byte)) + }; + + if starts_plaintext_mention && ends_plaintext_mention { return Some(index..end); } @@ -4286,8 +4408,15 @@ impl ChatComposer { .textarea .render_ref_masked(textarea_rect, buf, &mut state, mask_char); } else { - let highlight_ranges = self.history_search_highlight_ranges(); - if highlight_ranges.is_empty() { + let mut highlights = self.plugin_at_mention_highlights(); + let search_highlight_style = + Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD); + highlights.extend( + self.history_search_highlight_ranges() + .into_iter() + .map(|range| (range, search_highlight_style)), + ); + if highlights.is_empty() { StatefulWidgetRef::render_ref( &(&self.draft.textarea), textarea_rect, @@ -4295,12 +4424,6 @@ impl ChatComposer { &mut state, ); } else { - let highlight_style = - Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD); - let highlights = highlight_ranges - .into_iter() - .map(|range| (range, highlight_style)) - .collect::>(); self.draft.textarea.render_ref_styled_with_highlights( textarea_rect, buf, @@ -4736,6 +4859,142 @@ mod tests { ); } + fn plugin_mention_foreground_color(composer: &ChatComposer) -> Option { + let area = Rect::new(0, 0, 40, 5); + let mut buf = Buffer::empty(area); + composer.render(area, &mut buf); + + let textarea_row = 1; + let row_text = (0..area.width) + .map(|x| { + buf[(x, textarea_row)] + .symbol() + .chars() + .next() + .unwrap_or(' ') + }) + .collect::(); + let mention_x = row_text + .find("@sample") + .expect("expected plugin mention in composer row"); + buf[(mention_x as u16, textarea_row)].style().fg + } + + #[test] + fn plugin_at_mentions_use_plugin_accent_style() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ true, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_text_content_with_mention_bindings( + "@sample plugin".to_string(), + Vec::new(), + Vec::new(), + vec![MentionBinding { + sigil: '@', + mention: "sample".to_string(), + path: "plugin://sample@test".to_string(), + }], + ); + + assert_eq!( + plugin_mention_foreground_color(&composer), + Some(Color::Magenta) + ); + } + + #[test] + fn plugin_at_mentions_render_with_plugin_accent_snapshot() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ true, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_text_content_with_mention_bindings( + "@sample plugin".to_string(), + Vec::new(), + Vec::new(), + vec![MentionBinding { + sigil: '@', + mention: "sample".to_string(), + path: "plugin://sample@test".to_string(), + }], + ); + + let area = Rect::new(0, 0, 40, 5); + let mut buf = Buffer::empty(area); + composer.render(area, &mut buf); + + let textarea_row = 1; + let mut text = String::new(); + let mut magenta = String::new(); + for x in 0..area.width { + let cell = &buf[(x, textarea_row)]; + text.push(cell.symbol().chars().next().unwrap_or(' ')); + magenta.push(if cell.style().fg == Some(Color::Magenta) { + '^' + } else { + ' ' + }); + } + while text.ends_with(' ') { + text.pop(); + } + while magenta.ends_with(' ') { + magenta.pop(); + } + + insta::assert_snapshot!( + "plugin_at_mentions_render_with_plugin_accent", + format!("text: {text}\nmagenta: {magenta}") + ); + } + + #[test] + fn recalled_plugin_at_mentions_keep_plugin_accent_style() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ true, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_text_content_with_mention_bindings( + "@sample plugin".to_string(), + Vec::new(), + Vec::new(), + vec![MentionBinding { + sigil: '@', + mention: "sample".to_string(), + path: "plugin://sample@test".to_string(), + }], + ); + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!(result, InputResult::Submitted { .. })); + + composer.set_text_content(String::new(), Vec::new(), Vec::new()); + let (_, needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + assert!(needs_redraw); + + assert_eq!( + plugin_mention_foreground_color(&composer), + Some(Color::Magenta) + ); + } + #[test] fn status_line_hyperlink_marks_pr_number_cells() { let (tx, _rx) = unbounded_channel::(); @@ -6446,6 +6705,291 @@ mod tests { } } + #[test] + fn set_text_content_rebinds_at_sigiled_mentions() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + let mention_bindings = vec![MentionBinding { + sigil: '@', + mention: "figma".to_string(), + path: "/tmp/user/figma/SKILL.md".to_string(), + }]; + composer.set_text_content_with_mention_bindings( + "@figma please".to_string(), + Vec::new(), + Vec::new(), + mention_bindings.clone(), + ); + + assert_eq!(composer.mention_bindings(), mention_bindings); + } + + #[test] + fn set_text_content_rebinds_matching_sigil_only() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + let mention_bindings = vec![MentionBinding { + sigil: '$', + mention: "figma".to_string(), + path: "app://figma".to_string(), + }]; + composer.set_text_content_with_mention_bindings( + "@figma then $figma".to_string(), + Vec::new(), + Vec::new(), + mention_bindings.clone(), + ); + + let bound_tokens = composer + .current_mention_elements() + .into_iter() + .map(|(_, sigil, mention)| (sigil, mention)) + .collect::>(); + assert_eq!(bound_tokens, vec![('$', "figma".to_string())]); + assert_eq!(composer.mention_bindings(), mention_bindings); + } + + #[test] + fn set_text_content_rebinds_both_sigil_forms() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + let mention_bindings = vec![ + MentionBinding { + sigil: '@', + mention: "figma".to_string(), + path: "plugin://figma@test".to_string(), + }, + MentionBinding { + sigil: '$', + mention: "figma".to_string(), + path: "app://figma".to_string(), + }, + ]; + composer.set_text_content_with_mention_bindings( + "@figma then $figma".to_string(), + Vec::new(), + Vec::new(), + mention_bindings.clone(), + ); + + let bound_tokens = composer + .current_mention_elements() + .into_iter() + .map(|(_, sigil, mention)| (sigil, mention)) + .collect::>(); + assert_eq!( + bound_tokens, + vec![('@', "figma".to_string()), ('$', "figma".to_string())] + ); + assert_eq!(composer.mention_bindings(), mention_bindings); + } + + #[test] + fn set_text_content_rebinds_at_mentions_after_email_substrings() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + let text = "foo@sample.com then @sample".to_string(); + let mention_start = text.rfind("@sample").expect("expected bound mention token"); + let mention_range = mention_start..mention_start + "@sample".len(); + let mention_bindings = vec![MentionBinding { + sigil: '@', + mention: "sample".to_string(), + path: "plugin://sample@test".to_string(), + }]; + composer.set_text_content_with_mention_bindings( + text, + Vec::new(), + Vec::new(), + mention_bindings.clone(), + ); + + // Fix coverage: only the plaintext `@sample` should be atomic; the email substring stays editable. + assert_eq!( + composer + .draft + .textarea + .text_element_snapshots() + .into_iter() + .map(|snapshot| snapshot.range) + .collect::>(), + vec![mention_range] + ); + assert_eq!(composer.mention_bindings(), mention_bindings); + } + + #[test] + fn set_text_content_rebinds_at_mentions_after_punctuation() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + let text = "Please ask (@sample)".to_string(); + let mention_start = text.find("@sample").expect("expected bound mention token"); + let mention_range = mention_start..mention_start + "@sample".len(); + let mention_bindings = vec![MentionBinding { + sigil: '@', + mention: "sample".to_string(), + path: "plugin://sample@test".to_string(), + }]; + composer.set_text_content_with_mention_bindings( + text, + Vec::new(), + Vec::new(), + mention_bindings.clone(), + ); + + assert_eq!( + composer + .draft + .textarea + .text_element_snapshots() + .into_iter() + .map(|snapshot| snapshot.range) + .collect::>(), + vec![mention_range] + ); + assert_eq!(composer.mention_bindings(), mention_bindings); + } + + #[test] + fn bound_at_mentions_do_not_block_arrow_navigation() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + composer.set_text_content_with_mention_bindings( + "go @figma now".to_string(), + Vec::new(), + Vec::new(), + vec![MentionBinding { + sigil: '@', + mention: "figma".to_string(), + path: "plugin://figma@debug".to_string(), + }], + ); + composer.draft.textarea.set_cursor("go".len()); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); + assert_eq!(composer.draft.textarea.cursor(), "go".len() + 1); + assert!(matches!(composer.popups.active, ActivePopup::None)); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); + assert_eq!(composer.draft.textarea.cursor(), "go @figma".len()); + assert!(matches!(composer.popups.active, ActivePopup::None)); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)); + assert_eq!(composer.draft.textarea.cursor(), "go ".len()); + assert!(matches!(composer.popups.active, ActivePopup::None)); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)); + assert_eq!(composer.draft.textarea.cursor(), "go".len()); + assert!(matches!(composer.popups.active, ActivePopup::None)); + } + + #[test] + fn restored_bound_at_mentions_do_not_open_mention_popup() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + for (text, move_cursor_to_end) in [ + ("@sample".to_string(), false), + ("Please ask @sample.".to_string(), true), + ] { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_plugin_mentions(Some(vec![PluginCapabilitySummary { + config_name: "sample@test".to_string(), + display_name: "sample".to_string(), + description: None, + has_skills: true, + mcp_server_names: vec!["sample".to_string()], + app_connector_ids: Vec::new(), + }])); + + composer.set_text_content_with_mention_bindings( + text.clone(), + Vec::new(), + Vec::new(), + vec![MentionBinding { + sigil: '@', + mention: "sample".to_string(), + path: "plugin://sample@test".to_string(), + }], + ); + if move_cursor_to_end { + composer.move_cursor_to_end(); + } + + assert!(matches!(composer.popups.active, ActivePopup::None)); + + let (result, consumed) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(consumed); + match result { + InputResult::Submitted { + text: submitted, .. + } => assert_eq!(submitted, text), + _ => panic!("expected restored bound mention to submit"), + } + } + } + #[test] fn enter_submits_when_file_popup_has_no_selection() { use crossterm::event::KeyCode; @@ -6478,6 +7022,39 @@ mod tests { } } + #[test] + fn enter_submits_when_unified_mention_popup_has_no_selection() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_mentions_v2_enabled(/*enabled*/ true); + + let input = "npx -y @kaeawc/auto-mobile@latest"; + composer.draft.textarea.insert_str(input); + composer.draft.textarea.set_cursor(input.len()); + composer.sync_popups(); + + assert!(matches!(composer.popups.active, ActivePopup::MentionV2(_))); + + let (result, consumed) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(consumed); + match result { + InputResult::Submitted { text, .. } => assert_eq!(text, input), + _ => panic!("expected Submitted"), + } + } + /// Behavior: if the ASCII path has a pending first char (flicker suppression) and a non-ASCII /// char arrives next, the pending ASCII char should still be preserved and the overall input /// should submit normally (i.e. we should not misclassify this as a paste burst). @@ -8783,6 +9360,7 @@ mod tests { ); let mention_bindings = vec![MentionBinding { + sigil: '$', mention: "figma".to_string(), path: "/tmp/user/figma/SKILL.md".to_string(), }]; diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/draft_state.rs b/codex-rs/tui/src/bottom_pane/chat_composer/draft_state.rs index f739f10ec99..b219097ee18 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/draft_state.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/draft_state.rs @@ -40,6 +40,7 @@ impl DraftState { #[derive(Clone, Debug)] pub(super) struct ComposerMentionBinding { + pub(super) sigil: char, pub(super) mention: String, pub(super) path: String, } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer_history.rs b/codex-rs/tui/src/bottom_pane/chat_composer_history.rs index 6a490e81ec1..23b553fa089 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer_history.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer_history.rs @@ -18,7 +18,7 @@ use std::path::PathBuf; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::MentionBinding; -use crate::mention_codec::decode_history_mentions; +use crate::mention_codec::decode_history_mentions_with_at_mentions; use codex_protocol::ThreadId; use codex_protocol::user_input::TextElement; @@ -47,7 +47,11 @@ impl HistoryEntry { /// recorded with the full `HistoryEntry` value built by the composer; using `new` for a local /// image or paste submission would make recall lose placeholder ownership. pub(crate) fn new(text: String) -> Self { - let decoded = decode_history_mentions(&text); + Self::new_with_at_mentions(text, /*at_mentions_enabled*/ true) + } + + pub(crate) fn new_with_at_mentions(text: String, at_mentions_enabled: bool) -> Self { + let decoded = decode_history_mentions_with_at_mentions(&text, at_mentions_enabled); Self { text: decoded.text, text_elements: Vec::new(), @@ -57,6 +61,7 @@ impl HistoryEntry { .mentions .into_iter() .map(|mention| MentionBinding { + sigil: mention.sigil, mention: mention.mention, path: mention.path, }) @@ -132,6 +137,8 @@ pub(crate) struct ChatComposerHistory { /// Active incremental history search, if Ctrl+R search mode is open. search: Option, + /// Whether persistent history restore should rehydrate `@` tool mentions. + at_mention_restore_enabled: bool, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -226,7 +233,19 @@ impl ChatComposerHistory { history_cursor: None, last_history_text: None, search: None, + at_mention_restore_enabled: false, + } + } + + pub fn set_at_mention_restore_enabled(&mut self, enabled: bool) { + if self.at_mention_restore_enabled == enabled { + return; } + self.at_mention_restore_enabled = enabled; + self.fetched_history.clear(); + self.history_cursor = None; + self.last_history_text = None; + self.search = None; } /// Updates persistent history metadata when a new session is configured. @@ -393,7 +412,9 @@ impl ChatComposerHistory { return HistoryEntryResponse::Ignored; } - let entry = entry.map(HistoryEntry::new); + let entry = entry.map(|entry| { + HistoryEntry::new_with_at_mentions(entry, self.at_mention_restore_enabled) + }); if let Some(entry) = entry.clone() { self.fetched_history.insert(offset, entry); } @@ -840,6 +861,75 @@ mod tests { ); } + #[test] + fn persistent_restore_gates_at_mentions() { + let (tx, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + let mut history = ChatComposerHistory::new(); + history.set_metadata(test_thread_id(), /*log_id*/ 42, /*entry_count*/ 1); + + assert!(history.navigate_up(&tx).is_none()); + let disabled = history.on_entry_response( + /*log_id*/ 42, + /*offset*/ 0, + Some("[@sample](plugin://sample@test) and [$figma](app://figma)".to_string()), + &tx, + ); + assert_eq!( + disabled, + HistoryEntryResponse::Found(HistoryEntry { + text: "$sample and $figma".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), + remote_image_urls: Vec::new(), + mention_bindings: vec![ + MentionBinding { + sigil: '$', + mention: "sample".to_string(), + path: "plugin://sample@test".to_string(), + }, + MentionBinding { + sigil: '$', + mention: "figma".to_string(), + path: "app://figma".to_string(), + }, + ], + pending_pastes: Vec::new(), + }) + ); + + history.set_at_mention_restore_enabled(/*enabled*/ true); + assert!(history.navigate_up(&tx).is_none()); + let enabled = history.on_entry_response( + /*log_id*/ 42, + /*offset*/ 0, + Some("[@sample](plugin://sample@test) and [$figma](app://figma)".to_string()), + &tx, + ); + assert_eq!( + enabled, + HistoryEntryResponse::Found(HistoryEntry { + text: "@sample and $figma".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), + remote_image_urls: Vec::new(), + mention_bindings: vec![ + MentionBinding { + sigil: '@', + mention: "sample".to_string(), + path: "plugin://sample@test".to_string(), + }, + MentionBinding { + sigil: '$', + mention: "figma".to_string(), + path: "app://figma".to_string(), + }, + ], + pending_pastes: Vec::new(), + }) + ); + } + #[test] fn navigation_with_async_fetch() { let (tx, mut rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/feedback_view.rs b/codex-rs/tui/src/bottom_pane/feedback_view.rs index 2770598fc4d..ce380a867d1 100644 --- a/codex-rs/tui/src/bottom_pane/feedback_view.rs +++ b/codex-rs/tui/src/bottom_pane/feedback_view.rs @@ -313,7 +313,7 @@ pub(crate) fn feedback_success_cell( include_logs: bool, thread_id: &str, feedback_audience: FeedbackAudience, -) -> history_cell::PlainHistoryCell { +) -> history_cell::WebHyperlinkHistoryCell { let prefix = if include_logs { "• Feedback uploaded." } else { @@ -359,7 +359,7 @@ pub(crate) fn feedback_success_cell( ]); } } - history_cell::PlainHistoryCell::new(lines) + history_cell::WebHyperlinkHistoryCell::new(lines) } fn issue_url_for_category( diff --git a/codex-rs/tui/src/bottom_pane/memories_settings_view.rs b/codex-rs/tui/src/bottom_pane/memories_settings_view.rs index 06e9b31eb8c..793596b4685 100644 --- a/codex-rs/tui/src/bottom_pane/memories_settings_view.rs +++ b/codex-rs/tui/src/bottom_pane/memories_settings_view.rs @@ -421,6 +421,7 @@ impl Renderable for MemoriesSettingsView { } if self.reset_confirmation.is_none() { self.docs_link.clone().render(docs_area, buf); + crate::terminal_hyperlinks::mark_url_hyperlink(buf, docs_area, MEMORIES_DOC_URL); } let hint_area = Rect { diff --git a/codex-rs/tui/src/bottom_pane/mentions_v2/search_catalog.rs b/codex-rs/tui/src/bottom_pane/mentions_v2/search_catalog.rs index b1dd7f914bc..a555070a86f 100644 --- a/codex-rs/tui/src/bottom_pane/mentions_v2/search_catalog.rs +++ b/codex-rs/tui/src/bottom_pane/mentions_v2/search_catalog.rs @@ -50,6 +50,7 @@ fn plugin_candidate(plugin: &PluginCapabilitySummary) -> Candidate { .config_name .split_once('@') .unwrap_or((plugin.config_name.as_str(), "")); + let mention_name = plugin_mention_name(plugin_name, plugin.display_name.as_str()); let mut search_terms = vec![plugin_name.to_string(), plugin.config_name.clone()]; if plugin.display_name != plugin_name { search_terms.push(plugin.display_name.clone()); @@ -64,12 +65,88 @@ fn plugin_candidate(plugin: &PluginCapabilitySummary) -> Candidate { search_terms, mention_type: MentionType::Plugin, selection: Selection::Tool { - insert_text: format!("${plugin_name}"), + insert_text: format!("@{mention_name}"), path: Some(format!("plugin://{}", plugin.config_name)), }, } } +fn plugin_mention_name(plugin_name: &str, display_name: &str) -> String { + let plugin_segments = split_plugin_name_segments(plugin_name); + let display_segments = split_display_name_segments(display_name); + + if plugin_segments.len() == display_segments.len() + && plugin_segments.iter().zip(&display_segments).all( + |((plugin_segment, _), display_segment)| { + plugin_segment.eq_ignore_ascii_case(display_segment.as_str()) + }, + ) + { + let mut result = String::new(); + for ((_, separator), display_segment) in plugin_segments.into_iter().zip(display_segments) { + result.push_str(display_segment.as_str()); + if let Some(separator) = separator { + result.push(separator); + } + } + return result; + } + + title_case_plugin_name(plugin_name) +} + +fn split_plugin_name_segments(plugin_name: &str) -> Vec<(String, Option)> { + let mut segments = Vec::new(); + let mut current = String::new(); + + for ch in plugin_name.chars() { + if matches!(ch, '-' | '_') { + if !current.is_empty() { + segments.push((std::mem::take(&mut current), Some(ch))); + } + } else { + current.push(ch); + } + } + + if !current.is_empty() { + segments.push((current, None)); + } + + segments +} + +fn split_display_name_segments(display_name: &str) -> Vec { + display_name + .split(|ch: char| !ch.is_ascii_alphanumeric()) + .filter(|segment| !segment.is_empty()) + .map(ToString::to_string) + .collect() +} + +fn title_case_plugin_name(plugin_name: &str) -> String { + let mut result = String::with_capacity(plugin_name.len()); + let mut capitalize_next = true; + + for ch in plugin_name.chars() { + if matches!(ch, '-' | '_') { + capitalize_next = true; + result.push(ch); + continue; + } + + if capitalize_next && ch.is_ascii_alphabetic() { + result.push(ch.to_ascii_uppercase()); + capitalize_next = false; + } else { + result.push(ch); + capitalize_next = false; + } + } + + result +} + fn plugin_description(plugin: &PluginCapabilitySummary) -> Option { let capability_labels = plugin_capability_labels(plugin); plugin.description.clone().or_else(|| { @@ -109,3 +186,30 @@ fn optional_skill_description(skill: &SkillMetadata) -> Option { let description = skill_description(skill).trim(); (!description.is_empty()).then(|| description.to_string()) } + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn plugin_mention_name_uses_display_segments_when_they_match_plugin_name() { + assert_eq!( + plugin_mention_name("mcp-search", "MCP Search"), + "MCP-Search" + ); + assert_eq!( + plugin_mention_name("google_calendar", "Google Calendar"), + "Google_Calendar" + ); + } + + #[test] + fn plugin_mention_name_falls_back_to_title_cased_plugin_name() { + assert_eq!(plugin_mention_name("sample", "Sample Plugin"), "Sample"); + assert_eq!( + plugin_mention_name("browser-use", "Browser Use"), + "Browser-Use" + ); + } +} diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 1fcc7635c07..7ef385570e5 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -83,7 +83,9 @@ pub(crate) struct LocalImageAttachment { #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct MentionBinding { - /// Mention token text without the leading `$`. + /// Visible mention sigil (`$` or `@`). + pub(crate) sigil: char, + /// Mention token text without the leading sigil (`$` or `@`). pub(crate) mention: String, /// Canonical mention target (for example `app://...` or absolute SKILL.md path). pub(crate) path: String, diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__plugin_at_mentions_render_with_plugin_accent.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__plugin_at_mentions_render_with_plugin_accent.snap new file mode 100644 index 00000000000..0f6c47dca02 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__plugin_at_mentions_render_with_plugin_accent.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: "format!(\"text: {text}\\nmagenta: {magenta}\")" +--- +text: › @sample plugin +magenta: ^^^^^^^ diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 16ac980f352..d3e5cf68ef8 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -10,7 +10,8 @@ //! visible immediately. //! //! The transcript overlay is kept in sync by `App::overlay_forward_event`, which syncs a live tail -//! during draws using `active_cell_transcript_key()` and `active_cell_transcript_lines()`. The +//! during draws using `active_cell_transcript_key()` and +//! `active_cell_transcript_hyperlink_lines()`. The //! cache key is designed to change when the active cell mutates in place or when its transcript //! output is time-dependent so the overlay can refresh its cached tail without rebuilding it on //! every draw. @@ -77,6 +78,7 @@ use crate::status::StatusHistoryHandle; use crate::status::format_directory_display; use crate::status::format_tokens_compact; use crate::status::rate_limit_snapshot_display_for_limit; +use crate::terminal_hyperlinks::HyperlinkLine; use crate::terminal_title::SetTerminalTitleResult; use crate::terminal_title::clear_terminal_title; use crate::terminal_title::set_terminal_title; @@ -1824,28 +1826,37 @@ impl ChatWidget { }) } - /// Returns the active cell's transcript lines for a given terminal width. + /// Returns the active cell's annotated transcript lines for a given terminal width. /// /// This is a convenience for the transcript overlay live-tail path, and it intentionally /// filters out empty results so the overlay can treat "nothing to render" as "no tail". Callers /// should pass the same width the overlay uses; using a different width will cause wrapping /// mismatches between the main viewport and the transcript overlay. - pub(crate) fn active_cell_transcript_lines(&self, width: u16) -> Option>> { + pub(crate) fn active_cell_transcript_hyperlink_lines( + &self, + width: u16, + ) -> Option> { let mut lines = Vec::new(); if let Some(cell) = self.transcript.active_cell.as_ref() { - lines.extend(cell.transcript_lines(width)); + lines.extend(cell.transcript_hyperlink_lines(width)); } if let Some(hook_cell) = self.active_hook_cell.as_ref() { // Compute hook lines first so hidden hooks do not add a separator. - let hook_lines = hook_cell.transcript_lines(width); + let hook_lines = hook_cell.transcript_hyperlink_lines(width); if !hook_lines.is_empty() && !lines.is_empty() { - lines.push("".into()); + lines.push(HyperlinkLine::from("")); } lines.extend(hook_lines); } (!lines.is_empty()).then_some(lines) } + #[cfg(test)] + pub(crate) fn active_cell_transcript_lines(&self, width: u16) -> Option>> { + self.active_cell_transcript_hyperlink_lines(width) + .map(crate::terminal_hyperlinks::visible_lines) + } + /// Return a reference to the widget's current config (includes any /// runtime overrides applied via TUI, e.g., model or approval policy). pub(crate) fn config_ref(&self) -> &Config { diff --git a/codex-rs/tui/src/chatwidget/input_submission.rs b/codex-rs/tui/src/chatwidget/input_submission.rs index 4700ffc0cc6..578a35728fe 100644 --- a/codex-rs/tui/src/chatwidget/input_submission.rs +++ b/codex-rs/tui/src/chatwidget/input_submission.rs @@ -363,6 +363,7 @@ impl ChatWidget { let encoded_mentions = mention_bindings .iter() .map(|binding| LinkedMention { + sigil: binding.sigil, mention: binding.mention.clone(), path: binding.path.clone(), }) diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 23487af437a..27ae4daff26 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -570,6 +570,7 @@ async fn submission_prefers_selected_duplicate_skill_path() { Vec::new(), Vec::new(), vec![MentionBinding { + sigil: '$', mention: "figma".to_string(), path: user_skill_path.to_string_lossy().into_owned(), }], @@ -605,6 +606,7 @@ async fn blocked_image_restore_preserves_mention_bindings() { path: PathBuf::from("/tmp/blocked.png"), }]; let mention_bindings = vec![MentionBinding { + sigil: '$', mention: "file".to_string(), path: "/tmp/skills/file/SKILL.md".to_string(), }]; @@ -1236,6 +1238,7 @@ async fn submit_user_message_ignores_inaccessible_app_mentions_from_bindings() { remote_image_urls: Vec::new(), text_elements: Vec::new(), mention_bindings: vec![MentionBinding { + sigil: '$', mention: "arabica-uae".to_string(), path: "app://arabica_uae".to_string(), }], diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 5fc2fa2ce92..74cd5270385 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -1244,6 +1244,7 @@ async fn submit_user_message_emits_structured_plugin_mentions_from_bindings() { remote_image_urls: Vec::new(), text_elements: Vec::new(), mention_bindings: vec![MentionBinding { + sigil: '$', mention: "sample".to_string(), path: "plugin://sample@test".to_string(), }], diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index 55aadefb6d8..ade74b8908f 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -2200,7 +2200,7 @@ async fn memories_settings_popup_snapshot() { chat.open_memories_popup(); - let popup = render_bottom_popup(&chat, /*width*/ 80); + let popup = strip_osc8_for_snapshot(&render_bottom_popup(&chat, /*width*/ 80)); assert_chatwidget_snapshot!("memories_settings_popup", popup); } diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index 48bc341f6bb..62817407242 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -875,6 +875,7 @@ async fn manual_interrupt_restores_pending_steer_mention_bindings_to_composer() chat.on_agent_message_delta("Final answer line\n".to_string()); let mention_bindings = vec![MentionBinding { + sigil: '$', mention: "figma".to_string(), path: "/tmp/skills/figma/SKILL.md".to_string(), }]; diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 0346b970698..ff1feb084cc 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -656,6 +656,7 @@ async fn goal_slash_command_uses_plain_text_for_mentions() { Vec::new(), Vec::new(), vec![MentionBinding { + sigil: '$', mention: "figma".to_string(), path: "app://figma".to_string(), }], @@ -917,6 +918,7 @@ fn merged_history_record_preserves_raw_text_and_rebased_elements() { remote_image_urls: Vec::new(), text_elements: vec![TextElement::new((4..10).into(), Some("$figma".to_string()))], mention_bindings: vec![MentionBinding { + sigil: '$', mention: "figma".to_string(), path: "app://figma".to_string(), }], @@ -1034,6 +1036,7 @@ async fn interrupted_merged_message_history_encodes_mentions_once() { Vec::new(), Vec::new(), vec![MentionBinding { + sigil: '$', mention: "figma".to_string(), path: "app://figma".to_string(), }], diff --git a/codex-rs/tui/src/get_git_diff.rs b/codex-rs/tui/src/get_git_diff.rs index a7b4b668fba..7f554507078 100644 --- a/codex-rs/tui/src/get_git_diff.rs +++ b/codex-rs/tui/src/get_git_diff.rs @@ -13,6 +13,13 @@ use crate::workspace_command::WorkspaceCommandExecutor; use crate::workspace_command::WorkspaceCommandOutput; const DIFF_COMMAND_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 30); +const DISABLE_FSMONITOR_CONFIG: &str = "core.fsmonitor=false"; +const DISABLE_HOOKS_CONFIG: &str = if cfg!(windows) { + "core.hooksPath=NUL" +} else { + "core.hooksPath=/dev/null" +}; +const EXECUTABLE_FILTER_CONFIG_PATTERN: &str = r"^filter\..*\.(clean|process)$"; /// Return value of [`get_git_diff`]. /// @@ -27,9 +34,22 @@ pub(crate) async fn get_git_diff( return Ok((false, String::new())); } - // Run tracked diff and untracked file listing in parallel. + // Keep `/diff` informational: repository configuration must not select executable diff helpers. + let diff_config_overrides = diff_filter_config_overrides(runner, cwd).await?; let (tracked_diff_res, untracked_output_res) = tokio::join!( - run_git_capture_diff(runner, cwd, &["diff", "--color"]), + run_git_capture_diff( + runner, + cwd, + &diff_config_overrides, + &[ + "diff", + "--no-textconv", + "--no-ext-diff", + "--submodule=short", + "--ignore-submodules=dirty", + "--color", + ] + ), run_git_capture_stdout(runner, cwd, &["ls-files", "--others", "--exclude-standard"]), ); let tracked_diff = tracked_diff_res?; @@ -48,8 +68,19 @@ pub(crate) async fn get_git_diff( .map(str::trim) .filter(|s| !s.is_empty()) { - let args = ["diff", "--color", "--no-index", "--", null_path, file]; - let diff = run_git_capture_diff(runner, cwd, &args).await?; + let args = [ + "diff", + "--no-textconv", + "--no-ext-diff", + "--submodule=short", + "--ignore-submodules=dirty", + "--color", + "--no-index", + "--", + null_path, + file, + ]; + let diff = run_git_capture_diff(runner, cwd, &diff_config_overrides, &args).await?; untracked_diff.push_str(&diff); } @@ -63,7 +94,7 @@ async fn run_git_capture_stdout( cwd: &Path, args: &[&str], ) -> Result { - let output = run_git_command(runner, cwd, args).await?; + let output = run_git_command(runner, cwd, &[], args).await?; if output.success() { Ok(output.stdout) } else { @@ -79,9 +110,10 @@ async fn run_git_capture_stdout( async fn run_git_capture_diff( runner: &dyn WorkspaceCommandExecutor, cwd: &Path, + config_overrides: &[(String, String)], args: &[&str], ) -> Result { - let output = run_git_command(runner, cwd, args).await?; + let output = run_git_command(runner, cwd, config_overrides, args).await?; if output.success() || output.exit_code == 1 { Ok(output.stdout) } else { @@ -92,32 +124,88 @@ async fn run_git_capture_diff( } } +/// Return Git configuration overrides that prevent configured filter drivers +/// from executing while generating diffs. +async fn diff_filter_config_overrides( + runner: &dyn WorkspaceCommandExecutor, + cwd: &Path, +) -> Result, String> { + let args = [ + "config", + "--null", + "--name-only", + "--get-regexp", + EXECUTABLE_FILTER_CONFIG_PATTERN, + ]; + let output = run_git_command(runner, cwd, &[], &args).await?; + if output.exit_code != 0 && output.exit_code != 1 { + return Err(format!( + "git {:?} failed with status {}", + args, output.exit_code + )); + } + + let mut drivers = output + .stdout + .split('\0') + .filter_map(|key| { + key.strip_suffix(".clean") + .or_else(|| key.strip_suffix(".process")) + }) + .map(str::to_string) + .collect::>(); + drivers.sort(); + drivers.dedup(); + + Ok(drivers + .into_iter() + .flat_map(|driver| { + [ + (format!("{driver}.clean"), String::new()), + (format!("{driver}.process"), String::new()), + (format!("{driver}.required"), "false".to_string()), + ] + }) + .collect()) +} + /// Determine if the current directory is inside a Git repository. async fn inside_git_repo( runner: &dyn WorkspaceCommandExecutor, cwd: &Path, ) -> Result { - let output = run_git_command(runner, cwd, &["rev-parse", "--is-inside-work-tree"]).await?; + let output = run_git_command(runner, cwd, &[], &["rev-parse", "--is-inside-work-tree"]).await?; Ok(output.success()) } async fn run_git_command( runner: &dyn WorkspaceCommandExecutor, cwd: &Path, + config_overrides: &[(String, String)], args: &[&str], ) -> Result { - let mut argv = Vec::with_capacity(args.len() + 1); + let mut argv = Vec::with_capacity(args.len() + 5); argv.push("git".to_string()); + argv.extend([ + "-c".to_string(), + DISABLE_FSMONITOR_CONFIG.to_string(), + "-c".to_string(), + DISABLE_HOOKS_CONFIG.to_string(), + ]); argv.extend(args.iter().map(|arg| (*arg).to_string())); - runner - .run( - WorkspaceCommand::new(argv) - .cwd(cwd.to_path_buf()) - .timeout(DIFF_COMMAND_TIMEOUT) - .disable_output_cap(), - ) - .await - .map_err(|err| err.to_string()) + let mut command = WorkspaceCommand::new(argv) + .cwd(cwd.to_path_buf()) + .timeout(DIFF_COMMAND_TIMEOUT) + .disable_output_cap(); + if !config_overrides.is_empty() { + command = command.env("GIT_CONFIG_COUNT", config_overrides.len().to_string()); + for (index, (key, value)) in config_overrides.iter().enumerate() { + command = command + .env(format!("GIT_CONFIG_KEY_{index}"), key) + .env(format!("GIT_CONFIG_VALUE_{index}"), value); + } + } + runner.run(command).await.map_err(|err| err.to_string()) } #[cfg(test)] @@ -125,17 +213,24 @@ mod tests { use super::*; use crate::workspace_command::WorkspaceCommandError; use pretty_assertions::assert_eq; + use std::collections::HashMap; use std::collections::VecDeque; + #[cfg(unix)] + use std::fs; use std::future::Future; + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; use std::path::PathBuf; use std::pin::Pin; + #[cfg(unix)] + use std::process::Command as ProcessCommand; use std::sync::Mutex; #[tokio::test] async fn get_git_diff_returns_not_git_for_non_git_cwd() { let cwd = PathBuf::from("/workspace"); let runner = FakeRunner::new(vec![response( - &["git", "rev-parse", "--is-inside-work-tree"], + git_command(&["rev-parse", "--is-inside-work-tree"]), /*exit_code*/ 128, "", )]); @@ -145,40 +240,61 @@ mod tests { assert_eq!(result, Ok((false, String::new()))); assert_commands( &runner.commands(), - &[&["git", "rev-parse", "--is-inside-work-tree"]], + &[git_command(&["rev-parse", "--is-inside-work-tree"])], &cwd, ); } #[tokio::test] - async fn get_git_diff_concatenates_tracked_and_untracked_diffs() { + async fn get_git_diff_disables_helpers_for_tracked_and_untracked_diffs() { let cwd = PathBuf::from("/workspace"); let runner = FakeRunner::new(vec![ response( - &["git", "rev-parse", "--is-inside-work-tree"], + git_command(&["rev-parse", "--is-inside-work-tree"]), /*exit_code*/ 0, "true\n", ), response( - &["git", "diff", "--color"], + git_command(&[ + "config", + "--null", + "--name-only", + "--get-regexp", + EXECUTABLE_FILTER_CONFIG_PATTERN, + ]), + /*exit_code*/ 0, + "filter.evil.clean\0filter.evil.process\0", + ), + response( + git_command(&[ + "diff", + "--no-textconv", + "--no-ext-diff", + "--submodule=short", + "--ignore-submodules=dirty", + "--color", + ]), /*exit_code*/ 1, "tracked\n", ), response( - &["git", "ls-files", "--others", "--exclude-standard"], + git_command(&["ls-files", "--others", "--exclude-standard"]), /*exit_code*/ 0, "new.txt\n", ), response( - &[ - "git", + git_command(&[ "diff", + "--no-textconv", + "--no-ext-diff", + "--submodule=short", + "--ignore-submodules=dirty", "--color", "--no-index", "--", null_device(), "new.txt", - ], + ]), /*exit_code*/ 1, "untracked\n", ), @@ -187,24 +303,44 @@ mod tests { let result = get_git_diff(&runner, &cwd).await; assert_eq!(result, Ok((true, "tracked\nuntracked\n".to_string()))); + let commands = runner.commands(); assert_commands( - &runner.commands(), + &commands, &[ - &["git", "rev-parse", "--is-inside-work-tree"], - &["git", "diff", "--color"], - &["git", "ls-files", "--others", "--exclude-standard"], - &[ - "git", + git_command(&["rev-parse", "--is-inside-work-tree"]), + git_command(&[ + "config", + "--null", + "--name-only", + "--get-regexp", + EXECUTABLE_FILTER_CONFIG_PATTERN, + ]), + git_command(&[ + "diff", + "--no-textconv", + "--no-ext-diff", + "--submodule=short", + "--ignore-submodules=dirty", + "--color", + ]), + git_command(&["ls-files", "--others", "--exclude-standard"]), + git_command(&[ "diff", + "--no-textconv", + "--no-ext-diff", + "--submodule=short", + "--ignore-submodules=dirty", "--color", "--no-index", "--", null_device(), "new.txt", - ], + ]), ], &cwd, ); + assert_eq!(commands[2].env, filter_override_env("filter.evil")); + assert_eq!(commands[4].env, filter_override_env("filter.evil")); } #[tokio::test] @@ -212,17 +348,35 @@ mod tests { let cwd = PathBuf::from("/workspace"); let runner = FakeRunner::new(vec![ response( - &["git", "rev-parse", "--is-inside-work-tree"], + git_command(&["rev-parse", "--is-inside-work-tree"]), /*exit_code*/ 0, "true\n", ), response( - &["git", "diff", "--color"], + git_command(&[ + "config", + "--null", + "--name-only", + "--get-regexp", + EXECUTABLE_FILTER_CONFIG_PATTERN, + ]), + /*exit_code*/ 1, + "", + ), + response( + git_command(&[ + "diff", + "--no-textconv", + "--no-ext-diff", + "--submodule=short", + "--ignore-submodules=dirty", + "--color", + ]), /*exit_code*/ 1, "tracked\n", ), response( - &["git", "ls-files", "--others", "--exclude-standard"], + git_command(&["ls-files", "--others", "--exclude-standard"]), /*exit_code*/ 0, "", ), @@ -238,13 +392,35 @@ mod tests { let cwd = PathBuf::from("/workspace"); let runner = FakeRunner::new(vec![ response( - &["git", "rev-parse", "--is-inside-work-tree"], + git_command(&["rev-parse", "--is-inside-work-tree"]), /*exit_code*/ 0, "true\n", ), - response(&["git", "diff", "--color"], /*exit_code*/ 2, ""), response( - &["git", "ls-files", "--others", "--exclude-standard"], + git_command(&[ + "config", + "--null", + "--name-only", + "--get-regexp", + EXECUTABLE_FILTER_CONFIG_PATTERN, + ]), + /*exit_code*/ 1, + "", + ), + response( + git_command(&[ + "diff", + "--no-textconv", + "--no-ext-diff", + "--submodule=short", + "--ignore-submodules=dirty", + "--color", + ]), + /*exit_code*/ 2, + "", + ), + response( + git_command(&["ls-files", "--others", "--exclude-standard"]), /*exit_code*/ 0, "", ), @@ -255,14 +431,180 @@ mod tests { .expect_err("unexpected git diff status should fail"); assert!( - error.contains("git [\"diff\", \"--color\"] failed with status 2"), + error.contains( + "git [\"diff\", \"--no-textconv\", \"--no-ext-diff\", \"--submodule=short\", \"--ignore-submodules=dirty\", \"--color\"] failed with status 2" + ), "unexpected error: {error}", ); } - fn response(argv: &[&str], exit_code: i32, stdout: &str) -> FakeResponse { + #[cfg(unix)] + #[tokio::test] + async fn get_git_diff_does_not_execute_configured_filters_fsmonitor_or_hooks() { + let tempdir = tempfile::tempdir().expect("create temp directory"); + let repo = tempdir.path().join("repo"); + fs::create_dir(&repo).expect("create test repository directory"); + run_git_setup(&repo, &["init", "-q"]); + run_git_setup(&repo, &["config", "user.name", "test"]); + run_git_setup(&repo, &["config", "user.email", "test@example.com"]); + fs::write(repo.join(".gitattributes"), "*.txt filter=x=y\n").expect("write attributes"); + fs::write(repo.join("tracked.txt"), "before\n").expect("write tracked file"); + fs::write(repo.join("unchanged.txt"), "unchanged\n").expect("write unchanged file"); + run_git_setup( + &repo, + &["add", ".gitattributes", "tracked.txt", "unchanged.txt"], + ); + run_git_setup(&repo, &["commit", "-qm", "initial"]); + + let filter_helper = tempdir.path().join("filter-helper.sh"); + let fsmonitor_helper = tempdir.path().join("fsmonitor-helper.sh"); + let hooks_dir = tempdir.path().join("hooks"); + let hook_helper = hooks_dir.join("post-index-change"); + fs::create_dir(&hooks_dir).expect("create hooks directory"); + write_marker_helper(&filter_helper); + write_marker_helper(&fsmonitor_helper); + write_marker_helper(&hook_helper); + run_git_setup( + &repo, + &[ + "config", + "filter.x=y.clean", + filter_helper.to_str().expect("filter helper path"), + ], + ); + run_git_setup( + &repo, + &[ + "config", + "filter.x=y.process", + filter_helper.to_str().expect("filter helper path"), + ], + ); + run_git_setup(&repo, &["config", "filter.x=y.required", "true"]); + run_git_setup( + &repo, + &[ + "config", + "core.fsmonitor", + fsmonitor_helper.to_str().expect("fsmonitor helper path"), + ], + ); + run_git_setup( + &repo, + &[ + "config", + "core.hooksPath", + hooks_dir.to_str().expect("hooks directory path"), + ], + ); + std::thread::sleep(Duration::from_secs(/*secs*/ 1)); + fs::write(repo.join("unchanged.txt"), "unchanged\n").expect("refresh unchanged file"); + fs::write(repo.join("tracked.txt"), "after\n").expect("modify tracked file"); + + let result = get_git_diff(&LocalRunner, &repo) + .await + .expect("generate diff without invoking helpers"); + + assert!(result.1.contains("before")); + assert!(result.1.contains("after")); + assert!(!filter_helper.with_extension("sh.ran").exists()); + assert!(!fsmonitor_helper.with_extension("sh.ran").exists()); + assert!(!hook_helper.with_extension("sh.ran").exists()); + } + + #[cfg(unix)] + #[tokio::test] + async fn get_git_diff_does_not_execute_helpers_while_checking_dirty_submodules() { + let tempdir = tempfile::tempdir().expect("create temp directory"); + let child = tempdir.path().join("child"); + let repo = tempdir.path().join("repo"); + fs::create_dir(&child).expect("create child repository directory"); + fs::create_dir(&repo).expect("create parent repository directory"); + run_git_setup(&child, &["init", "-q"]); + run_git_setup(&child, &["config", "user.name", "test"]); + run_git_setup(&child, &["config", "user.email", "test@example.com"]); + fs::write(child.join(".gitattributes"), "*.txt filter=evil\n") + .expect("write child attributes"); + fs::write(child.join("tracked.txt"), "before\n").expect("write child tracked file"); + run_git_setup(&child, &["add", ".gitattributes", "tracked.txt"]); + run_git_setup(&child, &["commit", "-qm", "initial"]); + + run_git_setup(&repo, &["init", "-q"]); + run_git_setup(&repo, &["config", "user.name", "test"]); + run_git_setup(&repo, &["config", "user.email", "test@example.com"]); + run_git_setup( + &repo, + &[ + "-c", + "protocol.file.allow=always", + "submodule", + "add", + "-q", + child.to_str().expect("child repository path"), + "child", + ], + ); + run_git_setup(&repo, &["commit", "-qm", "add submodule"]); + + let helper = tempdir.path().join("submodule-helper.sh"); + write_marker_helper(&helper); + let checkout = repo.join("child"); + run_git_setup( + &checkout, + &[ + "config", + "filter.evil.clean", + helper.to_str().expect("submodule helper path"), + ], + ); + run_git_setup(&checkout, &["config", "filter.evil.required", "true"]); + std::thread::sleep(Duration::from_secs(/*secs*/ 1)); + fs::write(checkout.join("tracked.txt"), "before\n").expect("refresh child tracked file"); + + let result = get_git_diff(&LocalRunner, &repo) + .await + .expect("generate diff without inspecting submodule worktrees"); + + assert!(result.1.is_empty()); + assert!(!helper.with_extension("sh.ran").exists()); + } + + fn git_command(args: &[&str]) -> Vec { + let mut argv = vec![ + "git".to_string(), + "-c".to_string(), + DISABLE_FSMONITOR_CONFIG.to_string(), + "-c".to_string(), + DISABLE_HOOKS_CONFIG.to_string(), + ]; + argv.extend(args.iter().map(|arg| (*arg).to_string())); + argv + } + + fn filter_override_env(driver: &str) -> HashMap> { + HashMap::from([ + ("GIT_CONFIG_COUNT".to_string(), Some("3".to_string())), + ( + "GIT_CONFIG_KEY_0".to_string(), + Some(format!("{driver}.clean")), + ), + ("GIT_CONFIG_VALUE_0".to_string(), Some(String::new())), + ( + "GIT_CONFIG_KEY_1".to_string(), + Some(format!("{driver}.process")), + ), + ("GIT_CONFIG_VALUE_1".to_string(), Some(String::new())), + ( + "GIT_CONFIG_KEY_2".to_string(), + Some(format!("{driver}.required")), + ), + ("GIT_CONFIG_VALUE_2".to_string(), Some("false".to_string())), + ]) + } + + fn response(argv: Vec, exit_code: i32, stdout: &str) -> FakeResponse { FakeResponse { - argv: argv.iter().map(|arg| (*arg).to_string()).collect(), + argv, output: WorkspaceCommandOutput { exit_code, stdout: stdout.to_string(), @@ -275,15 +617,32 @@ mod tests { if cfg!(windows) { "NUL" } else { "/dev/null" } } - fn assert_commands(commands: &[WorkspaceCommand], expected: &[&[&str]], cwd: &Path) { + #[cfg(unix)] + fn run_git_setup(cwd: &Path, args: &[&str]) { + let status = ProcessCommand::new("git") + .args(args) + .current_dir(cwd) + .status() + .expect("run git setup command"); + assert!(status.success(), "git setup command failed: {args:?}"); + } + + #[cfg(unix)] + fn write_marker_helper(path: &Path) { + fs::write(path, "#!/bin/sh\nprintf ran >> \"$0.ran\"\nexit 1\n") + .expect("write helper script"); + let mut permissions = fs::metadata(path) + .expect("read helper metadata") + .permissions(); + permissions.set_mode(/*mode*/ 0o755); + fs::set_permissions(path, permissions).expect("make helper executable"); + } + + fn assert_commands(commands: &[WorkspaceCommand], expected: &[Vec], cwd: &Path) { let actual: Vec> = commands .iter() .map(|command| command.argv.clone()) .collect(); - let expected: Vec> = expected - .iter() - .map(|argv| argv.iter().map(|arg| (*arg).to_string()).collect()) - .collect(); assert_eq!(actual, expected); for command in commands { @@ -336,4 +695,44 @@ mod tests { }) } } + + #[cfg(unix)] + struct LocalRunner; + + #[cfg(unix)] + impl WorkspaceCommandExecutor for LocalRunner { + fn run( + &self, + command: WorkspaceCommand, + ) -> Pin< + Box< + dyn Future> + + Send + + '_, + >, + > { + Box::pin(async move { + let mut process = ProcessCommand::new(&command.argv[0]); + process + .args(&command.argv[1..]) + .current_dir(command.cwd.expect("test command cwd")); + for (key, value) in command.env { + match value { + Some(value) => { + process.env(key, value); + } + None => { + process.env_remove(key); + } + } + } + let output = process.output().expect("run test command"); + Ok(WorkspaceCommandOutput { + exit_code: output.status.code().expect("test command exit code"), + stdout: String::from_utf8(output.stdout).expect("utf8 stdout"), + stderr: String::from_utf8(output.stderr).expect("utf8 stderr"), + }) + }) + } + } } diff --git a/codex-rs/tui/src/history_cell/base.rs b/codex-rs/tui/src/history_cell/base.rs index e3b33aa9d18..e9ade849453 100644 --- a/codex-rs/tui/src/history_cell/base.rs +++ b/codex-rs/tui/src/history_cell/base.rs @@ -22,6 +22,35 @@ impl HistoryCell for PlainHistoryCell { plain_lines(self.lines.clone()) } } + +#[derive(Debug)] +pub(crate) struct WebHyperlinkHistoryCell { + lines: Vec>, +} + +impl WebHyperlinkHistoryCell { + pub(crate) fn new(lines: Vec>) -> Self { + Self { lines } + } +} + +impl HistoryCell for WebHyperlinkHistoryCell { + fn display_lines(&self, _width: u16) -> Vec> { + self.lines.clone() + } + + fn display_hyperlink_lines(&self, _width: u16) -> Vec { + crate::terminal_hyperlinks::annotate_web_urls(self.lines.clone()) + } + + fn transcript_hyperlink_lines(&self, width: u16) -> Vec { + self.display_hyperlink_lines(width) + } + + fn raw_lines(&self) -> Vec> { + plain_lines(self.lines.clone()) + } +} #[derive(Debug)] pub(crate) struct PrefixedWrappedHistoryCell { text: Text<'static>, @@ -86,6 +115,38 @@ impl HistoryCell for CompositeHistoryCell { out } + fn display_hyperlink_lines(&self, width: u16) -> Vec { + let mut out = Vec::new(); + let mut first = true; + for part in &self.parts { + let mut lines = part.display_hyperlink_lines(width); + if !lines.is_empty() { + if !first { + out.push(HyperlinkLine::from("")); + } + out.append(&mut lines); + first = false; + } + } + out + } + + fn transcript_hyperlink_lines(&self, width: u16) -> Vec { + let mut out = Vec::new(); + let mut first = true; + for part in &self.parts { + let mut lines = part.transcript_hyperlink_lines(width); + if !lines.is_empty() { + if !first { + out.push(HyperlinkLine::from("")); + } + out.append(&mut lines); + first = false; + } + } + out + } + fn raw_lines(&self) -> Vec> { let mut out: Vec> = Vec::new(); let mut first = true; diff --git a/codex-rs/tui/src/history_cell/mcp.rs b/codex-rs/tui/src/history_cell/mcp.rs index 7b1e8eebc43..599a44f092d 100644 --- a/codex-rs/tui/src/history_cell/mcp.rs +++ b/codex-rs/tui/src/history_cell/mcp.rs @@ -325,14 +325,17 @@ pub(crate) fn empty_mcp_output() -> PlainHistoryCell { " • No MCP servers configured.".italic().into(), Line::from(vec![ " See the ".into(), - "\u{1b}]8;;https://developers.openai.com/codex/mcp\u{7}MCP docs\u{1b}]8;;\u{7}" - .underlined(), + crate::terminal_hyperlinks::osc8_hyperlink( + "https://developers.openai.com/codex/mcp", + "MCP docs", + ) + .underlined(), " to configure them.".into(), ]) .style(Style::default().add_modifier(Modifier::DIM)), ]; - PlainHistoryCell { lines } + PlainHistoryCell::new(lines) } #[cfg(test)] diff --git a/codex-rs/tui/src/history_cell/messages.rs b/codex-rs/tui/src/history_cell/messages.rs index 19a6ec29039..ed899f3b2e8 100644 --- a/codex-rs/tui/src/history_cell/messages.rs +++ b/codex-rs/tui/src/history_cell/messages.rs @@ -268,12 +268,20 @@ impl HistoryCell for ReasoningSummaryCell { #[derive(Debug)] pub(crate) struct AgentMessageCell { - lines: Vec>, + lines: Vec, is_first_line: bool, } impl AgentMessageCell { + #[cfg(test)] pub(crate) fn new(lines: Vec>, is_first_line: bool) -> Self { + Self { + lines: plain_hyperlink_lines(lines), + is_first_line, + } + } + + pub(crate) fn new_hyperlink_lines(lines: Vec, is_first_line: bool) -> Self { Self { lines, is_first_line, @@ -283,6 +291,10 @@ impl AgentMessageCell { impl HistoryCell for AgentMessageCell { fn display_lines(&self, width: u16) -> Vec> { + visible_lines(self.display_hyperlink_lines(width)) + } + + fn display_hyperlink_lines(&self, width: u16) -> Vec { let mut wrapped = Vec::new(); for (index, line) in self.lines.iter().enumerate() { let initial_indent = if index == 0 && self.is_first_line { @@ -293,20 +305,23 @@ impl HistoryCell for AgentMessageCell { let mut subsequent_indent = Line::from(" "); subsequent_indent .spans - .extend(crate::insert_history::leading_whitespace_prefix(line).spans); - let line_wrapped = adaptive_wrap_line( - line, + .extend(crate::insert_history::leading_whitespace_prefix(&line.line).spans); + wrapped.extend(crate::terminal_hyperlinks::adaptive_wrap_hyperlink_lines( + std::slice::from_ref(line), RtOptions::new(width as usize) .initial_indent(initial_indent) .subsequent_indent(subsequent_indent), - ); - push_owned_lines(&line_wrapped, &mut wrapped); + )); } wrapped } + fn transcript_hyperlink_lines(&self, width: u16) -> Vec { + self.display_hyperlink_lines(width) + } + fn raw_lines(&self) -> Vec> { - plain_lines(self.lines.clone()) + plain_lines(visible_lines(self.lines.clone())) } fn is_stream_continuation(&self) -> bool { @@ -347,22 +362,32 @@ impl AgentMarkdownCell { impl HistoryCell for AgentMarkdownCell { fn display_lines(&self, width: u16) -> Vec> { + visible_lines(self.display_hyperlink_lines(width)) + } + + fn display_hyperlink_lines(&self, width: u16) -> Vec { let Some(wrap_width) = crate::width::usable_content_width_u16(width, /*reserved_cols*/ 2) else { - return prefix_lines(vec![Line::default()], "• ".dim(), " ".into()); + return prefix_hyperlink_lines( + vec![HyperlinkLine::new(Line::default())], + "• ".dim(), + " ".into(), + ); }; - let mut lines: Vec> = Vec::new(); // Re-render markdown from source at the current width. Reserve 2 columns for the "• " / // " " prefix prepended below. - crate::markdown::append_markdown_agent_with_cwd( + let lines = crate::markdown::render_markdown_agent_with_links_and_cwd( &self.markdown_source, Some(wrap_width), Some(self.cwd.as_path()), - &mut lines, ); - prefix_lines(lines, "• ".dim(), " ".into()) + prefix_hyperlink_lines(lines, "• ".dim(), " ".into()) + } + + fn transcript_hyperlink_lines(&self, width: u16) -> Vec { + self.display_hyperlink_lines(width) } fn raw_lines(&self) -> Vec> { @@ -377,12 +402,12 @@ impl HistoryCell for AgentMarkdownCell { /// every delta and cleared when the stream finalizes. #[derive(Debug)] pub(crate) struct StreamingAgentTailCell { - lines: Vec>, + lines: Vec, is_first_line: bool, } impl StreamingAgentTailCell { - pub(crate) fn new(lines: Vec>, is_first_line: bool) -> Self { + pub(crate) fn new(lines: Vec, is_first_line: bool) -> Self { Self { lines, is_first_line, @@ -391,10 +416,14 @@ impl StreamingAgentTailCell { } impl HistoryCell for StreamingAgentTailCell { - fn display_lines(&self, _width: u16) -> Vec> { + fn display_lines(&self, width: u16) -> Vec> { + visible_lines(self.display_hyperlink_lines(width)) + } + + fn display_hyperlink_lines(&self, _width: u16) -> Vec { // Tail lines are already rendered at the controller's current stream width. // Re-wrapping them here can split table borders and produce malformed in-flight rows. - prefix_lines( + prefix_hyperlink_lines( self.lines.clone(), if self.is_first_line { "• ".dim() @@ -405,8 +434,12 @@ impl HistoryCell for StreamingAgentTailCell { ) } + fn transcript_hyperlink_lines(&self, width: u16) -> Vec { + self.display_hyperlink_lines(width) + } + fn raw_lines(&self) -> Vec> { - plain_lines(self.display_lines(u16::MAX)) + plain_lines(self.display_lines(/*width*/ u16::MAX)) } fn is_stream_continuation(&self) -> bool { diff --git a/codex-rs/tui/src/history_cell/mod.rs b/codex-rs/tui/src/history_cell/mod.rs index 12788a59f0d..9cef3e4fa52 100644 --- a/codex-rs/tui/src/history_cell/mod.rs +++ b/codex-rs/tui/src/history_cell/mod.rs @@ -22,7 +22,6 @@ use crate::exec_command::strip_bash_lc_and_escape; use crate::legacy_core::config::Config; use crate::live_wrap::take_prefix_by_width; use crate::markdown::append_markdown; -use crate::markdown::append_markdown_agent_with_cwd; use crate::motion::MotionMode; use crate::motion::ReducedMotionIndicator; use crate::motion::activity_indicator; @@ -33,6 +32,11 @@ use crate::render::renderable::Renderable; use crate::session_state::ThreadSessionState; use crate::style::proposed_plan_style; use crate::style::user_message_style; +use crate::terminal_hyperlinks::HyperlinkLine; +use crate::terminal_hyperlinks::mark_buffer_hyperlinks; +use crate::terminal_hyperlinks::plain_hyperlink_lines; +use crate::terminal_hyperlinks::prefix_hyperlink_lines; +use crate::terminal_hyperlinks::visible_lines; #[cfg(test)] use crate::test_support::PathBufExt; #[cfg(test)] @@ -189,13 +193,29 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any { /// Returns copy-friendly plain logical lines for raw scrollback mode. fn raw_lines(&self) -> Vec>; + /// Returns rich visible lines plus terminal hyperlink metadata. + fn display_hyperlink_lines(&self, width: u16) -> Vec { + plain_hyperlink_lines(self.display_lines(width)) + } + fn display_lines_for_mode(&self, width: u16, mode: HistoryRenderMode) -> Vec> { match mode { - HistoryRenderMode::Rich => self.display_lines(width), + HistoryRenderMode::Rich => visible_lines(self.display_hyperlink_lines(width)), HistoryRenderMode::Raw => self.raw_lines(), } } + fn display_hyperlink_lines_for_mode( + &self, + width: u16, + mode: HistoryRenderMode, + ) -> Vec { + match mode { + HistoryRenderMode::Rich => self.display_hyperlink_lines(width), + HistoryRenderMode::Raw => plain_hyperlink_lines(self.raw_lines()), + } + } + /// Returns the number of viewport rows needed to render this cell. /// /// The default delegates to `Paragraph::line_count` with @@ -224,13 +244,22 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any { self.display_lines(width) } + /// Returns transcript-overlay lines plus terminal hyperlink metadata. + /// + /// Defaults to the plain transcript representation because some cells render different + /// display and transcript content. Rich cells whose transcript mirrors their display should + /// delegate to `display_hyperlink_lines`. + fn transcript_hyperlink_lines(&self, width: u16) -> Vec { + plain_hyperlink_lines(self.transcript_lines(width)) + } + /// Returns the number of viewport rows for the transcript overlay. /// /// Uses the same `Paragraph::line_count` measurement as /// `desired_height`. Contains a workaround for a ratatui bug where /// a single whitespace-only line reports 2 rows instead of 1. fn desired_transcript_height(&self, width: u16) -> u16 { - let lines = self.transcript_lines(width); + let lines = visible_lines(self.transcript_hyperlink_lines(width)); // Workaround: ratatui's line_count returns 2 for a single // whitespace-only line. Clamp to 1 in that case. if let [line] = &lines[..] @@ -270,7 +299,8 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any { impl Renderable for Box { fn render(&self, area: Rect, buf: &mut Buffer) { - let lines = self.display_lines(area.width); + let hyperlink_lines = self.display_hyperlink_lines(area.width); + let lines = visible_lines(hyperlink_lines.clone()); let paragraph = Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }); let y = if area.height == 0 { 0 @@ -284,6 +314,7 @@ impl Renderable for Box { // entire draw area first so stale glyphs from previous frames never linger. Clear.render(area, buf); paragraph.scroll((y, 0)).render(area, buf); + mark_buffer_hyperlinks(buf, area, &hyperlink_lines, usize::from(y)); } fn desired_height(&self, width: u16) -> u16 { HistoryCell::desired_height(self.as_ref(), width) diff --git a/codex-rs/tui/src/history_cell/notices.rs b/codex-rs/tui/src/history_cell/notices.rs index 7fb994442c0..d9dff4f5a00 100644 --- a/codex-rs/tui/src/history_cell/notices.rs +++ b/codex-rs/tui/src/history_cell/notices.rs @@ -71,6 +71,14 @@ impl HistoryCell for UpdateAvailableHistoryCell { Line::from("https://github.com/openai/codex/releases/latest"), ] } + + fn display_hyperlink_lines(&self, width: u16) -> Vec { + crate::terminal_hyperlinks::annotate_web_urls(self.display_lines(width)) + } + + fn transcript_hyperlink_lines(&self, width: u16) -> Vec { + self.display_hyperlink_lines(width) + } } #[allow(clippy::disallowed_methods)] pub(crate) fn new_warning_event(message: String) -> PrefixedWrappedHistoryCell { @@ -129,6 +137,14 @@ impl HistoryCell for CyberPolicyNoticeCell { Line::from(TRUSTED_ACCESS_FOR_CYBER_URL), ] } + + fn display_hyperlink_lines(&self, width: u16) -> Vec { + crate::terminal_hyperlinks::annotate_web_urls(self.display_lines(width)) + } + + fn transcript_hyperlink_lines(&self, width: u16) -> Vec { + self.display_hyperlink_lines(width) + } } #[derive(Debug)] diff --git a/codex-rs/tui/src/history_cell/plans.rs b/codex-rs/tui/src/history_cell/plans.rs index 6ea56195da1..6b644f9e368 100644 --- a/codex-rs/tui/src/history_cell/plans.rs +++ b/codex-rs/tui/src/history_cell/plans.rs @@ -9,12 +9,12 @@ use super::*; /// preview-only during streaming. #[derive(Debug)] pub(crate) struct StreamingPlanTailCell { - lines: Vec>, + lines: Vec, is_stream_continuation: bool, } impl StreamingPlanTailCell { - pub(crate) fn new(lines: Vec>, is_stream_continuation: bool) -> Self { + pub(crate) fn new(lines: Vec, is_stream_continuation: bool) -> Self { Self { lines, is_stream_continuation, @@ -24,11 +24,19 @@ impl StreamingPlanTailCell { impl HistoryCell for StreamingPlanTailCell { fn display_lines(&self, _width: u16) -> Vec> { + visible_lines(self.lines.clone()) + } + + fn display_hyperlink_lines(&self, _width: u16) -> Vec { self.lines.clone() } + fn transcript_hyperlink_lines(&self, width: u16) -> Vec { + self.display_hyperlink_lines(width) + } + fn raw_lines(&self) -> Vec> { - plain_lines(self.lines.clone()) + plain_lines(visible_lines(self.lines.clone())) } fn is_stream_continuation(&self) -> bool { @@ -58,11 +66,11 @@ pub(crate) fn new_proposed_plan(plan_markdown: String, cwd: &Path) -> ProposedPl /// Stream cells are display fragments, not source-backed history. They should be replaced by /// `ProposedPlanCell` during consolidation before relying on resize reflow for finalized history. pub(crate) fn new_proposed_plan_stream( - lines: Vec>, + lines: Vec>, is_stream_continuation: bool, ) -> ProposedPlanStreamCell { ProposedPlanStreamCell { - lines, + lines: lines.into_iter().map(Into::into).collect(), is_stream_continuation, } } @@ -85,36 +93,43 @@ pub(crate) struct ProposedPlanCell { /// terminal resize. #[derive(Debug)] pub(crate) struct ProposedPlanStreamCell { - lines: Vec>, + lines: Vec, is_stream_continuation: bool, } impl HistoryCell for ProposedPlanCell { fn display_lines(&self, width: u16) -> Vec> { - let mut lines: Vec> = Vec::new(); - lines.push(vec!["• ".dim(), "Proposed Plan".bold()].into()); - lines.push(Line::from(" ")); + visible_lines(self.display_hyperlink_lines(width)) + } + + fn display_hyperlink_lines(&self, width: u16) -> Vec { + let mut lines = vec![ + HyperlinkLine::new(vec!["• ".dim(), "Proposed Plan".bold()].into()), + HyperlinkLine::new(Line::from(" ")), + ]; - let mut plan_lines: Vec> = vec![Line::from(" ")]; + let mut plan_lines = vec![HyperlinkLine::new(Line::from(" "))]; let plan_style = proposed_plan_style(); let wrap_width = width.saturating_sub(4).max(1) as usize; - let mut body: Vec> = Vec::new(); - append_markdown_agent_with_cwd( + let mut body = crate::markdown::render_markdown_agent_with_links_and_cwd( &self.plan_markdown, Some(wrap_width), Some(self.cwd.as_path()), - &mut body, ); if body.is_empty() { - body.push(Line::from("(empty)".dim().italic())); + body.push(HyperlinkLine::new(Line::from("(empty)".dim().italic()))); } - plan_lines.extend(prefix_lines(body, " ".into(), " ".into())); - plan_lines.push(Line::from(" ")); + plan_lines.extend(prefix_hyperlink_lines(body, " ".into(), " ".into())); + plan_lines.push(HyperlinkLine::new(Line::from(" "))); lines.extend(plan_lines.into_iter().map(|line| line.style(plan_style))); lines } + fn transcript_hyperlink_lines(&self, width: u16) -> Vec { + self.display_hyperlink_lines(width) + } + fn raw_lines(&self) -> Vec> { raw_lines_from_source(&self.plan_markdown) } @@ -122,11 +137,19 @@ impl HistoryCell for ProposedPlanCell { impl HistoryCell for ProposedPlanStreamCell { fn display_lines(&self, _width: u16) -> Vec> { + visible_lines(self.lines.clone()) + } + + fn display_hyperlink_lines(&self, _width: u16) -> Vec { self.lines.clone() } + fn transcript_hyperlink_lines(&self, width: u16) -> Vec { + self.display_hyperlink_lines(width) + } + fn raw_lines(&self) -> Vec> { - plain_lines(self.lines.clone()) + plain_lines(visible_lines(self.lines.clone())) } fn is_stream_continuation(&self) -> bool { diff --git a/codex-rs/tui/src/history_cell/tests.rs b/codex-rs/tui/src/history_cell/tests.rs index 6c83982a412..76fe17f027e 100644 --- a/codex-rs/tui/src/history_cell/tests.rs +++ b/codex-rs/tui/src/history_cell/tests.rs @@ -295,6 +295,47 @@ fn proposed_plan_cell_renders_markdown_table() { ); } +#[test] +fn proposed_plan_cell_preserves_wrapped_table_web_links() { + let destination = "https://example.com/a/very/long/path/to/a/table/artifact"; + let plan = new_proposed_plan( + format!("| Step | URL |\n| --- | --- |\n| Verify | {destination} |\n"), + &test_cwd(), + ); + + let lines = plan.display_hyperlink_lines(/*width*/ 32); + let linked_rows = lines + .iter() + .filter(|line| !line.hyperlinks.is_empty()) + .collect::>(); + + assert!(linked_rows.len() > 1); + assert!(linked_rows.iter().all(|line| { + line.hyperlinks + .iter() + .all(|link| link.destination == destination) + })); +} + +#[test] +fn composite_cell_preserves_child_web_links() { + let destination = "https://chatgpt.com/codex/settings/usage"; + let cell = CompositeHistoryCell::new(vec![ + Box::new(PlainHistoryCell::new(vec![Line::from("/status")])), + Box::new(WebHyperlinkHistoryCell::new(vec![Line::from(destination)])), + ]); + + let lines = cell.display_hyperlink_lines(/*width*/ 80); + + assert_eq!( + lines[2].hyperlinks, + vec![crate::terminal_hyperlinks::TerminalHyperlink { + columns: 0..destination.len(), + destination: destination.to_string(), + }] + ); +} + #[test] fn proposed_plan_cell_unwraps_markdown_fenced_table() { let plan = new_proposed_plan( @@ -802,6 +843,7 @@ async fn mcp_tools_output_lists_tools_for_hyphenated_server_names() { fn mcp_tools_output_from_statuses_renders_status_only_servers() { let statuses = vec![McpServerStatus { name: "plugin_docs".to_string(), + server_info: None, tools: HashMap::from([( "lookup".to_string(), Tool { @@ -831,6 +873,7 @@ fn mcp_tools_output_from_statuses_renders_status_only_servers() { fn mcp_tools_output_from_statuses_renders_verbose_inventory() { let statuses = vec![McpServerStatus { name: "plugin_docs".to_string(), + server_info: None, tools: HashMap::from([( "lookup".to_string(), Tool { diff --git a/codex-rs/tui/src/insert_history.rs b/codex-rs/tui/src/insert_history.rs index 40da6ccbccf..760b5164216 100644 --- a/codex-rs/tui/src/insert_history.rs +++ b/codex-rs/tui/src/insert_history.rs @@ -7,6 +7,11 @@ use std::fmt; use std::io; use std::io::Write; +use crate::render::line_utils::line_to_static; +use crate::terminal_hyperlinks::HyperlinkLine; +use crate::terminal_hyperlinks::decorate_spans; +use crate::terminal_hyperlinks::plain_hyperlink_lines; +use crate::terminal_hyperlinks::remap_wrapped_line; use crate::wrapping::RtOptions; use crate::wrapping::adaptive_wrap_line; use crate::wrapping::line_contains_url_like; @@ -85,6 +90,23 @@ pub(crate) fn insert_history_lines_with_mode_and_wrap_policy( mode: InsertHistoryMode, wrap_policy: HistoryLineWrapPolicy, ) -> io::Result<()> +where + B: Backend + Write, +{ + insert_history_hyperlink_lines_with_mode_and_wrap_policy( + terminal, + plain_hyperlink_lines(lines.iter().map(line_to_static).collect()), + mode, + wrap_policy, + ) +} + +pub(crate) fn insert_history_hyperlink_lines_with_mode_and_wrap_policy( + terminal: &mut crate::custom_terminal::Terminal, + lines: Vec, + mode: InsertHistoryMode, + wrap_policy: HistoryLineWrapPolicy, +) -> io::Result<()> where B: Backend + Write, { @@ -113,13 +135,21 @@ where let line_wrapped = match wrap_policy { HistoryLineWrapPolicy::Terminal => vec![line.clone()], HistoryLineWrapPolicy::PreWrap - if line_contains_url_like(line) && !line_has_mixed_url_and_non_url_tokens(line) => + if line_contains_url_like(&line.line) + && !line_has_mixed_url_and_non_url_tokens(&line.line) => { vec![line.clone()] } - HistoryLineWrapPolicy::PreWrap => adaptive_wrap_line( + HistoryLineWrapPolicy::PreWrap => remap_wrapped_line( line, - RtOptions::new(wrap_width).subsequent_indent(leading_whitespace_prefix(line)), + adaptive_wrap_line( + &line.line, + RtOptions::new(wrap_width) + .subsequent_indent(leading_whitespace_prefix(&line.line)), + ) + .into_iter() + .map(|line| line_to_static(&line)) + .collect(), ), }; wrapped_rows += line_wrapped @@ -249,7 +279,11 @@ pub(crate) fn leading_whitespace_prefix(line: &Line<'_>) -> Line<'static> { /// Render a single wrapped history line: clear continuation rows for wide lines, /// set foreground/background colors, and write styled spans. Caller is responsible /// for cursor positioning and any leading `\r\n`. -fn write_history_line(writer: &mut W, line: &Line, wrap_width: usize) -> io::Result<()> { +fn write_history_line( + writer: &mut W, + line: &HyperlinkLine, + wrap_width: usize, +) -> io::Result<()> { let physical_rows = line.width().max(1).div_ceil(wrap_width) as u16; if physical_rows > 1 { queue!(writer, SavePosition)?; @@ -262,11 +296,13 @@ fn write_history_line(writer: &mut W, line: &Line, wrap_width: usize) queue!( writer, SetColors(Colors::new( - line.style + line.line + .style .fg .map(std::convert::Into::into) .unwrap_or(CColor::Reset), - line.style + line.line + .style .bg .map(std::convert::Into::into) .unwrap_or(CColor::Reset) @@ -276,14 +312,20 @@ fn write_history_line(writer: &mut W, line: &Line, wrap_width: usize) // Merge line-level style into each span so that ANSI colors reflect // line styles (e.g., blockquotes with green fg). let merged_spans: Vec = line + .line .spans .iter() .map(|s| Span { - style: s.style.patch(line.style), + style: s.style.patch(line.line.style), content: s.content.clone(), }) .collect(); - write_spans(writer, merged_spans.iter()) + let merged_line = HyperlinkLine { + line: Line::from(merged_spans), + hyperlinks: line.hyperlinks.clone(), + }; + let decorated = decorate_spans(&merged_line); + write_spans(writer, decorated.iter()) } #[derive(Debug, Clone, PartialEq, Eq)] @@ -470,6 +512,19 @@ mod tests { ); } + #[test] + fn writes_semantic_web_link_without_changing_visible_text() { + let destination = "https://example.com/long/path"; + let line = crate::terminal_hyperlinks::annotate_web_urls_in_line(Line::from(destination)); + let mut actual = Vec::new(); + + write_history_line(&mut actual, &line, /*wrap_width*/ 80).expect("write history line"); + + let output = String::from_utf8(actual).expect("UTF-8 terminal output"); + assert!(output.contains("\x1b]8;;https://example.com/long/path\x07")); + assert_eq!(line.line.spans[0].content, destination); + } + #[test] fn vt100_blockquote_line_emits_green_fg() { // Set up a small off-screen terminal diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index a444e30bcb0..962998349c2 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -177,6 +177,7 @@ mod status; mod status_indicator_widget; mod streaming; mod style; +mod terminal_hyperlinks; mod terminal_palette; mod terminal_probe; mod terminal_title; diff --git a/codex-rs/tui/src/markdown.rs b/codex-rs/tui/src/markdown.rs index 0cabee08052..31b8d09a763 100644 --- a/codex-rs/tui/src/markdown.rs +++ b/codex-rs/tui/src/markdown.rs @@ -25,6 +25,7 @@ use std::ops::Range; use std::path::Path; use crate::table_detect; +use crate::terminal_hyperlinks::HyperlinkLine; /// Render markdown source to styled ratatui lines and append them to `lines`. /// @@ -56,20 +57,22 @@ pub(crate) fn append_markdown_agent( width: Option, lines: &mut Vec>, ) { - append_markdown_agent_with_cwd(markdown_source, width, /*cwd*/ None, lines); + let normalized = unwrap_markdown_fences(markdown_source); + let rendered = crate::markdown_render::render_markdown_text_with_width_and_cwd( + &normalized, + width, + /*cwd*/ None, + ); + crate::render::line_utils::push_owned_lines(&rendered.lines, lines); } -/// Render an agent message while resolving local file links relative to `cwd`. -pub(crate) fn append_markdown_agent_with_cwd( +pub(crate) fn render_markdown_agent_with_links_and_cwd( markdown_source: &str, width: Option, cwd: Option<&Path>, - lines: &mut Vec>, -) { +) -> Vec { let normalized = unwrap_markdown_fences(markdown_source); - let rendered = - crate::markdown_render::render_markdown_text_with_width_and_cwd(&normalized, width, cwd); - crate::render::line_utils::push_owned_lines(&rendered.lines, lines); + crate::markdown_render::render_markdown_lines_with_width_and_cwd(&normalized, width, cwd) } /// Strip `` ```md ``/`` ```markdown `` fences that contain tables, emitting their content as bare diff --git a/codex-rs/tui/src/markdown_render.rs b/codex-rs/tui/src/markdown_render.rs index 87861989c0c..9847c6dc3a8 100644 --- a/codex-rs/tui/src/markdown_render.rs +++ b/codex-rs/tui/src/markdown_render.rs @@ -22,9 +22,9 @@ //! alignment count. //! 3. **Compute column widths** -- allocate widths with content-aware //! priority and iterative shrinking. -//! 4. **Render row-separated layout** -- theme-accented bold headers, a -//! heavier segmented header rule, and low-contrast segmented body -//! separators, or fallback to pipe format when the minimum cannot fit. +//! 4. **Choose presentation** -- render theme-accented row-separated columns +//! while values remain scannable, otherwise transpose body rows +//! into key/value records separated by muted rules. //! 5. **Append spillover** -- extracted spillover rows rendered as plain text //! after the table. //! @@ -34,14 +34,20 @@ //! or hashes), or Compact (short values such as counts and status labels). //! Token-heavy columns give up excess width before narrative columns so an //! oversized path does not collapse readable prose; compact values are -//! preserved last. When even 3-char-wide columns cannot fit, the table falls -//! back to pipe-delimited format. +//! preserved last. When compact values split, token-heavy values collapse into +//! unusably short chunks, expansive cells form tall narrow strips across enough +//! body rows, or even 3-char-wide columns cannot fit, body rows render as +//! key/value records. use crate::render::highlight::foreground_style_for_scopes; use crate::render::highlight::highlight_code_to_lines; use crate::render::line_utils::line_to_static; -use crate::render::line_utils::push_owned_lines; use crate::style::table_separator_style; +use crate::terminal_hyperlinks::HyperlinkLine; +use crate::terminal_hyperlinks::annotate_web_urls_in_line; +use crate::terminal_hyperlinks::remap_wrapped_line; +use crate::terminal_hyperlinks::visible_lines; +use crate::terminal_hyperlinks::web_destination; use crate::wrapping::RtOptions; use crate::wrapping::adaptive_wrap_line; use crate::wrapping::word_wrap_line; @@ -66,9 +72,12 @@ use std::ops::Range; use std::path::Path; use std::path::PathBuf; use std::sync::LazyLock; +use unicode_width::UnicodeWidthChar; use unicode_width::UnicodeWidthStr; use url::Url; +mod table_key_value; + const TABLE_COLUMN_GAP: usize = 2; const TABLE_CELL_PADDING: usize = 1; const TABLE_HEADER_SEPARATOR_CHAR: char = '━'; @@ -136,7 +145,7 @@ impl IndentContext { /// `lines` are used for final rendering. #[derive(Clone, Debug, Default)] struct TableCell { - lines: Vec>, + lines: Vec, } // TableCell mutators inlined — called per-span during table event parsing. @@ -144,7 +153,7 @@ impl TableCell { #[inline] fn ensure_line(&mut self) { if self.lines.is_empty() { - self.lines.push(Line::default()); + self.lines.push(HyperlinkLine::new(Line::default())); } } @@ -152,13 +161,26 @@ impl TableCell { fn push_span(&mut self, span: Span<'static>) { self.ensure_line(); if let Some(line) = self.lines.last_mut() { - line.push_span(span); + line.line.push_span(span); + } + } + + fn push_annotated(&mut self, mut appended: HyperlinkLine) { + self.ensure_line(); + if let Some(line) = self.lines.last_mut() { + let shift = line.width(); + line.line.spans.append(&mut appended.line.spans); + line.hyperlinks + .extend(appended.hyperlinks.into_iter().map(|mut link| { + link.columns = link.columns.start + shift..link.columns.end + shift; + link + })); } } #[inline] fn hard_break(&mut self) { - self.lines.push(Line::default()); + self.lines.push(HyperlinkLine::new(Line::default())); } fn plain_text(&self) -> String { @@ -168,7 +190,7 @@ impl TableCell { if i > 0 { buf.push(' '); } - for span in &line.spans { + for span in &line.line.spans { let _ = write!(buf, "{}", span.content); } } @@ -215,14 +237,14 @@ impl TableState { /// Rendered table output split by wrapping behavior. /// -/// `table_lines` are either prewrapped aligned rows or pipe -/// fallback rows that should still pass through normal wrapping. +/// `table_lines` are prewrapped aligned rows or key/value records, except +/// header-only tables may retain pipe fallback rows for normal wrapping. /// `spillover_lines` are prose rows extracted from parser artifacts and should /// be routed through normal wrapping. struct RenderedTableLines { - table_lines: Vec>, + table_lines: Vec, table_lines_prewrapped: bool, - spillover_lines: Vec>, + spillover_lines: Vec, } /// Classification of a table column for width-allocation priority. @@ -267,11 +289,11 @@ pub fn render_markdown_text(input: &str) -> Text<'static> { /// Render markdown constrained to a known terminal width. /// -/// The renderer preserves table structure when possible and falls back to -/// pipe-table output when an aligned table cannot fit the available width. Passing -/// `None` keeps intrinsic line widths and disables width-driven wrapping in the -/// markdown writer. Local file links render relative to the current process -/// working directory. +/// The renderer preserves columnar table structure while values remain +/// scannable and falls back to key/value records when body rows cannot fit +/// readably. Passing `None` keeps intrinsic line widths and disables +/// width-driven wrapping in the markdown writer. Local file links render +/// relative to the current process working directory. pub(crate) fn render_markdown_text_with_width(input: &str, width: Option) -> Text<'static> { let cwd = std::env::current_dir().ok(); render_markdown_text_with_width_and_cwd(input, width, cwd.as_deref()) @@ -287,6 +309,16 @@ pub(crate) fn render_markdown_text_with_width_and_cwd( width: Option, cwd: Option<&Path>, ) -> Text<'static> { + Text::from(visible_lines(render_markdown_lines_with_width_and_cwd( + input, width, cwd, + ))) +} + +pub(crate) fn render_markdown_lines_with_width_and_cwd( + input: &str, + width: Option, + cwd: Option<&Path>, +) -> Vec { let mut options = Options::empty(); options.insert(Options::ENABLE_STRIKETHROUGH); options.insert(Options::ENABLE_TABLES); @@ -338,7 +370,7 @@ where { input: &'a str, iter: I, - text: Text<'static>, + text: Vec, styles: MarkdownStyles, inline_styles: Vec