@expo/build-tools 18.2.0 → 18.4.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 (36) hide show
  1. package/dist/android/gradle.js +1 -1
  2. package/dist/buildErrors/detectError.d.ts +1 -1
  3. package/dist/buildErrors/detectError.js +8 -12
  4. package/dist/buildErrors/userErrorHandlers.d.ts +2 -2
  5. package/dist/buildErrors/userErrorHandlers.js +20 -20
  6. package/dist/builders/android.js +12 -2
  7. package/dist/builders/custom.js +1 -0
  8. package/dist/common/setup.js +4 -6
  9. package/dist/context.d.ts +2 -0
  10. package/dist/context.js +2 -0
  11. package/dist/customBuildContext.d.ts +5 -1
  12. package/dist/customBuildContext.js +22 -0
  13. package/dist/generic.d.ts +1 -3
  14. package/dist/generic.js +13 -15
  15. package/dist/ios/credentials/provisioningProfile.d.ts +2 -1
  16. package/dist/ios/credentials/provisioningProfile.js +10 -11
  17. package/dist/steps/functions/downloadArtifact.js +3 -3
  18. package/dist/steps/functions/downloadBuild.js +2 -2
  19. package/dist/steps/functions/internalMaestroTest.js +8 -12
  20. package/dist/steps/functions/maestroResultParser.js +134 -80
  21. package/dist/steps/functions/readIpaInfo.js +9 -9
  22. package/dist/steps/functions/reportMaestroTestResults.js +14 -7
  23. package/dist/steps/functions/uploadToAsc.js +4 -4
  24. package/dist/steps/utils/android/gradle.js +1 -1
  25. package/dist/steps/utils/android/gradleConfig.d.ts +3 -0
  26. package/dist/steps/utils/android/gradleConfig.js +15 -1
  27. package/dist/steps/utils/ios/AscApiUtils.js +3 -3
  28. package/dist/steps/utils/ios/credentials/provisioningProfile.d.ts +2 -1
  29. package/dist/steps/utils/ios/credentials/provisioningProfile.js +10 -11
  30. package/dist/utils/stepMetrics.d.ts +2 -2
  31. package/dist/utils/stepMetrics.js +4 -10
  32. package/package.json +4 -4
  33. package/dist/android/gradleConfig.d.ts +0 -3
  34. package/dist/android/gradleConfig.js +0 -47
  35. package/dist/templates/EasBuildGradle.d.ts +0 -1
  36. package/dist/templates/EasBuildGradle.js +0 -57
@@ -177,14 +177,14 @@ function createInternalEasMaestroTestFunction(ctx) {
177
177
  const failedFlows = [];
178
178
  for (const [flowIndex, flowPath] of flowPathsToExecute.entries()) {
179
179
  stepCtx.logger.info('');
180
- // If output_format is empty or noop, we won't use this.
181
- const outputPath = node_path_1.default.join(maestroReportsDir, [
182
- `${output_format ? output_format + '-' : ''}report-flow-${flowIndex + 1}`,
183
- MaestroOutputFormatToExtensionMap[output_format ?? 'noop'],
184
- ]
185
- .filter(Boolean)
186
- .join('.'));
187
180
  for (let attemptCount = 0; attemptCount < retries; attemptCount++) {
181
+ // Generate unique report path per attempt (not overwritten on retry)
182
+ const outputPath = node_path_1.default.join(maestroReportsDir, [
183
+ `${output_format ? output_format + '-' : ''}report-flow-${flowIndex + 1}-attempt-${attemptCount}`,
184
+ MaestroOutputFormatToExtensionMap[output_format ?? 'noop'],
185
+ ]
186
+ .filter(Boolean)
187
+ .join('.'));
188
188
  const localDeviceName = `eas-simulator-${flowIndex}-${attemptCount}`;
189
189
  // If the test passes, but the recording fails, we don't want to make the test fail,
190
190
  // so we return two separate results.
@@ -227,11 +227,7 @@ function createInternalEasMaestroTestFunction(ctx) {
227
227
  if (logsResult?.ok) {
228
228
  try {
229
229
  const extension = node_path_1.default.extname(logsResult.value.outputPath);
230
- const destinationPath = node_path_1.default.join(deviceLogsDir, `flow-${flowIndex}${extension}`);
231
- await node_fs_1.default.promises.rm(destinationPath, {
232
- force: true,
233
- recursive: true,
234
- });
230
+ const destinationPath = node_path_1.default.join(deviceLogsDir, `flow-${flowIndex}-attempt-${attemptCount}${extension}`);
235
231
  await node_fs_1.default.promises.rename(logsResult.value.outputPath, destinationPath);
236
232
  }
237
233
  catch (err) {
@@ -23,6 +23,68 @@ const xmlParser = new fast_xml_parser_1.XMLParser({
23
23
  // Ensure single-element arrays are always arrays
24
24
  isArray: name => ['testsuite', 'testcase', 'property'].includes(name),
25
25
  });
26
+ // Internal helper — not exported. Parses a single JUnit XML file.
27
+ async function parseJUnitFile(filePath) {
28
+ const results = [];
29
+ try {
30
+ const content = await promises_1.default.readFile(filePath, 'utf-8');
31
+ const parsed = xmlParser.parse(content);
32
+ const testsuites = parsed?.testsuites?.testsuite;
33
+ if (!Array.isArray(testsuites)) {
34
+ return results;
35
+ }
36
+ for (const suite of testsuites) {
37
+ const testcases = suite?.testcase;
38
+ if (!Array.isArray(testcases)) {
39
+ continue;
40
+ }
41
+ for (const tc of testcases) {
42
+ const name = tc['@_name'];
43
+ if (!name) {
44
+ continue;
45
+ }
46
+ const timeStr = tc['@_time'];
47
+ const timeSeconds = timeStr ? parseFloat(timeStr) : 0;
48
+ const duration = Number.isFinite(timeSeconds) ? Math.round(timeSeconds * 1000) : 0;
49
+ const status = tc['@_status'] === 'SUCCESS' ? 'passed' : 'failed';
50
+ const failureText = tc.failure != null
51
+ ? typeof tc.failure === 'string'
52
+ ? tc.failure
53
+ : (tc.failure?.['#text'] ?? null)
54
+ : null;
55
+ const errorText = tc.error != null
56
+ ? typeof tc.error === 'string'
57
+ ? tc.error
58
+ : (tc.error?.['#text'] ?? null)
59
+ : null;
60
+ const errorMessage = failureText ?? errorText ?? null;
61
+ const rawProperties = tc.properties?.property ?? [];
62
+ const properties = {};
63
+ for (const prop of rawProperties) {
64
+ const propName = prop['@_name'];
65
+ const value = prop['@_value'];
66
+ if (typeof propName !== 'string' || typeof value !== 'string') {
67
+ continue;
68
+ }
69
+ properties[propName] = value;
70
+ }
71
+ const tagsValue = properties['tags'];
72
+ const tags = tagsValue
73
+ ? tagsValue
74
+ .split(',')
75
+ .map(t => t.trim())
76
+ .filter(Boolean)
77
+ : [];
78
+ delete properties['tags'];
79
+ results.push({ name, status, duration, errorMessage, tags, properties });
80
+ }
81
+ }
82
+ }
83
+ catch {
84
+ // Skip malformed XML files
85
+ }
86
+ return results;
87
+ }
26
88
  async function parseJUnitTestCases(junitDirectory) {
27
89
  let entries;
28
90
  try {
@@ -37,68 +99,7 @@ async function parseJUnitTestCases(junitDirectory) {
37
99
  }
38
100
  const results = [];
39
101
  for (const xmlFile of xmlFiles) {
40
- try {
41
- const content = await promises_1.default.readFile(path_1.default.join(junitDirectory, xmlFile), 'utf-8');
42
- const parsed = xmlParser.parse(content);
43
- const testsuites = parsed?.testsuites?.testsuite;
44
- if (!Array.isArray(testsuites)) {
45
- continue;
46
- }
47
- for (const suite of testsuites) {
48
- const testcases = suite?.testcase;
49
- if (!Array.isArray(testcases)) {
50
- continue;
51
- }
52
- for (const tc of testcases) {
53
- const name = tc['@_name'];
54
- if (!name) {
55
- continue;
56
- }
57
- const timeStr = tc['@_time'];
58
- const timeSeconds = timeStr ? parseFloat(timeStr) : 0;
59
- const duration = Number.isFinite(timeSeconds) ? Math.round(timeSeconds * 1000) : 0;
60
- // Use @_status as primary indicator (more robust than checking <failure> presence)
61
- const status = tc['@_status'] === 'SUCCESS' ? 'passed' : 'failed';
62
- // Extract error message from <failure> or <error> elements
63
- const failureText = tc.failure != null
64
- ? typeof tc.failure === 'string'
65
- ? tc.failure
66
- : (tc.failure?.['#text'] ?? null)
67
- : null;
68
- const errorText = tc.error != null
69
- ? typeof tc.error === 'string'
70
- ? tc.error
71
- : (tc.error?.['#text'] ?? null)
72
- : null;
73
- const errorMessage = failureText ?? errorText ?? null;
74
- // Extract properties
75
- const rawProperties = tc.properties?.property ?? [];
76
- const properties = {};
77
- for (const prop of rawProperties) {
78
- const propName = prop['@_name'];
79
- const value = prop['@_value'];
80
- if (typeof propName !== 'string' || typeof value !== 'string') {
81
- continue;
82
- }
83
- properties[propName] = value;
84
- }
85
- // Extract tags from "tags" property (Maestro 2.2.0+, comma-separated)
86
- const tagsValue = properties['tags'];
87
- const tags = tagsValue
88
- ? tagsValue
89
- .split(',')
90
- .map(t => t.trim())
91
- .filter(Boolean)
92
- : [];
93
- delete properties['tags'];
94
- results.push({ name, status, duration, errorMessage, tags, properties });
95
- }
96
- }
97
- }
98
- catch {
99
- // Skip malformed XML files
100
- continue;
101
- }
102
+ results.push(...(await parseJUnitFile(path_1.default.join(junitDirectory, xmlFile))));
102
103
  }
103
104
  return results;
104
105
  }
@@ -130,9 +131,26 @@ async function parseFlowMetadata(filePath) {
130
131
  }
131
132
  }
132
133
  async function parseMaestroResults(junitDirectory, testsDirectory, projectRoot) {
133
- // 1. Parse JUnit XML files (primary source)
134
- const junitResults = await parseJUnitTestCases(junitDirectory);
135
- if (junitResults.length === 0) {
134
+ // 1. Parse JUnit XML files, tracking which file each result came from
135
+ let junitEntries;
136
+ try {
137
+ junitEntries = await promises_1.default.readdir(junitDirectory);
138
+ }
139
+ catch {
140
+ return [];
141
+ }
142
+ const xmlFiles = junitEntries.filter(f => f.endsWith('.xml')).sort();
143
+ if (xmlFiles.length === 0) {
144
+ return [];
145
+ }
146
+ const junitResultsWithSource = [];
147
+ for (const xmlFile of xmlFiles) {
148
+ const fileResults = await parseJUnitFile(path_1.default.join(junitDirectory, xmlFile));
149
+ for (const result of fileResults) {
150
+ junitResultsWithSource.push({ result, sourceFile: xmlFile });
151
+ }
152
+ }
153
+ if (junitResultsWithSource.length === 0) {
136
154
  return [];
137
155
  }
138
156
  // 2. Parse ai-*.json from debug output for flow_file_path + retryCount
@@ -172,23 +190,59 @@ async function parseMaestroResults(junitDirectory, testsDirectory, projectRoot)
172
190
  }
173
191
  // 3. Merge: JUnit results + ai-*.json metadata
174
192
  const results = [];
175
- for (const junit of junitResults) {
176
- const flowFilePath = flowPathMap.get(junit.name);
193
+ // Parse attempt index from filename pattern: *-attempt-N.*
194
+ const ATTEMPT_PATTERN = /attempt-(\d+)/;
195
+ // Group results by flow name
196
+ const resultsByName = new Map();
197
+ for (const entry of junitResultsWithSource) {
198
+ const group = resultsByName.get(entry.result.name) ?? [];
199
+ group.push(entry);
200
+ resultsByName.set(entry.result.name, group);
201
+ }
202
+ for (const [flowName, flowEntries] of resultsByName) {
203
+ const flowFilePath = flowPathMap.get(flowName);
177
204
  const relativePath = flowFilePath
178
205
  ? await relativizePathAsync(flowFilePath, projectRoot)
179
- : junit.name; // fallback: use flow name if ai-*.json not found
180
- const occurrences = flowOccurrences.get(junit.name) ?? 0;
181
- const retryCount = Math.max(0, occurrences - 1);
182
- results.push({
183
- name: junit.name,
184
- path: relativePath,
185
- status: junit.status,
186
- errorMessage: junit.errorMessage,
187
- duration: junit.duration,
188
- retryCount,
189
- tags: junit.tags,
190
- properties: junit.properties,
191
- });
206
+ : flowName;
207
+ if (flowEntries.length === 1) {
208
+ // Single result for this flow — use ai-*.json occurrence count for retryCount
209
+ // (backward compat with old-style single JUnit file that gets overwritten)
210
+ const { result } = flowEntries[0];
211
+ const occurrences = flowOccurrences.get(flowName) ?? 0;
212
+ const retryCount = Math.max(0, occurrences - 1);
213
+ results.push({
214
+ name: flowName,
215
+ path: relativePath,
216
+ status: result.status,
217
+ errorMessage: result.errorMessage,
218
+ duration: result.duration,
219
+ retryCount,
220
+ tags: result.tags,
221
+ properties: result.properties,
222
+ });
223
+ }
224
+ else {
225
+ // Multiple results — per-attempt JUnit files. Sort by attempt index from filename.
226
+ const sorted = flowEntries
227
+ .map(entry => {
228
+ const match = entry.sourceFile.match(ATTEMPT_PATTERN);
229
+ const attemptIndex = match ? parseInt(match[1], 10) : 0;
230
+ return { ...entry, attemptIndex };
231
+ })
232
+ .sort((a, b) => a.attemptIndex - b.attemptIndex);
233
+ for (const { result, attemptIndex } of sorted) {
234
+ results.push({
235
+ name: flowName,
236
+ path: relativePath,
237
+ status: result.status,
238
+ errorMessage: result.errorMessage,
239
+ duration: result.duration,
240
+ retryCount: attemptIndex,
241
+ tags: result.tags,
242
+ properties: result.properties,
243
+ });
244
+ }
245
+ }
192
246
  }
193
247
  return results;
194
248
  }
@@ -5,7 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.createReadIpaInfoBuildFunction = createReadIpaInfoBuildFunction;
7
7
  exports.readIpaInfoAsync = readIpaInfoAsync;
8
- const errors_1 = require("@expo/eas-build-job/dist/errors");
8
+ const eas_build_job_1 = require("@expo/eas-build-job");
9
9
  const steps_1 = require("@expo/steps");
10
10
  const fs_extra_1 = __importDefault(require("fs-extra"));
11
11
  const node_path_1 = __importDefault(require("node:path"));
@@ -45,7 +45,7 @@ function createReadIpaInfoBuildFunction() {
45
45
  const ipaPathInput = zod_1.z.string().parse(inputs.ipa_path.value);
46
46
  const ipaPath = node_path_1.default.resolve(stepCtx.workingDirectory, ipaPathInput);
47
47
  if (!(await fs_extra_1.default.pathExists(ipaPath))) {
48
- throw new errors_1.UserFacingError('EAS_READ_IPA_INFO_FILE_NOT_FOUND', `IPA file not found: ${ipaPath}`);
48
+ throw new eas_build_job_1.UserError('EAS_READ_IPA_INFO_FILE_NOT_FOUND', `IPA file not found: ${ipaPath}`);
49
49
  }
50
50
  const ipaInfo = await readIpaInfoAsync(ipaPath);
51
51
  outputs.bundle_identifier.set(ipaInfo.bundleIdentifier);
@@ -60,15 +60,15 @@ async function readIpaInfoAsync(ipaPath) {
60
60
  const infoPlist = parseInfoPlistBuffer(infoPlistBuffer);
61
61
  const bundleIdentifier = infoPlist.CFBundleIdentifier;
62
62
  if (typeof bundleIdentifier !== 'string') {
63
- throw new errors_1.UserFacingError('EAS_READ_IPA_INFO_INVALID_INFO_PLIST', 'Failed to read IPA info: Missing or invalid CFBundleIdentifier in Info.plist');
63
+ throw new eas_build_job_1.UserError('EAS_READ_IPA_INFO_INVALID_INFO_PLIST', 'Failed to read IPA info: Missing or invalid CFBundleIdentifier in Info.plist');
64
64
  }
65
65
  const bundleShortVersion = infoPlist.CFBundleShortVersionString;
66
66
  if (typeof bundleShortVersion !== 'string') {
67
- throw new errors_1.UserFacingError('EAS_READ_IPA_INFO_INVALID_INFO_PLIST', 'Failed to read IPA info: Missing or invalid CFBundleShortVersionString in Info.plist');
67
+ throw new eas_build_job_1.UserError('EAS_READ_IPA_INFO_INVALID_INFO_PLIST', 'Failed to read IPA info: Missing or invalid CFBundleShortVersionString in Info.plist');
68
68
  }
69
69
  const bundleVersion = infoPlist.CFBundleVersion;
70
70
  if (typeof bundleVersion !== 'string') {
71
- throw new errors_1.UserFacingError('EAS_READ_IPA_INFO_INVALID_INFO_PLIST', 'Failed to read IPA info: Missing or invalid CFBundleVersion in Info.plist');
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
73
  return {
74
74
  bundleIdentifier,
@@ -77,10 +77,10 @@ async function readIpaInfoAsync(ipaPath) {
77
77
  };
78
78
  }
79
79
  catch (error) {
80
- if (error instanceof errors_1.UserFacingError) {
80
+ if (error instanceof eas_build_job_1.UserError) {
81
81
  throw error;
82
82
  }
83
- throw new errors_1.UserFacingError('EAS_READ_IPA_INFO_FAILED', `Failed to read IPA info: ${error.message}`, { cause: error });
83
+ throw new eas_build_job_1.UserError('EAS_READ_IPA_INFO_FAILED', `Failed to read IPA info: ${error.message}`, { cause: error });
84
84
  }
85
85
  }
86
86
  function parseInfoPlistBuffer(data) {
@@ -89,7 +89,7 @@ function parseInfoPlistBuffer(data) {
89
89
  const parsedBinaryPlists = bplist_parser_1.default.parseBuffer(data);
90
90
  const parsedBinaryPlist = parsedBinaryPlists[0];
91
91
  if (!parsedBinaryPlist || typeof parsedBinaryPlist !== 'object') {
92
- throw new errors_1.UserFacingError('EAS_READ_IPA_INFO_INVALID_BINARY_PLIST', 'Invalid binary plist in IPA');
92
+ throw new eas_build_job_1.UserError('EAS_READ_IPA_INFO_INVALID_BINARY_PLIST', 'Invalid binary plist in IPA');
93
93
  }
94
94
  return parsedBinaryPlist;
95
95
  }
@@ -101,7 +101,7 @@ async function readInfoPlistBufferFromIpaAsync(ipaPath) {
101
101
  const entries = Object.values(await zip.entries());
102
102
  const infoPlistEntry = entries.find(entry => INFO_PLIST_PATH_REGEXP.test(entry.name));
103
103
  if (!infoPlistEntry) {
104
- throw new errors_1.UserFacingError('EAS_READ_IPA_INFO_INFO_PLIST_NOT_FOUND', `Failed to read IPA info: Could not find Info.plist in ${ipaPath}`);
104
+ throw new eas_build_job_1.UserError('EAS_READ_IPA_INFO_INFO_PLIST_NOT_FOUND', `Failed to read IPA info: Could not find Info.plist in ${ipaPath}`);
105
105
  }
106
106
  return await zip.entryData(infoPlistEntry.name);
107
107
  }
@@ -56,13 +56,20 @@ function createReportMaestroTestResultsFunction(ctx) {
56
56
  logger.info('No maestro test results found, skipping report');
57
57
  return;
58
58
  }
59
- // Maestro allows overriding flow names via config, so different flow files can share
60
- // the same name. JUnit XML only contains names (not file paths), making it impossible
61
- // to map duplicates back to their original flow files. Skip and let the user fix it.
62
- const names = flowResults.map(r => r.name);
63
- const duplicates = names.filter((n, i) => names.indexOf(n) !== i);
64
- if (duplicates.length > 0) {
65
- logger.error(`Duplicate test case names found in JUnit output: ${[...new Set(duplicates)].join(', ')}. Skipping report. Ensure each Maestro flow has a unique name.`);
59
+ // Detect truly conflicting results: same (name, retryCount) pair means different flow files
60
+ // share the same name (Maestro config override), which we can't disambiguate.
61
+ // Same name with different retryCount is expected (per-attempt results from retries).
62
+ const seen = new Set();
63
+ const conflicting = new Set();
64
+ for (const r of flowResults) {
65
+ const key = `${r.name}:${r.retryCount}`;
66
+ if (seen.has(key)) {
67
+ conflicting.add(r.name);
68
+ }
69
+ seen.add(key);
70
+ }
71
+ if (conflicting.size > 0) {
72
+ logger.error(`Duplicate test case names found in JUnit output: ${[...conflicting].join(', ')}. Skipping report. Ensure each Maestro flow has a unique name.`);
66
73
  return;
67
74
  }
68
75
  const testCaseResults = flowResults.flatMap(f => {
@@ -41,7 +41,7 @@ exports.isClosedVersionTrainError = isClosedVersionTrainError;
41
41
  exports.isInvalidBundleIdentifierError = isInvalidBundleIdentifierError;
42
42
  exports.isMissingPurposeStringError = isMissingPurposeStringError;
43
43
  exports.parseMissingUsageDescriptionKeys = parseMissingUsageDescriptionKeys;
44
- const errors_1 = require("@expo/eas-build-job/dist/errors");
44
+ const eas_build_job_1 = require("@expo/eas-build-job");
45
45
  const results_1 = require("@expo/results");
46
46
  const steps_1 = require("@expo/steps");
47
47
  const fs_extra_1 = __importDefault(require("fs-extra"));
@@ -252,7 +252,7 @@ function createUploadToAscBuildFunction() {
252
252
  const ipaBundleIdentifier = ipaInfoResult.ok
253
253
  ? ipaInfoResult.value.bundleIdentifier
254
254
  : null;
255
- throw new errors_1.UserFacingError('EAS_UPLOAD_TO_ASC_INVALID_BUNDLE_ID', `Build upload was rejected by App Store Connect because the app bundle identifier in the IPA does not match the selected App Store Connect app.\n\n` +
255
+ throw new eas_build_job_1.UserError('EAS_UPLOAD_TO_ASC_INVALID_BUNDLE_ID', `Build upload was rejected by App Store Connect because the app bundle identifier in the IPA does not match the selected App Store Connect app.\n\n` +
256
256
  `IPA bundle identifier: ${ipaBundleIdentifier ?? '(unavailable)'}\n` +
257
257
  `App Store Connect app bundle identifier: ${ascAppBundleIdentifier}\n\n` +
258
258
  'Bundle identifier cannot be changed for an existing App Store Connect app. ' +
@@ -261,7 +261,7 @@ function createUploadToAscBuildFunction() {
261
261
  }
262
262
  if (isMissingPurposeStringError(errors)) {
263
263
  const missingUsageDescriptionKeys = parseMissingUsageDescriptionKeys(errors);
264
- throw new errors_1.UserFacingError('EAS_UPLOAD_TO_ASC_MISSING_PURPOSE_STRING', `Build upload was rejected by App Store Connect because Info.plist is missing one or more privacy purpose strings.\n\n` +
264
+ throw new eas_build_job_1.UserError('EAS_UPLOAD_TO_ASC_MISSING_PURPOSE_STRING', `Build upload was rejected by App Store Connect because Info.plist is missing one or more privacy purpose strings.\n\n` +
265
265
  `${missingUsageDescriptionKeys.length > 0
266
266
  ? `Missing keys reported by App Store Connect:\n- ${missingUsageDescriptionKeys.join('\n- ')}\n\n`
267
267
  : ''}` +
@@ -272,7 +272,7 @@ function createUploadToAscBuildFunction() {
272
272
  });
273
273
  }
274
274
  if (isClosedVersionTrainError(errors)) {
275
- throw new errors_1.UserFacingError('EAS_UPLOAD_TO_ASC_CLOSED_VERSION_TRAIN', `Build upload was rejected by App Store Connect because the ${bundleShortVersion} app version is not accepted for new build submissions. ` +
275
+ throw new eas_build_job_1.UserError('EAS_UPLOAD_TO_ASC_CLOSED_VERSION_TRAIN', `Build upload was rejected by App Store Connect because the ${bundleShortVersion} app version is not accepted for new build submissions. ` +
276
276
  'This usually means the version train is closed or lower than a previously approved version. ' +
277
277
  'Bump the iOS app version (CFBundleShortVersionString, e.g. expo.version) to a higher version, then rebuild and submit again.');
278
278
  }
@@ -25,7 +25,7 @@ async function runGradleCommand({ logger, gradleCommand, androidDir, env, extraE
25
25
  return line;
26
26
  }
27
27
  },
28
- env: { ...env, ...extraEnv },
28
+ env: { ...env, ...extraEnv, LC_ALL: 'C.UTF-8' },
29
29
  });
30
30
  if (env.EAS_BUILD_RUNNER === 'eas-build' && process.platform === 'linux') {
31
31
  adjustOOMScore(spawnPromise, logger);
@@ -1,6 +1,9 @@
1
+ import { Android } from '@expo/eas-build-job';
1
2
  import { bunyan } from '@expo/logger';
3
+ import { BuildContext } from '../../../context';
2
4
  export declare function injectCredentialsGradleConfig(logger: bunyan, workingDir: string): Promise<void>;
3
5
  export declare function injectConfigureVersionGradleConfig(logger: bunyan, workingDir: string, { versionCode, versionName }: {
4
6
  versionCode?: string;
5
7
  versionName?: string;
6
8
  }): Promise<void>;
9
+ export declare function warnIfLegacyEasBuildGradleExists(ctx: BuildContext<Android.Job>): Promise<void>;
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.injectCredentialsGradleConfig = injectCredentialsGradleConfig;
7
7
  exports.injectConfigureVersionGradleConfig = injectConfigureVersionGradleConfig;
8
+ exports.warnIfLegacyEasBuildGradleExists = warnIfLegacyEasBuildGradleExists;
8
9
  const config_plugins_1 = require("@expo/config-plugins");
9
10
  const template_file_1 = require("@expo/template-file");
10
11
  const fs_extra_1 = __importDefault(require("fs-extra"));
@@ -41,6 +42,19 @@ async function deleteEasBuildConfigureVersionGradle(workingDir) {
41
42
  const targetPath = getEasBuildConfigureVersionGradlePath(workingDir);
42
43
  await fs_extra_1.default.remove(targetPath);
43
44
  }
45
+ let legacyEasBuildGradleWarningEmitted = false;
46
+ async function warnIfLegacyEasBuildGradleExists(ctx) {
47
+ const legacyGradlePath = getLegacyEasBuildGradlePath(ctx.getReactNativeProjectDirectory());
48
+ if ((await fs_extra_1.default.pathExists(legacyGradlePath)) &&
49
+ (process.env.NODE_ENV === 'test' || !legacyEasBuildGradleWarningEmitted)) {
50
+ legacyEasBuildGradleWarningEmitted = true;
51
+ ctx.logger.warn('eas-build.gradle script is deprecated, please remove it from your project.');
52
+ ctx.markBuildPhaseHasWarnings();
53
+ }
54
+ }
55
+ function getLegacyEasBuildGradlePath(projectRoot) {
56
+ return path_1.default.join(projectRoot, 'android/app/eas-build.gradle');
57
+ }
44
58
  function getEasBuildInjectCredentialsGradlePath(workingDir) {
45
59
  return path_1.default.join(workingDir, 'android/app/eas-build-inject-android-credentials.gradle');
46
60
  }
@@ -83,6 +97,6 @@ function hasLine(haystack, needle) {
83
97
  return (haystack
84
98
  .replace(/\r\n/g, '\n')
85
99
  .split('\n')
86
- // Check for both single and double quotes
100
+ // Check for both single and double quotes.
87
101
  .some(line => line === needle || line === needle.replace(/"/g, "'")));
88
102
  }
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.AscApiUtils = void 0;
4
- const errors_1 = require("@expo/eas-build-job/dist/errors");
4
+ const eas_build_job_1 = require("@expo/eas-build-job");
5
5
  const AscApiClient_1 = require("./AscApiClient");
6
6
  var AscApiUtils;
7
7
  (function (AscApiUtils) {
@@ -25,7 +25,7 @@ var AscApiUtils;
25
25
  // Don't hide the original NOT_FOUND error with a secondary lookup failure.
26
26
  throw error;
27
27
  }
28
- throw new errors_1.UserFacingError('EAS_UPLOAD_TO_ASC_APP_NOT_FOUND', `App Store Connect app for application identifier ${appleAppIdentifier} was not found. ` +
28
+ throw new eas_build_job_1.UserError('EAS_UPLOAD_TO_ASC_APP_NOT_FOUND', `App Store Connect app for application identifier ${appleAppIdentifier} was not found. ` +
29
29
  'Verify the configured application identifier and that the App Store Connect API key has access to the application in the correct App Store Connect account.' +
30
30
  (visibleAppsSummary
31
31
  ? `\n\nExample applications visible to this API key:\n${visibleAppsSummary}`
@@ -64,7 +64,7 @@ var AscApiUtils;
64
64
  const isDuplicateVersionError = errors.length > 0 &&
65
65
  errors.every(item => item.code === 'ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE');
66
66
  if (isDuplicateVersionError) {
67
- throw new errors_1.UserFacingError('EAS_UPLOAD_TO_ASC_VERSION_DUPLICATE', `Increment Build Number: Build number ${bundleVersion} for app version ${bundleShortVersion} has already been used. ` +
67
+ throw new eas_build_job_1.UserError('EAS_UPLOAD_TO_ASC_VERSION_DUPLICATE', `Increment Build Number: Build number ${bundleVersion} for app version ${bundleShortVersion} has already been used. ` +
68
68
  'App Store Connect requires unique build numbers within each app version (version train). ' +
69
69
  'Increment it by setting ios.buildNumber in app.json, or set "autoIncrement": true in eas.json (recommended). Then rebuild and resubmit.', {
70
70
  docsUrl: 'https://docs.expo.dev/build-reference/app-versions/',
@@ -23,11 +23,12 @@ export default class ProvisioningProfile {
23
23
  get data(): ProvisioningProfileData;
24
24
  private readonly profilePath;
25
25
  private profileData?;
26
+ private developerCertificates;
26
27
  constructor(profile: Buffer, keychainPath: string, target: string, certificateCommonName: string);
27
28
  init(logger: bunyan): Promise<void>;
28
29
  destroy(logger: bunyan): Promise<void>;
29
30
  verifyCertificate(fingerprint: string): void;
30
31
  private load;
31
32
  private resolveDistributionType;
32
- private genDerCertFingerprint;
33
+ private getAllDerCertFingerprints;
33
34
  }
@@ -34,6 +34,7 @@ class ProvisioningProfile {
34
34
  }
35
35
  profilePath;
36
36
  profileData;
37
+ developerCertificates = [];
37
38
  constructor(profile, keychainPath, target, certificateCommonName) {
38
39
  this.profile = profile;
39
40
  this.keychainPath = keychainPath;
@@ -58,10 +59,11 @@ class ProvisioningProfile {
58
59
  await fs_extra_1.default.remove(this.profilePath);
59
60
  }
60
61
  verifyCertificate(fingerprint) {
61
- const devCertFingerprint = this.genDerCertFingerprint();
62
- if (devCertFingerprint !== fingerprint) {
63
- throw new eas_build_job_1.errors.CredentialsDistCertMismatchError(`Provisioning profile and distribution certificate don't match.
64
- Profile's certificate fingerprint = ${devCertFingerprint}, distribution certificate fingerprint = ${fingerprint}`);
62
+ const devCertFingerprints = this.getAllDerCertFingerprints();
63
+ if (!devCertFingerprints.includes(fingerprint)) {
64
+ throw new eas_build_job_1.errors.CredentialsDistCertMismatchError(`Provisioning profile and distribution certificate don't match.\n` +
65
+ `Profile's certificate fingerprints = [${devCertFingerprints.join(', ')}], ` +
66
+ `distribution certificate fingerprint = ${fingerprint}`);
65
67
  }
66
68
  }
67
69
  async load() {
@@ -85,6 +87,7 @@ Profile's certificate fingerprint = ${devCertFingerprint}, distribution certific
85
87
  }
86
88
  const applicationIdentifier = plistData.Entitlements['application-identifier'];
87
89
  const bundleIdentifier = applicationIdentifier.replace(/^.+?\./, '');
90
+ this.developerCertificates = plistData.DeveloperCertificates.map((cert) => Buffer.from(cert, 'base64'));
88
91
  this.profileData = {
89
92
  path: this.profilePath,
90
93
  target: this.target,
@@ -92,7 +95,7 @@ Profile's certificate fingerprint = ${devCertFingerprint}, distribution certific
92
95
  teamId: plistData.TeamIdentifier[0],
93
96
  uuid: plistData.UUID,
94
97
  name: plistData.Name,
95
- developerCertificate: Buffer.from(plistData.DeveloperCertificates[0], 'base64'),
98
+ developerCertificate: this.developerCertificates[0],
96
99
  certificateCommonName: this.certificateCommonName,
97
100
  distributionType: this.resolveDistributionType(plistData),
98
101
  };
@@ -108,12 +111,8 @@ Profile's certificate fingerprint = ${devCertFingerprint}, distribution certific
108
111
  return DistributionType.APP_STORE;
109
112
  }
110
113
  }
111
- genDerCertFingerprint() {
112
- return crypto_1.default
113
- .createHash('sha1')
114
- .update(new Uint8Array(this.data.developerCertificate))
115
- .digest('hex')
116
- .toUpperCase();
114
+ getAllDerCertFingerprints() {
115
+ return this.developerCertificates.map(cert => crypto_1.default.createHash('sha1').update(new Uint8Array(cert)).digest('hex').toUpperCase());
117
116
  }
118
117
  }
119
118
  exports.default = ProvisioningProfile;
@@ -1,9 +1,9 @@
1
1
  import { bunyan } from '@expo/logger';
2
- import { StepMetricsCollection } from '@expo/steps';
2
+ import { StepMetric } from '@expo/steps';
3
3
  export declare function uploadStepMetricsToWwwAsync({ workflowJobId, robotAccessToken, expoApiV2BaseUrl, stepMetrics, logger, }: {
4
4
  workflowJobId: string;
5
5
  robotAccessToken: string;
6
6
  expoApiV2BaseUrl: string;
7
- stepMetrics: StepMetricsCollection;
7
+ stepMetrics: StepMetric[];
8
8
  logger: bunyan;
9
9
  }): Promise<void>;
@@ -3,22 +3,16 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.uploadStepMetricsToWwwAsync = uploadStepMetricsToWwwAsync;
4
4
  const turtleFetch_1 = require("./turtleFetch");
5
5
  async function uploadStepMetricsToWwwAsync({ workflowJobId, robotAccessToken, expoApiV2BaseUrl, stepMetrics, logger, }) {
6
- if (stepMetrics.length === 0) {
7
- logger.debug('No step metrics to upload');
8
- return;
9
- }
10
6
  try {
11
7
  await (0, turtleFetch_1.turtleFetch)(new URL(`workflows/${workflowJobId}/metrics`, expoApiV2BaseUrl).toString(), 'POST', {
12
8
  json: { stepMetrics },
13
- headers: {
14
- Authorization: `Bearer ${robotAccessToken}`,
15
- },
16
- timeout: 20000,
9
+ headers: { Authorization: `Bearer ${robotAccessToken}` },
10
+ timeout: 5000,
11
+ retries: 2,
17
12
  logger,
18
13
  });
19
- logger.info(`Uploaded ${stepMetrics.length} step metrics`);
20
14
  }
21
15
  catch {
22
- // Don't display unactionable error to the user, let's send it to Sentry in the future
16
+ // Don't fail the build for metrics silently give up
23
17
  }
24
18
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expo/build-tools",
3
- "version": "18.2.0",
3
+ "version": "18.4.0",
4
4
  "bugs": "https://github.com/expo/eas-cli/issues",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Expo <support@expo.io>",
@@ -38,14 +38,14 @@
38
38
  "@expo/config": "10.0.6",
39
39
  "@expo/config-plugins": "9.0.12",
40
40
  "@expo/downloader": "18.0.1",
41
- "@expo/eas-build-job": "18.2.0",
41
+ "@expo/eas-build-job": "18.4.0",
42
42
  "@expo/env": "^0.4.0",
43
43
  "@expo/logger": "18.0.1",
44
44
  "@expo/package-manager": "1.9.10",
45
45
  "@expo/plist": "^0.2.0",
46
46
  "@expo/results": "^1.0.0",
47
47
  "@expo/spawn-async": "1.7.2",
48
- "@expo/steps": "18.2.0",
48
+ "@expo/steps": "18.4.0",
49
49
  "@expo/template-file": "18.0.1",
50
50
  "@expo/turtle-spawn": "18.0.1",
51
51
  "@expo/xcpretty": "^4.3.1",
@@ -97,5 +97,5 @@
97
97
  "typescript": "^5.5.4",
98
98
  "uuid": "^9.0.1"
99
99
  },
100
- "gitHead": "5f400d77660990422c6976a65dfb41f99374c5cc"
100
+ "gitHead": "4e202db843be2dca6450af4b45ee76b226a662ea"
101
101
  }
@@ -1,3 +0,0 @@
1
- import { Android } from '@expo/eas-build-job';
2
- import { BuildContext } from '../context';
3
- export declare function configureBuildGradle(ctx: BuildContext<Android.Job>): Promise<void>;