Compare commits

...

8 Commits

Author SHA1 Message Date
Edward Frazer
0f248b817b fix: rename standalone platform tag plumbing 2026-04-30 10:23:19 -07:00
Edward Frazer
9ef401dee0 fix: install standalone release archives 2026-04-30 10:23:19 -07:00
Edward Frazer
10215b1af1 fix: name standalone archive tags generically 2026-04-30 10:23:04 -07:00
Edward Frazer
85a00e8a47 fix: keep standalone release staging independent from npm 2026-04-29 10:45:47 -07:00
Edward Frazer
444417d065 fix: extract release package metadata from build script 2026-04-29 10:45:47 -07:00
Edward Frazer
abb7490c1e fix: split standalone installer staging from npm release flow 2026-04-29 10:45:47 -07:00
Edward Frazer
35397a43e2 fix: stage selected standalone archives once 2026-04-29 10:45:47 -07:00
Edward Frazer
6912b101f0 feat: publish standalone installer archives 2026-04-29 10:45:47 -07:00
5 changed files with 333 additions and 82 deletions

View File

@@ -542,11 +542,47 @@ jobs:
--package codex-responses-api-proxy \
--package codex-sdk
- name: Stage standalone installer archives
env:
RELEASE_VERSION: ${{ steps.release_name.outputs.name }}
run: |
./scripts/stage_standalone_installer_archives.py \
--release-version "$RELEASE_VERSION" \
--workflow-url "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
--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:

View File

@@ -55,27 +55,35 @@ function Normalize-Version {
return $RawVersion
}
function Get-ReleaseAssetMetadata {
function Get-ReleaseAssetUrl {
param(
[string]$AssetName,
[string]$ResolvedVersion
)
$release = Invoke-RestMethod -Uri "https://api.github.com/repos/openai/codex/releases/tags/rust-v$ResolvedVersion"
$asset = $release.assets | Where-Object { $_.name -eq $AssetName } | Select-Object -First 1
if ($null -eq $asset) {
throw "Could not find release asset $AssetName for Codex $ResolvedVersion."
return "https://github.com/openai/codex/releases/download/rust-v$ResolvedVersion/$AssetName"
}
function Get-ChecksumForAsset {
param(
[string]$ChecksumsPath,
[string]$AssetName
)
foreach ($line in Get-Content -LiteralPath $ChecksumsPath) {
$parts = $line -split "\s+", 2
if ($parts.Length -ne 2) {
continue
}
$digest = $parts[0]
$filename = $parts[1].TrimStart("*")
if ($filename -eq $AssetName -and $digest -match "^[0-9a-fA-F]{64}$") {
return $digest.ToLowerInvariant()
}
}
$digestMatch = [regex]::Match([string]$asset.digest, "^sha256:([0-9a-fA-F]{64})$")
if (-not $digestMatch.Success) {
throw "Could not find SHA-256 digest for release asset $AssetName."
}
return [PSCustomObject]@{
Url = $asset.browser_download_url
Sha256 = $digestMatch.Groups[1].Value.ToLowerInvariant()
}
throw "Could not find SHA-256 checksum for release asset $AssetName."
}
function Test-ArchiveDigest {
@@ -86,7 +94,7 @@ function Test-ArchiveDigest {
$actualDigest = (Get-FileHash -LiteralPath $ArchivePath -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actualDigest -ne $ExpectedDigest) {
throw "Downloaded Codex archive checksum did not match release metadata. Expected $ExpectedDigest but got $actualDigest."
throw "Downloaded Codex archive checksum did not match release checksums. Expected $ExpectedDigest but got $actualDigest."
}
}
@@ -584,17 +592,17 @@ if (-not [Environment]::Is64BitOperatingSystem) {
$architecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture
$target = $null
$platformLabel = $null
$npmTag = $null
$platformTag = $null
switch ($architecture) {
"Arm64" {
$target = "aarch64-pc-windows-msvc"
$platformLabel = "Windows (ARM64)"
$npmTag = "win32-arm64"
$platformTag = "win32-arm64"
}
"X64" {
$target = "x86_64-pc-windows-msvc"
$platformLabel = "Windows (x64)"
$npmTag = "win32-x64"
$platformTag = "win32-x64"
}
default {
Write-Error "Unsupported architecture: $architecture"
@@ -637,7 +645,7 @@ Write-Step "Resolved version: $resolvedVersion"
$conflictingInstall = Get-ConflictingInstall -VisibleBinDir $visibleBinDir
$oldStandaloneBackup = $null
$packageAsset = "codex-npm-$npmTag-$resolvedVersion.tgz"
$packageAsset = "codex-standalone-$platformTag-$resolvedVersion.tar.gz"
$tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("codex-install-" + [System.Guid]::NewGuid().ToString("N"))
New-Item -ItemType Directory -Force -Path $tempDir | Out-Null
@@ -651,13 +659,17 @@ try {
}
$archivePath = Join-Path $tempDir $packageAsset
$checksumsPath = Join-Path $tempDir "codex-installer_SHA256SUMS"
$extractDir = Join-Path $tempDir "extract"
$stagingDir = Join-Path $releasesDir ".staging.$releaseName.$PID"
$assetMetadata = Get-ReleaseAssetMetadata -AssetName $packageAsset -ResolvedVersion $resolvedVersion
$archiveUrl = Get-ReleaseAssetUrl -AssetName $packageAsset -ResolvedVersion $resolvedVersion
$checksumsUrl = Get-ReleaseAssetUrl -AssetName "codex-installer_SHA256SUMS" -ResolvedVersion $resolvedVersion
Write-Step "Downloading Codex CLI"
Invoke-WebRequest -Uri $assetMetadata.Url -OutFile $archivePath
Test-ArchiveDigest -ArchivePath $archivePath -ExpectedDigest $assetMetadata.Sha256
Invoke-WebRequest -Uri $checksumsUrl -OutFile $checksumsPath
$expectedDigest = Get-ChecksumForAsset -ChecksumsPath $checksumsPath -AssetName $packageAsset
Invoke-WebRequest -Uri $archiveUrl -OutFile $archivePath
Test-ArchiveDigest -ArchivePath $archivePath -ExpectedDigest $expectedDigest
New-Item -ItemType Directory -Force -Path $extractDir | Out-Null
New-Item -ItemType Directory -Force -Path $releasesDir | Out-Null
@@ -667,18 +679,17 @@ try {
New-Item -ItemType Directory -Force -Path $stagingDir | Out-Null
tar -xzf $archivePath -C $extractDir
$vendorRoot = Join-Path $extractDir "package/vendor/$target"
$resourcesDir = Join-Path $stagingDir "codex-resources"
New-Item -ItemType Directory -Force -Path $resourcesDir | Out-Null
$copyMap = @{
"codex/codex.exe" = "codex.exe"
"codex/codex-command-runner.exe" = "codex-resources\codex-command-runner.exe"
"codex/codex-windows-sandbox-setup.exe" = "codex-resources\codex-windows-sandbox-setup.exe"
"path/rg.exe" = "codex-resources\rg.exe"
"codex.exe" = "codex.exe"
"codex-resources\codex-command-runner.exe" = "codex-resources\codex-command-runner.exe"
"codex-resources\codex-windows-sandbox-setup.exe" = "codex-resources\codex-windows-sandbox-setup.exe"
"codex-resources\rg.exe" = "codex-resources\rg.exe"
}
foreach ($relativeSource in $copyMap.Keys) {
Copy-Item -LiteralPath (Join-Path $vendorRoot $relativeSource) -Destination (Join-Path $stagingDir $copyMap[$relativeSource])
Copy-Item -LiteralPath (Join-Path $extractDir $relativeSource) -Destination (Join-Path $stagingDir $copyMap[$relativeSource])
}
if (Test-Path -LiteralPath $releaseDir) {

View File

@@ -114,55 +114,29 @@ release_url_for_asset() {
printf 'https://github.com/openai/codex/releases/download/rust-v%s/%s\n' "$resolved_version" "$asset"
}
release_metadata_url() {
resolved_version="$1"
checksum_for_asset() {
checksums_path="$1"
asset="$2"
printf 'https://api.github.com/repos/openai/codex/releases/tags/rust-v%s\n' "$resolved_version"
}
release_asset_digest() {
asset="$1"
resolved_version="$2"
release_json="$(download_text "$(release_metadata_url "$resolved_version")")"
digest="$(printf '%s\n' "$release_json" | awk -v asset="$asset" '
{
if ($0 ~ "\"name\":[[:space:]]*\"" asset "\"") {
in_asset = 1
asset_depth = depth
}
if (in_asset && /"digest":[[:space:]]*"[^"]+"/) {
sub(/^.*"digest":[[:space:]]*"/, "")
sub(/".*$/, "")
digest = $0
}
line = $0
opens = gsub(/\{/, "{", line)
closes = gsub(/\}/, "}", line)
depth += opens - closes
if (in_asset && depth < asset_depth) {
in_asset = 0
}
digest="$(awk -v asset="$asset" '
$2 == asset {
print $1
found = 1
exit
}
END {
if (digest != "") {
print digest
if (!found) {
exit 1
}
}
')"
' "$checksums_path" || true)"
case "$digest" in
sha256:????????????????????????????????????????????????????????????????)
printf '%s\n' "${digest#sha256:}"
;;
*)
echo "Could not find SHA-256 digest for release asset $asset." >&2
exit 1
;;
esac
if ! printf '%s\n' "$digest" | grep -Eq '^[0-9a-fA-F]{64}$'; then
echo "Could not find SHA-256 checksum for release asset $asset." >&2
exit 1
fi
printf '%s\n' "$digest" | tr 'A-F' 'a-f'
}
file_sha256() {
@@ -193,7 +167,7 @@ verify_archive_digest() {
actual_digest="$(file_sha256 "$archive_path")"
if [ "$actual_digest" != "$expected_digest" ]; then
echo "Downloaded Codex archive checksum did not match release metadata." >&2
echo "Downloaded Codex archive checksum did not match release checksums." >&2
echo "expected: $expected_digest" >&2
echo "actual: $actual_digest" >&2
exit 1
@@ -586,14 +560,14 @@ handle_conflicting_install() {
install_release() {
release_dir="$1"
vendor_root="$2"
archive_root="$2"
stage_release="$RELEASES_DIR/.staging.$(basename "$release_dir").$$"
mkdir -p "$RELEASES_DIR"
rm -rf "$stage_release"
mkdir -p "$stage_release/codex-resources"
cp "$vendor_root/codex/codex" "$stage_release/codex"
cp "$vendor_root/path/rg" "$stage_release/codex-resources/rg"
cp "$archive_root/codex" "$stage_release/codex"
cp "$archive_root/codex-resources/rg" "$stage_release/codex-resources/rg"
chmod 0755 "$stage_release/codex"
chmod 0755 "$stage_release/codex-resources/rg"
@@ -671,28 +645,28 @@ fi
if [ "$os" = "darwin" ]; then
if [ "$arch" = "aarch64" ]; then
npm_tag="darwin-arm64"
platform_tag="darwin-arm64"
vendor_target="aarch64-apple-darwin"
platform_label="macOS (Apple Silicon)"
else
npm_tag="darwin-x64"
platform_tag="darwin-x64"
vendor_target="x86_64-apple-darwin"
platform_label="macOS (Intel)"
fi
else
if [ "$arch" = "aarch64" ]; then
npm_tag="linux-arm64"
platform_tag="linux-arm64"
vendor_target="aarch64-unknown-linux-musl"
platform_label="Linux (ARM64)"
else
npm_tag="linux-x64"
platform_tag="linux-x64"
vendor_target="x86_64-unknown-linux-musl"
platform_label="Linux (x64)"
fi
fi
resolved_version="$(resolve_version)"
asset="codex-npm-$npm_tag-$resolved_version.tgz"
asset="codex-standalone-$platform_tag-$resolved_version.tar.gz"
download_url="$(release_url_for_asset "$asset" "$resolved_version")"
release_name="$resolved_version-$vendor_target"
release_dir="$RELEASES_DIR/$release_name"
@@ -728,10 +702,12 @@ if ! release_dir_is_complete "$release_dir" "$resolved_version" "$vendor_target"
fi
archive_path="$tmp_dir/$asset"
checksums_path="$tmp_dir/codex-installer_SHA256SUMS"
extract_dir="$tmp_dir/extract"
step "Downloading Codex CLI"
expected_digest="$(release_asset_digest "$asset" "$resolved_version")"
download_file "$(release_url_for_asset "codex-installer_SHA256SUMS" "$resolved_version")" "$checksums_path"
expected_digest="$(checksum_for_asset "$checksums_path" "$asset")"
download_file "$download_url" "$archive_path"
verify_archive_digest "$archive_path" "$expected_digest"
@@ -739,7 +715,7 @@ if ! release_dir_is_complete "$release_dir" "$resolved_version" "$vendor_target"
tar -xzf "$archive_path" -C "$extract_dir"
step "Installing standalone package to $release_dir"
install_release "$release_dir" "$extract_dir/package/vendor/$vendor_target"
install_release "$release_dir" "$extract_dir"
fi
update_current_link "$release_dir"
update_visible_command

View File

@@ -192,6 +192,7 @@ def main() -> int:
shutil.rmtree(staging_dir, ignore_errors=True)
final_messages.append(f"Staged {package} at {pack_output}")
finally:
if vendor_temp_root is not None and not args.keep_staging_dirs:
shutil.rmtree(vendor_temp_root, ignore_errors=True)

View File

@@ -0,0 +1,227 @@
#!/usr/bin/env python3
"""Stage standalone installer archives for Codex releases."""
from __future__ import annotations
import argparse
import json
import os
import shutil
import subprocess
import tarfile
import tempfile
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
INSTALL_NATIVE_DEPS = REPO_ROOT / "codex-cli" / "scripts" / "install_native_deps.py"
WORKFLOW_NAME = ".github/workflows/rust-release.yml"
CODEX_PLATFORM_PACKAGES: dict[str, dict[str, str]] = {
"codex-linux-x64": {
"platform_tag": "linux-x64",
"target_triple": "x86_64-unknown-linux-musl",
"os": "linux",
},
"codex-linux-arm64": {
"platform_tag": "linux-arm64",
"target_triple": "aarch64-unknown-linux-musl",
"os": "linux",
},
"codex-darwin-x64": {
"platform_tag": "darwin-x64",
"target_triple": "x86_64-apple-darwin",
"os": "darwin",
},
"codex-darwin-arm64": {
"platform_tag": "darwin-arm64",
"target_triple": "aarch64-apple-darwin",
"os": "darwin",
},
"codex-win32-x64": {
"platform_tag": "win32-x64",
"target_triple": "x86_64-pc-windows-msvc",
"os": "win32",
},
"codex-win32-arm64": {
"platform_tag": "win32-arm64",
"target_triple": "aarch64-pc-windows-msvc",
"os": "win32",
},
}
STANDALONE_NATIVE_COMPONENTS = (
"codex",
"codex-command-runner",
"codex-windows-sandbox-setup",
"rg",
)
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,
help="Directory containing native binaries under vendor/<target>.",
)
parser.add_argument(
"--workflow-url",
help=(
"Optional workflow URL to download native artifacts from when --vendor-src "
"is not provided."
),
)
parser.add_argument(
"--output-dir",
type=Path,
default=REPO_ROOT / "dist" / "installer",
help="Directory where standalone archives should be written.",
)
parser.add_argument(
"--package",
dest="packages",
action="append",
choices=sorted(CODEX_PLATFORM_PACKAGES),
help=(
"Codex platform package to stage. May be provided multiple times. "
"Defaults to all platform packages."
),
)
args = parser.parse_args()
if args.vendor_src is None and not args.workflow_url:
parser.error("Provide either --vendor-src or --workflow-url.")
return 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), recursive=False)
def resolve_release_workflow(version: str) -> dict:
stdout = subprocess.check_output(
[
"gh",
"run",
"list",
"--branch",
f"rust-v{version}",
"--json",
"workflowName,url,headSha",
"--workflow",
WORKFLOW_NAME,
"--jq",
"first(.[])",
],
cwd=REPO_ROOT,
text=True,
)
workflow = json.loads(stdout or "null")
if not workflow:
raise RuntimeError(f"Unable to find rust-release workflow for version {version}.")
return workflow
def resolve_workflow_url(version: str, override: str | None) -> str:
if override:
return override
workflow = resolve_release_workflow(version)
return workflow["url"]
def install_native_components(workflow_url: str, vendor_root: Path) -> Path:
cmd = [str(INSTALL_NATIVE_DEPS), "--workflow-url", workflow_url]
for component in STANDALONE_NATIVE_COMPONENTS:
cmd.extend(["--component", component])
cmd.append(str(vendor_root))
subprocess.run(cmd, cwd=REPO_ROOT, check=True)
return vendor_root / "vendor"
def main() -> int:
args = parse_args()
output_dir = args.output_dir.resolve()
output_dir.mkdir(parents=True, exist_ok=True)
packages = args.packages or sorted(CODEX_PLATFORM_PACKAGES)
runner_temp = Path(os.environ.get("RUNNER_TEMP", tempfile.gettempdir()))
vendor_temp_root: Path | None = None
try:
if args.vendor_src is not None:
vendor_src = args.vendor_src.resolve()
else:
workflow_url = resolve_workflow_url(args.release_version, args.workflow_url)
vendor_temp_root = Path(
tempfile.mkdtemp(prefix="standalone-native-", dir=runner_temp)
)
vendor_src = install_native_components(workflow_url, vendor_temp_root)
for package in sorted(set(packages)):
package_config = CODEX_PLATFORM_PACKAGES[package]
platform_tag = package_config["platform_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}")
finally:
if vendor_temp_root is not None:
shutil.rmtree(vendor_temp_root, ignore_errors=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())