add canonical fingerprint ota export tooling
This commit is contained in:
@@ -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/,
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user