@expo/build-tools 22.0.0 → 22.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/builders/android.js +2 -2
  2. package/dist/builders/ios.js +2 -2
  3. package/dist/steps/easFunctions.js +6 -0
  4. package/dist/steps/functions/downloadBuild.d.ts +9 -2
  5. package/dist/steps/functions/downloadBuild.js +83 -14
  6. package/dist/steps/functions/installBuild.d.ts +12 -0
  7. package/dist/steps/functions/installBuild.js +85 -0
  8. package/dist/steps/functions/installMaestro.d.ts +6 -1
  9. package/dist/steps/functions/installMaestro.js +107 -33
  10. package/dist/steps/functions/launchApplication.d.ts +10 -0
  11. package/dist/steps/functions/launchApplication.js +67 -0
  12. package/dist/steps/functions/maestroResultParser.d.ts +30 -0
  13. package/dist/steps/functions/maestroResultParser.js +78 -5
  14. package/dist/steps/functions/maestroScreenshots.d.ts +6 -0
  15. package/dist/steps/functions/maestroScreenshots.js +123 -0
  16. package/dist/steps/functions/maestroTests.js +187 -69
  17. package/dist/steps/functions/readIpaInfo.d.ts +1 -0
  18. package/dist/steps/functions/readIpaInfo.js +2 -0
  19. package/dist/steps/functions/repack.d.ts +5 -2
  20. package/dist/steps/functions/repack.js +50 -2
  21. package/dist/steps/functions/restoreBuildCache.d.ts +3 -3
  22. package/dist/steps/functions/restoreBuildCache.js +21 -7
  23. package/dist/steps/functions/saveBuildCache.d.ts +3 -3
  24. package/dist/steps/functions/saveBuildCache.js +21 -6
  25. package/dist/steps/functions/saveCache.js +3 -0
  26. package/dist/steps/functions/startAgentDeviceRemoteSession.js +7 -0
  27. package/dist/steps/functions/startAppiumRemoteSession.d.ts +17 -0
  28. package/dist/steps/functions/startAppiumRemoteSession.js +244 -0
  29. package/dist/steps/functions/startArgentRemoteSession.js +7 -0
  30. package/dist/steps/functions/startServeSimRemoteSession.js +17 -1
  31. package/dist/steps/functions/uploadToAsc.js +11 -1
  32. package/dist/steps/utils/appiumEvents.d.ts +10 -0
  33. package/dist/steps/utils/appiumEvents.js +175 -0
  34. package/dist/steps/utils/ios/AscApiClient.d.ts +1 -0
  35. package/dist/steps/utils/ios/AscApiUtils.d.ts +11 -2
  36. package/dist/steps/utils/ios/AscApiUtils.js +36 -2
  37. package/dist/steps/utils/remoteDeviceRunSession.d.ts +6 -3
  38. package/dist/steps/utils/remoteDeviceRunSession.js +81 -52
  39. package/dist/utils/AndroidEmulatorUtils.d.ts +1 -0
  40. package/dist/utils/AndroidEmulatorUtils.js +46 -4
  41. package/dist/utils/cacheKey.d.ts +8 -2
  42. package/dist/utils/cacheKey.js +12 -8
  43. package/package.json +6 -4
@@ -11,6 +11,7 @@ const promises_1 = __importDefault(require("fs/promises"));
11
11
  const os_1 = __importDefault(require("os"));
12
12
  const path_1 = __importDefault(require("path"));
13
13
  const zod_1 = require("zod");
14
+ const maestroBackend_1 = require("./maestroBackend");
14
15
  const maestroFlowDiscovery_1 = require("./maestroFlowDiscovery");
15
16
  const maestroResultParser_1 = require("./maestroResultParser");
16
17
  const maestroScreenshots_1 = require("./maestroScreenshots");
@@ -36,28 +37,40 @@ function isFilesystemError(err) {
36
37
  const code = err.code;
37
38
  return (code === 'ENOSPC' || code === 'EACCES' || code === 'EROFS' || code === 'EIO' || code === 'EPERM');
38
39
  }
39
- // `outputPath: null` means "let maestro pick" (no --output flag). Junit and
40
- // other declared formats pass an explicit path so downstream upload steps
41
- // know where to find the result.
42
- function buildMaestroArgs(args) {
43
- const out = ['test'];
44
- if (args.output_format) {
45
- out.push(`--format=${args.output_format.toUpperCase()}`);
46
- }
47
- if (args.outputPath) {
48
- out.push(`--output=${args.outputPath}`);
49
- }
50
- if (args.shards !== undefined) {
51
- out.push(`--shard-split=${args.shards}`);
52
- }
53
- if (args.include_tags) {
54
- out.push(`--include-tags=${args.include_tags}`);
55
- }
56
- if (args.exclude_tags) {
57
- out.push(`--exclude-tags=${args.exclude_tags}`);
40
+ function buildMaestroArgs({ backend, platform, flowPaths, output, outputFormat, shards, includeTags, excludeTags, }) {
41
+ switch (backend) {
42
+ case 'maestro': {
43
+ const args = ['test'];
44
+ if (outputFormat) {
45
+ args.push(`--format=${outputFormat.toUpperCase()}`);
46
+ }
47
+ if (output) {
48
+ args.push(`--output=${output}`);
49
+ }
50
+ if (shards !== undefined) {
51
+ args.push(`--shard-split=${shards}`);
52
+ }
53
+ if (includeTags) {
54
+ args.push(`--include-tags=${includeTags}`);
55
+ }
56
+ if (excludeTags) {
57
+ args.push(`--exclude-tags=${excludeTags}`);
58
+ }
59
+ args.push(...flowPaths);
60
+ return { executable: 'maestro', args };
61
+ }
62
+ case 'maestro-runner': {
63
+ const args = [`--platform=${platform}`, 'test', `--output=${output}`, '--flatten'];
64
+ if (includeTags) {
65
+ args.push(`--include-tags=${includeTags}`);
66
+ }
67
+ if (excludeTags) {
68
+ args.push(`--exclude-tags=${excludeTags}`);
69
+ }
70
+ args.push(...flowPaths);
71
+ return { executable: 'maestro-runner', args };
72
+ }
58
73
  }
59
- out.push(...args.flow_path);
60
- return out;
61
74
  }
62
75
  function createMaestroTestsBuildFunction(ctx) {
63
76
  return new steps_1.BuildFunction({
@@ -115,6 +128,11 @@ function createMaestroTestsBuildFunction(ctx) {
115
128
  required: false,
116
129
  allowedValueTypeName: steps_1.BuildStepInputValueTypeName.STRING,
117
130
  }),
131
+ steps_1.BuildStepInput.createProvider({
132
+ id: 'backend',
133
+ required: false,
134
+ allowedValueTypeName: steps_1.BuildStepInputValueTypeName.STRING,
135
+ }),
118
136
  ],
119
137
  outputProviders: [
120
138
  steps_1.BuildStepOutput.createProvider({ id: 'junit_report_directory', required: true }),
@@ -156,12 +174,25 @@ function createMaestroTestsBuildFunction(ctx) {
156
174
  if (finalReportPath !== undefined) {
157
175
  outputs.final_report_path.set(finalReportPath);
158
176
  }
177
+ // Resolved after the output assignments above: an invalid backend input or
178
+ // EAS_MAESTRO_BACKEND throws, and downstream `if: always()` upload steps still
179
+ // need the outputs interpolated.
180
+ const backend = (0, maestroBackend_1.resolveMaestroBackend)({
181
+ input: inputs.backend.value,
182
+ env,
183
+ });
159
184
  const flowPaths = parseInput(FlowPathSchema, inputs.flow_path.value, 'flow_path must be a non-empty array of non-empty strings.');
160
185
  const retries = parseInput(RetriesSchema, inputs.retries.value, 'retries must be a non-negative integer.');
161
186
  const shards = parseInput(ShardsSchema, inputs.shards.value, 'shards must be a positive integer.');
162
187
  const androidConnectionMode = parseInput(AndroidConnectionModeSchema, inputs.android_connection_mode.value ||
163
188
  env.EAS_MAESTRO_ANDROID_CONNECTION_MODE ||
164
189
  undefined, 'android_connection_mode and EAS_MAESTRO_ANDROID_CONNECTION_MODE must be either "adb" or "dadb".');
190
+ if (backend === 'maestro-runner' && shards !== undefined && shards > 1) {
191
+ throw new eas_build_job_1.UserError('ERR_MAESTRO_INVALID_INPUT', 'maestro-runner does not support EAS Maestro test sharding. Remove shards or set it to 1.');
192
+ }
193
+ if (backend === 'maestro-runner' && outputFormat !== undefined && outputFormat !== 'junit') {
194
+ throw new eas_build_job_1.UserError('ERR_MAESTRO_INVALID_INPUT', `maestro-runner only supports the "junit" output_format, but received "${outputFormat}".`);
195
+ }
165
196
  const retryFailedOnly = inputs.retry_failed_only.value;
166
197
  try {
167
198
  await promises_1.default.mkdir(junitReportDirectory, { recursive: true });
@@ -169,7 +200,7 @@ function createMaestroTestsBuildFunction(ctx) {
169
200
  catch (err) {
170
201
  throw new eas_build_job_1.SystemError('Failed to create JUnit report directory', { cause: err });
171
202
  }
172
- // Legacy-only (Maestro < 2.6.0 reports carry no `file=` attribute): the
203
+ // Official Maestro legacy-only (Maestro < 2.6.0 reports carry no `file=` attribute): the
173
204
  // flow scan is built lazily in the retry branch below and memoized so
174
205
  // retries share one scan. Never runs when the report has `file=`.
175
206
  let nameToPathPromise;
@@ -178,14 +209,20 @@ function createMaestroTestsBuildFunction(ctx) {
178
209
  // numeric err.status → maestro exited non-zero → retry.
179
210
  // else (signal-only, OOM kill, unknown) → infra → SystemError, never
180
211
  // downgraded to "tests failed".
181
- // Retry-failed-only (junit mode): after a failed attempt, subset to the failing
182
- // flows. The failed-flow parsers return null when the JUnit cannot be
183
- // trusted; we then fall through to dumb retry (re-run everything).
212
+ // Retry-failed-only: after a failed attempt, subset to the failing flows. The
213
+ // failed-flow parsers return null when a report cannot be trusted; we then fall
214
+ // through to dumb retry (re-run everything).
184
215
  let flowsToRun = flowPaths;
185
216
  let lastAttemptExitCode = null;
186
217
  const harvested = [];
218
+ const reportDirectories = backend === 'maestro' ? [junitReportDirectory] : [];
187
219
  const totalAttempts = retries + 1;
188
- if (platform === 'android' && androidConnectionMode === 'dadb') {
220
+ if (backend === 'maestro-runner' &&
221
+ platform === 'android' &&
222
+ androidConnectionMode === 'dadb') {
223
+ logger.info('maestro-runner does not support DADB. Using the default ADB connection.');
224
+ }
225
+ if (backend === 'maestro' && platform === 'android' && androidConnectionMode === 'dadb') {
189
226
  try {
190
227
  const adbOverrideDirectoryPath = await promises_1.default.mkdtemp(path_1.default.join(os_1.default.tmpdir(), 'maestro-tests-adb-override-'));
191
228
  await promises_1.default.writeFile(path_1.default.join(adbOverrideDirectoryPath, 'adb'), '#!/bin/sh\nexit 1\n', {
@@ -213,23 +250,37 @@ function createMaestroTestsBuildFunction(ctx) {
213
250
  // Do not restart ADB or remove the override. This keeps Maestro in direct DADB mode.
214
251
  }
215
252
  for (let attempt = 0; attempt <= retries; attempt++) {
253
+ // maestro-runner writes its JUnit report and screenshot metadata to this directory.
254
+ const runnerOutputDirectory = path_1.default.join(testsDirectory, `${platform}-maestro-runner-attempt-${attempt}`);
216
255
  const outputPath = outputFormat === 'junit'
217
256
  ? path_1.default.join(junitReportDirectory, `${platform}-maestro-junit-attempt-${attempt}.xml`)
218
- : outputFormat
257
+ : backend === 'maestro' && outputFormat
219
258
  ? path_1.default.join(testsDirectory, `${platform}-maestro-${outputFormat}.${outputFormat}`)
220
259
  : null;
221
- const maestroArgs = buildMaestroArgs({
222
- flow_path: flowsToRun,
223
- outputPath,
224
- output_format: outputFormat,
260
+ const { executable, args: maestroArgs } = buildMaestroArgs({
261
+ backend,
262
+ platform,
263
+ flowPaths: flowsToRun,
264
+ output: backend === 'maestro-runner' ? runnerOutputDirectory : outputPath,
265
+ outputFormat,
225
266
  shards,
226
- include_tags: includeTags,
227
- exclude_tags: excludeTags,
267
+ includeTags,
268
+ excludeTags,
228
269
  });
229
- logger.info(`Running maestro (attempt ${attempt + 1}/${totalAttempts}): maestro ${maestroArgs.join(' ')}`);
270
+ logger.info(`Running ${executable} (attempt ${attempt + 1}/${totalAttempts}): ${executable} ${maestroArgs.join(' ')}`);
271
+ // The runner output directory is deterministic and can survive a prior run; clear it
272
+ // best-effort so a crash before it writes fresh output can't resurrect stale results.
273
+ if (backend === 'maestro-runner') {
274
+ try {
275
+ await promises_1.default.rm(runnerOutputDirectory, { recursive: true, force: true });
276
+ }
277
+ catch (err) {
278
+ logger.warn({ err }, `Failed to clear ${runnerOutputDirectory} before the attempt.`);
279
+ }
280
+ }
230
281
  const attemptStartedAtMs = Date.now();
231
282
  try {
232
- await (0, turtle_spawn_1.default)('maestro', maestroArgs, {
283
+ await (0, turtle_spawn_1.default)(executable, maestroArgs, {
233
284
  cwd: stepCtx.workingDirectory,
234
285
  env: spawnEnv,
235
286
  logger,
@@ -239,53 +290,100 @@ function createMaestroTestsBuildFunction(ctx) {
239
290
  }
240
291
  catch (err) {
241
292
  if (err && (err.code === 'ENOENT' || err.code === 'EACCES')) {
242
- throw new eas_build_job_1.SystemError('Failed to invoke maestro', { cause: err });
293
+ throw new eas_build_job_1.SystemError(`Failed to invoke ${executable}`, { cause: err });
243
294
  }
244
295
  if (err && typeof err.status === 'number') {
245
296
  lastAttemptExitCode = err.status;
246
297
  }
247
298
  else {
248
- throw new eas_build_job_1.SystemError('Unexpected spawn failure invoking maestro', { cause: err });
299
+ throw new eas_build_job_1.SystemError(`Unexpected spawn failure invoking ${executable}`, {
300
+ cause: err,
301
+ });
302
+ }
303
+ }
304
+ // maestro-runner writes a report directory. Copy its JUnit file into the existing
305
+ // per-attempt directory so retry parsing and final report merging remain shared.
306
+ if (backend === 'maestro-runner' && outputPath) {
307
+ try {
308
+ await promises_1.default.copyFile(path_1.default.join(runnerOutputDirectory, 'junit-report.xml'), outputPath);
309
+ }
310
+ catch (err) {
311
+ logger.warn({ err }, `Failed to collect the maestro-runner JUnit report for attempt ${attempt + 1}.`);
249
312
  }
250
313
  }
314
+ if (backend === 'maestro-runner') {
315
+ reportDirectories.push(runnerOutputDirectory);
316
+ }
251
317
  // Harvest this attempt's failure screenshots before any retry subsetting. Gated on
252
318
  // junit: test-case-result rows (and therefore the summary icons) only exist for junit
253
319
  // runs, so harvesting other formats would just create orphan artifacts the website hides.
254
320
  if (outputFormat === 'junit') {
255
- const failedFlowNames = outputPath
256
- ? await (0, maestroResultParser_1.parseFailedFlowNamesFromJUnitFile)(outputPath)
257
- : new Set();
258
- harvested.push(...(await (0, maestroScreenshots_1.harvestFailureScreenshotsAsync)({
259
- testsDirectory,
260
- capturedSinceMs: attemptStartedAtMs,
261
- attemptIndex: attempt,
262
- failedFlowNames,
263
- logger,
264
- })));
321
+ let screenshots;
322
+ switch (backend) {
323
+ case 'maestro': {
324
+ const failedFlowNames = outputPath
325
+ ? await (0, maestroResultParser_1.parseFailedFlowNamesFromJUnitFile)(outputPath)
326
+ : new Set();
327
+ screenshots = await (0, maestroScreenshots_1.harvestFailureScreenshotsAsync)({
328
+ testsDirectory,
329
+ capturedSinceMs: attemptStartedAtMs,
330
+ attemptIndex: attempt,
331
+ failedFlowNames,
332
+ logger,
333
+ });
334
+ break;
335
+ }
336
+ case 'maestro-runner':
337
+ screenshots = await (0, maestroScreenshots_1.harvestMaestroRunnerFailureScreenshotsAsync)({
338
+ reportDirectory: runnerOutputDirectory,
339
+ capturedSinceMs: attemptStartedAtMs,
340
+ attemptIndex: attempt,
341
+ logger,
342
+ });
343
+ break;
344
+ }
345
+ harvested.push(...screenshots);
265
346
  }
266
347
  if (lastAttemptExitCode === 0 || attempt === retries) {
267
348
  break;
268
349
  }
269
- if (retryFailedOnly && outputFormat === 'junit' && outputPath) {
350
+ if (retryFailedOnly &&
351
+ (backend === 'maestro-runner' || (outputFormat === 'junit' && outputPath))) {
270
352
  let failed;
271
- if (await (0, maestroResultParser_1.junitFileHasFileAttrs)(outputPath)) {
272
- failed = await (0, maestroResultParser_1.parseFailedFlowsFromFileAttrs)({
273
- junitFile: outputPath,
274
- workingDirectory: stepCtx.workingDirectory,
275
- });
276
- }
277
- else {
278
- // Legacy (Maestro < 2.6.0): map failed testcase names back to flow
279
- // paths via the flow-file scan. DELETE this arm once the fleet is
280
- // on >= 2.6.0.
281
- const nameToPath = await (nameToPathPromise ??= (0, maestroFlowDiscovery_1.buildFlowNameToPathMap)({
282
- inputFlowPaths: flowPaths,
283
- projectRoot: stepCtx.workingDirectory,
284
- logger,
285
- }));
286
- failed = nameToPath
287
- ? await (0, maestroResultParser_1.parseFailedFlowsFromJUnit)({ junitFile: outputPath, nameToPath })
288
- : null;
353
+ switch (backend) {
354
+ case 'maestro-runner':
355
+ failed = await (0, maestroResultParser_1.parseFailedFlowsFromMaestroRunnerReport)({
356
+ reportDirectory: runnerOutputDirectory,
357
+ workingDirectory: stepCtx.workingDirectory,
358
+ });
359
+ break;
360
+ case 'maestro':
361
+ if (!outputPath) {
362
+ failed = null;
363
+ break;
364
+ }
365
+ if (await (0, maestroResultParser_1.junitFileHasFileAttrs)(outputPath)) {
366
+ failed = await (0, maestroResultParser_1.parseFailedFlowsFromFileAttrs)({
367
+ junitFile: outputPath,
368
+ workingDirectory: stepCtx.workingDirectory,
369
+ });
370
+ break;
371
+ }
372
+ // Legacy (Maestro < 2.6.0): map failed testcase names back to flow
373
+ // paths via the flow-file scan. DELETE this arm once the fleet is
374
+ // on >= 2.6.0.
375
+ const nameToPath = await (nameToPathPromise ??= (0, maestroFlowDiscovery_1.buildFlowNameToPathMap)({
376
+ inputFlowPaths: flowPaths,
377
+ projectRoot: stepCtx.workingDirectory,
378
+ logger,
379
+ }));
380
+ failed = nameToPath
381
+ ? await (0, maestroResultParser_1.parseFailedFlowsFromJUnit)({
382
+ junitFile: outputPath,
383
+ nameToPath,
384
+ })
385
+ : null;
386
+ break;
289
387
  }
290
388
  if (failed !== null && failed.length > 0) {
291
389
  flowsToRun = failed;
@@ -335,7 +433,13 @@ function createMaestroTestsBuildFunction(ctx) {
335
433
  // Upload before the ERR_MAESTRO_TESTS_FAILED throw below so fully-failed runs (which need
336
434
  // screenshots most) still upload. Harvest only ran for junit, so guard the same way.
337
435
  if (outputFormat === 'junit') {
338
- await uploadFailureScreenshotsAsync({ harvested, junitReportDirectory, ctx, logger });
436
+ await uploadFailureScreenshotsAsync({
437
+ harvested,
438
+ backend,
439
+ reportDirectories,
440
+ ctx,
441
+ logger,
442
+ });
339
443
  }
340
444
  // The retry loop exits via success (0), numeric status (retryable),
341
445
  // or throw (infra). A non-null non-zero status means the user's tests
@@ -349,14 +453,28 @@ function createMaestroTestsBuildFunction(ctx) {
349
453
  // Reduce harvested failure screenshots to what's worth uploading, then upload them as workflow
350
454
  // artifacts. Best-effort and verdict-neutral: never throws, so a screenshot problem can't mask
351
455
  // the maestro test result. Caller guards on junit (harvest only runs for junit).
352
- async function uploadFailureScreenshotsAsync({ harvested, junitReportDirectory, ctx, logger, }) {
456
+ async function uploadFailureScreenshotsAsync({ harvested, backend, reportDirectories, ctx, logger, }) {
353
457
  // Reduce to the attempts worth uploading — every failed attempt for flaky flows, only the final
354
458
  // attempt for all-failed flows. See computePureFailureFlowNames / selectFailureScreenshots.
355
459
  // Guard the JUnit re-parse so a malformed/missing report can't throw past here and mask the
356
460
  // test verdict (the whole step is verdict-neutral for screenshots).
357
461
  let selected;
358
462
  try {
359
- const pureFailureFlowNames = (0, maestroScreenshots_1.computePureFailureFlowNames)(await (0, maestroResultParser_1.parseJUnitTestCases)(junitReportDirectory));
463
+ let flowResults;
464
+ switch (backend) {
465
+ case 'maestro':
466
+ flowResults = (await Promise.all(reportDirectories.map(directory => (0, maestroResultParser_1.parseJUnitTestCases)(directory)))).flat();
467
+ break;
468
+ case 'maestro-runner': {
469
+ const results = await Promise.all(reportDirectories.map(directory => (0, maestroResultParser_1.parseMaestroRunnerReport)(directory)));
470
+ // Use whichever reports parsed. An unreadable report (e.g. a final retry that crashed
471
+ // before writing report.json) contributes no flows rather than discarding screenshots
472
+ // harvested from the attempts that did report — mirroring the maestro path above.
473
+ flowResults = results.flatMap(result => result?.flows ?? []);
474
+ break;
475
+ }
476
+ }
477
+ const pureFailureFlowNames = (0, maestroScreenshots_1.computePureFailureFlowNames)(flowResults);
360
478
  selected = (0, maestroScreenshots_1.selectFailureScreenshots)(harvested, pureFailureFlowNames);
361
479
  }
362
480
  catch (err) {
@@ -3,6 +3,7 @@ export type IpaInfo = {
3
3
  bundleIdentifier: string;
4
4
  bundleShortVersion: string;
5
5
  bundleVersion: string;
6
+ dtPlatformName: string | null;
6
7
  };
7
8
  export declare function createReadIpaInfoBuildFunction(): BuildFunction;
8
9
  export declare function readIpaInfoAsync(ipaPath: string): Promise<IpaInfo>;
@@ -70,10 +70,12 @@ async function readIpaInfoAsync(ipaPath) {
70
70
  if (typeof bundleVersion !== 'string') {
71
71
  throw new eas_build_job_1.UserError('EAS_READ_IPA_INFO_INVALID_INFO_PLIST', 'Failed to read IPA info: Missing or invalid CFBundleVersion in Info.plist');
72
72
  }
73
+ const dtPlatformName = typeof infoPlist.DTPlatformName === 'string' ? infoPlist.DTPlatformName : null;
73
74
  return {
74
75
  bundleIdentifier,
75
76
  bundleShortVersion,
76
77
  bundleVersion,
78
+ dtPlatformName,
77
79
  };
78
80
  }
79
81
  catch (error) {
@@ -11,11 +11,14 @@ export declare function resolveAndroidSigningOptionsAsync({ job, tmpDir, }: {
11
11
  tmpDir: string;
12
12
  }): Promise<AndroidSigningOptions | undefined>;
13
13
  /**
14
- * Resolves iOS signing options from the job secrets.
14
+ * Resolves iOS signing options from the job secrets, dispatching on the
15
+ * requested signing backend.
15
16
  */
16
- export declare function resolveIosSigningOptionsAsync({ job, logger, useAppEntitlements, entitlementsPath, }: {
17
+ export declare function resolveIosSigningOptionsAsync({ job, logger, backend, useAppEntitlements, entitlementsPath, tmpDir, }: {
17
18
  job: Job;
18
19
  logger: bunyan;
20
+ backend?: 'fastlane' | 'zsign';
19
21
  useAppEntitlements?: boolean;
20
22
  entitlementsPath?: string;
23
+ tmpDir: string;
21
24
  }): Promise<IosSigningOptions | undefined>;
@@ -53,6 +53,12 @@ function createRepackBuildFunction() {
53
53
  allowedValueTypeName: steps_1.BuildStepInputValueTypeName.STRING,
54
54
  required: false,
55
55
  }),
56
+ steps_1.BuildStepInput.createProvider({
57
+ id: 'ios_signing_backend',
58
+ allowedValueTypeName: steps_1.BuildStepInputValueTypeName.STRING,
59
+ required: false,
60
+ allowedValues: ['fastlane', 'zsign'],
61
+ }),
56
62
  steps_1.BuildStepInput.createProvider({
57
63
  id: 'repack_version',
58
64
  allowedValueTypeName: steps_1.BuildStepInputValueTypeName.STRING,
@@ -123,8 +129,10 @@ function createRepackBuildFunction() {
123
129
  iosSigningOptions: await resolveIosSigningOptionsAsync({
124
130
  job: stepsCtx.global.staticContext.job,
125
131
  logger: stepsCtx.logger,
132
+ backend: inputs.ios_signing_backend.value,
126
133
  useAppEntitlements: inputs.ios_signing_use_source_app_entitlements.value,
127
134
  entitlementsPath: inputs.ios_signing_app_entitlements_path.value,
135
+ tmpDir,
128
136
  }),
129
137
  logger: stepsCtx.logger,
130
138
  spawnAsync: repackSpawnAsync,
@@ -234,14 +242,26 @@ async function resolveAndroidSigningOptionsAsync({ job, tmpDir, }) {
234
242
  };
235
243
  }
236
244
  /**
237
- * Resolves iOS signing options from the job secrets.
245
+ * Resolves iOS signing options from the job secrets, dispatching on the
246
+ * requested signing backend.
238
247
  */
239
- async function resolveIosSigningOptionsAsync({ job, logger, useAppEntitlements, entitlementsPath, }) {
248
+ async function resolveIosSigningOptionsAsync({ job, logger, backend, useAppEntitlements, entitlementsPath, tmpDir, }) {
240
249
  const iosJob = job;
241
250
  const buildCredentials = iosJob.secrets?.buildCredentials;
242
251
  if (iosJob.simulator || buildCredentials == null) {
243
252
  return undefined;
244
253
  }
254
+ const commonOptions = { buildCredentials, logger, useAppEntitlements, entitlementsPath };
255
+ return backend === 'zsign'
256
+ ? await createIosZsignOptionsAsync({ ...commonOptions, tmpDir })
257
+ : await createIosFastlaneOptionsAsync(commonOptions);
258
+ }
259
+ /**
260
+ * Creates signing options for the fastlane backend: certificates are imported
261
+ * into a temporary keychain and provisioning profiles are parsed with the
262
+ * macOS `security` tool.
263
+ */
264
+ async function createIosFastlaneOptionsAsync({ buildCredentials, logger, useAppEntitlements, entitlementsPath, }) {
245
265
  const credentialsManager = new manager_1.default(buildCredentials);
246
266
  const credentials = await credentialsManager.prepare(logger);
247
267
  const provisioningProfile = {};
@@ -256,3 +276,31 @@ async function resolveIosSigningOptionsAsync({ job, logger, useAppEntitlements,
256
276
  entitlementsPath,
257
277
  };
258
278
  }
279
+ /**
280
+ * Creates signing options for the zsign backend. The distribution certificate
281
+ * secret is already a PKCS#12 file, so it goes to disk as-is together with the
282
+ * provisioning profiles.
283
+ */
284
+ async function createIosZsignOptionsAsync({ buildCredentials, logger, useAppEntitlements, entitlementsPath, tmpDir, }) {
285
+ const targets = Object.entries(buildCredentials);
286
+ const [targetName, targetCredentials] = targets[0];
287
+ logger.info(`Using the distribution certificate from target '${targetName}' for zsign`);
288
+ const certificatePath = node_path_1.default.join(tmpDir, `dist-cert-${(0, node_crypto_1.randomUUID)()}.p12`);
289
+ await node_fs_1.default.promises.writeFile(certificatePath, new Uint8Array(Buffer.from(targetCredentials.distributionCertificate.dataBase64, 'base64')));
290
+ // zsign matches profiles to bundles by the app-id suffix itself, so the
291
+ // record keys are informational only.
292
+ const provisioningProfile = {};
293
+ for (const [target, credentials] of targets) {
294
+ const profilePath = node_path_1.default.join(tmpDir, `profile-${target}-${(0, node_crypto_1.randomUUID)()}.mobileprovision`);
295
+ await node_fs_1.default.promises.writeFile(profilePath, new Uint8Array(Buffer.from(credentials.provisioningProfileBase64, 'base64')));
296
+ provisioningProfile[target] = profilePath;
297
+ }
298
+ return {
299
+ backend: 'zsign',
300
+ certificatePath,
301
+ keyPassword: targetCredentials.distributionCertificate.password,
302
+ provisioningProfile,
303
+ useAppEntitlements,
304
+ entitlementsPath,
305
+ };
306
+ }
@@ -1,12 +1,12 @@
1
- import { Platform } from '@expo/eas-build-job';
2
1
  import { bunyan } from '@expo/logger';
3
2
  import { BuildFunction } from '@expo/steps';
3
+ import { CcacheBuildTarget } from '../../utils/cacheKey';
4
4
  export declare function createRestoreBuildCacheFunction(): BuildFunction;
5
5
  export declare function createCacheStatsBuildFunction(): BuildFunction;
6
- export declare function restoreCcacheAsync({ logger, workingDirectory, platform, env, secrets, }: {
6
+ export declare function restoreCcacheAsync({ logger, workingDirectory, target, env, secrets, }: {
7
7
  logger: bunyan;
8
8
  workingDirectory: string;
9
- platform: Platform;
9
+ target: CcacheBuildTarget;
10
10
  env: Record<string, string | undefined>;
11
11
  secrets?: {
12
12
  robotAccessToken?: string;
@@ -33,6 +33,11 @@ function createRestoreBuildCacheFunction() {
33
33
  required: false,
34
34
  allowedValueTypeName: steps_1.BuildStepInputValueTypeName.STRING,
35
35
  }),
36
+ steps_1.BuildStepInput.createProvider({
37
+ id: 'simulator',
38
+ required: false,
39
+ allowedValueTypeName: steps_1.BuildStepInputValueTypeName.BOOLEAN,
40
+ }),
36
41
  ],
37
42
  fn: async (stepCtx, { env, inputs }) => {
38
43
  const { logger } = stepCtx;
@@ -42,10 +47,18 @@ function createRestoreBuildCacheFunction() {
42
47
  if (!platform || ![eas_build_job_1.Platform.ANDROID, eas_build_job_1.Platform.IOS].includes(platform)) {
43
48
  throw new Error(`Unsupported platform: ${platform}. Platform must be "${eas_build_job_1.Platform.ANDROID}" or "${eas_build_job_1.Platform.IOS}"`);
44
49
  }
50
+ const target = platform === eas_build_job_1.Platform.IOS
51
+ ? {
52
+ platform,
53
+ simulator: inputs.simulator.value ??
54
+ (stepCtx.global.staticContext.job.platform === eas_build_job_1.Platform.IOS &&
55
+ stepCtx.global.staticContext.job.simulator === true),
56
+ }
57
+ : { platform };
45
58
  await restoreCcacheAsync({
46
59
  logger,
47
60
  workingDirectory,
48
- platform,
61
+ target,
49
62
  env,
50
63
  secrets: stepCtx.global.staticContext.job.secrets,
51
64
  });
@@ -80,7 +93,7 @@ function createCacheStatsBuildFunction() {
80
93
  },
81
94
  });
82
95
  }
83
- async function restoreCcacheAsync({ logger, workingDirectory, platform, env, secrets, }) {
96
+ async function restoreCcacheAsync({ logger, workingDirectory, target, env, secrets, }) {
84
97
  const enabled = env.EAS_RESTORE_CACHE === '1' || (env.EAS_USE_CACHE === '1' && env.EAS_RESTORE_CACHE !== '0');
85
98
  if (!enabled) {
86
99
  return;
@@ -103,7 +116,7 @@ async function restoreCcacheAsync({ logger, workingDirectory, platform, env, sec
103
116
  env,
104
117
  stdio: 'pipe',
105
118
  }));
106
- const cacheKey = await (0, cacheKey_1.generateDefaultBuildCacheKeyAsync)(workingDirectory, platform);
119
+ const cacheKey = await (0, cacheKey_1.generateDefaultBuildCacheKeyAsync)(workingDirectory, target);
107
120
  logger.info(`Restoring cache key: ${cacheKey}`);
108
121
  const jobId = (0, nullthrows_1.default)(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
109
122
  const { archivePath, matchedKey } = await (0, restoreCache_1.downloadCacheAsync)({
@@ -113,8 +126,8 @@ async function restoreCcacheAsync({ logger, workingDirectory, platform, env, sec
113
126
  robotAccessToken,
114
127
  paths: [cachePath],
115
128
  key: cacheKey,
116
- keyPrefixes: [cacheKey_1.CACHE_KEY_PREFIX_BY_PLATFORM[platform]],
117
- platform,
129
+ keyPrefixes: [(0, cacheKey_1.getCcacheKeyPrefix)(target)],
130
+ platform: target.platform,
118
131
  });
119
132
  await (0, restoreCache_1.decompressCacheAsync)({
120
133
  archivePath,
@@ -139,7 +152,7 @@ async function restoreCcacheAsync({ logger, workingDirectory, platform, env, sec
139
152
  expoApiServerURL,
140
153
  robotAccessToken,
141
154
  paths: [cachePath],
142
- platform,
155
+ platform: target.platform,
143
156
  });
144
157
  await (0, restoreCache_1.decompressCacheAsync)({
145
158
  archivePath,
@@ -201,12 +214,13 @@ async function restoreGradleCacheAsync({ logger, workingDirectory, env, secrets,
201
214
  logger.info(`Restoring Gradle cache key: ${cacheKey}`);
202
215
  const gradleCachesPath = path_1.default.join(os_1.default.homedir(), '.gradle', 'caches');
203
216
  const buildCachePath = path_1.default.join(gradleCachesPath, 'build-cache-1');
217
+ const journalPath = path_1.default.join(gradleCachesPath, 'journal-1');
204
218
  const { archivePath, matchedKey } = await (0, restoreCache_1.downloadCacheAsync)({
205
219
  logger,
206
220
  jobId,
207
221
  expoApiServerURL,
208
222
  robotAccessToken,
209
- paths: [buildCachePath],
223
+ paths: [buildCachePath, journalPath],
210
224
  key: cacheKey,
211
225
  keyPrefixes: [gradleCacheKey_1.GRADLE_CACHE_KEY_PREFIX],
212
226
  platform: eas_build_job_1.Platform.ANDROID,
@@ -1,11 +1,11 @@
1
- import { Platform } from '@expo/eas-build-job';
2
1
  import { bunyan } from '@expo/logger';
3
2
  import { BuildFunction } from '@expo/steps';
3
+ import { CcacheBuildTarget } from '../../utils/cacheKey';
4
4
  export declare function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFunction;
5
- export declare function saveCcacheAsync({ logger, workingDirectory, platform, evictUsedBefore, env, secrets, }: {
5
+ export declare function saveCcacheAsync({ logger, workingDirectory, target, evictUsedBefore, env, secrets, }: {
6
6
  logger: bunyan;
7
7
  workingDirectory: string;
8
- platform: Platform;
8
+ target: CcacheBuildTarget;
9
9
  evictUsedBefore: Date;
10
10
  env: Record<string, string | undefined>;
11
11
  secrets?: {