From 77a59d685f439702586df3792b6704d1b78e5dc9 Mon Sep 17 00:00:00 2001 From: vineyardbovines Date: Wed, 26 Aug 2026 10:16:32 -0400 Subject: [PATCH] Require immutable tags for production OTAs --- .../workflows/bundle-deploy-eas-update.yml | 70 +++++++---------- .ota/README.md | 33 ++++++++ docs/deploy-ota.md | 45 +++++++---- scripts/resolveOtaTarget.sh | 77 +++++++++++++++++++ 4 files changed, 166 insertions(+), 59 deletions(-) create mode 100644 .ota/README.md create mode 100644 scripts/resolveOtaTarget.sh diff --git a/.github/workflows/bundle-deploy-eas-update.yml b/.github/workflows/bundle-deploy-eas-update.yml index d4e18cc510..0320e1b99e 100644 --- a/.github/workflows/bundle-deploy-eas-update.yml +++ b/.github/workflows/bundle-deploy-eas-update.yml @@ -15,16 +15,8 @@ on: - 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) + 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 permissions: @@ -54,8 +46,26 @@ 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 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + 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 @@ -63,34 +73,6 @@ jobs: 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 @@ -160,7 +142,7 @@ jobs: uses: bluesky-social/github-actions/fingerprint-native@abc6a46eb4badf243f55bfd7d6cec42722456300 # v0.3.0 with: profile: ${{ inputs.channel || 'testflight' }} - previous-commit-tag: ${{ inputs.runtimeVersion }} + previous-commit-tag: ${{ steps.target.outputs.runtime-version }} # 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. @@ -247,12 +229,12 @@ jobs: !steps.version.outputs.version-changed }} run: pnpm use-build-number bash scripts/denisPublish.sh env: - RUNTIME_VERSION: ${{ inputs.runtimeVersion }} + RUNTIME_VERSION: ${{ steps.target.outputs.runtime-version }} 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 }} + # 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 }} buildIfNecessaryIOS: name: Build and Submit iOS diff --git a/.ota/README.md b/.ota/README.md new file mode 100644 index 0000000000..dbc21b5538 --- /dev/null +++ b/.ota/README.md @@ -0,0 +1,33 @@ +# Production OTA intents + +Production OTA metadata is committed here so that it is reviewed with the code +being deployed. This is similar to a changeset: the file declares the release +target, while an immutable Git tag declares the exact source commit. + +For the first OTA targeting native release `1.131.1`, create +`.ota/1.131.1-1.json`: + +```json +{ + "runtimeVersion": "1.131.1", + "iosBuildNumber": 12345, + "androidVersionCode": 67890 +} +``` + +Commit and review the intent together with the OTA changes. After the OTA branch +is ready, tag its tip using the matching name: + +```sh +git tag ota-1.131.1-1 +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. + +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. diff --git a/docs/deploy-ota.md b/docs/deploy-ota.md index 52a319bf38..bb24e9c579 100644 --- a/docs/deploy-ota.md +++ b/docs/deploy-ota.md @@ -63,7 +63,30 @@ Run this and commit the result as the last commit on the OTA branch. pnpm intl:release ``` -### 6. Run the GitHub actions +### 6. Declare and tag the OTA + +Add a reviewed OTA intent at `.ota/-.json`. For example, +`.ota/1.131.1-1.json`: + +```json +{ + "runtimeVersion": "1.131.1", + "iosBuildNumber": 12345, + "androidVersionCode": 67890 +} +``` + +Commit the intent, then tag the exact commit that should be deployed: + +```sh +git tag ota-1.131.1-1 +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. @@ -73,20 +96,12 @@ and run the action. | Steps | | | ----- | --- | -| Select your OTA branch `1.x.0-ota-x`, select `production` in the dropdown, enter the git tag of the latest release `1.x.0`, enter the iOS build number and Android version code you found in **Step 1**, and click "Run workflow" | ![workflow](./img/ota_action.png) | +| 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) | > [!NOTE] -> Production OTAs are bound to the specific native build they target, so the -> workflow requires the build numbers to be entered manually. There is no need -> to change the global EAS build counters (and doing so is no longer necessary -> for OTAs - they are only used when producing new native builds). - -> [!NOTE] -> If you do enter an incorrect version here, the deployment will either: -> - Fail, because the action cannot find a commit with your misentered version -> - Succeed, but with no users receiving the update. This is because the -> version and build numbers you entered will not match any clients in the -> wild, so none will be able to receive the update. +> Production OTAs are bound to the specific native build they target. The +> workflow rejects branches and validates that the tag, intent, package +> version, native release ancestry, and build numbers agree before publishing. **For web,** head to [Actions > build-and-push-bskyweb-aws](https://github.com/bluesky-social/social-app/actions/workflows/build-and-push-bskyweb-aws.yaml) @@ -96,13 +111,13 @@ and run the action. | ----- | --- | | Select your OTA branch `1.x.0-ota-x` and click "Run workflow" | ![workflow](./img/web_action.png) | -### 7. Deploy web +### 8. Deploy web Once the web Docker container build finishes, go to your `1.x.0-ota-x` branch, copy the most recent commit hash. Post this hash in `#ops-deploys` and request someone with web deploy access deploy the built container. -### 8. Confirm successful deployment +### 9. Confirm successful deployment In about five minutes, the new deployment should be deployed and devices will begin downloading and installing in the background. diff --git a/scripts/resolveOtaTarget.sh b/scripts/resolveOtaTarget.sh new file mode 100644 index 0000000000..d4c7e448ca --- /dev/null +++ b/scripts/resolveOtaTarget.sh @@ -0,0 +1,77 @@ +#!/bin/bash +set -o errexit +set -o pipefail +set -o nounset + +error() { + echo "::error::$*" + exit 1 +} + +is_version() { + [[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] +} + +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}'." + +if [[ ! "${GITHUB_REF_NAME:-}" =~ ^ota-([0-9]+\.[0-9]+\.[0-9]+)-([1-9][0-9]*)$ ]]; then + error "Production OTA tag must use ota--, for example ota-1.131.1-1." +fi + +tag_version="${BASH_REMATCH[1]}" +sequence="${BASH_REMATCH[2]}" +manifest=".ota/${tag_version}-${sequence}.json" +[ -f "$manifest" ] || error "The OTA tag must contain its reviewed manifest at $manifest." + +jq -e ' + type == "object" and + (keys | sort) == ["androidVersionCode", "iosBuildNumber", "runtimeVersion"] and + (.runtimeVersion | type == "string") and + (.iosBuildNumber | type == "number" and floor == .) and + (.androidVersionCode | type == "number" and floor == .) +' "$manifest" >/dev/null || error "$manifest has an invalid schema." + +runtime_version="$(jq -r '.runtimeVersion' "$manifest")" +ios_build_number="$(jq -r '.iosBuildNumber' "$manifest")" +android_version_code="$(jq -r '.androidVersionCode' "$manifest")" +package_version="$(jq -r '.version' package.json)" + +is_version "$runtime_version" || error "Manifest runtimeVersion must use x.y.z format." +is_build_number "$ios_build_number" || error "Manifest iosBuildNumber must be a positive integer." +is_build_number "$android_version_code" || error "Manifest androidVersionCode must be a positive integer." +[ "$runtime_version" = "$tag_version" ] || + error "Manifest runtimeVersion '$runtime_version' does not match OTA tag version '$tag_version'." +[ "$runtime_version" = "$package_version" ] || + error "Manifest runtimeVersion '$runtime_version' does not match package.json version '$package_version'." + +# The native release tag is both the fingerprint baseline and proof that this +# OTA was built on top of the native release it targets. +git rev-parse --verify --quiet "refs/tags/$runtime_version^{commit}" >/dev/null || + error "Native release tag '$runtime_version' does not exist." +git merge-base --is-ancestor "$runtime_version" HEAD || + error "OTA commit $(git rev-parse HEAD) is not descended from native release $runtime_version." + +echo "Production OTA target validated:" +echo " source: $(git rev-parse HEAD) ($GITHUB_REF_NAME)" +echo " runtime: $runtime_version" +echo " iOS build: $ios_build_number" +echo " Android build: $android_version_code" + +echo "runtime-version=$runtime_version" >> "$GITHUB_OUTPUT" +echo "ios-build-number=$ios_build_number" >> "$GITHUB_OUTPUT" +echo "android-version-code=$android_version_code" >> "$GITHUB_OUTPUT"