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