227 lines
8.0 KiB
JavaScript
Executable File
227 lines
8.0 KiB
JavaScript
Executable File
#!/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
|
|
})
|