expo-release 2.3.1 → 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/README.md +117 -124
- package/action.yml +63 -12
- package/dist/args.d.ts +17 -15
- package/dist/cli.js +865 -143
- package/dist/decide.d.ts +26 -0
- package/dist/doctor.d.ts +30 -0
- package/dist/eas-json.d.ts +25 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +409 -122
- package/dist/operate.d.ts +8 -1
- package/dist/run.d.ts +42 -2
- package/dist/runtime/index.d.ts +1 -1
- package/dist/runtime/index.js +74 -14
- package/dist/runtime/ota.d.ts +23 -3
- package/dist/runtime/push.d.ts +15 -3
- package/dist/source-maps.d.ts +1 -0
- package/package.json +7 -3
- package/src/args.ts +35 -20
- package/src/cli.ts +233 -36
- package/src/decide.ts +236 -22
- package/src/doctor.ts +296 -0
- package/src/eas-json.ts +128 -0
- package/src/index.ts +2 -0
- package/src/operate.ts +97 -53
- package/src/run.ts +259 -37
- package/src/runtime/index.ts +2 -0
- package/src/runtime/ota.ts +92 -15
- package/src/runtime/push.ts +46 -13
- package/src/runtime/react-native.d.ts +10 -0
- package/src/source-maps.ts +4 -1
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
|
-
|
|
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 =
|
|
95
|
-
|
|
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 =
|
|
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 {
|
|
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
|
|
184
|
-
|
|
185
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
271
|
-
const
|
|
272
|
-
|
|
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
|
+
}
|