diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 801279a005..de10d47073 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2328,6 +2328,18 @@ dependencies = [ "serde_with", ] +[[package]] +name = "codex-build-info" +version = "0.0.0" +dependencies = [ + "codex-install-context", + "pretty_assertions", + "semver", + "serde", + "serde_json", + "tempfile", +] + [[package]] name = "codex-bwrap" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index f2b7192e77..f23232e3d0 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -6,6 +6,7 @@ members = [ "agent-identity", "backend-client", "bwrap", + "build-info", "ansi-escape", "async-utils", "app-server", diff --git a/codex-rs/build-info/BUILD.bazel b/codex-rs/build-info/BUILD.bazel new file mode 100644 index 0000000000..f747f25f88 --- /dev/null +++ b/codex-rs/build-info/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "build-info", + crate_name = "codex_build_info", +) diff --git a/codex-rs/build-info/Cargo.toml b/codex-rs/build-info/Cargo.toml new file mode 100644 index 0000000000..f9cc3d040f --- /dev/null +++ b/codex-rs/build-info/Cargo.toml @@ -0,0 +1,23 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-build-info" +version.workspace = true + +[lib] +doctest = false +name = "codex_build_info" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +codex-install-context = { workspace = true } +semver = { workspace = true, features = ["serde"] } +serde = { workspace = true, features = ["derive"] } + +[dev-dependencies] +pretty_assertions = { workspace = true } +serde_json = { workspace = true } +tempfile = { workspace = true } diff --git a/codex-rs/build-info/src/build_info_tests.rs b/codex-rs/build-info/src/build_info_tests.rs new file mode 100644 index 0000000000..eeaefe340e --- /dev/null +++ b/codex-rs/build-info/src/build_info_tests.rs @@ -0,0 +1,133 @@ +use std::fs; + +use codex_install_context::InstallContext; +use pretty_assertions::assert_eq; +use semver::Version; +use tempfile::tempdir; + +use crate::BuildInfo; + +const BUILD_COMMIT: &str = "0123456789abcdef0123456789abcdef01234567"; + +/// A packaged runtime takes its release identity from its package manifest. +#[test] +fn packaged_runtime_uses_manifest_version() { + let package = tempdir().expect("create runtime package"); + let bin_dir = package.path().join("bin"); + fs::create_dir(&bin_dir).expect("create runtime binary directory"); + let executable = bin_dir.join("codex"); + fs::write(&executable, b"").expect("create runtime binary"); + fs::write( + package.path().join("codex-package.json"), + r#"{"version":"1.2.3-alpha.4"}"#, + ) + .expect("create runtime package manifest"); + + let context = InstallContext::from_exe( + cfg!(target_os = "macos"), + Some(&executable), + /*method_override*/ None, + ); + + assert_eq!( + BuildInfo::resolve(&context, BUILD_COMMIT), + BuildInfo { + version: Version::parse("1.2.3-alpha.4").expect("valid release version"), + build_commit: BUILD_COMMIT.to_string(), + }, + ); +} + +/// Unpackaged builds expose their stamped commit and structured source version. +#[test] +fn unpackaged_runtime_uses_build_commit() { + let context = InstallContext::from_exe( + cfg!(target_os = "macos"), + /*current_exe*/ None, + /*method_override*/ None, + ); + + assert_eq!( + BuildInfo::resolve(&context, BUILD_COMMIT), + BuildInfo { + version: Version::new(0, 0, 0), + build_commit: BUILD_COMMIT.to_string(), + }, + ); +} + +/// Older package layouts without release metadata retain their build identity. +#[test] +fn legacy_package_without_version_uses_build_commit() { + let package = tempdir().expect("create runtime package"); + let bin_dir = package.path().join("bin"); + fs::create_dir(&bin_dir).expect("create runtime binary directory"); + let executable = bin_dir.join("codex"); + fs::write(&executable, b"").expect("create runtime binary"); + fs::write(package.path().join("codex-package.json"), "{}") + .expect("create legacy runtime package manifest"); + + let context = InstallContext::from_exe( + cfg!(target_os = "macos"), + Some(&executable), + /*method_override*/ None, + ); + + assert_eq!( + BuildInfo::resolve(&context, BUILD_COMMIT), + BuildInfo { + version: Version::new(0, 0, 0), + build_commit: BUILD_COMMIT.to_string(), + }, + ); +} + +/// Invalid package versions cannot override the executable's stamped commit. +#[test] +fn invalid_package_version_uses_build_commit() { + let package = tempdir().expect("create runtime package"); + let bin_dir = package.path().join("bin"); + fs::create_dir(&bin_dir).expect("create runtime binary directory"); + let executable = bin_dir.join("codex"); + fs::write(&executable, b"").expect("create runtime binary"); + fs::write( + package.path().join("codex-package.json"), + r#"{"version":"not-a-release-version"}"#, + ) + .expect("create runtime package manifest"); + + let context = InstallContext::from_exe( + cfg!(target_os = "macos"), + Some(&executable), + /*method_override*/ None, + ); + + assert_eq!( + BuildInfo::resolve(&context, BUILD_COMMIT), + BuildInfo { + version: Version::new(0, 0, 0), + build_commit: BUILD_COMMIT.to_string(), + }, + ); +} + +/// Serializing build information preserves both its release version and commit. +#[test] +fn build_info_serialization_preserves_build_provenance() { + let build_info = BuildInfo { + version: Version::parse("1.2.3-alpha.4").expect("valid release version"), + build_commit: BUILD_COMMIT.to_string(), + }; + + assert_eq!( + serde_json::to_string(&build_info).expect("serialize build information"), + format!("{{\"version\":\"1.2.3-alpha.4\",\"build_commit\":\"{BUILD_COMMIT}\"}}"), + ); + assert_eq!( + serde_json::from_str::(&format!( + "{{\"version\":\"1.2.3-alpha.4\",\"build_commit\":\"{BUILD_COMMIT}\"}}" + )) + .expect("deserialize build information"), + build_info, + ); +} diff --git a/codex-rs/build-info/src/lib.rs b/codex-rs/build-info/src/lib.rs new file mode 100644 index 0000000000..833c64be24 --- /dev/null +++ b/codex-rs/build-info/src/lib.rs @@ -0,0 +1,123 @@ +//! Resolve the release identity of the current Codex runtime. + +use std::fmt; +use std::sync::OnceLock; + +use codex_install_context::InstallContext; +use semver::Version; +use serde::Deserialize; +use serde::Serialize; + +static BUILD_INFO: OnceLock = OnceLock::new(); + +/// Initialize build information from the commit stamped into the calling executable. +/// +/// The environment lookup intentionally expands at the macro call site so Git +/// changes invalidate only final binary actions, not this shared library. +#[macro_export] +macro_rules! initialize { + () => { + $crate::BuildInfo::initialize(option_env!("STABLE_GIT_COMMIT").unwrap_or("dev")); + }; +} + +/// The packaged release version and build provenance for the current runtime. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct BuildInfo { + version: Version, + build_commit: String, +} + +impl BuildInfo { + /// Return build information for the current Codex runtime. + pub fn get() -> Self { + BUILD_INFO + .get_or_init(|| Self::resolve(InstallContext::current(), "dev")) + .clone() + } + + /// Initialize build information using the final executable's stamped commit. + /// + /// Keeping the stamp in the executable prevents Git operations from + /// invalidating the shared Rust library graph. + #[doc(hidden)] + pub fn initialize(build_commit: &'static str) { + let _ = BUILD_INFO.get_or_init(|| Self::resolve(InstallContext::current(), build_commit)); + } + + /// Recover structured release information from a persisted version string. + pub fn from_version(version: impl Into) -> Self { + let version = version.into(); + match Version::parse(&version) { + Ok(parsed_version) => Self { + build_commit: if parsed_version.major == 0 + && parsed_version.minor == 0 + && parsed_version.patch == 0 + { + version + } else { + "unknown".to_string() + }, + version: parsed_version, + }, + Err(_) => Self { + version: Version::new(0, 0, 0), + build_commit: version, + }, + } + } + + /// Return the parsed package version, or `0.0.0` for a source build. + pub fn version(&self) -> &Version { + &self.version + } + + /// Format the version for a user-facing Codex header. + pub fn display_version(&self) -> String { + if self.build_commit == "dev" { + "dev".to_string() + } else if self.is_source_build() { + format!("v{}", self.build_commit) + } else { + format!("v{}", self.version) + } + } + + /// Identify source builds without parsing their displayed Git commit. + pub fn is_source_build(&self) -> bool { + self.version.major == 0 && self.version.minor == 0 && self.version.patch == 0 + } + + /// Return the Git commit stamped into the final executable. + pub fn build_commit(&self) -> &str { + &self.build_commit + } + + fn resolve(install_context: &InstallContext, build_commit: &'static str) -> Self { + if let Some(manifest) = install_context.package_manifest() { + return Self { + version: manifest.version, + build_commit: build_commit.to_owned(), + }; + } + + Self { + version: Version::new(0, 0, 0), + build_commit: build_commit.to_owned(), + } + } +} + +impl fmt::Display for BuildInfo { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.is_source_build() { + formatter.write_str(&self.build_commit) + } else { + self.version.fmt(formatter) + } + } +} + +#[cfg(test)] +#[path = "build_info_tests.rs"] +mod tests; diff --git a/defs.bzl b/defs.bzl index ca8b014133..fc1dff5f80 100644 --- a/defs.bzl +++ b/defs.bzl @@ -378,7 +378,6 @@ def codex_rust_crate( sanitized_binaries.append(binary) cargo_env_runfiles[":" + binary] = "CARGO_BIN_EXE_" + binary cargo_env["CARGO_BIN_EXE_" + binary] = "$(rlocationpath :%s)" % binary - rust_binary( name = binary, crate_name = binary.replace("-", "_"), @@ -386,7 +385,11 @@ def codex_rust_crate( deps = all_crate_deps() + maybe_deps + deps_extra, edition = crate_edition, rustc_flags = rustc_flags_extra + WINDOWS_RUSTC_LINK_FLAGS, + # rules_rust substitutes workspace status values only for stamped + # actions, so pass the existing key through to final binaries. + rustc_env = {"STABLE_GIT_COMMIT": "{STABLE_GIT_COMMIT}"}, srcs = native.glob(["src/**/*.rs"]), + stamp = 1, visibility = ["//visibility:public"], )