Mirror Codex App Installers #10047
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Mirror Codex App Installers | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| release_tag: | |
| description: Optional release tag. Defaults to a version-derived tag. | |
| required: false | |
| type: string | |
| force_release: | |
| description: Publish even when the latest release already matches current sources. | |
| required: false | |
| default: false | |
| type: boolean | |
| schedule: | |
| - cron: "11 */6 * * *" | |
| permissions: | |
| contents: read | |
| env: | |
| R2_BUCKET_NAME: codex-app-mirror | |
| R2_PUBLIC_BASE_URL: https://codexapp.agentsmirror.com | |
| concurrency: | |
| group: codex-app-mirror | |
| cancel-in-progress: false | |
| jobs: | |
| probe: | |
| name: Probe current versions | |
| runs-on: ubuntu-latest | |
| outputs: | |
| should_release: ${{ steps.probe.outputs.should_release }} | |
| release_tag: ${{ steps.probe.outputs.release_tag }} | |
| latest_tag: ${{ steps.probe.outputs.latest_tag }} | |
| skip_reason: ${{ steps.probe.outputs.skip_reason }} | |
| version_summary: ${{ steps.probe.outputs.version_summary }} | |
| manifest: ${{ steps.probe.outputs.manifest }} | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: actions/setup-dotnet@v5 | |
| with: | |
| dotnet-version: 10.0.x | |
| - name: Probe sources | |
| id: probe | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| GH_REPO: ${{ github.repository }} | |
| FORCE_RELEASE: ${{ inputs.force_release || 'false' }} | |
| RELEASE_TAG: ${{ inputs.release_tag }} | |
| run: bash scripts/probe-release.sh | |
| macos: | |
| name: Download macOS installers | |
| runs-on: macos-latest | |
| needs: probe | |
| if: needs.probe.outputs.should_release == 'true' | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Download macOS DMGs | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| cat > probe-manifest.json <<'JSON' | |
| ${{ needs.probe.outputs.manifest }} | |
| JSON | |
| bash scripts/download-macos.sh dist/macos probe-manifest.json | |
| - name: Read macOS app metadata | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| arm_zip_basename="$(jq -r '.sources.macos.arm64.appcast.mirrorEnclosureBasename // empty' probe-manifest.json)" | |
| x64_zip_basename="$(jq -r '.sources.macos.x64.appcast.mirrorEnclosureBasename // empty' probe-manifest.json)" | |
| test -n "$arm_zip_basename" | |
| test -n "$x64_zip_basename" | |
| bash scripts/read-macos-metadata.sh \ | |
| dist/macos/macos-metadata.json \ | |
| dist/macos/Codex-mac-arm64.dmg \ | |
| dist/macos/Codex-mac-x64.dmg \ | |
| "dist/macos/$arm_zip_basename" \ | |
| "dist/macos/$x64_zip_basename" \ | |
| dist/macos-x64-backend-input | |
| - name: Upload macOS Intel backend input | |
| continue-on-error: true | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: codex-macos-x64-backend-input | |
| path: dist/macos-x64-backend-input/* | |
| if-no-files-found: error | |
| retention-days: 1 | |
| - name: Upload macOS artifacts | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: codex-macos | |
| path: dist/macos/* | |
| if-no-files-found: error | |
| retention-days: 1 | |
| windows: | |
| name: Download Windows installer | |
| runs-on: windows-latest | |
| needs: probe | |
| if: needs.probe.outputs.should_release == 'true' | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Download Windows MSIX | |
| shell: pwsh | |
| run: | | |
| @' | |
| ${{ needs.probe.outputs.manifest }} | |
| '@ | Set-Content -Encoding utf8 -Path probe-manifest.json | |
| ./scripts/download-windows.ps1 -OutDir dist/windows -ManifestPath probe-manifest.json | |
| - name: Read Windows x64 backend metadata | |
| shell: pwsh | |
| run: | | |
| $msix = Get-ChildItem -LiteralPath dist/windows -Filter '*_x64__*.Msix' | Select-Object -First 1 | |
| if ($null -eq $msix) { | |
| throw 'Missing Windows x64 MSIX.' | |
| } | |
| $msixPath = $msix.FullName | |
| $metadataPath = 'dist/windows/windows-backend-x64.json' | |
| try { | |
| $metadata = python scripts/read-codex-backend-version.py --json --platform windows --architecture x64 "$msixPath" | |
| if ($LASTEXITCODE -ne 0) { | |
| throw "Backend reader exited with code $LASTEXITCODE." | |
| } | |
| $metadataText = $metadata -join [Environment]::NewLine | |
| $null = ConvertFrom-Json -InputObject $metadataText -ErrorAction Stop | |
| $metadataText | Set-Content -Encoding utf8 -Path $metadataPath | |
| } | |
| catch { | |
| Write-Warning "Could not read Windows x64 backend metadata: $($_.Exception.Message)" | |
| '{"architecture":"x64","platform":"windows","status":"unavailable"}' | | |
| Set-Content -Encoding utf8 -Path $metadataPath | |
| } | |
| - name: Prepare Windows ARM64 backend input | |
| shell: pwsh | |
| run: | | |
| $inputDir = 'dist/windows-arm64-backend-input' | |
| $inputManifestPath = Join-Path $inputDir 'backend-input.json' | |
| New-Item -ItemType Directory -Force -Path $inputDir | Out-Null | |
| $writeUnavailable = { | |
| Remove-Item -LiteralPath (Join-Path $inputDir 'codex.exe') -Force -ErrorAction SilentlyContinue | |
| Remove-Item -LiteralPath (Join-Path $inputDir 'codex') -Force -ErrorAction SilentlyContinue | |
| '{"architecture":"arm64","platform":"windows","schemaVersion":1,"status":"unavailable"}' | | |
| Set-Content -Encoding utf8 -Path $inputManifestPath | |
| } | |
| $msix = Get-ChildItem -LiteralPath dist/windows -Filter '*_arm64__*.Msix' | Select-Object -First 1 | |
| if ($null -eq $msix) { | |
| & $writeUnavailable | |
| return | |
| } | |
| try { | |
| $sourceSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $msix.FullName).Hash.ToLowerInvariant() | |
| $prepareOutput = python scripts/read-codex-backend-version.py ` | |
| --prepare-input-dir $inputDir ` | |
| --source-package $msix.FullName ` | |
| --source-package-sha256 $sourceSha256 ` | |
| --platform windows ` | |
| --architecture arm64 ` | |
| $msix.FullName | |
| if ($LASTEXITCODE -ne 0) { | |
| throw "Backend input preparation exited with code $LASTEXITCODE." | |
| } | |
| $inputManifest = Get-Content -Raw -LiteralPath $inputManifestPath | ConvertFrom-Json -ErrorAction Stop | |
| if ($inputManifest.status -notin @('ready', 'unavailable')) { | |
| throw 'Backend input manifest has an invalid status.' | |
| } | |
| $prepareOutput | Write-Host | |
| } | |
| catch { | |
| Write-Warning "Could not prepare Windows ARM64 backend input: $($_.Exception.Message)" | |
| & $writeUnavailable | |
| } | |
| - name: Upload Windows ARM64 backend input | |
| continue-on-error: true | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: codex-windows-arm64-backend-input | |
| path: dist/windows-arm64-backend-input/* | |
| if-no-files-found: error | |
| retention-days: 1 | |
| - name: Upload Windows artifact | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: codex-windows | |
| path: dist/windows/* | |
| if-no-files-found: error | |
| retention-days: 1 | |
| windows_arm64_backend: | |
| name: Read Windows ARM64 backend metadata | |
| runs-on: windows-11-arm | |
| continue-on-error: true | |
| timeout-minutes: 30 | |
| needs: | |
| - probe | |
| - windows | |
| if: needs.probe.outputs.should_release == 'true' | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: actions/download-artifact@v8 | |
| with: | |
| name: codex-windows-arm64-backend-input | |
| path: dist/windows-arm64-backend-input | |
| - name: Read Windows ARM64 backend metadata | |
| shell: pwsh | |
| run: | | |
| New-Item -ItemType Directory -Force -Path dist/windows-arm64-backend | Out-Null | |
| $inputManifestPath = 'dist/windows-arm64-backend-input/backend-input.json' | |
| $metadataPath = 'dist/windows-arm64-backend/windows-backend-arm64.json' | |
| try { | |
| $metadata = python scripts/read-codex-backend-version.py ` | |
| --prepared-input ` | |
| --json ` | |
| --platform windows ` | |
| --architecture arm64 ` | |
| $inputManifestPath | |
| if ($LASTEXITCODE -ne 0) { | |
| throw "Backend reader exited with code $LASTEXITCODE." | |
| } | |
| $metadataText = $metadata -join [Environment]::NewLine | |
| $null = ConvertFrom-Json -InputObject $metadataText -ErrorAction Stop | |
| $metadataText | Set-Content -Encoding utf8 -Path $metadataPath | |
| } | |
| catch { | |
| Write-Warning "Could not read Windows ARM64 backend metadata: $($_.Exception.Message)" | |
| '{"architecture":"arm64","platform":"windows","status":"unavailable"}' | | |
| Set-Content -Encoding utf8 -Path $metadataPath | |
| } | |
| - uses: actions/upload-artifact@v7 | |
| with: | |
| name: codex-windows-arm64-backend | |
| path: dist/windows-arm64-backend/* | |
| if-no-files-found: error | |
| retention-days: 1 | |
| macos_x64_backend: | |
| name: Read macOS Intel backend metadata | |
| runs-on: macos-15-intel | |
| continue-on-error: true | |
| timeout-minutes: 30 | |
| needs: | |
| - probe | |
| - macos | |
| if: needs.probe.outputs.should_release == 'true' | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: actions/download-artifact@v8 | |
| with: | |
| name: codex-macos-x64-backend-input | |
| path: dist/macos-x64-backend-input | |
| - name: Read macOS Intel backend metadata | |
| run: bash scripts/read-macos-backend-metadata.sh dist/macos-x64-backend/macos-backend-x64.json x64 dist/macos-x64-backend-input/backend-input.json | |
| - uses: actions/upload-artifact@v7 | |
| with: | |
| name: codex-macos-x64-backend | |
| path: dist/macos-x64-backend/* | |
| if-no-files-found: error | |
| retention-days: 1 | |
| release: | |
| name: Publish release | |
| runs-on: ubuntu-latest | |
| if: always() && needs.probe.outputs.should_release == 'true' && needs.macos.result == 'success' && needs.windows.result == 'success' | |
| permissions: | |
| contents: write | |
| needs: | |
| - probe | |
| - macos | |
| - windows | |
| - windows_arm64_backend | |
| - macos_x64_backend | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Checkout agents-mirror-kit | |
| uses: actions/checkout@v6 | |
| with: | |
| repository: Wangnov/agents-mirror-kit | |
| ref: v0.2.0 | |
| path: .mirror-kit | |
| - name: Download macOS release artifacts | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: codex-macos | |
| path: artifacts/codex-macos | |
| - name: Download Windows release artifacts | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: codex-windows | |
| path: artifacts/codex-windows | |
| - name: Download Windows ARM64 backend metadata | |
| continue-on-error: true | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: codex-windows-arm64-backend | |
| path: artifacts/codex-windows-arm64-backend | |
| - name: Download macOS Intel backend metadata | |
| continue-on-error: true | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: codex-macos-x64-backend | |
| path: artifacts/codex-macos-x64-backend | |
| - name: Prepare release metadata | |
| id: meta | |
| env: | |
| RELEASE_TAG: ${{ needs.probe.outputs.release_tag }} | |
| INHERIT_LATEST_FROM_MIRROR: "true" | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| cat > probe-manifest.json <<'JSON' | |
| ${{ needs.probe.outputs.manifest }} | |
| JSON | |
| bash scripts/prepare-release-metadata.sh \ | |
| probe-manifest.json \ | |
| artifacts/codex-macos/macos-metadata.json \ | |
| artifacts \ | |
| "$R2_PUBLIC_BASE_URL" \ | |
| "$RELEASE_TAG" | |
| - name: Build macOS Sparkle appcasts | |
| if: steps.meta.outputs.sync_latest == 'true' | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| bash scripts/build-appcast.sh arm64 release-manifest.json "$R2_PUBLIC_BASE_URL" appcast.xml | |
| bash scripts/build-appcast.sh x64 release-manifest.json "$R2_PUBLIC_BASE_URL" appcast-x64.xml | |
| - name: Publish GitHub Release | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| GH_REPO: ${{ github.repository }} | |
| RELEASE_TAG: ${{ steps.meta.outputs.tag }} | |
| RELEASE_TITLE: ${{ steps.meta.outputs.title }} | |
| INCLUDE_WINDOWS: ${{ steps.meta.outputs.include_windows }} | |
| INCLUDE_MACOS: ${{ steps.meta.outputs.include_macos }} | |
| INCLUDE_WINDOWS_X64: ${{ steps.meta.outputs.include_windows_x64 }} | |
| INCLUDE_WINDOWS_ARM64: ${{ steps.meta.outputs.include_windows_arm64 }} | |
| INCLUDE_MACOS_ARM64: ${{ steps.meta.outputs.include_macos_arm64 }} | |
| INCLUDE_MACOS_X64: ${{ steps.meta.outputs.include_macos_x64 }} | |
| PRERELEASE: ${{ steps.meta.outputs.prerelease }} | |
| PUBLISH_LATEST: ${{ steps.meta.outputs.publish_latest }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| source scripts/github-api.sh | |
| release_assets=( | |
| assets/status.png | |
| release-manifest.json | |
| SHA256SUMS.txt | |
| ) | |
| desired_existing_asset_names=() | |
| required_existing_asset_names=() | |
| declare -A required_existing_asset_sha256=() | |
| add_macos_arch_assets() { | |
| local arch_key="$1" | |
| local dmg="$2" | |
| local zip_basename | |
| local zip | |
| local bn | |
| zip_basename="$(jq -r --arg a "$arch_key" '.sources.macos[$a].appcast.mirrorEnclosureBasename // empty' release-manifest.json)" | |
| test -n "$zip_basename" | |
| zip="artifacts/codex-macos/${zip_basename}" | |
| test -f "$dmg" | |
| test -f "$zip" | |
| release_assets+=("$dmg" "$zip") | |
| while IFS= read -r bn; do | |
| [[ -z "$bn" ]] && continue | |
| release_assets+=("artifacts/codex-macos/$bn") | |
| done < <( | |
| jq -r --arg a "$arch_key" '.sources.macos[$a].appcast.deltas[]?.basename // empty' release-manifest.json | |
| ) | |
| } | |
| if [[ "$INCLUDE_MACOS_ARM64" == "true" ]]; then | |
| add_macos_arch_assets arm64 artifacts/codex-macos/Codex-mac-arm64.dmg | |
| fi | |
| if [[ "$INCLUDE_MACOS_X64" == "true" ]]; then | |
| add_macos_arch_assets x64 artifacts/codex-macos/Codex-mac-x64.dmg | |
| fi | |
| if [[ "$INCLUDE_MACOS" == "true" ]]; then | |
| test -f artifacts/codex-macos/SHA256SUMS-macos.txt | |
| release_assets+=(artifacts/codex-macos/SHA256SUMS-macos.txt) | |
| fi | |
| if [[ "$INCLUDE_WINDOWS" == "true" ]]; then | |
| windows_assets=() | |
| if [[ "$INCLUDE_WINDOWS_X64" == "true" ]]; then | |
| win_x64_msix="$(find artifacts/codex-windows -maxdepth 1 -type f \( -name '*_x64__*.Msix' -o -name '*_x64__*.msix' \) | sort | head -n 1)" | |
| test -f "$win_x64_msix" | |
| windows_assets+=("$win_x64_msix") | |
| fi | |
| if [[ "$INCLUDE_WINDOWS_ARM64" == "true" ]]; then | |
| win_arm64_current_local="$(bash scripts/read-current-local-artifact.sh release-manifest.json arm64)" | |
| win_arm64_package="$(jq -r '.sources.windows.architectures.arm64.packageMoniker // empty' release-manifest.json)" | |
| if [[ "$win_arm64_current_local" == "true" ]]; then | |
| win_arm64_msix="$(find artifacts/codex-windows -maxdepth 1 -type f \( -name '*_arm64__*.Msix' -o -name '*_arm64__*.msix' \) | sort | head -n 1)" | |
| if [[ -z "$win_arm64_msix" || ! -f "$win_arm64_msix" ]]; then | |
| echo "release-manifest.json marks Windows ARM64 as a current local artifact, but the MSIX is missing from artifacts/codex-windows." >&2 | |
| exit 1 | |
| fi | |
| windows_assets+=("$win_arm64_msix") | |
| elif [[ -n "$win_arm64_package" ]]; then | |
| win_arm64_expected_sha="$(jq -r --arg name "${win_arm64_package}.Msix" '.derived.latestChecksums[$name] // empty' release-manifest.json)" | |
| if [[ -z "$win_arm64_expected_sha" ]]; then | |
| echo "Windows ARM64 is preserved but release-manifest.json has no latest checksum for ${win_arm64_package}.Msix." >&2 | |
| exit 1 | |
| fi | |
| desired_existing_asset_names+=("${win_arm64_package}.Msix") | |
| required_existing_asset_names+=("${win_arm64_package}.Msix") | |
| required_existing_asset_sha256["${win_arm64_package}.Msix"]="$win_arm64_expected_sha" | |
| else | |
| echo "Windows ARM64 is included but release-manifest.json has no packageMoniker." >&2 | |
| exit 1 | |
| fi | |
| fi | |
| test -f artifacts/codex-windows/SHA256SUMS-windows.txt | |
| windows_assets+=(artifacts/codex-windows/SHA256SUMS-windows.txt) | |
| release_assets+=("${windows_assets[@]}") | |
| fi | |
| if [[ "$INCLUDE_MACOS" != "true" && "$INCLUDE_WINDOWS" != "true" ]]; then | |
| echo "No platform assets were selected for release." >&2 | |
| exit 1 | |
| fi | |
| for asset in "${release_assets[@]}"; do | |
| if [[ ! -f "$asset" ]]; then | |
| echo "Missing selected GitHub Release asset: $asset" >&2 | |
| exit 1 | |
| fi | |
| done | |
| edit_args=( | |
| --title "$RELEASE_TITLE" | |
| --notes-file release-notes.md | |
| ) | |
| create_args=( | |
| --title "$RELEASE_TITLE" | |
| --notes-file release-notes.md | |
| ) | |
| if [[ "$PRERELEASE" == "true" ]]; then | |
| edit_args+=(--prerelease) | |
| create_args+=(--prerelease --latest=false) | |
| else | |
| edit_args+=(--prerelease=false --latest) | |
| create_args+=(--latest) | |
| fi | |
| release_lookup_status=0 | |
| if existing_assets_json="$(github_release_assets_json_allow_404 "$RELEASE_TAG")"; then | |
| desired_asset_names=() | |
| for asset in "${release_assets[@]}"; do | |
| desired_asset_names+=("$(basename "$asset")") | |
| done | |
| desired_asset_names+=("${desired_existing_asset_names[@]}") | |
| missing_assets=() | |
| replacement_assets=() | |
| mismatched_assets=() | |
| stale_optional_assets=() | |
| for asset in "${release_assets[@]}"; do | |
| name="$(basename "$asset")" | |
| local_size="$(wc -c < "$asset" | tr -d '[:space:]')" | |
| local_sha="$(sha256sum "$asset" | awk '{print $1}')" | |
| existing_size="$(jq -r --arg name "$name" '.[] | select(.name == $name) | .size' <<<"$existing_assets_json" | head -n 1)" | |
| existing_digest="$(jq -r --arg name "$name" '.[] | select(.name == $name) | .digest // ""' <<<"$existing_assets_json" | head -n 1)" | |
| existing_sha="${existing_digest#sha256:}" | |
| if [[ -z "$existing_size" || "$existing_size" == "null" ]]; then | |
| missing_assets+=("$asset") | |
| elif [[ "$existing_sha" == "$existing_digest" || -z "$existing_sha" ]]; then | |
| mismatched_assets+=("$name (release asset has no sha256 digest)") | |
| elif [[ "$existing_sha" != "$local_sha" ]]; then | |
| case "$name" in | |
| release-manifest.json|SHA256SUMS.txt|SHA256SUMS-macos.txt|SHA256SUMS-windows.txt|status.png) | |
| replacement_assets+=("$asset") | |
| ;; | |
| *) | |
| mismatched_assets+=("$name (release=$existing_size/$existing_sha local=$local_size/$local_sha)") | |
| ;; | |
| esac | |
| fi | |
| done | |
| for name in "${required_existing_asset_names[@]}"; do | |
| if ! jq -e --arg name "$name" 'any(.[]?; .name == $name)' <<<"$existing_assets_json" >/dev/null; then | |
| mismatched_assets+=("$name (preserved release asset is missing)") | |
| continue | |
| fi | |
| expected_sha="${required_existing_asset_sha256[$name]:-}" | |
| if [[ -n "$expected_sha" ]]; then | |
| existing_digest="$(jq -r --arg name "$name" '.[] | select(.name == $name) | .digest // ""' <<<"$existing_assets_json" | head -n 1)" | |
| existing_sha="${existing_digest#sha256:}" | |
| if [[ "$existing_sha" == "$existing_digest" || -z "$existing_sha" ]]; then | |
| mismatched_assets+=("$name (preserved release asset has no sha256 digest)") | |
| elif [[ "${existing_sha,,}" != "${expected_sha,,}" ]]; then | |
| mismatched_assets+=("$name (preserved release asset sha256 ${existing_sha,,} != manifest ${expected_sha,,})") | |
| fi | |
| fi | |
| done | |
| while IFS= read -r name; do | |
| [[ -z "$name" ]] && continue | |
| case "$name" in | |
| OpenAI.Codex_*_arm64__*.Msix|OpenAI.Codex_*_arm64__*.msix) | |
| if ! printf '%s\n' "${desired_asset_names[@]}" | grep -Fxq "$name"; then | |
| stale_optional_assets+=("$name") | |
| fi | |
| ;; | |
| esac | |
| done < <(jq -r '.[].name' <<<"$existing_assets_json") | |
| if [[ "${#mismatched_assets[@]}" -gt 0 ]]; then | |
| echo "Existing release assets differ from the newly built assets; refusing to overwrite them without operator review." >&2 | |
| printf ' %s\n' "${mismatched_assets[@]}" >&2 | |
| exit 1 | |
| fi | |
| gh release edit "$RELEASE_TAG" "${edit_args[@]}" | |
| if [[ "${#stale_optional_assets[@]}" -gt 0 ]]; then | |
| for name in "${stale_optional_assets[@]}"; do | |
| gh release delete-asset "$RELEASE_TAG" "$name" --yes | |
| done | |
| fi | |
| if [[ "${#missing_assets[@]}" -gt 0 ]]; then | |
| gh release upload "$RELEASE_TAG" "${missing_assets[@]}" | |
| fi | |
| if [[ "${#replacement_assets[@]}" -gt 0 ]]; then | |
| for asset in "${replacement_assets[@]}"; do | |
| gh release delete-asset "$RELEASE_TAG" "$(basename "$asset")" --yes | |
| gh release upload "$RELEASE_TAG" "$asset" | |
| done | |
| fi | |
| if [[ "${#missing_assets[@]}" -eq 0 && "${#replacement_assets[@]}" -eq 0 && "${#stale_optional_assets[@]}" -eq 0 ]]; then | |
| echo "All release assets already exist with matching SHA-256 digests." | |
| else | |
| echo "Release assets were reconciled without bulk clobbering existing downloads." | |
| fi | |
| else | |
| release_lookup_status=$? | |
| if [[ "$release_lookup_status" -ne "$GITHUB_API_NOT_FOUND_STATUS" ]]; then | |
| echo "Unable to determine whether GitHub Release $RELEASE_TAG exists; refusing to create it." >&2 | |
| exit "$release_lookup_status" | |
| fi | |
| if [[ "${#required_existing_asset_names[@]}" -gt 0 ]]; then | |
| echo "Release $RELEASE_TAG does not exist, but release-manifest.json depends on preserved assets that can only be reused from an existing Release:" >&2 | |
| printf ' %s\n' "${required_existing_asset_names[@]}" >&2 | |
| exit 1 | |
| fi | |
| gh release create "$RELEASE_TAG" "${create_args[@]}" "${release_assets[@]}" | |
| fi | |
| - name: Remove latest links from previous release notes | |
| if: steps.meta.outputs.sync_latest == 'true' && needs.probe.outputs.latest_tag != '' && needs.probe.outputs.latest_tag != steps.meta.outputs.tag | |
| continue-on-error: true | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| GH_REPO: ${{ github.repository }} | |
| LATEST_TAG: ${{ needs.probe.outputs.latest_tag }} | |
| shell: bash | |
| run: bash .mirror-kit/scripts/remove-latest-links-from-release-notes.sh "$LATEST_TAG" | |
| - name: Ensure AWS CLI | |
| if: steps.meta.outputs.sync_latest == 'true' | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| if ! command -v aws >/dev/null 2>&1; then | |
| python3 -m pip install --user awscli | |
| echo "$HOME/.local/bin" >> "$GITHUB_PATH" | |
| fi | |
| - name: Sync GitHub Release assets to Cloudflare R2 | |
| if: steps.meta.outputs.sync_latest == 'true' | |
| env: | |
| AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} | |
| AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} | |
| AWS_DEFAULT_REGION: auto | |
| R2_S3_ENDPOINT: https://${{ secrets.CLOUDFLARE_ACCOUNT_ID }}.r2.cloudflarestorage.com | |
| GH_TOKEN: ${{ github.token }} | |
| GH_REPO: ${{ github.repository }} | |
| RELEASE_TAG: ${{ steps.meta.outputs.tag }} | |
| LATEST_TAG: ${{ needs.probe.outputs.latest_tag }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| mac_arm64="artifacts/codex-macos/Codex-mac-arm64.dmg" | |
| mac_intel="artifacts/codex-macos/Codex-mac-x64.dmg" | |
| win_x64_msix="$(find artifacts/codex-windows -maxdepth 1 -type f \( -name '*_x64__*.Msix' -o -name '*_x64__*.msix' \) | sort | head -n 1)" | |
| win_arm64_msix="$(find artifacts/codex-windows -maxdepth 1 -type f \( -name '*_arm64__*.Msix' -o -name '*_arm64__*.msix' \) | sort | head -n 1 || true)" | |
| win_arm64_current_local="$(bash scripts/read-current-local-artifact.sh release-manifest.json arm64)" | |
| win_arm64_downloadable="$(jq -r '.sources.windows.architectures.arm64.downloadable // false' release-manifest.json)" | |
| win_arm64_package="$(jq -r '.sources.windows.architectures.arm64.packageMoniker // empty' release-manifest.json)" | |
| win_arm64_app_version="$(jq -r '.sources.windows.architectures.arm64.appVersion // empty' release-manifest.json)" | |
| win_arm64_expected_sha="" | |
| if [[ -n "$win_arm64_package" ]]; then | |
| win_arm64_expected_sha="$(jq -r --arg name "${win_arm64_package}.Msix" '.derived.latestChecksums[$name] // empty' release-manifest.json)" | |
| fi | |
| win_arm64_alias_source="$win_arm64_current_local" | |
| test -f "$mac_arm64" | |
| test -f "$mac_intel" | |
| test -f "$win_x64_msix" | |
| if [[ "$win_arm64_alias_source" != "true" && "$win_arm64_downloadable" == "true" && -n "$win_arm64_package" ]]; then | |
| if [[ -z "$win_arm64_expected_sha" ]]; then | |
| echo "Windows ARM64 is advertised by release-manifest.json but ${win_arm64_package}.Msix is missing from derived.latestChecksums." >&2 | |
| exit 1 | |
| fi | |
| restored_arm64_dir="artifacts/restored-windows-arm64" | |
| restored_arm64_asset="$restored_arm64_dir/${win_arm64_package}.Msix" | |
| restored_arm64=false | |
| mkdir -p "$restored_arm64_dir" | |
| if aws s3 cp "s3://$R2_BUCKET_NAME/latest/win-arm64" "$restored_arm64_asset" \ | |
| --endpoint-url "$R2_S3_ENDPOINT" \ | |
| --region "${AWS_DEFAULT_REGION:-auto}" \ | |
| --no-progress; then | |
| restored_arm64_sha="$(sha256sum "$restored_arm64_asset" | awk '{print tolower($1)}')" | |
| if [[ "$restored_arm64_sha" == "${win_arm64_expected_sha,,}" ]]; then | |
| echo "Preserving checksum-matching R2 latest/win-arm64 for ${win_arm64_package}.Msix." | |
| restored_arm64=true | |
| else | |
| echo "Existing R2 latest/win-arm64 sha256 $restored_arm64_sha != manifest ${win_arm64_expected_sha,,}; trying GitHub Release assets." >&2 | |
| rm -f "$restored_arm64_asset" | |
| fi | |
| fi | |
| if [[ "$restored_arm64" != "true" ]]; then | |
| for source_tag in "$RELEASE_TAG" "${LATEST_TAG:-}" "codex-app-${win_arm64_app_version}"; do | |
| [[ -n "$source_tag" && "$source_tag" != "codex-app-" ]] || continue | |
| rm -f "$restored_arm64_asset" | |
| if gh release download "$source_tag" \ | |
| --pattern "${win_arm64_package}.Msix" \ | |
| --dir "$restored_arm64_dir" \ | |
| --clobber; then | |
| if [[ -f "$restored_arm64_asset" ]]; then | |
| restored_arm64_sha="$(sha256sum "$restored_arm64_asset" | awk '{print tolower($1)}')" | |
| if [[ "$restored_arm64_sha" == "${win_arm64_expected_sha,,}" ]]; then | |
| restored_arm64=true | |
| break | |
| fi | |
| echo "Downloaded ${win_arm64_package}.Msix from $source_tag, but sha256 $restored_arm64_sha != manifest ${win_arm64_expected_sha,,}; trying another source." >&2 | |
| fi | |
| fi | |
| done | |
| if [[ "$restored_arm64" != "true" ]]; then | |
| echo "Windows ARM64 is advertised by release-manifest.json but no local, R2, or checksum-matching GitHub Release asset was found for ${win_arm64_package}.Msix." >&2 | |
| exit 1 | |
| fi | |
| fi | |
| win_arm64_msix="$restored_arm64_asset" | |
| win_arm64_alias_source=true | |
| fi | |
| # Sparkle update archives the generated appcasts point at. Their object | |
| # keys (latest/mac/{arm64,intel}/...) must match the enclosure URLs that | |
| # build-appcast.sh emits, or the client's downloads would 404. | |
| arm_zip_basename="$(jq -r '.sources.macos.arm64.appcast.mirrorEnclosureBasename // empty' release-manifest.json)" | |
| x64_zip_basename="$(jq -r '.sources.macos.x64.appcast.mirrorEnclosureBasename // empty' release-manifest.json)" | |
| test -n "$arm_zip_basename" | |
| test -n "$x64_zip_basename" | |
| mac_arm64_zip="artifacts/codex-macos/${arm_zip_basename}" | |
| mac_intel_zip="artifacts/codex-macos/${x64_zip_basename}" | |
| arm_zip_key="latest/mac/arm64/${arm_zip_basename}" | |
| intel_zip_key="latest/mac/intel/${x64_zip_basename}" | |
| test -f "$mac_arm64_zip" | |
| test -f "$mac_intel_zip" | |
| test -f appcast.xml | |
| test -f appcast-x64.xml | |
| # Sparkle delta archives (.delta) advertised under <sparkle:deltas>. | |
| # Their basenames come straight from release-manifest.json (the same | |
| # source build-appcast.sh used), and they were downloaded verbatim into | |
| # artifacts/codex-macos/. arm64 deltas key under latest/mac/arm64/ and | |
| # x64 deltas under latest/mac/intel/, matching the mirror enclosure URLs. | |
| # Listed into files (one basename per line) to avoid relying on bash | |
| # array semantics, consistent with the other mirror scripts. | |
| jq -r '.sources.macos.arm64.appcast.deltas[]?.basename // empty' release-manifest.json > /tmp/arm-delta-basenames.txt | |
| jq -r '.sources.macos.x64.appcast.deltas[]?.basename // empty' release-manifest.json > /tmp/x64-delta-basenames.txt | |
| while IFS= read -r bn; do | |
| [[ -z "$bn" ]] && continue | |
| test -f "artifacts/codex-macos/$bn" | |
| done < <(cat /tmp/arm-delta-basenames.txt /tmp/x64-delta-basenames.txt) | |
| staging_prefix="staging/${RELEASE_TAG}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" | |
| cleanup_staging() { | |
| bash .mirror-kit/scripts/clear-r2-prefix.sh "$R2_BUCKET_NAME" "$staging_prefix" || true | |
| } | |
| trap cleanup_staging EXIT | |
| upload_aliases() { | |
| local prefix="$1" | |
| bash .mirror-kit/scripts/sync-r2.sh --object "$R2_BUCKET_NAME" "$prefix/mac-arm64" "$mac_arm64" Codex-mac-arm64.dmg | |
| bash .mirror-kit/scripts/sync-r2.sh --object "$R2_BUCKET_NAME" "$prefix/mac-intel" "$mac_intel" Codex-mac-intel.dmg | |
| bash .mirror-kit/scripts/sync-r2.sh --object "$R2_BUCKET_NAME" "$prefix/win" "$win_x64_msix" Codex-Windows-x64.msix | |
| bash .mirror-kit/scripts/sync-r2.sh --object "$R2_BUCKET_NAME" "$prefix/win-x64" "$win_x64_msix" Codex-Windows-x64.msix | |
| if [[ "$win_arm64_alias_source" == "true" && -n "$win_arm64_msix" && -f "$win_arm64_msix" ]]; then | |
| bash .mirror-kit/scripts/sync-r2.sh --object "$R2_BUCKET_NAME" "$prefix/win-arm64" "$win_arm64_msix" Codex-Windows-arm64.msix | |
| fi | |
| bash .mirror-kit/scripts/sync-r2.sh --object "$R2_BUCKET_NAME" "$prefix/checksums" latest-SHA256SUMS.txt SHA256SUMS.txt | |
| bash .mirror-kit/scripts/sync-r2.sh --object "$R2_BUCKET_NAME" "$prefix/manifest" release-manifest.json release-manifest.json | |
| } | |
| # appcast feeds are small and change every release, so keep their CDN | |
| # cache short; the versioned Sparkle archives are immutable. | |
| upload_appcast() { | |
| local key="$1" | |
| local file="$2" | |
| R2_CACHE_CONTROL="public, max-age=600" \ | |
| bash .mirror-kit/scripts/sync-r2.sh --object "$R2_BUCKET_NAME" "$key" "$file" "$(basename "$file")" | |
| } | |
| upload_aliases "$staging_prefix" | |
| aws s3 cp "s3://$R2_BUCKET_NAME/$staging_prefix/manifest" /tmp/r2-staging-manifest.json \ | |
| --endpoint-url "$R2_S3_ENDPOINT" \ | |
| --region "${AWS_DEFAULT_REGION:-auto}" \ | |
| --no-progress | |
| cmp release-manifest.json /tmp/r2-staging-manifest.json | |
| bash .mirror-kit/scripts/sync-r2.sh --object "$R2_BUCKET_NAME" latest/mac-arm64 "$mac_arm64" Codex-mac-arm64.dmg | |
| bash .mirror-kit/scripts/sync-r2.sh --object "$R2_BUCKET_NAME" latest/mac-intel "$mac_intel" Codex-mac-intel.dmg | |
| bash .mirror-kit/scripts/sync-r2.sh --object "$R2_BUCKET_NAME" latest/win "$win_x64_msix" Codex-Windows-x64.msix | |
| bash .mirror-kit/scripts/sync-r2.sh --object "$R2_BUCKET_NAME" latest/win-x64 "$win_x64_msix" Codex-Windows-x64.msix | |
| if [[ "$win_arm64_alias_source" == "true" && -n "$win_arm64_msix" && -f "$win_arm64_msix" ]]; then | |
| bash .mirror-kit/scripts/sync-r2.sh --object "$R2_BUCKET_NAME" latest/win-arm64 "$win_arm64_msix" Codex-Windows-arm64.msix | |
| else | |
| echo "Removing stale R2 latest/win-arm64 because this manifest does not advertise a checksum-matching ARM64 package." | |
| aws s3 rm "s3://$R2_BUCKET_NAME/latest/win-arm64" \ | |
| --endpoint-url "$R2_S3_ENDPOINT" \ | |
| --region "${AWS_DEFAULT_REGION:-auto}" | |
| fi | |
| # Sparkle archives first, then the appcasts that reference them, so the | |
| # feed never advertises an enclosure that is not yet downloadable. The | |
| # full .zip and every .delta go up before the appcasts are published. | |
| bash .mirror-kit/scripts/sync-r2.sh --object "$R2_BUCKET_NAME" "$arm_zip_key" "$mac_arm64_zip" "$(basename "$mac_arm64_zip")" | |
| bash .mirror-kit/scripts/sync-r2.sh --object "$R2_BUCKET_NAME" "$intel_zip_key" "$mac_intel_zip" "$(basename "$mac_intel_zip")" | |
| while IFS= read -r bn; do | |
| [[ -z "$bn" ]] && continue | |
| bash .mirror-kit/scripts/sync-r2.sh --object "$R2_BUCKET_NAME" "latest/mac/arm64/$bn" "artifacts/codex-macos/$bn" "$bn" | |
| done < /tmp/arm-delta-basenames.txt | |
| while IFS= read -r bn; do | |
| [[ -z "$bn" ]] && continue | |
| bash .mirror-kit/scripts/sync-r2.sh --object "$R2_BUCKET_NAME" "latest/mac/intel/$bn" "artifacts/codex-macos/$bn" "$bn" | |
| done < /tmp/x64-delta-basenames.txt | |
| upload_appcast latest/appcast.xml appcast.xml | |
| upload_appcast latest/appcast-x64.xml appcast-x64.xml | |
| bash .mirror-kit/scripts/sync-r2.sh --object "$R2_BUCKET_NAME" latest/checksums latest-SHA256SUMS.txt SHA256SUMS.txt | |
| # Commit marker only: R2 has no multi-object transaction, so this does | |
| # not claim strict atomicity. Publishing the manifest last does ensure | |
| # it never advertises an archive or appcast that has not been uploaded. | |
| bash .mirror-kit/scripts/sync-r2.sh --object "$R2_BUCKET_NAME" latest/manifest release-manifest.json release-manifest.json | |
| curl -fsSL \ | |
| --retry 5 \ | |
| --retry-delay 2 \ | |
| --retry-all-errors \ | |
| --connect-timeout 20 \ | |
| --max-time 120 \ | |
| "$R2_PUBLIC_BASE_URL/latest/manifest?run=${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" \ | |
| -o /tmp/r2-public-manifest.json | |
| cmp release-manifest.json /tmp/r2-public-manifest.json | |
| # Read the appcasts back through the public CDN and diff against what we | |
| # generated. If R2 ever silently stops accepting these objects, this | |
| # fails the workflow instead of leaving a stale (orphaned) feed behind. | |
| curl -fsSL \ | |
| --retry 5 \ | |
| --retry-delay 2 \ | |
| --retry-all-errors \ | |
| --connect-timeout 20 \ | |
| --max-time 120 \ | |
| "$R2_PUBLIC_BASE_URL/latest/appcast.xml?run=${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" \ | |
| -o /tmp/r2-public-appcast.xml | |
| cmp appcast.xml /tmp/r2-public-appcast.xml | |
| curl -fsSL \ | |
| --retry 5 \ | |
| --retry-delay 2 \ | |
| --retry-all-errors \ | |
| --connect-timeout 20 \ | |
| --max-time 120 \ | |
| "$R2_PUBLIC_BASE_URL/latest/appcast-x64.xml?run=${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" \ | |
| -o /tmp/r2-public-appcast-x64.xml | |
| cmp appcast-x64.xml /tmp/r2-public-appcast-x64.xml | |
| # Keep latest/mac bounded without racing cached appcasts: only stale | |
| # archives outside the grace window are removed. Pass the freshly built | |
| # appcasts so the keep list protects the current full .zip AND every | |
| # live .delta they reference (prune-mac-source.sh parses the feeds). | |
| bash scripts/prune-mac-source.sh \ | |
| "$R2_BUCKET_NAME" \ | |
| latest/mac \ | |
| "$mac_arm64_zip" \ | |
| "$mac_intel_zip" \ | |
| appcast.xml \ | |
| appcast-x64.xml | |
| - name: Trigger Cloudflare secondary S3 sync | |
| if: steps.meta.outputs.sync_latest == 'true' | |
| env: | |
| CF_SECONDARY_SYNC_URL: ${{ secrets.CF_SECONDARY_SYNC_URL }} | |
| CF_SECONDARY_SYNC_TOKEN: ${{ secrets.CF_SECONDARY_SYNC_TOKEN }} | |
| SECONDARY_SYNC_MODE: start | |
| SECONDARY_SYNC_RELEASE_TAG: ${{ steps.meta.outputs.tag }} | |
| SECONDARY_SYNC_FORCE: ${{ inputs.force_release || 'false' }} | |
| SECONDARY_SYNC_TIMEOUT_SECONDS: 2400 | |
| shell: bash | |
| run: bash scripts/trigger-secondary-sync.sh | |
| no_changes: | |
| name: No new version | |
| runs-on: ubuntu-latest | |
| needs: probe | |
| if: needs.probe.outputs.should_release != 'true' | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Report current state | |
| env: | |
| SKIP_REASON: ${{ needs.probe.outputs.skip_reason }} | |
| VERSION_SUMMARY: ${{ needs.probe.outputs.version_summary }} | |
| run: | | |
| echo "No new Codex app version detected." | |
| echo "$SKIP_REASON" | |
| echo "$VERSION_SUMMARY" | |
| - name: Reconcile Cloudflare secondary S3 sync | |
| env: | |
| CF_SECONDARY_SYNC_URL: ${{ secrets.CF_SECONDARY_SYNC_URL }} | |
| CF_SECONDARY_SYNC_TOKEN: ${{ secrets.CF_SECONDARY_SYNC_TOKEN }} | |
| SECONDARY_SYNC_MODE: reconcile | |
| SECONDARY_SYNC_RELEASE_TAG: ${{ needs.probe.outputs.latest_tag }} | |
| SECONDARY_SYNC_FORCE: false | |
| SECONDARY_SYNC_TIMEOUT_SECONDS: 2400 | |
| shell: bash | |
| run: bash scripts/trigger-secondary-sync.sh | |
| cleanup_artifacts: | |
| name: Delete run artifacts | |
| runs-on: ubuntu-latest | |
| if: ${{ always() }} | |
| needs: | |
| - probe | |
| - macos | |
| - windows | |
| - windows_arm64_backend | |
| - macos_x64_backend | |
| - release | |
| - no_changes | |
| permissions: | |
| actions: write | |
| steps: | |
| - name: Delete artifacts from this run | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| GH_REPO: ${{ github.repository }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| artifact_ids="$( | |
| gh api --paginate \ | |
| "repos/$GH_REPO/actions/runs/$GITHUB_RUN_ID/artifacts?per_page=100" \ | |
| --jq '.artifacts[].id' | |
| )" | |
| if [[ -z "$artifact_ids" ]]; then | |
| echo "No artifacts to delete for run $GITHUB_RUN_ID." | |
| exit 0 | |
| fi | |
| deleted=0 | |
| while IFS= read -r artifact_id; do | |
| [[ -n "$artifact_id" ]] || continue | |
| gh api --method DELETE "repos/$GH_REPO/actions/artifacts/$artifact_id" | |
| ((deleted += 1)) | |
| done <<< "$artifact_ids" | |
| echo "Deleted $deleted artifact(s) from run $GITHUB_RUN_ID." |