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
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
+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
```
### 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
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.
+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"