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