952a176c83
The legacy dual-write leg posted to https://updates.bsky.app/v1/upload. That hostname has resolved to denis on EKS since the 2026-07-26 origin flip, and denis deliberately has no /v1/upload route -- publishing there is out-of-band via `denis publish`. The first OTA publish attempted after the flip therefore got a 404 (run 30220289826). Point it at ota1's own origin instead. The dual-write was never independent of the cutover: it wrote to ota1 *through the hostname being cut over*, so flipping the origin necessarily broke it. Addressing ota1 directly is what the step always meant. Verified reachable: ota1.us-east.updates.bsky.network/v1/upload returns 401 (route present, auth required) where updates.bsky.app returns 404. Also reorder the two publishes so denis goes first. This is the more serious half of the bug. The legacy step ran before the denis steps, so when it 404'd the three denis steps were skipped and the bundle reached NEITHER origin -- a dual-write that takes down the working path when its deprecated half fails is worse than no dual-write. denis is the sole origin serving clients, so it publishes first; the legacy upload follows and is still required to pass, so a stale ota1 remains a loud failure rather than a silent one. The upload URL is now overridable via OTA1_ORIGIN, and the comment explains why it must never be set back to the CDN hostname. Both halves go away together when ota1 is decommissioned (Phase 5). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
360 lines
16 KiB
YAML
360 lines
16 KiB
YAML
---
|
|
name: Bundle and Deploy EAS Update
|
|
|
|
on:
|
|
push:
|
|
branches:
|
|
- main
|
|
workflow_dispatch:
|
|
inputs:
|
|
channel:
|
|
type: choice
|
|
description: Deployment channel to use
|
|
options:
|
|
- testflight
|
|
- production
|
|
runtimeVersion:
|
|
type: string
|
|
description: Runtime version (in x.x.x format) that this update is for
|
|
required: true
|
|
|
|
# Deploys happen via EAS using EXPO_TOKEN; the GITHUB_TOKEN only checks out code
|
|
permissions:
|
|
contents: read
|
|
|
|
# denis release tag in bluesky-social/tango whose linux-amd64 binary this
|
|
# workflow downloads to publish OTA bundles. Bump this one line to roll denis.
|
|
env:
|
|
DENIS_RELEASE_TAG: denis-v0.1.1
|
|
|
|
jobs:
|
|
bundleDeploy:
|
|
if: github.repository == 'bluesky-social/social-app'
|
|
name: Bundle and Deploy EAS Update
|
|
runs-on: ubuntu-latest
|
|
# id-token: write lets this job mint an OIDC token to assume the denis
|
|
# publish role; actions: read loads the fingerprint baseline artifact;
|
|
# contents: read is still needed for the checkout.
|
|
permissions:
|
|
id-token: write
|
|
actions: read
|
|
contents: read
|
|
concurrency:
|
|
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-deploy
|
|
cancel-in-progress: true
|
|
outputs:
|
|
# A version bump forces a native build even if the fingerprint is unchanged
|
|
changes-detected: ${{ steps.fingerprint.outputs.includes-changes ||
|
|
steps.version.outputs.version-changed }}
|
|
|
|
steps:
|
|
- name: 🔑 Check for EXPO_TOKEN
|
|
run: >
|
|
if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then
|
|
echo "You must provide an EXPO_TOKEN secret linked to this project's Expo account in this repo's secrets. Learn more: https://docs.expo.dev/eas-update/github-actions"
|
|
exit 1
|
|
fi
|
|
|
|
# Validate the version if one is supplied. This should generally happen if the update is for a production client
|
|
- name: 🧐 Validate version
|
|
env:
|
|
RUNTIME_VERSION: ${{ inputs.runtimeVersion }}
|
|
if: ${{ inputs.runtimeVersion }}
|
|
run: |
|
|
if [ -z "$RUNTIME_VERSION" ]; then
|
|
[[ "$RUNTIME_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] && echo "Version is valid" || exit 1
|
|
fi
|
|
|
|
- name: ⬇️ Checkout
|
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
with:
|
|
fetch-depth: 0
|
|
|
|
- name: ⬇️ Fetch commits from base branch
|
|
if: ${{ github.ref != 'refs/heads/main' }}
|
|
run: git fetch origin main:main --depth 100
|
|
|
|
# A change to the version in package.json means a new native release, so
|
|
# an OTA update must not be deployed and full native builds are required
|
|
# regardless of what the fingerprint says
|
|
- name: 🔢 Check for version change
|
|
id: version
|
|
if: ${{ github.event_name == 'push' }}
|
|
env:
|
|
EVENT_BEFORE: ${{ github.event.before }}
|
|
run: |
|
|
CURRENT_VERSION=$(jq -r '.version' package.json)
|
|
if [ -n "$EVENT_BEFORE" ] && [[ ! "$EVENT_BEFORE" =~ ^0+$ ]] && git cat-file -e "$EVENT_BEFORE:package.json" 2>/dev/null; then
|
|
PREVIOUS_VERSION=$(git show "$EVENT_BEFORE:package.json" | jq -r '.version')
|
|
else
|
|
PREVIOUS_VERSION=$(git show HEAD~1:package.json | jq -r '.version')
|
|
fi
|
|
echo "Previous version: $PREVIOUS_VERSION, current version: $CURRENT_VERSION"
|
|
if [ "$CURRENT_VERSION" != "$PREVIOUS_VERSION" ]; then
|
|
echo "Version changed, full native builds are required"
|
|
echo "version-changed=true" >> "$GITHUB_OUTPUT"
|
|
fi
|
|
|
|
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
|
|
|
- name: 🔧 Setup Node
|
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
|
with:
|
|
node-version-file: package.json
|
|
cache: pnpm
|
|
|
|
- name: ⬇️ Load fingerprint baseline
|
|
id: baseline
|
|
if: ${{ (inputs.channel || 'testflight') == 'testflight' }}
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
REPOSITORY_ID: ${{ github.repository_id }}
|
|
run: |
|
|
url=$(gh api \
|
|
"repos/${GITHUB_REPOSITORY}/actions/artifacts?name=testflight-native-fingerprint&per_page=100" \
|
|
--jq "[.artifacts[] | select(
|
|
.expired == false and
|
|
.workflow_run.head_branch == \"main\" and
|
|
.workflow_run.head_repository_id == (\$ENV.REPOSITORY_ID | tonumber)
|
|
)] | max_by(.created_at) | .archive_download_url" \
|
|
2>/dev/null || true)
|
|
|
|
if [ -n "$url" ] && [ "$url" != "null" ]; then
|
|
mkdir baseline-artifact
|
|
if curl -sSL -H "Authorization: Bearer $GH_TOKEN" -o baseline.zip "$url" \
|
|
&& unzip -q baseline.zip -d baseline-artifact; then
|
|
if jq -e '.sources | type == "array"' \
|
|
baseline-artifact/native-fingerprint.json >/dev/null; then
|
|
echo "path=baseline-artifact/native-fingerprint.json" >> "$GITHUB_OUTPUT"
|
|
else
|
|
echo "::warning::Ignoring invalid fingerprint baseline artifact."
|
|
fi
|
|
else
|
|
echo "::warning::Could not download fingerprint baseline artifact."
|
|
fi
|
|
fi
|
|
|
|
- name: 📷 Check fingerprint and install dependencies
|
|
id: fingerprint
|
|
uses: bluesky-social/github-actions/fingerprint-native@abc6a46eb4badf243f55bfd7d6cec42722456300 # v0.3.0
|
|
with:
|
|
profile: ${{ inputs.channel || 'testflight' }}
|
|
previous-commit-tag: ${{ inputs.runtimeVersion }}
|
|
# The recordBaseline job uploads this marker after a successful deploy;
|
|
# on the native path, that requires both builds to succeed. A missing
|
|
# marker forces native builds so they can seed the baseline safely.
|
|
baseline-fingerprint-path: ${{ steps.baseline.outputs.path }}
|
|
|
|
# Hand the full fingerprint to recordBaseline through a short-lived
|
|
# artifact. It is uploaded unconditionally but promoted to the persistent
|
|
# baseline only after both native builds succeed.
|
|
- name: 🚀 Upload native fingerprint
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
name: native-fingerprint-${{ github.run_id }}
|
|
path: ${{ steps.fingerprint.outputs.current-fingerprint-path }}
|
|
retention-days: 1
|
|
if-no-files-found: error
|
|
|
|
- name: 🔤 Compile translations
|
|
uses: ./.github/actions/compile-i18n
|
|
|
|
- name: 🧹 Lint check
|
|
run: pnpm lint
|
|
|
|
- name: 💅 Prettier check
|
|
run: pnpm prettier --check .
|
|
|
|
- name: 🔎 Type check
|
|
run: pnpm typecheck
|
|
|
|
- name: 🔨 Setup EAS
|
|
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # 9.0.0
|
|
if: ${{ !steps.fingerprint.outputs.includes-changes &&
|
|
!steps.version.outputs.version-changed }}
|
|
with:
|
|
eas-version: '19.0.5'
|
|
packager: 'pnpm --allow-build=dtrace-provider'
|
|
token: ${{ secrets.EXPO_TOKEN }}
|
|
|
|
- name: 🪛 Setup jq
|
|
if: ${{ !steps.fingerprint.outputs.includes-changes &&
|
|
!steps.version.outputs.version-changed }}
|
|
uses: dcarbone/install-jq-action@4fcb5062d7ce9bc4382d1a352d19ba3ba2c317c1 # v4.0.1
|
|
|
|
# eas.json not used here, so EXPO_PUBLIC_ENV must be written explicitly
|
|
- name: ✏️ Write environment variables
|
|
id: env
|
|
if: ${{ !steps.fingerprint.outputs.includes-changes && !steps.version.outputs.version-changed }}
|
|
uses: ./.github/actions/write-env
|
|
with:
|
|
env-token: ${{ secrets.ENV_TOKEN }}
|
|
sentry-dsn: ${{ secrets.SENTRY_DSN }}
|
|
bitdrift-api-key: ${{ secrets.BITDRIFT_API_KEY }}
|
|
gcp-project-id: ${{ secrets.EXPO_PUBLIC_GCP_PROJECT_ID }}
|
|
google-services-token: ${{ secrets.GOOGLE_SERVICES_TOKEN }}
|
|
expo-public-env: ${{ inputs.channel || 'testflight' }}
|
|
|
|
- name: 🏗️ Create Bundle
|
|
if: ${{ !steps.fingerprint.outputs.includes-changes &&
|
|
!steps.version.outputs.version-changed }}
|
|
run: >
|
|
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
|
|
SENTRY_RELEASE=${{ steps.env.outputs.release-version }}
|
|
SENTRY_DIST=${{ steps.env.outputs.bundle-identifier }}
|
|
pnpm export
|
|
|
|
# denis on EKS has been the sole origin for updates.bsky.app since
|
|
# 2026-07-26, so it publishes FIRST: it is the path that actually serves
|
|
# clients. The legacy ota1 upload runs after it, and exists only so that
|
|
# rolling the Bunny origin back to ota1 would find current bundles there.
|
|
#
|
|
# The ordering is load-bearing, not cosmetic. While the legacy step ran
|
|
# first, its failure skipped these steps and nothing reached EITHER origin
|
|
# -- the dual-write took down the working path with it. Both steps are
|
|
# still required to pass, so a stale ota1 remains a loud failure, but the
|
|
# publish that serves users has already landed before the legacy one can
|
|
# fail.
|
|
#
|
|
# Both halves are removed together when ota1 is decommissioned (Phase 5).
|
|
- name: ☁️ Configure AWS credentials (denis)
|
|
if: ${{ !steps.fingerprint.outputs.includes-changes &&
|
|
!steps.version.outputs.version-changed }}
|
|
uses: aws-actions/configure-aws-credentials@254c19bd240aabef8777f48595e9d2d7b972184b # v6.2.1
|
|
with:
|
|
role-to-assume: arn:aws:iam::007404326489:role/denis-ci-publish
|
|
aws-region: us-east-2
|
|
|
|
- name: ⬇️ Setup denis CLI
|
|
if: ${{ !steps.fingerprint.outputs.includes-changes &&
|
|
!steps.version.outputs.version-changed }}
|
|
uses: ./.github/actions/setup-denis
|
|
with:
|
|
release-tag: ${{ env.DENIS_RELEASE_TAG }}
|
|
app-id: ${{ vars.SYNC_INTERNAL_APP_ID }}
|
|
private-key: ${{ secrets.SYNC_INTERNAL_PK }}
|
|
|
|
- name: 🚀 Publish OTA to denis (S3)
|
|
if: ${{ !steps.fingerprint.outputs.includes-changes &&
|
|
!steps.version.outputs.version-changed }}
|
|
run: pnpm use-build-number bash scripts/denisPublish.sh
|
|
env:
|
|
RUNTIME_VERSION: ${{ inputs.runtimeVersion }}
|
|
CHANNEL_NAME: ${{ inputs.channel || 'testflight' }}
|
|
|
|
- name: 📦 Package Bundle and 🚀 Deploy (legacy ota1)
|
|
if: ${{ !steps.fingerprint.outputs.includes-changes &&
|
|
!steps.version.outputs.version-changed }}
|
|
run: pnpm use-build-number bash scripts/bundleUpdate.sh
|
|
env:
|
|
DENIS_API_KEY: ${{ secrets.DENIS_API_KEY }}
|
|
RUNTIME_VERSION: ${{ inputs.runtimeVersion }}
|
|
CHANNEL_NAME: ${{ inputs.channel || 'testflight' }}
|
|
|
|
buildIfNecessaryIOS:
|
|
name: Build and Submit iOS
|
|
needs: [bundleDeploy]
|
|
# Gotta check if its NOT '[]' because any md5 hash in the outputs is detected as a possible secret and won't be
|
|
# available here
|
|
if: ${{ inputs.channel != 'production' &&
|
|
needs.bundleDeploy.outputs.changes-detected && github.repository ==
|
|
'bluesky-social/social-app' }}
|
|
uses: ./.github/workflows/build-submit-ios.yml
|
|
with:
|
|
profile: testflight
|
|
testFlightGroup: none
|
|
# OTA rebuilds don't need the xlarge builder used for releases
|
|
runner: macos-26
|
|
# Pass only the secrets the reusable workflow declares, rather than `secrets: inherit`,
|
|
# so this workflow never hands the reusable workflow the entire repo secret store.
|
|
secrets:
|
|
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
|
|
ENV_TOKEN: ${{ secrets.ENV_TOKEN }}
|
|
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
|
BITDRIFT_API_KEY: ${{ secrets.BITDRIFT_API_KEY }}
|
|
EXPO_PUBLIC_GCP_PROJECT_ID: ${{ secrets.EXPO_PUBLIC_GCP_PROJECT_ID }}
|
|
GOOGLE_SERVICES_TOKEN: ${{ secrets.GOOGLE_SERVICES_TOKEN }}
|
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
|
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
|
|
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
|
|
ASC_KEY_P8_BASE64: ${{ secrets.ASC_KEY_P8_BASE64 }}
|
|
SLACK_CLIENT_ALERT_WEBHOOK: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
|
|
|
buildIfNecessaryAndroid:
|
|
name: Build and Submit Android
|
|
needs: [bundleDeploy]
|
|
# Gotta check if its NOT '[]' because any md5 hash in the outputs is detected as a possible secret and won't be
|
|
# available here
|
|
if: ${{ inputs.channel != 'production' &&
|
|
needs.bundleDeploy.outputs.changes-detected && github.repository ==
|
|
'bluesky-social/social-app' }}
|
|
# build-submit-android.yml contains an attachToRelease job that requests contents: write.
|
|
# That job is skipped here (it needs a production tag build), but GitHub statically
|
|
# validates the reusable-workflow permission ceiling, so the caller must grant it.
|
|
permissions:
|
|
contents: write
|
|
uses: ./.github/workflows/build-submit-android.yml
|
|
with:
|
|
profile: testflight-android
|
|
runner: ubuntu-latest
|
|
# Pass only the secrets the reusable workflow declares, rather than `secrets: inherit`,
|
|
# so this workflow never hands the reusable workflow the entire repo secret store.
|
|
secrets:
|
|
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
|
|
ENV_TOKEN: ${{ secrets.ENV_TOKEN }}
|
|
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
|
BITDRIFT_API_KEY: ${{ secrets.BITDRIFT_API_KEY }}
|
|
EXPO_PUBLIC_GCP_PROJECT_ID: ${{ secrets.EXPO_PUBLIC_GCP_PROJECT_ID }}
|
|
GOOGLE_SERVICES_TOKEN: ${{ secrets.GOOGLE_SERVICES_TOKEN }}
|
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
|
SLACK_CLIENT_ALERT_WEBHOOK: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
|
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
|
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
|
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
|
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
|
|
|
# Advance the fingerprint baseline only after BOTH native builds have shipped
|
|
# the new native surface. This replaces the old actions/cache baseline, which
|
|
# only advanced on cache eviction and so silently froze - freezing meant every
|
|
# fingerprint looked changed and OTA updates stopped deploying entirely.
|
|
#
|
|
# On the native-build path, this runs only after both builds succeed. A
|
|
# successful OTA deploy also records its fingerprint, refreshing the
|
|
# persistent marker's retention without changing the native baseline.
|
|
#
|
|
# The persistent artifact replaces the old actions/cache marker without
|
|
# requiring a PAT or mutable repository variable. Each successful deploy adds
|
|
# an immutable marker; the next run reads the newest non-expired one using the
|
|
# built-in GITHUB_TOKEN.
|
|
recordBaseline:
|
|
name: Record fingerprint baseline
|
|
runs-on: ubuntu-latest
|
|
needs: [bundleDeploy, buildIfNecessaryIOS, buildIfNecessaryAndroid]
|
|
if: ${{ always() &&
|
|
(inputs.channel || 'testflight') == 'testflight' &&
|
|
needs.bundleDeploy.result == 'success' &&
|
|
(needs.bundleDeploy.outputs.changes-detected != 'true' ||
|
|
(needs.buildIfNecessaryIOS.result == 'success' &&
|
|
needs.buildIfNecessaryAndroid.result == 'success')) &&
|
|
github.repository == 'bluesky-social/social-app' }}
|
|
permissions:
|
|
actions: read
|
|
steps:
|
|
- name: ⬇️ Download native fingerprint
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
name: native-fingerprint-${{ github.run_id }}
|
|
|
|
- name: 🧐 Validate native fingerprint
|
|
run: >
|
|
jq -e '.sources | type == "array"' native-fingerprint.json >/dev/null ||
|
|
(echo "::error::native fingerprint artifact was invalid; refusing to record it as the baseline." && exit 1)
|
|
|
|
- name: 🚀 Record fingerprint baseline
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
name: testflight-native-fingerprint
|
|
path: native-fingerprint.json
|
|
retention-days: 90
|
|
if-no-files-found: error
|