feat(release): prepare 1.1.0 candidate

This commit is contained in:
Yun Chan 2026-08-29 18:33:45 +09:00
parent 5a34f66981
commit 5205dcdfa9
736 changed files with 115667 additions and 12203 deletions

View file

@ -0,0 +1,408 @@
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))