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/cli.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
+ import { relative } from 'node:path'
3
4
  import {
4
5
  CliUsageError,
5
6
  type CommandContext,
@@ -7,6 +8,7 @@ import {
7
8
  defineCommand
8
9
  } from 'hanzio/cli'
9
10
  import {
11
+ type CommandName,
10
12
  needsDecide,
11
13
  type ReleaseFlags,
12
14
  type ReleaseOptions,
@@ -17,23 +19,110 @@ import {
17
19
  import {
18
20
  type DecisionInput,
19
21
  decideRelease,
22
+ type EASBuild,
20
23
  type ReleaseDecision,
21
24
  writeDecideGitHubOutput
22
25
  } from './decide'
26
+ import { formatFindings, runDoctor } from './doctor'
27
+ import {
28
+ applyProfileEnv,
29
+ type BuildProfile,
30
+ type EasJson,
31
+ readEasJson,
32
+ resolveBuildProfile
33
+ } from './eas-json'
34
+ import { appendGitHubOutput } from './github-output'
23
35
  import {
24
36
  type OperationInput,
25
37
  operateRelease,
38
+ readExpoSdkMajor,
26
39
  writeOperateGitHubOutput
27
40
  } from './operate'
41
+ import { type Stage, StageError } from './run'
42
+
43
+ const inGitHubActions = process.env.GITHUB_ACTIONS === 'true'
44
+
45
+ // Metro caches in $TMPDIR/metro-cache. On a shared self-hosted machine
46
+ // /tmp/metro-cache can belong to another user (EACCES); the job's temp
47
+ // directory is ours.
48
+ if (
49
+ process.env.RUNNER_ENVIRONMENT === 'self-hosted' &&
50
+ process.env.RUNNER_TEMP &&
51
+ !process.env.TMPDIR
52
+ ) {
53
+ process.env.TMPDIR = process.env.RUNNER_TEMP
54
+ }
55
+
56
+ type Prepared = {
57
+ options: ReleaseOptions
58
+ easJson: EasJson | null
59
+ buildProfile: BuildProfile | null
60
+ }
61
+
62
+ // Read by onError, which gets no context.
63
+ let current: Prepared | null = null
64
+
65
+ async function prepare(command: CommandName, ctx: Context): Promise<Prepared> {
66
+ const platform = ctx.context.platform
67
+ const easJson = await readEasJson()
68
+ const buildProfile = easJson
69
+ ? resolveBuildProfile(easJson, ctx.context.profile, platform)
70
+ : null
71
+ const applied = applyProfileEnv(buildProfile)
72
+ if (applied.length > 0) {
73
+ process.stderr.write(
74
+ `[expo-release] env from eas.json build.${ctx.context.profile}: ${applied.join(', ')}\n`
75
+ )
76
+ }
77
+ // `eas update --environment` also turns off .env loading, so the profile's
78
+ // environment is a default only where SDK 55+ requires one anyway.
79
+ const sdk = await readExpoSdkMajor().catch(() => null)
80
+ const defaults =
81
+ buildProfile && (sdk === null || sdk < 55)
82
+ ? { ...buildProfile, environment: undefined }
83
+ : buildProfile
84
+ current = {
85
+ options: toReleaseOptions(command, ctx, defaults),
86
+ easJson,
87
+ buildProfile
88
+ }
89
+ return current
90
+ }
28
91
 
29
- function toDecisionInput(options: ReleaseOptions): DecisionInput {
92
+ function doctorInput(prepared: Prepared, full: boolean) {
93
+ const { options } = prepared
94
+ return {
95
+ profile: options.profile,
96
+ platform: options.platform,
97
+ environment: options.environment,
98
+ autoSubmit: options.autoSubmit,
99
+ easJson: prepared.easJson,
100
+ buildProfile: prepared.buildProfile,
101
+ full
102
+ }
103
+ }
104
+
105
+ function toDecisionInput(prepared: Prepared): DecisionInput {
106
+ const { options } = prepared
30
107
  return {
31
108
  base: options.base,
32
109
  head: options.head,
33
110
  profile: options.profile,
34
111
  platform: options.platform,
35
112
  impactPaths: options.impactPaths,
36
- ignoreExpoImpact: shouldIgnoreExpoImpact(options)
113
+ ignoreExpoImpact: shouldIgnoreExpoImpact(options),
114
+ environment: options.environment,
115
+ // The quick checks, as warnings: they explain a failure that follows.
116
+ preflight: async () => {
117
+ const findings = await runDoctor(doctorInput(prepared, false)).catch(
118
+ () => []
119
+ )
120
+ if (findings.length > 0) {
121
+ process.stderr.write(
122
+ `${formatFindings(findings, { asWarnings: true, github: inGitHubActions })}\n`
123
+ )
124
+ }
125
+ }
37
126
  }
38
127
  }
39
128
 
@@ -63,8 +152,8 @@ function toOperationInput(
63
152
 
64
153
  type Context = CommandContext<ReleaseFlags>
65
154
 
66
- async function decide(options: ReleaseOptions, ctx: Context) {
67
- const decision = await decideRelease(toDecisionInput(options))
155
+ async function decide(prepared: Prepared, ctx: Context) {
156
+ const decision = await decideRelease(toDecisionInput(prepared))
68
157
  await writeDecideGitHubOutput(decision)
69
158
  ctx.stdout.write(`${JSON.stringify(decision)}\n`)
70
159
  return decision
@@ -75,56 +164,165 @@ async function operate(
75
164
  decision: ReleaseDecision | null,
76
165
  ctx: Context
77
166
  ) {
78
- const result = await operateRelease(toOperationInput(options, decision))
79
- await writeOperateGitHubOutput(result)
167
+ let queued: EASBuild | undefined
168
+ const result = await operateRelease({
169
+ ...toOperationInput(options, decision),
170
+ onQueuedBuild: (build) => {
171
+ queued = build
172
+ }
173
+ })
174
+ await writeOperateGitHubOutput(result, queued)
80
175
  ctx.stdout.write(`${result}\n`)
81
176
  }
82
177
 
178
+ /** A fixed next step per stage, never guessed from EAS's wording. */
179
+ function nextStep(stage: Stage, options: ReleaseOptions): string {
180
+ const app = relative(
181
+ process.env.GITHUB_WORKSPACE ?? process.cwd(),
182
+ process.cwd()
183
+ )
184
+ const from = app ? `From ${app}: ` : ''
185
+ const { platform, profile } = options
186
+ switch (stage) {
187
+ case 'doctor':
188
+ return 'Fix the findings above; `expo-release doctor` runs the same checks.'
189
+ case 'diff':
190
+ return 'Check out with fetch-depth: 0 so both commits of the diff exist.'
191
+ case 'fingerprint':
192
+ return `The app config or EAS project did not load. ${from}\`npx expo config\` and \`eas fingerprint:generate --platform ${platform} --build-profile ${profile}\` with the job's environment variables.`
193
+ case 'build list':
194
+ case 'fingerprint diff':
195
+ return `Check that EXPO_TOKEN can read this project. ${from}\`eas build:list --platform ${platform} --build-profile ${profile}\`.`
196
+ case 'errored build':
197
+ return 'Fix the build failure on EAS, then run the workflow with operation force-native.'
198
+ case 'native build':
199
+ return `A native build that fails before it is queued usually needs one interactive run. ${from}\`eas credentials:configure-build --platform ${platform} --profile ${profile}\`.`
200
+ case 'ota export':
201
+ case 'ota update':
202
+ return 'Fix the bundle error above, then run the workflow with operation retry-ota.'
203
+ case 'pre-update':
204
+ case 'post-update':
205
+ return `The ${stage} command failed; it is the workflow's own step.`
206
+ case 'recovery':
207
+ return 'Check the group or runtime version passed to the recovery operation.'
208
+ default: {
209
+ const _exhaustive: never = stage
210
+ return _exhaustive
211
+ }
212
+ }
213
+ }
214
+
215
+ function escapeData(text: string) {
216
+ return text
217
+ .replaceAll('%', '%25')
218
+ .replaceAll('\r', '%0D')
219
+ .replaceAll('\n', '%0A')
220
+ }
221
+
222
+ function escapeProperty(text: string) {
223
+ return escapeData(text).replaceAll(':', '%3A').replaceAll(',', '%2C')
224
+ }
225
+
226
+ async function reportFailure(
227
+ error: Error,
228
+ io: { stderr: { write(text: string): unknown } }
229
+ ) {
230
+ const stage = error instanceof StageError ? error.stage : undefined
231
+ const options = current?.options
232
+ const said = error instanceof StageError ? error.tail : []
233
+ const next = stage && options ? nextStep(stage, options) : undefined
234
+
235
+ io.stderr.write(`${error.message}\n`)
236
+ if (inGitHubActions) {
237
+ const where = options ? ` (${options.profile}, ${options.platform})` : ''
238
+ const title = `expo-release: ${stage ?? 'release'} failed${where}`
239
+ const message = said.slice(-3).join('\n') || error.message
240
+ io.stderr.write(
241
+ `::error title=${escapeProperty(title)}::${escapeData(message)}\n`
242
+ )
243
+ }
244
+ await appendGitHubOutput({
245
+ result: 'failed',
246
+ failed_stage: stage ?? '',
247
+ failure: [
248
+ said.length > 0 ? `EAS said:\n${said.join('\n')}` : error.message,
249
+ next ? `Next: ${next}` : ''
250
+ ]
251
+ .filter(Boolean)
252
+ .join('\n')
253
+ })
254
+ }
255
+
83
256
  const cli = defineCli({
84
257
  name: 'expo-release',
85
258
  description:
86
259
  'Fingerprint-driven Expo EAS release: OTA update or native store build',
87
- // All three commands take the same flags, before or after the command name.
260
+ // Every command takes the same flags, before or after the command name.
88
261
  globals: releaseFlags,
89
262
  context: (flags) => flags,
90
263
  // The composite action names the command in EXPO_RELEASE_COMMAND and passes no argv.
91
264
  defaultCommand: (env) => env.EXPO_RELEASE_COMMAND?.trim() || undefined,
92
- onError: (error, io) => {
93
- // With no arguments at all, print help and exit 0.
94
- if (
95
- error instanceof CliUsageError &&
96
- error.kind === 'missing-command' &&
97
- process.argv.length <= 2
98
- ) {
99
- io.stdout.write(error.help)
100
- return 0
265
+ onError: async (error, io) => {
266
+ if (error instanceof CliUsageError) {
267
+ // With no arguments at all, print help and exit 0.
268
+ if (error.kind === 'missing-command' && process.argv.length <= 2) {
269
+ io.stdout.write(error.help)
270
+ return 0
271
+ }
272
+ // Bad arguments exit 1, as before hanzio/cli, which uses 2.
273
+ const code = io.print()
274
+ return code === 2 ? 1 : code
101
275
  }
102
- // Bad arguments exit 1, as before hanzio/cli, which uses 2.
103
- const code = io.print()
104
- return code === 2 ? 1 : code
276
+ // An expected failure: the message, an annotation and outputs; no stack.
277
+ if (error instanceof Error) {
278
+ await reportFailure(error, io)
279
+ return 1
280
+ }
281
+ return io.print()
105
282
  },
106
283
  commands: {
107
284
  release: defineCommand({
108
285
  description: 'Run decide, then operate on its decision',
109
286
  run: async (_input, ctx: Context) => {
110
- const options = toReleaseOptions('release', ctx)
111
- const decision = needsDecide(options.operation)
112
- ? await decide(options, ctx)
287
+ const prepared = await prepare('release', ctx)
288
+ const decision = needsDecide(prepared.options.operation)
289
+ ? await decide(prepared, ctx)
113
290
  : null
114
- await operate(options, decision, ctx)
291
+ await operate(prepared.options, decision, ctx)
115
292
  }
116
293
  }),
117
294
  decide: defineCommand({
118
295
  description:
119
- 'Print whether this change needs no deploy, an OTA update or a native build',
296
+ 'Print whether this change needs no deploy, an OTA update or a native build, and why',
120
297
  run: async (_input, ctx: Context) => {
121
- await decide(toReleaseOptions('decide', ctx), ctx)
298
+ await decide(await prepare('decide', ctx), ctx)
122
299
  }
123
300
  }),
124
301
  operate: defineCommand({
125
302
  description: 'Run --operation for --deploy-type',
126
303
  run: async (_input, ctx: Context) => {
127
- await operate(toReleaseOptions('operate', ctx), null, ctx)
304
+ await operate((await prepare('operate', ctx)).options, null, ctx)
305
+ }
306
+ }),
307
+ doctor: defineCommand({
308
+ description:
309
+ 'Check the EAS setup, the app config and the committed files; exit 1 on an error',
310
+ run: async (_input, ctx: Context) => {
311
+ const prepared = await prepare('doctor', ctx)
312
+ const findings = await runDoctor(doctorInput(prepared, true))
313
+ if (findings.length === 0) {
314
+ ctx.stdout.write('No problems found.\n')
315
+ return
316
+ }
317
+ ctx.stdout.write(
318
+ `${formatFindings(findings, { github: inGitHubActions })}\n`
319
+ )
320
+ if (findings.some((finding) => finding.level === 'error')) {
321
+ throw new StageError(
322
+ 'doctor',
323
+ `${findings.filter((f) => f.level === 'error').length} doctor check(s) failed.`
324
+ )
325
+ }
128
326
  }
129
327
  })
130
328
  }