stage fingerprint ota workflows and native receipts
This commit is contained in:
@@ -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
|
||||||
@@ -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/,
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -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(/<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('{"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/,
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -76,7 +76,7 @@ jobs:
|
|||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
outputs:
|
outputs:
|
||||||
package-version: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}
|
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:
|
steps:
|
||||||
- name: ⬇️ Checkout
|
- name: ⬇️ Checkout
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
@@ -109,6 +109,8 @@ jobs:
|
|||||||
|
|
||||||
- name: 🏗️ EAS Build
|
- name: 🏗️ EAS Build
|
||||||
uses: ./.github/actions/eas-local-build
|
uses: ./.github/actions/eas-local-build
|
||||||
|
env:
|
||||||
|
OTA_FINGERPRINT_PIPELINE_ENABLED: ${{ vars.OTA_FINGERPRINT_PIPELINE_ENABLED == 'true' && '1' || '' }}
|
||||||
with:
|
with:
|
||||||
platform: android
|
platform: android
|
||||||
profile: ${{ inputs.profile || 'testflight-android' }}
|
profile: ${{ inputs.profile || 'testflight-android' }}
|
||||||
@@ -122,6 +124,43 @@ jobs:
|
|||||||
id: get-build-info
|
id: get-build-info
|
||||||
run: bash scripts/setGitHubOutput.sh
|
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
|
# 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.
|
# deliberately short (1 day) since it's only an intra-run handoff artifact.
|
||||||
- name: 🚀 Upload AAB artifact
|
- name: 🚀 Upload AAB artifact
|
||||||
@@ -134,12 +173,12 @@ jobs:
|
|||||||
|
|
||||||
- name: 📝 Write build summary
|
- name: 📝 Write build summary
|
||||||
env:
|
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: |
|
run: |
|
||||||
{
|
{
|
||||||
echo "### Android build number"
|
echo "### Android build number"
|
||||||
echo
|
echo
|
||||||
echo "\`$REMOTE_VERSION_CODE\`"
|
echo "\`$PACKAGED_VERSION_CODE\`"
|
||||||
} >> "$GITHUB_STEP_SUMMARY"
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
submit:
|
submit:
|
||||||
|
|||||||
@@ -135,6 +135,8 @@ jobs:
|
|||||||
|
|
||||||
- name: 🏗️ EAS Build
|
- name: 🏗️ EAS Build
|
||||||
uses: ./.github/actions/eas-local-build
|
uses: ./.github/actions/eas-local-build
|
||||||
|
env:
|
||||||
|
OTA_FINGERPRINT_PIPELINE_ENABLED: ${{ vars.OTA_FINGERPRINT_PIPELINE_ENABLED == 'true' && '1' || '' }}
|
||||||
with:
|
with:
|
||||||
platform: ios
|
platform: ios
|
||||||
profile: ${{ inputs.profile || 'testflight' }}
|
profile: ${{ inputs.profile || 'testflight' }}
|
||||||
@@ -204,6 +206,28 @@ jobs:
|
|||||||
echo "IPA build number: $build_number"
|
echo "IPA build number: $build_number"
|
||||||
echo "build-number=$build_number" >> "$GITHUB_OUTPUT"
|
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
|
# 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.
|
# this artifact only exists to bridge the two jobs within a single run.
|
||||||
- name: 🚀 Upload build artifact
|
- name: 🚀 Upload build artifact
|
||||||
|
|||||||
@@ -37,7 +37,10 @@ env:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
bundleDeploy:
|
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
|
name: Bundle and Deploy EAS Update
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
# id-token: write lets this job mint an OIDC token to assume the denis
|
# 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"
|
||||||
@@ -262,6 +262,7 @@ jobs:
|
|||||||
github.event_name == 'pull_request' &&
|
github.event_name == 'pull_request' &&
|
||||||
github.event.pull_request.head.repo.full_name == github.repository &&
|
github.event.pull_request.head.repo.full_name == github.repository &&
|
||||||
github.event.pull_request.user.type != 'Bot' &&
|
github.event.pull_request.user.type != 'Bot' &&
|
||||||
|
vars.OTA_FINGERPRINT_PIPELINE_ENABLED != 'true' &&
|
||||||
needs.fingerprint-native.outputs.includes-changes != 'true'
|
needs.fingerprint-native.outputs.includes-changes != 'true'
|
||||||
concurrency:
|
concurrency:
|
||||||
group: pr-ota-${{ github.event.pull_request.number }}
|
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">
|
<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 }}`
|
`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
@@ -1,13 +1,66 @@
|
|||||||
# OTA Deployments
|
# 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
|
## 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
|
OTA deployments to TestFlight/APK installs happen automatically upon all merges
|
||||||
into main. In cases where the fingerprint diff shows incompatible native
|
into main. In cases where the fingerprint diff shows incompatible native
|
||||||
changes, a new client build will automatically be ran and deployed to TestFlight
|
changes, a new client build will automatically be ran and deployed to TestFlight
|
||||||
(iOS) or delivered in Slack (Android).
|
(iOS) or delivered in Slack (Android).
|
||||||
|
|
||||||
## Production OTAs
|
## Legacy production OTAs
|
||||||
|
|
||||||
Production OTAs can only update the JavaScript bundle. Changes to native modules
|
Production OTAs can only update the JavaScript bundle. Changes to native modules
|
||||||
must be done as a full release cycle through the app stores.
|
must be done as a full release cycle through the app stores.
|
||||||
|
|||||||
Reference in New Issue
Block a user