Require immutable tags for production OTAs

This commit is contained in:
vineyardbovines
2026-08-26 10:16:32 -04:00
parent 5b88335bb1
commit 77a59d685f
4 changed files with 166 additions and 59 deletions
+26 -44
View File
@@ -15,16 +15,8 @@ on:
- production - production
runtimeVersion: runtimeVersion:
type: string type: string
description: Runtime version (in x.x.x format) that this update is for description: Runtime version for non-production manual updates (production derives this from the OTA manifest)
required: true required: false
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 # Deploys happen via EAS using EXPO_TOKEN; the GITHUB_TOKEN only checks out code
permissions: permissions:
@@ -54,8 +46,26 @@ jobs:
# A version bump forces a native build even if the fingerprint is unchanged # A version bump forces a native build even if the fingerprint is unchanged
changes-detected: ${{ steps.fingerprint.outputs.includes-changes || changes-detected: ${{ steps.fingerprint.outputs.includes-changes ||
steps.version.outputs.version-changed }} 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: 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 - name: 🔑 Check for EXPO_TOKEN
run: > run: >
if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then
@@ -63,34 +73,6 @@ jobs:
exit 1 exit 1
fi 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 - name: ⬇️ Fetch commits from base branch
if: ${{ github.ref != 'refs/heads/main' }} if: ${{ github.ref != 'refs/heads/main' }}
run: git fetch origin main:main --depth 100 run: git fetch origin main:main --depth 100
@@ -160,7 +142,7 @@ jobs:
uses: bluesky-social/github-actions/fingerprint-native@abc6a46eb4badf243f55bfd7d6cec42722456300 # v0.3.0 uses: bluesky-social/github-actions/fingerprint-native@abc6a46eb4badf243f55bfd7d6cec42722456300 # v0.3.0
with: with:
profile: ${{ inputs.channel || 'testflight' }} 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; # The recordBaseline job uploads this marker after a successful deploy;
# on the native path, that requires both builds to succeed. A missing # on the native path, that requires both builds to succeed. A missing
# marker forces native builds so they can seed the baseline safely. # marker forces native builds so they can seed the baseline safely.
@@ -247,12 +229,12 @@ jobs:
!steps.version.outputs.version-changed }} !steps.version.outputs.version-changed }}
run: pnpm use-build-number bash scripts/denisPublish.sh run: pnpm use-build-number bash scripts/denisPublish.sh
env: env:
RUNTIME_VERSION: ${{ inputs.runtimeVersion }} RUNTIME_VERSION: ${{ steps.target.outputs.runtime-version }}
CHANNEL_NAME: ${{ inputs.channel || 'testflight' }} CHANNEL_NAME: ${{ inputs.channel || 'testflight' }}
# When set (required for production), these take precedence over the # Production values come from the reviewed manifest. On automatic
# global EAS counters inside the use-build-number wrapper # testflight runs these remain empty and the wrapper reads EAS.
BSKY_IOS_BUILD_NUMBER: ${{ inputs.iosBuildNumber }} BSKY_IOS_BUILD_NUMBER: ${{ steps.target.outputs.ios-build-number }}
BSKY_ANDROID_VERSION_CODE: ${{ inputs.androidVersionCode }} BSKY_ANDROID_VERSION_CODE: ${{ steps.target.outputs.android-version-code }}
buildIfNecessaryIOS: buildIfNecessaryIOS:
name: Build and Submit iOS name: Build and Submit iOS
+33
View File
@@ -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 <commit-sha>
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.
+30 -15
View File
@@ -63,7 +63,30 @@ Run this and commit the result as the last commit on the OTA branch.
pnpm intl:release pnpm intl:release
``` ```
### 6. Run the GitHub actions ### 6. Declare and tag the OTA
Add a reviewed OTA intent at `.ota/<version>-<sequence>.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 <commit-sha>
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 You'll need to run two separate actions: one to deploy the iOS/Android OTA
itself, and one to build the web Docker container. itself, and one to build the web Docker container.
@@ -73,20 +96,12 @@ and run the action.
| Steps | | | 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] > [!NOTE]
> Production OTAs are bound to the specific native build they target, so the > Production OTAs are bound to the specific native build they target. The
> workflow requires the build numbers to be entered manually. There is no need > workflow rejects branches and validates that the tag, intent, package
> to change the global EAS build counters (and doing so is no longer necessary > version, native release ancestry, and build numbers agree before publishing.
> 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.
**For web,** head to [Actions > **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) 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) | | 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, 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 copy the most recent commit hash. Post this hash in `#ops-deploys` and request
someone with web deploy access deploy the built container. 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 In about five minutes, the new deployment should be deployed and devices will
begin downloading and installing in the background. begin downloading and installing in the background.
+77
View File
@@ -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-<version>-<sequence>, 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"