1import { Command } from '@expo/commander'; 2import plist from '@expo/plist'; 3import spawnAsync from '@expo/spawn-async'; 4import assert from 'assert'; 5import aws from 'aws-sdk'; 6import fs, { mkdirp } from 'fs-extra'; 7import glob from 'glob-promise'; 8import inquirer from 'inquirer'; 9import fetch from 'node-fetch'; 10import os from 'os'; 11import path from 'path'; 12import { v4 as uuidv4 } from 'uuid'; 13 14import { EXPO_DIR } from '../Constants'; 15import Git from '../Git'; 16import logger from '../Logger'; 17import { androidAppVersionAsync, iosAppVersionAsync } from '../ProjectVersions'; 18import { modifySdkVersionsAsync } from '../Versions'; 19 20const s3Client = new aws.S3({ region: 'us-east-1' }); 21 22const RELEASE_BUILD_PROFILE = 'release-client'; 23const PUBLISH_CLIENT_BUILD_PROFILE = 'publish-client'; 24 25type Action = { 26 name: string; 27 actionId: string; 28 internal?: boolean; 29 action: () => Promise<void>; 30}; 31 32const CUSTOM_ACTIONS: Record<string, Action> = { 33 'ios-client-build-and-submit': { 34 name: 'Build a new iOS client and submit it to the App Store.', 35 actionId: 'ios-client-build-and-submit', 36 action: iosBuildAndSubmitAsync, 37 }, 38 'ios-simulator-client-build-and-publish': { 39 name: 'Build a new iOS Client simulator and publish it to S3', 40 actionId: 'ios-simulator-client-build-and-publish', 41 action: iosSimulatorBuildAndPublishAsync, 42 }, 43 'android-client-build-and-submit': { 44 name: 'Build a new Android client and submit it to the Play Store.', 45 actionId: 'android-client-build-and-submit', 46 action: androidBuildAndSubmitAsync, 47 }, 48 'android-apk-build-and-publish': { 49 name: 'Build a new Android client APK and publish it to S3', 50 actionId: 'android-apk-build-and-publish', 51 action: androidAPKBuildAndPublishAsync, 52 }, 53 'remove-background-permissions-from-info-plist': { 54 name: '[internal] Removes permissions for background features that should be disabled in the App Store.', 55 actionId: 'remove-background-permissions-from-info-plist', 56 action: internalRemoveBackgroundPermissionsFromInfoPlistAsync, 57 internal: true, 58 }, 59 'ios-simulator-publish': { 60 name: '[internal] Upload simulator builds to S3 and update www endpoint', 61 actionId: 'ios-simulator-publish', 62 action: internalIosSimulatorPublishAsync, 63 internal: true, 64 }, 65 'android-apk-publish': { 66 name: '[internal] Upload Android client to S3 and update www endpoint', 67 actionId: 'android-apk-publish', 68 action: internalAndroidAPKPublishAsync, 69 internal: true, 70 }, 71}; 72 73export default (program: Command) => { 74 program 75 .command('eas-dispatch [action]') 76 .alias('eas') 77 .description(`Runs predefined EAS Build & Submit jobs.`) 78 .asyncAction(main); 79}; 80 81async function main(actionId: string | undefined) { 82 if (!actionId || !CUSTOM_ACTIONS[actionId]) { 83 const actions = Object.values(CUSTOM_ACTIONS) 84 .filter((i) => !i.internal) 85 .map((i) => `\n- ${i.actionId} - ${i.name}`); 86 if (!actionId) { 87 logger.error(`You need to provide action name. Select one of: ${actions.join('')}`); 88 } else { 89 logger.error(`Unknown action ${actionId}. Select one of: ${actions.join('')}`); 90 } 91 return; 92 } 93 94 CUSTOM_ACTIONS[actionId].action(); 95} 96 97function getAndroidApkUrl(appVersion: string): string { 98 return `https://d1ahtucjixef4r.cloudfront.net/Exponent-${appVersion}.apk`; 99} 100 101function getIosSimulatorUrl(appVersion: string): string { 102 return `https://dpq5q02fu5f55.cloudfront.net/Exponent-${appVersion}.tar.gz`; 103} 104 105async function confirmPromptIfOverridingRemoteFileAsync( 106 url: string, 107 appVersion: string 108): Promise<void> { 109 const response = await fetch(url, { 110 method: 'HEAD', 111 }); 112 if (response.status < 400) { 113 const { selection } = await inquirer.prompt<{ selection: boolean }>([ 114 { 115 type: 'confirm', 116 name: 'selection', 117 default: false, 118 message: `${appVersion} version of a client was already uploaded to S3. Do you want to override it?`, 119 }, 120 ]); 121 if (!selection) { 122 throw new Error('ABORTING'); 123 } 124 } 125} 126 127async function enforceRunningOnSdkReleaseBranchAsync(): Promise<string> { 128 const sdkBranchVersion = await Git.getSDKVersionFromBranchNameAsync(); 129 if (!sdkBranchVersion) { 130 logger.error(`Client builds can be released only from the release branch!`); 131 throw new Error('ABORTING'); 132 } 133 return sdkBranchVersion; 134} 135 136async function iosBuildAndSubmitAsync() { 137 await enforceRunningOnSdkReleaseBranchAsync(); 138 const isDebug = !!process.env.EXPO_DEBUG; 139 const projectDir = path.join(EXPO_DIR, 'apps/eas-expo-go'); 140 const credentialsDir = path.join(projectDir, 'credentials'); 141 const fastlaneMatchBucketCopyPath = path.join(credentialsDir, 'fastlane-match'); 142 const releaseSecretsPath = path.join(credentialsDir, 'secrets'); 143 const isDarwin = os.platform() === 'darwin'; 144 145 logger.info('Preparing credentials'); 146 try { 147 await mkdirp(fastlaneMatchBucketCopyPath); 148 await mkdirp(releaseSecretsPath); 149 await spawnAsync( 150 'gsutil', 151 ['rsync', '-r', '-d', 'gs://expo-client-certificates', fastlaneMatchBucketCopyPath], 152 { stdio: isDebug ? 'inherit' : 'pipe' } 153 ); 154 await spawnAsync( 155 'gsutil', 156 ['rsync', '-r', '-d', 'gs://expo-go-release-secrets', releaseSecretsPath], 157 { stdio: isDebug ? 'inherit' : 'pipe' } 158 ); 159 160 const privateKeyMatches = glob.sync('*/certs/distribution/*.p12', { 161 absolute: true, 162 cwd: fastlaneMatchBucketCopyPath, 163 }); 164 assert(privateKeyMatches.length === 1); 165 const privateKeyPath = privateKeyMatches[0]; 166 167 const certDERMatches = glob.sync('*/certs/distribution/*.cer', { 168 absolute: true, 169 cwd: fastlaneMatchBucketCopyPath, 170 }); 171 assert(certDERMatches.length === 1); 172 const certDERPath = certDERMatches[0]; 173 174 const certPEMPath = path.join(credentialsDir, 'cert.pem'); 175 const p12KeystorePath = path.join(credentialsDir, 'dist.p12'); 176 const p12KeystorePassword = uuidv4(); 177 178 await spawnAsync( 179 'openssl', 180 ['x509', '-inform', 'der', '-in', certDERPath, '-out', certPEMPath], 181 { 182 stdio: isDebug ? 'inherit' : 'pipe', 183 } 184 ); 185 await spawnAsync( 186 'openssl', 187 [ 188 'pkcs12', 189 '-export', 190 ...(isDarwin ? [] : ['-legacy']), 191 '-out', 192 p12KeystorePath, 193 '-inkey', 194 privateKeyPath, 195 '-in', 196 certPEMPath, 197 '-password', 198 `pass:${p12KeystorePassword}`, 199 ], 200 { stdio: isDebug ? 'inherit' : 'pipe' } 201 ); 202 203 await fs.writeFile( 204 path.join(projectDir, 'credentials.json'), 205 JSON.stringify({ 206 ios: { 207 'Expo Go (versioned)': { 208 provisioningProfilePath: path.join( 209 fastlaneMatchBucketCopyPath, 210 'C8D8QTF339/profiles/appstore/AppStore_host.exp.Exponent.mobileprovision' 211 ), 212 distributionCertificate: { 213 path: p12KeystorePath, 214 password: p12KeystorePassword, 215 }, 216 }, 217 ExpoNotificationServiceExtension: { 218 provisioningProfilePath: path.join( 219 fastlaneMatchBucketCopyPath, 220 'C8D8QTF339/profiles/appstore/AppStore_host.exp.Exponent.ExpoNotificationServiceExtension.mobileprovision' 221 ), 222 distributionCertificate: { 223 path: p12KeystorePath, 224 password: p12KeystorePassword, 225 }, 226 }, 227 }, 228 }) 229 ); 230 } catch (err) { 231 if (!isDebug) { 232 logger.error( 233 'There was an error when preparing build credentials. Run with EXPO_DEBUG=1 env to see more details.' 234 ); 235 } 236 throw err; 237 } 238 239 await spawnAsync( 240 'eas', 241 ['build', '--platform', 'ios', '--profile', RELEASE_BUILD_PROFILE, '--auto-submit'], 242 { 243 cwd: projectDir, 244 stdio: 'inherit', 245 } 246 ); 247} 248 249async function iosSimulatorBuildAndPublishAsync() { 250 const projectDir = path.join(EXPO_DIR, 'apps/eas-expo-go'); 251 await enforceRunningOnSdkReleaseBranchAsync(); 252 253 const appVersion = await iosAppVersionAsync(); 254 await confirmPromptIfOverridingRemoteFileAsync(getIosSimulatorUrl(appVersion), appVersion); 255 256 await spawnAsync( 257 'eas', 258 ['build', '--platform', 'ios', '--profile', PUBLISH_CLIENT_BUILD_PROFILE], 259 { 260 cwd: projectDir, 261 stdio: 'inherit', 262 } 263 ); 264} 265 266async function prepareAndroidCredentialsAsync(projectDir: string): Promise<void> { 267 const isDebug = !!process.env.EXPO_DEBUG; 268 const credentialsDir = path.join(projectDir, 'credentials'); 269 const releaseSecretsPath = path.join(credentialsDir, 'secrets'); 270 await mkdirp(releaseSecretsPath); 271 const keystorePath = path.join(releaseSecretsPath, 'android-keystore.jks'); 272 const keystorePasswordPath = path.join(releaseSecretsPath, 'android-keystore.password'); 273 const keystoreAliasPasswordPath = path.join( 274 releaseSecretsPath, 275 'android-keystore-alias.password' 276 ); 277 278 logger.info('Preparing credentials'); 279 try { 280 await spawnAsync( 281 'gsutil', 282 ['rsync', '-r', '-d', 'gs://expo-go-release-secrets', releaseSecretsPath], 283 { stdio: isDebug ? 'inherit' : 'pipe' } 284 ); 285 286 await fs.writeFile( 287 path.join(projectDir, 'credentials.json'), 288 JSON.stringify({ 289 android: { 290 keystore: { 291 keystorePath, 292 keystorePassword: (await fs.readFile(keystorePasswordPath, 'utf-8')).trim(), 293 keyAlias: 'ExponentKey', 294 keyPassword: (await fs.readFile(keystoreAliasPasswordPath, 'utf-8')).trim(), 295 }, 296 }, 297 }) 298 ); 299 } catch (err) { 300 if (!isDebug) { 301 logger.error( 302 'There was an error when preparing build credentials. Run with EXPO_DEBUG=1 env to see more details.' 303 ); 304 } 305 throw err; 306 } 307} 308 309async function androidBuildAndSubmitAsync() { 310 const projectDir = path.join(EXPO_DIR, 'apps/eas-expo-go'); 311 await enforceRunningOnSdkReleaseBranchAsync(); 312 await prepareAndroidCredentialsAsync(projectDir); 313 314 await spawnAsync( 315 'eas', 316 ['build', '--platform', 'android', '--profile', RELEASE_BUILD_PROFILE, '--auto-submit'], 317 { 318 cwd: projectDir, 319 stdio: 'inherit', 320 env: { 321 ...process.env, 322 EAS_DANGEROUS_OVERRIDE_ANDROID_APPLICATION_ID: 'host.exp.exponent', 323 }, 324 } 325 ); 326 327 logger.info('Updating versionCode in local app/build.gradle with value from EAS servers.'); 328 await spawnAsync( 329 'eas', 330 ['build:version:sync', '--platform', 'android', '--profile', RELEASE_BUILD_PROFILE], 331 { 332 cwd: projectDir, 333 stdio: 'inherit', 334 env: { 335 ...process.env, 336 EAS_DANGEROUS_OVERRIDE_ANDROID_APPLICATION_ID: 'host.exp.exponent', 337 }, 338 } 339 ); 340} 341 342async function androidAPKBuildAndPublishAsync() { 343 const projectDir = path.join(EXPO_DIR, 'apps/eas-expo-go'); 344 await enforceRunningOnSdkReleaseBranchAsync(); 345 await prepareAndroidCredentialsAsync(projectDir); 346 347 const appVersion = await androidAppVersionAsync(); 348 await confirmPromptIfOverridingRemoteFileAsync(getAndroidApkUrl(appVersion), appVersion); 349 350 await spawnAsync( 351 'eas', 352 ['build', '--platform', 'android', '--profile', PUBLISH_CLIENT_BUILD_PROFILE], 353 { 354 cwd: projectDir, 355 stdio: 'inherit', 356 env: { 357 ...process.env, 358 EAS_DANGEROUS_OVERRIDE_ANDROID_APPLICATION_ID: 'host.exp.exponent', 359 }, 360 } 361 ); 362} 363 364async function internalRemoveBackgroundPermissionsFromInfoPlistAsync(): Promise<void> { 365 const INFO_PLIST_PATH = path.join(EXPO_DIR, 'ios/Exponent/Supporting/Info.plist'); 366 const rawPlist = await fs.readFile(INFO_PLIST_PATH, 'utf-8'); 367 const parsedPlist = plist.parse(rawPlist); 368 369 logger.info( 370 `Removing NSLocationAlwaysAndWhenInUseUsageDescription from ios/Exponent/Supporting/Info.plist` 371 ); 372 delete parsedPlist.NSLocationAlwaysAndWhenInUseUsageDescription; 373 logger.info(`Removing NSLocationAlwaysUsageDescription from ios/Exponent/Supporting/Info.plist`); 374 delete parsedPlist.NSLocationAlwaysUsageDescription; 375 376 logger.info( 377 `Removing location, audio and remonte-notfication from UIBackgroundModes from ios/Exponent/Supporting/Info.plist` 378 ); 379 parsedPlist.UIBackgroundModes = parsedPlist.UIBackgroundModes.filter( 380 (i: string) => !['location', 'audio', 'remote-notification'].includes(i) 381 ); 382 await fs.writeFile(INFO_PLIST_PATH, plist.build(parsedPlist)); 383} 384 385async function internalIosSimulatorPublishAsync() { 386 const tmpTarGzPath = path.join(os.tmpdir(), 'simulator.tar.gz'); 387 const projectDir = path.join(EXPO_DIR, 'apps/eas-expo-go'); 388 const sdkVersion = await enforceRunningOnSdkReleaseBranchAsync(); 389 const artifactPaths = glob.sync('ios/build/Build/Products/*simulator/*.app', { 390 absolute: true, 391 cwd: projectDir, 392 }); 393 394 if (artifactPaths.length !== 1) { 395 logger.error(`Expected exactly one .app directory. Found: ${artifactPaths}.`); 396 } 397 await spawnAsync('tar', ['-zcvf', tmpTarGzPath, '-C', artifactPaths[0], '.'], { 398 stdio: ['ignore', 'ignore', 'inherit'], // only stderr 399 }); 400 const appVersion = await iosAppVersionAsync(); 401 const file = fs.createReadStream(tmpTarGzPath); 402 403 logger.info(`Uploading Exponent-${appVersion}.tar.gz to S3`); 404 await s3Client 405 .putObject({ 406 Bucket: 'exp-ios-simulator-apps', 407 Key: `Exponent-${appVersion}.tar.gz`, 408 Body: file, 409 ACL: 'public-read', 410 }) 411 .promise(); 412 413 logger.info('Updating versions endpoint'); 414 await modifySdkVersionsAsync(sdkVersion, (sdkVersions) => { 415 sdkVersions.iosClientUrl = getIosSimulatorUrl(appVersion); 416 sdkVersions.iosClientVersion = appVersion; 417 return sdkVersions; 418 }); 419} 420 421async function internalAndroidAPKPublishAsync() { 422 const projectDir = path.join(EXPO_DIR, 'apps/eas-expo-go'); 423 const sdkVersion = await enforceRunningOnSdkReleaseBranchAsync(); 424 const artifactPaths = glob.sync('android/app/build/outputs/**/*.apk', { 425 absolute: true, 426 cwd: projectDir, 427 }); 428 429 if (artifactPaths.length !== 1) { 430 logger.error(`Expected exactly one .apk file. Found: ${artifactPaths}`); 431 } 432 const appVersion = await androidAppVersionAsync(); 433 const file = fs.createReadStream(artifactPaths[0]); 434 435 logger.info(`Uploading Exponent-${appVersion}.apk to S3`); 436 await s3Client 437 .putObject({ 438 Bucket: 'exp-android-apks', 439 Key: `Exponent-${appVersion}.apk`, 440 Body: file, 441 ACL: 'public-read', 442 }) 443 .promise(); 444 445 logger.info('Updating versions endpoint'); 446 await modifySdkVersionsAsync(sdkVersion, (sdkVersions) => { 447 sdkVersions.androidClientUrl = getAndroidApkUrl(appVersion); 448 sdkVersions.androidClientVersion = appVersion; 449 return sdkVersions; 450 }); 451} 452