stage fingerprint ota workflows and native receipts
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs/promises'
|
||||
import process from 'node:process'
|
||||
import {pathToFileURL} from 'node:url'
|
||||
|
||||
export function parsePositiveInteger(value, label) {
|
||||
if (!/^[1-9][0-9]*$/.test(value ?? '')) {
|
||||
throw new Error(`${label} must be a positive integer`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export function expectedArtifactName({platform, buildNumber, runId, attempt}) {
|
||||
if (!['ios', 'android'].includes(platform)) {
|
||||
throw new Error('platform must be ios or android')
|
||||
}
|
||||
if (!/^[0-9]+$/.test(buildNumber ?? '')) {
|
||||
throw new Error('build-number must be numeric')
|
||||
}
|
||||
parsePositiveInteger(runId, 'run-id')
|
||||
parsePositiveInteger(attempt, 'run-attempt')
|
||||
return `native-build-${platform}-production-${buildNumber}-${runId}-${attempt}`
|
||||
}
|
||||
|
||||
export function validateTrustedRun(run, {repository, workflow}) {
|
||||
if (
|
||||
run?.conclusion !== 'success' ||
|
||||
run?.head_repository?.full_name !== repository ||
|
||||
run?.path !== workflow ||
|
||||
!/^[0-9a-f]{40}$/.test(run?.head_sha ?? '')
|
||||
) {
|
||||
throw new Error('Receipt artifact came from an untrusted native build run')
|
||||
}
|
||||
return run.head_sha
|
||||
}
|
||||
|
||||
async function githubJson(url, token) {
|
||||
const response = await fetch(url, {
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
headers: {
|
||||
accept: 'application/vnd.github+json',
|
||||
authorization: `Bearer ${token}`,
|
||||
'x-github-api-version': '2022-11-28',
|
||||
},
|
||||
})
|
||||
if (!response.ok)
|
||||
throw new Error(`GitHub API request failed (${response.status})`)
|
||||
return response.json()
|
||||
}
|
||||
|
||||
async function findArtifact({apiUrl, repository, runId, name, token}) {
|
||||
for (let page = 1; page <= 10; page++) {
|
||||
const result = await githubJson(
|
||||
`${apiUrl}/repos/${repository}/actions/runs/${runId}/artifacts?per_page=100&page=${page}`,
|
||||
token,
|
||||
)
|
||||
const artifact = result.artifacts?.find(
|
||||
item => item.name === name && !item.expired,
|
||||
)
|
||||
if (artifact) return artifact
|
||||
if (!result.artifacts || result.artifacts.length < 100) return undefined
|
||||
}
|
||||
throw new Error('Receipt artifact lookup exceeded 1000 artifacts')
|
||||
}
|
||||
|
||||
async function main(argv = process.argv.slice(2), env = process.env) {
|
||||
const args = Object.fromEntries(
|
||||
Array.from({length: argv.length / 2}, (_, index) => [
|
||||
argv[index * 2]?.replace(/^--/, ''),
|
||||
argv[index * 2 + 1],
|
||||
]),
|
||||
)
|
||||
const runId = parsePositiveInteger(args['run-id'], 'run-id')
|
||||
const attempt = parsePositiveInteger(args['run-attempt'], 'run-attempt')
|
||||
const name = expectedArtifactName({
|
||||
platform: args.platform,
|
||||
buildNumber: args['build-number'],
|
||||
runId,
|
||||
attempt,
|
||||
})
|
||||
const repository = env.GITHUB_REPOSITORY
|
||||
const token = env.GH_TOKEN
|
||||
const output = env.GITHUB_OUTPUT
|
||||
if (!repository || !token || !output)
|
||||
throw new Error('GitHub Actions environment is incomplete')
|
||||
|
||||
let artifact
|
||||
try {
|
||||
artifact = await findArtifact({
|
||||
apiUrl: env.GITHUB_API_URL ?? 'https://api.github.com',
|
||||
repository,
|
||||
runId,
|
||||
name,
|
||||
token,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(`Warning: receipt artifact lookup was unavailable: ${error}`)
|
||||
await fs.appendFile(output, `available=false\nartifact-name=${name}\n`)
|
||||
return
|
||||
}
|
||||
if (!artifact) {
|
||||
console.error(
|
||||
`Warning: ${name} is missing or expired; target could not be verified`,
|
||||
)
|
||||
await fs.appendFile(output, `available=false\nartifact-name=${name}\n`)
|
||||
return
|
||||
}
|
||||
|
||||
const workflow = `.github/workflows/build-submit-${args.platform}.yml`
|
||||
let run
|
||||
try {
|
||||
run = await githubJson(
|
||||
`${env.GITHUB_API_URL ?? 'https://api.github.com'}/repos/${repository}/actions/runs/${runId}`,
|
||||
token,
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(`Warning: receipt build-run lookup was unavailable: ${error}`)
|
||||
await fs.appendFile(output, `available=false\nartifact-name=${name}\n`)
|
||||
return
|
||||
}
|
||||
const headSha = validateTrustedRun(run, {repository, workflow})
|
||||
await fs.appendFile(
|
||||
output,
|
||||
`available=true\nartifact-name=${name}\nhead-sha=${headSha}\n`,
|
||||
)
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
main().catch(error => {
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
expectedArtifactName,
|
||||
parsePositiveInteger,
|
||||
validateTrustedRun,
|
||||
} from './find-native-receipt.mjs'
|
||||
|
||||
test('builds an attempt-scoped artifact name', () => {
|
||||
assert.equal(
|
||||
expectedArtifactName({
|
||||
platform: 'ios',
|
||||
buildNumber: '123',
|
||||
runId: '45',
|
||||
attempt: '2',
|
||||
}),
|
||||
'native-build-ios-production-123-45-2',
|
||||
)
|
||||
})
|
||||
|
||||
test('rejects invalid run identifiers', () => {
|
||||
assert.throws(() => parsePositiveInteger('0', 'run-id'), /positive integer/)
|
||||
assert.throws(
|
||||
() => parsePositiveInteger('1; echo nope', 'run-id'),
|
||||
/positive integer/,
|
||||
)
|
||||
})
|
||||
|
||||
test('accepts only the expected successful native workflow', () => {
|
||||
const expected = {
|
||||
repository: 'bluesky-social/social-app',
|
||||
workflow: '.github/workflows/build-submit-android.yml',
|
||||
}
|
||||
const run = {
|
||||
conclusion: 'success',
|
||||
head_repository: {full_name: expected.repository},
|
||||
path: expected.workflow,
|
||||
head_sha: 'a'.repeat(40),
|
||||
}
|
||||
assert.equal(validateTrustedRun(run, expected), run.head_sha)
|
||||
assert.throws(
|
||||
() =>
|
||||
validateTrustedRun(
|
||||
{...run, path: '.github/workflows/evil.yml'},
|
||||
expected,
|
||||
),
|
||||
/untrusted/,
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'node:fs'
|
||||
|
||||
const REQUEST_HEADERS_KEY =
|
||||
'expo.modules.updates.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY'
|
||||
const RUNTIME_VERSION_KEY = 'expo.modules.updates.EXPO_RUNTIME_VERSION'
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
function decodeXml(value) {
|
||||
return value
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('&', '&')
|
||||
}
|
||||
|
||||
export function parsePackagedAndroidConfig(manifestXml, runtimeResources) {
|
||||
const metadataTags = [...manifestXml.matchAll(/<meta-data\b[^>]*>/g)].map(
|
||||
match => match[0],
|
||||
)
|
||||
const tagsNamed = requestedName =>
|
||||
metadataTags.filter(tag => {
|
||||
const name = tag.match(/android:name="([^"]+)"/)?.[1]
|
||||
return decodeXml(name ?? '') === requestedName
|
||||
})
|
||||
const matchingTags = tagsNamed(REQUEST_HEADERS_KEY)
|
||||
if (matchingTags.length !== 1) {
|
||||
fail(`Expected exactly one ${REQUEST_HEADERS_KEY} manifest entry`)
|
||||
}
|
||||
const encodedHeaders = matchingTags[0].match(/android:value="([^"]*)"/)?.[1]
|
||||
if (encodedHeaders == null) fail('Update request headers have no value')
|
||||
|
||||
let headers
|
||||
try {
|
||||
headers = JSON.parse(decodeXml(encodedHeaders))
|
||||
} catch {
|
||||
fail('Update request headers are not valid JSON')
|
||||
}
|
||||
if (
|
||||
!headers ||
|
||||
typeof headers !== 'object' ||
|
||||
Array.isArray(headers) ||
|
||||
typeof headers['expo-channel-name'] !== 'string' ||
|
||||
!headers['expo-channel-name']
|
||||
) {
|
||||
fail('Update request headers omit expo-channel-name')
|
||||
}
|
||||
|
||||
const runtimeTags = tagsNamed(RUNTIME_VERSION_KEY)
|
||||
if (runtimeTags.length !== 1) {
|
||||
fail(`Expected exactly one ${RUNTIME_VERSION_KEY} manifest entry`)
|
||||
}
|
||||
const runtimeManifestValue = decodeXml(
|
||||
runtimeTags[0].match(/android:value="([^"]*)"/)?.[1] ?? '',
|
||||
)
|
||||
const resourceIDs = [
|
||||
...runtimeResources.matchAll(
|
||||
/^\s*(0x[0-9a-f]+)\s+-\s+string\/expo_runtime_version\s*$/gim,
|
||||
),
|
||||
].map(match => match[1].toLowerCase())
|
||||
if (
|
||||
runtimeManifestValue === '@string/expo_runtime_version' ||
|
||||
/^@ref\/0x[0-9a-f]+$/i.test(runtimeManifestValue)
|
||||
) {
|
||||
if (resourceIDs.length !== 1) {
|
||||
fail('Expected exactly one string/expo_runtime_version resource ID')
|
||||
}
|
||||
if (
|
||||
runtimeManifestValue.startsWith('@ref/') &&
|
||||
runtimeManifestValue.slice('@ref/'.length).toLowerCase() !==
|
||||
resourceIDs[0]
|
||||
) {
|
||||
fail('Runtime manifest reference does not identify expo_runtime_version')
|
||||
}
|
||||
const values = [
|
||||
...runtimeResources.matchAll(
|
||||
/(?:\[STR\]|value:)\s*(?:\([^)]*\)\s*)?["']([^"']+)["']/gi,
|
||||
),
|
||||
].map(match => match[1])
|
||||
if (values.length === 0) {
|
||||
fail('Could not parse string/expo_runtime_version value')
|
||||
}
|
||||
if (values.some(value => value !== 'file:fingerprint')) {
|
||||
fail('Packaged Expo runtime resource is not file:fingerprint')
|
||||
}
|
||||
} else if (runtimeManifestValue !== 'file:fingerprint') {
|
||||
fail('Packaged Expo runtime is not file:fingerprint')
|
||||
}
|
||||
|
||||
return {
|
||||
channel: headers['expo-channel-name'],
|
||||
runtimeConfiguration: 'file:fingerprint',
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const [manifestPath, resourcesPath] = process.argv.slice(2)
|
||||
if (!manifestPath || !resourcesPath) {
|
||||
fail('Usage: native-receipt-android.mjs <manifest.xml> <resources.txt>')
|
||||
}
|
||||
const result = parsePackagedAndroidConfig(
|
||||
fs.readFileSync(manifestPath, 'utf8'),
|
||||
fs.readFileSync(resourcesPath, 'utf8'),
|
||||
)
|
||||
process.stdout.write(`${JSON.stringify(result)}\n`)
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] &&
|
||||
import.meta.url === new URL(process.argv[1], 'file:').href
|
||||
) {
|
||||
try {
|
||||
main()
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {parsePackagedAndroidConfig} from './native-receipt-android.mjs'
|
||||
|
||||
const manifest = (headers, runtime = '@string/expo_runtime_version') => `
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application>
|
||||
<meta-data android:name="expo.modules.updates.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY" android:value="${headers}" />
|
||||
<meta-data android:name="expo.modules.updates.EXPO_RUNTIME_VERSION" android:value="${runtime}" />
|
||||
</application>
|
||||
</manifest>`
|
||||
|
||||
const resources = value => `
|
||||
Package 'xyz.blueskyweb.app'
|
||||
0x7f1200aa - string/expo_runtime_version
|
||||
config (default): [STR] "${value}"
|
||||
`
|
||||
|
||||
test('extracts the exact EAS channel and fingerprint sentinel', () => {
|
||||
assert.deepEqual(
|
||||
parsePackagedAndroidConfig(
|
||||
manifest('{"expo-channel-name":"testflight"}'),
|
||||
resources('file:fingerprint'),
|
||||
),
|
||||
{channel: 'testflight', runtimeConfiguration: 'file:fingerprint'},
|
||||
)
|
||||
})
|
||||
|
||||
test('rejects an ambiguous request-header entry', () => {
|
||||
const entry = manifest(
|
||||
'{"expo-channel-name":"production"}',
|
||||
)
|
||||
assert.throws(
|
||||
() =>
|
||||
parsePackagedAndroidConfig(entry + entry, resources('file:fingerprint')),
|
||||
/exactly one/,
|
||||
)
|
||||
})
|
||||
|
||||
test('rejects a literal runtime even when a fingerprint asset exists', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
parsePackagedAndroidConfig(
|
||||
manifest('{"expo-channel-name":"production"}'),
|
||||
resources('1.2.3'),
|
||||
),
|
||||
/not file:fingerprint/,
|
||||
)
|
||||
})
|
||||
|
||||
test('accepts a manifest with a direct fingerprint sentinel', () => {
|
||||
assert.deepEqual(
|
||||
parsePackagedAndroidConfig(
|
||||
manifest(
|
||||
'{"expo-channel-name":"production"}',
|
||||
'file:fingerprint',
|
||||
),
|
||||
'',
|
||||
),
|
||||
{channel: 'production', runtimeConfiguration: 'file:fingerprint'},
|
||||
)
|
||||
})
|
||||
|
||||
test('binds a compiled runtime reference to the named resource ID', () => {
|
||||
assert.deepEqual(
|
||||
parsePackagedAndroidConfig(
|
||||
manifest(
|
||||
'{"expo-channel-name":"testflight"}',
|
||||
'@ref/0x7f1200aa',
|
||||
),
|
||||
resources('file:fingerprint'),
|
||||
),
|
||||
{channel: 'testflight', runtimeConfiguration: 'file:fingerprint'},
|
||||
)
|
||||
})
|
||||
|
||||
test('rejects a compiled reference to a different resource ID', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
parsePackagedAndroidConfig(
|
||||
manifest(
|
||||
'{"expo-channel-name":"testflight"}',
|
||||
'@ref/0x7f1300d8',
|
||||
),
|
||||
resources('file:fingerprint'),
|
||||
),
|
||||
/does not identify expo_runtime_version/,
|
||||
)
|
||||
})
|
||||
|
||||
test('rejects an ambiguous named runtime resource', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
parsePackagedAndroidConfig(
|
||||
manifest(
|
||||
'{"expo-channel-name":"testflight"}',
|
||||
'@ref/0x7f1200aa',
|
||||
),
|
||||
`${resources('file:fingerprint')}\n0x7f1200ab - string/expo_runtime_version\n config (default): [STR] "file:fingerprint"`,
|
||||
),
|
||||
/exactly one string\/expo_runtime_version resource ID/,
|
||||
)
|
||||
})
|
||||
|
||||
test('rejects a similarly named header instead of guessing the channel', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
parsePackagedAndroidConfig(
|
||||
manifest('{"channel":"production"}'),
|
||||
resources('file:fingerprint'),
|
||||
),
|
||||
/omit expo-channel-name/,
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user