add canonical fingerprint ota export tooling

This commit is contained in:
Samuel Newman
2026-09-05 18:55:17 +03:00
parent ee32926ed5
commit badce43161
11 changed files with 1116 additions and 130 deletions
+4 -2
View File
@@ -35,6 +35,8 @@ module.exports = function (_config) {
const UPDATES_ENABLED = IS_TESTFLIGHT || IS_PRODUCTION
const USE_SENTRY = Boolean(process.env.SENTRY_AUTH_TOKEN)
const USE_FINGERPRINT_RUNTIME =
process.env.OTA_FINGERPRINT_PIPELINE_ENABLED === '1'
const IOS_ICON_FILE =
PLATFORM === 'web' // web build doesn't like .icon files
@@ -51,7 +53,7 @@ module.exports = function (_config) {
scheme: 'bluesky',
owner: 'blueskysocial',
runtimeVersion: {
policy: 'appVersion',
policy: USE_FINGERPRINT_RUNTIME ? 'fingerprint' : 'appVersion',
},
icon: './assets/app-icons/ios_icon_default_next.png',
userInterfaceStyle: 'automatic',
@@ -285,7 +287,7 @@ module.exports = function (_config) {
{
name: 'MCEmojiPicker',
git: 'https://github.com/bluesky-social/MCEmojiPicker.git',
branch: 'main',
commit: 'e2c2e4917df25c34d8cc6ca04918fe026588d828',
},
],
},
+25
View File
@@ -0,0 +1,25 @@
// @ts-check
module.exports = {
hashAlgorithm: 'sha1',
sourceSkips: [
'ExpoConfigVersions',
'PackageJsonAndroidAndIosScriptsIfNotContainRun',
],
extraSources: [
{
type: 'file',
filePath: 'scripts/ota/fingerprint-policy.json',
overrideHashKey: 'fingerprint-policy.json',
reasons: ['Repository OTA compatibility policy'],
},
...(process.env.EAS_BUILD_PLATFORM === 'ios'
? ['BlueskyClip', 'BlueskyNSE', 'Share-with-Bluesky'].map(name => ({
type: 'dir',
filePath: `modules/${name}`,
overrideHashKey: `native-extension/${name}`,
reasons: ['Native extension sources copied by config plugins'],
}))
: []),
],
}
Regular → Executable
+118 -92
View File
@@ -1,104 +1,130 @@
#!/usr/bin/env node
/* oxlint-disable import/no-nodejs-modules -- This is a Node-only build script. */
const crypto = require('crypto')
const fs = require('fs')
const fsp = fs.promises
const path = require('path')
const DIST_DIR = './dist'
const BUNDLES_DIR = '/_expo/static/js'
const IOS_BUNDLE_DIR = path.join(DIST_DIR, BUNDLES_DIR, '/ios')
const ANDROID_BUNDLE_DIR = path.join(DIST_DIR, BUNDLES_DIR, '/android')
const METADATA_PATH = path.join(DIST_DIR, '/metadata.json')
const DEST_DIR = './bundleTempDir'
// Weird, don't feel like figuring out _why_ it wants this
const METADATA = require(`../${METADATA_PATH}`)
const IOS_METADATA_ASSETS = METADATA.fileMetadata.ios.assets
const ANDROID_METADATA_ASSETS = METADATA.fileMetadata.android.assets
const getMd5 = async path => {
return new Promise(res => {
const hash = crypto.createHash('md5')
const rStream = fs.createReadStream(path)
rStream.on('data', data => {
hash.update(data)
})
rStream.on('end', () => {
res(hash.digest('hex'))
})
})
/** @param {string[]} argv */
function args(argv) {
/** @type {Record<string, string>} */
const out = {}
for (let i = 0; i < argv.length; i += 2) {
if (!argv[i]?.startsWith('--') || argv[i + 1] == null)
throw new Error(`Invalid argument: ${argv[i]}`)
out[argv[i].slice(2)] = argv[i + 1]
}
return out
}
async function digest(file) {
const hash = crypto.createHash('md5')
for await (const chunk of fs.createReadStream(file)) hash.update(chunk)
return hash.digest('hex')
}
async function childOf(parent, child) {
const relative = path.relative(
await fsp.realpath(parent),
await fsp.realpath(child),
)
return (
relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative)
)
}
const moveFiles = async () => {
console.log('Making directory...')
await fsp.mkdir(DEST_DIR)
await fsp.mkdir(path.join(DEST_DIR, '/assets'))
console.log('Getting ios md5...')
const iosCurrPath = path.join(
IOS_BUNDLE_DIR,
(await fsp.readdir(IOS_BUNDLE_DIR))[0],
)
const iosMd5 = await getMd5(iosCurrPath)
const iosNewPath = `bundles/${iosMd5}.bundle`
console.log('Copying ios bundle...')
await fsp.cp(iosCurrPath, path.join(DEST_DIR, iosNewPath))
console.log('Getting android md5...')
const androidCurrPath = path.join(
ANDROID_BUNDLE_DIR,
(await fsp.readdir(ANDROID_BUNDLE_DIR))[0],
)
const androidMd5 = await getMd5(androidCurrPath)
const androidNewPath = `bundles/${androidMd5}.bundle`
console.log('Copying android bundle...')
await fsp.cp(androidCurrPath, path.join(DEST_DIR, androidNewPath))
const iosAssets = []
const androidAssets = []
console.log('Getting ios asset md5s and moving them...')
for (const asset of IOS_METADATA_ASSETS) {
const currPath = path.join(DIST_DIR, asset.path)
const md5 = await getMd5(currPath)
const withExtPath = `assets/${md5}.${asset.ext}`
iosAssets.push(withExtPath)
await fsp.cp(currPath, path.join(DEST_DIR, withExtPath))
/**
* @param {string} platform
* @param {string} sourceDir
* @param {string} destinationDir
*/
async function packagePlatform(platform, sourceDir, destinationDir) {
const metadataPath = path.join(sourceDir, 'metadata.json')
if (!(await childOf(sourceDir, metadataPath)))
throw new Error('Metadata escapes export directory')
/** @type {{fileMetadata?: Record<string, {bundle: string, assets?: {path: string, ext: string}[]}>}} */
const metadata = JSON.parse(await fsp.readFile(metadataPath, 'utf8'))
const input = metadata.fileMetadata?.[platform]
if (!input || typeof input.bundle !== 'string')
throw new Error(`${metadataPath} has no explicit ${platform} bundle`)
const outputDir = path.join(destinationDir, platform)
await fsp.mkdir(path.join(outputDir, 'bundles'), {recursive: true})
await fsp.mkdir(path.join(outputDir, 'assets'), {recursive: true})
const bundleSource = path.resolve(sourceDir, input.bundle)
if (!(await childOf(sourceDir, bundleSource)))
throw new Error(`Bundle escapes export directory: ${input.bundle}`)
const bundle = `bundles/${await digest(bundleSource)}.bundle`
await fsp.copyFile(bundleSource, path.join(outputDir, bundle))
const assets = []
for (const asset of input.assets ?? []) {
if (
typeof asset.path !== 'string' ||
typeof asset.ext !== 'string' ||
!/^[a-zA-Z0-9]+$/.test(asset.ext)
)
throw new Error(`Invalid ${platform} asset metadata`)
const source = path.resolve(sourceDir, asset.path)
if (!(await childOf(sourceDir, source)))
throw new Error(`Asset escapes export directory: ${asset.path}`)
const target = `assets/${await digest(source)}.${asset.ext}`
await fsp.copyFile(source, path.join(outputDir, target))
assets.push(target)
}
console.log('Getting android asset md5s and moving them...')
for (const asset of ANDROID_METADATA_ASSETS) {
const currPath = path.join(DIST_DIR, asset.path)
const md5 = await getMd5(currPath)
const withExtPath = `assets/${md5}.${asset.ext}`
androidAssets.push(withExtPath)
await fsp.cp(currPath, path.join(DEST_DIR, withExtPath))
}
const result = {
version: 0,
bundler: 'metro',
fileMetadata: {
ios: {
bundle: iosNewPath,
assets: iosAssets,
},
android: {
bundle: androidNewPath,
assets: androidAssets,
},
},
}
console.log('Writing metadata...')
await fsp.writeFile(
path.join(DEST_DIR, 'metadata.json'),
JSON.stringify(result),
path.join(outputDir, 'metadata.json'),
`${JSON.stringify({version: 0, bundler: 'metro', fileMetadata: {[platform]: {bundle, assets}}})}\n`,
)
console.log('Finished!')
console.log('Metadata:', result)
const rollbackPath = path.join(sourceDir, 'rollback')
if (fs.existsSync(rollbackPath)) {
if (!(await childOf(sourceDir, rollbackPath)))
throw new Error('Rollback marker escapes export directory')
await fsp.copyFile(rollbackPath, path.join(outputDir, 'rollback'))
}
}
moveFiles()
async function main() {
const options = args(process.argv.slice(2))
if (!options['release-file']) throw new Error('--release-file is required')
const releasePath = path.resolve(options['release-file'])
const base = path.dirname(releasePath)
/** @type {{schemaVersion: number, bundleVersion: string, platforms: Record<string, {bundleDirectory: string}>}} */
const release = JSON.parse(await fsp.readFile(releasePath, 'utf8'))
if (
release.schemaVersion !== 1 ||
!release.platforms ||
!/^[0-9]{13}$/.test(release.bundleVersion ?? '')
)
throw new Error('Invalid OTA export schema')
const outputDir = options['output-dir']
? path.resolve(options['output-dir'])
: await fsp.mkdtemp(path.join(base, 'ota-bundles-'))
const outputRelative = path.relative(base, outputDir)
if (
!outputRelative ||
outputRelative.startsWith('..') ||
path.isAbsolute(outputRelative)
)
throw new Error('Packaged output must be inside the release directory')
if (options['output-dir']) await fsp.mkdir(outputDir, {recursive: false})
for (const [platform, entry] of Object.entries(release.platforms)) {
if (
!['ios', 'android'].includes(platform) ||
typeof entry.bundleDirectory !== 'string'
)
throw new Error(`Invalid platform entry: ${platform}`)
const sourceDir = path.resolve(base, entry.bundleDirectory)
if (sourceDir !== base && !(await childOf(base, sourceDir)))
throw new Error('Export directory escapes release directory')
await packagePlatform(platform, sourceDir, outputDir)
entry.bundleDirectory = path.relative(base, path.join(outputDir, platform))
}
const packagedReleasePath = `${outputDir}.json`
await fsp.writeFile(
packagedReleasePath,
`${JSON.stringify(release, null, 2)}\n`,
{flag: 'wx'},
)
process.stdout.write(`${packagedReleasePath}\n`)
}
main().catch(error => {
console.error(error instanceof Error ? error.message : error)
process.exitCode = 1
})
+18 -36
View File
@@ -3,42 +3,24 @@ set -o errexit
set -o pipefail
set -o nounset
# Publishes the just-exported Expo bundle to the denis OTA service (S3) via the
# `denis publish` CLI.
# Expects: the `denis` binary on PATH (setup-denis action), ambient AWS creds
# (configure-aws-credentials OIDC), and BSKY_IOS_BUILD_NUMBER /
# BSKY_ANDROID_VERSION_CODE from the use-build-number wrapper.
rm -rf bundleTempDir
echo "Assembling bundle directory..."
node scripts/bundleUpdate.js
if [ -z "$RUNTIME_VERSION" ]; then
RUNTIME_VERSION=$(cat package.json | jq '.version' -r)
fi
BUNDLE_VERSION=$(date +%s)
DENIS_CDN_DOMAIN="${DENIS_CDN_DOMAIN:-updates.bsky.app}"
DENIS_S3_BUCKET="${DENIS_S3_BUCKET:-bsky-denis-ota-prod}"
PUBLISH_MODE="${DENIS_PUBLISH_MODE:-legacy}"
echo "Publishing to denis..."
echo " runtime-version: $RUNTIME_VERSION"
echo " bundle-version: $BUNDLE_VERSION"
echo " channel: $CHANNEL_NAME"
echo " ios-build-number: $BSKY_IOS_BUILD_NUMBER"
echo " android-build-number: $BSKY_ANDROID_VERSION_CODE"
echo " cdn-domain: $DENIS_CDN_DOMAIN"
echo " s3-bucket: $DENIS_S3_BUCKET"
denis publish \
--bundle-dir bundleTempDir \
--runtime-version "$RUNTIME_VERSION" \
--bundle-version "$BUNDLE_VERSION" \
--channel "$CHANNEL_NAME" \
--ios-build-number "$BSKY_IOS_BUILD_NUMBER" \
--android-build-number "$BSKY_ANDROID_VERSION_CODE" \
--cdn-domain "$DENIS_CDN_DOMAIN" \
--s3-bucket "$DENIS_S3_BUCKET"
rm -rf bundleTempDir
if [ "$PUBLISH_MODE" = "structured" ]; then
RELEASE_FILE="${1:?Usage: denisPublish.sh ota-export.json}"
node scripts/ota/validate-release.mjs --release-file "$RELEASE_FILE"
PACKAGED_RELEASE_FILE="$(node scripts/bundleUpdate.js --release-file "$RELEASE_FILE")"
node scripts/ota/validate-release.mjs --release-file "$PACKAGED_RELEASE_FILE"
denis publish --release-file "$PACKAGED_RELEASE_FILE" --cdn-domain "$DENIS_CDN_DOMAIN" --s3-bucket "$DENIS_S3_BUCKET"
# Retain the unique packaged output for debugging and idempotent CLI retries.
elif [ "$PUBLISH_MODE" = "legacy" ]; then
: "${CHANNEL_NAME:?CHANNEL_NAME is required}"
: "${BSKY_IOS_BUILD_NUMBER:?BSKY_IOS_BUILD_NUMBER is required}"
: "${BSKY_ANDROID_VERSION_CODE:?BSKY_ANDROID_VERSION_CODE is required}"
: "Legacy mode is retained by scripts/denisPublishLegacy.sh during rollout"
exec bash scripts/denisPublishLegacy.sh
else
echo "DENIS_PUBLISH_MODE must be structured or legacy" >&2
exit 1
fi
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
set -o errexit
set -o pipefail
set -o nounset
# Frozen numeric-runtime publication path. Remove only after legacy client support ends.
rm -rf bundleTempDir
node scripts/ota/package-legacy-export.js
RUNTIME_VERSION="${RUNTIME_VERSION:-$(jq -r .version package.json)}"
BUNDLE_VERSION="${BUNDLE_VERSION:-$(date +%s)000}"
denis publish --bundle-dir bundleTempDir --runtime-version "$RUNTIME_VERSION" --bundle-version "$BUNDLE_VERSION" --channel "$CHANNEL_NAME" --ios-build-number "$BSKY_IOS_BUILD_NUMBER" --android-build-number "$BSKY_ANDROID_VERSION_CODE" --cdn-domain "${DENIS_CDN_DOMAIN:-updates.bsky.app}" --s3-bucket "${DENIS_S3_BUCKET:-bsky-denis-ota-prod}"
rm -rf bundleTempDir
+10
View File
@@ -0,0 +1,10 @@
{
"schemaVersion": 1,
"fingerprintPolicyVersion": 1,
"runtimeFormat": "sha1-lowercase-hex",
"normalizedFields": [
"expo.version",
"expo.ios.buildNumber",
"expo.android.versionCode"
]
}
+276
View File
@@ -0,0 +1,276 @@
import assert from 'node:assert/strict'
import crypto from 'node:crypto'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import {spawnSync} from 'node:child_process'
import test from 'node:test'
const root = path.resolve(import.meta.dirname, '../..')
const packager = path.join(root, 'scripts/bundleUpdate.js')
const publisher = path.join(root, 'scripts/denisPublish.sh')
const sourceCommit = 'a'.repeat(40)
function writeJson(filename, value) {
fs.writeFileSync(filename, `${JSON.stringify(value, null, 2)}\n`)
}
function report(platform, runtime) {
return {
schemaVersion: 1,
platform,
nativeProfile: 'testflight',
sourceCommit,
runtimeVersion: runtime,
fingerprintPolicyVersion: 1,
fingerprintToolVersion: '0.20.8',
toolVersions: {
expo: '57.0.8',
expoUpdates: '57.0.10',
node: 'v24.19.0',
packageManager: 'pnpm@11.21.0',
},
fingerprintSources: [
{type: 'contents', id: 'expoConfig', hash: 'b'.repeat(40)},
{
type: 'file',
filePath: 'scripts/ota/fingerprint-policy.json',
hash: 'c'.repeat(40),
},
...(platform === 'ios'
? ['BlueskyClip', 'BlueskyNSE', 'Share-with-Bluesky'].map(name => ({
type: 'dir',
filePath: `modules/${name}`,
hash: 'd'.repeat(40),
}))
: [
{
type: 'file',
filePath: 'google-services.json',
hash: 'e'.repeat(40),
},
]),
],
}
}
function fixture(platforms = ['ios', 'android']) {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ota-package-'))
const release = {
schemaVersion: 1,
sourceCommit,
channel: 'testflight',
nativeProfile: 'testflight',
bundleVersion: '1788537600000',
platforms: {},
}
for (const platform of platforms) {
const runtime = platform === 'ios' ? '1'.repeat(40) : '2'.repeat(40)
const exportDirectory = path.join(directory, `export-${platform}`)
fs.mkdirSync(path.join(exportDirectory, 'bundles'), {recursive: true})
fs.mkdirSync(path.join(exportDirectory, 'assets'), {recursive: true})
fs.writeFileSync(path.join(exportDirectory, 'bundles/00-decoy.js'), 'decoy')
fs.writeFileSync(
path.join(exportDirectory, 'bundles/chosen.js'),
`${platform}-chosen`,
)
fs.writeFileSync(
path.join(exportDirectory, 'assets/icon.png'),
`${platform}-asset`,
)
writeJson(path.join(exportDirectory, 'metadata.json'), {
version: 0,
bundler: 'metro',
fileMetadata: {
[platform]: {
bundle: 'bundles/chosen.js',
assets: [{path: 'assets/icon.png', ext: 'png'}],
},
},
})
writeJson(
path.join(directory, `${platform}-report.json`),
report(platform, runtime),
)
release.platforms[platform] = {
runtimeVersion: runtime,
fingerprintReportRef: `${platform}-report.json`,
bundleDirectory: `export-${platform}`,
}
}
const releaseFile = path.join(directory, 'ota-export.json')
writeJson(releaseFile, release)
return {directory, releaseFile}
}
function packageRelease(releaseFile) {
const result = spawnSync(
process.execPath,
[packager, '--release-file', releaseFile],
{
encoding: 'utf8',
},
)
if (result.status !== 0) throw new Error(result.stderr.trim())
return result.stdout.trim()
}
function md5(value) {
return crypto.createHash('md5').update(value).digest('hex')
}
test('packages the metadata-referenced bundles per platform without mutating input', () => {
const value = fixture()
const before = fs.readFileSync(value.releaseFile, 'utf8')
const packagedFile = packageRelease(value.releaseFile)
const packaged = JSON.parse(fs.readFileSync(packagedFile))
assert.equal(fs.readFileSync(value.releaseFile, 'utf8'), before)
for (const platform of ['ios', 'android']) {
const bundleDirectory = path.resolve(
value.directory,
packaged.platforms[platform].bundleDirectory,
)
assert.equal(
fs.readFileSync(
path.join(
bundleDirectory,
`bundles/${md5(`${platform}-chosen`)}.bundle`,
),
'utf8',
),
`${platform}-chosen`,
)
assert.equal(
path.resolve(
path.dirname(packagedFile),
packaged.platforms[platform].fingerprintReportRef,
),
path.join(value.directory, `${platform}-report.json`),
)
}
const retry = packageRelease(value.releaseFile)
assert.notEqual(retry, packagedFile)
assert.equal(fs.readFileSync(value.releaseFile, 'utf8'), before)
})
test('preserves a rollback marker', () => {
const value = fixture(['ios'])
fs.writeFileSync(
path.join(value.directory, 'export-ios/rollback'),
'rollback\n',
)
const packaged = JSON.parse(
fs.readFileSync(packageRelease(value.releaseFile)),
)
assert.equal(
fs.readFileSync(
path.resolve(
value.directory,
packaged.platforms.ios.bundleDirectory,
'rollback',
),
'utf8',
),
'rollback\n',
)
})
test('rejects malformed metadata and path, symlink, and extension escapes', () => {
const mutations = {
'malformed metadata': value =>
fs.writeFileSync(
path.join(value.directory, 'export-ios/metadata.json'),
'{',
),
'bundle escape': value => {
const metadata = JSON.parse(
fs.readFileSync(path.join(value.directory, 'export-ios/metadata.json')),
)
metadata.fileMetadata.ios.bundle = '../outside.js'
fs.writeFileSync(path.join(value.directory, 'outside.js'), 'outside')
writeJson(
path.join(value.directory, 'export-ios/metadata.json'),
metadata,
)
},
'bundle symlink': value => {
fs.rmSync(path.join(value.directory, 'export-ios/bundles/chosen.js'))
fs.writeFileSync(path.join(value.directory, 'outside.js'), 'outside')
fs.symlinkSync(
path.join(value.directory, 'outside.js'),
path.join(value.directory, 'export-ios/bundles/chosen.js'),
)
},
'asset escape': value => {
const metadata = JSON.parse(
fs.readFileSync(path.join(value.directory, 'export-ios/metadata.json')),
)
metadata.fileMetadata.ios.assets[0].path = '../outside.png'
fs.writeFileSync(path.join(value.directory, 'outside.png'), 'outside')
writeJson(
path.join(value.directory, 'export-ios/metadata.json'),
metadata,
)
},
'asset symlink': value => {
fs.rmSync(path.join(value.directory, 'export-ios/assets/icon.png'))
fs.writeFileSync(path.join(value.directory, 'outside.png'), 'outside')
fs.symlinkSync(
path.join(value.directory, 'outside.png'),
path.join(value.directory, 'export-ios/assets/icon.png'),
)
},
'extension escape': value => {
const metadata = JSON.parse(
fs.readFileSync(path.join(value.directory, 'export-ios/metadata.json')),
)
metadata.fileMetadata.ios.assets[0].ext = '../js'
writeJson(
path.join(value.directory, 'export-ios/metadata.json'),
metadata,
)
},
}
for (const [name, mutate] of Object.entries(mutations)) {
const value = fixture(['ios'])
mutate(value)
assert.throws(() => packageRelease(value.releaseFile), undefined, name)
}
})
test('structured wrapper validates and invokes only release-file publishing', () => {
const value = fixture(['ios'])
const bin = fs.mkdtempSync(path.join(os.tmpdir(), 'fake-denis-'))
const log = path.join(bin, 'args.json')
const denis = path.join(bin, 'denis')
fs.writeFileSync(
denis,
'#!/bin/sh\nprintf "%s\\n" "$@" > "$DENIS_TEST_LOG"\n',
{mode: 0o755},
)
const result = spawnSync('bash', [publisher, value.releaseFile], {
cwd: root,
encoding: 'utf8',
env: {
...process.env,
PATH: `${bin}:${process.env.PATH}`,
DENIS_TEST_LOG: log,
DENIS_PUBLISH_MODE: 'structured',
},
})
assert.equal(result.status, 0, result.stderr)
const args = fs.readFileSync(log, 'utf8').trim().split('\n')
assert.deepEqual(args.slice(0, 2), ['publish', '--release-file'])
assert.notEqual(args[2], value.releaseFile)
assert.ok(fs.existsSync(args[2]))
})
test('wrapper defaults existing callers to legacy mode', () => {
const result = spawnSync('bash', [publisher], {
cwd: root,
encoding: 'utf8',
env: {...process.env, DENIS_PUBLISH_MODE: ''},
})
assert.notEqual(result.status, 0)
assert.match(result.stderr, /CHANNEL_NAME is required/)
})
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env node
/* oxlint-disable import/no-nodejs-modules -- This is a Node-only build script. */
const crypto = require('crypto')
const fs = require('fs')
const fsp = fs.promises
const path = require('path')
const source = path.resolve('dist')
const destination = path.resolve('bundleTempDir')
async function hash(file) {
const value = crypto.createHash('md5')
for await (const chunk of fs.createReadStream(file)) value.update(chunk)
return value.digest('hex')
}
async function main() {
/** @type {{fileMetadata: Record<string, {bundle: string, assets?: {path: string, ext: string}[]}>}} */
const metadata = JSON.parse(
await fsp.readFile(path.join(source, 'metadata.json'), 'utf8'),
)
await fsp.mkdir(path.join(destination, 'bundles'), {recursive: true})
await fsp.mkdir(path.join(destination, 'assets'), {recursive: true})
const result = {version: 0, bundler: 'metro', fileMetadata: {}}
for (const platform of ['ios', 'android']) {
const input = metadata.fileMetadata?.[platform]
if (!input?.bundle)
throw new Error(
`Missing explicit ${platform} bundle in dist/metadata.json`,
)
const bundleSource = path.resolve(source, input.bundle)
const bundle = `bundles/${await hash(bundleSource)}.bundle`
await fsp.copyFile(bundleSource, path.join(destination, bundle))
const assets = []
for (const asset of input.assets ?? []) {
const assetSource = path.resolve(source, asset.path)
const target = `assets/${await hash(assetSource)}.${asset.ext}`
await fsp.copyFile(assetSource, path.join(destination, target))
assets.push(target)
}
result.fileMetadata[platform] = {bundle, assets}
}
await fsp.writeFile(
path.join(destination, 'metadata.json'),
JSON.stringify(result),
)
}
main().catch(error => {
console.error(error)
process.exitCode = 1
})
+190
View File
@@ -0,0 +1,190 @@
#!/usr/bin/env node
import fs from 'node:fs/promises'
import path from 'node:path'
import process from 'node:process'
import {execFileSync} from 'node:child_process'
import {createRequire} from 'node:module'
const require = createRequire(import.meta.url)
const root = path.resolve(import.meta.dirname, '../..')
function fail(message) {
throw new Error(message)
}
function parseArgs(argv) {
const result = {}
for (let i = 0; i < argv.length; i += 2) {
const key = argv[i]
const value = argv[i + 1]
if (!key?.startsWith('--') || value == null)
fail(`Invalid argument: ${key}`)
result[key.slice(2)] = value
}
return result
}
async function assertFile(relativePath) {
const absolutePath = path.join(root, relativePath)
const stat = await fs.stat(absolutePath).catch(() => null)
if (!stat?.isFile())
fail(`Required fingerprint input is missing: ${relativePath}`)
}
async function main() {
const args = parseArgs(process.argv.slice(2))
const platform = args.platform
const nativeProfile = args.profile
if (!['ios', 'android'].includes(platform))
fail('--platform must be ios or android')
if (!['production', 'testflight'].includes(nativeProfile)) {
fail('--profile must be production or testflight')
}
if (
process.env.EAS_BUILD_PLATFORM &&
process.env.EAS_BUILD_PLATFORM !== platform
) {
fail(
`EAS_BUILD_PLATFORM=${process.env.EAS_BUILD_PLATFORM} conflicts with ${platform}`,
)
}
if (
process.env.EXPO_PUBLIC_ENV &&
process.env.EXPO_PUBLIC_ENV !== nativeProfile
) {
fail(
`EXPO_PUBLIC_ENV=${process.env.EXPO_PUBLIC_ENV} conflicts with ${nativeProfile}`,
)
}
process.env.EAS_BUILD_PLATFORM = platform
process.env.EXPO_PUBLIC_ENV = nativeProfile
process.env.OTA_FINGERPRINT_PIPELINE_ENABLED = '1'
const sourceCommit = args['source-commit'] ?? process.env.GITHUB_SHA
if (!/^[0-9a-f]{40}$/.test(sourceCommit ?? ''))
fail('--source-commit or GITHUB_SHA must be a full lowercase SHA')
const checkoutCommit = execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: root,
encoding: 'utf8',
}).trim()
if (sourceCommit !== checkoutCommit)
fail(
`sourceCommit ${sourceCommit} does not match checkout HEAD ${checkoutCommit}`,
)
for (const file of [
'app.config.js',
'fingerprint.config.js',
'scripts/ota/fingerprint-policy.json',
...(platform === 'android' ? ['google-services.json'] : []),
])
await assertFile(file)
if (platform === 'ios') {
for (const name of ['BlueskyClip', 'BlueskyNSE', 'Share-with-Bluesky']) {
const stat = await fs
.stat(path.join(root, 'modules', name))
.catch(() => null)
if (!stat?.isDirectory())
fail(`Required native extension is missing: modules/${name}`)
}
}
delete require.cache[
require.resolve(path.join(root, 'fingerprint.config.js'))
]
const fingerprintConfig = require(path.join(root, 'fingerprint.config.js'))
if (
fingerprintConfig.hashAlgorithm !== 'sha1' ||
!Array.isArray(fingerprintConfig.extraSources)
) {
fail('fingerprint.config.js did not load the canonical policy')
}
const {getConfig} = require('expo/config')
const {exp} = getConfig(root, {
isPublicConfig: true,
skipSDKVersionRequirement: true,
})
if (exp.runtimeVersion?.policy !== 'fingerprint')
fail('Expo runtimeVersion policy must be fingerprint')
const {
resolveRuntimeVersionAsync,
} = require('expo-updates/utils/build/resolveRuntimeVersionAsync')
const resolved = await resolveRuntimeVersionAsync(
root,
platform,
{silent: true},
{workflowOverride: 'managed'},
)
if (!/^[0-9a-f]{40}$/.test(resolved.runtimeVersion ?? ''))
fail('Resolver returned an invalid fingerprint runtime')
if (
!Array.isArray(resolved.fingerprintSources) ||
resolved.fingerprintSources.length === 0
) {
fail('Resolver returned no fingerprint sources')
}
const policySource = resolved.fingerprintSources.find(
source =>
source.type === 'file' &&
source.filePath?.endsWith('fingerprint-policy.json'),
)
if (!policySource?.hash)
fail('Fingerprint report omitted the compatibility policy source')
const expoConfigSource = resolved.fingerprintSources.find(
source => source.type === 'contents' && source.id === 'expoConfig',
)
if (!expoConfigSource?.hash)
fail('Fingerprint report omitted resolved Expo config')
if (platform === 'ios') {
for (const name of ['BlueskyClip', 'BlueskyNSE', 'Share-with-Bluesky']) {
const source = resolved.fingerprintSources.find(
candidate =>
candidate.type === 'dir' && candidate.filePath === `modules/${name}`,
)
if (!source?.hash) fail(`Fingerprint report omitted modules/${name}`)
}
} else {
const googleServicesSource = resolved.fingerprintSources.find(
source =>
source.type === 'file' && source.filePath === 'google-services.json',
)
if (!googleServicesSource?.hash)
fail('Fingerprint report omitted google-services.json')
}
const projectPackage = require(path.join(root, 'package.json'))
const packageManager =
projectPackage.packageManager ??
`${projectPackage.devEngines.packageManager.name}@${projectPackage.devEngines.packageManager.version}`
const report = {
schemaVersion: 1,
platform,
nativeProfile,
sourceCommit,
runtimeVersion: resolved.runtimeVersion,
fingerprintPolicyVersion: 1,
fingerprintToolVersion: require('@expo/fingerprint/package.json').version,
toolVersions: {
expo: require('expo/package.json').version,
expoUpdates: require('expo-updates/package.json').version,
node: process.version,
packageManager,
},
fingerprintSources: resolved.fingerprintSources,
}
const json = `${JSON.stringify(report, null, 2)}\n`
if (args.output) {
await fs.mkdir(path.dirname(path.resolve(args.output)), {recursive: true})
await fs.writeFile(path.resolve(args.output), json)
} else {
process.stdout.write(json)
}
}
main().catch(error => {
console.error(error instanceof Error ? error.message : error)
process.exitCode = 1
})
+226
View File
@@ -0,0 +1,226 @@
#!/usr/bin/env node
import fs from 'node:fs/promises'
import path from 'node:path'
import process from 'node:process'
function fail(message) {
throw new Error(message)
}
async function readJson(base, reference, label) {
const resolved = await resolvePath(base, reference, label)
return JSON.parse(await fs.readFile(resolved, 'utf8'))
}
async function resolvePath(base, reference, label) {
if (typeof reference !== 'string' || path.isAbsolute(reference))
fail(`${label} must be relative`)
const lexicalRelative = path.relative(base, path.resolve(base, reference))
if (lexicalRelative.startsWith('..') || path.isAbsolute(lexicalRelative))
fail(`${label} escapes the release directory`)
const realBase = await fs.realpath(base)
const resolved = await fs.realpath(path.resolve(base, reference))
const relative = path.relative(realBase, resolved)
if (relative.startsWith('..') || path.isAbsolute(relative))
fail(`${label} escapes the release directory`)
return resolved
}
function validateFingerprintReport(report, expected, label) {
if (
report.schemaVersion !== 1 ||
report.platform !== expected.platform ||
report.nativeProfile !== expected.nativeProfile ||
report.sourceCommit !== expected.sourceCommit ||
report.runtimeVersion !== expected.runtimeVersion ||
report.fingerprintPolicyVersion !== 1 ||
typeof report.fingerprintToolVersion !== 'string' ||
report.fingerprintToolVersion.length === 0 ||
!report.toolVersions ||
!['expo', 'expoUpdates', 'node', 'packageManager'].every(
key =>
typeof report.toolVersions[key] === 'string' &&
report.toolVersions[key].length > 0,
) ||
!Array.isArray(report.fingerprintSources) ||
report.fingerprintSources.length === 0 ||
report.fingerprintSources.some(
source =>
!source ||
!['file', 'dir', 'contents'].includes(source.type) ||
!Object.hasOwn(source, 'hash') ||
(source.hash !== null && !/^[0-9a-f]{40}$/.test(source.hash)),
)
)
fail(`${label} is incomplete or inconsistent`)
if (
!report.fingerprintSources.some(
source =>
source.type === 'contents' && source.id === 'expoConfig' && source.hash,
)
)
fail(`${label} omits resolved Expo config`)
if (
!report.fingerprintSources.some(
source =>
source.type === 'file' &&
source.filePath === 'scripts/ota/fingerprint-policy.json' &&
source.hash,
)
)
fail(`${label} omits the fingerprint policy`)
const requiredPlatformSources =
expected.platform === 'ios'
? [
'modules/BlueskyClip',
'modules/BlueskyNSE',
'modules/Share-with-Bluesky',
]
: ['google-services.json']
for (const filePath of requiredPlatformSources) {
if (
!report.fingerprintSources.some(
source => source.filePath === filePath && source.hash,
)
)
fail(`${label} omits ${filePath}`)
}
}
async function main() {
const i = process.argv.indexOf('--release-file')
if (i < 0 || !process.argv[i + 1]) fail('--release-file is required')
const releasePath = path.resolve(process.argv[i + 1])
const base = path.dirname(releasePath)
const release = JSON.parse(await fs.readFile(releasePath, 'utf8'))
if (
release.schemaVersion !== 1 ||
!/^[0-9a-f]{40}$/.test(release.sourceCommit ?? '')
)
fail('Invalid release sourceCommit')
if (!/^[0-9]{13}$/.test(release.bundleVersion ?? ''))
fail('bundleVersion must be a 13-digit Unix millisecond string')
if (!['production', 'testflight'].includes(release.nativeProfile))
fail('Invalid nativeProfile')
if (!(
release.channel === 'production' ||
release.channel === 'testflight' ||
/^pull-request-[1-9][0-9]*$/.test(release.channel ?? '')
))
fail('Invalid channel')
if (
release.channel === 'production' &&
release.nativeProfile !== 'production'
)
fail('Production channel requires the production native profile')
if (
release.channel !== 'production' &&
release.nativeProfile !== 'testflight'
)
fail(`${release.channel} requires the testflight native profile`)
if (!release.platforms || Object.keys(release.platforms).length === 0)
fail('At least one platform is required')
const verification = {schemaVersion: 1, valid: true, platforms: {}}
for (const [platform, entry] of Object.entries(release.platforms)) {
if (
!['ios', 'android'].includes(platform) ||
!/^[0-9a-f]{40}$/.test(entry.runtimeVersion ?? '')
)
fail(`Invalid ${platform} entry`)
const report = await readJson(
base,
entry.fingerprintReportRef,
`${platform} fingerprintReportRef`,
)
validateFingerprintReport(
report,
{
platform,
nativeProfile: release.nativeProfile,
sourceCommit: release.sourceCommit,
runtimeVersion: entry.runtimeVersion,
},
`${platform} export fingerprint report`,
)
await fs
.stat(
await resolvePath(
base,
entry.bundleDirectory,
`${platform} bundleDirectory`,
),
)
.catch(() => fail(`${platform} bundleDirectory is missing`))
if (release.channel === 'production') {
if (!/^[0-9]+$/.test(entry.targetNativeBuildNumber ?? ''))
fail(`Production ${platform} target is required`)
if (entry.targetNativeBuildReceiptRef != null) {
const receiptPath = await resolvePath(
base,
entry.targetNativeBuildReceiptRef,
`${platform} receipt ref`,
)
const receipt = JSON.parse(await fs.readFile(receiptPath, 'utf8'))
const receiptReport = await readJson(
path.dirname(receiptPath),
receipt.fingerprintReportRef,
`${platform} receipt report ref`,
)
if (
receipt.schemaVersion !== 1 ||
receipt.platform !== platform ||
receipt.nativeProfile !== 'production' ||
receipt.defaultChannel !== 'production' ||
receipt.nativeBuildNumber !== entry.targetNativeBuildNumber ||
receipt.runtimeVersion !== entry.runtimeVersion ||
receipt.fingerprintPolicyVersion !== 1 ||
typeof receipt.fingerprintToolVersion !== 'string' ||
!/^[0-9a-f]{40}$/.test(receipt.sourceCommit ?? '') ||
typeof receipt.appVersion !== 'string' ||
receipt.appVersion.length === 0 ||
typeof receipt.buildRunUrl !== 'string' ||
receipt.buildRunUrl.length === 0 ||
!/^[0-9a-f]{64}$/.test(receipt.artifactDigest ?? '')
)
fail(`Production ${platform} receipt is incompatible`)
validateFingerprintReport(
receiptReport,
{
platform,
nativeProfile: 'production',
sourceCommit: receipt.sourceCommit,
runtimeVersion: receipt.runtimeVersion,
},
`Production ${platform} receipt fingerprint report`,
)
if (
receiptReport.fingerprintToolVersion !==
receipt.fingerprintToolVersion
)
fail(`Production ${platform} receipt tool version is inconsistent`)
verification.platforms[platform] = {
receiptVerification: {status: 'verified', reason: 'compatible'},
}
} else {
console.error(
`Warning: production ${platform} target could not be verified because no native build receipt was supplied`,
)
verification.platforms[platform] = {
receiptVerification: {
status: 'unverified',
reason: 'receipt-unavailable',
},
}
}
} else if (
entry.targetNativeBuildNumber != null ||
entry.targetNativeBuildReceiptRef != null
)
fail(`${release.channel} must not target a native build`)
else
verification.platforms[platform] = {
receiptVerification: {status: 'not-required', reason: 'channel-policy'},
}
}
process.stdout.write(`${JSON.stringify(verification)}\n`)
}
main().catch(error => {
console.error(error instanceof Error ? error.message : error)
process.exitCode = 1
})
+189
View File
@@ -0,0 +1,189 @@
import assert from 'node:assert/strict'
import {execFileSync} from 'node:child_process'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import test from 'node:test'
const validator = path.resolve(import.meta.dirname, 'validate-release.mjs')
const sha = 'a'.repeat(40)
const runtime = 'b'.repeat(40)
function fixture(channel = 'production') {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ota-release-'))
fs.mkdirSync(path.join(directory, 'bundle'))
fs.writeFileSync(
path.join(directory, 'report.json'),
JSON.stringify({
schemaVersion: 1,
platform: 'ios',
nativeProfile: 'production',
sourceCommit: sha,
runtimeVersion: runtime,
fingerprintPolicyVersion: 1,
fingerprintToolVersion: '0.20.8',
toolVersions: {
expo: '57.0.8',
expoUpdates: '57.0.10',
node: 'v24.19.0',
packageManager: 'pnpm@11.21.0',
},
fingerprintSources: [
{type: 'contents', id: 'expoConfig', hash: 'c'.repeat(40)},
{
type: 'file',
filePath: 'scripts/ota/fingerprint-policy.json',
hash: 'd'.repeat(40),
},
...['BlueskyClip', 'BlueskyNSE', 'Share-with-Bluesky'].map(name => ({
type: 'dir',
filePath: `modules/${name}`,
hash: 'e'.repeat(40),
})),
],
}),
)
const release = {
schemaVersion: 1,
sourceCommit: sha,
channel,
nativeProfile: 'production',
bundleVersion: '1788537600000',
platforms: {
ios: {
runtimeVersion: runtime,
fingerprintReportRef: 'report.json',
bundleDirectory: 'bundle',
...(channel === 'production' ? {targetNativeBuildNumber: '42'} : {}),
},
},
}
const releaseFile = path.join(directory, 'ota-export.json')
fs.writeFileSync(releaseFile, JSON.stringify(release))
return {directory, release, releaseFile}
}
function validate(releaseFile) {
return execFileSync(
process.execPath,
[validator, '--release-file', releaseFile],
{
encoding: 'utf8',
},
)
}
test('production requires an exact build but permits an unavailable receipt', () => {
const value = fixture()
const result = JSON.parse(validate(value.releaseFile))
assert.deepEqual(result.platforms.ios.receiptVerification, {
status: 'unverified',
reason: 'receipt-unavailable',
})
delete value.release.platforms.ios.targetNativeBuildNumber
fs.writeFileSync(value.releaseFile, JSON.stringify(value.release))
assert.throws(
() => validate(value.releaseFile),
/Production ios target is required/,
)
})
test('testflight rejects build targeting', () => {
const value = fixture('testflight')
value.release.nativeProfile = 'testflight'
value.release.platforms.ios.targetNativeBuildNumber = '42'
const report = JSON.parse(
fs.readFileSync(path.join(value.directory, 'report.json')),
)
report.nativeProfile = 'testflight'
fs.writeFileSync(
path.join(value.directory, 'report.json'),
JSON.stringify(report),
)
fs.writeFileSync(value.releaseFile, JSON.stringify(value.release))
assert.throws(
() => validate(value.releaseFile),
/must not target a native build/,
)
})
test('a supplied receipt with an invalid source SHA fails closed', () => {
const value = fixture()
fs.mkdirSync(path.join(value.directory, 'receipts'))
value.release.platforms.ios.targetNativeBuildReceiptRef =
'receipts/receipt.json'
fs.writeFileSync(
path.join(value.directory, 'receipts/receipt.json'),
JSON.stringify({
schemaVersion: 1,
platform: 'ios',
nativeProfile: 'production',
defaultChannel: 'production',
appVersion: '1.133.0',
nativeBuildNumber: '42',
runtimeVersion: runtime,
sourceCommit: 'short',
fingerprintPolicyVersion: 1,
fingerprintToolVersion: '0.20.8',
artifactDigest: 'c'.repeat(64),
buildRunUrl: 'https://github.example/build/1',
fingerprintReportRef: 'report.json',
}),
)
fs.writeFileSync(path.join(value.directory, 'receipts/report.json'), '{}')
fs.writeFileSync(value.releaseFile, JSON.stringify(value.release))
assert.throws(() => validate(value.releaseFile), /receipt is incompatible/)
})
test('bundle directories cannot escape the release directory', () => {
const value = fixture('testflight')
value.release.nativeProfile = 'testflight'
value.release.platforms.ios.bundleDirectory = '../bundle'
const report = JSON.parse(
fs.readFileSync(path.join(value.directory, 'report.json')),
)
report.nativeProfile = 'testflight'
fs.writeFileSync(
path.join(value.directory, 'report.json'),
JSON.stringify(report),
)
fs.writeFileSync(value.releaseFile, JSON.stringify(value.release))
assert.throws(
() => validate(value.releaseFile),
/escapes the release directory/,
)
})
test('channel and native profile must agree', () => {
const value = fixture('testflight')
assert.throws(
() => validate(value.releaseFile),
/requires the testflight native profile/,
)
})
test('incomplete fingerprint reports are rejected', () => {
const value = fixture()
const report = JSON.parse(
fs.readFileSync(path.join(value.directory, 'report.json')),
)
delete report.fingerprintToolVersion
fs.writeFileSync(
path.join(value.directory, 'report.json'),
JSON.stringify(report),
)
assert.throws(
() => validate(value.releaseFile),
/fingerprint report is incomplete or inconsistent/,
)
})
test('symlinked bundle directories cannot escape the release directory', () => {
const value = fixture()
fs.rmSync(path.join(value.directory, 'bundle'), {recursive: true})
fs.symlinkSync(os.tmpdir(), path.join(value.directory, 'bundle'))
assert.throws(
() => validate(value.releaseFile),
/escapes the release directory/,
)
})