Files
bsky-social-app/scripts/ota/validate-release.mjs
T
2026-09-05 18:55:17 +03:00

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
})