Compare commits

...

3 Commits

Author SHA1 Message Date
Samuel Newman 212f700937 stage fingerprint ota workflows and native receipts 2026-09-05 18:56:37 +03:00
Samuel Newman 50752603ce handle fingerprint ota previews and request lifecycles 2026-09-05 18:56:00 +03:00
Samuel Newman badce43161 add canonical fingerprint ota export tooling 2026-09-05 18:55:17 +03:00
27 changed files with 2703 additions and 173 deletions
@@ -0,0 +1,126 @@
name: Native build receipt
description: Verify the packaged Expo runtime and record an immutable native artifact receipt.
inputs:
platform:
required: true
profile:
required: true
artifact-path:
required: true
native-build-number:
required: true
default-channel:
required: true
output-directory:
required: false
default: native-build-receipt
runs:
using: composite
steps:
- name: Resolve and verify packaged runtime
shell: bash
env:
PLATFORM: ${{ inputs.platform }}
PROFILE: ${{ inputs.profile }}
ARTIFACT_PATH: ${{ inputs.artifact-path }}
BUILD_NUMBER: ${{ inputs.native-build-number }}
DEFAULT_CHANNEL: ${{ inputs.default-channel }}
OUTPUT_DIRECTORY: ${{ inputs.output-directory }}
run: |
set -euo pipefail
[[ "$PLATFORM" = ios || "$PLATFORM" = android ]] || { echo "::error::platform must be ios or android"; exit 1; }
[[ "$PROFILE" = production || "$PROFILE" = testflight ]] || { echo "::error::profile must be production or testflight"; exit 1; }
[ "$DEFAULT_CHANNEL" = "$PROFILE" ] || { echo "::error::default channel must match the native profile"; exit 1; }
[[ "$BUILD_NUMBER" =~ ^[0-9]+$ ]] || { echo "::error::native build number must be numeric"; exit 1; }
[ -f "$ARTIFACT_PATH" ] || { echo "::error::native artifact does not exist"; exit 1; }
mkdir -p "$OUTPUT_DIRECTORY"
report="$OUTPUT_DIRECTORY/fingerprint-report.json"
node scripts/ota/resolve-runtime.mjs \
--platform "$PLATFORM" \
--profile "$PROFILE" \
--source-commit "$GITHUB_SHA" \
--output "$report"
if [ "$PLATFORM" = ios ]; then
runtime_entries=$(unzip -Z1 "$ARTIFACT_PATH" | grep -E '^Payload/[^/]+\.app/EXUpdates\.bundle/fingerprint$' || true)
if [ "$(printf '%s\n' "$runtime_entries" | grep -c .)" -ne 1 ]; then
echo "::error::Expected exactly one packaged iOS Expo fingerprint"
exit 1
fi
runtime_entry="$runtime_entries"
inspect_dir=$(mktemp -d)
unzip -q "$ARTIFACT_PATH" 'Payload/*.app/Info.plist' 'Payload/*.app/Expo.plist' -d "$inspect_dir"
info_plist=$(find "$inspect_dir" -name Info.plist -print -quit)
expo_plist=$(find "$inspect_dir" -name Expo.plist -print -quit)
packaged_build_number=$(/usr/libexec/PlistBuddy -c 'Print CFBundleVersion' "$info_plist")
packaged_channel=$(/usr/libexec/PlistBuddy -c 'Print EXUpdatesRequestHeaders:expo-channel-name' "$expo_plist")
packaged_runtime_configuration=$(/usr/libexec/PlistBuddy -c 'Print EXUpdatesRuntimeVersion' "$expo_plist")
else
runtime_entries=$(unzip -Z1 "$ARTIFACT_PATH" | grep -E '(^|/)assets/fingerprint$' || true)
if [ "$(printf '%s\n' "$runtime_entries" | grep -c .)" -ne 1 ]; then
echo "::error::Expected exactly one packaged Android Expo fingerprint"
exit 1
fi
runtime_entry="$runtime_entries"
inspect_dir=$(mktemp -d)
: > "$inspect_dir/runtime-resource.txt"
if [[ "$ARTIFACT_PATH" != *.aab ]]; then
echo "::error::Android receipt requires an .aab so bundletool can bind compiled resource identities"
exit 1
fi
bundletool dump manifest --bundle="$ARTIFACT_PATH" --module=base > "$inspect_dir/manifest.xml"
bundletool dump resources --bundle="$ARTIFACT_PATH" --resource=string/expo_runtime_version --values > "$inspect_dir/runtime-resource.txt"
packaged_build_number=$(bundletool dump manifest --bundle="$ARTIFACT_PATH" --module=base --xpath=/manifest/@android:versionCode)
packaged_config=$(node .github/scripts/native-receipt-android.mjs "$inspect_dir/manifest.xml" "$inspect_dir/runtime-resource.txt")
packaged_channel=$(jq -er .channel <<<"$packaged_config")
packaged_runtime_configuration=$(jq -er .runtimeConfiguration <<<"$packaged_config")
fi
if [ -z "$runtime_entry" ]; then
echo "::error::Could not find the packaged Expo fingerprint in $ARTIFACT_PATH"
exit 1
fi
packaged_runtime=$(unzip -p "$ARTIFACT_PATH" "$runtime_entry" | tr -d '\r\n')
if [ "$packaged_runtime_configuration" != 'file:fingerprint' ]; then
echo "::error::Packaged Expo runtime configuration does not use the fingerprint resource"
exit 1
fi
if [ "$packaged_build_number" != "$BUILD_NUMBER" ]; then
echo "::error::Packaged build number does not match the workflow output"
exit 1
fi
if [ "$packaged_channel" != "$DEFAULT_CHANNEL" ]; then
echo "::error::Packaged update channel does not match the expected native channel"
exit 1
fi
calculated_runtime=$(jq -r .runtimeVersion "$report")
if [ "$packaged_runtime" != "$calculated_runtime" ]; then
echo "::error::Packaged runtime does not match the canonical build calculation"
exit 1
fi
artifact_digest=$(shasum -a 256 "$ARTIFACT_PATH" | cut -d ' ' -f 1)
app_version=$(jq -r .version package.json)
jq -n \
--arg platform "$PLATFORM" \
--arg profile "$PROFILE" \
--arg channel "$DEFAULT_CHANNEL" \
--arg appVersion "$app_version" \
--arg nativeBuildNumber "$BUILD_NUMBER" \
--arg runtimeVersion "$packaged_runtime" \
--arg sourceCommit "$GITHUB_SHA" \
--arg artifactDigest "$artifact_digest" \
--arg buildRunUrl "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \
'{schemaVersion: 1, platform: $platform, nativeProfile: $profile,
defaultChannel: $channel, appVersion: $appVersion,
nativeBuildNumber: $nativeBuildNumber, runtimeVersion: $runtimeVersion,
sourceCommit: $sourceCommit, fingerprintPolicyVersion: 1,
fingerprintToolVersion: (input.fingerprintToolVersion),
artifactDigest: $artifactDigest, buildRunUrl: $buildRunUrl,
fingerprintReportRef: "fingerprint-report.json"}' \
"$report" > "$OUTPUT_DIRECTORY/receipt.json"
jq -e --arg runtime "$packaged_runtime" \
'.schemaVersion == 1 and .runtimeVersion == $runtime' \
"$OUTPUT_DIRECTORY/receipt.json" >/dev/null
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env node
import fs from 'node:fs/promises'
import process from 'node:process'
import {pathToFileURL} from 'node:url'
export function parsePositiveInteger(value, label) {
if (!/^[1-9][0-9]*$/.test(value ?? '')) {
throw new Error(`${label} must be a positive integer`)
}
return value
}
export function expectedArtifactName({platform, buildNumber, runId, attempt}) {
if (!['ios', 'android'].includes(platform)) {
throw new Error('platform must be ios or android')
}
if (!/^[0-9]+$/.test(buildNumber ?? '')) {
throw new Error('build-number must be numeric')
}
parsePositiveInteger(runId, 'run-id')
parsePositiveInteger(attempt, 'run-attempt')
return `native-build-${platform}-production-${buildNumber}-${runId}-${attempt}`
}
export function validateTrustedRun(run, {repository, workflow}) {
if (
run?.conclusion !== 'success' ||
run?.head_repository?.full_name !== repository ||
run?.path !== workflow ||
!/^[0-9a-f]{40}$/.test(run?.head_sha ?? '')
) {
throw new Error('Receipt artifact came from an untrusted native build run')
}
return run.head_sha
}
async function githubJson(url, token) {
const response = await fetch(url, {
signal: AbortSignal.timeout(15_000),
headers: {
accept: 'application/vnd.github+json',
authorization: `Bearer ${token}`,
'x-github-api-version': '2022-11-28',
},
})
if (!response.ok)
throw new Error(`GitHub API request failed (${response.status})`)
return response.json()
}
async function findArtifact({apiUrl, repository, runId, name, token}) {
for (let page = 1; page <= 10; page++) {
const result = await githubJson(
`${apiUrl}/repos/${repository}/actions/runs/${runId}/artifacts?per_page=100&page=${page}`,
token,
)
const artifact = result.artifacts?.find(
item => item.name === name && !item.expired,
)
if (artifact) return artifact
if (!result.artifacts || result.artifacts.length < 100) return undefined
}
throw new Error('Receipt artifact lookup exceeded 1000 artifacts')
}
async function main(argv = process.argv.slice(2), env = process.env) {
const args = Object.fromEntries(
Array.from({length: argv.length / 2}, (_, index) => [
argv[index * 2]?.replace(/^--/, ''),
argv[index * 2 + 1],
]),
)
const runId = parsePositiveInteger(args['run-id'], 'run-id')
const attempt = parsePositiveInteger(args['run-attempt'], 'run-attempt')
const name = expectedArtifactName({
platform: args.platform,
buildNumber: args['build-number'],
runId,
attempt,
})
const repository = env.GITHUB_REPOSITORY
const token = env.GH_TOKEN
const output = env.GITHUB_OUTPUT
if (!repository || !token || !output)
throw new Error('GitHub Actions environment is incomplete')
let artifact
try {
artifact = await findArtifact({
apiUrl: env.GITHUB_API_URL ?? 'https://api.github.com',
repository,
runId,
name,
token,
})
} catch (error) {
console.error(`Warning: receipt artifact lookup was unavailable: ${error}`)
await fs.appendFile(output, `available=false\nartifact-name=${name}\n`)
return
}
if (!artifact) {
console.error(
`Warning: ${name} is missing or expired; target could not be verified`,
)
await fs.appendFile(output, `available=false\nartifact-name=${name}\n`)
return
}
const workflow = `.github/workflows/build-submit-${args.platform}.yml`
let run
try {
run = await githubJson(
`${env.GITHUB_API_URL ?? 'https://api.github.com'}/repos/${repository}/actions/runs/${runId}`,
token,
)
} catch (error) {
console.error(`Warning: receipt build-run lookup was unavailable: ${error}`)
await fs.appendFile(output, `available=false\nartifact-name=${name}\n`)
return
}
const headSha = validateTrustedRun(run, {repository, workflow})
await fs.appendFile(
output,
`available=true\nartifact-name=${name}\nhead-sha=${headSha}\n`,
)
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch(error => {
console.error(error instanceof Error ? error.message : error)
process.exitCode = 1
})
}
@@ -0,0 +1,50 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
expectedArtifactName,
parsePositiveInteger,
validateTrustedRun,
} from './find-native-receipt.mjs'
test('builds an attempt-scoped artifact name', () => {
assert.equal(
expectedArtifactName({
platform: 'ios',
buildNumber: '123',
runId: '45',
attempt: '2',
}),
'native-build-ios-production-123-45-2',
)
})
test('rejects invalid run identifiers', () => {
assert.throws(() => parsePositiveInteger('0', 'run-id'), /positive integer/)
assert.throws(
() => parsePositiveInteger('1; echo nope', 'run-id'),
/positive integer/,
)
})
test('accepts only the expected successful native workflow', () => {
const expected = {
repository: 'bluesky-social/social-app',
workflow: '.github/workflows/build-submit-android.yml',
}
const run = {
conclusion: 'success',
head_repository: {full_name: expected.repository},
path: expected.workflow,
head_sha: 'a'.repeat(40),
}
assert.equal(validateTrustedRun(run, expected), run.head_sha)
assert.throws(
() =>
validateTrustedRun(
{...run, path: '.github/workflows/evil.yml'},
expected,
),
/untrusted/,
)
})
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env node
import fs from 'node:fs'
const REQUEST_HEADERS_KEY =
'expo.modules.updates.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY'
const RUNTIME_VERSION_KEY = 'expo.modules.updates.EXPO_RUNTIME_VERSION'
function fail(message) {
throw new Error(message)
}
function decodeXml(value) {
return value
.replaceAll('&quot;', '"')
.replaceAll('&apos;', "'")
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&amp;', '&')
}
export function parsePackagedAndroidConfig(manifestXml, runtimeResources) {
const metadataTags = [...manifestXml.matchAll(/<meta-data\b[^>]*>/g)].map(
match => match[0],
)
const tagsNamed = requestedName =>
metadataTags.filter(tag => {
const name = tag.match(/android:name="([^"]+)"/)?.[1]
return decodeXml(name ?? '') === requestedName
})
const matchingTags = tagsNamed(REQUEST_HEADERS_KEY)
if (matchingTags.length !== 1) {
fail(`Expected exactly one ${REQUEST_HEADERS_KEY} manifest entry`)
}
const encodedHeaders = matchingTags[0].match(/android:value="([^"]*)"/)?.[1]
if (encodedHeaders == null) fail('Update request headers have no value')
let headers
try {
headers = JSON.parse(decodeXml(encodedHeaders))
} catch {
fail('Update request headers are not valid JSON')
}
if (
!headers ||
typeof headers !== 'object' ||
Array.isArray(headers) ||
typeof headers['expo-channel-name'] !== 'string' ||
!headers['expo-channel-name']
) {
fail('Update request headers omit expo-channel-name')
}
const runtimeTags = tagsNamed(RUNTIME_VERSION_KEY)
if (runtimeTags.length !== 1) {
fail(`Expected exactly one ${RUNTIME_VERSION_KEY} manifest entry`)
}
const runtimeManifestValue = decodeXml(
runtimeTags[0].match(/android:value="([^"]*)"/)?.[1] ?? '',
)
const resourceIDs = [
...runtimeResources.matchAll(
/^\s*(0x[0-9a-f]+)\s+-\s+string\/expo_runtime_version\s*$/gim,
),
].map(match => match[1].toLowerCase())
if (
runtimeManifestValue === '@string/expo_runtime_version' ||
/^@ref\/0x[0-9a-f]+$/i.test(runtimeManifestValue)
) {
if (resourceIDs.length !== 1) {
fail('Expected exactly one string/expo_runtime_version resource ID')
}
if (
runtimeManifestValue.startsWith('@ref/') &&
runtimeManifestValue.slice('@ref/'.length).toLowerCase() !==
resourceIDs[0]
) {
fail('Runtime manifest reference does not identify expo_runtime_version')
}
const values = [
...runtimeResources.matchAll(
/(?:\[STR\]|value:)\s*(?:\([^)]*\)\s*)?["']([^"']+)["']/gi,
),
].map(match => match[1])
if (values.length === 0) {
fail('Could not parse string/expo_runtime_version value')
}
if (values.some(value => value !== 'file:fingerprint')) {
fail('Packaged Expo runtime resource is not file:fingerprint')
}
} else if (runtimeManifestValue !== 'file:fingerprint') {
fail('Packaged Expo runtime is not file:fingerprint')
}
return {
channel: headers['expo-channel-name'],
runtimeConfiguration: 'file:fingerprint',
}
}
function main() {
const [manifestPath, resourcesPath] = process.argv.slice(2)
if (!manifestPath || !resourcesPath) {
fail('Usage: native-receipt-android.mjs <manifest.xml> <resources.txt>')
}
const result = parsePackagedAndroidConfig(
fs.readFileSync(manifestPath, 'utf8'),
fs.readFileSync(resourcesPath, 'utf8'),
)
process.stdout.write(`${JSON.stringify(result)}\n`)
}
if (
process.argv[1] &&
import.meta.url === new URL(process.argv[1], 'file:').href
) {
try {
main()
} catch (error) {
console.error(error instanceof Error ? error.message : error)
process.exitCode = 1
}
}
@@ -0,0 +1,115 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {parsePackagedAndroidConfig} from './native-receipt-android.mjs'
const manifest = (headers, runtime = '@string/expo_runtime_version') => `
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<meta-data android:name="expo.modules.updates.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY" android:value="${headers}" />
<meta-data android:name="expo.modules.updates.EXPO_RUNTIME_VERSION" android:value="${runtime}" />
</application>
</manifest>`
const resources = value => `
Package 'xyz.blueskyweb.app'
0x7f1200aa - string/expo_runtime_version
config (default): [STR] "${value}"
`
test('extracts the exact EAS channel and fingerprint sentinel', () => {
assert.deepEqual(
parsePackagedAndroidConfig(
manifest('{&quot;expo-channel-name&quot;:&quot;testflight&quot;}'),
resources('file:fingerprint'),
),
{channel: 'testflight', runtimeConfiguration: 'file:fingerprint'},
)
})
test('rejects an ambiguous request-header entry', () => {
const entry = manifest(
'{&quot;expo-channel-name&quot;:&quot;production&quot;}',
)
assert.throws(
() =>
parsePackagedAndroidConfig(entry + entry, resources('file:fingerprint')),
/exactly one/,
)
})
test('rejects a literal runtime even when a fingerprint asset exists', () => {
assert.throws(
() =>
parsePackagedAndroidConfig(
manifest('{&quot;expo-channel-name&quot;:&quot;production&quot;}'),
resources('1.2.3'),
),
/not file:fingerprint/,
)
})
test('accepts a manifest with a direct fingerprint sentinel', () => {
assert.deepEqual(
parsePackagedAndroidConfig(
manifest(
'{&quot;expo-channel-name&quot;:&quot;production&quot;}',
'file:fingerprint',
),
'',
),
{channel: 'production', runtimeConfiguration: 'file:fingerprint'},
)
})
test('binds a compiled runtime reference to the named resource ID', () => {
assert.deepEqual(
parsePackagedAndroidConfig(
manifest(
'{&quot;expo-channel-name&quot;:&quot;testflight&quot;}',
'@ref/0x7f1200aa',
),
resources('file:fingerprint'),
),
{channel: 'testflight', runtimeConfiguration: 'file:fingerprint'},
)
})
test('rejects a compiled reference to a different resource ID', () => {
assert.throws(
() =>
parsePackagedAndroidConfig(
manifest(
'{&quot;expo-channel-name&quot;:&quot;testflight&quot;}',
'@ref/0x7f1300d8',
),
resources('file:fingerprint'),
),
/does not identify expo_runtime_version/,
)
})
test('rejects an ambiguous named runtime resource', () => {
assert.throws(
() =>
parsePackagedAndroidConfig(
manifest(
'{&quot;expo-channel-name&quot;:&quot;testflight&quot;}',
'@ref/0x7f1200aa',
),
`${resources('file:fingerprint')}\n0x7f1200ab - string/expo_runtime_version\n config (default): [STR] "file:fingerprint"`,
),
/exactly one string\/expo_runtime_version resource ID/,
)
})
test('rejects a similarly named header instead of guessing the channel', () => {
assert.throws(
() =>
parsePackagedAndroidConfig(
manifest('{&quot;channel&quot;:&quot;production&quot;}'),
resources('file:fingerprint'),
),
/omit expo-channel-name/,
)
})
+42 -3
View File
@@ -76,7 +76,7 @@ jobs:
cancel-in-progress: false
outputs:
package-version: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}
version-code: ${{ steps.get-build-info.outputs.BSKY_ANDROID_VERSION_CODE }}
version-code: ${{ steps.android-build-number.outputs.build-number || steps.get-build-info.outputs.BSKY_ANDROID_VERSION_CODE }}
steps:
- name: ⬇️ Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -109,6 +109,8 @@ jobs:
- name: 🏗️ EAS Build
uses: ./.github/actions/eas-local-build
env:
OTA_FINGERPRINT_PIPELINE_ENABLED: ${{ vars.OTA_FINGERPRINT_PIPELINE_ENABLED == 'true' && '1' || '' }}
with:
platform: android
profile: ${{ inputs.profile || 'testflight-android' }}
@@ -122,6 +124,43 @@ jobs:
id: get-build-info
run: bash scripts/setGitHubOutput.sh
- name: 🔧 Setup bundletool
if: ${{ vars.OTA_FINGERPRINT_PIPELINE_ENABLED == 'true' }}
uses: amyu/setup-bundletool@cc2e1857284660bd625e43f2c8a45626f034302f # v1.1
with:
version: "1.18.3"
- name: 🔢 Read build number from AAB
if: ${{ vars.OTA_FINGERPRINT_PIPELINE_ENABLED == 'true' }}
id: android-build-number
run: |
build_number=$(bundletool dump manifest --bundle=build.aab --module=base --xpath=/manifest/@android:versionCode)
[[ "$build_number" =~ ^[0-9]+$ ]] || { echo "::error::Could not read numeric versionCode from AAB"; exit 1; }
echo "Android build number: $build_number"
echo "build-number=$build_number" >> "$GITHUB_OUTPUT"
- name: 🧾 Create fingerprint native-build receipt
if: ${{ vars.OTA_FINGERPRINT_PIPELINE_ENABLED == 'true' }}
uses: ./.github/actions/native-build-receipt
env:
OTA_FINGERPRINT_PIPELINE_ENABLED: '1'
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
with:
platform: android
profile: ${{ (inputs.profile || 'testflight-android') == 'production' && 'production' || 'testflight' }}
artifact-path: build.aab
native-build-number: ${{ steps.android-build-number.outputs.build-number }}
default-channel: ${{ (inputs.profile || 'testflight-android') == 'production' && 'production' || 'testflight' }}
- name: ⬆️ Upload fingerprint native-build receipt
if: ${{ vars.OTA_FINGERPRINT_PIPELINE_ENABLED == 'true' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: native-build-android-${{ (inputs.profile || 'testflight-android') == 'production' && 'production' || 'testflight' }}-${{ steps.android-build-number.outputs.build-number }}-${{ github.run_id }}-${{ github.run_attempt }}
path: native-build-receipt
retention-days: 90
if-no-files-found: error
# Hands the built bundle off to the submit / universalApk jobs. Retention is
# deliberately short (1 day) since it's only an intra-run handoff artifact.
- name: 🚀 Upload AAB artifact
@@ -134,12 +173,12 @@ jobs:
- name: 📝 Write build summary
env:
REMOTE_VERSION_CODE: ${{ steps.get-build-info.outputs.BSKY_ANDROID_VERSION_CODE }}
PACKAGED_VERSION_CODE: ${{ steps.android-build-number.outputs.build-number || steps.get-build-info.outputs.BSKY_ANDROID_VERSION_CODE }}
run: |
{
echo "### Android build number"
echo
echo "\`$REMOTE_VERSION_CODE\`"
echo "\`$PACKAGED_VERSION_CODE\`"
} >> "$GITHUB_STEP_SUMMARY"
submit:
+24
View File
@@ -135,6 +135,8 @@ jobs:
- name: 🏗️ EAS Build
uses: ./.github/actions/eas-local-build
env:
OTA_FINGERPRINT_PIPELINE_ENABLED: ${{ vars.OTA_FINGERPRINT_PIPELINE_ENABLED == 'true' && '1' || '' }}
with:
platform: ios
profile: ${{ inputs.profile || 'testflight' }}
@@ -204,6 +206,28 @@ jobs:
echo "IPA build number: $build_number"
echo "build-number=$build_number" >> "$GITHUB_OUTPUT"
- name: 🧾 Create fingerprint native-build receipt
if: ${{ vars.OTA_FINGERPRINT_PIPELINE_ENABLED == 'true' }}
uses: ./.github/actions/native-build-receipt
env:
OTA_FINGERPRINT_PIPELINE_ENABLED: '1'
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
with:
platform: ios
profile: ${{ inputs.profile || 'testflight' }}
artifact-path: ${{ env.BUILD_DIR }}/Bluesky.ipa
native-build-number: ${{ steps.ipa-build-number.outputs.build-number }}
default-channel: ${{ (inputs.profile || 'testflight') == 'production' && 'production' || 'testflight' }}
- name: ⬆️ Upload fingerprint native-build receipt
if: ${{ vars.OTA_FINGERPRINT_PIPELINE_ENABLED == 'true' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: native-build-ios-${{ inputs.profile || 'testflight' }}-${{ steps.ipa-build-number.outputs.build-number }}-${{ github.run_id }}-${{ github.run_attempt }}
path: native-build-receipt
retention-days: 90
if-no-files-found: error
# Hand the IPA and dSYM off to the submit job. Retention is deliberately short since
# this artifact only exists to bridge the two jobs within a single run.
- name: 🚀 Upload build artifact
@@ -37,7 +37,10 @@ env:
jobs:
bundleDeploy:
if: github.repository == 'bluesky-social/social-app'
if: >-
github.repository == 'bluesky-social/social-app' &&
(vars.OTA_FINGERPRINT_PIPELINE_ENABLED != 'true' ||
(github.event_name == 'workflow_dispatch' && inputs.channel == 'production'))
name: Bundle and Deploy EAS Update
runs-on: ubuntu-latest
# id-token: write lets this job mint an OIDC token to assume the denis
@@ -0,0 +1,305 @@
---
name: Bundle and Deploy Fingerprint OTA
on:
push:
branches: [main]
workflow_dispatch:
inputs:
channel:
type: choice
options: [testflight, production]
default: testflight
iosBuildNumber:
type: string
description: Required exact production iOS build target
androidVersionCode:
type: string
description: Required exact production Android build target
iosReceiptRunId:
type: string
description: Optional trusted iOS native-build workflow run containing a matching receipt
iosReceiptRunAttempt:
type: string
default: '1'
description: Attempt number for the optional iOS receipt run
androidReceiptRunId:
type: string
description: Optional trusted Android native-build workflow run containing a matching receipt
androidReceiptRunAttempt:
type: string
default: '1'
description: Attempt number for the optional Android receipt run
permissions: {}
jobs:
publish:
name: Export and publish fingerprint OTA
if: >-
github.repository == 'bluesky-social/social-app' &&
vars.OTA_FINGERPRINT_PIPELINE_ENABLED == 'true'
runs-on: ubuntu-latest
concurrency:
group: fingerprint-ota-${{ github.ref }}-${{ inputs.channel || 'testflight' }}
cancel-in-progress: true
permissions:
contents: read
id-token: write
actions: read
env:
OTA_FINGERPRINT_PIPELINE_ENABLED: '1'
CHANNEL: ${{ inputs.channel || 'testflight' }}
steps:
- name: 🧭 Validate rollout configuration
env:
DENIS_VERSION: ${{ vars.OTA_FINGERPRINT_DENIS_VERSION }}
IOS_BUILD_NUMBER: ${{ inputs.iosBuildNumber }}
ANDROID_BUILD_NUMBER: ${{ inputs.androidVersionCode }}
run: |
if [ -z "$DENIS_VERSION" ]; then
echo "::error::OTA_FINGERPRINT_DENIS_VERSION must pin a structured-publisher release"
exit 1
fi
if [ "$CHANNEL" = production ]; then
[[ "$IOS_BUILD_NUMBER" =~ ^[0-9]+$ ]] || { echo "::error::A numeric iOS production target is required"; exit 1; }
[[ "$ANDROID_BUILD_NUMBER" =~ ^[0-9]+$ ]] || { echo "::error::A numeric Android production target is required"; exit 1; }
fi
- name: ⏱️ Allocate publication version
id: publication
run: echo "bundle-version=$(node -e 'process.stdout.write(String(Date.now()))')" >> "$GITHUB_OUTPUT"
- name: ⬇️ Checkout exact source commit
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- 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: ${{ inputs.channel || 'testflight' }}
- name: 🧬 Resolve iOS runtime
id: ios-runtime
uses: bluesky-social/github-actions/fingerprint-runtime@b890bb3f200c5fb9fee7a2a1647e08537a73bde0
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
with:
platform: ios
profile: ${{ inputs.channel || 'testflight' }}
source-commit: ${{ github.sha }}
- name: 🧬 Resolve Android runtime
id: android-runtime
uses: bluesky-social/github-actions/fingerprint-runtime@b890bb3f200c5fb9fee7a2a1647e08537a73bde0
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
with:
platform: android
profile: ${{ inputs.channel || 'testflight' }}
source-commit: ${{ github.sha }}
- name: 📋 Stage fingerprint reports
env:
IOS_REPORT: ${{ steps.ios-runtime.outputs.report-path }}
ANDROID_REPORT: ${{ steps.android-runtime.outputs.report-path }}
run: |
mkdir ota-release
cp "$IOS_REPORT" ota-release/ios-fingerprint.json
cp "$ANDROID_REPORT" ota-release/android-fingerprint.json
- name: 🏗️ Export per native platform
env:
EXPO_PUBLIC_ENV: ${{ inputs.channel || 'testflight' }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_RELEASE: ${{ steps.env.outputs.release-version }}
SENTRY_DIST: ${{ steps.env.outputs.bundle-identifier }}
run: |
EAS_BUILD_PLATFORM=ios pnpm exec expo export --platform ios --output-dir ota-release/dist-ios --dump-sourcemap
EAS_BUILD_PLATFORM=android pnpm exec expo export --platform android --output-dir ota-release/dist-android --dump-sourcemap
- name: 🧬 Re-resolve iOS runtime after export
id: ios-runtime-after-export
uses: bluesky-social/github-actions/fingerprint-runtime@b890bb3f200c5fb9fee7a2a1647e08537a73bde0
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
with:
platform: ios
profile: ${{ inputs.channel || 'testflight' }}
source-commit: ${{ github.sha }}
- name: 🧬 Re-resolve Android runtime after export
id: android-runtime-after-export
uses: bluesky-social/github-actions/fingerprint-runtime@b890bb3f200c5fb9fee7a2a1647e08537a73bde0
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
with:
platform: android
profile: ${{ inputs.channel || 'testflight' }}
source-commit: ${{ github.sha }}
- name: 🧐 Verify export did not change native inputs
env:
IOS_POST_REPORT: ${{ steps.ios-runtime-after-export.outputs.report-path }}
ANDROID_POST_REPORT: ${{ steps.android-runtime-after-export.outputs.report-path }}
run: |
test "$(jq -r .runtimeVersion ota-release/ios-fingerprint.json)" = "$(jq -r .runtimeVersion "$IOS_POST_REPORT")"
test "$(jq -r .runtimeVersion ota-release/android-fingerprint.json)" = "$(jq -r .runtimeVersion "$ANDROID_POST_REPORT")"
- name: 🔎 Find trusted iOS native receipt
id: find-ios-receipt
if: ${{ inputs.iosReceiptRunId }}
env:
GH_TOKEN: ${{ github.token }}
run: >-
node .github/scripts/find-native-receipt.mjs
--platform ios
--build-number "${{ inputs.iosBuildNumber }}"
--run-id "${{ inputs.iosReceiptRunId }}"
--run-attempt "${{ inputs.iosReceiptRunAttempt }}"
- name: 🔎 Find trusted Android native receipt
id: find-android-receipt
if: ${{ inputs.androidReceiptRunId && always() }}
env:
GH_TOKEN: ${{ github.token }}
run: >-
node .github/scripts/find-native-receipt.mjs
--platform android
--build-number "${{ inputs.androidVersionCode }}"
--run-id "${{ inputs.androidReceiptRunId }}"
--run-attempt "${{ inputs.androidReceiptRunAttempt }}"
- name: ⬇️ Download iOS native receipt
id: download-ios-receipt
if: ${{ steps.find-ios-receipt.outputs.available == 'true' && always() }}
continue-on-error: true
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ${{ steps.find-ios-receipt.outputs.artifact-name }}
path: ota-release/ios-native-receipt
run-id: ${{ inputs.iosReceiptRunId }}
github-token: ${{ github.token }}
- name: ⬇️ Download Android native receipt
id: download-android-receipt
if: ${{ steps.find-android-receipt.outputs.available == 'true' && always() }}
continue-on-error: true
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ${{ steps.find-android-receipt.outputs.artifact-name }}
path: ota-release/android-native-receipt
run-id: ${{ inputs.androidReceiptRunId }}
github-token: ${{ github.token }}
- name: 🧐 Bind downloaded iOS receipt to its build run
id: bind-ios-receipt
if: ${{ inputs.iosReceiptRunId && always() }}
env:
EXPECTED_SOURCE_COMMIT: ${{ steps.find-ios-receipt.outputs.head-sha }}
run: |
if [ ! -f ota-release/ios-native-receipt/receipt.json ]; then
echo "::warning::iOS native receipt was unavailable; target could not be verified"
echo "available=false" >> "$GITHUB_OUTPUT"
else
test -n "$EXPECTED_SOURCE_COMMIT"
test "$(jq -r .sourceCommit ota-release/ios-native-receipt/receipt.json)" = "$EXPECTED_SOURCE_COMMIT"
echo "available=true" >> "$GITHUB_OUTPUT"
fi
- name: 🧐 Bind downloaded Android receipt to its build run
id: bind-android-receipt
if: ${{ inputs.androidReceiptRunId && always() }}
env:
EXPECTED_SOURCE_COMMIT: ${{ steps.find-android-receipt.outputs.head-sha }}
run: |
if [ ! -f ota-release/android-native-receipt/receipt.json ]; then
echo "::warning::Android native receipt was unavailable; target could not be verified"
echo "available=false" >> "$GITHUB_OUTPUT"
else
test -n "$EXPECTED_SOURCE_COMMIT"
test "$(jq -r .sourceCommit ota-release/android-native-receipt/receipt.json)" = "$EXPECTED_SOURCE_COMMIT"
echo "available=true" >> "$GITHUB_OUTPUT"
fi
- name: 🧾 Create structured release
env:
BUNDLE_VERSION: ${{ steps.publication.outputs.bundle-version }}
IOS_BUILD_NUMBER: ${{ inputs.iosBuildNumber }}
ANDROID_BUILD_NUMBER: ${{ inputs.androidVersionCode }}
IOS_RECEIPT_AVAILABLE: ${{ steps.bind-ios-receipt.outputs.available == 'true' && 'true' || 'false' }}
ANDROID_RECEIPT_AVAILABLE: ${{ steps.bind-android-receipt.outputs.available == 'true' && 'true' || 'false' }}
run: |
jq -n \
--arg sourceCommit "$GITHUB_SHA" --arg channel "$CHANNEL" \
--arg bundleVersion "$BUNDLE_VERSION" \
--arg iosRuntime "$(jq -r .runtimeVersion ota-release/ios-fingerprint.json)" \
--arg androidRuntime "$(jq -r .runtimeVersion ota-release/android-fingerprint.json)" \
--arg iosBuild "$IOS_BUILD_NUMBER" --arg androidBuild "$ANDROID_BUILD_NUMBER" \
'{schemaVersion: 1, sourceCommit: $sourceCommit, channel: $channel,
nativeProfile: $channel, bundleVersion: $bundleVersion,
platforms: {
ios: ({runtimeVersion: $iosRuntime, fingerprintReportRef: "ios-fingerprint.json", bundleDirectory: "dist-ios"} + if $channel == "production" then ({targetNativeBuildNumber: $iosBuild} + if $ENV.IOS_RECEIPT_AVAILABLE == "true" then {targetNativeBuildReceiptRef: "ios-native-receipt/receipt.json"} else {} end) else {} end),
android: ({runtimeVersion: $androidRuntime, fingerprintReportRef: "android-fingerprint.json", bundleDirectory: "dist-android"} + if $channel == "production" then ({targetNativeBuildNumber: $androidBuild} + if $ENV.ANDROID_RECEIPT_AVAILABLE == "true" then {targetNativeBuildReceiptRef: "android-native-receipt/receipt.json"} else {} end) else {} end)
}}' > ota-release/ota-export.json
node scripts/ota/validate-release.mjs --release-file ota-release/ota-export.json > ota-release/verification.json
jq -e '.valid == true' ota-release/verification.json >/dev/null
- name: ⬆️ Upload export evidence
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: fingerprint-ota-export-${{ github.run_id }}-${{ github.run_attempt }}
path: |
ota-release/ota-export.json
ota-release/ios-fingerprint.json
ota-release/android-fingerprint.json
ota-release/verification.json
retention-days: 90
if-no-files-found: error
- name: ☁️ Configure AWS credentials (denis)
uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3
with:
role-to-assume: arn:aws:iam::007404326489:role/denis-ci-publish
aws-region: us-east-2
- name: ⬇️ Setup structured denis CLI
uses: ./.github/actions/setup-denis
with:
release-tag: ${{ vars.OTA_FINGERPRINT_DENIS_VERSION }}
app-id: ${{ vars.SYNC_INTERNAL_APP_ID }}
private-key: ${{ secrets.SYNC_INTERNAL_PK }}
- name: 🚀 Publish structured OTA to denis
run: bash scripts/denisPublish.sh ota-release/ota-export.json
env:
DENIS_PUBLISH_MODE: structured
- name: 📝 Summarize publication
run: |
{
echo "### Fingerprint OTA"
echo
echo "- Channel: \`$CHANNEL\`"
echo "- Source: \`$GITHUB_SHA\`"
echo "- Bundle version: \`${{ steps.publication.outputs.bundle-version }}\`"
if [ "$CHANNEL" = production ]; then
ios_status=$(jq -r '.platforms.ios.receiptVerification.status' ota-release/verification.json)
android_status=$(jq -r '.platforms.android.receiptVerification.status' ota-release/verification.json)
echo "- iOS native target verification: \`$ios_status\`"
echo "- Android native target verification: \`$android_status\`"
fi
} >> "$GITHUB_STEP_SUMMARY"
+187
View File
@@ -262,6 +262,7 @@ jobs:
github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.repository &&
github.event.pull_request.user.type != 'Bot' &&
vars.OTA_FINGERPRINT_PIPELINE_ENABLED != 'true' &&
needs.fingerprint-native.outputs.includes-changes != 'true'
concurrency:
group: pr-ota-${{ github.event.pull_request.number }}
@@ -370,3 +371,189 @@ jobs:
<img src="https://bsky-qr.vercel.app?channel=pull-request-${{ github.event.pull_request.number }}&releaseVersion=${{ needs.publish-pr-ota.outputs.release-version }}&iosBuildNumber=${{ needs.publish-pr-ota.outputs.ios-build-number }}&androidBuildNumber=${{ needs.publish-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.publish-pr-ota.outputs.release-version }}&iosBuildNumber=${{ needs.publish-pr-ota.outputs.ios-build-number }}&androidBuildNumber=${{ needs.publish-pr-ota.outputs.android-build-number }}`
publish-pr-ota-fingerprint:
name: Publish fingerprint PR OTA to denis
needs: fingerprint-native
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' &&
vars.OTA_FINGERPRINT_PIPELINE_ENABLED == 'true'
concurrency:
group: pr-ota-${{ github.event.pull_request.number }}
cancel-in-progress: true
permissions:
id-token: write
contents: read
env:
OTA_FINGERPRINT_PIPELINE_ENABLED: '1'
steps:
- name: 🧭 Validate fingerprint rollout configuration
env:
DENIS_VERSION: ${{ vars.OTA_FINGERPRINT_DENIS_VERSION }}
run: |
if [ -z "$DENIS_VERSION" ]; then
echo "::error::OTA_FINGERPRINT_DENIS_VERSION must pin a structured-publisher release"
exit 1
fi
- name: ⏱️ Allocate publication version
id: publication
run: echo "bundle-version=$(node -e 'process.stdout.write(String(Date.now()))')" >> "$GITHUB_OUTPUT"
- name: ⬇️ Checkout exact PR head
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: 🧬 Resolve iOS runtime
id: ios-runtime
uses: bluesky-social/github-actions/fingerprint-runtime@b890bb3f200c5fb9fee7a2a1647e08537a73bde0
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
with:
platform: ios
profile: testflight
source-commit: ${{ github.event.pull_request.head.sha }}
- name: 🧬 Resolve Android runtime
id: android-runtime
uses: bluesky-social/github-actions/fingerprint-runtime@b890bb3f200c5fb9fee7a2a1647e08537a73bde0
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
with:
platform: android
profile: testflight
source-commit: ${{ github.event.pull_request.head.sha }}
- name: 📋 Stage fingerprint reports
env:
IOS_REPORT: ${{ steps.ios-runtime.outputs.report-path }}
ANDROID_REPORT: ${{ steps.android-runtime.outputs.report-path }}
run: |
mkdir ota-release
cp "$IOS_REPORT" ota-release/ios-fingerprint.json
cp "$ANDROID_REPORT" ota-release/android-fingerprint.json
- name: 🏗️ Export exact PR head per native platform
env:
EXPO_PUBLIC_ENV: testflight
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_RELEASE: ${{ steps.env.outputs.release-version }}
SENTRY_DIST: ${{ steps.env.outputs.bundle-identifier }}
run: |
EAS_BUILD_PLATFORM=ios pnpm exec expo export --platform ios --output-dir ota-release/dist-ios --dump-sourcemap
EAS_BUILD_PLATFORM=android pnpm exec expo export --platform android --output-dir ota-release/dist-android --dump-sourcemap
- name: 🧬 Re-resolve runtimes after export
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: |
node scripts/ota/resolve-runtime.mjs --platform ios --profile testflight --source-commit "${{ github.event.pull_request.head.sha }}" --output ota-release/ios-post-export.json
node scripts/ota/resolve-runtime.mjs --platform android --profile testflight --source-commit "${{ github.event.pull_request.head.sha }}" --output ota-release/android-post-export.json
test "$(jq -r .runtimeVersion ota-release/ios-fingerprint.json)" = "$(jq -r .runtimeVersion ota-release/ios-post-export.json)"
test "$(jq -r .runtimeVersion ota-release/android-fingerprint.json)" = "$(jq -r .runtimeVersion ota-release/android-post-export.json)"
- name: 🧾 Create structured release
env:
SOURCE_COMMIT: ${{ github.event.pull_request.head.sha }}
CHANNEL: pull-request-${{ github.event.pull_request.number }}
BUNDLE_VERSION: ${{ steps.publication.outputs.bundle-version }}
run: |
jq -n \
--arg sourceCommit "$SOURCE_COMMIT" \
--arg channel "$CHANNEL" \
--arg bundleVersion "$BUNDLE_VERSION" \
--arg iosRuntime "$(jq -r .runtimeVersion ota-release/ios-fingerprint.json)" \
--arg androidRuntime "$(jq -r .runtimeVersion ota-release/android-fingerprint.json)" \
'{schemaVersion: 1, sourceCommit: $sourceCommit, channel: $channel,
nativeProfile: "testflight", bundleVersion: $bundleVersion,
platforms: {
ios: {runtimeVersion: $iosRuntime, fingerprintReportRef: "ios-fingerprint.json", bundleDirectory: "dist-ios"},
android: {runtimeVersion: $androidRuntime, fingerprintReportRef: "android-fingerprint.json", bundleDirectory: "dist-android"}
}}' > ota-release/ota-export.json
node scripts/ota/validate-release.mjs --release-file ota-release/ota-export.json > ota-release/verification.json
jq -e '.valid == true' ota-release/verification.json >/dev/null
- name: ⬆️ Upload PR export evidence
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: fingerprint-pr-export-${{ github.event.pull_request.number }}-${{ github.run_id }}-${{ github.run_attempt }}
path: |
ota-release/ota-export.json
ota-release/ios-fingerprint.json
ota-release/android-fingerprint.json
retention-days: 30
if-no-files-found: error
- name: ☁️ Configure AWS credentials (denis, PR-scoped)
uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3
with:
role-to-assume: arn:aws:iam::007404326489:role/denis-ci-publish-pr
aws-region: us-east-2
inline-session-policy: |-
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:GetObject", "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 structured denis CLI
uses: ./.github/actions/setup-denis
with:
release-tag: ${{ vars.OTA_FINGERPRINT_DENIS_VERSION }}
app-id: ${{ vars.SYNC_INTERNAL_APP_ID }}
private-key: ${{ secrets.SYNC_INTERNAL_PK }}
- name: 🚀 Publish structured OTA to denis
run: bash scripts/denisPublish.sh ota-release/ota-export.json
env:
DENIS_PUBLISH_MODE: structured
comment-pr-ota-fingerprint:
name: Comment fingerprint PR OTA install link
needs: publish-pr-ota-fingerprint
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 fingerprint OTA deployment for this PR was published. Expo will only offer it to a native build with the same platform runtime.
`bluesky://intent/apply-ota?channel=pull-request-${{ github.event.pull_request.number }}&sourceCommit=${{ github.event.pull_request.head.sha }}`
+4 -2
View File
@@ -35,6 +35,8 @@ module.exports = function (_config) {
const UPDATES_ENABLED = IS_TESTFLIGHT || IS_PRODUCTION
const USE_SENTRY = Boolean(process.env.SENTRY_AUTH_TOKEN)
const USE_FINGERPRINT_RUNTIME =
process.env.OTA_FINGERPRINT_PIPELINE_ENABLED === '1'
const IOS_ICON_FILE =
PLATFORM === 'web' // web build doesn't like .icon files
@@ -51,7 +53,7 @@ module.exports = function (_config) {
scheme: 'bluesky',
owner: 'blueskysocial',
runtimeVersion: {
policy: 'appVersion',
policy: USE_FINGERPRINT_RUNTIME ? 'fingerprint' : 'appVersion',
},
icon: './assets/app-icons/ios_icon_default_next.png',
userInterfaceStyle: 'automatic',
@@ -285,7 +287,7 @@ module.exports = function (_config) {
{
name: 'MCEmojiPicker',
git: 'https://github.com/bluesky-social/MCEmojiPicker.git',
branch: 'main',
commit: 'e2c2e4917df25c34d8cc6ca04918fe026588d828',
},
],
},
+54 -1
View File
@@ -1,13 +1,66 @@
# OTA Deployments
## Fingerprint rollout
The fingerprint pipeline is staged behind the repository variable
`OTA_FINGERPRINT_PIPELINE_ENABLED`. Leave it unset until the additive Denis
server support and structured publisher release have been deployed. The legacy
numeric-runtime workflow remains the rollback and supported-client hotfix path
during migration.
Fingerprint releases calculate iOS and Android runtimes independently from the
exact commit and native profile being exported. Production still requires an
explicit numeric native build target for each platform. A native build receipt
may be supplied as additional validation: a valid receipt must match the
calculated runtime, profile, platform, and build number, but an expired or
missing GitHub Actions artifact does not freeze production publishing. Never
copy a runtime from a receipt onto a newly exported bundle.
TestFlight and pull-request channels do not target build numbers once the
fingerprint server path is enabled. They remain isolated by platform, channel,
and the native runtime fingerprint. Existing numeric-runtime binaries continue
to use the legacy build filters.
Before enabling the repository variable, verify that the pinned Denis release
accepts `publish --release-file`, both serving and asset routes accept hash
runtimes, and the new shared calculator action has been pinned to a reviewed
commit. Do not enable the variable with only one of those dependencies ready.
The structured publisher version is pinned by `OTA_FINGERPRINT_DENIS_VERSION`.
After canary validation, set `OTA_FINGERPRINT_PIPELINE_ENABLED` to `true` to
select the new automatic workflow and fingerprint native builds. The process
environment flag passed to app config is `1`; an unset flag retains app-version
runtimes. Use **Bundle and Deploy Fingerprint OTA** for an explicit fingerprint
production publication, selecting the release branch and exact iOS/Android
build numbers. Optional native receipt run IDs/attempts add evidence, not a
runtime override. Each platform's verified/unverified result is in the summary.
This draft still needs automatic per-platform TestFlight receipt lookup and
native rebuild scheduling. With the gate enabled, the main workflow can publish
a safe fingerprinted update without a matching installed binary; it does not
yet automatically create the missing binary. Trigger a native build explicitly
during canary testing, and do not treat publication as proof that a compatible
TestFlight build is available to install. Real fingerprint IPA/AAB canaries are
also required to verify packaged runtime/channel/build extraction before
enablement. Slack notification enrichment and OTA notifications are tracked in
APP-3042; until implemented, verification warnings remain in the GHA summary.
The legacy workflow below remains available for explicitly dispatched
production hotfixes after the gate is enabled. It must export from the correct
legacy release source; do not use it as an automatic fallback after a failed
fingerprint publication.
## Automatic internal OTAs
The following describes the legacy workflow while the fingerprint rollout
flag is unset.
OTA deployments to TestFlight/APK installs happen automatically upon all merges
into main. In cases where the fingerprint diff shows incompatible native
changes, a new client build will automatically be ran and deployed to TestFlight
(iOS) or delivered in Slack (Android).
## Production OTAs
## Legacy production OTAs
Production OTAs can only update the JavaScript bundle. Changes to native modules
must be done as a full release cycle through the app stores.
+25
View File
@@ -0,0 +1,25 @@
// @ts-check
module.exports = {
hashAlgorithm: 'sha1',
sourceSkips: [
'ExpoConfigVersions',
'PackageJsonAndroidAndIosScriptsIfNotContainRun',
],
extraSources: [
{
type: 'file',
filePath: 'scripts/ota/fingerprint-policy.json',
overrideHashKey: 'fingerprint-policy.json',
reasons: ['Repository OTA compatibility policy'],
},
...(process.env.EAS_BUILD_PLATFORM === 'ios'
? ['BlueskyClip', 'BlueskyNSE', 'Share-with-Bluesky'].map(name => ({
type: 'dir',
filePath: `modules/${name}`,
overrideHashKey: `native-extension/${name}`,
reasons: ['Native extension sources copied by config plugins'],
}))
: []),
],
}
Regular → Executable
+118 -92
View File
@@ -1,104 +1,130 @@
#!/usr/bin/env node
/* oxlint-disable import/no-nodejs-modules -- This is a Node-only build script. */
const crypto = require('crypto')
const fs = require('fs')
const fsp = fs.promises
const path = require('path')
const DIST_DIR = './dist'
const BUNDLES_DIR = '/_expo/static/js'
const IOS_BUNDLE_DIR = path.join(DIST_DIR, BUNDLES_DIR, '/ios')
const ANDROID_BUNDLE_DIR = path.join(DIST_DIR, BUNDLES_DIR, '/android')
const METADATA_PATH = path.join(DIST_DIR, '/metadata.json')
const DEST_DIR = './bundleTempDir'
// Weird, don't feel like figuring out _why_ it wants this
const METADATA = require(`../${METADATA_PATH}`)
const IOS_METADATA_ASSETS = METADATA.fileMetadata.ios.assets
const ANDROID_METADATA_ASSETS = METADATA.fileMetadata.android.assets
const getMd5 = async path => {
return new Promise(res => {
const hash = crypto.createHash('md5')
const rStream = fs.createReadStream(path)
rStream.on('data', data => {
hash.update(data)
})
rStream.on('end', () => {
res(hash.digest('hex'))
})
})
/** @param {string[]} argv */
function args(argv) {
/** @type {Record<string, string>} */
const out = {}
for (let i = 0; i < argv.length; i += 2) {
if (!argv[i]?.startsWith('--') || argv[i + 1] == null)
throw new Error(`Invalid argument: ${argv[i]}`)
out[argv[i].slice(2)] = argv[i + 1]
}
return out
}
async function digest(file) {
const hash = crypto.createHash('md5')
for await (const chunk of fs.createReadStream(file)) hash.update(chunk)
return hash.digest('hex')
}
async function childOf(parent, child) {
const relative = path.relative(
await fsp.realpath(parent),
await fsp.realpath(child),
)
return (
relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative)
)
}
const moveFiles = async () => {
console.log('Making directory...')
await fsp.mkdir(DEST_DIR)
await fsp.mkdir(path.join(DEST_DIR, '/assets'))
console.log('Getting ios md5...')
const iosCurrPath = path.join(
IOS_BUNDLE_DIR,
(await fsp.readdir(IOS_BUNDLE_DIR))[0],
)
const iosMd5 = await getMd5(iosCurrPath)
const iosNewPath = `bundles/${iosMd5}.bundle`
console.log('Copying ios bundle...')
await fsp.cp(iosCurrPath, path.join(DEST_DIR, iosNewPath))
console.log('Getting android md5...')
const androidCurrPath = path.join(
ANDROID_BUNDLE_DIR,
(await fsp.readdir(ANDROID_BUNDLE_DIR))[0],
)
const androidMd5 = await getMd5(androidCurrPath)
const androidNewPath = `bundles/${androidMd5}.bundle`
console.log('Copying android bundle...')
await fsp.cp(androidCurrPath, path.join(DEST_DIR, androidNewPath))
const iosAssets = []
const androidAssets = []
console.log('Getting ios asset md5s and moving them...')
for (const asset of IOS_METADATA_ASSETS) {
const currPath = path.join(DIST_DIR, asset.path)
const md5 = await getMd5(currPath)
const withExtPath = `assets/${md5}.${asset.ext}`
iosAssets.push(withExtPath)
await fsp.cp(currPath, path.join(DEST_DIR, withExtPath))
/**
* @param {string} platform
* @param {string} sourceDir
* @param {string} destinationDir
*/
async function packagePlatform(platform, sourceDir, destinationDir) {
const metadataPath = path.join(sourceDir, 'metadata.json')
if (!(await childOf(sourceDir, metadataPath)))
throw new Error('Metadata escapes export directory')
/** @type {{fileMetadata?: Record<string, {bundle: string, assets?: {path: string, ext: string}[]}>}} */
const metadata = JSON.parse(await fsp.readFile(metadataPath, 'utf8'))
const input = metadata.fileMetadata?.[platform]
if (!input || typeof input.bundle !== 'string')
throw new Error(`${metadataPath} has no explicit ${platform} bundle`)
const outputDir = path.join(destinationDir, platform)
await fsp.mkdir(path.join(outputDir, 'bundles'), {recursive: true})
await fsp.mkdir(path.join(outputDir, 'assets'), {recursive: true})
const bundleSource = path.resolve(sourceDir, input.bundle)
if (!(await childOf(sourceDir, bundleSource)))
throw new Error(`Bundle escapes export directory: ${input.bundle}`)
const bundle = `bundles/${await digest(bundleSource)}.bundle`
await fsp.copyFile(bundleSource, path.join(outputDir, bundle))
const assets = []
for (const asset of input.assets ?? []) {
if (
typeof asset.path !== 'string' ||
typeof asset.ext !== 'string' ||
!/^[a-zA-Z0-9]+$/.test(asset.ext)
)
throw new Error(`Invalid ${platform} asset metadata`)
const source = path.resolve(sourceDir, asset.path)
if (!(await childOf(sourceDir, source)))
throw new Error(`Asset escapes export directory: ${asset.path}`)
const target = `assets/${await digest(source)}.${asset.ext}`
await fsp.copyFile(source, path.join(outputDir, target))
assets.push(target)
}
console.log('Getting android asset md5s and moving them...')
for (const asset of ANDROID_METADATA_ASSETS) {
const currPath = path.join(DIST_DIR, asset.path)
const md5 = await getMd5(currPath)
const withExtPath = `assets/${md5}.${asset.ext}`
androidAssets.push(withExtPath)
await fsp.cp(currPath, path.join(DEST_DIR, withExtPath))
}
const result = {
version: 0,
bundler: 'metro',
fileMetadata: {
ios: {
bundle: iosNewPath,
assets: iosAssets,
},
android: {
bundle: androidNewPath,
assets: androidAssets,
},
},
}
console.log('Writing metadata...')
await fsp.writeFile(
path.join(DEST_DIR, 'metadata.json'),
JSON.stringify(result),
path.join(outputDir, 'metadata.json'),
`${JSON.stringify({version: 0, bundler: 'metro', fileMetadata: {[platform]: {bundle, assets}}})}\n`,
)
console.log('Finished!')
console.log('Metadata:', result)
const rollbackPath = path.join(sourceDir, 'rollback')
if (fs.existsSync(rollbackPath)) {
if (!(await childOf(sourceDir, rollbackPath)))
throw new Error('Rollback marker escapes export directory')
await fsp.copyFile(rollbackPath, path.join(outputDir, 'rollback'))
}
}
moveFiles()
async function main() {
const options = args(process.argv.slice(2))
if (!options['release-file']) throw new Error('--release-file is required')
const releasePath = path.resolve(options['release-file'])
const base = path.dirname(releasePath)
/** @type {{schemaVersion: number, bundleVersion: string, platforms: Record<string, {bundleDirectory: string}>}} */
const release = JSON.parse(await fsp.readFile(releasePath, 'utf8'))
if (
release.schemaVersion !== 1 ||
!release.platforms ||
!/^[0-9]{13}$/.test(release.bundleVersion ?? '')
)
throw new Error('Invalid OTA export schema')
const outputDir = options['output-dir']
? path.resolve(options['output-dir'])
: await fsp.mkdtemp(path.join(base, 'ota-bundles-'))
const outputRelative = path.relative(base, outputDir)
if (
!outputRelative ||
outputRelative.startsWith('..') ||
path.isAbsolute(outputRelative)
)
throw new Error('Packaged output must be inside the release directory')
if (options['output-dir']) await fsp.mkdir(outputDir, {recursive: false})
for (const [platform, entry] of Object.entries(release.platforms)) {
if (
!['ios', 'android'].includes(platform) ||
typeof entry.bundleDirectory !== 'string'
)
throw new Error(`Invalid platform entry: ${platform}`)
const sourceDir = path.resolve(base, entry.bundleDirectory)
if (sourceDir !== base && !(await childOf(base, sourceDir)))
throw new Error('Export directory escapes release directory')
await packagePlatform(platform, sourceDir, outputDir)
entry.bundleDirectory = path.relative(base, path.join(outputDir, platform))
}
const packagedReleasePath = `${outputDir}.json`
await fsp.writeFile(
packagedReleasePath,
`${JSON.stringify(release, null, 2)}\n`,
{flag: 'wx'},
)
process.stdout.write(`${packagedReleasePath}\n`)
}
main().catch(error => {
console.error(error instanceof Error ? error.message : error)
process.exitCode = 1
})
+18 -36
View File
@@ -3,42 +3,24 @@ 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.
# 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}"
PUBLISH_MODE="${DENIS_PUBLISH_MODE:-legacy}"
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
if [ "$PUBLISH_MODE" = "structured" ]; then
RELEASE_FILE="${1:?Usage: denisPublish.sh ota-export.json}"
node scripts/ota/validate-release.mjs --release-file "$RELEASE_FILE"
PACKAGED_RELEASE_FILE="$(node scripts/bundleUpdate.js --release-file "$RELEASE_FILE")"
node scripts/ota/validate-release.mjs --release-file "$PACKAGED_RELEASE_FILE"
denis publish --release-file "$PACKAGED_RELEASE_FILE" --cdn-domain "$DENIS_CDN_DOMAIN" --s3-bucket "$DENIS_S3_BUCKET"
# Retain the unique packaged output for debugging and idempotent CLI retries.
elif [ "$PUBLISH_MODE" = "legacy" ]; then
: "${CHANNEL_NAME:?CHANNEL_NAME is required}"
: "${BSKY_IOS_BUILD_NUMBER:?BSKY_IOS_BUILD_NUMBER is required}"
: "${BSKY_ANDROID_VERSION_CODE:?BSKY_ANDROID_VERSION_CODE is required}"
: "Legacy mode is retained by scripts/denisPublishLegacy.sh during rollout"
exec bash scripts/denisPublishLegacy.sh
else
echo "DENIS_PUBLISH_MODE must be structured or legacy" >&2
exit 1
fi
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
set -o errexit
set -o pipefail
set -o nounset
# Frozen numeric-runtime publication path. Remove only after legacy client support ends.
rm -rf bundleTempDir
node scripts/ota/package-legacy-export.js
RUNTIME_VERSION="${RUNTIME_VERSION:-$(jq -r .version package.json)}"
BUNDLE_VERSION="${BUNDLE_VERSION:-$(date +%s)000}"
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:-updates.bsky.app}" --s3-bucket "${DENIS_S3_BUCKET:-bsky-denis-ota-prod}"
rm -rf bundleTempDir
+10
View File
@@ -0,0 +1,10 @@
{
"schemaVersion": 1,
"fingerprintPolicyVersion": 1,
"runtimeFormat": "sha1-lowercase-hex",
"normalizedFields": [
"expo.version",
"expo.ios.buildNumber",
"expo.android.versionCode"
]
}
+276
View File
@@ -0,0 +1,276 @@
import assert from 'node:assert/strict'
import crypto from 'node:crypto'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import {spawnSync} from 'node:child_process'
import test from 'node:test'
const root = path.resolve(import.meta.dirname, '../..')
const packager = path.join(root, 'scripts/bundleUpdate.js')
const publisher = path.join(root, 'scripts/denisPublish.sh')
const sourceCommit = 'a'.repeat(40)
function writeJson(filename, value) {
fs.writeFileSync(filename, `${JSON.stringify(value, null, 2)}\n`)
}
function report(platform, runtime) {
return {
schemaVersion: 1,
platform,
nativeProfile: 'testflight',
sourceCommit,
runtimeVersion: runtime,
fingerprintPolicyVersion: 1,
fingerprintToolVersion: '0.20.8',
toolVersions: {
expo: '57.0.8',
expoUpdates: '57.0.10',
node: 'v24.19.0',
packageManager: 'pnpm@11.21.0',
},
fingerprintSources: [
{type: 'contents', id: 'expoConfig', hash: 'b'.repeat(40)},
{
type: 'file',
filePath: 'scripts/ota/fingerprint-policy.json',
hash: 'c'.repeat(40),
},
...(platform === 'ios'
? ['BlueskyClip', 'BlueskyNSE', 'Share-with-Bluesky'].map(name => ({
type: 'dir',
filePath: `modules/${name}`,
hash: 'd'.repeat(40),
}))
: [
{
type: 'file',
filePath: 'google-services.json',
hash: 'e'.repeat(40),
},
]),
],
}
}
function fixture(platforms = ['ios', 'android']) {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ota-package-'))
const release = {
schemaVersion: 1,
sourceCommit,
channel: 'testflight',
nativeProfile: 'testflight',
bundleVersion: '1788537600000',
platforms: {},
}
for (const platform of platforms) {
const runtime = platform === 'ios' ? '1'.repeat(40) : '2'.repeat(40)
const exportDirectory = path.join(directory, `export-${platform}`)
fs.mkdirSync(path.join(exportDirectory, 'bundles'), {recursive: true})
fs.mkdirSync(path.join(exportDirectory, 'assets'), {recursive: true})
fs.writeFileSync(path.join(exportDirectory, 'bundles/00-decoy.js'), 'decoy')
fs.writeFileSync(
path.join(exportDirectory, 'bundles/chosen.js'),
`${platform}-chosen`,
)
fs.writeFileSync(
path.join(exportDirectory, 'assets/icon.png'),
`${platform}-asset`,
)
writeJson(path.join(exportDirectory, 'metadata.json'), {
version: 0,
bundler: 'metro',
fileMetadata: {
[platform]: {
bundle: 'bundles/chosen.js',
assets: [{path: 'assets/icon.png', ext: 'png'}],
},
},
})
writeJson(
path.join(directory, `${platform}-report.json`),
report(platform, runtime),
)
release.platforms[platform] = {
runtimeVersion: runtime,
fingerprintReportRef: `${platform}-report.json`,
bundleDirectory: `export-${platform}`,
}
}
const releaseFile = path.join(directory, 'ota-export.json')
writeJson(releaseFile, release)
return {directory, releaseFile}
}
function packageRelease(releaseFile) {
const result = spawnSync(
process.execPath,
[packager, '--release-file', releaseFile],
{
encoding: 'utf8',
},
)
if (result.status !== 0) throw new Error(result.stderr.trim())
return result.stdout.trim()
}
function md5(value) {
return crypto.createHash('md5').update(value).digest('hex')
}
test('packages the metadata-referenced bundles per platform without mutating input', () => {
const value = fixture()
const before = fs.readFileSync(value.releaseFile, 'utf8')
const packagedFile = packageRelease(value.releaseFile)
const packaged = JSON.parse(fs.readFileSync(packagedFile))
assert.equal(fs.readFileSync(value.releaseFile, 'utf8'), before)
for (const platform of ['ios', 'android']) {
const bundleDirectory = path.resolve(
value.directory,
packaged.platforms[platform].bundleDirectory,
)
assert.equal(
fs.readFileSync(
path.join(
bundleDirectory,
`bundles/${md5(`${platform}-chosen`)}.bundle`,
),
'utf8',
),
`${platform}-chosen`,
)
assert.equal(
path.resolve(
path.dirname(packagedFile),
packaged.platforms[platform].fingerprintReportRef,
),
path.join(value.directory, `${platform}-report.json`),
)
}
const retry = packageRelease(value.releaseFile)
assert.notEqual(retry, packagedFile)
assert.equal(fs.readFileSync(value.releaseFile, 'utf8'), before)
})
test('preserves a rollback marker', () => {
const value = fixture(['ios'])
fs.writeFileSync(
path.join(value.directory, 'export-ios/rollback'),
'rollback\n',
)
const packaged = JSON.parse(
fs.readFileSync(packageRelease(value.releaseFile)),
)
assert.equal(
fs.readFileSync(
path.resolve(
value.directory,
packaged.platforms.ios.bundleDirectory,
'rollback',
),
'utf8',
),
'rollback\n',
)
})
test('rejects malformed metadata and path, symlink, and extension escapes', () => {
const mutations = {
'malformed metadata': value =>
fs.writeFileSync(
path.join(value.directory, 'export-ios/metadata.json'),
'{',
),
'bundle escape': value => {
const metadata = JSON.parse(
fs.readFileSync(path.join(value.directory, 'export-ios/metadata.json')),
)
metadata.fileMetadata.ios.bundle = '../outside.js'
fs.writeFileSync(path.join(value.directory, 'outside.js'), 'outside')
writeJson(
path.join(value.directory, 'export-ios/metadata.json'),
metadata,
)
},
'bundle symlink': value => {
fs.rmSync(path.join(value.directory, 'export-ios/bundles/chosen.js'))
fs.writeFileSync(path.join(value.directory, 'outside.js'), 'outside')
fs.symlinkSync(
path.join(value.directory, 'outside.js'),
path.join(value.directory, 'export-ios/bundles/chosen.js'),
)
},
'asset escape': value => {
const metadata = JSON.parse(
fs.readFileSync(path.join(value.directory, 'export-ios/metadata.json')),
)
metadata.fileMetadata.ios.assets[0].path = '../outside.png'
fs.writeFileSync(path.join(value.directory, 'outside.png'), 'outside')
writeJson(
path.join(value.directory, 'export-ios/metadata.json'),
metadata,
)
},
'asset symlink': value => {
fs.rmSync(path.join(value.directory, 'export-ios/assets/icon.png'))
fs.writeFileSync(path.join(value.directory, 'outside.png'), 'outside')
fs.symlinkSync(
path.join(value.directory, 'outside.png'),
path.join(value.directory, 'export-ios/assets/icon.png'),
)
},
'extension escape': value => {
const metadata = JSON.parse(
fs.readFileSync(path.join(value.directory, 'export-ios/metadata.json')),
)
metadata.fileMetadata.ios.assets[0].ext = '../js'
writeJson(
path.join(value.directory, 'export-ios/metadata.json'),
metadata,
)
},
}
for (const [name, mutate] of Object.entries(mutations)) {
const value = fixture(['ios'])
mutate(value)
assert.throws(() => packageRelease(value.releaseFile), undefined, name)
}
})
test('structured wrapper validates and invokes only release-file publishing', () => {
const value = fixture(['ios'])
const bin = fs.mkdtempSync(path.join(os.tmpdir(), 'fake-denis-'))
const log = path.join(bin, 'args.json')
const denis = path.join(bin, 'denis')
fs.writeFileSync(
denis,
'#!/bin/sh\nprintf "%s\\n" "$@" > "$DENIS_TEST_LOG"\n',
{mode: 0o755},
)
const result = spawnSync('bash', [publisher, value.releaseFile], {
cwd: root,
encoding: 'utf8',
env: {
...process.env,
PATH: `${bin}:${process.env.PATH}`,
DENIS_TEST_LOG: log,
DENIS_PUBLISH_MODE: 'structured',
},
})
assert.equal(result.status, 0, result.stderr)
const args = fs.readFileSync(log, 'utf8').trim().split('\n')
assert.deepEqual(args.slice(0, 2), ['publish', '--release-file'])
assert.notEqual(args[2], value.releaseFile)
assert.ok(fs.existsSync(args[2]))
})
test('wrapper defaults existing callers to legacy mode', () => {
const result = spawnSync('bash', [publisher], {
cwd: root,
encoding: 'utf8',
env: {...process.env, DENIS_PUBLISH_MODE: ''},
})
assert.notEqual(result.status, 0)
assert.match(result.stderr, /CHANNEL_NAME is required/)
})
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env node
/* oxlint-disable import/no-nodejs-modules -- This is a Node-only build script. */
const crypto = require('crypto')
const fs = require('fs')
const fsp = fs.promises
const path = require('path')
const source = path.resolve('dist')
const destination = path.resolve('bundleTempDir')
async function hash(file) {
const value = crypto.createHash('md5')
for await (const chunk of fs.createReadStream(file)) value.update(chunk)
return value.digest('hex')
}
async function main() {
/** @type {{fileMetadata: Record<string, {bundle: string, assets?: {path: string, ext: string}[]}>}} */
const metadata = JSON.parse(
await fsp.readFile(path.join(source, 'metadata.json'), 'utf8'),
)
await fsp.mkdir(path.join(destination, 'bundles'), {recursive: true})
await fsp.mkdir(path.join(destination, 'assets'), {recursive: true})
const result = {version: 0, bundler: 'metro', fileMetadata: {}}
for (const platform of ['ios', 'android']) {
const input = metadata.fileMetadata?.[platform]
if (!input?.bundle)
throw new Error(
`Missing explicit ${platform} bundle in dist/metadata.json`,
)
const bundleSource = path.resolve(source, input.bundle)
const bundle = `bundles/${await hash(bundleSource)}.bundle`
await fsp.copyFile(bundleSource, path.join(destination, bundle))
const assets = []
for (const asset of input.assets ?? []) {
const assetSource = path.resolve(source, asset.path)
const target = `assets/${await hash(assetSource)}.${asset.ext}`
await fsp.copyFile(assetSource, path.join(destination, target))
assets.push(target)
}
result.fileMetadata[platform] = {bundle, assets}
}
await fsp.writeFile(
path.join(destination, 'metadata.json'),
JSON.stringify(result),
)
}
main().catch(error => {
console.error(error)
process.exitCode = 1
})
+190
View File
@@ -0,0 +1,190 @@
#!/usr/bin/env node
import fs from 'node:fs/promises'
import path from 'node:path'
import process from 'node:process'
import {execFileSync} from 'node:child_process'
import {createRequire} from 'node:module'
const require = createRequire(import.meta.url)
const root = path.resolve(import.meta.dirname, '../..')
function fail(message) {
throw new Error(message)
}
function parseArgs(argv) {
const result = {}
for (let i = 0; i < argv.length; i += 2) {
const key = argv[i]
const value = argv[i + 1]
if (!key?.startsWith('--') || value == null)
fail(`Invalid argument: ${key}`)
result[key.slice(2)] = value
}
return result
}
async function assertFile(relativePath) {
const absolutePath = path.join(root, relativePath)
const stat = await fs.stat(absolutePath).catch(() => null)
if (!stat?.isFile())
fail(`Required fingerprint input is missing: ${relativePath}`)
}
async function main() {
const args = parseArgs(process.argv.slice(2))
const platform = args.platform
const nativeProfile = args.profile
if (!['ios', 'android'].includes(platform))
fail('--platform must be ios or android')
if (!['production', 'testflight'].includes(nativeProfile)) {
fail('--profile must be production or testflight')
}
if (
process.env.EAS_BUILD_PLATFORM &&
process.env.EAS_BUILD_PLATFORM !== platform
) {
fail(
`EAS_BUILD_PLATFORM=${process.env.EAS_BUILD_PLATFORM} conflicts with ${platform}`,
)
}
if (
process.env.EXPO_PUBLIC_ENV &&
process.env.EXPO_PUBLIC_ENV !== nativeProfile
) {
fail(
`EXPO_PUBLIC_ENV=${process.env.EXPO_PUBLIC_ENV} conflicts with ${nativeProfile}`,
)
}
process.env.EAS_BUILD_PLATFORM = platform
process.env.EXPO_PUBLIC_ENV = nativeProfile
process.env.OTA_FINGERPRINT_PIPELINE_ENABLED = '1'
const sourceCommit = args['source-commit'] ?? process.env.GITHUB_SHA
if (!/^[0-9a-f]{40}$/.test(sourceCommit ?? ''))
fail('--source-commit or GITHUB_SHA must be a full lowercase SHA')
const checkoutCommit = execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: root,
encoding: 'utf8',
}).trim()
if (sourceCommit !== checkoutCommit)
fail(
`sourceCommit ${sourceCommit} does not match checkout HEAD ${checkoutCommit}`,
)
for (const file of [
'app.config.js',
'fingerprint.config.js',
'scripts/ota/fingerprint-policy.json',
...(platform === 'android' ? ['google-services.json'] : []),
])
await assertFile(file)
if (platform === 'ios') {
for (const name of ['BlueskyClip', 'BlueskyNSE', 'Share-with-Bluesky']) {
const stat = await fs
.stat(path.join(root, 'modules', name))
.catch(() => null)
if (!stat?.isDirectory())
fail(`Required native extension is missing: modules/${name}`)
}
}
delete require.cache[
require.resolve(path.join(root, 'fingerprint.config.js'))
]
const fingerprintConfig = require(path.join(root, 'fingerprint.config.js'))
if (
fingerprintConfig.hashAlgorithm !== 'sha1' ||
!Array.isArray(fingerprintConfig.extraSources)
) {
fail('fingerprint.config.js did not load the canonical policy')
}
const {getConfig} = require('expo/config')
const {exp} = getConfig(root, {
isPublicConfig: true,
skipSDKVersionRequirement: true,
})
if (exp.runtimeVersion?.policy !== 'fingerprint')
fail('Expo runtimeVersion policy must be fingerprint')
const {
resolveRuntimeVersionAsync,
} = require('expo-updates/utils/build/resolveRuntimeVersionAsync')
const resolved = await resolveRuntimeVersionAsync(
root,
platform,
{silent: true},
{workflowOverride: 'managed'},
)
if (!/^[0-9a-f]{40}$/.test(resolved.runtimeVersion ?? ''))
fail('Resolver returned an invalid fingerprint runtime')
if (
!Array.isArray(resolved.fingerprintSources) ||
resolved.fingerprintSources.length === 0
) {
fail('Resolver returned no fingerprint sources')
}
const policySource = resolved.fingerprintSources.find(
source =>
source.type === 'file' &&
source.filePath?.endsWith('fingerprint-policy.json'),
)
if (!policySource?.hash)
fail('Fingerprint report omitted the compatibility policy source')
const expoConfigSource = resolved.fingerprintSources.find(
source => source.type === 'contents' && source.id === 'expoConfig',
)
if (!expoConfigSource?.hash)
fail('Fingerprint report omitted resolved Expo config')
if (platform === 'ios') {
for (const name of ['BlueskyClip', 'BlueskyNSE', 'Share-with-Bluesky']) {
const source = resolved.fingerprintSources.find(
candidate =>
candidate.type === 'dir' && candidate.filePath === `modules/${name}`,
)
if (!source?.hash) fail(`Fingerprint report omitted modules/${name}`)
}
} else {
const googleServicesSource = resolved.fingerprintSources.find(
source =>
source.type === 'file' && source.filePath === 'google-services.json',
)
if (!googleServicesSource?.hash)
fail('Fingerprint report omitted google-services.json')
}
const projectPackage = require(path.join(root, 'package.json'))
const packageManager =
projectPackage.packageManager ??
`${projectPackage.devEngines.packageManager.name}@${projectPackage.devEngines.packageManager.version}`
const report = {
schemaVersion: 1,
platform,
nativeProfile,
sourceCommit,
runtimeVersion: resolved.runtimeVersion,
fingerprintPolicyVersion: 1,
fingerprintToolVersion: require('@expo/fingerprint/package.json').version,
toolVersions: {
expo: require('expo/package.json').version,
expoUpdates: require('expo-updates/package.json').version,
node: process.version,
packageManager,
},
fingerprintSources: resolved.fingerprintSources,
}
const json = `${JSON.stringify(report, null, 2)}\n`
if (args.output) {
await fs.mkdir(path.dirname(path.resolve(args.output)), {recursive: true})
await fs.writeFile(path.resolve(args.output), json)
} else {
process.stdout.write(json)
}
}
main().catch(error => {
console.error(error instanceof Error ? error.message : error)
process.exitCode = 1
})
+226
View File
@@ -0,0 +1,226 @@
#!/usr/bin/env node
import fs from 'node:fs/promises'
import path from 'node:path'
import process from 'node:process'
function fail(message) {
throw new Error(message)
}
async function readJson(base, reference, label) {
const resolved = await resolvePath(base, reference, label)
return JSON.parse(await fs.readFile(resolved, 'utf8'))
}
async function resolvePath(base, reference, label) {
if (typeof reference !== 'string' || path.isAbsolute(reference))
fail(`${label} must be relative`)
const lexicalRelative = path.relative(base, path.resolve(base, reference))
if (lexicalRelative.startsWith('..') || path.isAbsolute(lexicalRelative))
fail(`${label} escapes the release directory`)
const realBase = await fs.realpath(base)
const resolved = await fs.realpath(path.resolve(base, reference))
const relative = path.relative(realBase, resolved)
if (relative.startsWith('..') || path.isAbsolute(relative))
fail(`${label} escapes the release directory`)
return resolved
}
function validateFingerprintReport(report, expected, label) {
if (
report.schemaVersion !== 1 ||
report.platform !== expected.platform ||
report.nativeProfile !== expected.nativeProfile ||
report.sourceCommit !== expected.sourceCommit ||
report.runtimeVersion !== expected.runtimeVersion ||
report.fingerprintPolicyVersion !== 1 ||
typeof report.fingerprintToolVersion !== 'string' ||
report.fingerprintToolVersion.length === 0 ||
!report.toolVersions ||
!['expo', 'expoUpdates', 'node', 'packageManager'].every(
key =>
typeof report.toolVersions[key] === 'string' &&
report.toolVersions[key].length > 0,
) ||
!Array.isArray(report.fingerprintSources) ||
report.fingerprintSources.length === 0 ||
report.fingerprintSources.some(
source =>
!source ||
!['file', 'dir', 'contents'].includes(source.type) ||
!Object.hasOwn(source, 'hash') ||
(source.hash !== null && !/^[0-9a-f]{40}$/.test(source.hash)),
)
)
fail(`${label} is incomplete or inconsistent`)
if (
!report.fingerprintSources.some(
source =>
source.type === 'contents' && source.id === 'expoConfig' && source.hash,
)
)
fail(`${label} omits resolved Expo config`)
if (
!report.fingerprintSources.some(
source =>
source.type === 'file' &&
source.filePath === 'scripts/ota/fingerprint-policy.json' &&
source.hash,
)
)
fail(`${label} omits the fingerprint policy`)
const requiredPlatformSources =
expected.platform === 'ios'
? [
'modules/BlueskyClip',
'modules/BlueskyNSE',
'modules/Share-with-Bluesky',
]
: ['google-services.json']
for (const filePath of requiredPlatformSources) {
if (
!report.fingerprintSources.some(
source => source.filePath === filePath && source.hash,
)
)
fail(`${label} omits ${filePath}`)
}
}
async function main() {
const i = process.argv.indexOf('--release-file')
if (i < 0 || !process.argv[i + 1]) fail('--release-file is required')
const releasePath = path.resolve(process.argv[i + 1])
const base = path.dirname(releasePath)
const release = JSON.parse(await fs.readFile(releasePath, 'utf8'))
if (
release.schemaVersion !== 1 ||
!/^[0-9a-f]{40}$/.test(release.sourceCommit ?? '')
)
fail('Invalid release sourceCommit')
if (!/^[0-9]{13}$/.test(release.bundleVersion ?? ''))
fail('bundleVersion must be a 13-digit Unix millisecond string')
if (!['production', 'testflight'].includes(release.nativeProfile))
fail('Invalid nativeProfile')
if (!(
release.channel === 'production' ||
release.channel === 'testflight' ||
/^pull-request-[1-9][0-9]*$/.test(release.channel ?? '')
))
fail('Invalid channel')
if (
release.channel === 'production' &&
release.nativeProfile !== 'production'
)
fail('Production channel requires the production native profile')
if (
release.channel !== 'production' &&
release.nativeProfile !== 'testflight'
)
fail(`${release.channel} requires the testflight native profile`)
if (!release.platforms || Object.keys(release.platforms).length === 0)
fail('At least one platform is required')
const verification = {schemaVersion: 1, valid: true, platforms: {}}
for (const [platform, entry] of Object.entries(release.platforms)) {
if (
!['ios', 'android'].includes(platform) ||
!/^[0-9a-f]{40}$/.test(entry.runtimeVersion ?? '')
)
fail(`Invalid ${platform} entry`)
const report = await readJson(
base,
entry.fingerprintReportRef,
`${platform} fingerprintReportRef`,
)
validateFingerprintReport(
report,
{
platform,
nativeProfile: release.nativeProfile,
sourceCommit: release.sourceCommit,
runtimeVersion: entry.runtimeVersion,
},
`${platform} export fingerprint report`,
)
await fs
.stat(
await resolvePath(
base,
entry.bundleDirectory,
`${platform} bundleDirectory`,
),
)
.catch(() => fail(`${platform} bundleDirectory is missing`))
if (release.channel === 'production') {
if (!/^[0-9]+$/.test(entry.targetNativeBuildNumber ?? ''))
fail(`Production ${platform} target is required`)
if (entry.targetNativeBuildReceiptRef != null) {
const receiptPath = await resolvePath(
base,
entry.targetNativeBuildReceiptRef,
`${platform} receipt ref`,
)
const receipt = JSON.parse(await fs.readFile(receiptPath, 'utf8'))
const receiptReport = await readJson(
path.dirname(receiptPath),
receipt.fingerprintReportRef,
`${platform} receipt report ref`,
)
if (
receipt.schemaVersion !== 1 ||
receipt.platform !== platform ||
receipt.nativeProfile !== 'production' ||
receipt.defaultChannel !== 'production' ||
receipt.nativeBuildNumber !== entry.targetNativeBuildNumber ||
receipt.runtimeVersion !== entry.runtimeVersion ||
receipt.fingerprintPolicyVersion !== 1 ||
typeof receipt.fingerprintToolVersion !== 'string' ||
!/^[0-9a-f]{40}$/.test(receipt.sourceCommit ?? '') ||
typeof receipt.appVersion !== 'string' ||
receipt.appVersion.length === 0 ||
typeof receipt.buildRunUrl !== 'string' ||
receipt.buildRunUrl.length === 0 ||
!/^[0-9a-f]{64}$/.test(receipt.artifactDigest ?? '')
)
fail(`Production ${platform} receipt is incompatible`)
validateFingerprintReport(
receiptReport,
{
platform,
nativeProfile: 'production',
sourceCommit: receipt.sourceCommit,
runtimeVersion: receipt.runtimeVersion,
},
`Production ${platform} receipt fingerprint report`,
)
if (
receiptReport.fingerprintToolVersion !==
receipt.fingerprintToolVersion
)
fail(`Production ${platform} receipt tool version is inconsistent`)
verification.platforms[platform] = {
receiptVerification: {status: 'verified', reason: 'compatible'},
}
} else {
console.error(
`Warning: production ${platform} target could not be verified because no native build receipt was supplied`,
)
verification.platforms[platform] = {
receiptVerification: {
status: 'unverified',
reason: 'receipt-unavailable',
},
}
}
} else if (
entry.targetNativeBuildNumber != null ||
entry.targetNativeBuildReceiptRef != null
)
fail(`${release.channel} must not target a native build`)
else
verification.platforms[platform] = {
receiptVerification: {status: 'not-required', reason: 'channel-policy'},
}
}
process.stdout.write(`${JSON.stringify(verification)}\n`)
}
main().catch(error => {
console.error(error instanceof Error ? error.message : error)
process.exitCode = 1
})
+189
View File
@@ -0,0 +1,189 @@
import assert from 'node:assert/strict'
import {execFileSync} from 'node:child_process'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import test from 'node:test'
const validator = path.resolve(import.meta.dirname, 'validate-release.mjs')
const sha = 'a'.repeat(40)
const runtime = 'b'.repeat(40)
function fixture(channel = 'production') {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ota-release-'))
fs.mkdirSync(path.join(directory, 'bundle'))
fs.writeFileSync(
path.join(directory, 'report.json'),
JSON.stringify({
schemaVersion: 1,
platform: 'ios',
nativeProfile: 'production',
sourceCommit: sha,
runtimeVersion: runtime,
fingerprintPolicyVersion: 1,
fingerprintToolVersion: '0.20.8',
toolVersions: {
expo: '57.0.8',
expoUpdates: '57.0.10',
node: 'v24.19.0',
packageManager: 'pnpm@11.21.0',
},
fingerprintSources: [
{type: 'contents', id: 'expoConfig', hash: 'c'.repeat(40)},
{
type: 'file',
filePath: 'scripts/ota/fingerprint-policy.json',
hash: 'd'.repeat(40),
},
...['BlueskyClip', 'BlueskyNSE', 'Share-with-Bluesky'].map(name => ({
type: 'dir',
filePath: `modules/${name}`,
hash: 'e'.repeat(40),
})),
],
}),
)
const release = {
schemaVersion: 1,
sourceCommit: sha,
channel,
nativeProfile: 'production',
bundleVersion: '1788537600000',
platforms: {
ios: {
runtimeVersion: runtime,
fingerprintReportRef: 'report.json',
bundleDirectory: 'bundle',
...(channel === 'production' ? {targetNativeBuildNumber: '42'} : {}),
},
},
}
const releaseFile = path.join(directory, 'ota-export.json')
fs.writeFileSync(releaseFile, JSON.stringify(release))
return {directory, release, releaseFile}
}
function validate(releaseFile) {
return execFileSync(
process.execPath,
[validator, '--release-file', releaseFile],
{
encoding: 'utf8',
},
)
}
test('production requires an exact build but permits an unavailable receipt', () => {
const value = fixture()
const result = JSON.parse(validate(value.releaseFile))
assert.deepEqual(result.platforms.ios.receiptVerification, {
status: 'unverified',
reason: 'receipt-unavailable',
})
delete value.release.platforms.ios.targetNativeBuildNumber
fs.writeFileSync(value.releaseFile, JSON.stringify(value.release))
assert.throws(
() => validate(value.releaseFile),
/Production ios target is required/,
)
})
test('testflight rejects build targeting', () => {
const value = fixture('testflight')
value.release.nativeProfile = 'testflight'
value.release.platforms.ios.targetNativeBuildNumber = '42'
const report = JSON.parse(
fs.readFileSync(path.join(value.directory, 'report.json')),
)
report.nativeProfile = 'testflight'
fs.writeFileSync(
path.join(value.directory, 'report.json'),
JSON.stringify(report),
)
fs.writeFileSync(value.releaseFile, JSON.stringify(value.release))
assert.throws(
() => validate(value.releaseFile),
/must not target a native build/,
)
})
test('a supplied receipt with an invalid source SHA fails closed', () => {
const value = fixture()
fs.mkdirSync(path.join(value.directory, 'receipts'))
value.release.platforms.ios.targetNativeBuildReceiptRef =
'receipts/receipt.json'
fs.writeFileSync(
path.join(value.directory, 'receipts/receipt.json'),
JSON.stringify({
schemaVersion: 1,
platform: 'ios',
nativeProfile: 'production',
defaultChannel: 'production',
appVersion: '1.133.0',
nativeBuildNumber: '42',
runtimeVersion: runtime,
sourceCommit: 'short',
fingerprintPolicyVersion: 1,
fingerprintToolVersion: '0.20.8',
artifactDigest: 'c'.repeat(64),
buildRunUrl: 'https://github.example/build/1',
fingerprintReportRef: 'report.json',
}),
)
fs.writeFileSync(path.join(value.directory, 'receipts/report.json'), '{}')
fs.writeFileSync(value.releaseFile, JSON.stringify(value.release))
assert.throws(() => validate(value.releaseFile), /receipt is incompatible/)
})
test('bundle directories cannot escape the release directory', () => {
const value = fixture('testflight')
value.release.nativeProfile = 'testflight'
value.release.platforms.ios.bundleDirectory = '../bundle'
const report = JSON.parse(
fs.readFileSync(path.join(value.directory, 'report.json')),
)
report.nativeProfile = 'testflight'
fs.writeFileSync(
path.join(value.directory, 'report.json'),
JSON.stringify(report),
)
fs.writeFileSync(value.releaseFile, JSON.stringify(value.release))
assert.throws(
() => validate(value.releaseFile),
/escapes the release directory/,
)
})
test('channel and native profile must agree', () => {
const value = fixture('testflight')
assert.throws(
() => validate(value.releaseFile),
/requires the testflight native profile/,
)
})
test('incomplete fingerprint reports are rejected', () => {
const value = fixture()
const report = JSON.parse(
fs.readFileSync(path.join(value.directory, 'report.json')),
)
delete report.fingerprintToolVersion
fs.writeFileSync(
path.join(value.directory, 'report.json'),
JSON.stringify(report),
)
assert.throws(
() => validate(value.releaseFile),
/fingerprint report is incomplete or inconsistent/,
)
})
test('symlinked bundle directories cannot escape the release directory', () => {
const value = fixture()
fs.rmSync(path.join(value.directory, 'bundle'), {recursive: true})
fs.symlinkSync(os.tmpdir(), path.join(value.directory, 'bundle'))
assert.throws(
() => validate(value.releaseFile),
/escapes the release directory/,
)
})
+3 -1
View File
@@ -92,11 +92,13 @@ export function useIntentHandler() {
releaseVersion && buildNumber
? `${releaseVersion}.${buildNumber}`
: null
const sourceCommit = params.get('sourceCommit')
const publicationId = params.get('publicationId')
if (!channel) {
Alert.alert('Error', 'No channel provided to look for.')
return
}
tryApplyUpdate(channel, appVersion)
tryApplyUpdate(channel, appVersion, {sourceCommit, publicationId})
return
}
default: {
+207 -2
View File
@@ -1,4 +1,4 @@
import {Alert} from 'react-native'
import {Alert, AppState} from 'react-native'
import {
checkForUpdateAsync,
fetchUpdateAsync,
@@ -13,15 +13,24 @@ import {logger} from '#/logger'
import {APP_VERSION} from '#/env'
import {device} from '#/storage'
import {
checkForOTAUpdate,
prepareOTAUpdateRequest,
useApplyPullRequestOTAUpdate,
useOTAUpdateRecovery,
useOTAUpdates,
} from './useOTAUpdates'
let mockRuntimeVersion = '1.133.0'
jest.mock('expo-updates', () => ({
channel: 'testflight',
checkForUpdateAsync: jest.fn(),
fetchUpdateAsync: jest.fn(),
isEnabled: true,
reloadAsync: jest.fn(),
get runtimeVersion() {
return mockRuntimeVersion
},
setExtraParamAsync: jest.fn(),
UpdateCheckResultNotAvailableReason: {
NO_UPDATE_AVAILABLE_ON_SERVER: 'noUpdateAvailableOnServer',
@@ -61,10 +70,12 @@ function mockCurrentlyRunning({
buildChannel = 'testflight',
channel,
updateId = 'current-update',
isUpdatePending = false,
}: {
buildChannel?: string
channel?: string
updateId?: string
isUpdatePending?: boolean
} = {}) {
const currentlyRunning = {
channel: buildChannel,
@@ -76,6 +87,7 @@ function mockCurrentlyRunning({
}
jest.mocked(useUpdates).mockReturnValue({
currentlyRunning,
isUpdatePending,
} as ReturnType<typeof useUpdates>)
return currentlyRunning
}
@@ -84,13 +96,30 @@ const currentUpdate = {updateId: 'current-update'}
beforeEach(() => {
jest.clearAllMocks()
mockRuntimeVersion = '1.133.0'
mockCurrentlyRunning()
jest.mocked(setExtraParamAsync).mockResolvedValue(undefined)
jest.mocked(reloadAsync).mockResolvedValue(undefined)
jest.spyOn(Alert, 'alert').mockImplementation(() => {})
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({status: 'available', deployments: []}),
})
})
describe('useApplyPullRequestOTAUpdate', () => {
it('rejects manually applying a non-PR channel', async () => {
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
await act(() => result.current.tryApplyUpdate('testflight'))
expect(Alert.alert).toHaveBeenCalledWith(
'Invalid Deployment',
expect.stringContaining('Only pull request'),
)
expect(checkForUpdateAsync).not.toHaveBeenCalled()
})
it('detects a running PR deployment from the manifest metadata', () => {
mockCurrentlyRunning({
buildChannel: 'testflight',
@@ -209,7 +238,7 @@ describe('useApplyPullRequestOTAUpdate', () => {
isNew: true,
isRollBackToEmbedded: false,
manifest: {id: 'mismatched-update'},
} as Awaited<ReturnType<typeof fetchUpdateAsync>>)
} as unknown as Awaited<ReturnType<typeof fetchUpdateAsync>>)
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
await act(() => result.current.tryApplyUpdate('pull-request-123', '0.0.0'))
@@ -231,6 +260,103 @@ describe('useApplyPullRequestOTAUpdate', () => {
})
})
it('does not offer an app-version override to fingerprint clients', async () => {
mockRuntimeVersion = 'a'.repeat(40)
jest.mocked(checkForUpdateAsync).mockResolvedValue({
isAvailable: true,
} as Awaited<ReturnType<typeof checkForUpdateAsync>>)
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
await act(() => result.current.tryApplyUpdate('pull-request-123', '0.0.0'))
expect(Alert.alert).toHaveBeenCalledWith(
'Apply update from PR #123?',
expect.stringContaining('relaunch'),
expect.arrayContaining([expect.objectContaining({text: 'Apply'})]),
)
expect(Alert.alert).not.toHaveBeenCalledWith(
'App Version Mismatch',
expect.anything(),
expect.anything(),
)
})
it.each([
['runtime-mismatch', 'Different Native Build Required'],
['not-published', 'No Deployment Available'],
['stale-link', 'Deployment Link Is Out of Date'],
] as const)('handles the %s diagnostic status', async (status, title) => {
mockRuntimeVersion = 'a'.repeat(40)
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({status, deployments: []}),
})
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
await act(() => result.current.tryApplyUpdate('pull-request-123'))
expect(Alert.alert).toHaveBeenCalledWith(title, expect.any(String))
expect(checkForUpdateAsync).not.toHaveBeenCalled()
})
it('stays quiet when diagnostics report the deployment already running', async () => {
mockRuntimeVersion = 'a'.repeat(40)
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({status: 'already-running', deployments: []}),
})
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
await act(() => result.current.tryApplyUpdate('pull-request-123'))
expect(Alert.alert).not.toHaveBeenCalled()
expect(checkForUpdateAsync).not.toHaveBeenCalled()
expect(result.current.pending).toBe(false)
})
it('allows only one PR confirmation prompt at a time', async () => {
jest.mocked(checkForUpdateAsync).mockResolvedValue({
isAvailable: true,
} as Awaited<ReturnType<typeof checkForUpdateAsync>>)
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
await act(() => result.current.tryApplyUpdate('pull-request-123'))
await act(() => result.current.tryApplyUpdate('pull-request-456'))
expect(Alert.alert).toHaveBeenCalledTimes(1)
expect(result.current.pending).toBe(true)
})
it('rejects a fetched deployment whose source does not match the link', async () => {
mockRuntimeVersion = 'a'.repeat(40)
jest.mocked(checkForUpdateAsync).mockResolvedValue({
isAvailable: true,
} as Awaited<ReturnType<typeof checkForUpdateAsync>>)
jest.mocked(fetchUpdateAsync).mockResolvedValue({
isNew: true,
isRollBackToEmbedded: false,
manifest: {id: 'new-update', extra: {ota: {sourceCommit: 'newer'}}},
} as unknown as Awaited<ReturnType<typeof fetchUpdateAsync>>)
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
await act(() =>
result.current.tryApplyUpdate('pull-request-123', null, {
sourceCommit: 'expected',
}),
)
const buttons = jest.mocked(Alert.alert).mock.calls[0][2]
void act(() => buttons?.[1].onPress?.())
await waitFor(() =>
expect(Alert.alert).toHaveBeenLastCalledWith(
'Update Failed',
expect.stringContaining('deployment changed'),
),
)
expect(reloadAsync).not.toHaveBeenCalled()
expect(setExtraParamAsync).toHaveBeenLastCalledWith('channel', 'testflight')
})
it('informs the user when checking for an OTA fails', async () => {
jest.mocked(checkForUpdateAsync).mockRejectedValue(new Error('offline'))
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
@@ -297,8 +423,87 @@ describe('useApplyPullRequestOTAUpdate', () => {
updateId: 'new-update',
})
expect(device.remove).toHaveBeenCalledWith(['pendingOTAUpdate'])
expect(setExtraParamAsync).toHaveBeenLastCalledWith('channel', 'testflight')
expect(result.current.pending).toBe(false)
})
it('restores the default request after cancellation', async () => {
jest.mocked(checkForUpdateAsync).mockResolvedValue({
isAvailable: true,
} as Awaited<ReturnType<typeof checkForUpdateAsync>>)
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
await act(() => result.current.tryApplyUpdate('pull-request-123'))
const buttons = jest.mocked(Alert.alert).mock.calls[0][2]
await act(() => buttons?.[0].onPress?.())
await waitFor(() =>
expect(setExtraParamAsync).toHaveBeenLastCalledWith(
'channel',
'testflight',
),
)
expect(result.current.pending).toBe(false)
})
})
describe('useOTAUpdates', () => {
it('does not reload a pending default update while a PR prompt owns the request', async () => {
let appStateListener: ((state: string) => Promise<void>) | undefined
jest
.spyOn(AppState, 'addEventListener')
.mockImplementation((_, listener) => {
appStateListener = listener as (state: string) => Promise<void>
return {remove: jest.fn()}
})
jest.mocked(checkForUpdateAsync).mockResolvedValue({
isAvailable: true,
} as Awaited<ReturnType<typeof checkForUpdateAsync>>)
mockCurrentlyRunning({isUpdatePending: true})
const apply = renderHook(() => useApplyPullRequestOTAUpdate())
renderHook(() => useOTAUpdates())
await act(() => apply.result.current.tryApplyUpdate('pull-request-123'))
jest
.spyOn(Date, 'now')
.mockReturnValueOnce(0)
.mockReturnValue(16 * 60e3)
await act(async () => {
await appStateListener?.('background')
await appStateListener?.('active')
})
expect(reloadAsync).not.toHaveBeenCalled()
})
})
describe('OTA request preparation', () => {
it('sets the native build number before the channel', async () => {
await prepareOTAUpdateRequest('pull-request-123')
expect(setExtraParamAsync).toHaveBeenNthCalledWith(
1,
expect.stringMatching(/^(ios|android)-build-number$/),
expect.any(String),
)
expect(setExtraParamAsync).toHaveBeenNthCalledWith(
2,
'channel',
'pull-request-123',
)
})
it('prepares the default channel before checking', async () => {
jest.mocked(checkForUpdateAsync).mockResolvedValue({
isAvailable: false,
reason: UpdateCheckResultNotAvailableReason.NO_UPDATE_AVAILABLE_ON_SERVER,
} as Awaited<ReturnType<typeof checkForUpdateAsync>>)
await checkForOTAUpdate()
expect(setExtraParamAsync).toHaveBeenLastCalledWith('channel', 'testflight')
expect(checkForUpdateAsync).toHaveBeenCalledTimes(1)
})
})
describe('useOTAUpdateRecovery', () => {
+203 -32
View File
@@ -7,12 +7,14 @@ import {
} from 'react-native'
import {nativeBuildVersion} from 'expo-application'
import {
channel as nativeChannel,
checkForUpdateAsync,
type CurrentlyRunningInfo,
fetchUpdateAsync,
isEnabled,
reloadAsync,
type ReloadScreenOptions,
runtimeVersion as nativeRuntimeVersion,
setExtraParamAsync,
UpdateCheckResultNotAvailableReason,
useUpdates,
@@ -31,7 +33,42 @@ const OTA_RECOVERY_WINDOW = 5 * 60e3
* The channel this native build is expected to receive updates from. Anything
* else is only reachable through the dev tooling in settings.
*/
const DEFAULT_CHANNEL = IS_TESTFLIGHT ? 'testflight' : 'production'
const DEFAULT_CHANNEL =
nativeChannel || (IS_TESTFLIGHT ? 'testflight' : 'production')
const FINGERPRINT_RUNTIME_VERSION = /^[0-9a-f]{40}$/
const OTA_RESOLVE_URL = 'https://updates.bsky.app/v1/ota/resolve'
type OTAResolveResult = {
status:
| 'available'
| 'already-running'
| 'runtime-mismatch'
| 'not-published'
| 'stale-link'
deployments: {
runtimeVersion: string
sourceCommit?: string
publicationId?: string
updateId?: string
}[]
}
let requestQueue = Promise.resolve()
let manualRequestOwner: symbol | undefined
function isManualRequestPending() {
return manualRequestOwner !== undefined
}
function serializeOTARequest<T>(operation: () => Promise<T>): Promise<T> {
const result = requestQueue.then(operation, operation)
requestQueue = result.then(
() => undefined,
() => undefined,
)
return result
}
/**
* Channels that our native builds are configured with, see `eas.json`. An
@@ -69,17 +106,7 @@ function getRunningChannel(
return currentlyRunning?.channel || undefined
}
async function setExtraParams() {
await setExtraParamAsync(
IS_IOS ? 'ios-build-number' : 'android-build-number',
// Hilariously, `buildVersion` is not actually a string on Android even though the TS type says it is.
// This just ensures it gets passed as a string
`${nativeBuildVersion}`,
)
await setExtraParamAsync('channel', DEFAULT_CHANNEL)
}
async function setExtraParamsPullRequest(channel: string) {
export async function prepareOTAUpdateRequest(channel = DEFAULT_CHANNEL) {
await setExtraParamAsync(
IS_IOS ? 'ios-build-number' : 'android-build-number',
// Hilariously, `buildVersion` is not actually a string on Android even though the TS type says it is.
@@ -89,12 +116,55 @@ async function setExtraParamsPullRequest(channel: string) {
await setExtraParamAsync('channel', channel)
}
async function updateTestflight(scheme: 'light' | 'dark') {
await setExtraParams()
export function checkForOTAUpdate(channel = DEFAULT_CHANNEL) {
return serializeOTARequest(async () => {
await prepareOTAUpdateRequest(channel)
return checkForUpdateAsync()
})
}
const res = await checkForUpdateAsync()
export function fetchOTAUpdate(channel = DEFAULT_CHANNEL) {
return serializeOTARequest(async () => {
await prepareOTAUpdateRequest(channel)
return fetchUpdateAsync()
})
}
async function resolvePullRequestDeployment({
channel,
currentUpdateId,
expected,
}: {
channel: string
currentUpdateId?: string
expected?: {sourceCommit?: string | null; publicationId?: string | null}
}): Promise<OTAResolveResult | undefined> {
if (!FINGERPRINT_RUNTIME_VERSION.test(nativeRuntimeVersion || '')) {
return undefined
}
const query = new URLSearchParams({
channel,
platform: IS_IOS ? 'ios' : 'android',
runtimeVersion: nativeRuntimeVersion!,
})
if (currentUpdateId) query.set('currentUpdateId', currentUpdateId)
if (expected?.sourceCommit) {
query.set('sourceCommit', expected.sourceCommit)
}
if (expected?.publicationId) {
query.set('publicationId', expected.publicationId)
}
const response = await fetch(`${OTA_RESOLVE_URL}?${query}`)
if (!response.ok) throw new Error(`OTA resolve failed (${response.status})`)
return response.json() as Promise<OTAResolveResult>
}
async function updateTestflight(scheme: 'light' | 'dark') {
const res = await checkForOTAUpdate()
if (res.isAvailable) {
await fetchUpdateAsync()
await fetchOTAUpdate()
Alert.alert(
'Update Available',
'A new version of the app is available. Relaunch now?',
@@ -121,6 +191,7 @@ export function useApplyPullRequestOTAUpdate() {
const t = useTheme()
const {currentlyRunning} = useUpdates()
const [pending, setPending] = useState(false)
const requestOwnerRef = useRef<symbol>(undefined)
const currentChannel = getRunningChannel(currentlyRunning)
const isCurrentlyRunningPullRequestDeployment =
currentChannel?.startsWith('pull-request')
@@ -133,15 +204,73 @@ export function useApplyPullRequestOTAUpdate() {
currentChannel && !STANDARD_CHANNELS.includes(currentChannel),
)
useEffect(() => {
return () => {
if (manualRequestOwner === requestOwnerRef.current) {
manualRequestOwner = undefined
void serializeOTARequest(() => prepareOTAUpdateRequest())
}
}
}, [])
const tryApplyUpdate = async (
channel: string,
declaredAppVersion?: string | null,
expected?: {sourceCommit?: string | null; publicationId?: string | null},
) => {
if (!/^pull-request-[1-9]\d*$/.test(channel)) {
Alert.alert(
'Invalid Deployment',
'Only pull request deployments can be applied manually.',
)
return
}
if (manualRequestOwner) return
const requestOwner = Symbol(channel)
manualRequestOwner = requestOwner
requestOwnerRef.current = requestOwner
setPending(true)
const deploymentName = getDeploymentName(channel)
let resolvedDeployment: OTAResolveResult | undefined
const checkForDeployment = async () => {
await setExtraParamsPullRequest(channel)
const res = await checkForUpdateAsync()
try {
resolvedDeployment = await resolvePullRequestDeployment({
channel,
currentUpdateId: currentlyRunning?.updateId,
expected,
})
} catch (err) {
logger.debug('Could not resolve OTA deployment diagnostics', {
safeMessage: err,
})
}
if (resolvedDeployment?.status === 'already-running') return false
if (resolvedDeployment?.status === 'runtime-mismatch') {
Alert.alert(
'Different Native Build Required',
`The ${deploymentName} deployment requires a different native build. Install a compatible TestFlight build and try again.`,
)
return false
}
if (resolvedDeployment?.status === 'not-published') {
Alert.alert(
'No Deployment Available',
`The ${deploymentName} deployment has not been published or is no longer available.`,
)
return false
}
if (resolvedDeployment?.status === 'stale-link') {
Alert.alert(
'Deployment Link Is Out of Date',
`This link does not refer to the latest ${deploymentName} deployment. Open the newest link and try again.`,
)
return false
}
const res = await checkForOTAUpdate(channel)
if (!res.isAvailable) {
if (
res.reason ===
@@ -161,16 +290,50 @@ export function useApplyPullRequestOTAUpdate() {
return res.isAvailable
}
const finishManualRequest = async (restoreDefault: boolean) => {
if (manualRequestOwner !== requestOwner) return
manualRequestOwner = undefined
requestOwnerRef.current = undefined
if (restoreDefault) {
await serializeOTARequest(() => prepareOTAUpdateRequest()).catch(
() => {},
)
}
setPending(false)
}
const restoreAfterCancellation = () => {
void finishManualRequest(true)
}
const applyUpdate = () => {
setPending(true)
void (async () => {
let reloadSucceeded = false
try {
if (!(await checkForDeployment())) return
const fetchedUpdate = await fetchUpdateAsync()
const fetchedUpdate = await fetchOTAUpdate(channel)
if (!fetchedUpdate.isNew) {
throw new Error('Expo did not download a new update.')
}
const manifest = fetchedUpdate.manifest as {
extra?: {
ota?: {sourceCommit?: unknown; publicationId?: unknown}
}
}
const sourceCommit = manifest.extra?.ota?.sourceCommit
const publicationId = manifest.extra?.ota?.publicationId
if (
(expected?.sourceCommit &&
sourceCommit !== expected.sourceCommit) ||
(expected?.publicationId &&
publicationId !== expected.publicationId)
) {
throw new Error(
'The deployment changed while it was being downloaded. Check the link again to review the newest deployment.',
)
}
device.set(['pendingOTAUpdate'], {
attemptedAt: Date.now(),
channel,
@@ -180,6 +343,7 @@ export function useApplyPullRequestOTAUpdate() {
await reloadAsync({
reloadScreenOptions: splash(t.scheme),
})
reloadSucceeded = true
} catch (e) {
device.remove(['pendingOTAUpdate'])
throw e
@@ -192,7 +356,7 @@ export function useApplyPullRequestOTAUpdate() {
`Could not apply the ${deploymentName} deployment: ${error}`,
)
} finally {
setPending(false)
await finishManualRequest(!reloadSucceeded)
}
})()
}
@@ -203,11 +367,17 @@ export function useApplyPullRequestOTAUpdate() {
* update re-delivers the deep link that triggered it, and the same link may
* also just be tapped again.
*/
setPending(true)
try {
if (!(await checkForDeployment())) return
if (!(await checkForDeployment())) {
await finishManualRequest(true)
return
}
if (declaredAppVersion && declaredAppVersion !== APP_VERSION) {
if (
!FINGERPRINT_RUNTIME_VERSION.test(nativeRuntimeVersion || '') &&
declaredAppVersion &&
declaredAppVersion !== APP_VERSION
) {
Alert.alert(
'App Version Mismatch',
`This OTA update was built for a different version of the app.\n\nCurrent app version: ${APP_VERSION}\nOTA app version: ${declaredAppVersion}\n\nApplying it anyway may cause the app to stop working and require a reinstall.`,
@@ -215,6 +385,7 @@ export function useApplyPullRequestOTAUpdate() {
{
text: 'Cancel',
style: 'cancel',
onPress: restoreAfterCancellation,
},
{
text: 'Apply Anyway',
@@ -233,6 +404,7 @@ export function useApplyPullRequestOTAUpdate() {
{
text: 'Cancel',
style: 'cancel',
onPress: restoreAfterCancellation,
},
{
text: 'Apply',
@@ -242,14 +414,13 @@ export function useApplyPullRequestOTAUpdate() {
],
)
} catch (e: unknown) {
await finishManualRequest(true)
const error = String(e)
logger.error('Internal OTA Update Error', {error})
Alert.alert(
'Update Check Failed',
`Could not check the ${deploymentName} deployment: ${error}`,
)
} finally {
setPending(false)
}
}
@@ -260,10 +431,9 @@ export function useApplyPullRequestOTAUpdate() {
const restoreDefaultChannel = async () => {
setPending(true)
try {
await setExtraParams()
const res = await checkForUpdateAsync()
const res = await checkForOTAUpdate()
if (res.isAvailable) {
await fetchUpdateAsync()
await fetchOTAUpdate()
await reloadAsync()
} else {
Alert.alert(
@@ -351,14 +521,14 @@ export function useOTAUpdates() {
const setCheckTimeout = useCallback(() => {
timeout.current = setTimeout(async () => {
try {
await setExtraParams()
if (isManualRequestPending()) return
logger.debug('Checking for update...')
const res = await checkForUpdateAsync()
const res = await checkForOTAUpdate()
if (res.isAvailable) {
logger.debug('Attempting to fetch update...')
await fetchUpdateAsync()
await fetchOTAUpdate()
} else {
logger.debug('No update available.')
}
@@ -419,6 +589,7 @@ export function useOTAUpdates() {
// If it's been 15 minutes since the last "minimize", we should feel comfortable updating the client since
// chances are that there isn't anything important going on in the current session.
if (lastMinimize.current <= Date.now() - MINIMUM_MINIMIZE_TIME) {
if (isManualRequestPending()) return
if (isUpdatePending) {
await reloadAsync({
reloadScreenOptions: splash(t.scheme),
+4
View File
@@ -5,6 +5,10 @@ export function useApplyPullRequestOTAUpdate() {
tryApplyUpdate: async (
_channel: string,
_declaredAppVersion?: string | null,
_expected?: {
sourceCommit?: string | null
publicationId?: string | null
},
) => {},
restoreDefaultChannel: async () => {},
isCurrentlyRunningPullRequestDeployment: false,
+7 -3
View File
@@ -4,7 +4,11 @@ import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {useMutation, useQuery} from '@tanstack/react-query'
import {splash} from '#/lib/hooks/useOTAUpdates'
import {
checkForOTAUpdate,
fetchOTAUpdate,
splash,
} from '#/lib/hooks/useOTAUpdates'
import {useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotate'
@@ -25,7 +29,7 @@ export function OTAInfo() {
} = useQuery({
queryKey: ['ota-info'],
queryFn: async () => {
const status = await Updates.checkForUpdateAsync()
const status = await checkForOTAUpdate()
return status.isAvailable
},
})
@@ -33,7 +37,7 @@ export function OTAInfo() {
const {mutate: fetchAndLaunchUpdate, isPending: isPendingUpdate} =
useMutation({
mutationFn: async () => {
await Updates.fetchUpdateAsync()
await fetchOTAUpdate()
await Updates.reloadAsync({
reloadScreenOptions: splash(t.scheme),
})