Split production and TestFlight OTA workflows

This commit is contained in:
vineyardbovines
2026-08-26 10:28:46 -04:00
parent 77a59d685f
commit 5738050cca
5 changed files with 164 additions and 68 deletions
+144
View File
@@ -0,0 +1,144 @@
---
name: Deploy Production OTA
on:
push:
tags:
- 'ota-*'
permissions:
contents: read
env:
DENIS_RELEASE_TAG: denis-v0.1.1
jobs:
validate:
if: github.repository == 'bluesky-social/social-app'
name: Validate production OTA
runs-on: ubuntu-latest
outputs:
runtime-version: ${{ steps.target.outputs.runtime-version }}
ios-build-number: ${{ steps.target.outputs.ios-build-number }}
android-version-code: ${{ steps.target.outputs.android-version-code }}
steps:
- name: ⬇️ Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- name: 🧐 Resolve and validate OTA target
id: target
run: bash scripts/resolveOtaTarget.sh
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
- name: 🔧 Setup Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: package.json
cache: pnpm
- name: 📷 Check native fingerprint and install dependencies
id: fingerprint
uses: bluesky-social/github-actions/fingerprint-native@abc6a46eb4badf243f55bfd7d6cec42722456300 # v0.3.0
with:
profile: production
previous-commit-tag: ${{ steps.target.outputs.runtime-version }}
- name: 🛑 Reject native changes
if: ${{ steps.fingerprint.outputs.includes-changes }}
run: |
echo "::error::Production OTA contains native changes and requires a full app release."
exit 1
- 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
publish:
name: Publish production OTA
needs: [validate]
runs-on: ubuntu-latest
environment: production-ota
permissions:
id-token: write
contents: read
concurrency:
group: production-ota-deploy
cancel-in-progress: false
steps:
- name: ⬇️ Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
- name: 🔧 Setup Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: package.json
cache: pnpm
- 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."
exit 1
fi
- name: 🔨 Setup EAS
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # 9.0.0
with:
eas-version: '19.0.5'
packager: 'pnpm --allow-build=dtrace-provider'
token: ${{ secrets.EXPO_TOKEN }}
- name: 🪛 Setup jq
uses: dcarbone/install-jq-action@4fcb5062d7ce9bc4382d1a352d19ba3ba2c317c1 # v4.0.1
- name: ✏️ Write environment variables
id: env
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: production
- name: 🏗️ Create Bundle
run: >
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_RELEASE=${{ steps.env.outputs.release-version }}
SENTRY_DIST=${{ steps.env.outputs.bundle-identifier }}
pnpm export
- name: ☁️ Configure AWS credentials (denis)
uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3
with:
role-to-assume: arn:aws:iam::007404326489:role/denis-ci-publish
aws-region: us-east-2
- name: ⬇️ Setup denis CLI
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)
run: pnpm use-build-number bash scripts/denisPublish.sh
env:
RUNTIME_VERSION: ${{ needs.validate.outputs.runtime-version }}
CHANNEL_NAME: production
BSKY_IOS_BUILD_NUMBER: ${{ needs.validate.outputs.ios-build-number }}
BSKY_ANDROID_VERSION_CODE: ${{ needs.validate.outputs.android-version-code }}
@@ -1,24 +1,12 @@
---
name: Bundle and Deploy EAS Update
name: Deploy TestFlight OTA
on:
push:
branches:
- main
workflow_dispatch:
inputs:
channel:
type: choice
description: Deployment channel to use
options:
- testflight
- production
runtimeVersion:
type: string
description: Runtime version for non-production manual updates (production derives this from the OTA manifest)
required: false
# Deploys happen via EAS using EXPO_TOKEN; the GITHUB_TOKEN only checks out code
# Deploys happen via EAS using EXPO_TOKEN; the GITHUB_TOKEN only checks out code.
permissions:
contents: read
@@ -30,7 +18,7 @@ env:
jobs:
bundleDeploy:
if: github.repository == 'bluesky-social/social-app'
name: Bundle and Deploy EAS Update
name: Bundle and Deploy TestFlight OTA
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;
@@ -46,9 +34,6 @@ jobs:
# 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 }}
runtime-version: ${{ steps.target.outputs.runtime-version }}
ios-build-number: ${{ steps.target.outputs.ios-build-number }}
android-version-code: ${{ steps.target.outputs.android-version-code }}
steps:
- name: ⬇️ Checkout
@@ -56,16 +41,6 @@ jobs:
with:
fetch-depth: 0
# A production OTA is an immutable, reviewed release artifact. The tag
# pins the bundle source SHA and its committed manifest pins the native
# builds that are allowed to receive it.
- name: 🧐 Resolve and validate OTA target
id: target
env:
CHANNEL: ${{ inputs.channel || 'testflight' }}
INPUT_RUNTIME_VERSION: ${{ inputs.runtimeVersion }}
run: bash scripts/resolveOtaTarget.sh
- name: 🔑 Check for EXPO_TOKEN
run: >
if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then
@@ -108,7 +83,6 @@ jobs:
- name: ⬇️ Load fingerprint baseline
id: baseline
if: ${{ (inputs.channel || 'testflight') == 'testflight' }}
env:
GH_TOKEN: ${{ github.token }}
REPOSITORY_ID: ${{ github.repository_id }}
@@ -141,8 +115,7 @@ jobs:
id: fingerprint
uses: bluesky-social/github-actions/fingerprint-native@abc6a46eb4badf243f55bfd7d6cec42722456300 # v0.3.0
with:
profile: ${{ inputs.channel || 'testflight' }}
previous-commit-tag: ${{ steps.target.outputs.runtime-version }}
profile: testflight
# 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.
@@ -196,7 +169,7 @@ jobs:
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' }}
expo-public-env: testflight
- name: 🏗️ Create Bundle
if: ${{ !steps.fingerprint.outputs.includes-changes &&
@@ -229,20 +202,14 @@ jobs:
!steps.version.outputs.version-changed }}
run: pnpm use-build-number bash scripts/denisPublish.sh
env:
RUNTIME_VERSION: ${{ steps.target.outputs.runtime-version }}
CHANNEL_NAME: ${{ inputs.channel || 'testflight' }}
# Production values come from the reviewed manifest. On automatic
# testflight runs these remain empty and the wrapper reads EAS.
BSKY_IOS_BUILD_NUMBER: ${{ steps.target.outputs.ios-build-number }}
BSKY_ANDROID_VERSION_CODE: ${{ steps.target.outputs.android-version-code }}
CHANNEL_NAME: 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 ==
if: ${{ needs.bundleDeploy.outputs.changes-detected && github.repository ==
'bluesky-social/social-app' }}
uses: ./.github/workflows/build-submit-ios.yml
with:
@@ -270,8 +237,7 @@ jobs:
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 ==
if: ${{ 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
@@ -316,7 +282,6 @@ jobs:
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' &&
+5 -5
View File
@@ -23,11 +23,11 @@ git tag ota-1.131.1-1 <commit-sha>
git push origin ota-1.131.1-1
```
Run **Bundle and Deploy EAS Update** from that tag and select `production`. The
workflow rejects branches, mismatched versions, missing native release tags,
and OTA commits that are not descended from the native release. Runtime and
build numbers are read from the intent; production values typed into the
workflow form are ignored.
Pushing the tag starts **Deploy Production OTA** automatically. The workflow
rejects mismatched versions, missing native release tags, native changes, and
OTA commits that are not descended from the native release. Runtime and build
numbers are read from the intent. Publishing waits at the protected
`production-ota` environment.
Use `ota-1.131.1-2` and `.ota/1.131.1-2.json` for the next OTA. Base it on the
previous OTA so that each update contains all earlier fixes.
+6 -9
View File
@@ -86,17 +86,14 @@ git push origin ota-1.131.1-1
The sequence in the filename and tag must match. See [`.ota/README.md`](../.ota/README.md)
for the complete contract.
### 7. Run the GitHub actions
You'll need to run two separate actions: one to deploy the iOS/Android OTA
itself, and one to build the web Docker container.
Pushing the tag automatically starts the **Deploy Production OTA** workflow.
It validates and prepares the exact tagged bundle, then waits at the protected
`production-ota` environment before publishing.
**For the iOS/Android OTA,** head to [Actions > Bundle and Deploy EAS
Update](https://github.com/bluesky-social/social-app/actions/workflows/bundle-deploy-eas-update.yml)
and run the action.
### 7. Build web
| Steps | |
| ----- | --- |
| Select your immutable tag `ota-1.x.0-x`, select `production` in the dropdown, and click "Run workflow". Runtime and build numbers are read from the reviewed OTA intent. | ![workflow](./img/ota_action.png) |
The production OTA and TestFlight OTA workflows are separate. Production never
uses the automatic TestFlight build-number or native-rebuild behavior.
> [!NOTE]
> Production OTAs are bound to the specific native build they target. The
+1 -11
View File
@@ -16,18 +16,8 @@ is_build_number() {
[[ "$1" =~ ^[1-9][0-9]*$ ]]
}
if [ "${CHANNEL:-testflight}" != "production" ]; then
if [ -n "${INPUT_RUNTIME_VERSION:-}" ] && ! is_version "$INPUT_RUNTIME_VERSION"; then
error "runtimeVersion must use x.y.z format"
fi
echo "runtime-version=${INPUT_RUNTIME_VERSION:-}" >> "$GITHUB_OUTPUT"
echo "ios-build-number=" >> "$GITHUB_OUTPUT"
echo "android-version-code=" >> "$GITHUB_OUTPUT"
exit 0
fi
[ "${GITHUB_REF_TYPE:-}" = "tag" ] ||
error "Production OTAs must be dispatched from an immutable OTA tag, not '${GITHUB_REF_NAME:-unknown}'."
error "Production OTAs must run from an immutable OTA tag, not '${GITHUB_REF_NAME:-unknown}'."
if [[ ! "${GITHUB_REF_NAME:-}" =~ ^ota-([0-9]+\.[0-9]+\.[0-9]+)-([1-9][0-9]*)$ ]]; then
error "Production OTA tag must use ota-<version>-<sequence>, for example ota-1.131.1-1."