xref: /expo/tools/src/commands/EasDispatch.ts (revision b9e44d74)
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