Skip to content

Commit 7f4d7ae

Browse files
authored
build: add Codex package builder (#23513)
## Why Codex CLI packaging is currently split across npm staging, standalone installers, and release bundle creation, which makes it hard to define and validate a single valid package directory. This adds the first standalone package builder so later release paths can converge on the same canonical layout. ## What changed - Added `scripts/build_codex_package.py` as the stable executable wrapper around `scripts/codex_package`. - Added modules for CLI parsing, target metadata, grouped cargo builds, package layout validation, and archive writing. - The builder creates a package directory with `codex-package.json`, `bin/`, `codex-resources/`, and `codex-path`, and can serialize it as `.tar.gz`, `.tar.zst`, or `.zip`. - Source-built artifacts are built by one grouped `cargo build`: `codex` for all targets, `bwrap` for Linux, and the Windows sandbox helpers for Windows. `rg` remains an input because it is vendored from upstream rather than built from this repo. - Added `scripts/codex_package/README.md` to document the package layout, source-built artifacts, and cargo profile behavior. ## Verification - Ran wrapper/module syntax compilation. - Ran `scripts/build_codex_package.py --help` from `/private/tmp`. - Ran fake-cargo package/archive builds for macOS, Linux, and Windows target layouts, including an assertion that generated tar archives contain no duplicate member names. --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/23513). * #23526 * __->__ #23513
1 parent d661ab7 commit 7f4d7ae

8 files changed

Lines changed: 621 additions & 0 deletions

File tree

scripts/build_codex_package.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
#!/usr/bin/env python3
2+
"""Build a canonical Codex package directory and optional archive."""
3+
4+
from pathlib import Path
5+
import sys
6+
7+
8+
# Some developer environments set PYTHONSAFEPATH=1, which prevents Python from
9+
# adding the script directory to sys.path. Add it explicitly so the local helper
10+
# package remains importable when this executable is launched from any cwd.
11+
sys.path.insert(0, str(Path(__file__).resolve().parent))
12+
13+
from codex_package.cli import main
14+
15+
if __name__ == "__main__":
16+
raise SystemExit(main())

scripts/codex_package/README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Codex package builder
2+
3+
This package contains the implementation behind `scripts/build_codex_package.py`.
4+
The top-level script is the stable executable entry point; these modules keep the
5+
package-building logic split by responsibility.
6+
7+
The builder creates a canonical Codex package directory:
8+
9+
```text
10+
.
11+
├── codex-package.json
12+
├── bin
13+
│ └── codex[.exe]
14+
├── codex-resources
15+
│ ├── bwrap # Linux only
16+
│ ├── codex-command-runner.exe # Windows only
17+
│ └── codex-windows-sandbox-setup.exe # Windows only
18+
└── codex-path
19+
└── rg[.exe]
20+
```
21+
22+
The package directory is the primary artifact. Archive formats such as
23+
`.tar.gz`, `.tar.zst`, and `.zip` are serializations of that directory.
24+
25+
## Source-built artifacts
26+
27+
Artifacts built from this repository are always built by the package builder in
28+
one grouped `cargo build` command per package:
29+
30+
- all targets: `codex`
31+
- Linux targets: `bwrap`
32+
- Windows targets: `codex-command-runner` and `codex-windows-sandbox-setup`
33+
34+
The default cargo profile is `dev-small` because local iteration should favor
35+
fast, small builds. Release jobs should pass `--cargo-profile release`.
36+
37+
`rg` is not built from this repository, so it remains an input. If `--rg-bin` is
38+
omitted, the builder looks in the existing `codex-cli/vendor/<target>/path/`
39+
location.

scripts/codex_package/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Helpers for building canonical Codex package archives."""

scripts/codex_package/archive.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"""Archive writers for canonical Codex package directories."""
2+
3+
import shutil
4+
import subprocess
5+
import tarfile
6+
import tempfile
7+
import zipfile
8+
from pathlib import Path
9+
10+
11+
def write_archive(package_dir: Path, archive_path: Path, *, force: bool) -> None:
12+
if is_relative_to(archive_path, package_dir):
13+
raise RuntimeError(
14+
f"Archive output must be outside the package directory: {archive_path}"
15+
)
16+
17+
archive_path.parent.mkdir(parents=True, exist_ok=True)
18+
if archive_path.exists():
19+
if not force:
20+
raise RuntimeError(f"Archive output already exists: {archive_path}")
21+
archive_path.unlink()
22+
23+
archive_format = archive_format_for_path(archive_path)
24+
if archive_format == "tar.gz":
25+
write_tar_archive(package_dir, archive_path, mode="w:gz")
26+
elif archive_format == "tar.zst":
27+
write_tar_zst_archive(package_dir, archive_path)
28+
elif archive_format == "zip":
29+
write_zip_archive(package_dir, archive_path)
30+
else:
31+
raise AssertionError(f"unexpected archive format: {archive_format}")
32+
33+
34+
def is_relative_to(path: Path, parent: Path) -> bool:
35+
try:
36+
path.relative_to(parent)
37+
return True
38+
except ValueError:
39+
return False
40+
41+
42+
def archive_format_for_path(path: Path) -> str:
43+
suffixes = path.suffixes
44+
if suffixes[-2:] == [".tar", ".gz"] or path.suffix == ".tgz":
45+
return "tar.gz"
46+
if suffixes[-2:] == [".tar", ".zst"]:
47+
return "tar.zst"
48+
if path.suffix == ".zip":
49+
return "zip"
50+
raise RuntimeError(
51+
f"Unsupported archive suffix for {path}. Use .tar.gz, .tgz, .tar.zst, or .zip."
52+
)
53+
54+
55+
def write_tar_archive(package_dir: Path, archive_path: Path, *, mode: str) -> None:
56+
with tarfile.open(archive_path, mode) as archive:
57+
for path in package_entries(package_dir):
58+
archive.add(
59+
path,
60+
arcname=path.relative_to(package_dir),
61+
recursive=False,
62+
)
63+
64+
65+
def write_tar_zst_archive(package_dir: Path, archive_path: Path) -> None:
66+
zstd = shutil.which("zstd")
67+
if zstd is None:
68+
raise RuntimeError("zstd is required to write .tar.zst archives.")
69+
70+
with tempfile.TemporaryDirectory(prefix="codex-package-archive-") as temp_dir_str:
71+
tar_path = Path(temp_dir_str) / "package.tar"
72+
write_tar_archive(package_dir, tar_path, mode="w")
73+
subprocess.check_call([zstd, "-T0", "-19", "-f", str(tar_path), "-o", str(archive_path)])
74+
75+
76+
def write_zip_archive(package_dir: Path, archive_path: Path) -> None:
77+
with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED) as archive:
78+
for path in package_entries(package_dir):
79+
relative_path = path.relative_to(package_dir)
80+
if path.is_dir():
81+
archive.write(path, f"{relative_path}/")
82+
else:
83+
archive.write(path, relative_path)
84+
85+
86+
def package_entries(package_dir: Path) -> list[Path]:
87+
return sorted(package_dir.rglob("*"), key=lambda path: path.relative_to(package_dir).as_posix())

scripts/codex_package/cargo.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
"""Cargo builds for source-built Codex package artifacts."""
2+
3+
import os
4+
import subprocess
5+
from dataclasses import dataclass
6+
from pathlib import Path
7+
8+
from .targets import REPO_ROOT
9+
from .targets import TargetSpec
10+
11+
12+
CODEX_RS_ROOT = REPO_ROOT / "codex-rs"
13+
14+
15+
@dataclass(frozen=True)
16+
class SourceBuildOutputs:
17+
codex_bin: Path
18+
bwrap_bin: Path | None
19+
codex_command_runner_bin: Path | None
20+
codex_windows_sandbox_setup_bin: Path | None
21+
22+
23+
def build_source_binaries(
24+
spec: TargetSpec,
25+
*,
26+
cargo: str,
27+
profile: str,
28+
) -> SourceBuildOutputs:
29+
binaries = source_binaries_for_target(spec)
30+
cmd = [
31+
cargo,
32+
"build",
33+
"--target",
34+
spec.target,
35+
"--profile",
36+
profile,
37+
]
38+
for binary in binaries:
39+
cmd.extend(["--bin", binary])
40+
41+
print("+", " ".join(cmd))
42+
subprocess.run(cmd, cwd=CODEX_RS_ROOT, check=True)
43+
44+
output_dir = cargo_profile_output_dir(spec, profile)
45+
outputs = SourceBuildOutputs(
46+
codex_bin=output_dir / spec.codex_name,
47+
bwrap_bin=output_dir / "bwrap" if spec.is_linux else None,
48+
codex_command_runner_bin=(
49+
output_dir / "codex-command-runner.exe" if spec.is_windows else None
50+
),
51+
codex_windows_sandbox_setup_bin=(
52+
output_dir / "codex-windows-sandbox-setup.exe" if spec.is_windows else None
53+
),
54+
)
55+
validate_source_outputs(outputs)
56+
return outputs
57+
58+
59+
def source_binaries_for_target(spec: TargetSpec) -> list[str]:
60+
binaries = ["codex"]
61+
if spec.is_linux:
62+
binaries.append("bwrap")
63+
if spec.is_windows:
64+
binaries.extend(
65+
[
66+
"codex-command-runner",
67+
"codex-windows-sandbox-setup",
68+
]
69+
)
70+
return binaries
71+
72+
73+
def cargo_profile_output_dir(spec: TargetSpec, profile: str) -> Path:
74+
target_dir = cargo_target_dir()
75+
return target_dir / spec.target / cargo_profile_dirname(profile)
76+
77+
78+
def cargo_target_dir() -> Path:
79+
target_dir = os.environ.get("CARGO_TARGET_DIR")
80+
if target_dir is None:
81+
return CODEX_RS_ROOT / "target"
82+
83+
path = Path(target_dir)
84+
if path.is_absolute():
85+
return path
86+
87+
return CODEX_RS_ROOT / path
88+
89+
90+
def cargo_profile_dirname(profile: str) -> str:
91+
if profile == "dev":
92+
return "debug"
93+
if profile == "release":
94+
return "release"
95+
return profile
96+
97+
98+
def validate_source_outputs(outputs: SourceBuildOutputs) -> None:
99+
for path in [
100+
outputs.codex_bin,
101+
outputs.bwrap_bin,
102+
outputs.codex_command_runner_bin,
103+
outputs.codex_windows_sandbox_setup_bin,
104+
]:
105+
if path is not None and not path.is_file():
106+
raise RuntimeError(f"cargo build did not produce expected binary: {path}")

scripts/codex_package/cli.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
"""Command-line interface for building Codex package directories."""
2+
3+
import argparse
4+
from pathlib import Path
5+
6+
from .archive import write_archive
7+
from .cargo import build_source_binaries
8+
from .layout import build_package_dir
9+
from .layout import prepare_package_dir
10+
from .layout import validate_package_dir
11+
from .targets import TARGET_SPECS
12+
from .targets import PackageInputs
13+
from .targets import resolve_rg_bin
14+
15+
16+
def parse_args() -> argparse.Namespace:
17+
parser = argparse.ArgumentParser(
18+
description="Build a canonical Codex package directory and optional archive.",
19+
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
20+
)
21+
parser.add_argument(
22+
"--target",
23+
required=True,
24+
choices=sorted(TARGET_SPECS),
25+
help="Rust target triple for the package.",
26+
)
27+
parser.add_argument(
28+
"--version",
29+
default="0.0.0-dev",
30+
help="Codex version to record in codex-package.json.",
31+
)
32+
parser.add_argument(
33+
"--variant",
34+
default="codex",
35+
help="Package variant to record in codex-package.json.",
36+
)
37+
parser.add_argument(
38+
"--package-dir",
39+
type=Path,
40+
required=True,
41+
help="Output directory to create as the package root.",
42+
)
43+
parser.add_argument(
44+
"--archive-output",
45+
type=Path,
46+
help=(
47+
"Optional archive output path. Supported suffixes: .tar.gz, .tgz, "
48+
".tar.zst, .zip."
49+
),
50+
)
51+
parser.add_argument(
52+
"--force",
53+
action="store_true",
54+
help="Replace an existing package directory or archive output.",
55+
)
56+
parser.add_argument(
57+
"--cargo",
58+
default="cargo",
59+
help="Cargo executable to use for source-built package artifacts.",
60+
)
61+
parser.add_argument(
62+
"--cargo-profile",
63+
default="dev-small",
64+
help=(
65+
"Cargo profile for source-built package artifacts. Use release for "
66+
"release packages."
67+
),
68+
)
69+
parser.add_argument(
70+
"--rg-bin",
71+
type=Path,
72+
help="Path to the ripgrep executable to place in codex-path/.",
73+
)
74+
return parser.parse_args()
75+
76+
77+
def main() -> int:
78+
args = parse_args()
79+
spec = TARGET_SPECS[args.target]
80+
package_dir = args.package_dir.resolve()
81+
82+
source_outputs = build_source_binaries(
83+
spec,
84+
cargo=args.cargo,
85+
profile=args.cargo_profile,
86+
)
87+
inputs = PackageInputs(
88+
codex_bin=source_outputs.codex_bin,
89+
rg_bin=resolve_rg_bin(spec, args.rg_bin),
90+
bwrap_bin=source_outputs.bwrap_bin,
91+
codex_command_runner_bin=source_outputs.codex_command_runner_bin,
92+
codex_windows_sandbox_setup_bin=source_outputs.codex_windows_sandbox_setup_bin,
93+
)
94+
prepare_package_dir(package_dir, force=args.force)
95+
build_package_dir(package_dir, args.version, args.variant, spec, inputs)
96+
validate_package_dir(package_dir, spec)
97+
98+
archive_output = args.archive_output
99+
if archive_output is not None:
100+
archive_path = archive_output.resolve()
101+
write_archive(package_dir, archive_path, force=args.force)
102+
print(f"Built Codex package archive at {archive_path}")
103+
104+
print(f"Built Codex package directory at {package_dir}")
105+
return 0

0 commit comments

Comments
 (0)