408 lines
16 KiB
JavaScript
408 lines
16 KiB
JavaScript
import { readFile } from 'node:fs/promises'
|
|
import { resolve } from 'node:path'
|
|
|
|
const ROOT = resolve(import.meta.dirname, '../..')
|
|
const BUILD_GRADLE = resolve(ROOT, 'apps/mobile-rn/android/app/build.gradle')
|
|
const ROOT_BUILD_GRADLE = resolve(ROOT, 'apps/mobile-rn/android/build.gradle')
|
|
const PRODUCT_VERSION = resolve(ROOT, 'release/product-version.json')
|
|
const CONFIG_MODULE = resolve(
|
|
ROOT,
|
|
'apps/mobile-rn/android/app/src/main/java/com/d3ro/voice/D3ROConfigModule.kt',
|
|
)
|
|
const GOOGLE_TEST_PUBLISHER_ID = 'ca-app-pub-3940256099942544'
|
|
const TEST_PUBLISHER_PREFIX = `${GOOGLE_TEST_PUBLISHER_ID}/`
|
|
const TEST_BANNER_ID = `${TEST_PUBLISHER_PREFIX}6300978111`
|
|
const TEST_REWARDED_ID = `${TEST_PUBLISHER_PREFIX}5224354917`
|
|
const VALID_VARIANTS = new Set(['debug', 'e2e', 'release'])
|
|
|
|
function fail(message) {
|
|
throw new Error(`mobile_build_config_invalid:${message}`)
|
|
}
|
|
|
|
function requireMatch(source, expression, message) {
|
|
if (!expression.test(source)) fail(message)
|
|
}
|
|
|
|
function extractBlock(source, marker, message) {
|
|
const markerIndex = source.search(marker)
|
|
if (markerIndex < 0) fail(message)
|
|
const openIndex = source.indexOf('{', markerIndex)
|
|
if (openIndex < 0) fail(message)
|
|
let depth = 0
|
|
for (let index = openIndex; index < source.length; index += 1) {
|
|
if (source[index] === '{') depth += 1
|
|
if (source[index] === '}') {
|
|
depth -= 1
|
|
if (depth === 0) return source.slice(openIndex + 1, index)
|
|
}
|
|
}
|
|
fail(`${message}_unclosed`)
|
|
}
|
|
|
|
function parseBooleanConstant(source, name) {
|
|
const match = source.match(new RegExp(
|
|
`public static final boolean ${name} = (?:Boolean\\.parseBoolean\\(\"(true|false)\"\\)|(true|false));`,
|
|
))
|
|
if (!match) fail(`missing_boolean_${name}`)
|
|
return (match[1] ?? match[2]) === 'true'
|
|
}
|
|
|
|
function parseStringConstant(source, name) {
|
|
const match = source.match(new RegExp(`public static final String ${name} = \"([^\"]*)\";`))
|
|
if (!match) fail(`missing_string_${name}`)
|
|
return match[1]
|
|
}
|
|
|
|
function parseIntegerConstant(source, name) {
|
|
const match = source.match(new RegExp(`public static final int ${name} = ([0-9]+);`))
|
|
if (!match) fail(`missing_integer_${name}`)
|
|
return Number(match[1])
|
|
}
|
|
|
|
async function readSourceContractFiles() {
|
|
const [gradle, rootGradle, configModule, productVersionSource] = await Promise.all([
|
|
readFile(BUILD_GRADLE, 'utf8'),
|
|
readFile(ROOT_BUILD_GRADLE, 'utf8'),
|
|
readFile(CONFIG_MODULE, 'utf8'),
|
|
readFile(PRODUCT_VERSION, 'utf8'),
|
|
])
|
|
return { gradle, rootGradle, configModule, productVersion: JSON.parse(productVersionSource) }
|
|
}
|
|
|
|
function verifySourceContractSources({ gradle, rootGradle, configModule, productVersion }) {
|
|
if (/gradle\.startParameter\.taskNames/.test(gradle)) {
|
|
fail('variant_gate_must_not_use_requested_task_name_heuristics')
|
|
}
|
|
if (/gradle\.taskGraph|whenReady\s*\(/.test(gradle)) {
|
|
fail('variant_gate_must_not_use_configuration_cache_unsafe_task_graph_listeners')
|
|
}
|
|
|
|
const defaultConfigBlock = extractBlock(gradle, /\bdefaultConfig\s*\{/, 'missing_default_config')
|
|
const e2eBlock = extractBlock(gradle, /\be2e\s*\{/, 'missing_e2e_build_type')
|
|
const debugBlock = extractBlock(gradle, /\bdebug\s*\{/, 'missing_debug_build_type')
|
|
const releaseBlock = extractBlock(gradle, /\brelease\s*\{/, 'missing_release_build_type')
|
|
const gateTypeBlock = extractBlock(
|
|
gradle,
|
|
/abstract class VerifyD3roMobileVariantConfiguration extends DefaultTask\s*\{/,
|
|
'missing_variant_configuration_gate_type',
|
|
)
|
|
const e2eGateBlock = extractBlock(
|
|
gradle,
|
|
/tasks\.register\(\s*"verifyE2eBuildConfiguration"/,
|
|
'missing_e2e_configuration_gate',
|
|
)
|
|
const releaseGateBlock = extractBlock(
|
|
gradle,
|
|
/tasks\.register\(\s*"verifyReleaseBuildConfiguration"/,
|
|
'missing_release_configuration_gate',
|
|
)
|
|
const lifecycleWiringBlock = extractBlock(
|
|
gradle,
|
|
/tasks\.configureEach\s*\{/,
|
|
'missing_variant_lifecycle_gate_wiring',
|
|
)
|
|
|
|
requireMatch(
|
|
defaultConfigBlock,
|
|
/buildConfigField\s+"boolean",\s*"E2E_TEST_BUILD",\s*"false"/,
|
|
'default_e2e_flag_must_be_false',
|
|
)
|
|
requireMatch(e2eBlock, /debuggable\s+false/, 'e2e_must_not_be_debuggable')
|
|
requireMatch(
|
|
e2eBlock,
|
|
/matchingFallbacks\s*=\s*\["release"\]/,
|
|
'e2e_native_dependencies_must_match_non_debug_abi',
|
|
)
|
|
requireMatch(
|
|
e2eBlock,
|
|
/buildConfigField\s+"boolean",\s*"E2E_TEST_BUILD",\s*"true"/,
|
|
'e2e_flag_must_be_true',
|
|
)
|
|
requireMatch(e2eBlock, /abiFilters\.add\("arm64-v8a"\)/, 'e2e_arm64_abi_missing')
|
|
requireMatch(e2eBlock, /abiFilters\.add\("x86_64"\)/, 'e2e_x86_64_abi_missing')
|
|
if (/"E2E_TEST_BUILD",\s*"true"/.test(debugBlock)) fail('debug_e2e_flag_must_not_be_true')
|
|
if (/"E2E_TEST_BUILD",\s*"true"/.test(releaseBlock)) fail('release_e2e_flag_must_not_be_true')
|
|
const trueAssignments = [...gradle.matchAll(/"E2E_TEST_BUILD",\s*"true"/g)]
|
|
if (trueAssignments.length !== 1) fail('e2e_true_assignment_must_be_unique')
|
|
requireMatch(
|
|
gradle,
|
|
/debuggableVariants\s*=\s*\["debug"\]/,
|
|
'only_debug_may_skip_embedded_bundle',
|
|
)
|
|
requireMatch(
|
|
gradle,
|
|
/configuredVersionName\s*=\s*secureSetting\("D3RO_VERSION_NAME"\)/,
|
|
'version_name_must_come_from_secure_setting',
|
|
)
|
|
requireMatch(
|
|
gradle,
|
|
/configuredVersionCode\s*=\s*secureSetting\("D3RO_VERSION_CODE"\)/,
|
|
'version_code_must_come_from_secure_setting',
|
|
)
|
|
if (typeof productVersion?.version !== 'string') fail('product_version_name_invalid')
|
|
if (!Number.isSafeInteger(productVersion?.androidVersionCode) || productVersion.androidVersionCode <= 0) {
|
|
fail('product_version_code_invalid')
|
|
}
|
|
const fallbackVersionName = gradle.match(
|
|
/resolvedVersionName\s*=\s*versionSettingsValid\s*\?\s*configuredVersionName\s*:\s*"([^"]+)"/,
|
|
)?.[1]
|
|
if (fallbackVersionName !== productVersion.version) {
|
|
fail('validated_version_name_must_feed_product_version')
|
|
}
|
|
const fallbackVersionCode = Number(gradle.match(
|
|
/resolvedVersionCode\s*=\s*versionSettingsValid\s*\?\s*configuredVersionCodeValue\.toInteger\(\)\s*:\s*(\d+)/,
|
|
)?.[1])
|
|
if (fallbackVersionCode !== productVersion.androidVersionCode) {
|
|
fail('validated_version_code_must_feed_product_version')
|
|
}
|
|
requireMatch(
|
|
gateTypeBlock,
|
|
/@org\.gradle\.api\.tasks\.TaskAction\s+void\s+verifyConfiguration\(\)/,
|
|
'variant_gate_must_be_an_execution_time_task',
|
|
)
|
|
for (const [pattern, message] of [
|
|
[
|
|
/private static final String GOOGLE_TEST_ADMOB_PUBLISHER_ID\s*=\s*"ca-app-pub-3940256099942544"/,
|
|
'release_gate_google_test_publisher_constant_required',
|
|
],
|
|
[/D3RO_VERSION_NAME is required for release and e2e builds/, 'variant_gate_version_name_required'],
|
|
[/D3RO_VERSION_NAME must be a strict semantic version/, 'variant_gate_semver_required'],
|
|
[/D3RO_VERSION_CODE must be a positive decimal integer/, 'variant_gate_version_code_required'],
|
|
[/D3RO_VERSION_CODE exceeds the Android maximum/, 'variant_gate_version_code_max_required'],
|
|
[/google-services\.json for com\.d3ro\.voice/, 'release_gate_firebase_required'],
|
|
[/missingReleaseSettings\.get\(\)/, 'release_gate_missing_settings_required'],
|
|
[/D3RO_RELEASE_STORE_FILE must point to an existing keystore/, 'release_gate_keystore_required'],
|
|
[/D3RO_ADMOB_APP_ID has an invalid format/, 'release_gate_admob_app_id_required'],
|
|
[
|
|
/adMobAppId\.startsWith\(\s*GOOGLE_TEST_ADMOB_PUBLISHER_ID\s*\+\s*"~"\s*\)/,
|
|
'release_gate_admob_app_test_publisher_rejection_required',
|
|
],
|
|
[/D3RO_ADMOB_BANNER_UNIT_ID:\s*productionBannerUnitId\.get\(\)/, 'release_gate_banner_id_required'],
|
|
[/D3RO_ADMOB_REWARDED_UNIT_ID:\s*productionRewardedUnitId\.get\(\)/, 'release_gate_rewarded_id_required'],
|
|
[
|
|
/value\.startsWith\(\s*GOOGLE_TEST_ADMOB_PUBLISHER_ID\s*\+\s*"\/"\s*\)/,
|
|
'release_gate_admob_unit_test_publisher_rejection_required',
|
|
],
|
|
]) {
|
|
requireMatch(gateTypeBlock, pattern, message)
|
|
}
|
|
requireMatch(e2eGateBlock, /variantName\.set\("e2e"\)/, 'e2e_gate_variant_identity_missing')
|
|
requireMatch(
|
|
e2eGateBlock,
|
|
/configureVersionGate\(delegate\)/,
|
|
'e2e_gate_must_receive_version_contract',
|
|
)
|
|
requireMatch(
|
|
releaseGateBlock,
|
|
/variantName\.set\("release"\)/,
|
|
'release_gate_variant_identity_missing',
|
|
)
|
|
requireMatch(
|
|
releaseGateBlock,
|
|
/configureVersionGate\(delegate\)/,
|
|
'release_gate_must_receive_version_contract',
|
|
)
|
|
for (const [pattern, message] of [
|
|
[/missingReleaseSettings\.set\(missingReleaseSettingNames\)/, 'release_gate_missing_settings_input_missing'],
|
|
[/firebaseConfigPath\.set\(firebaseConfigFile\.absolutePath\)/, 'release_gate_firebase_input_missing'],
|
|
[/releaseStoreFilePath\.set\(releaseStoreAbsolutePath\)/, 'release_gate_keystore_input_missing'],
|
|
[/productionAdMobAppId\.set\(releaseAdMobAppIdValue\)/, 'release_gate_admob_app_input_missing'],
|
|
[/productionBannerUnitId\.set\(releaseBannerUnitIdValue\)/, 'release_gate_banner_input_missing'],
|
|
[/productionRewardedUnitId\.set\(releaseRewardedUnitIdValue\)/, 'release_gate_rewarded_input_missing'],
|
|
]) {
|
|
requireMatch(releaseGateBlock, pattern, message)
|
|
}
|
|
requireMatch(
|
|
lifecycleWiringBlock,
|
|
/candidate\.name\s*==\s*"preE2eBuild"[\s\S]*candidate\.dependsOn\(verifyE2eBuildConfiguration\)/,
|
|
'e2e_gate_must_be_wired_to_pre_e2e_build',
|
|
)
|
|
requireMatch(
|
|
lifecycleWiringBlock,
|
|
/candidate\.name\s*==\s*"preReleaseBuild"[\s\S]*candidate\.dependsOn\(verifyReleaseBuildConfiguration\)/,
|
|
'release_gate_must_be_wired_to_pre_release_build',
|
|
)
|
|
requireMatch(
|
|
configModule,
|
|
/"debug"\s+to\s+\(BuildConfig\.DEBUG\s*\|\|\s*BuildConfig\.E2E_TEST_BUILD\)/,
|
|
'native_runtime_must_recognize_non_debuggable_e2e',
|
|
)
|
|
requireMatch(
|
|
rootGradle,
|
|
/e2eRequested\s*=\s*requestedBuildTasks\.any\s*\{\s*it\.contains\("e2e"\)\s*\}/,
|
|
'root_gradle_must_detect_e2e_tasks',
|
|
)
|
|
requireMatch(
|
|
rootGradle,
|
|
/isUniversalNativeBuild\s*=\s*e2eRequested\s*\|\|/,
|
|
'root_gradle_e2e_must_be_universal',
|
|
)
|
|
requireMatch(
|
|
rootGradle,
|
|
/requestedArchitectures\.toSet\(\)\s*!=\s*\["arm64-v8a",\s*"x86_64"\]\.toSet\(\)/,
|
|
'explicit_universal_architectures_must_be_exact',
|
|
)
|
|
}
|
|
|
|
async function verifySourceContract() {
|
|
verifySourceContractSources(await readSourceContractFiles())
|
|
}
|
|
|
|
function expectSourceContractFailure(sources, mutate, expectedMessage) {
|
|
let failedAsExpected = false
|
|
try {
|
|
verifySourceContractSources({ ...sources, gradle: mutate(sources.gradle) })
|
|
} catch (error) {
|
|
if (!(error instanceof Error) || !error.message.includes(expectedMessage)) throw error
|
|
failedAsExpected = true
|
|
}
|
|
if (!failedAsExpected) fail(`self_test_missed_${expectedMessage}`)
|
|
}
|
|
|
|
function verifyVariant(variant, buildConfig) {
|
|
const debug = parseBooleanConstant(buildConfig, 'DEBUG')
|
|
const e2eTestBuild = parseBooleanConstant(buildConfig, 'E2E_TEST_BUILD')
|
|
const buildType = parseStringConstant(buildConfig, 'BUILD_TYPE')
|
|
const versionName = parseStringConstant(buildConfig, 'VERSION_NAME')
|
|
const versionCode = parseIntegerConstant(buildConfig, 'VERSION_CODE')
|
|
const bannerUnitId = parseStringConstant(buildConfig, 'ADMOB_BANNER_UNIT_ID')
|
|
const rewardedUnitId = parseStringConstant(buildConfig, 'ADMOB_REWARDED_UNIT_ID')
|
|
const runtimeDebug = debug || e2eTestBuild
|
|
|
|
if (buildType !== variant) fail(`build_type_${buildType}_expected_${variant}`)
|
|
if (variant === 'debug') {
|
|
if (!debug || e2eTestBuild || !runtimeDebug) fail('debug_identity_mismatch')
|
|
if (bannerUnitId !== TEST_BANNER_ID || rewardedUnitId !== TEST_REWARDED_ID) {
|
|
fail('debug_must_use_google_test_units')
|
|
}
|
|
}
|
|
if (variant === 'e2e') {
|
|
if (debug || !e2eTestBuild || !runtimeDebug) fail('e2e_identity_mismatch')
|
|
if (bannerUnitId !== TEST_BANNER_ID || rewardedUnitId !== TEST_REWARDED_ID) {
|
|
fail('e2e_must_use_google_test_units')
|
|
}
|
|
}
|
|
if (variant === 'release') {
|
|
if (debug || e2eTestBuild || runtimeDebug) fail('release_identity_mismatch')
|
|
for (const [name, value] of Object.entries({ bannerUnitId, rewardedUnitId })) {
|
|
if (!/^ca-app-pub-\d+\/\d+$/.test(value)) fail(`release_${name}_format`)
|
|
if (value.startsWith(TEST_PUBLISHER_PREFIX)) fail(`release_${name}_uses_test_publisher`)
|
|
}
|
|
const expectedBanner = process.env.D3RO_ADMOB_BANNER_UNIT_ID
|
|
const expectedRewarded = process.env.D3RO_ADMOB_REWARDED_UNIT_ID
|
|
if (expectedBanner && bannerUnitId !== expectedBanner) fail('release_banner_env_mismatch')
|
|
if (expectedRewarded && rewardedUnitId !== expectedRewarded) fail('release_rewarded_env_mismatch')
|
|
}
|
|
|
|
if (variant === 'e2e' || variant === 'release') {
|
|
const expectedVersionName = process.env.D3RO_VERSION_NAME
|
|
const expectedVersionCode = Number(process.env.D3RO_VERSION_CODE)
|
|
if (!expectedVersionName) fail(`${variant}_expected_version_name_missing`)
|
|
if (!Number.isSafeInteger(expectedVersionCode) || expectedVersionCode <= 0) {
|
|
fail(`${variant}_expected_version_code_invalid`)
|
|
}
|
|
if (versionName !== expectedVersionName) fail(`${variant}_version_name_mismatch`)
|
|
if (versionCode !== expectedVersionCode) fail(`${variant}_version_code_mismatch`)
|
|
}
|
|
|
|
return {
|
|
variant,
|
|
buildType,
|
|
debuggableRuntime: debug,
|
|
e2eTestBuild,
|
|
runtimeDebug,
|
|
versionName,
|
|
versionCode,
|
|
adPublisher: bannerUnitId.split('/')[0],
|
|
}
|
|
}
|
|
|
|
if (process.argv.includes('--self-test')) {
|
|
const sources = await readSourceContractFiles()
|
|
verifySourceContractSources(sources)
|
|
expectSourceContractFailure(
|
|
sources,
|
|
(gradle) => gradle.replace('candidate.name == "preE2eBuild"', 'candidate.name == "preE2eBuildDisabled"'),
|
|
'e2e_gate_must_be_wired_to_pre_e2e_build',
|
|
)
|
|
expectSourceContractFailure(
|
|
sources,
|
|
(gradle) => gradle.replace('candidate.name == "preReleaseBuild"', 'candidate.name == "preReleaseBuildDisabled"'),
|
|
'release_gate_must_be_wired_to_pre_release_build',
|
|
)
|
|
expectSourceContractFailure(
|
|
sources,
|
|
(gradle) => `def requested = gradle.startParameter.taskNames\n${gradle}`,
|
|
'variant_gate_must_not_use_requested_task_name_heuristics',
|
|
)
|
|
expectSourceContractFailure(
|
|
sources,
|
|
(gradle) => gradle.replace(
|
|
'firebaseConfigPath.set(firebaseConfigFile.absolutePath)',
|
|
'firebaseConfigPath.set("")',
|
|
),
|
|
'release_gate_firebase_input_missing',
|
|
)
|
|
expectSourceContractFailure(
|
|
sources,
|
|
(gradle) => gradle.replace(
|
|
'GOOGLE_TEST_ADMOB_PUBLISHER_ID + "~"',
|
|
'"disabled-google-test-publisher~"',
|
|
),
|
|
'release_gate_admob_app_test_publisher_rejection_required',
|
|
)
|
|
expectSourceContractFailure(
|
|
sources,
|
|
(gradle) => gradle.replace(
|
|
'GOOGLE_TEST_ADMOB_PUBLISHER_ID + "/"',
|
|
'"disabled-google-test-publisher/"',
|
|
),
|
|
'release_gate_admob_unit_test_publisher_rejection_required',
|
|
)
|
|
expectSourceContractFailure(
|
|
sources,
|
|
(gradle) => gradle.replace(
|
|
'configureVersionGate(delegate)',
|
|
'configureVersionGateDisabled(delegate)',
|
|
),
|
|
'e2e_gate_must_receive_version_contract',
|
|
)
|
|
expectSourceContractFailure(
|
|
sources,
|
|
(gradle) => gradle.replace(
|
|
`: "${sources.productVersion.version}"`,
|
|
': "9.9.9"',
|
|
),
|
|
'validated_version_name_must_feed_product_version',
|
|
)
|
|
expectSourceContractFailure(
|
|
sources,
|
|
(gradle) => gradle.replace(
|
|
`: ${sources.productVersion.androidVersionCode}`,
|
|
': 1',
|
|
),
|
|
'validated_version_code_must_feed_product_version',
|
|
)
|
|
console.log('Mobile build configuration gate self-test passed.')
|
|
process.exit(0)
|
|
}
|
|
|
|
await verifySourceContract()
|
|
|
|
const variant = process.argv[2]
|
|
if (variant === undefined) {
|
|
console.log(JSON.stringify({ sourceContract: 'verified' }))
|
|
process.exit(0)
|
|
}
|
|
if (!VALID_VARIANTS.has(variant)) fail(`unknown_variant_${variant}`)
|
|
|
|
const generatedPath = resolve(
|
|
process.argv[3]
|
|
?? `apps/mobile-rn/android/app/build/generated/source/buildConfig/${variant}/com/d3ro/voice/BuildConfig.java`,
|
|
)
|
|
const buildConfig = await readFile(generatedPath, 'utf8')
|
|
console.log(JSON.stringify({
|
|
sourceContract: 'verified',
|
|
generatedPath,
|
|
...verifyVariant(variant, buildConfig),
|
|
}, null, 2))
|