diff --git a/app.config.js b/app.config.js index bc1de0b2d7..1c5026fe41 100644 --- a/app.config.js +++ b/app.config.js @@ -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', }, ], }, diff --git a/fingerprint.config.js b/fingerprint.config.js new file mode 100644 index 0000000000..a3f4ca4716 --- /dev/null +++ b/fingerprint.config.js @@ -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'], + })) + : []), + ], +} diff --git a/scripts/bundleUpdate.js b/scripts/bundleUpdate.js old mode 100644 new mode 100755 index 00217dcd7b..863983d0ac --- a/scripts/bundleUpdate.js +++ b/scripts/bundleUpdate.js @@ -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} */ + 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}} */ + 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}} */ + 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 +}) diff --git a/scripts/denisPublish.sh b/scripts/denisPublish.sh index 2b4624eca0..c95a7fa164 100755 --- a/scripts/denisPublish.sh +++ b/scripts/denisPublish.sh @@ -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 diff --git a/scripts/denisPublishLegacy.sh b/scripts/denisPublishLegacy.sh new file mode 100755 index 0000000000..3e86aa0fca --- /dev/null +++ b/scripts/denisPublishLegacy.sh @@ -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 diff --git a/scripts/ota/fingerprint-policy.json b/scripts/ota/fingerprint-policy.json new file mode 100644 index 0000000000..e14e84e64b --- /dev/null +++ b/scripts/ota/fingerprint-policy.json @@ -0,0 +1,10 @@ +{ + "schemaVersion": 1, + "fingerprintPolicyVersion": 1, + "runtimeFormat": "sha1-lowercase-hex", + "normalizedFields": [ + "expo.version", + "expo.ios.buildNumber", + "expo.android.versionCode" + ] +} diff --git a/scripts/ota/package-export.test.mjs b/scripts/ota/package-export.test.mjs new file mode 100644 index 0000000000..d6cc605c99 --- /dev/null +++ b/scripts/ota/package-export.test.mjs @@ -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/) +}) diff --git a/scripts/ota/package-legacy-export.js b/scripts/ota/package-legacy-export.js new file mode 100755 index 0000000000..176c27b277 --- /dev/null +++ b/scripts/ota/package-legacy-export.js @@ -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}} */ + 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 +}) diff --git a/scripts/ota/resolve-runtime.mjs b/scripts/ota/resolve-runtime.mjs new file mode 100755 index 0000000000..7703c121b4 --- /dev/null +++ b/scripts/ota/resolve-runtime.mjs @@ -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 +}) diff --git a/scripts/ota/validate-release.mjs b/scripts/ota/validate-release.mjs new file mode 100755 index 0000000000..93c6763256 --- /dev/null +++ b/scripts/ota/validate-release.mjs @@ -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 +}) diff --git a/scripts/ota/validate-release.test.mjs b/scripts/ota/validate-release.test.mjs new file mode 100644 index 0000000000..5d6474b478 --- /dev/null +++ b/scripts/ota/validate-release.test.mjs @@ -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/, + ) +})