From 9eb78c0f6c8dd359975360ee8d10284e81ee7620 Mon Sep 17 00:00:00 2001 From: Austin McKinley <54160+amckinley@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:18:00 -0700 Subject: [PATCH] ci: publish OTA to denis (S3) + automatic per-PR previews (#11235) Co-authored-by: Claude Opus 4.8 (1M context) --- .github/actions/setup-denis/action.yml | 50 ++++ .../workflows/bundle-deploy-eas-update.yml | 40 ++++ .github/workflows/pull-request-comment.yml | 221 ------------------ .github/workflows/pull-request-commit.yml | 98 ++++++++ scripts/denisPublish.sh | 45 ++++ 5 files changed, 233 insertions(+), 221 deletions(-) create mode 100644 .github/actions/setup-denis/action.yml delete mode 100644 .github/workflows/pull-request-comment.yml create mode 100755 scripts/denisPublish.sh diff --git a/.github/actions/setup-denis/action.yml b/.github/actions/setup-denis/action.yml new file mode 100644 index 0000000000..dfc9b514b4 --- /dev/null +++ b/.github/actions/setup-denis/action.yml @@ -0,0 +1,50 @@ +--- +name: Setup denis CLI +description: > + Download and verify the denis OTA publish binary from the (private) + bluesky-social/tango releases and put it on PATH. Uses a short-lived GitHub + App token scoped to contents:read on tango, since the default GITHUB_TOKEN + cannot read a private repo's releases. + +inputs: + release-tag: + description: denis release tag in bluesky-social/tango to download + required: true + app-id: + description: GitHub App ID for the token used to read tango releases + required: true + private-key: + description: GitHub App private key + required: true + +runs: + using: composite + steps: + - name: 🔑 Mint tango read token + id: tango-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ inputs.app-id }} + private-key: ${{ inputs.private-key }} + repositories: tango + permission-contents: read + + - name: ⬇️ Download and verify denis binary + shell: bash + env: + GH_TOKEN: ${{ steps.tango-token.outputs.token }} + RELEASE_TAG: ${{ inputs.release-tag }} + run: | + BIN_DIR="$RUNNER_TEMP/denis-bin" + mkdir -p "$BIN_DIR" + cd "$BIN_DIR" + gh release download "$RELEASE_TAG" \ + --repo bluesky-social/tango \ + --pattern denis-linux-amd64 \ + --pattern denis-linux-amd64.sha256 \ + --clobber + # Verify before making it executable / putting it on PATH. + sha256sum -c denis-linux-amd64.sha256 + mv denis-linux-amd64 denis + chmod +x denis + echo "$BIN_DIR" >> "$GITHUB_PATH" diff --git a/.github/workflows/bundle-deploy-eas-update.yml b/.github/workflows/bundle-deploy-eas-update.yml index eab0851c5b..9154d90040 100644 --- a/.github/workflows/bundle-deploy-eas-update.yml +++ b/.github/workflows/bundle-deploy-eas-update.yml @@ -22,11 +22,21 @@ on: 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; contents: read is still needed for the checkout. + permissions: + id-token: write + contents: read concurrency: group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-deploy cancel-in-progress: true @@ -155,6 +165,36 @@ jobs: RUNTIME_VERSION: ${{ inputs.runtimeVersion }} CHANNEL_NAME: ${{ inputs.channel || 'testflight' }} + # The three steps below dual-write the same exported bundle to the new + # denis/S3 service alongside the legacy ota1 upload above. This is a + # deliberate temporary dual-write during the ota1 -> denis migration: + # both paths run and both must succeed. The legacy step above and this + # block are removed together once denis is the sole origin (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: ⬇️ Restore Cache id: get-base-commit uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 diff --git a/.github/workflows/pull-request-comment.yml b/.github/workflows/pull-request-comment.yml deleted file mode 100644 index 944801326a..0000000000 --- a/.github/workflows/pull-request-comment.yml +++ /dev/null @@ -1,221 +0,0 @@ ---- -name: PR Comment Trigger - -on: - issue_comment: - types: [created] - -# Permissions are granted per-job below; anything unlisted defaults to none -permissions: {} - -jobs: - handle-comment: - if: github.event.issue.pull_request - runs-on: ubuntu-latest - permissions: - contents: read - outputs: - should-deploy: ${{ steps.check-org.outputs.result }} - - steps: - - name: Check if bot is mentioned - id: check-mention - env: - COMMENT: ${{ github.event.comment.body }} - run: | - if [[ "$COMMENT" == *"@github-actions"* ]] || \ - [[ "$COMMENT" == *"github-actions[bot]"* ]]; then - bot_mentioned=true - else - bot_mentioned=false - fi - - - if [[ "$COMMENT" == *"ota"* ]]; then - has_ota=true - else - has_ota=false - fi - - - if [[ "$bot_mentioned" == "true" ]] && [[ "$has_ota" == "true" ]]; then - echo "mentioned=true" >> $GITHUB_OUTPUT - else - echo "mentioned=false" >> $GITHUB_OUTPUT - fi - - - name: Check commenter has write access - if: steps.check-mention.outputs.mentioned == 'true' - id: check-org - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - script: | - try { - const { data: perm } = await github.rest.repos.getCollaboratorPermissionLevel({ - owner: context.repo.owner, - repo: context.repo.repo, - username: context.payload.comment.user.login - }); - - const hasAccess = ['admin', 'write'].includes(perm.permission); - console.log(`User has ${perm.permission} access`); - - return hasAccess; - } catch(error) { - console.log('User has no repository access'); - return false; - } - - bundle-deploy: - name: Bundle and Deploy EAS Update - runs-on: ubuntu-latest - needs: [handle-comment] - if: needs.handle-comment.outputs.should-deploy == 'true' - permissions: - contents: read - pull-requests: write - - steps: - - name: Get PR HEAD SHA - env: - ISSUE_NUMBER: ${{ github.event.issue.number }} - id: pr-info - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - script: | - const pr = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: process.env.ISSUE_NUMBER, - }); - - // This workflow runs with repo secrets in scope, so never build - // code from a fork: the commenter authorizes the deploy, but a - // fork controls what code would run during it - const expected = `${context.repo.owner}/${context.repo.repo}`; - const head = pr.data.head.repo?.full_name; - if (head !== expected) { - core.setFailed(`OTA deploys are only allowed for branches in ${expected}, not forks (got ${head})`); - return; - } - - console.log(`PR HEAD SHA: ${pr.data.head.sha}`); - console.log(`PR HEAD REF: ${pr.data.head.ref}`); - - core.setOutput('head-sha', pr.data.head.sha); - core.setOutput('head-ref', pr.data.head.ref); - - - name: 💬 Drop a comment - uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5 - with: - header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }} - number: ${{ github.event.issue.number }} - message: | - An OTA deployment has been requested and is now running for `${{ steps.pr-info.outputs.head-sha }}`. - - [Here is some music to listen to while you wait...](https://www.youtube.com/watch?v=VBlFHuCzPgY) - --- - *Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖* - - - 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 - - - name: ⬇️ Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - ref: ${{ steps.pr-info.outputs.head-sha }} - - - 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: Install dependencies - run: pnpm install --frozen-lockfile - - - name: 🔤 Compile translations - run: pnpm intl:build 2>&1 | tee i18n.log - - - name: Check for i18n compilation errors - run: if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation errors!\n\n" && exit 1; else echo "\n\nNo compilation errors!\n\n"; fi - - - name: Lint check - run: pnpm lint - - - name: Type check - run: pnpm typecheck - - - 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: Env - id: env - run: | - export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}' - echo "${{ secrets.ENV_TOKEN }}" > .env - echo "EXPO_PUBLIC_ENV=testflight" >> .env - echo "EXPO_PUBLIC_RELEASE_VERSION=$(jq -r '.version' package.json)" >> .env - echo "EXPO_PUBLIC_RELEASE_VERSION=$(jq -r '.version' package.json)" >> $GITHUB_OUTPUT - echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse HEAD)" >> .env - echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT - echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env - echo "EXPO_PUBLIC_SENTRY_DSN=${{ secrets.SENTRY_DSN }}" >> .env - echo "EXPO_PUBLIC_BITDRIFT_API_KEY=${{ secrets.BITDRIFT_API_KEY }}" >> .env - echo "EXPO_PUBLIC_GCP_PROJECT_ID=${{ secrets.EXPO_PUBLIC_GCP_PROJECT_ID }}" >> .env - echo "$json" > google-services.json - - - name: 🏗️ Create Bundle - run: > - SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} - SENTRY_RELEASE=${{ steps.env.outputs.EXPO_PUBLIC_RELEASE_VERSION }} - SENTRY_DIST=${{ steps.env.outputs.EXPO_PUBLIC_BUNDLE_IDENTIFIER }} - pnpm export - - - name: 📦 Package Bundle and 🚀 Deploy - run: pnpm use-build-number bash scripts/bundleUpdate.sh - env: - DENIS_API_KEY: ${{ secrets.DENIS_API_KEY }} - CHANNEL_NAME: pull-request-${{ github.event.issue.number }} - RUNTIME_VERSION: - - - name: 💬 Drop a comment - uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5 - env: - ISSUE_NUMBER: ${{ github.event.issue.number }} - with: - header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }} - number: ${{ github.event.issue.number }} - message: | - Your requested OTA deployment was successful! You may now apply it by either scanning the QR code or opening the deep link below in your browser: - - - - `bluesky://intent/apply-ota?channel=pull-request-${{ github.event.issue.number }}` - --- - - *Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖* - - - name: 💬 Drop a comment - uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5 - if: failure() - with: - header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }} - number: ${{ github.event.issue.number }} - message: | - Your requested OTA deployment was unsuccessful. See action logs for more details. - --- - *Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖* diff --git a/.github/workflows/pull-request-commit.yml b/.github/workflows/pull-request-commit.yml index 264ee5d311..133e7ede46 100644 --- a/.github/workflows/pull-request-commit.yml +++ b/.github/workflows/pull-request-commit.yml @@ -18,6 +18,11 @@ concurrency: # bundle-size and fingerprint diffs permissions: {} +# denis release tag in bluesky-social/tango whose linux-amd64 binary the PR OTA +# job downloads. Bump this one line to roll denis. +env: + DENIS_RELEASE_TAG: denis-v0.1.1 + jobs: # Populate this from main so every PR can restore the same trusted baseline. webpack-analyzer-base: @@ -220,3 +225,96 @@ jobs: PR_NUMBER: ${{ github.event.pull_request.number }} run: | gh pr edit "$PR_NUMBER" --remove-label "bot: fingerprint changed" || true + + # Automatic per-PR OTA preview, published to the pull-request- channel on + # denis. Replaces the old `@github-actions ota` comment trigger. Gated to + # same-repo PRs (fork guard): a branch can only exist in this repo if someone + # with write access pushed it, so an outside contributor (who can only open a + # PR from a fork) never runs this job with the denis publish role in scope. + # This matches the fork-guard gate the other jobs in this workflow use; + # author_association is deliberately NOT checked (it can't identify a private + # org member and would skip their PRs). + publish-pr-ota: + name: Publish PR OTA to denis + runs-on: ubuntu-latest + if: >- + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository + concurrency: + group: pr-ota-${{ github.event.pull_request.number }} + cancel-in-progress: true + permissions: + id-token: write + contents: read + steps: + - name: ⬇️ Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - name: 🛠️ Setup Expo project + uses: ./.github/actions/setup-expo-project + with: + expo-token: ${{ secrets.EXPO_TOKEN }} + + - name: 🔤 Compile translations + uses: ./.github/actions/compile-i18n + + - 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: testflight + + - 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, PR-scoped) + uses: aws-actions/configure-aws-credentials@254c19bd240aabef8777f48595e9d2d7b972184b # v6.2.1 + with: + role-to-assume: arn:aws:iam::007404326489:role/denis-ci-publish-pr + aws-region: us-east-2 + # Defense-in-depth: the base role is already scoped to pr/*, but narrow + # this session further to just THIS PR's prefix so a bug can't write to + # another PR's objects or the prod tree. + inline-session-policy: |- + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["s3:PutObject", "s3:DeleteObject"], + "Resource": "arn:aws:s3:::bsky-denis-ota-prod/pr/${{ github.event.pull_request.number }}/*" + }, + { + "Effect": "Allow", + "Action": "s3:ListBucket", + "Resource": "arn:aws:s3:::bsky-denis-ota-prod", + "Condition": { + "StringLike": { "s3:prefix": "pr/${{ github.event.pull_request.number }}/*" } + } + } + ] + } + + - 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: '' + CHANNEL_NAME: pull-request-${{ github.event.pull_request.number }} diff --git a/scripts/denisPublish.sh b/scripts/denisPublish.sh new file mode 100755 index 0000000000..f983bf21d1 --- /dev/null +++ b/scripts/denisPublish.sh @@ -0,0 +1,45 @@ +#!/bin/bash +set -o errexit +set -o pipefail +set -o nounset + +# Publishes the just-exported Expo bundle to the denis OTA service (S3) via the +# `denis publish` CLI. Mirrors bundleUpdate.sh's inputs (runtime version, bundle +# version, build numbers) but targets denis instead of the legacy ota1 upload. +# Expects: the `denis` binary on PATH (setup-denis action), ambient AWS creds +# (configure-aws-credentials OIDC), and BSKY_IOS_BUILD_NUMBER / +# BSKY_ANDROID_VERSION_CODE from the use-build-number wrapper. + +rm -rf bundleTempDir + +echo "Assembling bundle directory..." +node scripts/bundleUpdate.js + +if [ -z "$RUNTIME_VERSION" ]; then + RUNTIME_VERSION=$(cat package.json | jq '.version' -r) +fi + +BUNDLE_VERSION=$(date +%s) +DENIS_CDN_DOMAIN="${DENIS_CDN_DOMAIN:-updates.bsky.app}" +DENIS_S3_BUCKET="${DENIS_S3_BUCKET:-bsky-denis-ota-prod}" + +echo "Publishing to denis..." +echo " runtime-version: $RUNTIME_VERSION" +echo " bundle-version: $BUNDLE_VERSION" +echo " channel: $CHANNEL_NAME" +echo " ios-build-number: $BSKY_IOS_BUILD_NUMBER" +echo " android-build-number: $BSKY_ANDROID_VERSION_CODE" +echo " cdn-domain: $DENIS_CDN_DOMAIN" +echo " s3-bucket: $DENIS_S3_BUCKET" + +denis publish \ + --bundle-dir bundleTempDir \ + --runtime-version "$RUNTIME_VERSION" \ + --bundle-version "$BUNDLE_VERSION" \ + --channel "$CHANNEL_NAME" \ + --ios-build-number "$BSKY_IOS_BUILD_NUMBER" \ + --android-build-number "$BSKY_ANDROID_VERSION_CODE" \ + --cdn-domain "$DENIS_CDN_DOMAIN" \ + --s3-bucket "$DENIS_S3_BUCKET" + +rm -rf bundleTempDir