--- 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 iosBuildNumber: type: string description: iOS build number of the native build this update targets (required for production) androidVersionCode: type: string description: Android version code of the native build this update targets (required for production) # 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: | [[ "$RUNTIME_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] && echo "Version is valid" || exit 1 # Production OTAs are bound to the specific native build they target, so # the build numbers must be entered manually rather than read from the # global EAS counters, which advance with every testflight build and so # point past older production releases - name: ๐Ÿง Validate build numbers if: ${{ inputs.channel == 'production' }} env: IOS_BUILD_NUMBER: ${{ inputs.iosBuildNumber }} ANDROID_VERSION_CODE: ${{ inputs.androidVersionCode }} run: | [[ "$IOS_BUILD_NUMBER" =~ ^[0-9]+$ ]] || (echo "A numeric iosBuildNumber is required for production updates" && exit 1) [[ "$ANDROID_VERSION_CODE" =~ ^[0-9]+$ ]] || (echo "A numeric androidVersionCode is required for production updates" && exit 1) - name: โฌ‡๏ธ Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 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@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - 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 - name: โ˜๏ธ Configure AWS credentials (denis) if: ${{ !steps.fingerprint.outputs.includes-changes && !steps.version.outputs.version-changed }} 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 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' }} # When set (required for production), these take precedence over the # global EAS counters inside the use-build-number wrapper BSKY_IOS_BUILD_NUMBER: ${{ inputs.iosBuildNumber }} BSKY_ANDROID_VERSION_CODE: ${{ inputs.androidVersionCode }} 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