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