fbcd5422ee
publish-pr-ota needed fingerprint-native, so the bundle build - the slow half - did not start until the fingerprint had finished, even though the bundle does not depend on the fingerprint verdict. Only the decision to publish it does. Split the job in two: build-pr-ota builds and assembles the bundle with no dependency on the fingerprint (so it runs alongside it) and hands it over as a short-lived artifact, and publish-pr-ota keeps the fingerprint gate and does just the denis publish. A dirty fingerprint now discards an already-built bundle instead of saving the build, which costs a runner but takes the fingerprint's wall clock off the critical path of every fingerprint-clean PR. Since the build job already has the EAS CLI set up, it also reads the native build numbers and exposes them (along with the release version) as job outputs, so the publish job needs neither EXPO_TOKEN nor node_modules and can invoke denisPublish.sh directly rather than through the use-build-number wrapper. denisPublish.sh gains SKIP_BUNDLE_ASSEMBLY for that case, where bundleTempDir arrives as an artifact and ./dist is not present; without it the script assembles the directory as before, so the main-branch deploy is unaffected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UL83tcSq9MvwBkUGJGRyWV
444 lines
18 KiB
YAML
444 lines
18 KiB
YAML
# Credit for fingerprint action https://github.com/expo/expo
|
||
# https://github.com/expo/expo/blob/main/.github/workflows/pr-labeler.yml
|
||
---
|
||
name: PR Tests
|
||
|
||
on:
|
||
push:
|
||
branches: [main]
|
||
pull_request:
|
||
types: [opened, synchronize]
|
||
|
||
concurrency:
|
||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
|
||
cancel-in-progress: true
|
||
|
||
# Permissions are granted per-job below; anything unlisted defaults to none.
|
||
# pull-requests: write is needed by sticky-pull-request-comment to post the
|
||
# bundle-size and fingerprint diffs and the PR OTA install link
|
||
permissions: {}
|
||
|
||
# denis release tag in bluesky-social/tango whose linux-amd64 binary the PR OTA
|
||
# publish 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:
|
||
runs-on: ubuntu-24.04
|
||
if: ${{ github.event_name == 'push' }}
|
||
permissions:
|
||
contents: read
|
||
steps:
|
||
- name: ⬇️ Checkout
|
||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||
|
||
- 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: ⬇️ Get base stats from cache
|
||
id: get-base-stats
|
||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||
with:
|
||
path: stats.json
|
||
key: stats-base-main-${{ github.sha }}
|
||
|
||
- name: 🔦 Generate stats file for base commit
|
||
if: ${{ !steps.get-base-stats.outputs.cache-hit }}
|
||
run: |
|
||
pnpm install
|
||
pnpm intl:build
|
||
pnpm generate-webpack-stats-file
|
||
|
||
- name: ⬆️ Save base stats to cache
|
||
if: ${{ !steps.get-base-stats.outputs.cache-hit }}
|
||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||
with:
|
||
path: stats.json
|
||
key: stats-base-main-${{ github.sha }}
|
||
|
||
webpack-analyzer:
|
||
runs-on: ubuntu-24.04
|
||
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.event_name == 'pull_request'}}
|
||
permissions:
|
||
contents: read
|
||
pull-requests: write
|
||
steps:
|
||
- name: ⬇️ Checkout
|
||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||
with:
|
||
fetch-depth: 0
|
||
|
||
- 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: 🌿 Ensure tracking relevant branches and checkout base
|
||
env:
|
||
HEAD_REF: ${{ github.head_ref }}
|
||
BASE_REF: ${{ github.base_ref }}
|
||
run: |
|
||
git checkout $HEAD_REF
|
||
git checkout $BASE_REF
|
||
|
||
- name: 🔍 Get the base commit
|
||
id: base-commit
|
||
env:
|
||
BASE_REF: ${{ github.base_ref }}
|
||
run: echo base-commit=$(git log -n 1 $BASE_REF --pretty=format:'%H') >> "$GITHUB_OUTPUT"
|
||
|
||
- name: 🔀 Merge PR commit
|
||
env:
|
||
HEAD_REF: ${{ github.head_ref }}
|
||
run: |
|
||
# Have to set a git config for the merge to work
|
||
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
||
git config --global user.name "github-actions[bot]"
|
||
git merge --no-edit $HEAD_REF
|
||
pnpm install
|
||
pnpm intl:build
|
||
|
||
- name: 🔦 Generate stats file for PR
|
||
run: |
|
||
pnpm generate-webpack-stats-file
|
||
mv stats.json ../stats-new.json
|
||
|
||
- name: ⬇️ Get base stats from cache
|
||
id: get-base-stats
|
||
# Restore-only prevents PR-scoped fallback builds from creating caches.
|
||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||
with:
|
||
path: stats.json
|
||
key: stats-base-main-${{ steps.base-commit.outputs.base-commit }}
|
||
|
||
- name: ⏪ Restore to base commit
|
||
if: ${{ !steps.get-base-stats.outputs.cache-hit }}
|
||
env:
|
||
BASE_COMMIT: ${{ steps.base-commit.outputs.base-commit }}
|
||
run: |
|
||
git reset "$BASE_COMMIT"
|
||
git restore .
|
||
|
||
- name: 🔦 Generate stats file from base commit
|
||
if: ${{ !steps.get-base-stats.outputs.cache-hit }}
|
||
run: |
|
||
pnpm install
|
||
pnpm intl:build
|
||
pnpm generate-webpack-stats-file
|
||
|
||
- name: % Get diff
|
||
id: get-diff
|
||
uses: NejcZdovc/bundle-size-diff@5321de41d2d62a7b0f4d6e60f59d1280a0034160 # v1.1.0
|
||
with:
|
||
base_path: "stats.json"
|
||
pr_path: "../stats-new.json"
|
||
excluded_assets: "(.+).chunk.js|(.+).js.map|(.+).json|(.+).png|(.+).svg|(.+).webp|(.+).jpg|(.+).ico"
|
||
|
||
- name: 💬 Drop a comment
|
||
uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
|
||
with:
|
||
header: bundle-diff
|
||
message: |
|
||
| Old size | New size | Diff |
|
||
|----------|----------|-----------------------|
|
||
| ${{ steps.get-diff.outputs.base_file_string }} | ${{ steps.get-diff.outputs.pr_file_string }} | ${{ steps.get-diff.outputs.diff_file_string }} (${{ steps.get-diff.outputs.percent }}%) |
|
||
---
|
||
|
||
fingerprint-native:
|
||
runs-on: ubuntu-22.04
|
||
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.event_name == 'pull_request'}}
|
||
permissions:
|
||
contents: read
|
||
pull-requests: write
|
||
outputs:
|
||
# Empty when the native surface is unchanged, 'true' when it changed.
|
||
# publish-pr-ota gates on this.
|
||
includes-changes: ${{ steps.fingerprint.outputs.includes-changes }}
|
||
steps:
|
||
- name: ⬇️ Checkout
|
||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||
with:
|
||
fetch-depth: 100
|
||
|
||
- name: ⬇️ Fetch commits from base branch
|
||
run: git fetch origin main:main --depth 100
|
||
if: github.event_name == 'pull_request'
|
||
|
||
- 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: 📷 Check fingerprint and install dependencies
|
||
id: fingerprint
|
||
timeout-minutes: 5
|
||
uses: bluesky-social/github-actions/fingerprint-native@b5556913e4aef3964cfd5936d0add3fc0d809bdb # v0.2.0
|
||
with:
|
||
profile: pull-request
|
||
|
||
- name: 💬 Drop a comment
|
||
uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
|
||
if: ${{ steps.fingerprint.outputs.includes-changes }}
|
||
with:
|
||
header: fingerprint-diff
|
||
message: |
|
||
The Pull Request introduced fingerprint changes against the base commit:
|
||
<details><summary>Fingerprint diff</summary>
|
||
|
||
```json
|
||
${{ steps.fingerprint.outputs.diff }}
|
||
```
|
||
|
||
</details>
|
||
|
||
---
|
||
*Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
|
||
|
||
- name: 💬 Delete comment
|
||
uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
|
||
if: ${{ !steps.fingerprint.outputs.includes-changes }}
|
||
with:
|
||
header: fingerprint-diff
|
||
delete: true
|
||
|
||
# publish-pr-ota is skipped once the fingerprint changes, so any install
|
||
# link left over from an earlier fingerprint-clean commit on this PR now
|
||
# points at a bundle that no longer matches the PR. Drop it.
|
||
- name: 💬 Delete stale OTA install comment
|
||
uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
|
||
if: ${{ steps.fingerprint.outputs.includes-changes }}
|
||
with:
|
||
header: pull-request-ota
|
||
delete: true
|
||
|
||
- name: 🏷️ Label as fingerprint changed
|
||
if: ${{ steps.fingerprint.outputs.includes-changes }}
|
||
env:
|
||
GH_TOKEN: ${{ github.token }}
|
||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||
run: |
|
||
gh pr edit "$PR_NUMBER" --add-label "bot: fingerprint changed" || true
|
||
|
||
- name: 🏷️ Remove fingerprint changed label
|
||
if: ${{ !steps.fingerprint.outputs.includes-changes }}
|
||
env:
|
||
GH_TOKEN: ${{ github.token }}
|
||
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-<N> 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).
|
||
#
|
||
# Bot authors are excluded: Dependabot pushes in-repo branches, so it passes
|
||
# the fork guard, but GitHub withholds repo secrets from Dependabot-triggered
|
||
# runs. EXPO_TOKEN is then empty and the job fails at setup — a red check on
|
||
# every dependabot PR. There is no OTA preview worth publishing for a
|
||
# dependency bump anyway.
|
||
#
|
||
# The bundle is built here rather than in publish-pr-ota so it runs in
|
||
# PARALLEL with fingerprint-native instead of after it: both are multi-minute
|
||
# jobs, and the bundle does not depend on the fingerprint verdict - only the
|
||
# decision to publish it does. This job deliberately carries no AWS or denis
|
||
# credentials; it only produces an artifact. When the fingerprint turns out to
|
||
# be dirty the bundle is simply discarded unpublished, which costs a runner but
|
||
# saves the fingerprint's wall clock on every clean PR.
|
||
build-pr-ota:
|
||
name: Build PR OTA bundle
|
||
runs-on: ubuntu-latest
|
||
if: >-
|
||
github.event_name == 'pull_request' &&
|
||
github.event.pull_request.head.repo.full_name == github.repository &&
|
||
github.event.pull_request.user.type != 'Bot'
|
||
concurrency:
|
||
group: pr-ota-build-${{ github.event.pull_request.number }}
|
||
cancel-in-progress: true
|
||
permissions:
|
||
contents: read
|
||
outputs:
|
||
release-version: ${{ steps.env.outputs.release-version }}
|
||
ios-build-number: ${{ steps.build-info.outputs.BSKY_IOS_BUILD_NUMBER }}
|
||
android-build-number: ${{ steps.build-info.outputs.BSKY_ANDROID_VERSION_CODE }}
|
||
steps:
|
||
- name: ⬇️ Checkout
|
||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||
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
|
||
|
||
# Assembling here (rather than inside denisPublish.sh) keeps the handoff to
|
||
# the publish job to just the files denis uploads, instead of all of ./dist
|
||
# plus a Node toolchain to process it.
|
||
- name: 📦 Assemble bundle directory
|
||
run: node scripts/bundleUpdate.js
|
||
|
||
# Read from the global EAS counters while the EAS CLI is still set up, so
|
||
# the publish job needs neither EXPO_TOKEN nor node_modules. These are the
|
||
# numbers the install link advertises, so the publish must be pinned to the
|
||
# same ones.
|
||
- name: 🔢 Get native build numbers
|
||
id: build-info
|
||
run: bash scripts/setGitHubOutput.sh
|
||
|
||
# The name is keyed on the run rather than the attempt so that re-running
|
||
# only the failed jobs still finds the bundle this run already built;
|
||
# overwrite keeps a full re-run (which rebuilds it) from colliding with the
|
||
# previous attempt's upload.
|
||
- name: 🚀 Upload bundle
|
||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||
with:
|
||
name: pr-ota-bundle-${{ github.run_id }}
|
||
path: bundleTempDir
|
||
retention-days: 1
|
||
if-no-files-found: error
|
||
overwrite: true
|
||
|
||
# Publish the bundle built above, once fingerprint-native has returned a clean
|
||
# verdict. An OTA can only carry JS, so once the native surface changes the
|
||
# published bundle no longer represents the PR and installing it on a
|
||
# store/TestFlight client is misleading at best. Those PRs need a native build
|
||
# instead. A skipped or failed fingerprint job also skips this one - without a
|
||
# verdict we can't say the OTA is representative.
|
||
#
|
||
# This is kept separate from build-pr-ota so the fingerprint gate holds back
|
||
# only the publish, which is quick, rather than the whole bundle build.
|
||
publish-pr-ota:
|
||
name: Publish PR OTA to denis
|
||
needs: [fingerprint-native, build-pr-ota]
|
||
runs-on: ubuntu-latest
|
||
if: >-
|
||
github.event_name == 'pull_request' &&
|
||
github.event.pull_request.head.repo.full_name == github.repository &&
|
||
github.event.pull_request.user.type != 'Bot' &&
|
||
needs.fingerprint-native.outputs.includes-changes != 'true'
|
||
concurrency:
|
||
group: pr-ota-publish-${{ github.event.pull_request.number }}
|
||
cancel-in-progress: true
|
||
permissions:
|
||
id-token: write
|
||
contents: read
|
||
steps:
|
||
# Needed for scripts/denisPublish.sh and the local composite actions; no
|
||
# dependency install, since denis is a downloaded binary.
|
||
- name: ⬇️ Checkout
|
||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||
with:
|
||
ref: ${{ github.event.pull_request.head.sha }}
|
||
|
||
- name: ⬇️ Download bundle
|
||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||
with:
|
||
name: pr-ota-bundle-${{ github.run_id }}
|
||
path: bundleTempDir
|
||
|
||
- name: ☁️ Configure AWS credentials (denis, PR-scoped)
|
||
uses: aws-actions/configure-aws-credentials@517a711dbcd0e402f90c77e7e2f81e849156e31d # v6.2.2
|
||
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 }}
|
||
|
||
# The use-build-number wrapper is skipped: every value it would look up is
|
||
# already fixed by the build job, and calling it would mean installing
|
||
# dependencies again just to shell out to the EAS CLI.
|
||
- name: 🚀 Publish OTA to denis (S3)
|
||
run: bash scripts/denisPublish.sh
|
||
env:
|
||
# bundleTempDir arrived as an artifact; ./dist is not in this job.
|
||
SKIP_BUNDLE_ASSEMBLY: '1'
|
||
CHANNEL_NAME: pull-request-${{ github.event.pull_request.number }}
|
||
# Pin the publish to the same values exposed in the install link.
|
||
RUNTIME_VERSION: ${{ needs.build-pr-ota.outputs.release-version }}
|
||
BSKY_IOS_BUILD_NUMBER: ${{ needs.build-pr-ota.outputs.ios-build-number }}
|
||
BSKY_ANDROID_VERSION_CODE: ${{ needs.build-pr-ota.outputs.android-build-number }}
|
||
|
||
comment-pr-ota:
|
||
name: Comment PR OTA install link
|
||
needs: [build-pr-ota, publish-pr-ota]
|
||
runs-on: ubuntu-latest
|
||
permissions:
|
||
pull-requests: write
|
||
steps:
|
||
- name: 💬 Drop OTA install comment
|
||
uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
|
||
with:
|
||
header: pull-request-ota
|
||
message: |
|
||
The OTA deployment for this PR was successful! You may now apply it by either scanning the QR code or opening the deep link below in your browser:
|
||
|
||
<img src="https://bsky-qr.vercel.app?channel=pull-request-${{ github.event.pull_request.number }}&releaseVersion=${{ needs.build-pr-ota.outputs.release-version }}&iosBuildNumber=${{ needs.build-pr-ota.outputs.ios-build-number }}&androidBuildNumber=${{ needs.build-pr-ota.outputs.android-build-number }}" width="300" height="300" alt="QR code for the PR OTA deployment">
|
||
|
||
`bluesky://intent/apply-ota?channel=pull-request-${{ github.event.pull_request.number }}&releaseVersion=${{ needs.build-pr-ota.outputs.release-version }}&iosBuildNumber=${{ needs.build-pr-ota.outputs.ios-build-number }}&androidBuildNumber=${{ needs.build-pr-ota.outputs.android-build-number }}`
|