Files
Adam Perry @ OpenAI 835d3049f0 Enable BuildBuddy for voice release builds (#54737)
## What changed

Pass `BUILDBUDDY_API_KEY` to Linux, macOS, and Windows voice release builds, and give the dedicated macOS and Windows voice jobs access to the `bazel` environment without creating deployments.

Use the shared Bazel preparation action and BuildBuddy wrapper for Linux and macOS voice builds so they inherit the existing repository cache and remote cache configuration.

GitOrigin-RevId: 52cdf05bc4567f3e8c460d2509e57bb30e667868
2026-10-16 23:44:37 +00:00

2196 lines
86 KiB
YAML

# Release workflow for codex-rs.
# To release, follow a workflow like:
# ```
# git tag -a rust-v0.1.0 -m "Release 0.1.0"
# git push origin rust-v0.1.0
# ```
#
# Tag releases sign macOS binaries and DMGs through the protected `codesigning`
# GitHub environment and Azure Key Vault before final verification on macOS.
name: rust-release
on:
push:
tags:
- "rust-v*.*.*"
env:
CODEX_REPO_ROOT: ${{ github.workspace }}
# The launcher uses this per-tag group as the migration boundary. Keep shared
# publication jobs on the original queued group below when changing this.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
tag-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0
- name: Validate tag matches Cargo.toml version
shell: bash
run: |
set -euo pipefail
echo "::group::Tag validation"
# Release runs must come from a tag.
[[ "${GITHUB_REF_TYPE}" == "tag" ]] \
|| { echo "❌ Not a tag ref"; exit 1; }
tag_ver="${GITHUB_REF_NAME#rust-v}"
[[ "${GITHUB_REF_NAME}" == rust-v* ]] \
&& python3 .github/scripts/releases.py "${tag_ver}" \
|| { echo "❌ Tag '${GITHUB_REF_NAME}' doesn't match expected format"; exit 1; }
# Release tags must match the version in Cargo.toml.
cargo_ver="$(grep -m1 '^version' codex-rs/Cargo.toml \
| sed -E 's/version *= *"([^"]+)".*/\1/')"
[[ "${tag_ver}" == "${cargo_ver}" ]] \
|| { echo "❌ Tag ${tag_ver} ≠ Cargo.toml ${cargo_ver}"; exit 1; }
echo "✅ Tag and Cargo.toml agree (${tag_ver})"
echo "::endgroup::"
build:
needs: tag-check
name: Build - ${{ matrix.runner }} - ${{ matrix.target }} - ${{ matrix.bundle }} - ${{ matrix.build-system }}
# Parallel Bazel artifacts are advisory while Cargo remains the release path.
continue-on-error: ${{ matrix.build-system == 'bazel' }}
runs-on: ${{ matrix.runner }}
environment:
name: bazel
deployment: false
# Linux releases also build the native voice runtime in this job.
timeout-minutes: 120
permissions:
contents: read
id-token: write
defaults:
run:
working-directory: codex-rs
env:
# Preserve Cargo names while teams opt into the additional Bazel artifacts.
ARTIFACT_SUFFIX: ${{ matrix.build-system == 'bazel' && '-bazel' || '' }}
# Archive packed macOS dSYMs before stripping the Cargo binaries.
CARGO_PROFILE_RELEASE_SPLIT_DEBUGINFO: ${{ contains(matrix.target, 'apple-darwin') && 'packed' || 'off' }}
# The git CLI reliably fetches nested native-library submodules on macOS.
CARGO_NET_GIT_FETCH_WITH_CLI: "true"
strategy:
fail-fast: false
matrix:
build-system: [cargo, bazel]
target:
- aarch64-apple-darwin
- x86_64-apple-darwin
- x86_64-unknown-linux-musl
- aarch64-unknown-linux-musl
bundle: [primary, app-server]
include:
- runner: macos-15-xlarge
target: aarch64-apple-darwin
bundle: primary
artifact_name: aarch64-apple-darwin
binaries: "codex codex-code-mode-host 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 codex-code-mode-host"
build_dmg: "false"
- runner: macos-15-xlarge
target: x86_64-apple-darwin
bundle: primary
artifact_name: x86_64-apple-darwin
binaries: "codex codex-code-mode-host 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 codex-code-mode-host"
build_dmg: "false"
# Release artifacts intentionally ship MUSL-linked Linux binaries.
- runner: ${{ github.event.repository.name }}-linux-x64-xl
target: x86_64-unknown-linux-musl
bundle: primary
artifact_name: x86_64-unknown-linux-musl
binaries: "codex codex-code-mode-host codex-responses-api-proxy bwrap"
build_dmg: "false"
- runner: ${{ github.event.repository.name }}-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 codex-code-mode-host"
build_dmg: "false"
- runner: ${{ github.event.repository.name }}-linux-arm64
target: aarch64-unknown-linux-musl
bundle: primary
artifact_name: aarch64-unknown-linux-musl
binaries: "codex codex-code-mode-host codex-responses-api-proxy bwrap"
build_dmg: "false"
- runner: ${{ github.event.repository.name }}-linux-arm64
target: aarch64-unknown-linux-musl
bundle: app-server
artifact_name: aarch64-unknown-linux-musl-app-server
binaries: "codex-app-server codex-code-mode-host"
build_dmg: "false"
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Print runner specs (Linux)
if: ${{ runner.os == 'Linux' }}
shell: bash
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' }}
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 .
- name: Install Linux bwrap build dependencies
if: ${{ runner.os == 'Linux' }}
shell: bash
run: |
set -euo pipefail
sudo apt-get update -y
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends binutils pkg-config libcap-dev
- name: Set up Rust toolchain
uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0
with:
targets: ${{ matrix.build-system == 'cargo' && matrix.target || '' }}
- if: ${{ matrix.build-system == 'cargo' && contains(matrix.target, 'linux') }}
name: Use hermetic Cargo home (musl)
shell: bash
run: |
set -euo pipefail
cargo_home="${GITHUB_WORKSPACE}/.cargo-home"
mkdir -p "${cargo_home}/bin"
echo "CARGO_HOME=${cargo_home}" >> "$GITHUB_ENV"
echo "${cargo_home}/bin" >> "$GITHUB_PATH"
: > "${cargo_home}/config.toml"
- if: ${{ matrix.build-system == 'cargo' && contains(matrix.target, 'linux') }}
name: Install Zig
uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1
with:
version: 0.14.0
use-cache: false
- if: ${{ matrix.build-system == 'cargo' && contains(matrix.target, 'linux') }}
name: Install musl build tools
env:
TARGET: ${{ matrix.target }}
run: bash "${GITHUB_WORKSPACE}/.github/scripts/install-musl-build-tools.sh"
- if: ${{ matrix.build-system == 'cargo' && contains(matrix.target, 'linux') }}
name: Disable aws-lc jitter entropy (musl)
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"
- name: Configure rusty_v8 artifact overrides and verify checksums
if: ${{ matrix.build-system == 'cargo' }}
uses: ./.github/actions/setup-rusty-v8
with:
target: ${{ matrix.target }}
- if: ${{ matrix.build-system == 'cargo' && contains(matrix.target, 'linux') }}
name: Build bwrap and export digest
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
fi
# Codex embeds this digest at build time and verifies the bundled
# bwrap resource before use. Strip bwrap before hashing so the digest
# covers the exact bytes that the release packages.
strip --strip-debug --strip-unneeded "$bwrap_path"
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: ${{ matrix.build-system == 'cargo' }}
shell: bash
run: |
target="${{ matrix.target }}"
build_args=()
for binary in ${{ matrix.binaries }}; do
# bwrap was built, finalized, and hashed before this build so
# Codex can embed the digest of the bytes that will be packaged.
if [[ "$binary" == "bwrap" ]]; then
continue
fi
build_args+=(--bin "$binary")
done
STABLE_GIT_COMMIT="$(git rev-parse HEAD)"
export STABLE_GIT_COMMIT
cargo build --target "$target" --release --timings "${build_args[@]}"
- name: Prepare Bazel for release binaries or Linux voice
if: matrix.build-system == 'bazel' || (matrix.bundle == 'primary' && contains(matrix.target, 'linux'))
uses: ./.github/actions/prepare-bazel-ci
with:
target: ${{ matrix.target }}
cache-scope: bazel-release-${{ matrix.bundle }}
- name: Build Bazel release binaries and symbols
if: ${{ matrix.build-system == 'bazel' }}
uses: ./.github/actions/build-bazel-release
env:
BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }}
with:
target: ${{ matrix.target }}
bundle: ${{ matrix.bundle }}
release-dir: ${{ github.workspace }}/codex-rs/target/${{ matrix.target }}/release
- name: Build symbols archive and strip binaries
shell: bash
run: |
binaries=()
for binary in ${{ matrix.binaries }}; do
# bwrap is already stripped before hashing. Its symbols are not
# useful enough to justify a separate pre-Codex symbols pass.
if [[ "$binary" == "bwrap" ]]; then
continue
fi
binaries+=("$binary")
done
bash "${GITHUB_WORKSPACE}/.github/scripts/archive-release-symbols-and-strip-binaries.sh" \
--target "${{ matrix.target }}" \
--artifact-name "${{ matrix.artifact_name }}" \
--release-dir "target/${{ matrix.target }}/release" \
--archive-dir "symbols-dist/${{ matrix.artifact_name }}" \
--binaries "${binaries[*]}"
- name: Suffix Bazel artifact filenames
if: ${{ matrix.build-system == 'bazel' }}
shell: bash
run: |
set -euo pipefail
for artifact in "symbols-dist/${{ matrix.artifact_name }}"/*; do
name="${artifact##*/}"
stem="${name%%.*}"
mv "$artifact" "${artifact%/*}/${stem}-bazel${name#"$stem"}"
done
- name: Upload symbols archive
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: ${{ matrix.artifact_name }}-symbols${{ env.ARTIFACT_SUFFIX }}
path: codex-rs/symbols-dist/${{ matrix.artifact_name }}/*
if-no-files-found: error
- name: Build Linux voice runtime
if: ${{ matrix.bundle == 'primary' && contains(matrix.target, 'linux') }}
shell: bash
env:
APP_TARGET: ${{ matrix.target }}
BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }}
run: |
set -euo pipefail
cd "$GITHUB_WORKSPACE"
voice_target="${APP_TARGET%-musl}-gnu"
case "$voice_target" in
aarch64-unknown-linux-gnu) prefix=linux_aarch64 ;;
x86_64-unknown-linux-gnu) prefix=linux_x86_64 ;;
*) exit 1 ;;
esac
python3 .github/scripts/run_bazel_with_buildbuddy.py build --config=release \
//codex-rs/voice-host:codex-voice-host //third_party/voice:native_runtime
source="bazel-bin/third_party/voice/native_runtime_${prefix}"
output="${RUNNER_TEMP}/signed-voice/${APP_TARGET}"
mkdir -p "$output"
python3 third_party/voice/release_runtime.py stage \
--target "$voice_target" --source "$source" --output "$output/runtime"
cp bazel-bin/codex-rs/voice-host/codex-voice-host "$output/codex-voice-host"
chmod 0755 "$output/codex-voice-host"
python3 third_party/voice/release_runtime.py seal \
--target "$voice_target" --output "$output/runtime"
- if: ${{ runner.os == 'macOS' }}
name: Stage unsigned macOS artifacts
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
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' }}
name: Upload unsigned macOS artifacts
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: ${{ matrix.artifact_name }}-unsigned${{ env.ARTIFACT_SUFFIX }}
path: codex-rs/unsigned-dist/${{ matrix.target }}/*
if-no-files-found: error
- if: ${{ contains(matrix.target, 'linux') }}
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 }}
- name: Stage artifacts
if: ${{ runner.os != 'macOS' }}
shell: bash
run: |
dest="dist/${{ matrix.target }}"
mkdir -p "$dest"
for binary in ${{ matrix.binaries }}; do
# Both variants package the host, but only the primary bundle publishes
# standalone binary archives to avoid duplicate release asset names.
if [[ "${{ matrix.bundle }}" == "app-server" && "$binary" == "codex-code-mode-host" ]]; then
continue
fi
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"
fi
done
if [[ "${{ matrix.build_dmg }}" == "true" ]]; 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' }}
shell: bash
env:
TARGET: ${{ matrix.target }}
BUNDLE: ${{ matrix.bundle }}
run: |
set -euo pipefail
voice_args=()
if [[ "$BUNDLE" == "primary" && "$TARGET" == *-unknown-linux-musl ]]; then
voice_args+=(--voice-release-dir "${RUNNER_TEMP}/signed-voice/${TARGET}")
voice_args+=(--release-version "${GITHUB_REF_NAME#rust-v}")
fi
bash "${GITHUB_WORKSPACE}/.github/scripts/build-codex-package-archive.sh" \
--target "$TARGET" \
--bundle "$BUNDLE" \
--entrypoint-dir "target/${TARGET}/release" \
--archive-dir "dist/${TARGET}" \
"${voice_args[@]}"
- name: Cosign Linux voice package archives
if: ${{ matrix.bundle == 'primary' && contains(matrix.target, 'linux') }}
uses: ./.github/actions/linux-code-sign
with:
target: ${{ matrix.target }}
artifacts-dir: ${{ github.workspace }}/codex-rs/dist/${{ matrix.target }}
binaries: codex-package-${{ matrix.target }}.tar.gz codex-package-${{ matrix.target }}.tar.zst
- name: Build Python runtime wheel
if: ${{ matrix.build-system == 'cargo' && matrix.bundle == 'primary' && runner.os != 'macOS' }}
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
# Keep the existing manylinux_2_17 wheel compatible with older glibc.
# GNU voice libraries belong in the signed release package archives,
# but their minimum glibc version is not covered by this wheel tag.
wheel_archives="${RUNNER_TEMP}/voice-free-wheel/${{ matrix.target }}"
bash "${GITHUB_WORKSPACE}/.github/scripts/build-codex-package-archive.sh" \
--target "${{ matrix.target }}" \
--bundle primary \
--entrypoint-dir "target/${{ matrix.target }}/release" \
--archive-dir "$wheel_archives"
wheel_archive="${wheel_archives}/codex-package-${{ matrix.target }}.tar.gz"
python3 - "$wheel_archive" <<'PY'
import sys
import tarfile
with tarfile.open(sys.argv[1]) as archive:
assert not any("codex-resources/voice/" in item.name for item in archive)
PY
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"
"$wheel_archive"
--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.build-system == 'cargo' && matrix.bundle == 'primary' && runner.os != 'macOS' }}
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
if: ${{ runner.os != 'macOS' }}
shell: bash
run: |
# Path that contains the uncompressed binaries for the current
# ${{ matrix.target }}
dest="dist/${{ matrix.target }}"
# For compatibility with environments that lack the `zstd` tool we
# additionally create a `.tar.gz` alongside every binary we publish.
# The end result is:
# codex-<target>.zst (existing)
# codex-<target>.tar.gz (new)
# 1. Produce a .tar.gz for every file in the directory *before* we
# run `zstd --rm`, because that flag deletes the original files.
for f in "$dest"/*; do
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
continue
fi
# Don't try to compress signature bundles.
if [[ "$base" == *.sigstore ]]; then
continue
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
- name: Suffix Bazel artifact filenames
if: ${{ matrix.build-system == 'bazel' && runner.os != 'macOS' }}
shell: bash
run: |
set -euo pipefail
for artifact in "dist/${{ matrix.target }}"/*; do
name="${artifact##*/}"
stem="${name%%.*}"
mv "$artifact" "${artifact%/*}/${stem}-bazel${name#"$stem"}"
done
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
if: ${{ runner.os != 'macOS' }}
with:
name: ${{ matrix.artifact_name }}${{ env.ARTIFACT_SUFFIX }}
# Upload the per-binary .zst files, .tar.gz equivalents, and any
# prebuilt archives staged above.
path: |
codex-rs/dist/${{ matrix.target }}/*
build-macos-voice:
needs: tag-check
name: Build voice runtime - ${{ matrix.target }}
runs-on: ${{ matrix.runner }}
environment:
name: bazel
deployment: false
timeout-minutes: 120
permissions:
contents: read
strategy:
fail-fast: false
matrix:
include:
- target: aarch64-apple-darwin
runner: macos-15
prefix: macos_aarch64
- target: x86_64-apple-darwin
runner: macos-15-intel
prefix: macos_x86_64
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12"
- name: Prepare Bazel CI
uses: ./.github/actions/prepare-bazel-ci
with:
target: ${{ matrix.target }}
cache-scope: bazel-release-primary
- name: Build matching helper and native runtime
shell: bash
env:
TARGET: ${{ matrix.target }}
PREFIX: ${{ matrix.prefix }}
BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }}
run: |
set -euo pipefail
# Release tags bump Cargo.toml without rewriting the workspace lockfile.
# Refresh workspace versions before Bazel reads the Cargo dependency graph.
(cd codex-rs && cargo update --workspace)
python3 .github/scripts/run_bazel_with_buildbuddy.py build --config=release \
--macos_minimum_os=14.0 //codex-rs/voice-host:codex-voice-host //third_party/voice:native_runtime
runtime="bazel-bin/third_party/voice/native_runtime_${PREFIX}"
PYTHONPATH=third_party/voice python3 - "$runtime" "$TARGET" <<'PY'
from pathlib import Path
import sys
from package_runtime import runtime_files
runtime_files(Path(sys.argv[1]).resolve(strict=True), sys.argv[2])
PY
mkdir -p "voice-unsigned/${TARGET}"
cp -R "$runtime" "voice-unsigned/${TARGET}/runtime"
# GNU tar extraction and signing need writable copies of Bazel outputs.
chmod -R u+w "voice-unsigned/${TARGET}/runtime"
cp bazel-bin/codex-rs/voice-host/codex-voice-host "voice-unsigned/${TARGET}/codex-voice-host"
tar -C "voice-unsigned/${TARGET}" -czf "voice-unsigned-${TARGET}.tar.gz" runtime codex-voice-host
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: voice-${{ matrix.target }}-unsigned
path: voice-unsigned-${{ matrix.target }}.tar.gz
if-no-files-found: error
sign-macos-binaries:
needs: [build, build-macos-voice]
if: ${{ always() && needs.build.result == 'success' && needs.build-macos-voice.result == 'success' }}
name: Sign macOS binaries - ${{ matrix.target }} - ${{ matrix.bundle }} - ${{ matrix.build-system }}
continue-on-error: ${{ matrix.build-system == 'bazel' }}
runs-on: ubuntu-latest
timeout-minutes: 45
environment:
name: codesigning
deployment: false
permissions:
contents: read
id-token: write
env:
ARTIFACT_SUFFIX: ${{ matrix.build-system == 'bazel' && '-bazel' || '' }}
strategy:
fail-fast: false
matrix:
build-system: [cargo, bazel]
target:
- aarch64-apple-darwin
- x86_64-apple-darwin
bundle: [primary, app-server]
include:
- target: aarch64-apple-darwin
bundle: primary
artifact_name: aarch64-apple-darwin
binaries: "codex codex-code-mode-host codex-responses-api-proxy"
- target: aarch64-apple-darwin
bundle: app-server
artifact_name: aarch64-apple-darwin-app-server
binaries: "codex-app-server codex-code-mode-host"
- target: x86_64-apple-darwin
bundle: primary
artifact_name: x86_64-apple-darwin
binaries: "codex codex-code-mode-host codex-responses-api-proxy"
- target: x86_64-apple-darwin
bundle: app-server
artifact_name: x86_64-apple-darwin-app-server
binaries: "codex-app-server codex-code-mode-host"
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Download unsigned macOS binaries
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ${{ matrix.artifact_name }}-unsigned${{ env.ARTIFACT_SUFFIX }}
path: ${{ runner.temp }}/unsigned-macos
- name: Download unsigned voice runtime
if: ${{ matrix.bundle == 'primary' }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: voice-${{ matrix.target }}-unsigned
path: ${{ runner.temp }}/unsigned-voice
- name: Set up AKV PKCS11 macOS signing
uses: ./.github/actions/setup-akv-pkcs11-codesigning
with:
rcodesign-blob-uri: ${{ secrets.AKV_CODESIGN_RCODESIGN_BLOB_URI }}
rcodesign-sha256: ${{ secrets.AKV_CODESIGN_RCODESIGN_SHA256 }}
akv-pkcs11-library-blob-uri: ${{ secrets.AKV_CODESIGN_PKCS11_LIBRARY_BLOB_URI }}
akv-pkcs11-library-sha256: ${{ secrets.AKV_CODESIGN_PKCS11_LIBRARY_SHA256 }}
azure-client-id: ${{ secrets.AKV_CODESIGN_AZURE_CLIENT_ID }}
azure-tenant-id: ${{ secrets.AKV_CODESIGN_TENANT }}
azure-subscription-id: ${{ secrets.AKV_CODESIGN_SUBSCRIPTION }}
key-vault-name: ${{ secrets.AKV_CODESIGN_KEY_VAULT_NAME }}
key-name: ${{ secrets.AKV_CODESIGN_KEY_NAME }}
key-version: ${{ secrets.AKV_CODESIGN_KEY_VERSION || '' }}
certificate-sha256: ${{ secrets.AKV_CODESIGN_CERTIFICATE_SHA256 || '' }}
- name: Sign and notarize macOS binaries
shell: bash
env:
TARGET: ${{ matrix.target }}
BINARIES: ${{ matrix.binaries }}
APPLE_NOTARIZATION_AKV_KEY_NAME: ${{ secrets.AKV_NOTARIZATION_KEY_NAME }}
APPLE_NOTARIZATION_AKV_KEY_VERSION: ${{ secrets.AKV_NOTARIZATION_KEY_VERSION }}
run: |
set -euo pipefail
input_dir="${RUNNER_TEMP}/unsigned-macos"
output_dir="${GITHUB_WORKSPACE}/signed-macos/${TARGET}"
report_dir="${GITHUB_WORKSPACE}/macos-binary-signing-verification/${TARGET}"
mkdir -p "$output_dir" "$report_dir"
for binary in ${BINARIES}; do
unsigned_path="${input_dir}/${binary}-${TARGET}-unsigned.zst"
signed_path="${output_dir}/${binary}"
if [[ ! -f "$unsigned_path" ]]; then
echo "Unsigned binary $unsigned_path not found"
exit 1
fi
zstd -d --stdout "$unsigned_path" >"$signed_path"
chmod 0755 "$signed_path"
entitlements="${GITHUB_WORKSPACE}/.github/scripts/macos-signing/${binary}.entitlements.plist"
if [[ ! -f "$entitlements" ]]; then
echo "Entitlements file $entitlements not found"
exit 1
fi
.github/scripts/macos-signing/sign_macos_code.sh \
--target "$signed_path" \
--identity unused \
--deep false \
--identifier "$binary" \
--options runtime \
--timestamp true \
--entitlements "$entitlements"
mkdir -p "${report_dir}/${binary}"
rcodesign print-signature-info "$signed_path" \
>"${report_dir}/${binary}/signature-info.yaml"
.github/scripts/macos-signing/notarize_macos_binary_with_akv.sh \
--binary "$signed_path" \
--report-dir "${report_dir}/${binary}"
done
- name: Fetch, sign, and notarize pinned macOS helpers
if: ${{ matrix.bundle == 'primary' }}
shell: bash
env:
TARGET: ${{ matrix.target }}
APPLE_NOTARIZATION_AKV_KEY_NAME: ${{ secrets.AKV_NOTARIZATION_KEY_NAME }}
APPLE_NOTARIZATION_AKV_KEY_VERSION: ${{ secrets.AKV_NOTARIZATION_KEY_VERSION }}
run: |
set -euo pipefail
signed_root="${GITHUB_WORKSPACE}/signed-resources/${TARGET}"
mkdir -p "$signed_root"
PYTHONPATH="${GITHUB_WORKSPACE}/scripts" python3 - "$TARGET" "$signed_root" <<'PY'
import shutil
import sys
from pathlib import Path
from codex_package.ripgrep import fetch_rg
from codex_package.targets import TARGET_SPECS
spec = TARGET_SPECS[sys.argv[1]]
signed_root = Path(sys.argv[2])
shutil.copy2(fetch_rg(spec), signed_root / "rg")
PY
for resource in rg; do
binary="${signed_root}/${resource}"
report_dir="${GITHUB_WORKSPACE}/macos-binary-signing-verification/${TARGET}/${resource}"
mkdir -p "$report_dir"
chmod 0755 "$binary"
.github/scripts/macos-signing/sign_macos_code.sh \
--target "$binary" \
--identity unused \
--deep false \
--identifier "com.openai.codex.${resource}" \
--options runtime \
--timestamp true
rcodesign print-signature-info "$binary" \
>"${report_dir}/signature-info.yaml"
.github/scripts/macos-signing/notarize_macos_binary_with_akv.sh \
--binary "$binary" \
--report-dir "$report_dir"
done
- name: Sign and notarize voice runtime
if: ${{ matrix.bundle == 'primary' }}
shell: bash
env:
TARGET: ${{ matrix.target }}
APPLE_NOTARIZATION_AKV_KEY_NAME: ${{ secrets.AKV_NOTARIZATION_KEY_NAME }}
APPLE_NOTARIZATION_AKV_KEY_VERSION: ${{ secrets.AKV_NOTARIZATION_KEY_VERSION }}
run: |
set -euo pipefail
unsigned="${RUNNER_TEMP}/unsigned-voice/extracted"
signed="${GITHUB_WORKSPACE}/signed-voice/${TARGET}"
mkdir -p "$unsigned" "$signed"
tar -xzf "${RUNNER_TEMP}/unsigned-voice/voice-unsigned-${TARGET}.tar.gz" -C "$unsigned"
python3 third_party/voice/release_runtime.py stage \
--target "$TARGET" --source "$unsigned/runtime" --output "$signed/runtime"
cp "$unsigned/codex-voice-host" "$signed/codex-voice-host"
chmod 0755 "$signed/codex-voice-host"
while IFS= read -r -d '' library; do
name="$(basename "$library")"
.github/scripts/macos-signing/sign_macos_code.sh \
--target "$library" --identity unused --deep false \
--identifier "com.openai.codex.voice.${name}" \
--options runtime --timestamp true
done < <(find "$signed/runtime" -name '*.dylib' -type f -print0)
.github/scripts/macos-signing/sign_macos_code.sh \
--target "$signed/codex-voice-host" --identity unused --deep false \
--identifier com.openai.codex.voice-host --options runtime --timestamp true \
--entitlements .github/scripts/macos-signing/codex-voice-host.entitlements.plist
# Notarize the actual signed closure together, then hash those bytes.
(cd "$signed" && zip -qr "${RUNNER_TEMP}/voice-${TARGET}.zip" runtime codex-voice-host)
report_dir="${GITHUB_WORKSPACE}/macos-binary-signing-verification/${TARGET}/voice"
mkdir -p "$report_dir"
python3 .github/scripts/macos-signing/notarize_with_akv.py \
--file "${RUNNER_TEMP}/voice-${TARGET}.zip" \
--report-log "${report_dir}/notarization.json" \
--max-wait-seconds 600
python3 third_party/voice/release_runtime.py seal \
--target "$TARGET" --output "$signed/runtime"
tar -C "$signed" -czf "${GITHUB_WORKSPACE}/signed-voice-${TARGET}.tar.gz" \
runtime codex-voice-host
- name: Upload signed voice runtime
if: ${{ matrix.bundle == 'primary' }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: voice-${{ matrix.target }}-signed${{ env.ARTIFACT_SUFFIX }}
path: signed-voice-${{ matrix.target }}.tar.gz
if-no-files-found: error
- name: Upload signed macOS binaries
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: ${{ matrix.artifact_name }}-signed-binaries${{ env.ARTIFACT_SUFFIX }}
path: signed-macos/${{ matrix.target }}/*
if-no-files-found: error
- name: Upload signed macOS helpers
if: ${{ matrix.bundle == 'primary' }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: ${{ matrix.target }}-signed-resources${{ env.ARTIFACT_SUFFIX }}
path: signed-resources/${{ matrix.target }}/*
if-no-files-found: error
- name: Upload binary signing verification
if: ${{ always() }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: ${{ matrix.artifact_name }}-binary-signing-verification${{ env.ARTIFACT_SUFFIX }}
path: macos-binary-signing-verification/${{ matrix.target }}/
if-no-files-found: warn
package-macos:
needs: sign-macos-binaries
name: Package macOS artifacts - ${{ matrix.target }} - ${{ matrix.bundle }} - ${{ matrix.build-system }}
continue-on-error: ${{ matrix.build-system == 'bazel' }}
runs-on: macos-15-xlarge
timeout-minutes: 45
permissions:
contents: read
defaults:
run:
working-directory: codex-rs
env:
ARTIFACT_SUFFIX: ${{ matrix.build-system == 'bazel' && '-bazel' || '' }}
strategy:
fail-fast: false
matrix:
build-system: [cargo, bazel]
target:
- aarch64-apple-darwin
- x86_64-apple-darwin
bundle: [primary, app-server]
include:
- target: aarch64-apple-darwin
bundle: primary
artifact_name: aarch64-apple-darwin
binaries: "codex codex-code-mode-host codex-responses-api-proxy"
build_dmg: "true"
- target: aarch64-apple-darwin
bundle: app-server
artifact_name: aarch64-apple-darwin-app-server
binaries: "codex-app-server codex-code-mode-host"
build_dmg: "false"
- target: x86_64-apple-darwin
bundle: primary
artifact_name: x86_64-apple-darwin
binaries: "codex codex-code-mode-host codex-responses-api-proxy"
build_dmg: "true"
- target: x86_64-apple-darwin
bundle: app-server
artifact_name: x86_64-apple-darwin-app-server
binaries: "codex-app-server codex-code-mode-host"
build_dmg: "false"
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Download signed macOS binaries
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ${{ matrix.artifact_name }}-signed-binaries${{ env.ARTIFACT_SUFFIX }}
path: codex-rs/target/${{ matrix.target }}/release
- name: Download signed macOS helpers
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ${{ matrix.target }}-signed-resources${{ env.ARTIFACT_SUFFIX }}
path: codex-rs/signed-resources/${{ matrix.target }}
- name: Download signed voice runtime
if: ${{ matrix.bundle == 'primary' }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: voice-${{ matrix.target }}-signed${{ env.ARTIFACT_SUFFIX }}
path: ${{ runner.temp }}/signed-voice
- name: Verify signed voice runtime
if: ${{ matrix.bundle == 'primary' }}
shell: bash
env:
TARGET: ${{ matrix.target }}
run: |
set -euo pipefail
root="${RUNNER_TEMP}/signed-voice/${TARGET}"
mkdir -p "$root"
tar -xzf "${RUNNER_TEMP}/signed-voice/signed-voice-${TARGET}.tar.gz" -C "$root"
case "$TARGET" in
aarch64-apple-darwin) arch=arm64 ;;
x86_64-apple-darwin) arch=x86_64 ;;
*) exit 1 ;;
esac
PYTHONPATH="${GITHUB_WORKSPACE}/third_party/voice" python3 - "$root/runtime" "$TARGET" <<'PY'
from pathlib import Path
import sys
from package_runtime import runtime_files
runtime_files(Path(sys.argv[1]).resolve(strict=True), sys.argv[2], public_release=True)
PY
while IFS= read -r -d '' binary; do
lipo "$binary" -verify_arch "$arch"
codesign --verify --strict --verbose=2 "$binary"
done < <(find "$root/runtime" -name '*.dylib' -type f -print0)
lipo "$root/codex-voice-host" -verify_arch "$arch"
codesign --verify --strict --verbose=2 "$root/codex-voice-host"
- name: Verify signed macOS binaries
shell: bash
run: |
set -euo pipefail
for binary in ${{ matrix.binaries }}; do
binary_path="target/${{ matrix.target }}/release/${binary}"
chmod 0755 "$binary_path"
codesign --verify --strict --verbose=2 "$binary_path"
done
for resource in rg; do
resource_path="signed-resources/${{ matrix.target }}/${resource}"
chmod 0755 "$resource_path"
codesign --verify --strict --verbose=2 "$resource_path"
done
- name: Stage macOS artifacts
shell: bash
run: |
set -euo pipefail
dest="dist/${{ matrix.target }}"
mkdir -p "$dest"
for binary in ${{ matrix.binaries }}; do
# Both variants package the host, but only the primary bundle publishes
# standalone binary archives to avoid duplicate release asset names.
if [[ "${{ matrix.bundle }}" == "app-server" && "$binary" == "codex-code-mode-host" ]]; then
continue
fi
cp "target/${{ matrix.target }}/release/${binary}" "$dest/${binary}-${{ matrix.target }}"
done
- name: Build Codex package archive
shell: bash
env:
TARGET: ${{ matrix.target }}
BUNDLE: ${{ matrix.bundle }}
run: |
set -euo pipefail
voice_args=()
if [[ "$BUNDLE" == "primary" ]]; then
voice_args+=(--voice-release-dir "${RUNNER_TEMP}/signed-voice/${TARGET}")
voice_args+=(--release-version "${GITHUB_REF_NAME#rust-v}")
fi
bash "${GITHUB_WORKSPACE}/.github/scripts/build-codex-package-archive.sh" \
--target "$TARGET" \
--bundle "$BUNDLE" \
--entrypoint-dir "target/${TARGET}/release" \
--archive-dir "dist/${TARGET}" \
--rg-bin "signed-resources/${TARGET}/rg" \
${voice_args[@]+"${voice_args[@]}"}
- name: Build unsigned macOS DMG
if: ${{ matrix.build_dmg == 'true' }}
shell: bash
run: |
set -euo pipefail
target="${{ matrix.target }}"
release_dir="target/${target}/release"
dmg_root="${RUNNER_TEMP}/codex-dmg-root-${target}"
volname="Codex (${target})"
dmg_path="${release_dir}/codex-${target}.dmg"
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 [[ "${{ matrix.bundle }}" == "primary" ]]; then
# Keep the root-level invocation, but resolve it into the canonical
# package layout so InstallContext finds its adjacent voice runtime.
package="${RUNNER_TEMP}/codex-package-voice-${target}"
ditto "$package" "$dmg_root"
rm "${dmg_root}/codex"
ln -s bin/codex "${dmg_root}/codex"
fi
rm -f "$dmg_path"
hdiutil create \
-volname "$volname" \
-srcfolder "$dmg_root" \
-format UDZO \
-ov \
"$dmg_path"
if [[ ! -f "$dmg_path" ]]; then
echo "DMG $dmg_path not found after build"
exit 1
fi
- name: Upload unsigned macOS DMG
if: ${{ matrix.build_dmg == 'true' }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: ${{ matrix.artifact_name }}-unsigned-dmg${{ env.ARTIFACT_SUFFIX }}
path: codex-rs/target/${{ matrix.target }}/release/codex-${{ matrix.target }}.dmg
if-no-files-found: error
- name: Build Python runtime wheel
if: ${{ matrix.build-system == 'cargo' && 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
# The wheel keeps its macOS 10.9/11 tags. The native voice build
# currently targets macOS 14, so keep it in the release archives and
# DMG but leave this older-OS-compatible wheel unchanged.
wheel_archives="${RUNNER_TEMP}/voice-free-wheel/${{ matrix.target }}"
bash "${GITHUB_WORKSPACE}/.github/scripts/build-codex-package-archive.sh" \
--target "${{ matrix.target }}" \
--bundle primary \
--entrypoint-dir "target/${{ matrix.target }}/release" \
--archive-dir "$wheel_archives" \
--rg-bin "signed-resources/${{ matrix.target }}/rg"
wheel_archive="${wheel_archives}/codex-package-${{ matrix.target }}.tar.gz"
python3 - "$wheel_archive" <<'PY'
import sys
import tarfile
with tarfile.open(sys.argv[1]) as archive:
assert not any("codex-resources/voice/" in item.name for item in archive)
PY
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" \
"$wheel_archive" \
--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.build-system == 'cargo' && 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
fi
tar -C "$dest" -czf "$dest/${base}.tar.gz" "$base"
zstd -T0 -19 --rm "$dest/$base"
done
- name: Upload packaged macOS artifacts
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: ${{ matrix.artifact_name }}-packaged${{ env.ARTIFACT_SUFFIX }}
path: codex-rs/dist/${{ matrix.target }}/*
if-no-files-found: error
sign-macos-dmg:
needs: package-macos
name: Sign macOS DMG - ${{ matrix.target }} - ${{ matrix.build-system }}
continue-on-error: ${{ matrix.build-system == 'bazel' }}
runs-on: ubuntu-latest
timeout-minutes: 45
environment:
name: codesigning
deployment: false
permissions:
contents: read
id-token: write
env:
ARTIFACT_SUFFIX: ${{ matrix.build-system == 'bazel' && '-bazel' || '' }}
strategy:
fail-fast: false
matrix:
build-system: [cargo, bazel]
target:
- aarch64-apple-darwin
- x86_64-apple-darwin
include:
- target: aarch64-apple-darwin
artifact_name: aarch64-apple-darwin
- target: x86_64-apple-darwin
artifact_name: x86_64-apple-darwin
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Download unsigned macOS DMG
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ${{ matrix.artifact_name }}-unsigned-dmg${{ env.ARTIFACT_SUFFIX }}
path: ${{ runner.temp }}/unsigned-dmg
- name: Set up AKV PKCS11 macOS signing
uses: ./.github/actions/setup-akv-pkcs11-codesigning
with:
rcodesign-blob-uri: ${{ secrets.AKV_CODESIGN_RCODESIGN_BLOB_URI }}
rcodesign-sha256: ${{ secrets.AKV_CODESIGN_RCODESIGN_SHA256 }}
akv-pkcs11-library-blob-uri: ${{ secrets.AKV_CODESIGN_PKCS11_LIBRARY_BLOB_URI }}
akv-pkcs11-library-sha256: ${{ secrets.AKV_CODESIGN_PKCS11_LIBRARY_SHA256 }}
azure-client-id: ${{ secrets.AKV_CODESIGN_AZURE_CLIENT_ID }}
azure-tenant-id: ${{ secrets.AKV_CODESIGN_TENANT }}
azure-subscription-id: ${{ secrets.AKV_CODESIGN_SUBSCRIPTION }}
key-vault-name: ${{ secrets.AKV_CODESIGN_KEY_VAULT_NAME }}
key-name: ${{ secrets.AKV_CODESIGN_KEY_NAME }}
key-version: ${{ secrets.AKV_CODESIGN_KEY_VERSION || '' }}
certificate-sha256: ${{ secrets.AKV_CODESIGN_CERTIFICATE_SHA256 || '' }}
- name: Sign, notarize, and staple macOS DMG
shell: bash
env:
TARGET: ${{ matrix.target }}
APPLE_NOTARIZATION_AKV_KEY_NAME: ${{ secrets.AKV_NOTARIZATION_KEY_NAME }}
APPLE_NOTARIZATION_AKV_KEY_VERSION: ${{ secrets.AKV_NOTARIZATION_KEY_VERSION }}
run: |
set -euo pipefail
dmg_path="${RUNNER_TEMP}/unsigned-dmg/codex-${TARGET}.dmg"
report_dir="${GITHUB_WORKSPACE}/macos-dmg-signing-verification/${TARGET}"
if [[ ! -f "$dmg_path" ]]; then
echo "Unsigned DMG $dmg_path not found"
exit 1
fi
.github/scripts/macos-signing/sign_macos_code.sh \
--target "$dmg_path" \
--identity unused \
--deep false \
--timestamp true
mkdir -p "$report_dir"
rcodesign print-signature-info "$dmg_path" \
>"${report_dir}/signature-info-before-notarization.yaml"
.github/scripts/macos-signing/notarize_macos_dmg_with_akv.sh \
--dmg "$dmg_path" \
--report-dir "$report_dir"
rcodesign print-signature-info "$dmg_path" \
>"${report_dir}/signature-info.yaml"
- name: Upload signed macOS DMG
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: ${{ matrix.artifact_name }}-signed-dmg${{ env.ARTIFACT_SUFFIX }}
path: ${{ runner.temp }}/unsigned-dmg/codex-${{ matrix.target }}.dmg
if-no-files-found: error
- name: Upload DMG signing verification
if: ${{ always() }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: ${{ matrix.artifact_name }}-dmg-signing-verification${{ env.ARTIFACT_SUFFIX }}
path: macos-dmg-signing-verification/${{ matrix.target }}/
if-no-files-found: warn
finalize-macos:
needs:
- package-macos
- sign-macos-dmg
name: Verify macOS artifacts - ${{ matrix.target }} - ${{ matrix.bundle }} - ${{ matrix.build-system }}
continue-on-error: ${{ matrix.build-system == 'bazel' }}
runs-on: macos-15-xlarge
timeout-minutes: 30
permissions:
contents: read
defaults:
run:
working-directory: codex-rs
env:
ARTIFACT_SUFFIX: ${{ matrix.build-system == 'bazel' && '-bazel' || '' }}
strategy:
fail-fast: false
matrix:
build-system: [cargo, bazel]
target:
- aarch64-apple-darwin
- x86_64-apple-darwin
bundle: [primary, app-server]
include:
- target: aarch64-apple-darwin
bundle: primary
artifact_name: aarch64-apple-darwin
binaries: "codex codex-code-mode-host codex-responses-api-proxy"
verify_dmg: "true"
- target: aarch64-apple-darwin
bundle: app-server
artifact_name: aarch64-apple-darwin-app-server
binaries: "codex-app-server codex-code-mode-host"
verify_dmg: "false"
- target: x86_64-apple-darwin
bundle: primary
artifact_name: x86_64-apple-darwin
binaries: "codex codex-code-mode-host codex-responses-api-proxy"
verify_dmg: "true"
- target: x86_64-apple-darwin
bundle: app-server
artifact_name: x86_64-apple-darwin-app-server
binaries: "codex-app-server codex-code-mode-host"
verify_dmg: "false"
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Download packaged macOS artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ${{ matrix.artifact_name }}-packaged${{ env.ARTIFACT_SUFFIX }}
path: codex-rs/dist/${{ matrix.target }}
- name: Download signed macOS binaries
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ${{ matrix.artifact_name }}-signed-binaries${{ env.ARTIFACT_SUFFIX }}
path: ${{ runner.temp }}/signed-binaries
- name: Download signed macOS DMG
if: ${{ matrix.verify_dmg == 'true' }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ${{ matrix.artifact_name }}-signed-dmg${{ env.ARTIFACT_SUFFIX }}
path: ${{ runner.temp }}/signed-dmg
- name: Verify signed macOS artifacts
shell: bash
run: |
set -euo pipefail
target="${{ matrix.target }}"
packaged_dir="dist/${target}"
case "$target" in
aarch64-apple-darwin) expected_arch="arm64" ;;
x86_64-apple-darwin) expected_arch="x86_64" ;;
*)
echo "Unexpected macOS target: $target"
exit 1
;;
esac
verify_signed_binary() {
local path="$1"
local binary="$2"
local actual_entitlements expected_entitlements normalized_actual normalized_expected
chmod 0755 "$path"
lipo "$path" -verify_arch "$expected_arch"
codesign --verify --strict --verbose=2 "$path"
expected_entitlements="${GITHUB_WORKSPACE}/.github/scripts/macos-signing/${binary}.entitlements.plist"
if [[ ! -f "$expected_entitlements" ]]; then
echo "Expected entitlements file $expected_entitlements not found"
exit 1
fi
actual_entitlements="$(mktemp)"
normalized_actual="$(mktemp)"
normalized_expected="$(mktemp)"
codesign -d --entitlements :- "$path" >"$actual_entitlements"
plutil -convert xml1 -o "$normalized_actual" "$actual_entitlements"
plutil -convert xml1 -o "$normalized_expected" "$expected_entitlements"
diff -u "$normalized_expected" "$normalized_actual"
rm -f "$actual_entitlements" "$normalized_actual" "$normalized_expected"
}
for binary in ${{ matrix.binaries }}; do
binary_path="${RUNNER_TEMP}/signed-binaries/${binary}"
verify_signed_binary "$binary_path" "$binary"
# The app-server package contains the host, but its standalone archives
# are omitted above to avoid duplicate release asset names.
if [[ "${{ matrix.bundle }}" == "app-server" && "$binary" == "codex-code-mode-host" ]]; then
continue
fi
direct_archive_dir="${RUNNER_TEMP}/direct-archive-${binary}-${target}"
rm -rf "$direct_archive_dir"
mkdir -p "$direct_archive_dir"
tar -xzf "${packaged_dir}/${binary}-${target}.tar.gz" -C "$direct_archive_dir"
verify_signed_binary "${direct_archive_dir}/${binary}-${target}" "$binary"
direct_zstd_path="${RUNNER_TEMP}/${binary}-${target}-from-zstd"
zstd -d --stdout "${packaged_dir}/${binary}-${target}.zst" >"$direct_zstd_path"
verify_signed_binary "$direct_zstd_path" "$binary"
done
case "${{ matrix.bundle }}" in
primary)
package_stem="codex-package"
package_entrypoint="codex"
;;
app-server)
package_stem="codex-app-server-package"
package_entrypoint="codex-app-server"
;;
*)
echo "Unexpected macOS bundle: ${{ matrix.bundle }}"
exit 1
;;
esac
package_dir="${RUNNER_TEMP}/${package_stem}-${target}"
rm -rf "$package_dir"
mkdir -p "$package_dir"
tar -xzf "${packaged_dir}/${package_stem}-${target}.tar.gz" -C "$package_dir"
verify_signed_binary "${package_dir}/bin/${package_entrypoint}" "$package_entrypoint"
verify_signed_binary "${package_dir}/bin/codex-code-mode-host" "codex-code-mode-host"
for resource in \
"${package_dir}/codex-path/rg"
do
chmod 0755 "$resource"
lipo "$resource" -verify_arch "$expected_arch"
codesign --verify --strict --verbose=2 "$resource"
entitlements="$(mktemp)"
codesign -d --entitlements :- "$resource" >"$entitlements"
if [[ -s "$entitlements" ]]; then
echo "Bundled helper $resource must not have code-signing entitlements." >&2
plutil -p "$entitlements" >&2
exit 1
fi
rm -f "$entitlements"
done
if [[ "${{ matrix.bundle }}" == "primary" ]]; then
voice="${package_dir}/codex-resources/voice"
[[ -f "${voice}/lib/libgstreamer-1.0.0.dylib" ]]
export VOICE_BUILD_COMMIT="$(git rev-parse HEAD)"
PYTHONPATH="${GITHUB_WORKSPACE}/third_party/voice" python3 - "$voice" "$target" "$package_dir" <<'PY'
import json
import sys
from pathlib import Path
from package_runtime import runtime_files
from runtime import digest
voice, target, package = map(Path, sys.argv[1:])
runtime_files(voice.resolve(strict=True), str(target), public_release=True)
manifest = json.loads((voice / "manifest.json").read_text())
assert manifest["buildCommit"] == __import__("os").environ["VOICE_BUILD_COMMIT"]
for relative, expected in manifest["sha256"].items():
assert digest(package / relative) == expected, relative
PY
helper="${voice}/bin/codex-voice-host"
[[ "$("$helper" --build-commit)" == "$VOICE_BUILD_COMMIT" ]]
while IFS= read -r -d '' library; do
lipo "$library" -verify_arch "$expected_arch"
codesign --verify --strict --verbose=2 "$library"
done < <(find "$voice" -name '*.dylib' -type f -print0)
verify_signed_binary "$helper" "codex-voice-host"
fi
if [[ "${{ matrix.verify_dmg }}" != "true" ]]; then
exit 0
fi
dmg_path="${RUNNER_TEMP}/signed-dmg/codex-${target}.dmg"
mount_dir="${RUNNER_TEMP}/codex-dmg-mount-${target}"
if [[ ! -f "$dmg_path" ]]; then
echo "Signed DMG $dmg_path not found"
exit 1
fi
hdiutil verify "$dmg_path"
codesign --verify --strict --verbose=2 "$dmg_path"
xcrun stapler validate "$dmg_path"
rm -rf "$mount_dir"
mkdir -p "$mount_dir"
hdiutil attach "$dmg_path" -nobrowse -readonly -mountpoint "$mount_dir"
cleanup_mount() {
hdiutil detach "$mount_dir" >/dev/null
}
trap cleanup_mount EXIT
for binary in ${{ matrix.binaries }}; do
verify_signed_binary "${mount_dir}/${binary}" "$binary"
done
if [[ "${{ matrix.bundle }}" == "primary" ]]; then
[[ "$(readlink "${mount_dir}/codex")" == "bin/codex" ]]
cmp "${mount_dir}/codex-package.json" "${package_dir}/codex-package.json"
cmp "${mount_dir}/codex-resources/voice/manifest.json" \
"${package_dir}/codex-resources/voice/manifest.json"
cmp "${mount_dir}/codex-resources/voice/bin/codex-voice-host" \
"${package_dir}/codex-resources/voice/bin/codex-voice-host"
fi
cleanup_mount
trap - EXIT
cp "$dmg_path" "dist/${target}/codex-${target}.dmg"
- name: Suffix Bazel artifact filenames
if: ${{ matrix.build-system == 'bazel' }}
shell: bash
run: |
set -euo pipefail
for artifact in "dist/${{ matrix.target }}"/*; do
name="${artifact##*/}"
stem="${name%%.*}"
mv "$artifact" "${artifact%/*}/${stem}-bazel${name#"$stem"}"
done
- name: Upload verified macOS artifacts
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: ${{ matrix.artifact_name }}${{ env.ARTIFACT_SUFFIX }}
path: codex-rs/dist/${{ matrix.target }}/*
if-no-files-found: error
# Keep provisioned packages separate from the legacy CLI/npm/installer layout.
# Desktop consumes the versioned codex-provisioned manifest from this release.
provisioned-macos-candidate:
needs: finalize-macos
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/rust-v') && vars.CODEX_PROVISIONED_MACOS_CANDIDATE == 'true'
permissions:
contents: read
id-token: write
uses: ./.github/workflows/rust-release-provisioned-macos.yml
secrets: inherit
build-windows:
needs: tag-check
uses: ./.github/workflows/rust-release-windows.yml
secrets: inherit
argument-comment-lint-release-assets:
name: argument-comment-lint release assets
needs: tag-check
uses: ./.github/workflows/rust-release-argument-comment-lint.yml
with:
publish: true
stage-npm-packages:
name: stage npm packages
needs:
- build
- finalize-macos
- build-windows
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Download target artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: dist
pattern: "{aarch64,x86_64}-{apple-darwin{,-app-server},unknown-linux-musl{,-app-server},pc-windows-msvc}"
- name: Setup pnpm
uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5
with:
run_install: false
- name: Setup Node.js for npm packaging
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 22
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Stage npm packages
run: |
release_version="${GITHUB_REF_NAME#rust-v}"
./scripts/stage_npm_packages.py \
--release-version "$release_version" \
--artifacts-dir "${GITHUB_WORKSPACE}/dist" \
--package codex \
--package codex-responses-api-proxy \
--package codex-sdk
- name: Upload staged npm packages
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: npm-packages
path: dist/npm/*.tgz
if-no-files-found: error
release:
# Each job checks its channel under this lock; out-of-order builds cannot
# roll back GitHub, npm, R2, or the canary branch.
concurrency: &release-publication
group: rust-release
queue: max
cancel-in-progress: false
needs:
- tag-check
- build
- finalize-macos
- build-windows
- argument-comment-lint-release-assets
- stage-npm-packages
- provisioned-macos-candidate
if: >-
${{
always() &&
needs.tag-check.result == 'success' &&
needs.build.result == 'success' &&
needs.finalize-macos.result == 'success' &&
needs.build-windows.result == 'success' &&
needs.argument-comment-lint-release-assets.result == 'success' &&
needs.stage-npm-packages.result == 'success' &&
(needs.provisioned-macos-candidate.result == 'success' ||
(vars.CODEX_PROVISIONED_MACOS_CANDIDATE != 'true' &&
needs.provisioned-macos-candidate.result == 'skipped'))
}}
name: release
runs-on: ubuntu-latest
permissions:
contents: write
outputs:
version: ${{ steps.release_name.outputs.name }}
tag: ${{ github.ref_name }}
make_latest: ${{ steps.release_name.outputs.make_latest }}
prerelease: ${{ steps.release_name.outputs.prerelease }}
should_publish_npm: ${{ steps.npm_publish_settings.outputs.should_publish }}
npm_tag: ${{ steps.npm_publish_settings.outputs.npm_tag }}
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Generate release notes from tag commit message
id: release_notes
shell: bash
run: |
set -euo pipefail
# On tag pushes, GITHUB_SHA may be a tag object for annotated tags;
# peel it to the underlying commit.
commit="$(git rev-parse "${GITHUB_SHA}^{commit}")"
notes_path="${RUNNER_TEMP}/release-notes.md"
# Use the commit message for the commit the tag points at (not the
# annotated tag message).
# Preserve the previous publisher's 124,999-character cap, reserving
# the last character for a trailing newline for GitHub's renderer.
git log -1 --format=%B "${commit}" |
python3 -c 'import sys; print(sys.stdin.read()[:124998])' > "${notes_path}"
echo "path=${notes_path}" >> "${GITHUB_OUTPUT}"
- name: Download release artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: dist
pattern: "{{aarch64,x86_64}-{apple-darwin{,-app-server},unknown-linux-musl{,-app-server},pc-windows-msvc}{,-bazel},*-symbols{,-bazel},argument-comment-lint-*,python-runtime-wheel-*,npm-packages}"
- name: Download verified provisioned macOS packages
if: vars.CODEX_PROVISIONED_MACOS_CANDIDATE == 'true'
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: dist
pattern: "provisioned-macos-verified-candidate-*"
- name: Remove per-architecture checksum filenames before release upload
if: vars.CODEX_PROVISIONED_MACOS_CANDIDATE == 'true'
run: rm dist/provisioned-macos-verified-candidate-*/SHA256SUMS
- name: List
run: 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' -o -name 'codex-provisioned-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
- name: Define release name
id: release_name
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
# Extract the version from the tag name, which is in the format
# "rust-v0.1.0".
version="${GITHUB_REF_NAME#rust-v}"
echo "name=${version}" >> $GITHUB_OUTPUT
if [[ "${version}" == *-* ]]; then
echo "make_latest=false" >> $GITHUB_OUTPUT
echo "prerelease=true" >> $GITHUB_OUTPUT
else
make_latest="$(gh api "repos/${GITHUB_REPOSITORY}/releases/latest" \
| python3 .github/scripts/releases.py "$version" --check-github latest)"
echo "make_latest=${make_latest}" >> $GITHUB_OUTPUT
echo "prerelease=false" >> $GITHUB_OUTPUT
fi
- name: Determine npm publish settings
id: npm_publish_settings
env:
VERSION: ${{ steps.release_name.outputs.name }}
run: |
set -euo pipefail
version="${VERSION}"
if [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "should_publish=true" >> "$GITHUB_OUTPUT"
echo "npm_tag=" >> "$GITHUB_OUTPUT"
elif [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+-alpha\.[0-9]+(\.[0-9]+)?$ ]]; then
echo "should_publish=true" >> "$GITHUB_OUTPUT"
echo "npm_tag=alpha" >> "$GITHUB_OUTPUT"
else
echo "should_publish=false" >> "$GITHUB_OUTPUT"
echo "npm_tag=" >> "$GITHUB_OUTPUT"
fi
- name: Stage installer scripts
run: |
cp scripts/install/install.sh dist/install.sh
mv dist/x86_64-pc-windows-msvc/install.ps1 dist/install.ps1
- name: Create GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_NAME: ${{ steps.release_name.outputs.name }}
RELEASE_NOTES: ${{ steps.release_notes.outputs.path }}
MAKE_LATEST: ${{ steps.release_name.outputs.make_latest }}
PRERELEASE: ${{ steps.release_name.outputs.prerelease }}
run: |
set -euo pipefail
# Look up this tag, including drafts, without listing release history.
# API errors must fail the step rather than look like an absent release.
# shellcheck disable=SC2016
exists="$(gh api graphql \
-f query='query($owner:String!,$repo:String!,$tag:String!){repository(owner:$owner,name:$repo){release(tagName:$tag){databaseId}}}' \
-f owner="$GITHUB_REPOSITORY_OWNER" \
-f repo="${GITHUB_REPOSITORY#*/}" \
-f tag="$GITHUB_REF_NAME" \
--jq '.data.repository.release != null')"
release_args=(
--repo "$GITHUB_REPOSITORY"
--title "$RELEASE_NAME"
--notes-file "$RELEASE_NOTES"
--prerelease="$PRERELEASE"
)
if [[ "$exists" == false ]]; then
# Prereleases must be created directly to keep prereleased events.
draft=true
if [[ "$PRERELEASE" == true ]]; then
draft=false
fi
gh release create "$GITHUB_REF_NAME" "${release_args[@]}" \
--draft="$draft" --verify-tag --latest=false
fi
mapfile -d '' assets < <(find dist -type f -print0 | sort -z)
gh release upload "$GITHUB_REF_NAME" "${assets[@]}" \
--repo "$GITHUB_REPOSITORY" --clobber
gh release edit "$GITHUB_REF_NAME" "${release_args[@]}" \
--draft=false --latest="$MAKE_LATEST"
publish-r2-assets:
name: publish-r2-assets
needs: release
if: ${{ !cancelled() && needs.release.result == 'success' }}
permissions:
contents: read
uses: ./.github/workflows/r2-release.yml
secrets: inherit
with:
tag: ${{ needs.release.outputs.tag }}
make_latest: ${{ fromJSON(needs.release.outputs.make_latest) }}
prerelease: ${{ fromJSON(needs.release.outputs.prerelease) }}
stage: assets
publish-r2:
concurrency: *release-publication
name: publish-r2
needs: [release, publish-dotslash, publish-r2-assets]
if: >-
${{
!cancelled() &&
needs.release.result == 'success' &&
needs.publish-dotslash.result == 'success' &&
needs.publish-r2-assets.result == 'success'
}}
permissions:
contents: read
uses: ./.github/workflows/r2-release.yml
secrets: inherit
with:
tag: ${{ needs.release.outputs.tag }}
make_latest: ${{ fromJSON(needs.release.outputs.make_latest) }}
prerelease: ${{ fromJSON(needs.release.outputs.prerelease) }}
stage: finalize
publish-dotslash:
name: publish-dotslash
needs: release
if: ${{ !cancelled() && needs.release.result == 'success' }}
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: ./.github/actions/publish-dotslash
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag: ${{ github.ref_name }}
configs: |
.github/dotslash-config.json
.github/dotslash-argument-comment-lint-config.json
- name: Publish the provisioned macOS package manifest
if: vars.CODEX_PROVISIONED_MACOS_CANDIDATE == 'true'
uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag: ${{ github.ref_name }}
config: .github/dotslash-provisioned-config.json
# 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:
concurrency: *release-publication
# Publish to npm for stable releases, numbered alphas, and alpha hotfixes.
if: >-
${{
!cancelled() &&
needs.release.result == 'success' &&
needs.release.outputs.should_publish_npm == 'true'
}}
name: publish-npm
needs: release
runs-on: ubuntu-latest
permissions:
id-token: write # Required for OIDC
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
# Node 24 bundles npm >= 11.5.1, which trusted publishing requires.
node-version: 24
registry-url: "https://registry.npmjs.org"
scope: "@openai"
- 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"
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
# No NODE_AUTH_TOKEN needed because we use OIDC.
- name: Publish to npm
env:
VERSION: ${{ needs.release.outputs.version }}
NPM_TAG: ${{ needs.release.outputs.npm_tag }}
NPM_CONFIG_LOGLEVEL: verbose
NPM_CONFIG_LOGS_DIR: ${{ runner.temp }}/npm-publish-logs
NPM_CONFIG_LOGS_MAX: "100"
run: |
set -euo pipefail
prefix=""
if [[ -n "${NPM_TAG}" ]]; then
prefix="${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
# npm returns HTTP 409 when concurrent publishes update the same
# packument. Every platform tarball is a version of @openai/codex,
# so publish all tarballs serially.
tarballs=(
"${platform_tarballs[@]}"
"${other_tarballs[@]}"
"${root_tarball}"
)
# The SDK depends on this exact root package version.
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
# The publication workflow holds the lock from this lookup through
# publish, including concurrent releases from other version lines.
tag="$(python3 .github/scripts/npm_alpha_tag.py "${tarball}" "${VERSION}" "${tag:-latest}")"
publish_cmd=(npm publish "${GITHUB_WORKSPACE}/${tarball}")
if [[ -n "${tag}" ]]; then
publish_cmd+=(--tag "${tag}")
fi
# A fresh npm process obtains new OIDC credentials on each attempt.
for attempt in 1 2 3; do
echo "+ ${publish_cmd[*]} (attempt ${attempt}/3)"
set +e
publish_output="$("${publish_cmd[@]}" 2>&1)"
publish_status=$?
set -e
echo "${publish_output}"
if [[ ${publish_status} -eq 0 ]]; then
break
fi
if grep -qiE "previously published|cannot publish over|version already exists" <<< "${publish_output}"; then
echo "Skipping already-published package version for ${filename}"
break
fi
if [[ ${attempt} -eq 3 ]]; then
exit "${publish_status}"
fi
delay=$((attempt * 10))
echo "Publishing ${filename} failed; retrying in ${delay} seconds."
sleep "${delay}"
done
done
- name: Retain npm debug logs
if: always()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: npm-publish-logs-${{ github.run_attempt }}
path: ${{ runner.temp }}/npm-publish-logs/
if-no-files-found: ignore
retention-days: 7
deploy-dev-website:
name: Trigger developers.openai.com deploy
needs: release
# Only trigger the deploy for a stable release.
# The deploy updates developers.openai.com with the new config schema json file.
if: >-
${{
!cancelled() &&
needs.release.result == 'success' &&
!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:
concurrency: *release-publication
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' &&
!contains(needs.release.outputs.version, '-')
}}
# komac opens the winget-pkgs PR using the published Windows archives;
# it runs on Linux and does not execute the Windows binaries.
runs-on: ubuntu-latest
permissions:
contents: read
environment:
name: mainline-release-winget
deployment: false
steps:
# Adapted from the WinGet Releaser version previously used by this job:
# https://github.com/vedantmgoyal9/winget-releaser/blob/7bd472be23763def6e16bd06cc8b1cdfab0e2fd5/action.yml
# Keep dependencies explicit because its cargo-binstall@main reference
# violates the policy requiring full commit SHA action pins.
- name: Install cargo-binstall
uses: cargo-bins/cargo-binstall@b874e25ea559687bec77e281e9b271aa1367b624 # v1.23.0
with:
version: "1.23.0"
env:
GITHUB_TOKEN: ${{ github.token }}
- name: Install komac
env:
GITHUB_TOKEN: ${{ github.token }}
run: cargo-binstall komac --version 2.16.0 --no-confirm
- name: Publish to WinGet
shell: bash
env:
GH_TOKEN: ${{ github.token }}
GITHUB_TOKEN: ${{ secrets.WINGET_PUBLISH_PAT }}
RELEASE_VERSION: ${{ needs.release.outputs.version }}
RELEASE_TAG: ${{ needs.release.outputs.tag }}
KOMAC_FORK_OWNER: openai-oss-forks
KOMAC_CREATED_WITH: Codex release workflow
KOMAC_CREATED_WITH_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set -euo pipefail
installer_urls="$(gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \
--json assets \
--jq '.assets[] | select(.name | test("^codex-(x86_64|aarch64)-pc-windows-msvc\\.exe\\.zip$")) | .url')"
mapfile -t urls <<< "$installer_urls"
if [[ ${#urls[@]} -ne 2 ]]; then
echo "::error::Expected x86_64 and aarch64 Windows archives for $RELEASE_TAG"
exit 1
fi
komac sync-fork
komac update OpenAI.Codex --version "$RELEASE_VERSION" --submit --urls "${urls[@]}"
komac cleanup --only-merged
update-branch:
concurrency: *release-publication
name: Update latest-alpha-cli branch
if: >-
${{
!cancelled() &&
needs.release.result == 'success' &&
needs.release.outputs.npm_tag == 'alpha' &&
needs.publish-r2.result == 'success' &&
needs.publish-npm.result == 'success'
}}
permissions:
contents: write
# R2 waits for the GitHub release and DotSlash release-asset uploads.
needs:
- release
- publish-r2
- publish-npm
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Determine whether to update the GitHub canary branch
id: github_canary
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_VERSION: ${{ needs.release.outputs.version }}
PYTHONPATH: .github/scripts
run: |
set -euo pipefail
# Despite its name, raw+json returns raw TOML. The default response
# is JSON metadata with the file contents encoded as base64.
update="$(gh api "repos/$GITHUB_REPOSITORY/contents/codex-rs/Cargo.toml?ref=latest-alpha-cli" \
-H 'Accept: application/vnd.github.raw+json' \
| python3 .github/scripts/releases.py "$RELEASE_VERSION" --check-github canary)"
echo "update=$update" >> "$GITHUB_OUTPUT"
- name: Update latest-alpha-cli branch
if: success() && steps.github_canary.outputs.update == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
gh api \
repos/${GITHUB_REPOSITORY}/git/refs/heads/latest-alpha-cli \
-X PATCH \
-f sha="${GITHUB_SHA}" \
-F force=true