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.
@@ -0,0 +1,128 @@
1
+ import type { ReleasePlatform } from './args'
2
+
3
+ /** The fields of an eas.json build profile that expo-release reads. */
4
+ export type BuildProfile = {
5
+ channel?: string
6
+ environment?: string
7
+ env?: Record<string, string>
8
+ node?: string
9
+ bun?: string
10
+ }
11
+
12
+ export type EasJson = {
13
+ cli?: { version?: string }
14
+ build?: Record<string, Record<string, unknown>>
15
+ submit?: Record<string, unknown>
16
+ }
17
+
18
+ /** Reads eas.json from the working directory. eas-cli accepts JSON5 there. */
19
+ export async function readEasJson(path = 'eas.json'): Promise<EasJson | null> {
20
+ const file = Bun.file(path)
21
+ if (!(await file.exists())) return null
22
+ const text = await file.text()
23
+ let value: unknown
24
+ try {
25
+ value = JSON.parse(text)
26
+ } catch (error) {
27
+ // eas-cli also accepts JSON5 (comments, trailing commas). Bun.JSON5 is
28
+ // missing on older Bun, where plain JSON still works.
29
+ const json5 = (Bun as { JSON5?: { parse(text: string): unknown } }).JSON5
30
+ if (!json5) throw error
31
+ value = json5.parse(text)
32
+ }
33
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
34
+ throw new Error(`${path} must hold an object`)
35
+ }
36
+ return value as EasJson
37
+ }
38
+
39
+ type RawProfile = Record<string, unknown>
40
+
41
+ function asRecord(value: unknown): RawProfile | undefined {
42
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
43
+ ? (value as RawProfile)
44
+ : undefined
45
+ }
46
+
47
+ // The merge eas-cli applies (@expo/eas-json build/resolver): later keys win,
48
+ // `env` and the platform blocks merge one level deep.
49
+ function merge(base: RawProfile, update: RawProfile): RawProfile {
50
+ const result = { ...base, ...update }
51
+ for (const key of ['env', 'ios', 'android']) {
52
+ const a = asRecord(base[key])
53
+ const b = asRecord(update[key])
54
+ if (a && b) result[key] = key === 'env' ? { ...a, ...b } : merge(a, b)
55
+ }
56
+ return result
57
+ }
58
+
59
+ function resolveRaw(
60
+ easJson: EasJson,
61
+ name: string,
62
+ depth: number
63
+ ): RawProfile | null {
64
+ if (depth >= 5) {
65
+ throw new Error(
66
+ 'eas.json: too long a chain of "extends"; check the build profiles for a cycle'
67
+ )
68
+ }
69
+ const profile = asRecord(easJson.build?.[name])
70
+ if (!profile) {
71
+ if (depth === 0) return null
72
+ throw new Error(`eas.json: build profile extends missing "${name}"`)
73
+ }
74
+ const { extends: parent, ...rest } = profile
75
+ return typeof parent === 'string'
76
+ ? merge(resolveRaw(easJson, parent, depth + 1) ?? {}, rest)
77
+ : rest
78
+ }
79
+
80
+ /** `build.<name>` with `extends` and the platform block applied, or null. */
81
+ export function resolveBuildProfile(
82
+ easJson: EasJson,
83
+ name: string,
84
+ platform: ReleasePlatform
85
+ ): BuildProfile | null {
86
+ const raw = resolveRaw(easJson, name, 0)
87
+ if (!raw) return null
88
+ const { ios, android, ...base } = raw
89
+ const resolved = merge(
90
+ base,
91
+ asRecord(platform === 'ios' ? ios : android) ?? {}
92
+ )
93
+ const text = (key: string) =>
94
+ typeof resolved[key] === 'string' && resolved[key] !== ''
95
+ ? (resolved[key] as string)
96
+ : undefined
97
+ const env = asRecord(resolved.env)
98
+ return {
99
+ channel: text('channel'),
100
+ environment: text('environment'),
101
+ node: text('node'),
102
+ bun: text('bun'),
103
+ ...(env
104
+ ? {
105
+ env: Object.fromEntries(
106
+ Object.entries(env).map(([key, value]) => [key, String(value)])
107
+ )
108
+ }
109
+ : {})
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Puts the profile's `env` into this process, so every EAS command and hook
115
+ * sees what the EAS build machine sees. Variables already set win.
116
+ */
117
+ export function applyProfileEnv(
118
+ profile: BuildProfile | null,
119
+ env: Record<string, string | undefined> = process.env
120
+ ): string[] {
121
+ const applied: string[] = []
122
+ for (const [key, value] of Object.entries(profile?.env ?? {})) {
123
+ if (env[key] !== undefined) continue
124
+ env[key] = value
125
+ applied.push(key)
126
+ }
127
+ return applied
128
+ }
package/src/index.ts CHANGED
@@ -8,7 +8,9 @@ export {
8
8
  } from './args'
9
9
  export {
10
10
  type DecisionInput,
11
+ type DecisionReason,
11
12
  type EASBuild,
13
+ type FingerprintDiff,
12
14
  type ReleaseDecision,
13
15
  decideRelease
14
16
  } from './decide'
package/src/operate.ts CHANGED
@@ -1,11 +1,13 @@
1
1
  import type { DeployType, Operation, ReleasePlatform } from './args'
2
+ import { type EASBuild, parseBuildList } from './decide'
2
3
  import { appendGitHubOutput } from './github-output'
3
4
  import {
4
5
  type CommandRunner,
5
6
  dryRunCommand,
6
7
  easCommand,
7
8
  generateFingerprint,
8
- runInherited
9
+ runInherited,
10
+ stage
9
11
  } from './run'
10
12
  import {
11
13
  assertSourceMaps,
@@ -32,14 +34,23 @@ export type OperationInput = {
32
34
  updateExtraArgs?: string
33
35
  exportSourceMaps?: boolean
34
36
  dryRun?: boolean
37
+ /** Called with the native build `eas build` queued, when it printed one. */
38
+ onQueuedBuild?: (build: EASBuild) => void
35
39
  readPackageJson?: () => Promise<unknown>
36
40
  hasSourceMaps?: () => Promise<boolean>
37
41
  }
38
42
 
39
43
  export type OperationResult = 'skipped' | 'ota' | 'native-build' | 'recovery'
40
44
 
41
- function extraUpdateArgs(value: string | undefined): string[] {
42
- return value?.split(/\s+/).filter(Boolean) ?? []
45
+ /** Splits like a shell: whitespace separates, single or double quotes group. */
46
+ export function splitArgs(value: string | undefined): string[] {
47
+ const args: string[] = []
48
+ for (const match of (value ?? '').matchAll(
49
+ /"((?:\\.|[^"\\])*)"|'([^']*)'|(\S+)/g
50
+ )) {
51
+ args.push(match[1]?.replace(/\\(.)/g, '$1') ?? match[2] ?? match[3] ?? '')
52
+ }
53
+ return args
43
54
  }
44
55
 
45
56
  export function updateMessage(input: {
@@ -69,31 +80,40 @@ async function defaultReadPackageJson(): Promise<unknown> {
69
80
  return file.json()
70
81
  }
71
82
 
72
- export async function requireEnvironmentIfNeeded(
73
- input: Pick<OperationInput, 'environment' | 'readPackageJson'>
74
- ) {
75
- if (input.environment) return
76
- const pkg = await (input.readPackageJson ?? defaultReadPackageJson)()
77
- if (!pkg || typeof pkg !== 'object' || pkg === null) return
83
+ /** The Expo SDK major from package.json, or null when it is not a version. */
84
+ export async function readExpoSdkMajor(
85
+ readPackageJson: () => Promise<unknown> = defaultReadPackageJson
86
+ ): Promise<number | null> {
87
+ const pkg = await readPackageJson()
88
+ if (!pkg || typeof pkg !== 'object') return null
78
89
  const record = pkg as {
79
90
  dependencies?: { expo?: string }
80
91
  devDependencies?: { expo?: string }
81
92
  }
82
- const version = record.dependencies?.expo ?? record.devDependencies?.expo
83
- const major = parseExpoSdkMajor(version)
93
+ return parseExpoSdkMajor(
94
+ record.dependencies?.expo ?? record.devDependencies?.expo
95
+ )
96
+ }
97
+
98
+ export async function requireEnvironmentIfNeeded(
99
+ input: Pick<OperationInput, 'environment' | 'readPackageJson'>
100
+ ) {
101
+ if (input.environment) return
102
+ const major = await readExpoSdkMajor(input.readPackageJson)
84
103
  if (major !== null && major >= 55) {
85
104
  throw new Error(
86
- 'EAS Update requires --environment for Expo SDK 55+. Pass --environment <name>.'
105
+ 'EAS Update requires --environment for Expo SDK 55+. Pass --environment <name> or set build.<profile>.environment in eas.json.'
87
106
  )
88
107
  }
89
108
  }
90
109
 
91
110
  async function runHook(
111
+ name: 'pre-update' | 'post-update',
92
112
  command: string | undefined,
93
113
  commandRunner: CommandRunner
94
114
  ) {
95
115
  if (!command) return
96
- await commandRunner(['bash', '-c', command])
116
+ await stage(name, () => commandRunner(['bash', '-c', command]))
97
117
  }
98
118
 
99
119
  function mutatingRunner(
@@ -115,12 +135,14 @@ async function publishUpdate(
115
135
  ) {
116
136
  await requireEnvironmentIfNeeded(input)
117
137
  if (input.exportSourceMaps) {
118
- await commandRunner(sourceMapExportCommand(input.platform))
119
- if (input.dryRun !== true) {
120
- await assertSourceMaps(input.hasSourceMaps)
121
- }
138
+ await stage('ota export', async () => {
139
+ await commandRunner(sourceMapExportCommand(input.platform))
140
+ if (input.dryRun !== true) {
141
+ await assertSourceMaps(input.hasSourceMaps)
142
+ }
143
+ })
122
144
  }
123
- await runHook(input.preUpdate, commandRunner)
145
+ await runHook('pre-update', input.preUpdate, commandRunner)
124
146
  const command = easCommand(
125
147
  'update',
126
148
  '--channel',
@@ -136,18 +158,19 @@ async function publishUpdate(
136
158
  }
137
159
  command.push(
138
160
  ...mergeUpdateArgs(
139
- extraUpdateArgs(input.updateExtraArgs),
161
+ splitArgs(input.updateExtraArgs),
140
162
  input.exportSourceMaps === true
141
163
  )
142
164
  )
143
- await commandRunner(command)
144
- await runHook(input.postUpdate, commandRunner)
165
+ await stage('ota update', () => commandRunner(command))
166
+ await runHook('post-update', input.postUpdate, commandRunner)
145
167
  }
146
168
 
169
+ /** Queues the build and reports the build EAS started (none in a dry run). */
147
170
  async function queueNativeBuild(
148
171
  input: OperationInput,
149
172
  commandRunner: CommandRunner
150
- ) {
173
+ ): Promise<OperationResult> {
151
174
  const command = easCommand(
152
175
  'build',
153
176
  '--platform',
@@ -155,12 +178,23 @@ async function queueNativeBuild(
155
178
  '--profile',
156
179
  input.profile,
157
180
  '--non-interactive',
158
- '--no-wait'
181
+ '--no-wait',
182
+ '--json'
159
183
  )
160
184
  if (input.autoSubmit) {
161
185
  command.push('--auto-submit')
162
186
  }
163
- await commandRunner(command)
187
+ const output = await stage('native build', () => commandRunner(command))
188
+ // The build is queued; an unreadable record only loses the outputs.
189
+ try {
190
+ const queued = output.trim() ? parseBuildList(output)[0] : undefined
191
+ if (queued) input.onQueuedBuild?.(queued)
192
+ } catch (error) {
193
+ process.stderr.write(
194
+ `Warning: could not read the queued build from eas build --json: ${error instanceof Error ? error.message : String(error)}\n`
195
+ )
196
+ }
197
+ return 'native-build'
164
198
  }
165
199
 
166
200
  async function resolveRuntimeVersion(
@@ -169,7 +203,7 @@ async function resolveRuntimeVersion(
169
203
  ): Promise<string> {
170
204
  if (input.runtimeVersion) return input.runtimeVersion
171
205
  if (input.fingerprint) return input.fingerprint
172
- return generateFingerprint(input, commandRunner)
206
+ return stage('fingerprint', () => generateFingerprint(input, commandRunner))
173
207
  }
174
208
 
175
209
  export async function operateRelease(
@@ -185,17 +219,20 @@ export async function operateRelease(
185
219
  'Usage: --operation republish requires --group <update-group-id>'
186
220
  )
187
221
  }
222
+ const group = input.group
188
223
  await requireEnvironmentIfNeeded(input)
189
- await execute(
190
- easCommand(
191
- 'update:republish',
192
- '--group',
193
- input.group,
194
- '--platform',
195
- input.platform,
196
- '--message',
197
- updateMessage(input),
198
- '--non-interactive'
224
+ await stage('recovery', () =>
225
+ execute(
226
+ easCommand(
227
+ 'update:republish',
228
+ '--group',
229
+ group,
230
+ '--platform',
231
+ input.platform,
232
+ '--message',
233
+ updateMessage(input),
234
+ '--non-interactive'
235
+ )
199
236
  )
200
237
  )
201
238
  return 'recovery'
@@ -203,25 +240,26 @@ export async function operateRelease(
203
240
  case 'rollback-embedded': {
204
241
  await requireEnvironmentIfNeeded(input)
205
242
  const runtimeVersion = await resolveRuntimeVersion(input, commandRunner)
206
- await execute(
207
- easCommand(
208
- 'update:roll-back-to-embedded',
209
- '--channel',
210
- input.channel,
211
- '--platform',
212
- input.platform,
213
- '--runtime-version',
214
- runtimeVersion,
215
- '--message',
216
- updateMessage(input),
217
- '--non-interactive'
243
+ await stage('recovery', () =>
244
+ execute(
245
+ easCommand(
246
+ 'update:roll-back-to-embedded',
247
+ '--channel',
248
+ input.channel,
249
+ '--platform',
250
+ input.platform,
251
+ '--runtime-version',
252
+ runtimeVersion,
253
+ '--message',
254
+ updateMessage(input),
255
+ '--non-interactive'
256
+ )
218
257
  )
219
258
  )
220
259
  return 'recovery'
221
260
  }
222
261
  case 'force-native':
223
- await queueNativeBuild(input, execute)
224
- return 'native-build'
262
+ return queueNativeBuild(input, execute)
225
263
  case 'retry-ota':
226
264
  await publishUpdate(input, execute)
227
265
  return 'ota'
@@ -237,8 +275,7 @@ export async function operateRelease(
237
275
  case 'none':
238
276
  return 'skipped'
239
277
  case 'native':
240
- await queueNativeBuild(input, execute)
241
- return 'native-build'
278
+ return queueNativeBuild(input, execute)
242
279
  case 'ota':
243
280
  await publishUpdate(input, execute)
244
281
  return 'ota'
@@ -249,6 +286,13 @@ export async function operateRelease(
249
286
  }
250
287
  }
251
288
 
252
- export async function writeOperateGitHubOutput(result: OperationResult) {
253
- await appendGitHubOutput({ result })
289
+ export async function writeOperateGitHubOutput(
290
+ result: OperationResult,
291
+ queuedBuild?: EASBuild
292
+ ) {
293
+ await appendGitHubOutput({
294
+ result,
295
+ queued_build_id: queuedBuild?.id ?? '',
296
+ queued_build_url: queuedBuild?.buildUrl ?? ''
297
+ })
254
298
  }