Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 212f700937 | |||
| 50752603ce | |||
| badce43161 |
@@ -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
|
||||
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:
|
||||
|
||||
@@ -135,6 +135,8 @@ jobs:
|
||||
|
||||
- name: 🏗️ EAS Build
|
||||
uses: ./.github/actions/eas-local-build
|
||||
env:
|
||||
OTA_FINGERPRINT_PIPELINE_ENABLED: ${{ vars.OTA_FINGERPRINT_PIPELINE_ENABLED == 'true' && '1' || '' }}
|
||||
with:
|
||||
platform: ios
|
||||
profile: ${{ inputs.profile || 'testflight' }}
|
||||
@@ -204,6 +206,28 @@ jobs:
|
||||
echo "IPA build number: $build_number"
|
||||
echo "build-number=$build_number" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 🧾 Create fingerprint native-build receipt
|
||||
if: ${{ vars.OTA_FINGERPRINT_PIPELINE_ENABLED == 'true' }}
|
||||
uses: ./.github/actions/native-build-receipt
|
||||
env:
|
||||
OTA_FINGERPRINT_PIPELINE_ENABLED: '1'
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
with:
|
||||
platform: ios
|
||||
profile: ${{ inputs.profile || 'testflight' }}
|
||||
artifact-path: ${{ env.BUILD_DIR }}/Bluesky.ipa
|
||||
native-build-number: ${{ steps.ipa-build-number.outputs.build-number }}
|
||||
default-channel: ${{ (inputs.profile || 'testflight') == 'production' && 'production' || 'testflight' }}
|
||||
|
||||
- name: ⬆️ Upload fingerprint native-build receipt
|
||||
if: ${{ vars.OTA_FINGERPRINT_PIPELINE_ENABLED == 'true' }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: native-build-ios-${{ inputs.profile || 'testflight' }}-${{ steps.ipa-build-number.outputs.build-number }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: native-build-receipt
|
||||
retention-days: 90
|
||||
if-no-files-found: error
|
||||
|
||||
# Hand the IPA and dSYM off to the submit job. Retention is deliberately short since
|
||||
# this artifact only exists to bridge the two jobs within a single run.
|
||||
- name: 🚀 Upload build artifact
|
||||
|
||||
@@ -37,7 +37,10 @@ env:
|
||||
|
||||
jobs:
|
||||
bundleDeploy:
|
||||
if: github.repository == 'bluesky-social/social-app'
|
||||
if: >-
|
||||
github.repository == 'bluesky-social/social-app' &&
|
||||
(vars.OTA_FINGERPRINT_PIPELINE_ENABLED != 'true' ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.channel == 'production'))
|
||||
name: Bundle and Deploy EAS Update
|
||||
runs-on: ubuntu-latest
|
||||
# id-token: write lets this job mint an OIDC token to assume the denis
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
---
|
||||
name: Bundle and Deploy Fingerprint OTA
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
channel:
|
||||
type: choice
|
||||
options: [testflight, production]
|
||||
default: testflight
|
||||
iosBuildNumber:
|
||||
type: string
|
||||
description: Required exact production iOS build target
|
||||
androidVersionCode:
|
||||
type: string
|
||||
description: Required exact production Android build target
|
||||
iosReceiptRunId:
|
||||
type: string
|
||||
description: Optional trusted iOS native-build workflow run containing a matching receipt
|
||||
iosReceiptRunAttempt:
|
||||
type: string
|
||||
default: '1'
|
||||
description: Attempt number for the optional iOS receipt run
|
||||
androidReceiptRunId:
|
||||
type: string
|
||||
description: Optional trusted Android native-build workflow run containing a matching receipt
|
||||
androidReceiptRunAttempt:
|
||||
type: string
|
||||
default: '1'
|
||||
description: Attempt number for the optional Android receipt run
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Export and publish fingerprint OTA
|
||||
if: >-
|
||||
github.repository == 'bluesky-social/social-app' &&
|
||||
vars.OTA_FINGERPRINT_PIPELINE_ENABLED == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: fingerprint-ota-${{ github.ref }}-${{ inputs.channel || 'testflight' }}
|
||||
cancel-in-progress: true
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
actions: read
|
||||
env:
|
||||
OTA_FINGERPRINT_PIPELINE_ENABLED: '1'
|
||||
CHANNEL: ${{ inputs.channel || 'testflight' }}
|
||||
steps:
|
||||
- name: 🧭 Validate rollout configuration
|
||||
env:
|
||||
DENIS_VERSION: ${{ vars.OTA_FINGERPRINT_DENIS_VERSION }}
|
||||
IOS_BUILD_NUMBER: ${{ inputs.iosBuildNumber }}
|
||||
ANDROID_BUILD_NUMBER: ${{ inputs.androidVersionCode }}
|
||||
run: |
|
||||
if [ -z "$DENIS_VERSION" ]; then
|
||||
echo "::error::OTA_FINGERPRINT_DENIS_VERSION must pin a structured-publisher release"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$CHANNEL" = production ]; then
|
||||
[[ "$IOS_BUILD_NUMBER" =~ ^[0-9]+$ ]] || { echo "::error::A numeric iOS production target is required"; exit 1; }
|
||||
[[ "$ANDROID_BUILD_NUMBER" =~ ^[0-9]+$ ]] || { echo "::error::A numeric Android production target is required"; exit 1; }
|
||||
fi
|
||||
|
||||
- name: ⏱️ Allocate publication version
|
||||
id: publication
|
||||
run: echo "bundle-version=$(node -e 'process.stdout.write(String(Date.now()))')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: ⬇️ Checkout exact source commit
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: 🛠️ Setup Expo project
|
||||
uses: ./.github/actions/setup-expo-project
|
||||
with:
|
||||
expo-token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- name: 🔤 Compile translations
|
||||
uses: ./.github/actions/compile-i18n
|
||||
|
||||
- name: ✏️ Write environment variables
|
||||
id: env
|
||||
uses: ./.github/actions/write-env
|
||||
with:
|
||||
env-token: ${{ secrets.ENV_TOKEN }}
|
||||
sentry-dsn: ${{ secrets.SENTRY_DSN }}
|
||||
bitdrift-api-key: ${{ secrets.BITDRIFT_API_KEY }}
|
||||
gcp-project-id: ${{ secrets.EXPO_PUBLIC_GCP_PROJECT_ID }}
|
||||
google-services-token: ${{ secrets.GOOGLE_SERVICES_TOKEN }}
|
||||
expo-public-env: ${{ inputs.channel || 'testflight' }}
|
||||
|
||||
- name: 🧬 Resolve iOS runtime
|
||||
id: ios-runtime
|
||||
uses: bluesky-social/github-actions/fingerprint-runtime@b890bb3f200c5fb9fee7a2a1647e08537a73bde0
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
with:
|
||||
platform: ios
|
||||
profile: ${{ inputs.channel || 'testflight' }}
|
||||
source-commit: ${{ github.sha }}
|
||||
|
||||
- name: 🧬 Resolve Android runtime
|
||||
id: android-runtime
|
||||
uses: bluesky-social/github-actions/fingerprint-runtime@b890bb3f200c5fb9fee7a2a1647e08537a73bde0
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
with:
|
||||
platform: android
|
||||
profile: ${{ inputs.channel || 'testflight' }}
|
||||
source-commit: ${{ github.sha }}
|
||||
|
||||
- name: 📋 Stage fingerprint reports
|
||||
env:
|
||||
IOS_REPORT: ${{ steps.ios-runtime.outputs.report-path }}
|
||||
ANDROID_REPORT: ${{ steps.android-runtime.outputs.report-path }}
|
||||
run: |
|
||||
mkdir ota-release
|
||||
cp "$IOS_REPORT" ota-release/ios-fingerprint.json
|
||||
cp "$ANDROID_REPORT" ota-release/android-fingerprint.json
|
||||
|
||||
- name: 🏗️ Export per native platform
|
||||
env:
|
||||
EXPO_PUBLIC_ENV: ${{ inputs.channel || 'testflight' }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_RELEASE: ${{ steps.env.outputs.release-version }}
|
||||
SENTRY_DIST: ${{ steps.env.outputs.bundle-identifier }}
|
||||
run: |
|
||||
EAS_BUILD_PLATFORM=ios pnpm exec expo export --platform ios --output-dir ota-release/dist-ios --dump-sourcemap
|
||||
EAS_BUILD_PLATFORM=android pnpm exec expo export --platform android --output-dir ota-release/dist-android --dump-sourcemap
|
||||
|
||||
- name: 🧬 Re-resolve iOS runtime after export
|
||||
id: ios-runtime-after-export
|
||||
uses: bluesky-social/github-actions/fingerprint-runtime@b890bb3f200c5fb9fee7a2a1647e08537a73bde0
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
with:
|
||||
platform: ios
|
||||
profile: ${{ inputs.channel || 'testflight' }}
|
||||
source-commit: ${{ github.sha }}
|
||||
|
||||
- name: 🧬 Re-resolve Android runtime after export
|
||||
id: android-runtime-after-export
|
||||
uses: bluesky-social/github-actions/fingerprint-runtime@b890bb3f200c5fb9fee7a2a1647e08537a73bde0
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
with:
|
||||
platform: android
|
||||
profile: ${{ inputs.channel || 'testflight' }}
|
||||
source-commit: ${{ github.sha }}
|
||||
|
||||
- name: 🧐 Verify export did not change native inputs
|
||||
env:
|
||||
IOS_POST_REPORT: ${{ steps.ios-runtime-after-export.outputs.report-path }}
|
||||
ANDROID_POST_REPORT: ${{ steps.android-runtime-after-export.outputs.report-path }}
|
||||
run: |
|
||||
test "$(jq -r .runtimeVersion ota-release/ios-fingerprint.json)" = "$(jq -r .runtimeVersion "$IOS_POST_REPORT")"
|
||||
test "$(jq -r .runtimeVersion ota-release/android-fingerprint.json)" = "$(jq -r .runtimeVersion "$ANDROID_POST_REPORT")"
|
||||
|
||||
- name: 🔎 Find trusted iOS native receipt
|
||||
id: find-ios-receipt
|
||||
if: ${{ inputs.iosReceiptRunId }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: >-
|
||||
node .github/scripts/find-native-receipt.mjs
|
||||
--platform ios
|
||||
--build-number "${{ inputs.iosBuildNumber }}"
|
||||
--run-id "${{ inputs.iosReceiptRunId }}"
|
||||
--run-attempt "${{ inputs.iosReceiptRunAttempt }}"
|
||||
|
||||
- name: 🔎 Find trusted Android native receipt
|
||||
id: find-android-receipt
|
||||
if: ${{ inputs.androidReceiptRunId && always() }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: >-
|
||||
node .github/scripts/find-native-receipt.mjs
|
||||
--platform android
|
||||
--build-number "${{ inputs.androidVersionCode }}"
|
||||
--run-id "${{ inputs.androidReceiptRunId }}"
|
||||
--run-attempt "${{ inputs.androidReceiptRunAttempt }}"
|
||||
|
||||
- name: ⬇️ Download iOS native receipt
|
||||
id: download-ios-receipt
|
||||
if: ${{ steps.find-ios-receipt.outputs.available == 'true' && always() }}
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: ${{ steps.find-ios-receipt.outputs.artifact-name }}
|
||||
path: ota-release/ios-native-receipt
|
||||
run-id: ${{ inputs.iosReceiptRunId }}
|
||||
github-token: ${{ github.token }}
|
||||
|
||||
- name: ⬇️ Download Android native receipt
|
||||
id: download-android-receipt
|
||||
if: ${{ steps.find-android-receipt.outputs.available == 'true' && always() }}
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: ${{ steps.find-android-receipt.outputs.artifact-name }}
|
||||
path: ota-release/android-native-receipt
|
||||
run-id: ${{ inputs.androidReceiptRunId }}
|
||||
github-token: ${{ github.token }}
|
||||
|
||||
- name: 🧐 Bind downloaded iOS receipt to its build run
|
||||
id: bind-ios-receipt
|
||||
if: ${{ inputs.iosReceiptRunId && always() }}
|
||||
env:
|
||||
EXPECTED_SOURCE_COMMIT: ${{ steps.find-ios-receipt.outputs.head-sha }}
|
||||
run: |
|
||||
if [ ! -f ota-release/ios-native-receipt/receipt.json ]; then
|
||||
echo "::warning::iOS native receipt was unavailable; target could not be verified"
|
||||
echo "available=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
test -n "$EXPECTED_SOURCE_COMMIT"
|
||||
test "$(jq -r .sourceCommit ota-release/ios-native-receipt/receipt.json)" = "$EXPECTED_SOURCE_COMMIT"
|
||||
echo "available=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: 🧐 Bind downloaded Android receipt to its build run
|
||||
id: bind-android-receipt
|
||||
if: ${{ inputs.androidReceiptRunId && always() }}
|
||||
env:
|
||||
EXPECTED_SOURCE_COMMIT: ${{ steps.find-android-receipt.outputs.head-sha }}
|
||||
run: |
|
||||
if [ ! -f ota-release/android-native-receipt/receipt.json ]; then
|
||||
echo "::warning::Android native receipt was unavailable; target could not be verified"
|
||||
echo "available=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
test -n "$EXPECTED_SOURCE_COMMIT"
|
||||
test "$(jq -r .sourceCommit ota-release/android-native-receipt/receipt.json)" = "$EXPECTED_SOURCE_COMMIT"
|
||||
echo "available=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: 🧾 Create structured release
|
||||
env:
|
||||
BUNDLE_VERSION: ${{ steps.publication.outputs.bundle-version }}
|
||||
IOS_BUILD_NUMBER: ${{ inputs.iosBuildNumber }}
|
||||
ANDROID_BUILD_NUMBER: ${{ inputs.androidVersionCode }}
|
||||
IOS_RECEIPT_AVAILABLE: ${{ steps.bind-ios-receipt.outputs.available == 'true' && 'true' || 'false' }}
|
||||
ANDROID_RECEIPT_AVAILABLE: ${{ steps.bind-android-receipt.outputs.available == 'true' && 'true' || 'false' }}
|
||||
run: |
|
||||
jq -n \
|
||||
--arg sourceCommit "$GITHUB_SHA" --arg channel "$CHANNEL" \
|
||||
--arg bundleVersion "$BUNDLE_VERSION" \
|
||||
--arg iosRuntime "$(jq -r .runtimeVersion ota-release/ios-fingerprint.json)" \
|
||||
--arg androidRuntime "$(jq -r .runtimeVersion ota-release/android-fingerprint.json)" \
|
||||
--arg iosBuild "$IOS_BUILD_NUMBER" --arg androidBuild "$ANDROID_BUILD_NUMBER" \
|
||||
'{schemaVersion: 1, sourceCommit: $sourceCommit, channel: $channel,
|
||||
nativeProfile: $channel, bundleVersion: $bundleVersion,
|
||||
platforms: {
|
||||
ios: ({runtimeVersion: $iosRuntime, fingerprintReportRef: "ios-fingerprint.json", bundleDirectory: "dist-ios"} + if $channel == "production" then ({targetNativeBuildNumber: $iosBuild} + if $ENV.IOS_RECEIPT_AVAILABLE == "true" then {targetNativeBuildReceiptRef: "ios-native-receipt/receipt.json"} else {} end) else {} end),
|
||||
android: ({runtimeVersion: $androidRuntime, fingerprintReportRef: "android-fingerprint.json", bundleDirectory: "dist-android"} + if $channel == "production" then ({targetNativeBuildNumber: $androidBuild} + if $ENV.ANDROID_RECEIPT_AVAILABLE == "true" then {targetNativeBuildReceiptRef: "android-native-receipt/receipt.json"} else {} end) else {} end)
|
||||
}}' > ota-release/ota-export.json
|
||||
node scripts/ota/validate-release.mjs --release-file ota-release/ota-export.json > ota-release/verification.json
|
||||
jq -e '.valid == true' ota-release/verification.json >/dev/null
|
||||
|
||||
- name: ⬆️ Upload export evidence
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: fingerprint-ota-export-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: |
|
||||
ota-release/ota-export.json
|
||||
ota-release/ios-fingerprint.json
|
||||
ota-release/android-fingerprint.json
|
||||
ota-release/verification.json
|
||||
retention-days: 90
|
||||
if-no-files-found: error
|
||||
|
||||
- name: ☁️ Configure AWS credentials (denis)
|
||||
uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3
|
||||
with:
|
||||
role-to-assume: arn:aws:iam::007404326489:role/denis-ci-publish
|
||||
aws-region: us-east-2
|
||||
|
||||
- name: ⬇️ Setup structured denis CLI
|
||||
uses: ./.github/actions/setup-denis
|
||||
with:
|
||||
release-tag: ${{ vars.OTA_FINGERPRINT_DENIS_VERSION }}
|
||||
app-id: ${{ vars.SYNC_INTERNAL_APP_ID }}
|
||||
private-key: ${{ secrets.SYNC_INTERNAL_PK }}
|
||||
|
||||
- name: 🚀 Publish structured OTA to denis
|
||||
run: bash scripts/denisPublish.sh ota-release/ota-export.json
|
||||
env:
|
||||
DENIS_PUBLISH_MODE: structured
|
||||
|
||||
- name: 📝 Summarize publication
|
||||
run: |
|
||||
{
|
||||
echo "### Fingerprint OTA"
|
||||
echo
|
||||
echo "- Channel: \`$CHANNEL\`"
|
||||
echo "- Source: \`$GITHUB_SHA\`"
|
||||
echo "- Bundle version: \`${{ steps.publication.outputs.bundle-version }}\`"
|
||||
if [ "$CHANNEL" = production ]; then
|
||||
ios_status=$(jq -r '.platforms.ios.receiptVerification.status' ota-release/verification.json)
|
||||
android_status=$(jq -r '.platforms.android.receiptVerification.status' ota-release/verification.json)
|
||||
echo "- iOS native target verification: \`$ios_status\`"
|
||||
echo "- Android native target verification: \`$android_status\`"
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -262,6 +262,7 @@ jobs:
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event.pull_request.head.repo.full_name == github.repository &&
|
||||
github.event.pull_request.user.type != 'Bot' &&
|
||||
vars.OTA_FINGERPRINT_PIPELINE_ENABLED != 'true' &&
|
||||
needs.fingerprint-native.outputs.includes-changes != 'true'
|
||||
concurrency:
|
||||
group: pr-ota-${{ github.event.pull_request.number }}
|
||||
@@ -370,3 +371,189 @@ jobs:
|
||||
<img src="https://bsky-qr.vercel.app?channel=pull-request-${{ github.event.pull_request.number }}&releaseVersion=${{ needs.publish-pr-ota.outputs.release-version }}&iosBuildNumber=${{ needs.publish-pr-ota.outputs.ios-build-number }}&androidBuildNumber=${{ needs.publish-pr-ota.outputs.android-build-number }}" width="300" height="300" alt="QR code for the PR OTA deployment">
|
||||
|
||||
`bluesky://intent/apply-ota?channel=pull-request-${{ github.event.pull_request.number }}&releaseVersion=${{ needs.publish-pr-ota.outputs.release-version }}&iosBuildNumber=${{ needs.publish-pr-ota.outputs.ios-build-number }}&androidBuildNumber=${{ needs.publish-pr-ota.outputs.android-build-number }}`
|
||||
|
||||
publish-pr-ota-fingerprint:
|
||||
name: Publish fingerprint PR OTA to denis
|
||||
needs: fingerprint-native
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event.pull_request.head.repo.full_name == github.repository &&
|
||||
github.event.pull_request.user.type != 'Bot' &&
|
||||
vars.OTA_FINGERPRINT_PIPELINE_ENABLED == 'true'
|
||||
concurrency:
|
||||
group: pr-ota-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
env:
|
||||
OTA_FINGERPRINT_PIPELINE_ENABLED: '1'
|
||||
steps:
|
||||
- name: 🧭 Validate fingerprint rollout configuration
|
||||
env:
|
||||
DENIS_VERSION: ${{ vars.OTA_FINGERPRINT_DENIS_VERSION }}
|
||||
run: |
|
||||
if [ -z "$DENIS_VERSION" ]; then
|
||||
echo "::error::OTA_FINGERPRINT_DENIS_VERSION must pin a structured-publisher release"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: ⏱️ Allocate publication version
|
||||
id: publication
|
||||
run: echo "bundle-version=$(node -e 'process.stdout.write(String(Date.now()))')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: ⬇️ Checkout exact PR head
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: 🛠️ Setup Expo project
|
||||
uses: ./.github/actions/setup-expo-project
|
||||
with:
|
||||
expo-token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- name: 🔤 Compile translations
|
||||
uses: ./.github/actions/compile-i18n
|
||||
|
||||
- name: ✏️ Write environment variables
|
||||
id: env
|
||||
uses: ./.github/actions/write-env
|
||||
with:
|
||||
env-token: ${{ secrets.ENV_TOKEN }}
|
||||
sentry-dsn: ${{ secrets.SENTRY_DSN }}
|
||||
bitdrift-api-key: ${{ secrets.BITDRIFT_API_KEY }}
|
||||
gcp-project-id: ${{ secrets.EXPO_PUBLIC_GCP_PROJECT_ID }}
|
||||
google-services-token: ${{ secrets.GOOGLE_SERVICES_TOKEN }}
|
||||
expo-public-env: testflight
|
||||
|
||||
- name: 🧬 Resolve iOS runtime
|
||||
id: ios-runtime
|
||||
uses: bluesky-social/github-actions/fingerprint-runtime@b890bb3f200c5fb9fee7a2a1647e08537a73bde0
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
with:
|
||||
platform: ios
|
||||
profile: testflight
|
||||
source-commit: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: 🧬 Resolve Android runtime
|
||||
id: android-runtime
|
||||
uses: bluesky-social/github-actions/fingerprint-runtime@b890bb3f200c5fb9fee7a2a1647e08537a73bde0
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
with:
|
||||
platform: android
|
||||
profile: testflight
|
||||
source-commit: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: 📋 Stage fingerprint reports
|
||||
env:
|
||||
IOS_REPORT: ${{ steps.ios-runtime.outputs.report-path }}
|
||||
ANDROID_REPORT: ${{ steps.android-runtime.outputs.report-path }}
|
||||
run: |
|
||||
mkdir ota-release
|
||||
cp "$IOS_REPORT" ota-release/ios-fingerprint.json
|
||||
cp "$ANDROID_REPORT" ota-release/android-fingerprint.json
|
||||
|
||||
- name: 🏗️ Export exact PR head per native platform
|
||||
env:
|
||||
EXPO_PUBLIC_ENV: testflight
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_RELEASE: ${{ steps.env.outputs.release-version }}
|
||||
SENTRY_DIST: ${{ steps.env.outputs.bundle-identifier }}
|
||||
run: |
|
||||
EAS_BUILD_PLATFORM=ios pnpm exec expo export --platform ios --output-dir ota-release/dist-ios --dump-sourcemap
|
||||
EAS_BUILD_PLATFORM=android pnpm exec expo export --platform android --output-dir ota-release/dist-android --dump-sourcemap
|
||||
|
||||
- name: 🧬 Re-resolve runtimes after export
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
run: |
|
||||
node scripts/ota/resolve-runtime.mjs --platform ios --profile testflight --source-commit "${{ github.event.pull_request.head.sha }}" --output ota-release/ios-post-export.json
|
||||
node scripts/ota/resolve-runtime.mjs --platform android --profile testflight --source-commit "${{ github.event.pull_request.head.sha }}" --output ota-release/android-post-export.json
|
||||
test "$(jq -r .runtimeVersion ota-release/ios-fingerprint.json)" = "$(jq -r .runtimeVersion ota-release/ios-post-export.json)"
|
||||
test "$(jq -r .runtimeVersion ota-release/android-fingerprint.json)" = "$(jq -r .runtimeVersion ota-release/android-post-export.json)"
|
||||
|
||||
- name: 🧾 Create structured release
|
||||
env:
|
||||
SOURCE_COMMIT: ${{ github.event.pull_request.head.sha }}
|
||||
CHANNEL: pull-request-${{ github.event.pull_request.number }}
|
||||
BUNDLE_VERSION: ${{ steps.publication.outputs.bundle-version }}
|
||||
run: |
|
||||
jq -n \
|
||||
--arg sourceCommit "$SOURCE_COMMIT" \
|
||||
--arg channel "$CHANNEL" \
|
||||
--arg bundleVersion "$BUNDLE_VERSION" \
|
||||
--arg iosRuntime "$(jq -r .runtimeVersion ota-release/ios-fingerprint.json)" \
|
||||
--arg androidRuntime "$(jq -r .runtimeVersion ota-release/android-fingerprint.json)" \
|
||||
'{schemaVersion: 1, sourceCommit: $sourceCommit, channel: $channel,
|
||||
nativeProfile: "testflight", bundleVersion: $bundleVersion,
|
||||
platforms: {
|
||||
ios: {runtimeVersion: $iosRuntime, fingerprintReportRef: "ios-fingerprint.json", bundleDirectory: "dist-ios"},
|
||||
android: {runtimeVersion: $androidRuntime, fingerprintReportRef: "android-fingerprint.json", bundleDirectory: "dist-android"}
|
||||
}}' > ota-release/ota-export.json
|
||||
node scripts/ota/validate-release.mjs --release-file ota-release/ota-export.json > ota-release/verification.json
|
||||
jq -e '.valid == true' ota-release/verification.json >/dev/null
|
||||
|
||||
- name: ⬆️ Upload PR export evidence
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: fingerprint-pr-export-${{ github.event.pull_request.number }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: |
|
||||
ota-release/ota-export.json
|
||||
ota-release/ios-fingerprint.json
|
||||
ota-release/android-fingerprint.json
|
||||
retention-days: 30
|
||||
if-no-files-found: error
|
||||
|
||||
- name: ☁️ Configure AWS credentials (denis, PR-scoped)
|
||||
uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3
|
||||
with:
|
||||
role-to-assume: arn:aws:iam::007404326489:role/denis-ci-publish-pr
|
||||
aws-region: us-east-2
|
||||
inline-session-policy: |-
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
|
||||
"Resource": "arn:aws:s3:::bsky-denis-ota-prod/pr/${{ github.event.pull_request.number }}/*"
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": "s3:ListBucket",
|
||||
"Resource": "arn:aws:s3:::bsky-denis-ota-prod",
|
||||
"Condition": {"StringLike": {"s3:prefix": "pr/${{ github.event.pull_request.number }}/*"}}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
- name: ⬇️ Setup structured denis CLI
|
||||
uses: ./.github/actions/setup-denis
|
||||
with:
|
||||
release-tag: ${{ vars.OTA_FINGERPRINT_DENIS_VERSION }}
|
||||
app-id: ${{ vars.SYNC_INTERNAL_APP_ID }}
|
||||
private-key: ${{ secrets.SYNC_INTERNAL_PK }}
|
||||
|
||||
- name: 🚀 Publish structured OTA to denis
|
||||
run: bash scripts/denisPublish.sh ota-release/ota-export.json
|
||||
env:
|
||||
DENIS_PUBLISH_MODE: structured
|
||||
|
||||
comment-pr-ota-fingerprint:
|
||||
name: Comment fingerprint PR OTA install link
|
||||
needs: publish-pr-ota-fingerprint
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: 💬 Drop OTA install comment
|
||||
uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
|
||||
with:
|
||||
header: pull-request-ota
|
||||
message: |
|
||||
The fingerprint OTA deployment for this PR was published. Expo will only offer it to a native build with the same platform runtime.
|
||||
|
||||
`bluesky://intent/apply-ota?channel=pull-request-${{ github.event.pull_request.number }}&sourceCommit=${{ github.event.pull_request.head.sha }}`
|
||||
|
||||
+4
-2
@@ -35,6 +35,8 @@ module.exports = function (_config) {
|
||||
const UPDATES_ENABLED = IS_TESTFLIGHT || IS_PRODUCTION
|
||||
|
||||
const USE_SENTRY = Boolean(process.env.SENTRY_AUTH_TOKEN)
|
||||
const USE_FINGERPRINT_RUNTIME =
|
||||
process.env.OTA_FINGERPRINT_PIPELINE_ENABLED === '1'
|
||||
|
||||
const IOS_ICON_FILE =
|
||||
PLATFORM === 'web' // web build doesn't like .icon files
|
||||
@@ -51,7 +53,7 @@ module.exports = function (_config) {
|
||||
scheme: 'bluesky',
|
||||
owner: 'blueskysocial',
|
||||
runtimeVersion: {
|
||||
policy: 'appVersion',
|
||||
policy: USE_FINGERPRINT_RUNTIME ? 'fingerprint' : 'appVersion',
|
||||
},
|
||||
icon: './assets/app-icons/ios_icon_default_next.png',
|
||||
userInterfaceStyle: 'automatic',
|
||||
@@ -285,7 +287,7 @@ module.exports = function (_config) {
|
||||
{
|
||||
name: 'MCEmojiPicker',
|
||||
git: 'https://github.com/bluesky-social/MCEmojiPicker.git',
|
||||
branch: 'main',
|
||||
commit: 'e2c2e4917df25c34d8cc6ca04918fe026588d828',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
+54
-1
@@ -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.
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// @ts-check
|
||||
|
||||
module.exports = {
|
||||
hashAlgorithm: 'sha1',
|
||||
sourceSkips: [
|
||||
'ExpoConfigVersions',
|
||||
'PackageJsonAndroidAndIosScriptsIfNotContainRun',
|
||||
],
|
||||
extraSources: [
|
||||
{
|
||||
type: 'file',
|
||||
filePath: 'scripts/ota/fingerprint-policy.json',
|
||||
overrideHashKey: 'fingerprint-policy.json',
|
||||
reasons: ['Repository OTA compatibility policy'],
|
||||
},
|
||||
...(process.env.EAS_BUILD_PLATFORM === 'ios'
|
||||
? ['BlueskyClip', 'BlueskyNSE', 'Share-with-Bluesky'].map(name => ({
|
||||
type: 'dir',
|
||||
filePath: `modules/${name}`,
|
||||
overrideHashKey: `native-extension/${name}`,
|
||||
reasons: ['Native extension sources copied by config plugins'],
|
||||
}))
|
||||
: []),
|
||||
],
|
||||
}
|
||||
Regular → Executable
+118
-92
@@ -1,104 +1,130 @@
|
||||
#!/usr/bin/env node
|
||||
/* oxlint-disable import/no-nodejs-modules -- This is a Node-only build script. */
|
||||
const crypto = require('crypto')
|
||||
const fs = require('fs')
|
||||
const fsp = fs.promises
|
||||
const path = require('path')
|
||||
|
||||
const DIST_DIR = './dist'
|
||||
const BUNDLES_DIR = '/_expo/static/js'
|
||||
const IOS_BUNDLE_DIR = path.join(DIST_DIR, BUNDLES_DIR, '/ios')
|
||||
const ANDROID_BUNDLE_DIR = path.join(DIST_DIR, BUNDLES_DIR, '/android')
|
||||
const METADATA_PATH = path.join(DIST_DIR, '/metadata.json')
|
||||
const DEST_DIR = './bundleTempDir'
|
||||
|
||||
// Weird, don't feel like figuring out _why_ it wants this
|
||||
const METADATA = require(`../${METADATA_PATH}`)
|
||||
const IOS_METADATA_ASSETS = METADATA.fileMetadata.ios.assets
|
||||
const ANDROID_METADATA_ASSETS = METADATA.fileMetadata.android.assets
|
||||
|
||||
const getMd5 = async path => {
|
||||
return new Promise(res => {
|
||||
const hash = crypto.createHash('md5')
|
||||
const rStream = fs.createReadStream(path)
|
||||
rStream.on('data', data => {
|
||||
hash.update(data)
|
||||
})
|
||||
rStream.on('end', () => {
|
||||
res(hash.digest('hex'))
|
||||
})
|
||||
})
|
||||
/** @param {string[]} argv */
|
||||
function args(argv) {
|
||||
/** @type {Record<string, string>} */
|
||||
const out = {}
|
||||
for (let i = 0; i < argv.length; i += 2) {
|
||||
if (!argv[i]?.startsWith('--') || argv[i + 1] == null)
|
||||
throw new Error(`Invalid argument: ${argv[i]}`)
|
||||
out[argv[i].slice(2)] = argv[i + 1]
|
||||
}
|
||||
return out
|
||||
}
|
||||
async function digest(file) {
|
||||
const hash = crypto.createHash('md5')
|
||||
for await (const chunk of fs.createReadStream(file)) hash.update(chunk)
|
||||
return hash.digest('hex')
|
||||
}
|
||||
async function childOf(parent, child) {
|
||||
const relative = path.relative(
|
||||
await fsp.realpath(parent),
|
||||
await fsp.realpath(child),
|
||||
)
|
||||
return (
|
||||
relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative)
|
||||
)
|
||||
}
|
||||
|
||||
const moveFiles = async () => {
|
||||
console.log('Making directory...')
|
||||
await fsp.mkdir(DEST_DIR)
|
||||
await fsp.mkdir(path.join(DEST_DIR, '/assets'))
|
||||
|
||||
console.log('Getting ios md5...')
|
||||
const iosCurrPath = path.join(
|
||||
IOS_BUNDLE_DIR,
|
||||
(await fsp.readdir(IOS_BUNDLE_DIR))[0],
|
||||
)
|
||||
const iosMd5 = await getMd5(iosCurrPath)
|
||||
const iosNewPath = `bundles/${iosMd5}.bundle`
|
||||
|
||||
console.log('Copying ios bundle...')
|
||||
await fsp.cp(iosCurrPath, path.join(DEST_DIR, iosNewPath))
|
||||
|
||||
console.log('Getting android md5...')
|
||||
const androidCurrPath = path.join(
|
||||
ANDROID_BUNDLE_DIR,
|
||||
(await fsp.readdir(ANDROID_BUNDLE_DIR))[0],
|
||||
)
|
||||
const androidMd5 = await getMd5(androidCurrPath)
|
||||
const androidNewPath = `bundles/${androidMd5}.bundle`
|
||||
|
||||
console.log('Copying android bundle...')
|
||||
await fsp.cp(androidCurrPath, path.join(DEST_DIR, androidNewPath))
|
||||
|
||||
const iosAssets = []
|
||||
const androidAssets = []
|
||||
|
||||
console.log('Getting ios asset md5s and moving them...')
|
||||
for (const asset of IOS_METADATA_ASSETS) {
|
||||
const currPath = path.join(DIST_DIR, asset.path)
|
||||
const md5 = await getMd5(currPath)
|
||||
const withExtPath = `assets/${md5}.${asset.ext}`
|
||||
iosAssets.push(withExtPath)
|
||||
await fsp.cp(currPath, path.join(DEST_DIR, withExtPath))
|
||||
/**
|
||||
* @param {string} platform
|
||||
* @param {string} sourceDir
|
||||
* @param {string} destinationDir
|
||||
*/
|
||||
async function packagePlatform(platform, sourceDir, destinationDir) {
|
||||
const metadataPath = path.join(sourceDir, 'metadata.json')
|
||||
if (!(await childOf(sourceDir, metadataPath)))
|
||||
throw new Error('Metadata escapes export directory')
|
||||
/** @type {{fileMetadata?: Record<string, {bundle: string, assets?: {path: string, ext: string}[]}>}} */
|
||||
const metadata = JSON.parse(await fsp.readFile(metadataPath, 'utf8'))
|
||||
const input = metadata.fileMetadata?.[platform]
|
||||
if (!input || typeof input.bundle !== 'string')
|
||||
throw new Error(`${metadataPath} has no explicit ${platform} bundle`)
|
||||
const outputDir = path.join(destinationDir, platform)
|
||||
await fsp.mkdir(path.join(outputDir, 'bundles'), {recursive: true})
|
||||
await fsp.mkdir(path.join(outputDir, 'assets'), {recursive: true})
|
||||
const bundleSource = path.resolve(sourceDir, input.bundle)
|
||||
if (!(await childOf(sourceDir, bundleSource)))
|
||||
throw new Error(`Bundle escapes export directory: ${input.bundle}`)
|
||||
const bundle = `bundles/${await digest(bundleSource)}.bundle`
|
||||
await fsp.copyFile(bundleSource, path.join(outputDir, bundle))
|
||||
const assets = []
|
||||
for (const asset of input.assets ?? []) {
|
||||
if (
|
||||
typeof asset.path !== 'string' ||
|
||||
typeof asset.ext !== 'string' ||
|
||||
!/^[a-zA-Z0-9]+$/.test(asset.ext)
|
||||
)
|
||||
throw new Error(`Invalid ${platform} asset metadata`)
|
||||
const source = path.resolve(sourceDir, asset.path)
|
||||
if (!(await childOf(sourceDir, source)))
|
||||
throw new Error(`Asset escapes export directory: ${asset.path}`)
|
||||
const target = `assets/${await digest(source)}.${asset.ext}`
|
||||
await fsp.copyFile(source, path.join(outputDir, target))
|
||||
assets.push(target)
|
||||
}
|
||||
|
||||
console.log('Getting android asset md5s and moving them...')
|
||||
for (const asset of ANDROID_METADATA_ASSETS) {
|
||||
const currPath = path.join(DIST_DIR, asset.path)
|
||||
const md5 = await getMd5(currPath)
|
||||
const withExtPath = `assets/${md5}.${asset.ext}`
|
||||
androidAssets.push(withExtPath)
|
||||
await fsp.cp(currPath, path.join(DEST_DIR, withExtPath))
|
||||
}
|
||||
|
||||
const result = {
|
||||
version: 0,
|
||||
bundler: 'metro',
|
||||
fileMetadata: {
|
||||
ios: {
|
||||
bundle: iosNewPath,
|
||||
assets: iosAssets,
|
||||
},
|
||||
android: {
|
||||
bundle: androidNewPath,
|
||||
assets: androidAssets,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
console.log('Writing metadata...')
|
||||
await fsp.writeFile(
|
||||
path.join(DEST_DIR, 'metadata.json'),
|
||||
JSON.stringify(result),
|
||||
path.join(outputDir, 'metadata.json'),
|
||||
`${JSON.stringify({version: 0, bundler: 'metro', fileMetadata: {[platform]: {bundle, assets}}})}\n`,
|
||||
)
|
||||
|
||||
console.log('Finished!')
|
||||
console.log('Metadata:', result)
|
||||
const rollbackPath = path.join(sourceDir, 'rollback')
|
||||
if (fs.existsSync(rollbackPath)) {
|
||||
if (!(await childOf(sourceDir, rollbackPath)))
|
||||
throw new Error('Rollback marker escapes export directory')
|
||||
await fsp.copyFile(rollbackPath, path.join(outputDir, 'rollback'))
|
||||
}
|
||||
}
|
||||
|
||||
moveFiles()
|
||||
async function main() {
|
||||
const options = args(process.argv.slice(2))
|
||||
if (!options['release-file']) throw new Error('--release-file is required')
|
||||
const releasePath = path.resolve(options['release-file'])
|
||||
const base = path.dirname(releasePath)
|
||||
/** @type {{schemaVersion: number, bundleVersion: string, platforms: Record<string, {bundleDirectory: string}>}} */
|
||||
const release = JSON.parse(await fsp.readFile(releasePath, 'utf8'))
|
||||
if (
|
||||
release.schemaVersion !== 1 ||
|
||||
!release.platforms ||
|
||||
!/^[0-9]{13}$/.test(release.bundleVersion ?? '')
|
||||
)
|
||||
throw new Error('Invalid OTA export schema')
|
||||
const outputDir = options['output-dir']
|
||||
? path.resolve(options['output-dir'])
|
||||
: await fsp.mkdtemp(path.join(base, 'ota-bundles-'))
|
||||
const outputRelative = path.relative(base, outputDir)
|
||||
if (
|
||||
!outputRelative ||
|
||||
outputRelative.startsWith('..') ||
|
||||
path.isAbsolute(outputRelative)
|
||||
)
|
||||
throw new Error('Packaged output must be inside the release directory')
|
||||
if (options['output-dir']) await fsp.mkdir(outputDir, {recursive: false})
|
||||
for (const [platform, entry] of Object.entries(release.platforms)) {
|
||||
if (
|
||||
!['ios', 'android'].includes(platform) ||
|
||||
typeof entry.bundleDirectory !== 'string'
|
||||
)
|
||||
throw new Error(`Invalid platform entry: ${platform}`)
|
||||
const sourceDir = path.resolve(base, entry.bundleDirectory)
|
||||
if (sourceDir !== base && !(await childOf(base, sourceDir)))
|
||||
throw new Error('Export directory escapes release directory')
|
||||
await packagePlatform(platform, sourceDir, outputDir)
|
||||
entry.bundleDirectory = path.relative(base, path.join(outputDir, platform))
|
||||
}
|
||||
const packagedReleasePath = `${outputDir}.json`
|
||||
await fsp.writeFile(
|
||||
packagedReleasePath,
|
||||
`${JSON.stringify(release, null, 2)}\n`,
|
||||
{flag: 'wx'},
|
||||
)
|
||||
process.stdout.write(`${packagedReleasePath}\n`)
|
||||
}
|
||||
main().catch(error => {
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
|
||||
+18
-36
@@ -3,42 +3,24 @@ set -o errexit
|
||||
set -o pipefail
|
||||
set -o nounset
|
||||
|
||||
# Publishes the just-exported Expo bundle to the denis OTA service (S3) via the
|
||||
# `denis publish` CLI.
|
||||
# Expects: the `denis` binary on PATH (setup-denis action), ambient AWS creds
|
||||
# (configure-aws-credentials OIDC), and BSKY_IOS_BUILD_NUMBER /
|
||||
# BSKY_ANDROID_VERSION_CODE from the use-build-number wrapper.
|
||||
|
||||
rm -rf bundleTempDir
|
||||
|
||||
echo "Assembling bundle directory..."
|
||||
node scripts/bundleUpdate.js
|
||||
|
||||
if [ -z "$RUNTIME_VERSION" ]; then
|
||||
RUNTIME_VERSION=$(cat package.json | jq '.version' -r)
|
||||
fi
|
||||
|
||||
BUNDLE_VERSION=$(date +%s)
|
||||
DENIS_CDN_DOMAIN="${DENIS_CDN_DOMAIN:-updates.bsky.app}"
|
||||
DENIS_S3_BUCKET="${DENIS_S3_BUCKET:-bsky-denis-ota-prod}"
|
||||
PUBLISH_MODE="${DENIS_PUBLISH_MODE:-legacy}"
|
||||
|
||||
echo "Publishing to denis..."
|
||||
echo " runtime-version: $RUNTIME_VERSION"
|
||||
echo " bundle-version: $BUNDLE_VERSION"
|
||||
echo " channel: $CHANNEL_NAME"
|
||||
echo " ios-build-number: $BSKY_IOS_BUILD_NUMBER"
|
||||
echo " android-build-number: $BSKY_ANDROID_VERSION_CODE"
|
||||
echo " cdn-domain: $DENIS_CDN_DOMAIN"
|
||||
echo " s3-bucket: $DENIS_S3_BUCKET"
|
||||
|
||||
denis publish \
|
||||
--bundle-dir bundleTempDir \
|
||||
--runtime-version "$RUNTIME_VERSION" \
|
||||
--bundle-version "$BUNDLE_VERSION" \
|
||||
--channel "$CHANNEL_NAME" \
|
||||
--ios-build-number "$BSKY_IOS_BUILD_NUMBER" \
|
||||
--android-build-number "$BSKY_ANDROID_VERSION_CODE" \
|
||||
--cdn-domain "$DENIS_CDN_DOMAIN" \
|
||||
--s3-bucket "$DENIS_S3_BUCKET"
|
||||
|
||||
rm -rf bundleTempDir
|
||||
if [ "$PUBLISH_MODE" = "structured" ]; then
|
||||
RELEASE_FILE="${1:?Usage: denisPublish.sh ota-export.json}"
|
||||
node scripts/ota/validate-release.mjs --release-file "$RELEASE_FILE"
|
||||
PACKAGED_RELEASE_FILE="$(node scripts/bundleUpdate.js --release-file "$RELEASE_FILE")"
|
||||
node scripts/ota/validate-release.mjs --release-file "$PACKAGED_RELEASE_FILE"
|
||||
denis publish --release-file "$PACKAGED_RELEASE_FILE" --cdn-domain "$DENIS_CDN_DOMAIN" --s3-bucket "$DENIS_S3_BUCKET"
|
||||
# Retain the unique packaged output for debugging and idempotent CLI retries.
|
||||
elif [ "$PUBLISH_MODE" = "legacy" ]; then
|
||||
: "${CHANNEL_NAME:?CHANNEL_NAME is required}"
|
||||
: "${BSKY_IOS_BUILD_NUMBER:?BSKY_IOS_BUILD_NUMBER is required}"
|
||||
: "${BSKY_ANDROID_VERSION_CODE:?BSKY_ANDROID_VERSION_CODE is required}"
|
||||
: "Legacy mode is retained by scripts/denisPublishLegacy.sh during rollout"
|
||||
exec bash scripts/denisPublishLegacy.sh
|
||||
else
|
||||
echo "DENIS_PUBLISH_MODE must be structured or legacy" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
set -o nounset
|
||||
|
||||
# Frozen numeric-runtime publication path. Remove only after legacy client support ends.
|
||||
rm -rf bundleTempDir
|
||||
node scripts/ota/package-legacy-export.js
|
||||
RUNTIME_VERSION="${RUNTIME_VERSION:-$(jq -r .version package.json)}"
|
||||
BUNDLE_VERSION="${BUNDLE_VERSION:-$(date +%s)000}"
|
||||
denis publish --bundle-dir bundleTempDir --runtime-version "$RUNTIME_VERSION" --bundle-version "$BUNDLE_VERSION" --channel "$CHANNEL_NAME" --ios-build-number "$BSKY_IOS_BUILD_NUMBER" --android-build-number "$BSKY_ANDROID_VERSION_CODE" --cdn-domain "${DENIS_CDN_DOMAIN:-updates.bsky.app}" --s3-bucket "${DENIS_S3_BUCKET:-bsky-denis-ota-prod}"
|
||||
rm -rf bundleTempDir
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"fingerprintPolicyVersion": 1,
|
||||
"runtimeFormat": "sha1-lowercase-hex",
|
||||
"normalizedFields": [
|
||||
"expo.version",
|
||||
"expo.ios.buildNumber",
|
||||
"expo.android.versionCode"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import crypto from 'node:crypto'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import {spawnSync} from 'node:child_process'
|
||||
import test from 'node:test'
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '../..')
|
||||
const packager = path.join(root, 'scripts/bundleUpdate.js')
|
||||
const publisher = path.join(root, 'scripts/denisPublish.sh')
|
||||
const sourceCommit = 'a'.repeat(40)
|
||||
|
||||
function writeJson(filename, value) {
|
||||
fs.writeFileSync(filename, `${JSON.stringify(value, null, 2)}\n`)
|
||||
}
|
||||
|
||||
function report(platform, runtime) {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
platform,
|
||||
nativeProfile: 'testflight',
|
||||
sourceCommit,
|
||||
runtimeVersion: runtime,
|
||||
fingerprintPolicyVersion: 1,
|
||||
fingerprintToolVersion: '0.20.8',
|
||||
toolVersions: {
|
||||
expo: '57.0.8',
|
||||
expoUpdates: '57.0.10',
|
||||
node: 'v24.19.0',
|
||||
packageManager: 'pnpm@11.21.0',
|
||||
},
|
||||
fingerprintSources: [
|
||||
{type: 'contents', id: 'expoConfig', hash: 'b'.repeat(40)},
|
||||
{
|
||||
type: 'file',
|
||||
filePath: 'scripts/ota/fingerprint-policy.json',
|
||||
hash: 'c'.repeat(40),
|
||||
},
|
||||
...(platform === 'ios'
|
||||
? ['BlueskyClip', 'BlueskyNSE', 'Share-with-Bluesky'].map(name => ({
|
||||
type: 'dir',
|
||||
filePath: `modules/${name}`,
|
||||
hash: 'd'.repeat(40),
|
||||
}))
|
||||
: [
|
||||
{
|
||||
type: 'file',
|
||||
filePath: 'google-services.json',
|
||||
hash: 'e'.repeat(40),
|
||||
},
|
||||
]),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function fixture(platforms = ['ios', 'android']) {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ota-package-'))
|
||||
const release = {
|
||||
schemaVersion: 1,
|
||||
sourceCommit,
|
||||
channel: 'testflight',
|
||||
nativeProfile: 'testflight',
|
||||
bundleVersion: '1788537600000',
|
||||
platforms: {},
|
||||
}
|
||||
for (const platform of platforms) {
|
||||
const runtime = platform === 'ios' ? '1'.repeat(40) : '2'.repeat(40)
|
||||
const exportDirectory = path.join(directory, `export-${platform}`)
|
||||
fs.mkdirSync(path.join(exportDirectory, 'bundles'), {recursive: true})
|
||||
fs.mkdirSync(path.join(exportDirectory, 'assets'), {recursive: true})
|
||||
fs.writeFileSync(path.join(exportDirectory, 'bundles/00-decoy.js'), 'decoy')
|
||||
fs.writeFileSync(
|
||||
path.join(exportDirectory, 'bundles/chosen.js'),
|
||||
`${platform}-chosen`,
|
||||
)
|
||||
fs.writeFileSync(
|
||||
path.join(exportDirectory, 'assets/icon.png'),
|
||||
`${platform}-asset`,
|
||||
)
|
||||
writeJson(path.join(exportDirectory, 'metadata.json'), {
|
||||
version: 0,
|
||||
bundler: 'metro',
|
||||
fileMetadata: {
|
||||
[platform]: {
|
||||
bundle: 'bundles/chosen.js',
|
||||
assets: [{path: 'assets/icon.png', ext: 'png'}],
|
||||
},
|
||||
},
|
||||
})
|
||||
writeJson(
|
||||
path.join(directory, `${platform}-report.json`),
|
||||
report(platform, runtime),
|
||||
)
|
||||
release.platforms[platform] = {
|
||||
runtimeVersion: runtime,
|
||||
fingerprintReportRef: `${platform}-report.json`,
|
||||
bundleDirectory: `export-${platform}`,
|
||||
}
|
||||
}
|
||||
const releaseFile = path.join(directory, 'ota-export.json')
|
||||
writeJson(releaseFile, release)
|
||||
return {directory, releaseFile}
|
||||
}
|
||||
|
||||
function packageRelease(releaseFile) {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[packager, '--release-file', releaseFile],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
},
|
||||
)
|
||||
if (result.status !== 0) throw new Error(result.stderr.trim())
|
||||
return result.stdout.trim()
|
||||
}
|
||||
|
||||
function md5(value) {
|
||||
return crypto.createHash('md5').update(value).digest('hex')
|
||||
}
|
||||
|
||||
test('packages the metadata-referenced bundles per platform without mutating input', () => {
|
||||
const value = fixture()
|
||||
const before = fs.readFileSync(value.releaseFile, 'utf8')
|
||||
const packagedFile = packageRelease(value.releaseFile)
|
||||
const packaged = JSON.parse(fs.readFileSync(packagedFile))
|
||||
assert.equal(fs.readFileSync(value.releaseFile, 'utf8'), before)
|
||||
for (const platform of ['ios', 'android']) {
|
||||
const bundleDirectory = path.resolve(
|
||||
value.directory,
|
||||
packaged.platforms[platform].bundleDirectory,
|
||||
)
|
||||
assert.equal(
|
||||
fs.readFileSync(
|
||||
path.join(
|
||||
bundleDirectory,
|
||||
`bundles/${md5(`${platform}-chosen`)}.bundle`,
|
||||
),
|
||||
'utf8',
|
||||
),
|
||||
`${platform}-chosen`,
|
||||
)
|
||||
assert.equal(
|
||||
path.resolve(
|
||||
path.dirname(packagedFile),
|
||||
packaged.platforms[platform].fingerprintReportRef,
|
||||
),
|
||||
path.join(value.directory, `${platform}-report.json`),
|
||||
)
|
||||
}
|
||||
const retry = packageRelease(value.releaseFile)
|
||||
assert.notEqual(retry, packagedFile)
|
||||
assert.equal(fs.readFileSync(value.releaseFile, 'utf8'), before)
|
||||
})
|
||||
|
||||
test('preserves a rollback marker', () => {
|
||||
const value = fixture(['ios'])
|
||||
fs.writeFileSync(
|
||||
path.join(value.directory, 'export-ios/rollback'),
|
||||
'rollback\n',
|
||||
)
|
||||
const packaged = JSON.parse(
|
||||
fs.readFileSync(packageRelease(value.releaseFile)),
|
||||
)
|
||||
assert.equal(
|
||||
fs.readFileSync(
|
||||
path.resolve(
|
||||
value.directory,
|
||||
packaged.platforms.ios.bundleDirectory,
|
||||
'rollback',
|
||||
),
|
||||
'utf8',
|
||||
),
|
||||
'rollback\n',
|
||||
)
|
||||
})
|
||||
|
||||
test('rejects malformed metadata and path, symlink, and extension escapes', () => {
|
||||
const mutations = {
|
||||
'malformed metadata': value =>
|
||||
fs.writeFileSync(
|
||||
path.join(value.directory, 'export-ios/metadata.json'),
|
||||
'{',
|
||||
),
|
||||
'bundle escape': value => {
|
||||
const metadata = JSON.parse(
|
||||
fs.readFileSync(path.join(value.directory, 'export-ios/metadata.json')),
|
||||
)
|
||||
metadata.fileMetadata.ios.bundle = '../outside.js'
|
||||
fs.writeFileSync(path.join(value.directory, 'outside.js'), 'outside')
|
||||
writeJson(
|
||||
path.join(value.directory, 'export-ios/metadata.json'),
|
||||
metadata,
|
||||
)
|
||||
},
|
||||
'bundle symlink': value => {
|
||||
fs.rmSync(path.join(value.directory, 'export-ios/bundles/chosen.js'))
|
||||
fs.writeFileSync(path.join(value.directory, 'outside.js'), 'outside')
|
||||
fs.symlinkSync(
|
||||
path.join(value.directory, 'outside.js'),
|
||||
path.join(value.directory, 'export-ios/bundles/chosen.js'),
|
||||
)
|
||||
},
|
||||
'asset escape': value => {
|
||||
const metadata = JSON.parse(
|
||||
fs.readFileSync(path.join(value.directory, 'export-ios/metadata.json')),
|
||||
)
|
||||
metadata.fileMetadata.ios.assets[0].path = '../outside.png'
|
||||
fs.writeFileSync(path.join(value.directory, 'outside.png'), 'outside')
|
||||
writeJson(
|
||||
path.join(value.directory, 'export-ios/metadata.json'),
|
||||
metadata,
|
||||
)
|
||||
},
|
||||
'asset symlink': value => {
|
||||
fs.rmSync(path.join(value.directory, 'export-ios/assets/icon.png'))
|
||||
fs.writeFileSync(path.join(value.directory, 'outside.png'), 'outside')
|
||||
fs.symlinkSync(
|
||||
path.join(value.directory, 'outside.png'),
|
||||
path.join(value.directory, 'export-ios/assets/icon.png'),
|
||||
)
|
||||
},
|
||||
'extension escape': value => {
|
||||
const metadata = JSON.parse(
|
||||
fs.readFileSync(path.join(value.directory, 'export-ios/metadata.json')),
|
||||
)
|
||||
metadata.fileMetadata.ios.assets[0].ext = '../js'
|
||||
writeJson(
|
||||
path.join(value.directory, 'export-ios/metadata.json'),
|
||||
metadata,
|
||||
)
|
||||
},
|
||||
}
|
||||
for (const [name, mutate] of Object.entries(mutations)) {
|
||||
const value = fixture(['ios'])
|
||||
mutate(value)
|
||||
assert.throws(() => packageRelease(value.releaseFile), undefined, name)
|
||||
}
|
||||
})
|
||||
|
||||
test('structured wrapper validates and invokes only release-file publishing', () => {
|
||||
const value = fixture(['ios'])
|
||||
const bin = fs.mkdtempSync(path.join(os.tmpdir(), 'fake-denis-'))
|
||||
const log = path.join(bin, 'args.json')
|
||||
const denis = path.join(bin, 'denis')
|
||||
fs.writeFileSync(
|
||||
denis,
|
||||
'#!/bin/sh\nprintf "%s\\n" "$@" > "$DENIS_TEST_LOG"\n',
|
||||
{mode: 0o755},
|
||||
)
|
||||
const result = spawnSync('bash', [publisher, value.releaseFile], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${bin}:${process.env.PATH}`,
|
||||
DENIS_TEST_LOG: log,
|
||||
DENIS_PUBLISH_MODE: 'structured',
|
||||
},
|
||||
})
|
||||
assert.equal(result.status, 0, result.stderr)
|
||||
const args = fs.readFileSync(log, 'utf8').trim().split('\n')
|
||||
assert.deepEqual(args.slice(0, 2), ['publish', '--release-file'])
|
||||
assert.notEqual(args[2], value.releaseFile)
|
||||
assert.ok(fs.existsSync(args[2]))
|
||||
})
|
||||
|
||||
test('wrapper defaults existing callers to legacy mode', () => {
|
||||
const result = spawnSync('bash', [publisher], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
env: {...process.env, DENIS_PUBLISH_MODE: ''},
|
||||
})
|
||||
assert.notEqual(result.status, 0)
|
||||
assert.match(result.stderr, /CHANNEL_NAME is required/)
|
||||
})
|
||||
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env node
|
||||
/* oxlint-disable import/no-nodejs-modules -- This is a Node-only build script. */
|
||||
const crypto = require('crypto')
|
||||
const fs = require('fs')
|
||||
const fsp = fs.promises
|
||||
const path = require('path')
|
||||
const source = path.resolve('dist')
|
||||
const destination = path.resolve('bundleTempDir')
|
||||
async function hash(file) {
|
||||
const value = crypto.createHash('md5')
|
||||
for await (const chunk of fs.createReadStream(file)) value.update(chunk)
|
||||
return value.digest('hex')
|
||||
}
|
||||
async function main() {
|
||||
/** @type {{fileMetadata: Record<string, {bundle: string, assets?: {path: string, ext: string}[]}>}} */
|
||||
const metadata = JSON.parse(
|
||||
await fsp.readFile(path.join(source, 'metadata.json'), 'utf8'),
|
||||
)
|
||||
await fsp.mkdir(path.join(destination, 'bundles'), {recursive: true})
|
||||
await fsp.mkdir(path.join(destination, 'assets'), {recursive: true})
|
||||
const result = {version: 0, bundler: 'metro', fileMetadata: {}}
|
||||
for (const platform of ['ios', 'android']) {
|
||||
const input = metadata.fileMetadata?.[platform]
|
||||
if (!input?.bundle)
|
||||
throw new Error(
|
||||
`Missing explicit ${platform} bundle in dist/metadata.json`,
|
||||
)
|
||||
const bundleSource = path.resolve(source, input.bundle)
|
||||
const bundle = `bundles/${await hash(bundleSource)}.bundle`
|
||||
await fsp.copyFile(bundleSource, path.join(destination, bundle))
|
||||
const assets = []
|
||||
for (const asset of input.assets ?? []) {
|
||||
const assetSource = path.resolve(source, asset.path)
|
||||
const target = `assets/${await hash(assetSource)}.${asset.ext}`
|
||||
await fsp.copyFile(assetSource, path.join(destination, target))
|
||||
assets.push(target)
|
||||
}
|
||||
result.fileMetadata[platform] = {bundle, assets}
|
||||
}
|
||||
await fsp.writeFile(
|
||||
path.join(destination, 'metadata.json'),
|
||||
JSON.stringify(result),
|
||||
)
|
||||
}
|
||||
main().catch(error => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
Executable
+190
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import {execFileSync} from 'node:child_process'
|
||||
import {createRequire} from 'node:module'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const root = path.resolve(import.meta.dirname, '../..')
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const result = {}
|
||||
for (let i = 0; i < argv.length; i += 2) {
|
||||
const key = argv[i]
|
||||
const value = argv[i + 1]
|
||||
if (!key?.startsWith('--') || value == null)
|
||||
fail(`Invalid argument: ${key}`)
|
||||
result[key.slice(2)] = value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
async function assertFile(relativePath) {
|
||||
const absolutePath = path.join(root, relativePath)
|
||||
const stat = await fs.stat(absolutePath).catch(() => null)
|
||||
if (!stat?.isFile())
|
||||
fail(`Required fingerprint input is missing: ${relativePath}`)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
const platform = args.platform
|
||||
const nativeProfile = args.profile
|
||||
if (!['ios', 'android'].includes(platform))
|
||||
fail('--platform must be ios or android')
|
||||
if (!['production', 'testflight'].includes(nativeProfile)) {
|
||||
fail('--profile must be production or testflight')
|
||||
}
|
||||
|
||||
if (
|
||||
process.env.EAS_BUILD_PLATFORM &&
|
||||
process.env.EAS_BUILD_PLATFORM !== platform
|
||||
) {
|
||||
fail(
|
||||
`EAS_BUILD_PLATFORM=${process.env.EAS_BUILD_PLATFORM} conflicts with ${platform}`,
|
||||
)
|
||||
}
|
||||
if (
|
||||
process.env.EXPO_PUBLIC_ENV &&
|
||||
process.env.EXPO_PUBLIC_ENV !== nativeProfile
|
||||
) {
|
||||
fail(
|
||||
`EXPO_PUBLIC_ENV=${process.env.EXPO_PUBLIC_ENV} conflicts with ${nativeProfile}`,
|
||||
)
|
||||
}
|
||||
process.env.EAS_BUILD_PLATFORM = platform
|
||||
process.env.EXPO_PUBLIC_ENV = nativeProfile
|
||||
process.env.OTA_FINGERPRINT_PIPELINE_ENABLED = '1'
|
||||
|
||||
const sourceCommit = args['source-commit'] ?? process.env.GITHUB_SHA
|
||||
if (!/^[0-9a-f]{40}$/.test(sourceCommit ?? ''))
|
||||
fail('--source-commit or GITHUB_SHA must be a full lowercase SHA')
|
||||
const checkoutCommit = execFileSync('git', ['rev-parse', 'HEAD'], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
}).trim()
|
||||
if (sourceCommit !== checkoutCommit)
|
||||
fail(
|
||||
`sourceCommit ${sourceCommit} does not match checkout HEAD ${checkoutCommit}`,
|
||||
)
|
||||
|
||||
for (const file of [
|
||||
'app.config.js',
|
||||
'fingerprint.config.js',
|
||||
'scripts/ota/fingerprint-policy.json',
|
||||
...(platform === 'android' ? ['google-services.json'] : []),
|
||||
])
|
||||
await assertFile(file)
|
||||
if (platform === 'ios') {
|
||||
for (const name of ['BlueskyClip', 'BlueskyNSE', 'Share-with-Bluesky']) {
|
||||
const stat = await fs
|
||||
.stat(path.join(root, 'modules', name))
|
||||
.catch(() => null)
|
||||
if (!stat?.isDirectory())
|
||||
fail(`Required native extension is missing: modules/${name}`)
|
||||
}
|
||||
}
|
||||
|
||||
delete require.cache[
|
||||
require.resolve(path.join(root, 'fingerprint.config.js'))
|
||||
]
|
||||
const fingerprintConfig = require(path.join(root, 'fingerprint.config.js'))
|
||||
if (
|
||||
fingerprintConfig.hashAlgorithm !== 'sha1' ||
|
||||
!Array.isArray(fingerprintConfig.extraSources)
|
||||
) {
|
||||
fail('fingerprint.config.js did not load the canonical policy')
|
||||
}
|
||||
const {getConfig} = require('expo/config')
|
||||
const {exp} = getConfig(root, {
|
||||
isPublicConfig: true,
|
||||
skipSDKVersionRequirement: true,
|
||||
})
|
||||
if (exp.runtimeVersion?.policy !== 'fingerprint')
|
||||
fail('Expo runtimeVersion policy must be fingerprint')
|
||||
|
||||
const {
|
||||
resolveRuntimeVersionAsync,
|
||||
} = require('expo-updates/utils/build/resolveRuntimeVersionAsync')
|
||||
const resolved = await resolveRuntimeVersionAsync(
|
||||
root,
|
||||
platform,
|
||||
{silent: true},
|
||||
{workflowOverride: 'managed'},
|
||||
)
|
||||
if (!/^[0-9a-f]{40}$/.test(resolved.runtimeVersion ?? ''))
|
||||
fail('Resolver returned an invalid fingerprint runtime')
|
||||
if (
|
||||
!Array.isArray(resolved.fingerprintSources) ||
|
||||
resolved.fingerprintSources.length === 0
|
||||
) {
|
||||
fail('Resolver returned no fingerprint sources')
|
||||
}
|
||||
const policySource = resolved.fingerprintSources.find(
|
||||
source =>
|
||||
source.type === 'file' &&
|
||||
source.filePath?.endsWith('fingerprint-policy.json'),
|
||||
)
|
||||
if (!policySource?.hash)
|
||||
fail('Fingerprint report omitted the compatibility policy source')
|
||||
const expoConfigSource = resolved.fingerprintSources.find(
|
||||
source => source.type === 'contents' && source.id === 'expoConfig',
|
||||
)
|
||||
if (!expoConfigSource?.hash)
|
||||
fail('Fingerprint report omitted resolved Expo config')
|
||||
if (platform === 'ios') {
|
||||
for (const name of ['BlueskyClip', 'BlueskyNSE', 'Share-with-Bluesky']) {
|
||||
const source = resolved.fingerprintSources.find(
|
||||
candidate =>
|
||||
candidate.type === 'dir' && candidate.filePath === `modules/${name}`,
|
||||
)
|
||||
if (!source?.hash) fail(`Fingerprint report omitted modules/${name}`)
|
||||
}
|
||||
} else {
|
||||
const googleServicesSource = resolved.fingerprintSources.find(
|
||||
source =>
|
||||
source.type === 'file' && source.filePath === 'google-services.json',
|
||||
)
|
||||
if (!googleServicesSource?.hash)
|
||||
fail('Fingerprint report omitted google-services.json')
|
||||
}
|
||||
|
||||
const projectPackage = require(path.join(root, 'package.json'))
|
||||
const packageManager =
|
||||
projectPackage.packageManager ??
|
||||
`${projectPackage.devEngines.packageManager.name}@${projectPackage.devEngines.packageManager.version}`
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
platform,
|
||||
nativeProfile,
|
||||
sourceCommit,
|
||||
runtimeVersion: resolved.runtimeVersion,
|
||||
fingerprintPolicyVersion: 1,
|
||||
fingerprintToolVersion: require('@expo/fingerprint/package.json').version,
|
||||
toolVersions: {
|
||||
expo: require('expo/package.json').version,
|
||||
expoUpdates: require('expo-updates/package.json').version,
|
||||
node: process.version,
|
||||
packageManager,
|
||||
},
|
||||
fingerprintSources: resolved.fingerprintSources,
|
||||
}
|
||||
const json = `${JSON.stringify(report, null, 2)}\n`
|
||||
if (args.output) {
|
||||
await fs.mkdir(path.dirname(path.resolve(args.output)), {recursive: true})
|
||||
await fs.writeFile(path.resolve(args.output), json)
|
||||
} else {
|
||||
process.stdout.write(json)
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
Executable
+226
@@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(message)
|
||||
}
|
||||
async function readJson(base, reference, label) {
|
||||
const resolved = await resolvePath(base, reference, label)
|
||||
return JSON.parse(await fs.readFile(resolved, 'utf8'))
|
||||
}
|
||||
async function resolvePath(base, reference, label) {
|
||||
if (typeof reference !== 'string' || path.isAbsolute(reference))
|
||||
fail(`${label} must be relative`)
|
||||
const lexicalRelative = path.relative(base, path.resolve(base, reference))
|
||||
if (lexicalRelative.startsWith('..') || path.isAbsolute(lexicalRelative))
|
||||
fail(`${label} escapes the release directory`)
|
||||
const realBase = await fs.realpath(base)
|
||||
const resolved = await fs.realpath(path.resolve(base, reference))
|
||||
const relative = path.relative(realBase, resolved)
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative))
|
||||
fail(`${label} escapes the release directory`)
|
||||
return resolved
|
||||
}
|
||||
function validateFingerprintReport(report, expected, label) {
|
||||
if (
|
||||
report.schemaVersion !== 1 ||
|
||||
report.platform !== expected.platform ||
|
||||
report.nativeProfile !== expected.nativeProfile ||
|
||||
report.sourceCommit !== expected.sourceCommit ||
|
||||
report.runtimeVersion !== expected.runtimeVersion ||
|
||||
report.fingerprintPolicyVersion !== 1 ||
|
||||
typeof report.fingerprintToolVersion !== 'string' ||
|
||||
report.fingerprintToolVersion.length === 0 ||
|
||||
!report.toolVersions ||
|
||||
!['expo', 'expoUpdates', 'node', 'packageManager'].every(
|
||||
key =>
|
||||
typeof report.toolVersions[key] === 'string' &&
|
||||
report.toolVersions[key].length > 0,
|
||||
) ||
|
||||
!Array.isArray(report.fingerprintSources) ||
|
||||
report.fingerprintSources.length === 0 ||
|
||||
report.fingerprintSources.some(
|
||||
source =>
|
||||
!source ||
|
||||
!['file', 'dir', 'contents'].includes(source.type) ||
|
||||
!Object.hasOwn(source, 'hash') ||
|
||||
(source.hash !== null && !/^[0-9a-f]{40}$/.test(source.hash)),
|
||||
)
|
||||
)
|
||||
fail(`${label} is incomplete or inconsistent`)
|
||||
if (
|
||||
!report.fingerprintSources.some(
|
||||
source =>
|
||||
source.type === 'contents' && source.id === 'expoConfig' && source.hash,
|
||||
)
|
||||
)
|
||||
fail(`${label} omits resolved Expo config`)
|
||||
if (
|
||||
!report.fingerprintSources.some(
|
||||
source =>
|
||||
source.type === 'file' &&
|
||||
source.filePath === 'scripts/ota/fingerprint-policy.json' &&
|
||||
source.hash,
|
||||
)
|
||||
)
|
||||
fail(`${label} omits the fingerprint policy`)
|
||||
const requiredPlatformSources =
|
||||
expected.platform === 'ios'
|
||||
? [
|
||||
'modules/BlueskyClip',
|
||||
'modules/BlueskyNSE',
|
||||
'modules/Share-with-Bluesky',
|
||||
]
|
||||
: ['google-services.json']
|
||||
for (const filePath of requiredPlatformSources) {
|
||||
if (
|
||||
!report.fingerprintSources.some(
|
||||
source => source.filePath === filePath && source.hash,
|
||||
)
|
||||
)
|
||||
fail(`${label} omits ${filePath}`)
|
||||
}
|
||||
}
|
||||
async function main() {
|
||||
const i = process.argv.indexOf('--release-file')
|
||||
if (i < 0 || !process.argv[i + 1]) fail('--release-file is required')
|
||||
const releasePath = path.resolve(process.argv[i + 1])
|
||||
const base = path.dirname(releasePath)
|
||||
const release = JSON.parse(await fs.readFile(releasePath, 'utf8'))
|
||||
if (
|
||||
release.schemaVersion !== 1 ||
|
||||
!/^[0-9a-f]{40}$/.test(release.sourceCommit ?? '')
|
||||
)
|
||||
fail('Invalid release sourceCommit')
|
||||
if (!/^[0-9]{13}$/.test(release.bundleVersion ?? ''))
|
||||
fail('bundleVersion must be a 13-digit Unix millisecond string')
|
||||
if (!['production', 'testflight'].includes(release.nativeProfile))
|
||||
fail('Invalid nativeProfile')
|
||||
if (!(
|
||||
release.channel === 'production' ||
|
||||
release.channel === 'testflight' ||
|
||||
/^pull-request-[1-9][0-9]*$/.test(release.channel ?? '')
|
||||
))
|
||||
fail('Invalid channel')
|
||||
if (
|
||||
release.channel === 'production' &&
|
||||
release.nativeProfile !== 'production'
|
||||
)
|
||||
fail('Production channel requires the production native profile')
|
||||
if (
|
||||
release.channel !== 'production' &&
|
||||
release.nativeProfile !== 'testflight'
|
||||
)
|
||||
fail(`${release.channel} requires the testflight native profile`)
|
||||
if (!release.platforms || Object.keys(release.platforms).length === 0)
|
||||
fail('At least one platform is required')
|
||||
const verification = {schemaVersion: 1, valid: true, platforms: {}}
|
||||
for (const [platform, entry] of Object.entries(release.platforms)) {
|
||||
if (
|
||||
!['ios', 'android'].includes(platform) ||
|
||||
!/^[0-9a-f]{40}$/.test(entry.runtimeVersion ?? '')
|
||||
)
|
||||
fail(`Invalid ${platform} entry`)
|
||||
const report = await readJson(
|
||||
base,
|
||||
entry.fingerprintReportRef,
|
||||
`${platform} fingerprintReportRef`,
|
||||
)
|
||||
validateFingerprintReport(
|
||||
report,
|
||||
{
|
||||
platform,
|
||||
nativeProfile: release.nativeProfile,
|
||||
sourceCommit: release.sourceCommit,
|
||||
runtimeVersion: entry.runtimeVersion,
|
||||
},
|
||||
`${platform} export fingerprint report`,
|
||||
)
|
||||
await fs
|
||||
.stat(
|
||||
await resolvePath(
|
||||
base,
|
||||
entry.bundleDirectory,
|
||||
`${platform} bundleDirectory`,
|
||||
),
|
||||
)
|
||||
.catch(() => fail(`${platform} bundleDirectory is missing`))
|
||||
if (release.channel === 'production') {
|
||||
if (!/^[0-9]+$/.test(entry.targetNativeBuildNumber ?? ''))
|
||||
fail(`Production ${platform} target is required`)
|
||||
if (entry.targetNativeBuildReceiptRef != null) {
|
||||
const receiptPath = await resolvePath(
|
||||
base,
|
||||
entry.targetNativeBuildReceiptRef,
|
||||
`${platform} receipt ref`,
|
||||
)
|
||||
const receipt = JSON.parse(await fs.readFile(receiptPath, 'utf8'))
|
||||
const receiptReport = await readJson(
|
||||
path.dirname(receiptPath),
|
||||
receipt.fingerprintReportRef,
|
||||
`${platform} receipt report ref`,
|
||||
)
|
||||
if (
|
||||
receipt.schemaVersion !== 1 ||
|
||||
receipt.platform !== platform ||
|
||||
receipt.nativeProfile !== 'production' ||
|
||||
receipt.defaultChannel !== 'production' ||
|
||||
receipt.nativeBuildNumber !== entry.targetNativeBuildNumber ||
|
||||
receipt.runtimeVersion !== entry.runtimeVersion ||
|
||||
receipt.fingerprintPolicyVersion !== 1 ||
|
||||
typeof receipt.fingerprintToolVersion !== 'string' ||
|
||||
!/^[0-9a-f]{40}$/.test(receipt.sourceCommit ?? '') ||
|
||||
typeof receipt.appVersion !== 'string' ||
|
||||
receipt.appVersion.length === 0 ||
|
||||
typeof receipt.buildRunUrl !== 'string' ||
|
||||
receipt.buildRunUrl.length === 0 ||
|
||||
!/^[0-9a-f]{64}$/.test(receipt.artifactDigest ?? '')
|
||||
)
|
||||
fail(`Production ${platform} receipt is incompatible`)
|
||||
validateFingerprintReport(
|
||||
receiptReport,
|
||||
{
|
||||
platform,
|
||||
nativeProfile: 'production',
|
||||
sourceCommit: receipt.sourceCommit,
|
||||
runtimeVersion: receipt.runtimeVersion,
|
||||
},
|
||||
`Production ${platform} receipt fingerprint report`,
|
||||
)
|
||||
if (
|
||||
receiptReport.fingerprintToolVersion !==
|
||||
receipt.fingerprintToolVersion
|
||||
)
|
||||
fail(`Production ${platform} receipt tool version is inconsistent`)
|
||||
verification.platforms[platform] = {
|
||||
receiptVerification: {status: 'verified', reason: 'compatible'},
|
||||
}
|
||||
} else {
|
||||
console.error(
|
||||
`Warning: production ${platform} target could not be verified because no native build receipt was supplied`,
|
||||
)
|
||||
verification.platforms[platform] = {
|
||||
receiptVerification: {
|
||||
status: 'unverified',
|
||||
reason: 'receipt-unavailable',
|
||||
},
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
entry.targetNativeBuildNumber != null ||
|
||||
entry.targetNativeBuildReceiptRef != null
|
||||
)
|
||||
fail(`${release.channel} must not target a native build`)
|
||||
else
|
||||
verification.platforms[platform] = {
|
||||
receiptVerification: {status: 'not-required', reason: 'channel-policy'},
|
||||
}
|
||||
}
|
||||
process.stdout.write(`${JSON.stringify(verification)}\n`)
|
||||
}
|
||||
main().catch(error => {
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -0,0 +1,189 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import {execFileSync} from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
|
||||
const validator = path.resolve(import.meta.dirname, 'validate-release.mjs')
|
||||
const sha = 'a'.repeat(40)
|
||||
const runtime = 'b'.repeat(40)
|
||||
|
||||
function fixture(channel = 'production') {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ota-release-'))
|
||||
fs.mkdirSync(path.join(directory, 'bundle'))
|
||||
fs.writeFileSync(
|
||||
path.join(directory, 'report.json'),
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
platform: 'ios',
|
||||
nativeProfile: 'production',
|
||||
sourceCommit: sha,
|
||||
runtimeVersion: runtime,
|
||||
fingerprintPolicyVersion: 1,
|
||||
fingerprintToolVersion: '0.20.8',
|
||||
toolVersions: {
|
||||
expo: '57.0.8',
|
||||
expoUpdates: '57.0.10',
|
||||
node: 'v24.19.0',
|
||||
packageManager: 'pnpm@11.21.0',
|
||||
},
|
||||
fingerprintSources: [
|
||||
{type: 'contents', id: 'expoConfig', hash: 'c'.repeat(40)},
|
||||
{
|
||||
type: 'file',
|
||||
filePath: 'scripts/ota/fingerprint-policy.json',
|
||||
hash: 'd'.repeat(40),
|
||||
},
|
||||
...['BlueskyClip', 'BlueskyNSE', 'Share-with-Bluesky'].map(name => ({
|
||||
type: 'dir',
|
||||
filePath: `modules/${name}`,
|
||||
hash: 'e'.repeat(40),
|
||||
})),
|
||||
],
|
||||
}),
|
||||
)
|
||||
const release = {
|
||||
schemaVersion: 1,
|
||||
sourceCommit: sha,
|
||||
channel,
|
||||
nativeProfile: 'production',
|
||||
bundleVersion: '1788537600000',
|
||||
platforms: {
|
||||
ios: {
|
||||
runtimeVersion: runtime,
|
||||
fingerprintReportRef: 'report.json',
|
||||
bundleDirectory: 'bundle',
|
||||
...(channel === 'production' ? {targetNativeBuildNumber: '42'} : {}),
|
||||
},
|
||||
},
|
||||
}
|
||||
const releaseFile = path.join(directory, 'ota-export.json')
|
||||
fs.writeFileSync(releaseFile, JSON.stringify(release))
|
||||
return {directory, release, releaseFile}
|
||||
}
|
||||
|
||||
function validate(releaseFile) {
|
||||
return execFileSync(
|
||||
process.execPath,
|
||||
[validator, '--release-file', releaseFile],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
test('production requires an exact build but permits an unavailable receipt', () => {
|
||||
const value = fixture()
|
||||
const result = JSON.parse(validate(value.releaseFile))
|
||||
assert.deepEqual(result.platforms.ios.receiptVerification, {
|
||||
status: 'unverified',
|
||||
reason: 'receipt-unavailable',
|
||||
})
|
||||
delete value.release.platforms.ios.targetNativeBuildNumber
|
||||
fs.writeFileSync(value.releaseFile, JSON.stringify(value.release))
|
||||
assert.throws(
|
||||
() => validate(value.releaseFile),
|
||||
/Production ios target is required/,
|
||||
)
|
||||
})
|
||||
|
||||
test('testflight rejects build targeting', () => {
|
||||
const value = fixture('testflight')
|
||||
value.release.nativeProfile = 'testflight'
|
||||
value.release.platforms.ios.targetNativeBuildNumber = '42'
|
||||
const report = JSON.parse(
|
||||
fs.readFileSync(path.join(value.directory, 'report.json')),
|
||||
)
|
||||
report.nativeProfile = 'testflight'
|
||||
fs.writeFileSync(
|
||||
path.join(value.directory, 'report.json'),
|
||||
JSON.stringify(report),
|
||||
)
|
||||
fs.writeFileSync(value.releaseFile, JSON.stringify(value.release))
|
||||
assert.throws(
|
||||
() => validate(value.releaseFile),
|
||||
/must not target a native build/,
|
||||
)
|
||||
})
|
||||
|
||||
test('a supplied receipt with an invalid source SHA fails closed', () => {
|
||||
const value = fixture()
|
||||
fs.mkdirSync(path.join(value.directory, 'receipts'))
|
||||
value.release.platforms.ios.targetNativeBuildReceiptRef =
|
||||
'receipts/receipt.json'
|
||||
fs.writeFileSync(
|
||||
path.join(value.directory, 'receipts/receipt.json'),
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
platform: 'ios',
|
||||
nativeProfile: 'production',
|
||||
defaultChannel: 'production',
|
||||
appVersion: '1.133.0',
|
||||
nativeBuildNumber: '42',
|
||||
runtimeVersion: runtime,
|
||||
sourceCommit: 'short',
|
||||
fingerprintPolicyVersion: 1,
|
||||
fingerprintToolVersion: '0.20.8',
|
||||
artifactDigest: 'c'.repeat(64),
|
||||
buildRunUrl: 'https://github.example/build/1',
|
||||
fingerprintReportRef: 'report.json',
|
||||
}),
|
||||
)
|
||||
fs.writeFileSync(path.join(value.directory, 'receipts/report.json'), '{}')
|
||||
fs.writeFileSync(value.releaseFile, JSON.stringify(value.release))
|
||||
assert.throws(() => validate(value.releaseFile), /receipt is incompatible/)
|
||||
})
|
||||
|
||||
test('bundle directories cannot escape the release directory', () => {
|
||||
const value = fixture('testflight')
|
||||
value.release.nativeProfile = 'testflight'
|
||||
value.release.platforms.ios.bundleDirectory = '../bundle'
|
||||
const report = JSON.parse(
|
||||
fs.readFileSync(path.join(value.directory, 'report.json')),
|
||||
)
|
||||
report.nativeProfile = 'testflight'
|
||||
fs.writeFileSync(
|
||||
path.join(value.directory, 'report.json'),
|
||||
JSON.stringify(report),
|
||||
)
|
||||
fs.writeFileSync(value.releaseFile, JSON.stringify(value.release))
|
||||
assert.throws(
|
||||
() => validate(value.releaseFile),
|
||||
/escapes the release directory/,
|
||||
)
|
||||
})
|
||||
|
||||
test('channel and native profile must agree', () => {
|
||||
const value = fixture('testflight')
|
||||
assert.throws(
|
||||
() => validate(value.releaseFile),
|
||||
/requires the testflight native profile/,
|
||||
)
|
||||
})
|
||||
|
||||
test('incomplete fingerprint reports are rejected', () => {
|
||||
const value = fixture()
|
||||
const report = JSON.parse(
|
||||
fs.readFileSync(path.join(value.directory, 'report.json')),
|
||||
)
|
||||
delete report.fingerprintToolVersion
|
||||
fs.writeFileSync(
|
||||
path.join(value.directory, 'report.json'),
|
||||
JSON.stringify(report),
|
||||
)
|
||||
assert.throws(
|
||||
() => validate(value.releaseFile),
|
||||
/fingerprint report is incomplete or inconsistent/,
|
||||
)
|
||||
})
|
||||
|
||||
test('symlinked bundle directories cannot escape the release directory', () => {
|
||||
const value = fixture()
|
||||
fs.rmSync(path.join(value.directory, 'bundle'), {recursive: true})
|
||||
fs.symlinkSync(os.tmpdir(), path.join(value.directory, 'bundle'))
|
||||
assert.throws(
|
||||
() => validate(value.releaseFile),
|
||||
/escapes the release directory/,
|
||||
)
|
||||
})
|
||||
@@ -92,11 +92,13 @@ export function useIntentHandler() {
|
||||
releaseVersion && buildNumber
|
||||
? `${releaseVersion}.${buildNumber}`
|
||||
: null
|
||||
const sourceCommit = params.get('sourceCommit')
|
||||
const publicationId = params.get('publicationId')
|
||||
if (!channel) {
|
||||
Alert.alert('Error', 'No channel provided to look for.')
|
||||
return
|
||||
}
|
||||
tryApplyUpdate(channel, appVersion)
|
||||
tryApplyUpdate(channel, appVersion, {sourceCommit, publicationId})
|
||||
return
|
||||
}
|
||||
default: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {Alert} from 'react-native'
|
||||
import {Alert, AppState} from 'react-native'
|
||||
import {
|
||||
checkForUpdateAsync,
|
||||
fetchUpdateAsync,
|
||||
@@ -13,15 +13,24 @@ import {logger} from '#/logger'
|
||||
import {APP_VERSION} from '#/env'
|
||||
import {device} from '#/storage'
|
||||
import {
|
||||
checkForOTAUpdate,
|
||||
prepareOTAUpdateRequest,
|
||||
useApplyPullRequestOTAUpdate,
|
||||
useOTAUpdateRecovery,
|
||||
useOTAUpdates,
|
||||
} from './useOTAUpdates'
|
||||
|
||||
let mockRuntimeVersion = '1.133.0'
|
||||
|
||||
jest.mock('expo-updates', () => ({
|
||||
channel: 'testflight',
|
||||
checkForUpdateAsync: jest.fn(),
|
||||
fetchUpdateAsync: jest.fn(),
|
||||
isEnabled: true,
|
||||
reloadAsync: jest.fn(),
|
||||
get runtimeVersion() {
|
||||
return mockRuntimeVersion
|
||||
},
|
||||
setExtraParamAsync: jest.fn(),
|
||||
UpdateCheckResultNotAvailableReason: {
|
||||
NO_UPDATE_AVAILABLE_ON_SERVER: 'noUpdateAvailableOnServer',
|
||||
@@ -61,10 +70,12 @@ function mockCurrentlyRunning({
|
||||
buildChannel = 'testflight',
|
||||
channel,
|
||||
updateId = 'current-update',
|
||||
isUpdatePending = false,
|
||||
}: {
|
||||
buildChannel?: string
|
||||
channel?: string
|
||||
updateId?: string
|
||||
isUpdatePending?: boolean
|
||||
} = {}) {
|
||||
const currentlyRunning = {
|
||||
channel: buildChannel,
|
||||
@@ -76,6 +87,7 @@ function mockCurrentlyRunning({
|
||||
}
|
||||
jest.mocked(useUpdates).mockReturnValue({
|
||||
currentlyRunning,
|
||||
isUpdatePending,
|
||||
} as ReturnType<typeof useUpdates>)
|
||||
return currentlyRunning
|
||||
}
|
||||
@@ -84,13 +96,30 @@ const currentUpdate = {updateId: 'current-update'}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockRuntimeVersion = '1.133.0'
|
||||
mockCurrentlyRunning()
|
||||
jest.mocked(setExtraParamAsync).mockResolvedValue(undefined)
|
||||
jest.mocked(reloadAsync).mockResolvedValue(undefined)
|
||||
jest.spyOn(Alert, 'alert').mockImplementation(() => {})
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({status: 'available', deployments: []}),
|
||||
})
|
||||
})
|
||||
|
||||
describe('useApplyPullRequestOTAUpdate', () => {
|
||||
it('rejects manually applying a non-PR channel', async () => {
|
||||
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
|
||||
|
||||
await act(() => result.current.tryApplyUpdate('testflight'))
|
||||
|
||||
expect(Alert.alert).toHaveBeenCalledWith(
|
||||
'Invalid Deployment',
|
||||
expect.stringContaining('Only pull request'),
|
||||
)
|
||||
expect(checkForUpdateAsync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('detects a running PR deployment from the manifest metadata', () => {
|
||||
mockCurrentlyRunning({
|
||||
buildChannel: 'testflight',
|
||||
@@ -209,7 +238,7 @@ describe('useApplyPullRequestOTAUpdate', () => {
|
||||
isNew: true,
|
||||
isRollBackToEmbedded: false,
|
||||
manifest: {id: 'mismatched-update'},
|
||||
} as Awaited<ReturnType<typeof fetchUpdateAsync>>)
|
||||
} as unknown as Awaited<ReturnType<typeof fetchUpdateAsync>>)
|
||||
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
|
||||
|
||||
await act(() => result.current.tryApplyUpdate('pull-request-123', '0.0.0'))
|
||||
@@ -231,6 +260,103 @@ describe('useApplyPullRequestOTAUpdate', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('does not offer an app-version override to fingerprint clients', async () => {
|
||||
mockRuntimeVersion = 'a'.repeat(40)
|
||||
jest.mocked(checkForUpdateAsync).mockResolvedValue({
|
||||
isAvailable: true,
|
||||
} as Awaited<ReturnType<typeof checkForUpdateAsync>>)
|
||||
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
|
||||
|
||||
await act(() => result.current.tryApplyUpdate('pull-request-123', '0.0.0'))
|
||||
|
||||
expect(Alert.alert).toHaveBeenCalledWith(
|
||||
'Apply update from PR #123?',
|
||||
expect.stringContaining('relaunch'),
|
||||
expect.arrayContaining([expect.objectContaining({text: 'Apply'})]),
|
||||
)
|
||||
expect(Alert.alert).not.toHaveBeenCalledWith(
|
||||
'App Version Mismatch',
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['runtime-mismatch', 'Different Native Build Required'],
|
||||
['not-published', 'No Deployment Available'],
|
||||
['stale-link', 'Deployment Link Is Out of Date'],
|
||||
] as const)('handles the %s diagnostic status', async (status, title) => {
|
||||
mockRuntimeVersion = 'a'.repeat(40)
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({status, deployments: []}),
|
||||
})
|
||||
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
|
||||
|
||||
await act(() => result.current.tryApplyUpdate('pull-request-123'))
|
||||
|
||||
expect(Alert.alert).toHaveBeenCalledWith(title, expect.any(String))
|
||||
expect(checkForUpdateAsync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stays quiet when diagnostics report the deployment already running', async () => {
|
||||
mockRuntimeVersion = 'a'.repeat(40)
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({status: 'already-running', deployments: []}),
|
||||
})
|
||||
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
|
||||
|
||||
await act(() => result.current.tryApplyUpdate('pull-request-123'))
|
||||
|
||||
expect(Alert.alert).not.toHaveBeenCalled()
|
||||
expect(checkForUpdateAsync).not.toHaveBeenCalled()
|
||||
expect(result.current.pending).toBe(false)
|
||||
})
|
||||
|
||||
it('allows only one PR confirmation prompt at a time', async () => {
|
||||
jest.mocked(checkForUpdateAsync).mockResolvedValue({
|
||||
isAvailable: true,
|
||||
} as Awaited<ReturnType<typeof checkForUpdateAsync>>)
|
||||
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
|
||||
|
||||
await act(() => result.current.tryApplyUpdate('pull-request-123'))
|
||||
await act(() => result.current.tryApplyUpdate('pull-request-456'))
|
||||
|
||||
expect(Alert.alert).toHaveBeenCalledTimes(1)
|
||||
expect(result.current.pending).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a fetched deployment whose source does not match the link', async () => {
|
||||
mockRuntimeVersion = 'a'.repeat(40)
|
||||
jest.mocked(checkForUpdateAsync).mockResolvedValue({
|
||||
isAvailable: true,
|
||||
} as Awaited<ReturnType<typeof checkForUpdateAsync>>)
|
||||
jest.mocked(fetchUpdateAsync).mockResolvedValue({
|
||||
isNew: true,
|
||||
isRollBackToEmbedded: false,
|
||||
manifest: {id: 'new-update', extra: {ota: {sourceCommit: 'newer'}}},
|
||||
} as unknown as Awaited<ReturnType<typeof fetchUpdateAsync>>)
|
||||
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
|
||||
|
||||
await act(() =>
|
||||
result.current.tryApplyUpdate('pull-request-123', null, {
|
||||
sourceCommit: 'expected',
|
||||
}),
|
||||
)
|
||||
const buttons = jest.mocked(Alert.alert).mock.calls[0][2]
|
||||
void act(() => buttons?.[1].onPress?.())
|
||||
|
||||
await waitFor(() =>
|
||||
expect(Alert.alert).toHaveBeenLastCalledWith(
|
||||
'Update Failed',
|
||||
expect.stringContaining('deployment changed'),
|
||||
),
|
||||
)
|
||||
expect(reloadAsync).not.toHaveBeenCalled()
|
||||
expect(setExtraParamAsync).toHaveBeenLastCalledWith('channel', 'testflight')
|
||||
})
|
||||
|
||||
it('informs the user when checking for an OTA fails', async () => {
|
||||
jest.mocked(checkForUpdateAsync).mockRejectedValue(new Error('offline'))
|
||||
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
|
||||
@@ -297,8 +423,87 @@ describe('useApplyPullRequestOTAUpdate', () => {
|
||||
updateId: 'new-update',
|
||||
})
|
||||
expect(device.remove).toHaveBeenCalledWith(['pendingOTAUpdate'])
|
||||
expect(setExtraParamAsync).toHaveBeenLastCalledWith('channel', 'testflight')
|
||||
expect(result.current.pending).toBe(false)
|
||||
})
|
||||
|
||||
it('restores the default request after cancellation', async () => {
|
||||
jest.mocked(checkForUpdateAsync).mockResolvedValue({
|
||||
isAvailable: true,
|
||||
} as Awaited<ReturnType<typeof checkForUpdateAsync>>)
|
||||
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
|
||||
|
||||
await act(() => result.current.tryApplyUpdate('pull-request-123'))
|
||||
const buttons = jest.mocked(Alert.alert).mock.calls[0][2]
|
||||
await act(() => buttons?.[0].onPress?.())
|
||||
|
||||
await waitFor(() =>
|
||||
expect(setExtraParamAsync).toHaveBeenLastCalledWith(
|
||||
'channel',
|
||||
'testflight',
|
||||
),
|
||||
)
|
||||
expect(result.current.pending).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useOTAUpdates', () => {
|
||||
it('does not reload a pending default update while a PR prompt owns the request', async () => {
|
||||
let appStateListener: ((state: string) => Promise<void>) | undefined
|
||||
jest
|
||||
.spyOn(AppState, 'addEventListener')
|
||||
.mockImplementation((_, listener) => {
|
||||
appStateListener = listener as (state: string) => Promise<void>
|
||||
return {remove: jest.fn()}
|
||||
})
|
||||
jest.mocked(checkForUpdateAsync).mockResolvedValue({
|
||||
isAvailable: true,
|
||||
} as Awaited<ReturnType<typeof checkForUpdateAsync>>)
|
||||
mockCurrentlyRunning({isUpdatePending: true})
|
||||
const apply = renderHook(() => useApplyPullRequestOTAUpdate())
|
||||
renderHook(() => useOTAUpdates())
|
||||
|
||||
await act(() => apply.result.current.tryApplyUpdate('pull-request-123'))
|
||||
jest
|
||||
.spyOn(Date, 'now')
|
||||
.mockReturnValueOnce(0)
|
||||
.mockReturnValue(16 * 60e3)
|
||||
await act(async () => {
|
||||
await appStateListener?.('background')
|
||||
await appStateListener?.('active')
|
||||
})
|
||||
|
||||
expect(reloadAsync).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('OTA request preparation', () => {
|
||||
it('sets the native build number before the channel', async () => {
|
||||
await prepareOTAUpdateRequest('pull-request-123')
|
||||
|
||||
expect(setExtraParamAsync).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.stringMatching(/^(ios|android)-build-number$/),
|
||||
expect.any(String),
|
||||
)
|
||||
expect(setExtraParamAsync).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'channel',
|
||||
'pull-request-123',
|
||||
)
|
||||
})
|
||||
|
||||
it('prepares the default channel before checking', async () => {
|
||||
jest.mocked(checkForUpdateAsync).mockResolvedValue({
|
||||
isAvailable: false,
|
||||
reason: UpdateCheckResultNotAvailableReason.NO_UPDATE_AVAILABLE_ON_SERVER,
|
||||
} as Awaited<ReturnType<typeof checkForUpdateAsync>>)
|
||||
|
||||
await checkForOTAUpdate()
|
||||
|
||||
expect(setExtraParamAsync).toHaveBeenLastCalledWith('channel', 'testflight')
|
||||
expect(checkForUpdateAsync).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useOTAUpdateRecovery', () => {
|
||||
|
||||
+203
-32
@@ -7,12 +7,14 @@ import {
|
||||
} from 'react-native'
|
||||
import {nativeBuildVersion} from 'expo-application'
|
||||
import {
|
||||
channel as nativeChannel,
|
||||
checkForUpdateAsync,
|
||||
type CurrentlyRunningInfo,
|
||||
fetchUpdateAsync,
|
||||
isEnabled,
|
||||
reloadAsync,
|
||||
type ReloadScreenOptions,
|
||||
runtimeVersion as nativeRuntimeVersion,
|
||||
setExtraParamAsync,
|
||||
UpdateCheckResultNotAvailableReason,
|
||||
useUpdates,
|
||||
@@ -31,7 +33,42 @@ const OTA_RECOVERY_WINDOW = 5 * 60e3
|
||||
* The channel this native build is expected to receive updates from. Anything
|
||||
* else is only reachable through the dev tooling in settings.
|
||||
*/
|
||||
const DEFAULT_CHANNEL = IS_TESTFLIGHT ? 'testflight' : 'production'
|
||||
const DEFAULT_CHANNEL =
|
||||
nativeChannel || (IS_TESTFLIGHT ? 'testflight' : 'production')
|
||||
|
||||
const FINGERPRINT_RUNTIME_VERSION = /^[0-9a-f]{40}$/
|
||||
const OTA_RESOLVE_URL = 'https://updates.bsky.app/v1/ota/resolve'
|
||||
|
||||
type OTAResolveResult = {
|
||||
status:
|
||||
| 'available'
|
||||
| 'already-running'
|
||||
| 'runtime-mismatch'
|
||||
| 'not-published'
|
||||
| 'stale-link'
|
||||
deployments: {
|
||||
runtimeVersion: string
|
||||
sourceCommit?: string
|
||||
publicationId?: string
|
||||
updateId?: string
|
||||
}[]
|
||||
}
|
||||
|
||||
let requestQueue = Promise.resolve()
|
||||
let manualRequestOwner: symbol | undefined
|
||||
|
||||
function isManualRequestPending() {
|
||||
return manualRequestOwner !== undefined
|
||||
}
|
||||
|
||||
function serializeOTARequest<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const result = requestQueue.then(operation, operation)
|
||||
requestQueue = result.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Channels that our native builds are configured with, see `eas.json`. An
|
||||
@@ -69,17 +106,7 @@ function getRunningChannel(
|
||||
return currentlyRunning?.channel || undefined
|
||||
}
|
||||
|
||||
async function setExtraParams() {
|
||||
await setExtraParamAsync(
|
||||
IS_IOS ? 'ios-build-number' : 'android-build-number',
|
||||
// Hilariously, `buildVersion` is not actually a string on Android even though the TS type says it is.
|
||||
// This just ensures it gets passed as a string
|
||||
`${nativeBuildVersion}`,
|
||||
)
|
||||
await setExtraParamAsync('channel', DEFAULT_CHANNEL)
|
||||
}
|
||||
|
||||
async function setExtraParamsPullRequest(channel: string) {
|
||||
export async function prepareOTAUpdateRequest(channel = DEFAULT_CHANNEL) {
|
||||
await setExtraParamAsync(
|
||||
IS_IOS ? 'ios-build-number' : 'android-build-number',
|
||||
// Hilariously, `buildVersion` is not actually a string on Android even though the TS type says it is.
|
||||
@@ -89,12 +116,55 @@ async function setExtraParamsPullRequest(channel: string) {
|
||||
await setExtraParamAsync('channel', channel)
|
||||
}
|
||||
|
||||
async function updateTestflight(scheme: 'light' | 'dark') {
|
||||
await setExtraParams()
|
||||
export function checkForOTAUpdate(channel = DEFAULT_CHANNEL) {
|
||||
return serializeOTARequest(async () => {
|
||||
await prepareOTAUpdateRequest(channel)
|
||||
return checkForUpdateAsync()
|
||||
})
|
||||
}
|
||||
|
||||
const res = await checkForUpdateAsync()
|
||||
export function fetchOTAUpdate(channel = DEFAULT_CHANNEL) {
|
||||
return serializeOTARequest(async () => {
|
||||
await prepareOTAUpdateRequest(channel)
|
||||
return fetchUpdateAsync()
|
||||
})
|
||||
}
|
||||
|
||||
async function resolvePullRequestDeployment({
|
||||
channel,
|
||||
currentUpdateId,
|
||||
expected,
|
||||
}: {
|
||||
channel: string
|
||||
currentUpdateId?: string
|
||||
expected?: {sourceCommit?: string | null; publicationId?: string | null}
|
||||
}): Promise<OTAResolveResult | undefined> {
|
||||
if (!FINGERPRINT_RUNTIME_VERSION.test(nativeRuntimeVersion || '')) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const query = new URLSearchParams({
|
||||
channel,
|
||||
platform: IS_IOS ? 'ios' : 'android',
|
||||
runtimeVersion: nativeRuntimeVersion!,
|
||||
})
|
||||
if (currentUpdateId) query.set('currentUpdateId', currentUpdateId)
|
||||
if (expected?.sourceCommit) {
|
||||
query.set('sourceCommit', expected.sourceCommit)
|
||||
}
|
||||
if (expected?.publicationId) {
|
||||
query.set('publicationId', expected.publicationId)
|
||||
}
|
||||
|
||||
const response = await fetch(`${OTA_RESOLVE_URL}?${query}`)
|
||||
if (!response.ok) throw new Error(`OTA resolve failed (${response.status})`)
|
||||
return response.json() as Promise<OTAResolveResult>
|
||||
}
|
||||
|
||||
async function updateTestflight(scheme: 'light' | 'dark') {
|
||||
const res = await checkForOTAUpdate()
|
||||
if (res.isAvailable) {
|
||||
await fetchUpdateAsync()
|
||||
await fetchOTAUpdate()
|
||||
Alert.alert(
|
||||
'Update Available',
|
||||
'A new version of the app is available. Relaunch now?',
|
||||
@@ -121,6 +191,7 @@ export function useApplyPullRequestOTAUpdate() {
|
||||
const t = useTheme()
|
||||
const {currentlyRunning} = useUpdates()
|
||||
const [pending, setPending] = useState(false)
|
||||
const requestOwnerRef = useRef<symbol>(undefined)
|
||||
const currentChannel = getRunningChannel(currentlyRunning)
|
||||
const isCurrentlyRunningPullRequestDeployment =
|
||||
currentChannel?.startsWith('pull-request')
|
||||
@@ -133,15 +204,73 @@ export function useApplyPullRequestOTAUpdate() {
|
||||
currentChannel && !STANDARD_CHANNELS.includes(currentChannel),
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (manualRequestOwner === requestOwnerRef.current) {
|
||||
manualRequestOwner = undefined
|
||||
void serializeOTARequest(() => prepareOTAUpdateRequest())
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const tryApplyUpdate = async (
|
||||
channel: string,
|
||||
declaredAppVersion?: string | null,
|
||||
expected?: {sourceCommit?: string | null; publicationId?: string | null},
|
||||
) => {
|
||||
if (!/^pull-request-[1-9]\d*$/.test(channel)) {
|
||||
Alert.alert(
|
||||
'Invalid Deployment',
|
||||
'Only pull request deployments can be applied manually.',
|
||||
)
|
||||
return
|
||||
}
|
||||
if (manualRequestOwner) return
|
||||
|
||||
const requestOwner = Symbol(channel)
|
||||
manualRequestOwner = requestOwner
|
||||
requestOwnerRef.current = requestOwner
|
||||
setPending(true)
|
||||
const deploymentName = getDeploymentName(channel)
|
||||
let resolvedDeployment: OTAResolveResult | undefined
|
||||
|
||||
const checkForDeployment = async () => {
|
||||
await setExtraParamsPullRequest(channel)
|
||||
const res = await checkForUpdateAsync()
|
||||
try {
|
||||
resolvedDeployment = await resolvePullRequestDeployment({
|
||||
channel,
|
||||
currentUpdateId: currentlyRunning?.updateId,
|
||||
expected,
|
||||
})
|
||||
} catch (err) {
|
||||
logger.debug('Could not resolve OTA deployment diagnostics', {
|
||||
safeMessage: err,
|
||||
})
|
||||
}
|
||||
|
||||
if (resolvedDeployment?.status === 'already-running') return false
|
||||
if (resolvedDeployment?.status === 'runtime-mismatch') {
|
||||
Alert.alert(
|
||||
'Different Native Build Required',
|
||||
`The ${deploymentName} deployment requires a different native build. Install a compatible TestFlight build and try again.`,
|
||||
)
|
||||
return false
|
||||
}
|
||||
if (resolvedDeployment?.status === 'not-published') {
|
||||
Alert.alert(
|
||||
'No Deployment Available',
|
||||
`The ${deploymentName} deployment has not been published or is no longer available.`,
|
||||
)
|
||||
return false
|
||||
}
|
||||
if (resolvedDeployment?.status === 'stale-link') {
|
||||
Alert.alert(
|
||||
'Deployment Link Is Out of Date',
|
||||
`This link does not refer to the latest ${deploymentName} deployment. Open the newest link and try again.`,
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
const res = await checkForOTAUpdate(channel)
|
||||
if (!res.isAvailable) {
|
||||
if (
|
||||
res.reason ===
|
||||
@@ -161,16 +290,50 @@ export function useApplyPullRequestOTAUpdate() {
|
||||
return res.isAvailable
|
||||
}
|
||||
|
||||
const finishManualRequest = async (restoreDefault: boolean) => {
|
||||
if (manualRequestOwner !== requestOwner) return
|
||||
manualRequestOwner = undefined
|
||||
requestOwnerRef.current = undefined
|
||||
if (restoreDefault) {
|
||||
await serializeOTARequest(() => prepareOTAUpdateRequest()).catch(
|
||||
() => {},
|
||||
)
|
||||
}
|
||||
setPending(false)
|
||||
}
|
||||
|
||||
const restoreAfterCancellation = () => {
|
||||
void finishManualRequest(true)
|
||||
}
|
||||
|
||||
const applyUpdate = () => {
|
||||
setPending(true)
|
||||
void (async () => {
|
||||
let reloadSucceeded = false
|
||||
try {
|
||||
if (!(await checkForDeployment())) return
|
||||
const fetchedUpdate = await fetchUpdateAsync()
|
||||
const fetchedUpdate = await fetchOTAUpdate(channel)
|
||||
if (!fetchedUpdate.isNew) {
|
||||
throw new Error('Expo did not download a new update.')
|
||||
}
|
||||
|
||||
const manifest = fetchedUpdate.manifest as {
|
||||
extra?: {
|
||||
ota?: {sourceCommit?: unknown; publicationId?: unknown}
|
||||
}
|
||||
}
|
||||
const sourceCommit = manifest.extra?.ota?.sourceCommit
|
||||
const publicationId = manifest.extra?.ota?.publicationId
|
||||
if (
|
||||
(expected?.sourceCommit &&
|
||||
sourceCommit !== expected.sourceCommit) ||
|
||||
(expected?.publicationId &&
|
||||
publicationId !== expected.publicationId)
|
||||
) {
|
||||
throw new Error(
|
||||
'The deployment changed while it was being downloaded. Check the link again to review the newest deployment.',
|
||||
)
|
||||
}
|
||||
|
||||
device.set(['pendingOTAUpdate'], {
|
||||
attemptedAt: Date.now(),
|
||||
channel,
|
||||
@@ -180,6 +343,7 @@ export function useApplyPullRequestOTAUpdate() {
|
||||
await reloadAsync({
|
||||
reloadScreenOptions: splash(t.scheme),
|
||||
})
|
||||
reloadSucceeded = true
|
||||
} catch (e) {
|
||||
device.remove(['pendingOTAUpdate'])
|
||||
throw e
|
||||
@@ -192,7 +356,7 @@ export function useApplyPullRequestOTAUpdate() {
|
||||
`Could not apply the ${deploymentName} deployment: ${error}`,
|
||||
)
|
||||
} finally {
|
||||
setPending(false)
|
||||
await finishManualRequest(!reloadSucceeded)
|
||||
}
|
||||
})()
|
||||
}
|
||||
@@ -203,11 +367,17 @@ export function useApplyPullRequestOTAUpdate() {
|
||||
* update re-delivers the deep link that triggered it, and the same link may
|
||||
* also just be tapped again.
|
||||
*/
|
||||
setPending(true)
|
||||
try {
|
||||
if (!(await checkForDeployment())) return
|
||||
if (!(await checkForDeployment())) {
|
||||
await finishManualRequest(true)
|
||||
return
|
||||
}
|
||||
|
||||
if (declaredAppVersion && declaredAppVersion !== APP_VERSION) {
|
||||
if (
|
||||
!FINGERPRINT_RUNTIME_VERSION.test(nativeRuntimeVersion || '') &&
|
||||
declaredAppVersion &&
|
||||
declaredAppVersion !== APP_VERSION
|
||||
) {
|
||||
Alert.alert(
|
||||
'App Version Mismatch',
|
||||
`This OTA update was built for a different version of the app.\n\nCurrent app version: ${APP_VERSION}\nOTA app version: ${declaredAppVersion}\n\nApplying it anyway may cause the app to stop working and require a reinstall.`,
|
||||
@@ -215,6 +385,7 @@ export function useApplyPullRequestOTAUpdate() {
|
||||
{
|
||||
text: 'Cancel',
|
||||
style: 'cancel',
|
||||
onPress: restoreAfterCancellation,
|
||||
},
|
||||
{
|
||||
text: 'Apply Anyway',
|
||||
@@ -233,6 +404,7 @@ export function useApplyPullRequestOTAUpdate() {
|
||||
{
|
||||
text: 'Cancel',
|
||||
style: 'cancel',
|
||||
onPress: restoreAfterCancellation,
|
||||
},
|
||||
{
|
||||
text: 'Apply',
|
||||
@@ -242,14 +414,13 @@ export function useApplyPullRequestOTAUpdate() {
|
||||
],
|
||||
)
|
||||
} catch (e: unknown) {
|
||||
await finishManualRequest(true)
|
||||
const error = String(e)
|
||||
logger.error('Internal OTA Update Error', {error})
|
||||
Alert.alert(
|
||||
'Update Check Failed',
|
||||
`Could not check the ${deploymentName} deployment: ${error}`,
|
||||
)
|
||||
} finally {
|
||||
setPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,10 +431,9 @@ export function useApplyPullRequestOTAUpdate() {
|
||||
const restoreDefaultChannel = async () => {
|
||||
setPending(true)
|
||||
try {
|
||||
await setExtraParams()
|
||||
const res = await checkForUpdateAsync()
|
||||
const res = await checkForOTAUpdate()
|
||||
if (res.isAvailable) {
|
||||
await fetchUpdateAsync()
|
||||
await fetchOTAUpdate()
|
||||
await reloadAsync()
|
||||
} else {
|
||||
Alert.alert(
|
||||
@@ -351,14 +521,14 @@ export function useOTAUpdates() {
|
||||
const setCheckTimeout = useCallback(() => {
|
||||
timeout.current = setTimeout(async () => {
|
||||
try {
|
||||
await setExtraParams()
|
||||
if (isManualRequestPending()) return
|
||||
|
||||
logger.debug('Checking for update...')
|
||||
const res = await checkForUpdateAsync()
|
||||
const res = await checkForOTAUpdate()
|
||||
|
||||
if (res.isAvailable) {
|
||||
logger.debug('Attempting to fetch update...')
|
||||
await fetchUpdateAsync()
|
||||
await fetchOTAUpdate()
|
||||
} else {
|
||||
logger.debug('No update available.')
|
||||
}
|
||||
@@ -419,6 +589,7 @@ export function useOTAUpdates() {
|
||||
// If it's been 15 minutes since the last "minimize", we should feel comfortable updating the client since
|
||||
// chances are that there isn't anything important going on in the current session.
|
||||
if (lastMinimize.current <= Date.now() - MINIMUM_MINIMIZE_TIME) {
|
||||
if (isManualRequestPending()) return
|
||||
if (isUpdatePending) {
|
||||
await reloadAsync({
|
||||
reloadScreenOptions: splash(t.scheme),
|
||||
|
||||
@@ -5,6 +5,10 @@ export function useApplyPullRequestOTAUpdate() {
|
||||
tryApplyUpdate: async (
|
||||
_channel: string,
|
||||
_declaredAppVersion?: string | null,
|
||||
_expected?: {
|
||||
sourceCommit?: string | null
|
||||
publicationId?: string | null
|
||||
},
|
||||
) => {},
|
||||
restoreDefaultChannel: async () => {},
|
||||
isCurrentlyRunningPullRequestDeployment: false,
|
||||
|
||||
@@ -4,7 +4,11 @@ import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
import {useMutation, useQuery} from '@tanstack/react-query'
|
||||
|
||||
import {splash} from '#/lib/hooks/useOTAUpdates'
|
||||
import {
|
||||
checkForOTAUpdate,
|
||||
fetchOTAUpdate,
|
||||
splash,
|
||||
} from '#/lib/hooks/useOTAUpdates'
|
||||
import {useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotate'
|
||||
@@ -25,7 +29,7 @@ export function OTAInfo() {
|
||||
} = useQuery({
|
||||
queryKey: ['ota-info'],
|
||||
queryFn: async () => {
|
||||
const status = await Updates.checkForUpdateAsync()
|
||||
const status = await checkForOTAUpdate()
|
||||
return status.isAvailable
|
||||
},
|
||||
})
|
||||
@@ -33,7 +37,7 @@ export function OTAInfo() {
|
||||
const {mutate: fetchAndLaunchUpdate, isPending: isPendingUpdate} =
|
||||
useMutation({
|
||||
mutationFn: async () => {
|
||||
await Updates.fetchUpdateAsync()
|
||||
await fetchOTAUpdate()
|
||||
await Updates.reloadAsync({
|
||||
reloadScreenOptions: splash(t.scheme),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user