# 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: branches: - "**" tags: - "rust-v*.*.*" # DO NOT SUBMIT pull_request: {} concurrency: group: ${{ github.workflow }} cancel-in-progress: true jobs: tag-check: if: github.event_name != 'pull_request' runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - 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::" build: # needs: tag-check if: ${{ github.event_name == 'pull_request' || needs.tag-check.result == 'success' || needs.tag-check.result == 'skipped' }} name: Build - ${{ matrix.runner }} - ${{ matrix.target }} runs-on: ${{ matrix.runner }} timeout-minutes: 30 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 - uses: dtolnay/rust-toolchain@1.90 with: targets: ${{ matrix.target }} - uses: actions/cache@v4 with: path: | ~/.cargo/bin/ ~/.cargo/registry/index/ ~/.cargo/registry/cache/ ~/.cargo/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 musl build tools run: | sudo apt-get update sudo apt-get install -y musl-tools pkg-config - name: Cargo build run: cargo build --target ${{ matrix.target }} --release --bin codex --bin codex-responses-api-proxy - if: ${{ contains(matrix.target, 'linux') }} name: Install cosign uses: sigstore/cosign-installer@v3.7.0 - if: ${{ contains(matrix.target, 'linux') }} name: Cosign Linux artifacts shell: bash env: COSIGN_EXPERIMENTAL: "1" COSIGN_YES: "true" COSIGN_OIDC_CLIENT_ID: "sigstore" COSIGN_OIDC_ISSUER: "https://oauth2.sigstore.dev/auth" run: | set -euo pipefail dest="dist/${{ matrix.target }}" if [[ ! -d "$dest" ]]; then echo "Destination $dest does not exist" exit 1 fi shopt -s nullglob for artifact in "$dest"/*; do if [[ -f "$artifact" ]]; then cosign sign-blob \ --yes \ --output-signature "${artifact}.sig" \ --output-certificate "${artifact}.pem" \ "$artifact" fi done - if: ${{ contains(matrix.target, 'windows') }} name: Azure login for Trusted Signing (OIDC) uses: azure/login@v2 with: client-id: ${{ secrets.AZURE_TRUSTED_SIGNING_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TRUSTED_SIGNING_TENANT_ID }} subscription-id: ${{ secrets.AZURE_TRUSTED_SIGNING_SUBSCRIPTION_ID }} - if: ${{ contains(matrix.target, 'windows') }} name: Sign Windows binaries with Azure Trusted Signing uses: azure/trusted-signing-action@v0 with: endpoint: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} trusted-signing-account-name: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} certificate-profile-name: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }} exclude-environment-credential: true exclude-workload-identity-credential: true exclude-managed-identity-credential: true exclude-shared-token-cache-credential: true exclude-visual-studio-credential: true exclude-visual-studio-code-credential: true exclude-azure-cli-credential: false exclude-azure-powershell-credential: true exclude-azure-developer-cli-credential: true exclude-interactive-browser-credential: true files: | ${{ github.workspace }}/codex-rs/target/${{ matrix.target }}/release/codex.exe ${{ github.workspace }}/codex-rs/target/${{ matrix.target }}/release/codex-responses-api-proxy.exe - if: ${{ matrix.runner == 'macos-15-xlarge' }} name: Configure Apple code signing shell: bash env: KEYCHAIN_PASSWORD: actions APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE_P12 }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} run: | set -euo pipefail if [[ -z "${APPLE_CERTIFICATE:-}" ]]; then echo "APPLE_CERTIFICATE is required for macOS signing" exit 1 fi if [[ -z "${APPLE_CERTIFICATE_PASSWORD:-}" ]]; then echo "APPLE_CERTIFICATE_PASSWORD is required for macOS signing" exit 1 fi cert_path="${RUNNER_TEMP}/apple_signing_certificate.p12" echo "$APPLE_CERTIFICATE" | base64 -d > "$cert_path" keychain_path="${RUNNER_TEMP}/codex-signing.keychain-db" security create-keychain -p "$KEYCHAIN_PASSWORD" "$keychain_path" security set-keychain-settings -lut 21600 "$keychain_path" security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$keychain_path" keychain_args=() cleanup_keychain() { if ((${#keychain_args[@]} > 0)); then security list-keychains -s "${keychain_args[@]}" || true security default-keychain -s "${keychain_args[0]}" || true else security list-keychains -s || true fi if [[ -f "$keychain_path" ]]; then security delete-keychain "$keychain_path" || true fi } while IFS= read -r keychain; do [[ -n "$keychain" ]] && keychain_args+=("$keychain") done < <(security list-keychains | sed 's/^[[:space:]]*//;s/[[:space:]]*$//;s/"//g') if ((${#keychain_args[@]} > 0)); then security list-keychains -s "$keychain_path" "${keychain_args[@]}" else security list-keychains -s "$keychain_path" fi security default-keychain -s "$keychain_path" security import "$cert_path" -k "$keychain_path" -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$keychain_path" > /dev/null codesign_hashes=() while IFS= read -r hash; do [[ -n "$hash" ]] && codesign_hashes+=("$hash") done < <(security find-identity -v -p codesigning "$keychain_path" \ | sed -n 's/.*\([0-9A-F]\{40\}\).*/\1/p' \ | sort -u) if ((${#codesign_hashes[@]} == 0)); then echo "No signing identities found in $keychain_path" cleanup_keychain rm -f "$cert_path" exit 1 fi if ((${#codesign_hashes[@]} > 1)); then echo "Multiple signing identities found in $keychain_path:" printf ' %s\n' "${codesign_hashes[@]}" cleanup_keychain rm -f "$cert_path" exit 1 fi APPLE_CODESIGN_IDENTITY="${codesign_hashes[0]}" rm -f "$cert_path" echo "APPLE_CODESIGN_IDENTITY=$APPLE_CODESIGN_IDENTITY" >> "$GITHUB_ENV" echo "APPLE_CODESIGN_KEYCHAIN=$keychain_path" >> "$GITHUB_ENV" echo "::add-mask::$APPLE_CODESIGN_IDENTITY" - if: ${{ matrix.runner == 'macos-15-xlarge' }} name: Sign macOS binaries shell: bash run: | set -euo pipefail if [[ -z "${APPLE_CODESIGN_IDENTITY:-}" ]]; then echo "APPLE_CODESIGN_IDENTITY is required for macOS signing" exit 1 fi keychain_args=() if [[ -n "${APPLE_CODESIGN_KEYCHAIN:-}" && -f "${APPLE_CODESIGN_KEYCHAIN}" ]]; then keychain_args+=(--keychain "${APPLE_CODESIGN_KEYCHAIN}") fi for binary in codex codex-responses-api-proxy; do path="target/${{ matrix.target }}/release/${binary}" codesign --force --options runtime --timestamp --sign "$APPLE_CODESIGN_IDENTITY" "${keychain_args[@]}" "$path" done - if: ${{ matrix.runner == 'macos-15-xlarge' }} name: Notarize macOS binaries shell: bash env: 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 }} run: | set -euo pipefail for var in APPLE_NOTARIZATION_KEY_P8 APPLE_NOTARIZATION_KEY_ID APPLE_NOTARIZATION_ISSUER_ID; do if [[ -z "${!var:-}" ]]; then echo "$var is required for notarization" exit 1 fi done notary_key_path="${RUNNER_TEMP}/notarytool.key.p8" echo "$APPLE_NOTARIZATION_KEY_P8" | base64 -d > "$notary_key_path" cleanup_notary() { rm -f "$notary_key_path" } trap cleanup_notary EXIT notarize_binary() { local binary="$1" local source_path="target/${{ matrix.target }}/release/${binary}" local archive_path="${RUNNER_TEMP}/${binary}.zip" if [[ ! -f "$source_path" ]]; then echo "Binary $source_path not found" exit 1 fi rm -f "$archive_path" ditto -c -k --keepParent "$source_path" "$archive_path" submission_json=$(xcrun notarytool submit "$archive_path" \ --key "$notary_key_path" \ --key-id "$APPLE_NOTARIZATION_KEY_ID" \ --issuer "$APPLE_NOTARIZATION_ISSUER_ID" \ --output-format json \ --wait) status=$(printf '%s\n' "$submission_json" | jq -r '.status // "Unknown"') submission_id=$(printf '%s\n' "$submission_json" | jq -r '.id // ""') if [[ -z "$submission_id" ]]; then echo "Failed to retrieve submission ID for $binary" exit 1 fi echo "::notice title=Notarization::$binary submission ${submission_id} completed with status ${status}" if [[ "$status" != "Accepted" ]]; then echo "Notarization failed for ${binary} (submission ${submission_id}, status ${status})" exit 1 fi } notarize_binary "codex" notarize_binary "codex-responses-api-proxy" - 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" 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.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 }}" # 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 ]]; 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 (cd "$dest" && 7z a "${base}.zip" "$base") 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 - name: Remove signing keychain if: ${{ always() && matrix.runner == 'macos-15-xlarge' }} shell: bash env: APPLE_CODESIGN_KEYCHAIN: ${{ env.APPLE_CODESIGN_KEYCHAIN }} run: | set -euo pipefail if [[ -n "${APPLE_CODESIGN_KEYCHAIN:-}" ]]; then keychain_args=() while IFS= read -r keychain; do [[ "$keychain" == "$APPLE_CODESIGN_KEYCHAIN" ]] && continue [[ -n "$keychain" ]] && keychain_args+=("$keychain") done < <(security list-keychains | sed 's/^[[:space:]]*//;s/[[:space:]]*$//;s/"//g') if ((${#keychain_args[@]} > 0)); then security list-keychains -s "${keychain_args[@]}" security default-keychain -s "${keychain_args[0]}" fi if [[ -f "$APPLE_CODESIGN_KEYCHAIN" ]]; then security delete-keychain "$APPLE_CODESIGN_KEYCHAIN" fi fi - uses: actions/upload-artifact@v5 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 # - uses: actions/download-artifact@v4 # 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: 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@v5 # 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 }} # 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 # # 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@v5 # 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