diff --git a/.github/actions/native-build-receipt/action.yml b/.github/actions/native-build-receipt/action.yml new file mode 100644 index 0000000000..bef7fda754 --- /dev/null +++ b/.github/actions/native-build-receipt/action.yml @@ -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 diff --git a/.github/scripts/find-native-receipt.mjs b/.github/scripts/find-native-receipt.mjs new file mode 100644 index 0000000000..8016860d16 --- /dev/null +++ b/.github/scripts/find-native-receipt.mjs @@ -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 + }) +} diff --git a/.github/scripts/find-native-receipt.test.mjs b/.github/scripts/find-native-receipt.test.mjs new file mode 100644 index 0000000000..c33cf72c02 --- /dev/null +++ b/.github/scripts/find-native-receipt.test.mjs @@ -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/, + ) +}) diff --git a/.github/scripts/native-receipt-android.mjs b/.github/scripts/native-receipt-android.mjs new file mode 100644 index 0000000000..0fc743feea --- /dev/null +++ b/.github/scripts/native-receipt-android.mjs @@ -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('"', '"') + .replaceAll(''', "'") + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('&', '&') +} + +export function parsePackagedAndroidConfig(manifestXml, runtimeResources) { + const metadataTags = [...manifestXml.matchAll(/]*>/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 ') + } + 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 + } +} diff --git a/.github/scripts/native-receipt-android.test.mjs b/.github/scripts/native-receipt-android.test.mjs new file mode 100644 index 0000000000..897227b19c --- /dev/null +++ b/.github/scripts/native-receipt-android.test.mjs @@ -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') => ` + + + + + +` + +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('{"expo-channel-name":"testflight"}'), + resources('file:fingerprint'), + ), + {channel: 'testflight', runtimeConfiguration: 'file:fingerprint'}, + ) +}) + +test('rejects an ambiguous request-header entry', () => { + const entry = manifest( + '{"expo-channel-name":"production"}', + ) + 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('{"expo-channel-name":"production"}'), + resources('1.2.3'), + ), + /not file:fingerprint/, + ) +}) + +test('accepts a manifest with a direct fingerprint sentinel', () => { + assert.deepEqual( + parsePackagedAndroidConfig( + manifest( + '{"expo-channel-name":"production"}', + 'file:fingerprint', + ), + '', + ), + {channel: 'production', runtimeConfiguration: 'file:fingerprint'}, + ) +}) + +test('binds a compiled runtime reference to the named resource ID', () => { + assert.deepEqual( + parsePackagedAndroidConfig( + manifest( + '{"expo-channel-name":"testflight"}', + '@ref/0x7f1200aa', + ), + resources('file:fingerprint'), + ), + {channel: 'testflight', runtimeConfiguration: 'file:fingerprint'}, + ) +}) + +test('rejects a compiled reference to a different resource ID', () => { + assert.throws( + () => + parsePackagedAndroidConfig( + manifest( + '{"expo-channel-name":"testflight"}', + '@ref/0x7f1300d8', + ), + resources('file:fingerprint'), + ), + /does not identify expo_runtime_version/, + ) +}) + +test('rejects an ambiguous named runtime resource', () => { + assert.throws( + () => + parsePackagedAndroidConfig( + manifest( + '{"expo-channel-name":"testflight"}', + '@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('{"channel":"production"}'), + resources('file:fingerprint'), + ), + /omit expo-channel-name/, + ) +}) diff --git a/.github/workflows/build-submit-android.yml b/.github/workflows/build-submit-android.yml index 4f0a8b6418..797444495d 100644 --- a/.github/workflows/build-submit-android.yml +++ b/.github/workflows/build-submit-android.yml @@ -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: diff --git a/.github/workflows/build-submit-ios.yml b/.github/workflows/build-submit-ios.yml index f1c54c6cb0..eda5fae609 100644 --- a/.github/workflows/build-submit-ios.yml +++ b/.github/workflows/build-submit-ios.yml @@ -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 diff --git a/.github/workflows/bundle-deploy-eas-update.yml b/.github/workflows/bundle-deploy-eas-update.yml index d4e18cc510..1ade4be045 100644 --- a/.github/workflows/bundle-deploy-eas-update.yml +++ b/.github/workflows/bundle-deploy-eas-update.yml @@ -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 diff --git a/.github/workflows/bundle-deploy-fingerprint.yml b/.github/workflows/bundle-deploy-fingerprint.yml new file mode 100644 index 0000000000..7159ad0930 --- /dev/null +++ b/.github/workflows/bundle-deploy-fingerprint.yml @@ -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" diff --git a/.github/workflows/pull-request-commit.yml b/.github/workflows/pull-request-commit.yml index bcd7749479..23c30367eb 100644 --- a/.github/workflows/pull-request-commit.yml +++ b/.github/workflows/pull-request-commit.yml @@ -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: 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 }}` diff --git a/docs/deploy-ota.md b/docs/deploy-ota.md index 52a319bf38..318cddac57 100644 --- a/docs/deploy-ota.md +++ b/docs/deploy-ota.md @@ -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.