expo-release 2.3.2 → 2.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.
package/src/decide.ts CHANGED
@@ -7,11 +7,15 @@ import { appendGitHubOutput } from './github-output'
7
7
  import {
8
8
  type CommandRunner,
9
9
  easCommand,
10
- getRecord,
11
10
  generateFingerprint,
11
+ getRecord,
12
12
  parseJson,
13
- runCaptured
13
+ parseJsonDocument,
14
+ runCaptured,
15
+ StageError,
16
+ stage
14
17
  } from './run'
18
+ import { expoCommand } from './source-maps'
15
19
 
16
20
  export type EASBuildStatus =
17
21
  | 'finished'
@@ -28,14 +32,33 @@ export type EASBuild = {
28
32
  buildUrl?: string
29
33
  fingerprint?: string
30
34
  runtimeVersion?: string
35
+ /** Why EAS failed the build, for an errored one. */
36
+ error?: string
37
+ }
38
+
39
+ /** Why the decision came out the way it did. */
40
+ export type DecisionReason =
41
+ | 'no-impact-path'
42
+ | 'finished-build'
43
+ | 'build-in-progress'
44
+ | 'no-build-for-fingerprint'
45
+
46
+ /** What changed since the profile's last finished build, for a native decision. */
47
+ export type FingerprintDiff = {
48
+ buildId: string
49
+ buildUrl?: string
50
+ /** `changed <path>`, `added <path>` or `removed <path>`; contents sources by id. */
51
+ sources: string[]
31
52
  }
32
53
 
33
54
  export type ReleaseDecision = {
34
55
  deployType: DeployType
56
+ reason: DecisionReason
35
57
  nativeBuildPending: boolean
36
58
  changedFiles: string[]
37
59
  fingerprint: string | null
38
60
  build: EASBuild | null
61
+ fingerprintDiff?: FingerprintDiff | null
39
62
  }
40
63
 
41
64
  export type DecisionInput = {
@@ -45,6 +68,10 @@ export type DecisionInput = {
45
68
  platform: ReleasePlatform
46
69
  impactPaths: string[]
47
70
  ignoreExpoImpact?: boolean
71
+ /** Passed to `eas fingerprint:compare` when explaining a native decision. */
72
+ environment?: string
73
+ /** Runs once the diff says EAS is needed, before the first EAS command. */
74
+ preflight?: () => Promise<void>
48
75
  }
49
76
 
50
77
  const ACTIVE_BUILD_STATUSES: readonly EASBuildStatus[] = [
@@ -88,11 +115,18 @@ function normalizeBuildStatus(value: string): EASBuildStatus | null {
88
115
  return null
89
116
  }
90
117
 
118
+ // eas-cli prints its GraphQL BuildFragment: `project.ownerAccount.name`,
119
+ // `project.slug` and a top-level `runtimeVersion`. `app` and `runtime` are kept
120
+ // as fallbacks for older output.
91
121
  function dashboardBuildUrl(
92
122
  record: Record<string, unknown>
93
123
  ): string | undefined {
94
- const ownerName = nestedString(record, 'app', 'ownerAccount', 'name')
95
- const slug = nestedString(record, 'app', 'slug')
124
+ const ownerName =
125
+ nestedString(record, 'project', 'ownerAccount', 'name') ??
126
+ nestedString(record, 'app', 'ownerAccount', 'name')
127
+ const slug =
128
+ nestedString(record, 'project', 'slug') ??
129
+ nestedString(record, 'app', 'slug')
96
130
  if (typeof record.id !== 'string' || !ownerName || !slug) {
97
131
  return undefined
98
132
  }
@@ -122,14 +156,20 @@ export function parseBuildList(output: string): EASBuild[] {
122
156
 
123
157
  const buildUrl = dashboardBuildUrl(record)
124
158
  const fingerprint = nestedString(record, 'fingerprint', 'hash')
125
- const runtimeVersion = nestedString(record, 'runtime', 'version')
159
+ const runtimeVersion =
160
+ nestedString(record, 'runtimeVersion') ??
161
+ nestedString(record, 'runtime', 'version')
162
+ const errorCode = nestedString(record, 'error', 'errorCode')
163
+ const errorMessage = nestedString(record, 'error', 'message')
164
+ const error = [errorCode, errorMessage].filter(Boolean).join(': ')
126
165
 
127
166
  builds.push({
128
167
  id: record.id,
129
168
  status,
130
169
  ...(buildUrl ? { buildUrl } : {}),
131
170
  ...(fingerprint ? { fingerprint } : {}),
132
- ...(runtimeVersion ? { runtimeVersion } : {})
171
+ ...(runtimeVersion ? { runtimeVersion } : {}),
172
+ ...(error ? { error } : {})
133
173
  })
134
174
  }
135
175
 
@@ -159,12 +199,18 @@ export function hasExpoImpact(
159
199
 
160
200
  export function resolveBuildDecision(builds: EASBuild[]): {
161
201
  deployType: Exclude<DeployType, 'none'>
202
+ reason: Exclude<DecisionReason, 'no-impact-path'>
162
203
  nativeBuildPending: boolean
163
204
  build: EASBuild | null
164
205
  } {
165
206
  const finished = builds.find((build) => build.status === 'finished')
166
207
  if (finished) {
167
- return { deployType: 'ota', nativeBuildPending: false, build: finished }
208
+ return {
209
+ deployType: 'ota',
210
+ reason: 'finished-build',
211
+ nativeBuildPending: false,
212
+ build: finished
213
+ }
168
214
  }
169
215
 
170
216
  const inProgress = builds.find((build) =>
@@ -173,6 +219,7 @@ export function resolveBuildDecision(builds: EASBuild[]): {
173
219
  if (inProgress) {
174
220
  return {
175
221
  deployType: 'ota',
222
+ reason: 'build-in-progress',
176
223
  nativeBuildPending: true,
177
224
  build: inProgress
178
225
  }
@@ -180,13 +227,59 @@ export function resolveBuildDecision(builds: EASBuild[]): {
180
227
 
181
228
  const newest = builds[0]
182
229
  if (newest?.status === 'errored') {
183
- const location = newest.buildUrl ?? newest.id
184
- throw new Error(
185
- `The most recent native build for this fingerprint failed (${location}). Fix the failure or use --operation force-native to queue another build.`
186
- )
230
+ const lines = [
231
+ `The newest native build for this fingerprint failed on EAS${newest.error ? `: ${newest.error}` : ` (build ${newest.id})`}`,
232
+ newest.buildUrl,
233
+ 'Fix it, then run the workflow with operation force-native.'
234
+ ].filter((line): line is string => Boolean(line))
235
+ throw new StageError('errored build', lines.join('\n'), lines)
187
236
  }
188
237
 
189
- return { deployType: 'native', nativeBuildPending: false, build: null }
238
+ return {
239
+ deployType: 'native',
240
+ reason: 'no-build-for-fingerprint',
241
+ nativeBuildPending: false,
242
+ build: null
243
+ }
244
+ }
245
+
246
+ async function readPullRequestBase(): Promise<string | undefined> {
247
+ const path = process.env.GITHUB_EVENT_PATH
248
+ if (!path) return undefined
249
+ try {
250
+ const event = (await Bun.file(path).json()) as {
251
+ pull_request?: { base?: { sha?: unknown } }
252
+ }
253
+ const sha = event.pull_request?.base?.sha
254
+ return typeof sha === 'string' && sha ? sha : undefined
255
+ } catch {
256
+ return undefined
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Without `--base`: the merge base with the PR's base branch on a pull request
262
+ * (`github.event.before` there is the previous push, not the base), else
263
+ * `github.event.before`.
264
+ */
265
+ export async function defaultBase(
266
+ head: string | undefined,
267
+ commandRunner: CommandRunner
268
+ ): Promise<string | undefined> {
269
+ const event = process.env.GITHUB_EVENT_NAME
270
+ if (event === 'pull_request' || event === 'pull_request_target') {
271
+ const base = await readPullRequestBase()
272
+ if (base && head) {
273
+ try {
274
+ const mergeBase = (
275
+ await commandRunner(['git', 'merge-base', base, head])
276
+ ).trim()
277
+ if (mergeBase) return mergeBase
278
+ } catch {}
279
+ }
280
+ if (base) return base
281
+ }
282
+ return process.env.GITHUB_EVENT_BEFORE?.trim() || undefined
190
283
  }
191
284
 
192
285
  async function getChangedFiles(
@@ -194,7 +287,8 @@ async function getChangedFiles(
194
287
  commandRunner: CommandRunner
195
288
  ): Promise<{ files: string[]; unreachable: boolean }> {
196
289
  const head = input.head
197
- if (!input.base || !head || /^0+$/.test(input.base)) {
290
+ const base = input.base ?? (await defaultBase(head, commandRunner))
291
+ if (!base || !head || /^0+$/.test(base)) {
198
292
  return { files: [], unreachable: true }
199
293
  }
200
294
 
@@ -205,7 +299,7 @@ async function getChangedFiles(
205
299
  'diff.relative=false',
206
300
  'diff',
207
301
  '--name-only',
208
- input.base,
302
+ base,
209
303
  head
210
304
  ])
211
305
  return {
@@ -244,13 +338,116 @@ async function getMatchingBuilds(
244
338
  )
245
339
  }
246
340
 
341
+ type FingerprintSource = {
342
+ type?: unknown
343
+ filePath?: unknown
344
+ id?: unknown
345
+ hash?: unknown
346
+ }
347
+
348
+ function fingerprintSources(value: unknown): Map<string, unknown> {
349
+ const sources = (value as { sources?: unknown } | null)?.sources
350
+ const map = new Map<string, unknown>()
351
+ if (!Array.isArray(sources)) return map
352
+ for (const source of sources as FingerprintSource[]) {
353
+ const key =
354
+ source.type === 'contents' ? source.id : (source.filePath ?? source.id)
355
+ if (typeof key === 'string') map.set(key, source.hash ?? null)
356
+ }
357
+ return map
358
+ }
359
+
360
+ const MAX_DIFF_SOURCES = 30
361
+
362
+ /** Changed sources between the two fingerprints `eas fingerprint:compare --json` prints. */
363
+ export function diffFingerprintSources(compare: unknown): string[] {
364
+ const record = getRecord(compare, 'EAS fingerprint:compare')
365
+ const before = fingerprintSources(record.fingerprint1)
366
+ const after = fingerprintSources(record.fingerprint2)
367
+ const lines: string[] = []
368
+ for (const [key, hash] of after) {
369
+ if (!before.has(key)) lines.push(`added ${key}`)
370
+ else if (before.get(key) !== hash) lines.push(`changed ${key}`)
371
+ }
372
+ for (const key of before.keys()) {
373
+ if (!after.has(key)) lines.push(`removed ${key}`)
374
+ }
375
+ if (lines.length <= MAX_DIFF_SOURCES) return lines
376
+ return [
377
+ ...lines.slice(0, MAX_DIFF_SOURCES),
378
+ `... and ${lines.length - MAX_DIFF_SOURCES} more`
379
+ ]
380
+ }
381
+
382
+ /**
383
+ * Compares the current fingerprint with the profile's last finished build.
384
+ * Two extra EAS calls, made only for a native decision. A failure here only
385
+ * loses the explanation, never the release.
386
+ */
387
+ async function explainNativeDecision(
388
+ input: DecisionInput,
389
+ commandRunner: CommandRunner
390
+ ): Promise<FingerprintDiff | null> {
391
+ try {
392
+ return await stage('fingerprint diff', async () => {
393
+ const [last] = parseBuildList(
394
+ await commandRunner(
395
+ easCommand(
396
+ 'build:list',
397
+ '--platform',
398
+ input.platform,
399
+ '--build-profile',
400
+ input.profile,
401
+ '--status',
402
+ 'finished',
403
+ '--limit',
404
+ '1',
405
+ '--json',
406
+ '--non-interactive'
407
+ )
408
+ )
409
+ )
410
+ if (!last) return null
411
+ const command = easCommand(
412
+ 'fingerprint:compare',
413
+ '--build-id',
414
+ last.id,
415
+ '--json',
416
+ '--non-interactive'
417
+ )
418
+ if (input.environment) command.push('--environment', input.environment)
419
+ const { value } = parseJsonDocument(await commandRunner(command))
420
+ return {
421
+ buildId: last.id,
422
+ ...(last.buildUrl ? { buildUrl: last.buildUrl } : {}),
423
+ sources: diffFingerprintSources(value)
424
+ }
425
+ })
426
+ } catch (error) {
427
+ process.stderr.write(
428
+ `Warning: could not compare the fingerprint with the last finished build: ${error instanceof Error ? error.message : String(error)}\n`
429
+ )
430
+ return null
431
+ }
432
+ }
433
+
434
+ async function explainConfigFailure(
435
+ commandRunner: CommandRunner
436
+ ): Promise<string> {
437
+ try {
438
+ await commandRunner(expoCommand('config', '--json'))
439
+ return ''
440
+ } catch (error) {
441
+ return error instanceof Error ? error.message : String(error)
442
+ }
443
+ }
444
+
247
445
  export async function decideRelease(
248
446
  input: DecisionInput,
249
447
  commandRunner: CommandRunner = runCaptured
250
448
  ): Promise<ReleaseDecision> {
251
- const { files: changedFiles, unreachable } = await getChangedFiles(
252
- input,
253
- commandRunner
449
+ const { files: changedFiles, unreachable } = await stage('diff', () =>
450
+ getChangedFiles(input, commandRunner)
254
451
  )
255
452
 
256
453
  if (
@@ -260,6 +457,7 @@ export async function decideRelease(
260
457
  ) {
261
458
  return {
262
459
  deployType: 'none',
460
+ reason: 'no-impact-path',
263
461
  nativeBuildPending: false,
264
462
  changedFiles,
265
463
  fingerprint: null,
@@ -267,17 +465,27 @@ export async function decideRelease(
267
465
  }
268
466
  }
269
467
 
270
- const fingerprint = await generateFingerprint(input, commandRunner)
271
- const resolved = resolveBuildDecision(
272
- await getMatchingBuilds(input, fingerprint, commandRunner)
468
+ await input.preflight?.()
469
+ const fingerprint = await stage('fingerprint', () =>
470
+ generateFingerprint(input, commandRunner, () =>
471
+ explainConfigFailure(commandRunner)
472
+ )
473
+ )
474
+ const builds = await stage('build list', () =>
475
+ getMatchingBuilds(input, fingerprint, commandRunner)
273
476
  )
477
+ const resolved = resolveBuildDecision(builds)
274
478
 
275
479
  return {
276
480
  deployType: resolved.deployType,
481
+ reason: resolved.reason,
277
482
  nativeBuildPending: resolved.nativeBuildPending,
278
483
  changedFiles,
279
484
  fingerprint,
280
- build: resolved.build
485
+ build: resolved.build,
486
+ ...(resolved.deployType === 'native'
487
+ ? { fingerprintDiff: await explainNativeDecision(input, commandRunner) }
488
+ : {})
281
489
  }
282
490
  }
283
491
 
@@ -288,6 +496,12 @@ export async function writeDecideGitHubOutput(decision: ReleaseDecision) {
288
496
  changed: String(decision.deployType !== 'none'),
289
497
  fingerprint: decision.fingerprint ?? '',
290
498
  build_id: decision.build?.id ?? '',
291
- build_url: decision.build?.buildUrl ?? ''
499
+ build_url: decision.build?.buildUrl ?? '',
500
+ reason: decision.reason,
501
+ changed_sources: decision.fingerprintDiff?.sources.join('\n') ?? '',
502
+ compared_build_url:
503
+ decision.fingerprintDiff?.buildUrl ??
504
+ decision.fingerprintDiff?.buildId ??
505
+ ''
292
506
  })
293
507
  }
package/src/doctor.ts ADDED
@@ -0,0 +1,296 @@
1
+ import type { ReleasePlatform } from './args'
2
+ import type { BuildProfile, EasJson } from './eas-json'
3
+ import { readExpoSdkMajor } from './operate'
4
+ import { type CommandRunner, easCommand, runSilent } from './run'
5
+ import { expoCommand } from './source-maps'
6
+
7
+ export type Finding = {
8
+ level: 'error' | 'warning'
9
+ message: string
10
+ /** The command or change that fixes it. */
11
+ fix?: string
12
+ }
13
+
14
+ export type DoctorInput = {
15
+ profile: string
16
+ platform: ReleasePlatform
17
+ environment?: string
18
+ autoSubmit: boolean
19
+ easJson: EasJson | null
20
+ buildProfile: BuildProfile | null
21
+ /** Also load the app config (`expo config`, slow in large apps). */
22
+ full?: boolean
23
+ readPackageJson?: () => Promise<unknown>
24
+ readFile?: (path: string) => Promise<string | null>
25
+ env?: Record<string, string | undefined>
26
+ bunVersion?: string
27
+ }
28
+
29
+ const CREDENTIAL_FILE = /\.(keystore|jks|p8|p12|mobileprovision)$/
30
+
31
+ async function tryRun(runner: CommandRunner, command: string[]) {
32
+ try {
33
+ return { ok: true as const, output: await runner(command) }
34
+ } catch (error) {
35
+ return {
36
+ ok: false as const,
37
+ error: error instanceof Error ? error.message : String(error)
38
+ }
39
+ }
40
+ }
41
+
42
+ async function defaultReadFile(path: string): Promise<string | null> {
43
+ const file = Bun.file(path)
44
+ return (await file.exists()) ? file.text() : null
45
+ }
46
+
47
+ function versionOf(output: string): string | undefined {
48
+ return output.match(/(\d+\.\d+\.\d+)/)?.[1]
49
+ }
50
+
51
+ async function checkTools(
52
+ input: DoctorInput,
53
+ runner: CommandRunner,
54
+ findings: Finding[]
55
+ ) {
56
+ const env = input.env ?? process.env
57
+ const eas = await tryRun(runner, easCommand('--version'))
58
+ if (!eas.ok) {
59
+ findings.push({
60
+ level: 'error',
61
+ message: 'eas-cli did not run.',
62
+ fix: 'Add eas-cli to the app devDependencies, or set it up with expo/expo-github-action.'
63
+ })
64
+ } else {
65
+ const wanted = input.easJson?.cli?.version
66
+ const actual = versionOf(eas.output)
67
+ if (wanted && actual && !Bun.semver.satisfies(actual, wanted)) {
68
+ findings.push({
69
+ level: 'error',
70
+ message: `eas-cli ${actual} does not satisfy eas.json cli.version ${wanted}.`,
71
+ fix: `Pin eas-cli ${wanted} in the app devDependencies or the workflow.`
72
+ })
73
+ }
74
+ }
75
+ if (env.CI && !env.EXPO_TOKEN) {
76
+ findings.push({
77
+ level: 'error',
78
+ message: 'EXPO_TOKEN is not set.',
79
+ fix: 'Add the EXPO_TOKEN secret (https://expo.dev/settings/access-tokens) and pass it to the job.'
80
+ })
81
+ }
82
+
83
+ const bun = input.buildProfile?.bun
84
+ const bunVersion = input.bunVersion ?? Bun.version
85
+ if (bun && bun !== bunVersion) {
86
+ findings.push({
87
+ level: 'error',
88
+ message: `This job runs Bun ${bunVersion}, the "${input.profile}" profile builds with Bun ${bun}. The fingerprint and lockfile can differ between them.`,
89
+ fix: `Use Bun ${bun} in CI, or set build.${input.profile}.bun to ${bunVersion}.`
90
+ })
91
+ }
92
+ const node = input.buildProfile?.node
93
+ if (node) {
94
+ const actual = await tryRun(runner, ['node', '--version'])
95
+ const version = actual.ok ? versionOf(actual.output) : undefined
96
+ if (version && version !== node) {
97
+ findings.push({
98
+ level: 'warning',
99
+ message: `This job runs Node ${version}, the "${input.profile}" profile builds with Node ${node}.`,
100
+ fix: `Use Node ${node} in CI (actions/setup-node).`
101
+ })
102
+ }
103
+ }
104
+ }
105
+
106
+ async function checkProfile(input: DoctorInput, findings: Finding[]) {
107
+ if (!input.easJson) {
108
+ findings.push({
109
+ level: 'error',
110
+ message: 'No eas.json in the working directory.',
111
+ fix: 'Set working-directory to the Expo app, or run `eas build:configure` there.'
112
+ })
113
+ return
114
+ }
115
+ if (!input.buildProfile) {
116
+ const names = Object.keys(input.easJson.build ?? {}).join(', ') || 'none'
117
+ findings.push({
118
+ level: 'error',
119
+ message: `eas.json has no build profile "${input.profile}" (profiles: ${names}).`
120
+ })
121
+ return
122
+ }
123
+ if (!input.buildProfile.channel) {
124
+ findings.push({
125
+ level: 'warning',
126
+ message: `Build profile "${input.profile}" has no channel, so its builds receive no EAS updates.`,
127
+ fix: `Set build.${input.profile}.channel in eas.json.`
128
+ })
129
+ }
130
+ if (input.autoSubmit && !input.easJson.submit?.[input.profile]) {
131
+ findings.push({
132
+ level: 'warning',
133
+ message: `auto-submit is on, but eas.json has no submit.${input.profile}.`,
134
+ fix:
135
+ input.platform === 'ios'
136
+ ? `Set submit.${input.profile}.ios.ascAppId.`
137
+ : `Set submit.${input.profile}.android.serviceAccountKeyPath, pointing at a file from an EAS file secret.`
138
+ })
139
+ }
140
+ if (!input.environment) {
141
+ const major = await readExpoSdkMajor(input.readPackageJson)
142
+ if (major !== null && major >= 55) {
143
+ findings.push({
144
+ level: 'error',
145
+ message: 'Expo SDK 55+ needs an EAS environment for eas update.',
146
+ fix: `Set build.${input.profile}.environment in eas.json, or pass --environment.`
147
+ })
148
+ }
149
+ }
150
+ }
151
+
152
+ async function checkTrackedFiles(
153
+ runner: CommandRunner,
154
+ findings: Finding[],
155
+ readFile: (path: string) => Promise<string | null>
156
+ ) {
157
+ const listed = await tryRun(runner, ['git', 'ls-files'])
158
+ if (!listed.ok) return
159
+ const files = listed.output.split('\n').filter(Boolean)
160
+
161
+ const credentials = files.filter(
162
+ (file) => CREDENTIAL_FILE.test(file) && !/(^|\/)debug\.keystore$/.test(file)
163
+ )
164
+ for (const file of files) {
165
+ if (!file.endsWith('.json') || credentials.includes(file)) continue
166
+ const text = await readFile(file)
167
+ if (
168
+ text &&
169
+ text.length < 50_000 &&
170
+ /"type"\s*:\s*"service_account"/.test(text)
171
+ ) {
172
+ credentials.push(file)
173
+ }
174
+ }
175
+ if (credentials.length > 0) {
176
+ findings.push({
177
+ level: 'error',
178
+ message: `Credentials are committed: ${credentials.join(', ')}.`,
179
+ fix: 'Remove them from git, rotate them, and keep them in EAS (`eas credentials`, EAS file secrets).'
180
+ })
181
+ }
182
+
183
+ const generated = files.filter(
184
+ (file) => /^\.expo\//.test(file) || /\.log$/.test(file)
185
+ )
186
+ if (generated.length > 0) {
187
+ findings.push({
188
+ level: 'warning',
189
+ message: `Generated files are committed: ${generated.slice(0, 5).join(', ')}${generated.length > 5 ? `, and ${generated.length - 5} more` : ''}.`,
190
+ fix: 'git rm --cached them and add .expo/ and *.log to .gitignore.'
191
+ })
192
+ }
193
+
194
+ const nativeDirs = ['android', 'ios'].filter((dir) =>
195
+ files.some((file) => file.startsWith(`${dir}/`))
196
+ )
197
+ if (nativeDirs.length === 0) return
198
+ const ignore = (await readFile('.fingerprintignore')) ?? ''
199
+ // Only a pattern for the whole folder hides it; `android/build/**` and the
200
+ // like are ordinary ignores.
201
+ const lines = ignore.split('\n').map((line) => line.trim().replace(/^\//, ''))
202
+ const hidden = nativeDirs.filter((dir) =>
203
+ lines.some((line) =>
204
+ [dir, `${dir}/`, `${dir}/*`, `${dir}/**`, `${dir}/**/*`].includes(line)
205
+ )
206
+ )
207
+ if (hidden.length > 0) {
208
+ findings.push({
209
+ level: 'error',
210
+ message: `${hidden.join(' and ')} are committed, and .fingerprintignore hides them: EAS builds the committed tree, but a change there never asks for a native build.`,
211
+ fix: 'Either delete and gitignore the native folders (continuous native generation), or stop ignoring them in .fingerprintignore.'
212
+ })
213
+ }
214
+ return nativeDirs
215
+ }
216
+
217
+ async function checkAppConfig(
218
+ runner: CommandRunner,
219
+ findings: Finding[],
220
+ nativeDirs: string[]
221
+ ) {
222
+ const config = await tryRun(runner, expoCommand('config', '--json'))
223
+ if (!config.ok) {
224
+ findings.push({
225
+ level: 'error',
226
+ message: `The app config did not load:\n${config.error}`,
227
+ fix: 'Run `npx expo config` in the app with the same environment variables.'
228
+ })
229
+ return
230
+ }
231
+ let parsed: { runtimeVersion?: unknown; plugins?: unknown } = {}
232
+ try {
233
+ parsed = JSON.parse(config.output)
234
+ } catch {
235
+ return
236
+ }
237
+ const policy = (parsed.runtimeVersion as { policy?: unknown } | undefined)
238
+ ?.policy
239
+ if (policy !== 'fingerprint') {
240
+ findings.push({
241
+ level: 'warning',
242
+ message: `runtimeVersion is ${JSON.stringify(parsed.runtimeVersion ?? null)}, not { policy: 'fingerprint' }: an OTA can reach a binary whose native code differs.`,
243
+ fix: "Set runtimeVersion: { policy: 'fingerprint' } in the app config."
244
+ })
245
+ }
246
+ if (
247
+ nativeDirs.length > 0 &&
248
+ Array.isArray(parsed.plugins) &&
249
+ parsed.plugins.length > 0
250
+ ) {
251
+ findings.push({
252
+ level: 'warning',
253
+ message: `${nativeDirs.join(' and ')} are committed while the app config has plugins: EAS builds the committed tree and never applies config plugin changes.`,
254
+ fix: 'Delete and gitignore the native folders, or run `npx expo prebuild` and commit after every plugin change.'
255
+ })
256
+ }
257
+ }
258
+
259
+ /** Checks what makes a release fail later, or ship the wrong thing. */
260
+ export async function runDoctor(
261
+ input: DoctorInput,
262
+ runner: CommandRunner = runSilent
263
+ ): Promise<Finding[]> {
264
+ const findings: Finding[] = []
265
+ const readFile = input.readFile ?? defaultReadFile
266
+ await checkProfile(input, findings)
267
+ await checkTools(input, runner, findings)
268
+ const nativeDirs = (await checkTrackedFiles(runner, findings, readFile)) ?? []
269
+ if (input.full) await checkAppConfig(runner, findings, nativeDirs)
270
+ return findings
271
+ }
272
+
273
+ function escapeAnnotation(text: string) {
274
+ return text
275
+ .replaceAll('%', '%25')
276
+ .replaceAll('\r', '%0D')
277
+ .replaceAll('\n', '%0A')
278
+ }
279
+
280
+ /** One line per finding; GitHub annotations inside Actions. */
281
+ export function formatFindings(
282
+ findings: Finding[],
283
+ options: { asWarnings?: boolean; github?: boolean } = {}
284
+ ): string {
285
+ return findings
286
+ .map((finding) => {
287
+ const level = options.asWarnings ? 'warning' : finding.level
288
+ const text = finding.fix
289
+ ? `${finding.message}\nFix: ${finding.fix}`
290
+ : finding.message
291
+ return options.github
292
+ ? `::${level} title=expo-release doctor::${escapeAnnotation(text)}`
293
+ : `${level}: ${text}`
294
+ })
295
+ .join('\n')
296
+ }