diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index d2c2cfdba7..1f1519df04 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -540,13 +540,41 @@ jobs: --release-version "$RELEASE_VERSION" \ --package codex \ --package codex-responses-api-proxy \ - --package codex-sdk + --package codex-sdk \ + --standalone-output-dir dist/installer - name: Stage installer scripts run: | cp scripts/install/install.sh dist/install.sh cp scripts/install/install.ps1 dist/install.ps1 + - name: Stage installer checksums + env: + RELEASE_VERSION: ${{ steps.release_name.outputs.name }} + run: | + set -euo pipefail + + checksums="dist/codex-installer_SHA256SUMS" + found=false + : > "$checksums" + + for archive in dist/installer/codex-standalone-*-"${RELEASE_VERSION}".tar.gz; do + if [[ ! -e "$archive" ]]; then + continue + fi + + found=true + digest="$(sha256sum "$archive" | awk '{print $1}')" + printf '%s %s\n' "$digest" "$(basename "$archive")" >> "$checksums" + done + + if [[ "$found" != true ]]; then + echo "No Codex standalone installer archives found for ${RELEASE_VERSION}." + exit 1 + fi + + sort -k2,2 "$checksums" -o "$checksums" + - name: Create GitHub Release uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2 with: diff --git a/scripts/stage_npm_packages.py b/scripts/stage_npm_packages.py index 5bbee755e9..119b23044a 100755 --- a/scripts/stage_npm_packages.py +++ b/scripts/stage_npm_packages.py @@ -16,6 +16,9 @@ from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent BUILD_SCRIPT = REPO_ROOT / "codex-cli" / "scripts" / "build_npm_package.py" INSTALL_NATIVE_DEPS = REPO_ROOT / "codex-cli" / "scripts" / "install_native_deps.py" +STAGE_STANDALONE_INSTALLER_ARCHIVES = ( + REPO_ROOT / "scripts" / "stage_standalone_installer_archives.py" +) WORKFLOW_NAME = ".github/workflows/rust-release.yml" GITHUB_REPO = "openai/codex" @@ -53,6 +56,12 @@ def parse_args() -> argparse.Namespace: default=None, help="Directory where npm tarballs should be written (default: dist/npm).", ) + parser.add_argument( + "--standalone-output-dir", + type=Path, + default=None, + help="Directory where standalone installer archives should be written.", + ) parser.add_argument( "--keep-staging-dirs", action="store_true", @@ -130,6 +139,24 @@ def run_command(cmd: list[str]) -> None: subprocess.run(cmd, cwd=REPO_ROOT, check=True) +def stage_standalone_installer_archives( + release_version: str, + vendor_src: Path, + output_dir: Path, +) -> None: + run_command( + [ + str(STAGE_STANDALONE_INSTALLER_ARCHIVES), + "--release-version", + release_version, + "--vendor-src", + str(vendor_src), + "--output-dir", + str(output_dir), + ] + ) + + def tarball_name_for_package(package: str, version: str) -> str: if package in CODEX_PLATFORM_PACKAGES: platform = package.removeprefix("codex-") @@ -192,6 +219,24 @@ def main() -> int: shutil.rmtree(staging_dir, ignore_errors=True) final_messages.append(f"Staged {package} at {pack_output}") + + if args.standalone_output_dir is not None: + if vendor_src is None: + raise RuntimeError( + "--standalone-output-dir requires staged native binaries, but no selected " + "package installed native components." + ) + + standalone_output_dir = args.standalone_output_dir + standalone_output_dir.mkdir(parents=True, exist_ok=True) + stage_standalone_installer_archives( + args.release_version, + vendor_src, + standalone_output_dir, + ) + final_messages.append( + f"Staged standalone installer archives in {standalone_output_dir}" + ) finally: if vendor_temp_root is not None and not args.keep_staging_dirs: shutil.rmtree(vendor_temp_root, ignore_errors=True) diff --git a/scripts/stage_standalone_installer_archives.py b/scripts/stage_standalone_installer_archives.py new file mode 100755 index 0000000000..65c024a5e7 --- /dev/null +++ b/scripts/stage_standalone_installer_archives.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +"""Stage standalone installer archives for Codex releases.""" + +from __future__ import annotations + +import argparse +import importlib.util +import shutil +import tarfile +import tempfile +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parent.parent +BUILD_SCRIPT = REPO_ROOT / "codex-cli" / "scripts" / "build_npm_package.py" + +_SPEC = importlib.util.spec_from_file_location("codex_build_npm_package", BUILD_SCRIPT) +if _SPEC is None or _SPEC.loader is None: + raise RuntimeError(f"Unable to load module from {BUILD_SCRIPT}") +_BUILD_MODULE = importlib.util.module_from_spec(_SPEC) +_SPEC.loader.exec_module(_BUILD_MODULE) +CODEX_PLATFORM_PACKAGES = getattr(_BUILD_MODULE, "CODEX_PLATFORM_PACKAGES", {}) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--release-version", + required=True, + help="Version to stage (e.g. 0.1.0 or 0.1.0-alpha.1).", + ) + parser.add_argument( + "--vendor-src", + type=Path, + required=True, + help="Directory containing native binaries under vendor/.", + ) + parser.add_argument( + "--output-dir", + type=Path, + default=REPO_ROOT / "dist" / "installer", + help="Directory where standalone archives should be written.", + ) + return parser.parse_args() + + +def archive_name(platform_tag: str, version: str) -> str: + return f"codex-standalone-{platform_tag}-{version}.tar.gz" + + +def copy_executable(source: Path, destination: Path) -> None: + if not source.exists(): + raise RuntimeError(f"Missing standalone installer archive input: {source}") + + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, destination) + destination.chmod(0o755) + + +def stage_target(vendor_src: Path, staging_dir: Path, target: str, is_windows: bool) -> None: + target_root = vendor_src / target + codex_root = target_root / "codex" + path_root = target_root / "path" + resources_dir = staging_dir / "codex-resources" + resources_dir.mkdir(parents=True, exist_ok=True) + + if is_windows: + copy_executable(codex_root / "codex.exe", staging_dir / "codex.exe") + copy_executable( + codex_root / "codex-command-runner.exe", + resources_dir / "codex-command-runner.exe", + ) + copy_executable( + codex_root / "codex-windows-sandbox-setup.exe", + resources_dir / "codex-windows-sandbox-setup.exe", + ) + copy_executable(path_root / "rg.exe", resources_dir / "rg.exe") + return + + copy_executable(codex_root / "codex", staging_dir / "codex") + copy_executable(path_root / "rg", resources_dir / "rg") + + +def write_archive(staging_dir: Path, output_path: Path) -> None: + output_path.parent.mkdir(parents=True, exist_ok=True) + with tarfile.open(output_path, "w:gz") as archive: + for path in sorted(staging_dir.rglob("*")): + archive.add(path, arcname=path.relative_to(staging_dir)) + + +def main() -> int: + args = parse_args() + vendor_src = args.vendor_src.resolve() + output_dir = args.output_dir.resolve() + output_dir.mkdir(parents=True, exist_ok=True) + + for package in sorted(CODEX_PLATFORM_PACKAGES): + package_config = CODEX_PLATFORM_PACKAGES[package] + platform_tag = package_config["npm_tag"] + target = package_config["target_triple"] + is_windows = package_config["os"] == "win32" + output_path = output_dir / archive_name(platform_tag, args.release_version) + + with tempfile.TemporaryDirectory( + prefix=f"codex-standalone-{platform_tag}-" + ) as staging_dir_str: + staging_dir = Path(staging_dir_str) + stage_target(vendor_src, staging_dir, target, is_windows) + write_archive(staging_dir, output_path) + + print(f"Staged standalone installer archive at {output_path}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())