Skip to content

Mirror Codex App Installers #10048

Mirror Codex App Installers

Mirror Codex App Installers #10048

Workflow file for this run

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."