# 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 # ``` name: rust-release on: push: tags: - "rust-v*.*.*" concurrency: group: ${{ github.workflow }} cancel-in-progress: true jobs: tag-check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: dtolnay/rust-toolchain@1.92 - name: Validate tag matches Cargo.toml version shell: bash run: | set -euo pipefail echo "::group::Tag validation" # 1. Must be a tag and match the regex [[ "${GITHUB_REF_TYPE}" == "tag" ]] \ || { echo "❌ Not a tag push"; exit 1; } [[ "${GITHUB_REF_NAME}" =~ ^rust-v[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta)(\.[0-9]+)?)?$ ]] \ || { echo "❌ Tag '${GITHUB_REF_NAME}' doesn't match expected format"; exit 1; } # 2. Extract versions tag_ver="${GITHUB_REF_NAME#rust-v}" cargo_ver="$(grep -m1 '^version' codex-rs/Cargo.toml \ | sed -E 's/version *= *"([^"]+)".*/\1/')" # 3. Compare [[ "${tag_ver}" == "${cargo_ver}" ]] \ || { echo "❌ Tag ${tag_ver} ≠ Cargo.toml ${cargo_ver}"; exit 1; } echo "✅ Tag and Cargo.toml agree (${tag_ver})" echo "::endgroup::" - name: Verify config schema fixture shell: bash working-directory: codex-rs run: | set -euo pipefail echo "If this fails, run: just write-config-schema to overwrite fixture with intentional changes." cargo run -p codex-core --bin codex-write-config-schema git diff --exit-code core/config.schema.json build: needs: tag-check name: Build - ${{ matrix.runner }} - ${{ matrix.target }} runs-on: ${{ matrix.runner }} timeout-minutes: 60 permissions: contents: read id-token: write defaults: run: working-directory: codex-rs strategy: fail-fast: false matrix: include: - runner: macos-15-xlarge target: aarch64-apple-darwin - runner: macos-15-xlarge target: x86_64-apple-darwin - runner: ubuntu-24.04 target: x86_64-unknown-linux-musl - runner: ubuntu-24.04 target: x86_64-unknown-linux-gnu - runner: ubuntu-24.04-arm target: aarch64-unknown-linux-musl - runner: ubuntu-24.04-arm target: aarch64-unknown-linux-gnu - runner: windows-latest target: x86_64-pc-windows-msvc - runner: windows-11-arm target: aarch64-pc-windows-msvc steps: - uses: actions/checkout@v6 - name: Install UBSan runtime (musl) if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl' }} shell: bash run: | set -euo pipefail if command -v apt-get >/dev/null 2>&1; then sudo apt-get update -y sudo DEBIAN_FRONTEND=noninteractive apt-get install -y libubsan1 fi - uses: dtolnay/rust-toolchain@1.92 with: targets: ${{ matrix.target }} - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} 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" - uses: actions/cache@v5 with: path: | ~/.cargo/bin/ ~/.cargo/registry/index/ ~/.cargo/registry/cache/ ~/.cargo/git/db/ ${{ github.workspace }}/.cargo-home/bin/ ${{ github.workspace }}/.cargo-home/registry/index/ ${{ github.workspace }}/.cargo-home/registry/cache/ ${{ github.workspace }}/.cargo-home/git/db/ ${{ github.workspace }}/codex-rs/target/ key: cargo-${{ matrix.runner }}-${{ matrix.target }}-release-${{ hashFiles('**/Cargo.lock') }} - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} name: Install Zig uses: mlugg/setup-zig@v2 with: version: 0.14.0 - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} name: Install musl build tools env: TARGET: ${{ matrix.target }} run: bash "${GITHUB_WORKSPACE}/.github/scripts/install-musl-build-tools.sh" - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} name: Configure rustc UBSan wrapper (musl host) shell: bash run: | set -euo pipefail ubsan="" if command -v ldconfig >/dev/null 2>&1; then ubsan="$(ldconfig -p | grep -m1 'libubsan\.so\.1' | sed -E 's/.*=> (.*)$/\1/')" fi wrapper_root="${RUNNER_TEMP:-/tmp}" wrapper="${wrapper_root}/rustc-ubsan-wrapper" cat > "${wrapper}" <> "$GITHUB_ENV" echo "RUSTC_WORKSPACE_WRAPPER=" >> "$GITHUB_ENV" - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} name: Clear sanitizer flags (musl) shell: bash run: | set -euo pipefail # Clear global Rust flags so host/proc-macro builds don't pull in UBSan. echo "RUSTFLAGS=" >> "$GITHUB_ENV" echo "CARGO_ENCODED_RUSTFLAGS=" >> "$GITHUB_ENV" echo "RUSTDOCFLAGS=" >> "$GITHUB_ENV" # Override any runner-level Cargo config rustflags as well. echo "CARGO_BUILD_RUSTFLAGS=" >> "$GITHUB_ENV" echo "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS=" >> "$GITHUB_ENV" echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_RUSTFLAGS=" >> "$GITHUB_ENV" echo "CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_RUSTFLAGS=" >> "$GITHUB_ENV" echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_RUSTFLAGS=" >> "$GITHUB_ENV" sanitize_flags() { local input="$1" input="${input//-fsanitize=undefined/}" input="${input//-fno-sanitize-recover=undefined/}" input="${input//-fno-sanitize-trap=undefined/}" echo "$input" } cflags="$(sanitize_flags "${CFLAGS-}")" cxxflags="$(sanitize_flags "${CXXFLAGS-}")" echo "CFLAGS=${cflags}" >> "$GITHUB_ENV" echo "CXXFLAGS=${cxxflags}" >> "$GITHUB_ENV" - name: Cargo build shell: bash run: | if [[ "${{ contains(matrix.target, 'windows') }}" == 'true' ]]; then cargo build --target ${{ matrix.target }} --release --bin codex --bin codex-responses-api-proxy --bin codex-windows-sandbox-setup --bin codex-command-runner else cargo build --target ${{ matrix.target }} --release --bin codex --bin codex-responses-api-proxy fi - 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 - if: ${{ contains(matrix.target, 'windows') }} name: Sign Windows binaries with Azure Trusted Signing uses: ./.github/actions/windows-code-sign with: target: ${{ matrix.target }} client-id: ${{ secrets.AZURE_TRUSTED_SIGNING_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TRUSTED_SIGNING_TENANT_ID }} subscription-id: ${{ secrets.AZURE_TRUSTED_SIGNING_SUBSCRIPTION_ID }} endpoint: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} account-name: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} certificate-profile-name: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} - if: ${{ runner.os == 'macOS' }} name: MacOS code signing (binaries) uses: ./.github/actions/macos-code-sign with: target: ${{ matrix.target }} sign-binaries: "true" sign-dmg: "false" apple-certificate: ${{ secrets.APPLE_CERTIFICATE_P12 }} apple-certificate-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} apple-notarization-key-p8: ${{ secrets.APPLE_NOTARIZATION_KEY_P8 }} apple-notarization-key-id: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} apple-notarization-issuer-id: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} - if: ${{ runner.os == 'macOS' }} name: Build macOS dmg shell: bash run: | set -euo pipefail target="${{ matrix.target }}" release_dir="target/${target}/release" dmg_root="${RUNNER_TEMP}/codex-dmg-root" volname="Codex (${target})" dmg_path="${release_dir}/codex-${target}.dmg" # The previous "MacOS code signing (binaries)" step signs + notarizes the # built artifacts in `${release_dir}`. This step packages *those same* # signed binaries into a dmg. codex_binary_path="${release_dir}/codex" proxy_binary_path="${release_dir}/codex-responses-api-proxy" rm -rf "$dmg_root" mkdir -p "$dmg_root" if [[ ! -f "$codex_binary_path" ]]; then echo "Binary $codex_binary_path not found" exit 1 fi if [[ ! -f "$proxy_binary_path" ]]; then echo "Binary $proxy_binary_path not found" exit 1 fi ditto "$codex_binary_path" "${dmg_root}/codex" ditto "$proxy_binary_path" "${dmg_root}/codex-responses-api-proxy" 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 - if: ${{ runner.os == 'macOS' }} name: MacOS code signing (dmg) uses: ./.github/actions/macos-code-sign with: target: ${{ matrix.target }} sign-binaries: "false" sign-dmg: "true" apple-certificate: ${{ secrets.APPLE_CERTIFICATE_P12 }} apple-certificate-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} apple-notarization-key-p8: ${{ secrets.APPLE_NOTARIZATION_KEY_P8 }} apple-notarization-key-id: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} apple-notarization-issuer-id: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} - name: Stage artifacts shell: bash run: | dest="dist/${{ matrix.target }}" mkdir -p "$dest" if [[ "${{ matrix.runner }}" == windows* ]]; then cp target/${{ matrix.target }}/release/codex.exe "$dest/codex-${{ matrix.target }}.exe" cp target/${{ matrix.target }}/release/codex-responses-api-proxy.exe "$dest/codex-responses-api-proxy-${{ matrix.target }}.exe" cp target/${{ matrix.target }}/release/codex-windows-sandbox-setup.exe "$dest/codex-windows-sandbox-setup-${{ matrix.target }}.exe" cp target/${{ matrix.target }}/release/codex-command-runner.exe "$dest/codex-command-runner-${{ matrix.target }}.exe" else cp target/${{ matrix.target }}/release/codex "$dest/codex-${{ matrix.target }}" cp target/${{ matrix.target }}/release/codex-responses-api-proxy "$dest/codex-responses-api-proxy-${{ matrix.target }}" fi if [[ "${{ matrix.target }}" == *linux* ]]; then cp target/${{ matrix.target }}/release/codex.sigstore "$dest/codex-${{ matrix.target }}.sigstore" cp target/${{ matrix.target }}/release/codex-responses-api-proxy.sigstore "$dest/codex-responses-api-proxy-${{ matrix.target }}.sigstore" fi if [[ "${{ matrix.target }}" == *apple-darwin ]]; then cp target/${{ matrix.target }}/release/codex-${{ matrix.target }}.dmg "$dest/codex-${{ matrix.target }}.dmg" fi - if: ${{ matrix.runner == 'windows-11-arm' }} name: Install zstd shell: powershell run: choco install -y zstandard - name: Compress artifacts shell: bash run: | # Path that contains the uncompressed binaries for the current # ${{ matrix.target }} dest="dist/${{ matrix.target }}" repo_root=$PWD # We want to ship the raw Windows executables in the GitHub Release # in addition to the compressed archives. Keep the originals for # Windows targets; remove them elsewhere to limit the number of # artifacts that end up in the GitHub Release. keep_originals=false if [[ "${{ matrix.runner }}" == windows* ]]; then keep_originals=true fi # For compatibility with environments that lack the `zstd` tool we # additionally create a `.tar.gz` for all platforms and `.zip` for # Windows alongside every single binary that we publish. The end result is: # codex-.zst (existing) # codex-.tar.gz (new) # codex-.zip (only for Windows) # 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" == *.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" # Create zip archive for Windows binaries # Must run from inside the dest dir so 7z won't # embed the directory path inside the zip. if [[ "${{ matrix.runner }}" == windows* ]]; then if [[ "$base" == "codex-${{ matrix.target }}.exe" ]]; then # Bundle the sandbox helper binaries into the main codex zip so # WinGet installs include the required helpers next to codex.exe. # Fall back to the single-binary zip if the helpers are missing # to avoid breaking releases. bundle_dir="$(mktemp -d)" runner_src="$dest/codex-command-runner-${{ matrix.target }}.exe" setup_src="$dest/codex-windows-sandbox-setup-${{ matrix.target }}.exe" if [[ -f "$runner_src" && -f "$setup_src" ]]; then cp "$dest/$base" "$bundle_dir/$base" cp "$runner_src" "$bundle_dir/codex-command-runner.exe" cp "$setup_src" "$bundle_dir/codex-windows-sandbox-setup.exe" # Use an absolute path so bundle zips land in the real dist # dir even when 7z runs from a temp directory. (cd "$bundle_dir" && 7z a "$repo_root/$dest/${base}.zip" .) else echo "warning: missing sandbox binaries; falling back to single-binary zip" echo "warning: expected $runner_src and $setup_src" (cd "$dest" && 7z a "${base}.zip" "$base") fi rm -rf "$bundle_dir" else (cd "$dest" && 7z a "${base}.zip" "$base") fi fi # Also create .zst (existing behaviour) *and* remove the original # uncompressed binary to keep the directory small. zstd_args=(-T0 -19) if [[ "${keep_originals}" == false ]]; then zstd_args+=(--rm) fi zstd "${zstd_args[@]}" "$dest/$base" done - uses: actions/upload-artifact@v6 with: name: ${{ matrix.target }} # Upload the per-binary .zst files as well as the new .tar.gz # equivalents we generated in the previous step. path: | codex-rs/dist/${{ matrix.target }}/* shell-tool-mcp: name: shell-tool-mcp needs: tag-check uses: ./.github/workflows/shell-tool-mcp.yml with: release-tag: ${{ github.ref_name }} publish: true secrets: inherit release: needs: - build - shell-tool-mcp name: release runs-on: ubuntu-latest permissions: contents: write actions: read outputs: version: ${{ steps.release_name.outputs.name }} tag: ${{ github.ref_name }} 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@v6 - 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). git log -1 --format=%B "${commit}" > "${notes_path}" # Ensure trailing newline so GitHub's markdown renderer doesn't # occasionally run the last line into subsequent content. echo >> "${notes_path}" echo "path=${notes_path}" >> "${GITHUB_OUTPUT}" - uses: actions/download-artifact@v7 with: path: dist - name: List run: ls -R dist/ # This is a temporary fix: we should modify shell-tool-mcp.yml so these # files do not end up in dist/ in the first place. - name: Delete entries from dist/ that should not go in the release run: | rm -rf dist/shell-tool-mcp* ls -R dist/ - 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 run: | # 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 - 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]+$ ]]; 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: Setup pnpm uses: pnpm/action-setup@v4 with: run_install: false - name: Setup Node.js for npm packaging uses: actions/setup-node@v6 with: node-version: 22 - name: Install dependencies run: pnpm install --frozen-lockfile # stage_npm_packages.py requires DotSlash when staging releases. - uses: facebook/install-dotslash@v2 - name: Stage npm packages env: GH_TOKEN: ${{ github.token }} run: | ./scripts/stage_npm_packages.py \ --release-version "${{ steps.release_name.outputs.name }}" \ --package codex \ --package codex-responses-api-proxy \ --package codex-sdk - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: name: ${{ steps.release_name.outputs.name }} tag_name: ${{ github.ref_name }} body_path: ${{ steps.release_notes.outputs.path }} files: dist/** # Mark as prerelease only when the version has a suffix after x.y.z # (e.g. -alpha, -beta). Otherwise publish a normal release. prerelease: ${{ contains(steps.release_name.outputs.name, '-') }} - uses: facebook/dotslash-publish-release@v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: tag: ${{ github.ref_name }} config: .github/dotslash-config.json - name: Trigger developers.openai.com deploy # Only trigger the deploy if the release is not a pre-release. # The deploy is used to update the developers.openai.com website with the new config schema json file. if: ${{ !contains(steps.release_name.outputs.name, '-') }} 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 # 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: # Publish to npm for stable releases and alpha pre-releases with numeric suffixes. if: ${{ 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: Setup Node.js uses: actions/setup-node@v6 with: node-version: 22 registry-url: "https://registry.npmjs.org" scope: "@openai" # Trusted publishing requires npm CLI version 11.5.1 or later. - name: Update npm run: npm install -g npm@latest - name: Download npm tarballs from release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail version="${{ needs.release.outputs.version }}" tag="${{ needs.release.outputs.tag }}" mkdir -p dist/npm gh release download "$tag" \ --repo "${GITHUB_REPOSITORY}" \ --pattern "codex-npm-${version}.tgz" \ --dir dist/npm gh release download "$tag" \ --repo "${GITHUB_REPOSITORY}" \ --pattern "codex-responses-api-proxy-npm-${version}.tgz" \ --dir dist/npm gh release download "$tag" \ --repo "${GITHUB_REPOSITORY}" \ --pattern "codex-sdk-npm-${version}.tgz" \ --dir dist/npm # 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 }} run: | set -euo pipefail tag_args=() if [[ -n "${NPM_TAG}" ]]; then tag_args+=(--tag "${NPM_TAG}") fi tarballs=( "codex-npm-${VERSION}.tgz" "codex-responses-api-proxy-npm-${VERSION}.tgz" "codex-sdk-npm-${VERSION}.tgz" ) for tarball in "${tarballs[@]}"; do npm publish "${GITHUB_WORKSPACE}/dist/npm/${tarball}" "${tag_args[@]}" done update-branch: name: Update latest-alpha-cli branch permissions: contents: write needs: release runs-on: ubuntu-latest steps: - name: Update latest-alpha-cli branch 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