stage fingerprint ota workflows and native receipts

This commit is contained in:
Samuel Newman
2026-09-05 18:56:37 +03:00
parent 50752603ce
commit 212f700937
11 changed files with 1163 additions and 5 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 }}`
+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.