1import { Command } from '@expo/commander'; 2import plist from '@expo/plist'; 3import spawnAsync from '@expo/spawn-async'; 4import assert from 'assert'; 5import fs, { mkdirp } from 'fs-extra'; 6import glob from 'glob-promise'; 7import os from 'os'; 8import path from 'path'; 9import { v4 as uuidv4 } from 'uuid'; 10 11import { EXPO_DIR } from '../Constants'; 12import logger from '../Logger'; 13 14const RELEASE_BUILD_PROFILE = 'release-client'; 15 16type Action = { 17 name: string; 18 actionId: string; 19 internal?: boolean; 20 action: () => Promise<void>; 21}; 22 23const CUSTOM_ACTIONS: Record<string, Action> = { 24 'ios-client-build-and-submit': { 25 name: 'Build a new iOS client and submit it to the App Store.', 26 actionId: 'ios-client-build-and-submit', 27 action: iosBuildAndSubmitAsync, 28 }, 29 'android-client-build-and-submit': { 30 name: 'Build a new Android client and submit it to the Play Store.', 31 actionId: 'android-client-build-and-submit', 32 action: androidBuildAndSubmitAsync, 33 }, 34 'remove-background-permissions-from-info-plist': { 35 name: 'Removes permissions for background features that should be disabled in the App Store.', 36 actionId: 'remove-background-permissions-from-info-plist', 37 action: internalRemoveBackgroundPermissionsFromInfoPlistAsync, 38 internal: true, 39 }, 40}; 41 42export default (program: Command) => { 43 program 44 .command('eas-dispatch [action]') 45 .alias('eas') 46 .description(`Runs predefined EAS Build & Submit jobs.`) 47 .asyncAction(main); 48}; 49 50async function main(actionId: string | undefined) { 51 if (!actionId || !CUSTOM_ACTIONS[actionId]) { 52 const actions = Object.values(CUSTOM_ACTIONS) 53 .filter((i) => !i.internal) 54 .map((i) => `\n- ${i.actionId} - ${i.name}`); 55 if (!actionId) { 56 logger.error(`You need to provide action name. Select one of: ${actions.join('')}`); 57 } else { 58 logger.error(`Unknown action ${actionId}. Select one of: ${actions.join('')}`); 59 } 60 return; 61 } 62 63 CUSTOM_ACTIONS[actionId].action(); 64} 65 66async function iosBuildAndSubmitAsync() { 67 const isDebug = !!process.env.EXPO_DEBUG; 68 const projectDir = path.join(EXPO_DIR, 'apps/eas-expo-go'); 69 const credentialsDir = path.join(projectDir, 'credentials'); 70 const fastlaneMatchBucketCopyPath = path.join(credentialsDir, 'fastlane-match'); 71 const releaseSecretsPath = path.join(credentialsDir, 'secrets'); 72 const isDarwin = os.platform() === 'darwin'; 73 74 try { 75 await mkdirp(fastlaneMatchBucketCopyPath); 76 await mkdirp(releaseSecretsPath); 77 await spawnAsync( 78 'gsutil', 79 ['rsync', '-r', '-d', 'gs://expo-client-certificates', fastlaneMatchBucketCopyPath], 80 { stdio: isDebug ? 'inherit' : 'pipe' } 81 ); 82 await spawnAsync( 83 'gsutil', 84 ['rsync', '-r', '-d', 'gs://expo-go-release-secrets', releaseSecretsPath], 85 { stdio: isDebug ? 'inherit' : 'pipe' } 86 ); 87 88 const privateKeyMatches = glob.sync('*/certs/distribution/*.p12', { 89 absolute: true, 90 cwd: fastlaneMatchBucketCopyPath, 91 }); 92 assert(privateKeyMatches.length === 1); 93 const privateKeyPath = privateKeyMatches[0]; 94 95 const certDERMatches = glob.sync('*/certs/distribution/*.cer', { 96 absolute: true, 97 cwd: fastlaneMatchBucketCopyPath, 98 }); 99 assert(certDERMatches.length === 1); 100 const certDERPath = certDERMatches[0]; 101 102 const certPEMPath = path.join(credentialsDir, 'cert.pem'); 103 const p12KeystorePath = path.join(credentialsDir, 'dist.p12'); 104 const p12KeystorePassword = uuidv4(); 105 106 await spawnAsync( 107 'openssl', 108 ['x509', '-inform', 'der', '-in', certDERPath, '-out', certPEMPath], 109 { 110 stdio: isDebug ? 'inherit' : 'pipe', 111 } 112 ); 113 await spawnAsync( 114 'openssl', 115 [ 116 'pkcs12', 117 '-export', 118 ...(isDarwin ? [] : ['-legacy']), 119 '-out', 120 p12KeystorePath, 121 '-inkey', 122 privateKeyPath, 123 '-in', 124 certPEMPath, 125 '-password', 126 `pass:${p12KeystorePassword}`, 127 ], 128 { stdio: isDebug ? 'inherit' : 'pipe' } 129 ); 130 131 await fs.writeFile( 132 path.join(projectDir, 'credentials.json'), 133 JSON.stringify({ 134 ios: { 135 'Expo Go (versioned)': { 136 provisioningProfilePath: path.join( 137 fastlaneMatchBucketCopyPath, 138 'C8D8QTF339/profiles/appstore/AppStore_host.exp.Exponent.mobileprovision' 139 ), 140 distributionCertificate: { 141 path: p12KeystorePath, 142 password: p12KeystorePassword, 143 }, 144 }, 145 ExpoNotificationServiceExtension: { 146 provisioningProfilePath: path.join( 147 fastlaneMatchBucketCopyPath, 148 'C8D8QTF339/profiles/appstore/AppStore_host.exp.Exponent.ExpoNotificationServiceExtension.mobileprovision' 149 ), 150 distributionCertificate: { 151 path: p12KeystorePath, 152 password: p12KeystorePassword, 153 }, 154 }, 155 }, 156 }) 157 ); 158 } catch (err) { 159 if (!isDebug) { 160 logger.error( 161 'There was an error when preparing build credentials. Run with EXPO_DEBUG=1 env to see more details.' 162 ); 163 } 164 throw err; 165 } 166 167 await spawnAsync( 168 'eas', 169 ['build', '--platform', 'ios', '--profile', RELEASE_BUILD_PROFILE, '--auto-submit'], 170 { 171 cwd: projectDir, 172 stdio: 'inherit', 173 } 174 ); 175} 176 177async function androidBuildAndSubmitAsync() { 178 const isDebug = !!process.env.EXPO_DEBUG; 179 const projectDir = path.join(EXPO_DIR, 'apps/eas-expo-go'); 180 const credentialsDir = path.join(projectDir, 'credentials'); 181 const releaseSecretsPath = path.join(credentialsDir, 'secrets'); 182 await mkdirp(releaseSecretsPath); 183 184 const keystorePath = path.join(releaseSecretsPath, 'android-keystore.jks'); 185 const keystorePasswordPath = path.join(releaseSecretsPath, 'android-keystore.password'); 186 const keystoreAliasPasswordPath = path.join( 187 releaseSecretsPath, 188 'android-keystore-alias.password' 189 ); 190 191 logger.info('Preparing credentials'); 192 try { 193 await spawnAsync( 194 'gsutil', 195 ['rsync', '-r', '-d', 'gs://expo-go-release-secrets', releaseSecretsPath], 196 { stdio: isDebug ? 'inherit' : 'pipe' } 197 ); 198 199 await fs.writeFile( 200 path.join(projectDir, 'credentials.json'), 201 JSON.stringify({ 202 android: { 203 keystore: { 204 keystorePath, 205 keystorePassword: (await fs.readFile(keystorePasswordPath, 'utf-8')).trim(), 206 keyAlias: 'ExponentKey', 207 keyPassword: (await fs.readFile(keystoreAliasPasswordPath, 'utf-8')).trim(), 208 }, 209 }, 210 }) 211 ); 212 } catch (err) { 213 if (!isDebug) { 214 logger.error( 215 'There was an error when preparing build credentials. Run with EXPO_DEBUG=1 env to see more details.' 216 ); 217 } 218 throw err; 219 } 220 221 await spawnAsync( 222 'eas', 223 ['build', '--platform', 'android', '--profile', RELEASE_BUILD_PROFILE, '--auto-submit'], 224 { 225 cwd: projectDir, 226 stdio: 'inherit', 227 env: { 228 ...process.env, 229 EAS_DANGEROUS_OVERRIDE_ANDROID_APPLICATION_ID: 'host.exp.exponent', 230 }, 231 } 232 ); 233 234 logger.info('Updating versionCode in local app/build.gradle with value from EAS servers.'); 235 await spawnAsync( 236 'eas', 237 ['build:version:sync', '--platform', 'android', '--profile', RELEASE_BUILD_PROFILE], 238 { 239 cwd: projectDir, 240 stdio: 'inherit', 241 env: { 242 ...process.env, 243 EAS_DANGEROUS_OVERRIDE_ANDROID_APPLICATION_ID: 'host.exp.exponent', 244 }, 245 } 246 ); 247} 248 249async function internalRemoveBackgroundPermissionsFromInfoPlistAsync(): Promise<void> { 250 const INFO_PLIST_PATH = path.join(EXPO_DIR, 'ios/Exponent/Supporting/Info.plist'); 251 const rawPlist = await fs.readFile(INFO_PLIST_PATH, 'utf-8'); 252 const parsedPlist = plist.parse(rawPlist); 253 254 logger.info( 255 `Removing NSLocationAlwaysAndWhenInUseUsageDescription from ios/Exponent/Supporting/Info.plist` 256 ); 257 delete parsedPlist.NSLocationAlwaysAndWhenInUseUsageDescription; 258 logger.info(`Removing NSLocationAlwaysUsageDescription from ios/Exponent/Supporting/Info.plist`); 259 delete parsedPlist.NSLocationAlwaysUsageDescription; 260 261 logger.info( 262 `Removing location, audio and remonte-notfication from UIBackgroundModes from ios/Exponent/Supporting/Info.plist` 263 ); 264 parsedPlist.UIBackgroundModes = parsedPlist.UIBackgroundModes.filter( 265 (i: string) => !['location', 'audio', 'remote-notification'].includes(i) 266 ); 267 await fs.writeFile(INFO_PLIST_PATH, plist.build(parsedPlist)); 268} 269