expo-release 2.0.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/LICENSE +21 -0
- package/README.md +375 -0
- package/action.yml +162 -0
- package/dist/args.d.ts +38 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +599 -0
- package/dist/decide.d.ts +35 -0
- package/dist/github-output.d.ts +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +525 -0
- package/dist/operate.d.ts +31 -0
- package/dist/run.d.ts +13 -0
- package/package.json +63 -0
- package/src/args.ts +318 -0
- package/src/cli.ts +97 -0
- package/src/decide.ts +293 -0
- package/src/github-output.ts +12 -0
- package/src/index.ts +19 -0
- package/src/operate.ts +236 -0
- package/src/run.ts +90 -0
package/src/args.ts
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { parseArgs } from 'node:util'
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_IMPACT_PATHS = ['apps/expo/'] as const
|
|
4
|
+
|
|
5
|
+
export type ReleasePlatform = 'ios' | 'android'
|
|
6
|
+
|
|
7
|
+
export type CommandName = 'release' | 'decide' | 'operate'
|
|
8
|
+
|
|
9
|
+
export type Operation =
|
|
10
|
+
| 'deploy'
|
|
11
|
+
| 'force-native'
|
|
12
|
+
| 'retry-ota'
|
|
13
|
+
| 'republish'
|
|
14
|
+
| 'rollback-embedded'
|
|
15
|
+
|
|
16
|
+
export type DeployType = 'none' | 'ota' | 'native'
|
|
17
|
+
|
|
18
|
+
export type ReleaseOptions = {
|
|
19
|
+
command: CommandName
|
|
20
|
+
profile: string
|
|
21
|
+
base?: string
|
|
22
|
+
head?: string
|
|
23
|
+
platform: ReleasePlatform
|
|
24
|
+
impactPaths: string[]
|
|
25
|
+
ignoreExpoImpact: boolean
|
|
26
|
+
operation: Operation
|
|
27
|
+
deployType: DeployType
|
|
28
|
+
channel: string
|
|
29
|
+
environment?: string
|
|
30
|
+
autoSubmit: boolean
|
|
31
|
+
preUpdate?: string
|
|
32
|
+
postUpdate?: string
|
|
33
|
+
updateExtraArgs?: string
|
|
34
|
+
group?: string
|
|
35
|
+
runtimeVersion?: string
|
|
36
|
+
message?: string
|
|
37
|
+
fingerprint?: string
|
|
38
|
+
dryRun: boolean
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const USAGE =
|
|
42
|
+
'Usage: expo-release <release|decide|operate> --profile <profile> [--base <sha>] [--head <sha>] [--platform ios|android] [--impact-paths apps/expo/] [--operation deploy|force-native|retry-ota|republish|rollback-embedded] [--deploy-type none|ota|native] [--channel <channel>] [--environment <env>] [--group <id>] [--runtime-version <hash>] [--message <msg>] [--fingerprint <hash>] [--auto-submit true] [--pre-update <cmd>] [--post-update <cmd>] [--update-extra-args <args>] [--dry-run] [--ignore-expo-impact true]'
|
|
43
|
+
|
|
44
|
+
function fromEnv(env: NodeJS.ProcessEnv, name: string): string | undefined {
|
|
45
|
+
const value = env[name]?.trim()
|
|
46
|
+
return value ? value : undefined
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function firstDefined(
|
|
50
|
+
...values: Array<string | undefined>
|
|
51
|
+
): string | undefined {
|
|
52
|
+
return values.find((value) => value !== undefined && value.length > 0)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function optionalString(value: string | undefined): string | undefined {
|
|
56
|
+
const trimmed = value?.trim()
|
|
57
|
+
return trimmed ? trimmed : undefined
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function parseImpactPaths(value: string | undefined): string[] {
|
|
61
|
+
const paths = (value ?? DEFAULT_IMPACT_PATHS.join(','))
|
|
62
|
+
.split(',')
|
|
63
|
+
.map((path) => path.trim())
|
|
64
|
+
.filter(Boolean)
|
|
65
|
+
if (paths.length === 0) {
|
|
66
|
+
throw new Error('At least one --impact-paths entry is required')
|
|
67
|
+
}
|
|
68
|
+
return paths
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function parsePlatform(value: string | undefined): ReleasePlatform {
|
|
72
|
+
if (value === undefined || value === 'ios') return 'ios'
|
|
73
|
+
if (value === 'android') return 'android'
|
|
74
|
+
throw new Error('Usage: --platform ios|android')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function parseBoolean(value: string | undefined, fallback: boolean) {
|
|
78
|
+
if (value === undefined) return fallback
|
|
79
|
+
if (value === 'true') return true
|
|
80
|
+
if (value === 'false') return false
|
|
81
|
+
throw new Error(`Expected true or false, received: ${value}`)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function parseOperation(value: string | undefined): Operation {
|
|
85
|
+
if (
|
|
86
|
+
value === undefined ||
|
|
87
|
+
value === 'deploy' ||
|
|
88
|
+
value === 'force-native' ||
|
|
89
|
+
value === 'retry-ota' ||
|
|
90
|
+
value === 'republish' ||
|
|
91
|
+
value === 'rollback-embedded'
|
|
92
|
+
) {
|
|
93
|
+
return value ?? 'deploy'
|
|
94
|
+
}
|
|
95
|
+
throw new Error(
|
|
96
|
+
'Usage: --operation deploy|force-native|retry-ota|republish|rollback-embedded'
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function parseDeployType(value: string | undefined): DeployType {
|
|
101
|
+
if (value === undefined || value === 'none') return 'none'
|
|
102
|
+
if (value === 'ota' || value === 'native') return value
|
|
103
|
+
throw new Error('Usage: --deploy-type none|ota|native')
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function parseCommandName(value: string | undefined): CommandName {
|
|
107
|
+
if (value === 'release' || value === 'decide' || value === 'operate') {
|
|
108
|
+
return value
|
|
109
|
+
}
|
|
110
|
+
throw new Error(USAGE)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function needsDecide(operation: Operation): boolean {
|
|
114
|
+
switch (operation) {
|
|
115
|
+
case 'deploy':
|
|
116
|
+
case 'retry-ota':
|
|
117
|
+
return true
|
|
118
|
+
case 'force-native':
|
|
119
|
+
case 'republish':
|
|
120
|
+
case 'rollback-embedded':
|
|
121
|
+
return false
|
|
122
|
+
default: {
|
|
123
|
+
const _exhaustive: never = operation
|
|
124
|
+
throw new Error(`Unsupported operation: ${_exhaustive}`)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function shouldIgnoreExpoImpact(
|
|
130
|
+
options: Pick<ReleaseOptions, 'ignoreExpoImpact' | 'operation'>
|
|
131
|
+
): boolean {
|
|
132
|
+
if (options.ignoreExpoImpact) return true
|
|
133
|
+
if (options.operation === 'retry-ota') return true
|
|
134
|
+
return (
|
|
135
|
+
process.env.GITHUB_EVENT_NAME === 'workflow_dispatch' &&
|
|
136
|
+
options.operation === 'deploy'
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function parseReleaseOptions(
|
|
141
|
+
args: string[],
|
|
142
|
+
env: NodeJS.ProcessEnv = process.env
|
|
143
|
+
): ReleaseOptions {
|
|
144
|
+
let values: {
|
|
145
|
+
profile?: string
|
|
146
|
+
base?: string
|
|
147
|
+
head?: string
|
|
148
|
+
platform?: string
|
|
149
|
+
'impact-paths'?: string
|
|
150
|
+
'ignore-expo-impact'?: string
|
|
151
|
+
operation?: string
|
|
152
|
+
'deploy-type'?: string
|
|
153
|
+
channel?: string
|
|
154
|
+
environment?: string
|
|
155
|
+
'auto-submit'?: string
|
|
156
|
+
'pre-update'?: string
|
|
157
|
+
'post-update'?: string
|
|
158
|
+
'update-extra-args'?: string
|
|
159
|
+
group?: string
|
|
160
|
+
'runtime-version'?: string
|
|
161
|
+
message?: string
|
|
162
|
+
fingerprint?: string
|
|
163
|
+
'dry-run'?: boolean
|
|
164
|
+
}
|
|
165
|
+
let positionals: string[]
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
const parsed = parseArgs({
|
|
169
|
+
args,
|
|
170
|
+
allowPositionals: true,
|
|
171
|
+
strict: true,
|
|
172
|
+
options: {
|
|
173
|
+
profile: { type: 'string' },
|
|
174
|
+
base: { type: 'string' },
|
|
175
|
+
head: { type: 'string' },
|
|
176
|
+
platform: { type: 'string' },
|
|
177
|
+
'impact-paths': { type: 'string' },
|
|
178
|
+
'ignore-expo-impact': { type: 'string' },
|
|
179
|
+
operation: { type: 'string' },
|
|
180
|
+
'deploy-type': { type: 'string' },
|
|
181
|
+
channel: { type: 'string' },
|
|
182
|
+
environment: { type: 'string' },
|
|
183
|
+
'auto-submit': { type: 'string' },
|
|
184
|
+
'pre-update': { type: 'string' },
|
|
185
|
+
'post-update': { type: 'string' },
|
|
186
|
+
'update-extra-args': { type: 'string' },
|
|
187
|
+
group: { type: 'string' },
|
|
188
|
+
'runtime-version': { type: 'string' },
|
|
189
|
+
message: { type: 'string' },
|
|
190
|
+
fingerprint: { type: 'string' },
|
|
191
|
+
'dry-run': { type: 'boolean', default: false }
|
|
192
|
+
}
|
|
193
|
+
})
|
|
194
|
+
values = parsed.values
|
|
195
|
+
positionals = parsed.positionals
|
|
196
|
+
} catch (error) {
|
|
197
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
198
|
+
throw new Error(`${USAGE}\n${message}`)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (positionals.length > 1) {
|
|
202
|
+
throw new Error(USAGE)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const command = parseCommandName(
|
|
206
|
+
positionals[0] ?? fromEnv(env, 'EXPO_RELEASE_COMMAND')
|
|
207
|
+
)
|
|
208
|
+
const profile = firstDefined(
|
|
209
|
+
values.profile,
|
|
210
|
+
fromEnv(env, 'EXPO_RELEASE_PROFILE')
|
|
211
|
+
)
|
|
212
|
+
if (!profile) {
|
|
213
|
+
throw new Error(USAGE)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const operation = parseOperation(
|
|
217
|
+
firstDefined(values.operation, fromEnv(env, 'EXPO_RELEASE_OPERATION'))
|
|
218
|
+
)
|
|
219
|
+
const group = optionalString(
|
|
220
|
+
firstDefined(values.group, fromEnv(env, 'EXPO_RELEASE_GROUP'))
|
|
221
|
+
)
|
|
222
|
+
if (operation === 'republish' && !group) {
|
|
223
|
+
throw new Error(
|
|
224
|
+
'Usage: --operation republish requires --group <update-group-id>'
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const channel =
|
|
229
|
+
firstDefined(values.channel, fromEnv(env, 'EXPO_RELEASE_CHANNEL')) ??
|
|
230
|
+
profile
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
command,
|
|
234
|
+
profile,
|
|
235
|
+
base: optionalString(
|
|
236
|
+
firstDefined(
|
|
237
|
+
values.base,
|
|
238
|
+
fromEnv(env, 'EXPO_RELEASE_BASE'),
|
|
239
|
+
fromEnv(env, 'GITHUB_EVENT_BEFORE')
|
|
240
|
+
)
|
|
241
|
+
),
|
|
242
|
+
head: optionalString(
|
|
243
|
+
firstDefined(
|
|
244
|
+
values.head,
|
|
245
|
+
fromEnv(env, 'EXPO_RELEASE_HEAD'),
|
|
246
|
+
fromEnv(env, 'GITHUB_SHA')
|
|
247
|
+
)
|
|
248
|
+
),
|
|
249
|
+
platform: parsePlatform(
|
|
250
|
+
firstDefined(values.platform, fromEnv(env, 'EXPO_RELEASE_PLATFORM'))
|
|
251
|
+
),
|
|
252
|
+
impactPaths: parseImpactPaths(
|
|
253
|
+
firstDefined(
|
|
254
|
+
values['impact-paths'],
|
|
255
|
+
fromEnv(env, 'EXPO_RELEASE_IMPACT_PATHS')
|
|
256
|
+
)
|
|
257
|
+
),
|
|
258
|
+
ignoreExpoImpact: parseBoolean(
|
|
259
|
+
firstDefined(
|
|
260
|
+
values['ignore-expo-impact'],
|
|
261
|
+
fromEnv(env, 'EXPO_RELEASE_IGNORE_EXPO_IMPACT')
|
|
262
|
+
),
|
|
263
|
+
false
|
|
264
|
+
),
|
|
265
|
+
operation,
|
|
266
|
+
deployType: parseDeployType(
|
|
267
|
+
firstDefined(
|
|
268
|
+
values['deploy-type'],
|
|
269
|
+
fromEnv(env, 'EXPO_RELEASE_DEPLOY_TYPE')
|
|
270
|
+
)
|
|
271
|
+
),
|
|
272
|
+
channel,
|
|
273
|
+
environment: optionalString(
|
|
274
|
+
firstDefined(values.environment, fromEnv(env, 'EXPO_RELEASE_ENVIRONMENT'))
|
|
275
|
+
),
|
|
276
|
+
autoSubmit: parseBoolean(
|
|
277
|
+
firstDefined(
|
|
278
|
+
values['auto-submit'],
|
|
279
|
+
fromEnv(env, 'EXPO_RELEASE_AUTO_SUBMIT')
|
|
280
|
+
),
|
|
281
|
+
true
|
|
282
|
+
),
|
|
283
|
+
preUpdate: optionalString(
|
|
284
|
+
firstDefined(
|
|
285
|
+
values['pre-update'],
|
|
286
|
+
fromEnv(env, 'EXPO_RELEASE_PRE_UPDATE')
|
|
287
|
+
)
|
|
288
|
+
),
|
|
289
|
+
postUpdate: optionalString(
|
|
290
|
+
firstDefined(
|
|
291
|
+
values['post-update'],
|
|
292
|
+
fromEnv(env, 'EXPO_RELEASE_POST_UPDATE')
|
|
293
|
+
)
|
|
294
|
+
),
|
|
295
|
+
updateExtraArgs: optionalString(
|
|
296
|
+
firstDefined(
|
|
297
|
+
values['update-extra-args'],
|
|
298
|
+
fromEnv(env, 'EXPO_RELEASE_UPDATE_EXTRA_ARGS')
|
|
299
|
+
)
|
|
300
|
+
),
|
|
301
|
+
group,
|
|
302
|
+
runtimeVersion: optionalString(
|
|
303
|
+
firstDefined(
|
|
304
|
+
values['runtime-version'],
|
|
305
|
+
fromEnv(env, 'EXPO_RELEASE_RUNTIME_VERSION')
|
|
306
|
+
)
|
|
307
|
+
),
|
|
308
|
+
message: optionalString(
|
|
309
|
+
firstDefined(values.message, fromEnv(env, 'EXPO_RELEASE_MESSAGE'))
|
|
310
|
+
),
|
|
311
|
+
fingerprint: optionalString(
|
|
312
|
+
firstDefined(values.fingerprint, fromEnv(env, 'EXPO_RELEASE_FINGERPRINT'))
|
|
313
|
+
),
|
|
314
|
+
dryRun:
|
|
315
|
+
values['dry-run'] === true ||
|
|
316
|
+
parseBoolean(fromEnv(env, 'EXPO_RELEASE_DRY_RUN'), false)
|
|
317
|
+
}
|
|
318
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type ReleaseOptions,
|
|
5
|
+
USAGE,
|
|
6
|
+
needsDecide,
|
|
7
|
+
parseReleaseOptions,
|
|
8
|
+
shouldIgnoreExpoImpact
|
|
9
|
+
} from './args'
|
|
10
|
+
import {
|
|
11
|
+
type DecisionInput,
|
|
12
|
+
type ReleaseDecision,
|
|
13
|
+
decideRelease,
|
|
14
|
+
writeDecideGitHubOutput
|
|
15
|
+
} from './decide'
|
|
16
|
+
import {
|
|
17
|
+
type OperationInput,
|
|
18
|
+
operateRelease,
|
|
19
|
+
writeOperateGitHubOutput
|
|
20
|
+
} from './operate'
|
|
21
|
+
|
|
22
|
+
function toDecisionInput(options: ReleaseOptions): DecisionInput {
|
|
23
|
+
return {
|
|
24
|
+
base: options.base,
|
|
25
|
+
head: options.head,
|
|
26
|
+
profile: options.profile,
|
|
27
|
+
platform: options.platform,
|
|
28
|
+
impactPaths: options.impactPaths,
|
|
29
|
+
ignoreExpoImpact: shouldIgnoreExpoImpact(options)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function toOperationInput(
|
|
34
|
+
options: ReleaseOptions,
|
|
35
|
+
decision: ReleaseDecision | null
|
|
36
|
+
): OperationInput {
|
|
37
|
+
return {
|
|
38
|
+
profile: options.profile,
|
|
39
|
+
deployType: decision?.deployType ?? options.deployType,
|
|
40
|
+
operation: options.operation,
|
|
41
|
+
channel: options.channel,
|
|
42
|
+
environment: options.environment,
|
|
43
|
+
platform: options.platform,
|
|
44
|
+
autoSubmit: options.autoSubmit,
|
|
45
|
+
fingerprint: options.fingerprint ?? decision?.fingerprint ?? undefined,
|
|
46
|
+
group: options.group,
|
|
47
|
+
runtimeVersion: options.runtimeVersion,
|
|
48
|
+
message: options.message,
|
|
49
|
+
preUpdate: options.preUpdate,
|
|
50
|
+
postUpdate: options.postUpdate,
|
|
51
|
+
updateExtraArgs: options.updateExtraArgs,
|
|
52
|
+
dryRun: options.dryRun
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function main() {
|
|
57
|
+
const raw = Bun.argv.slice(2)
|
|
58
|
+
if (raw.length === 0 || raw[0] === '--help' || raw[0] === '-h') {
|
|
59
|
+
process.stdout.write(`${USAGE}\n`)
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const options = parseReleaseOptions(raw)
|
|
64
|
+
|
|
65
|
+
switch (options.command) {
|
|
66
|
+
case 'decide': {
|
|
67
|
+
const decision = await decideRelease(toDecisionInput(options))
|
|
68
|
+
await writeDecideGitHubOutput(decision)
|
|
69
|
+
process.stdout.write(`${JSON.stringify(decision)}\n`)
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
case 'operate': {
|
|
73
|
+
const result = await operateRelease(toOperationInput(options, null))
|
|
74
|
+
await writeOperateGitHubOutput(result)
|
|
75
|
+
process.stdout.write(`${result}\n`)
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
case 'release': {
|
|
79
|
+
let decision: ReleaseDecision | null = null
|
|
80
|
+
if (needsDecide(options.operation)) {
|
|
81
|
+
decision = await decideRelease(toDecisionInput(options))
|
|
82
|
+
await writeDecideGitHubOutput(decision)
|
|
83
|
+
process.stdout.write(`${JSON.stringify(decision)}\n`)
|
|
84
|
+
}
|
|
85
|
+
const result = await operateRelease(toOperationInput(options, decision))
|
|
86
|
+
await writeOperateGitHubOutput(result)
|
|
87
|
+
process.stdout.write(`${result}\n`)
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
default: {
|
|
91
|
+
const _exhaustive: never = options.command
|
|
92
|
+
throw new Error(`Unsupported command: ${_exhaustive}`)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
await main()
|
package/src/decide.ts
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_IMPACT_PATHS,
|
|
3
|
+
type DeployType,
|
|
4
|
+
type ReleasePlatform
|
|
5
|
+
} from './args'
|
|
6
|
+
import { appendGitHubOutput } from './github-output'
|
|
7
|
+
import {
|
|
8
|
+
type CommandRunner,
|
|
9
|
+
easCommand,
|
|
10
|
+
getRecord,
|
|
11
|
+
generateFingerprint,
|
|
12
|
+
parseJson,
|
|
13
|
+
runCaptured
|
|
14
|
+
} from './run'
|
|
15
|
+
|
|
16
|
+
export type EASBuildStatus =
|
|
17
|
+
| 'finished'
|
|
18
|
+
| 'in-progress'
|
|
19
|
+
| 'new'
|
|
20
|
+
| 'in-queue'
|
|
21
|
+
| 'pending-cancel'
|
|
22
|
+
| 'errored'
|
|
23
|
+
| 'canceled'
|
|
24
|
+
|
|
25
|
+
export type EASBuild = {
|
|
26
|
+
id: string
|
|
27
|
+
status: EASBuildStatus
|
|
28
|
+
buildUrl?: string
|
|
29
|
+
fingerprint?: string
|
|
30
|
+
runtimeVersion?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type ReleaseDecision = {
|
|
34
|
+
deployType: DeployType
|
|
35
|
+
nativeBuildPending: boolean
|
|
36
|
+
changedFiles: string[]
|
|
37
|
+
fingerprint: string | null
|
|
38
|
+
build: EASBuild | null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type DecisionInput = {
|
|
42
|
+
base?: string
|
|
43
|
+
head?: string
|
|
44
|
+
profile: string
|
|
45
|
+
platform: ReleasePlatform
|
|
46
|
+
impactPaths: string[]
|
|
47
|
+
ignoreExpoImpact?: boolean
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const ACTIVE_BUILD_STATUSES: readonly EASBuildStatus[] = [
|
|
51
|
+
'in-progress',
|
|
52
|
+
'new',
|
|
53
|
+
'in-queue',
|
|
54
|
+
'pending-cancel'
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
const KNOWN_BUILD_STATUSES = new Set<string>([
|
|
58
|
+
'finished',
|
|
59
|
+
'in-progress',
|
|
60
|
+
'new',
|
|
61
|
+
'in-queue',
|
|
62
|
+
'pending-cancel',
|
|
63
|
+
'errored',
|
|
64
|
+
'canceled'
|
|
65
|
+
])
|
|
66
|
+
|
|
67
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
68
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function nestedString(
|
|
72
|
+
record: Record<string, unknown>,
|
|
73
|
+
...path: string[]
|
|
74
|
+
): string | undefined {
|
|
75
|
+
let current: unknown = record
|
|
76
|
+
for (const key of path) {
|
|
77
|
+
if (!isRecord(current)) return undefined
|
|
78
|
+
current = current[key]
|
|
79
|
+
}
|
|
80
|
+
return typeof current === 'string' && current.length > 0 ? current : undefined
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function normalizeBuildStatus(value: string): EASBuildStatus | null {
|
|
84
|
+
const status = value.toLowerCase().replaceAll('_', '-')
|
|
85
|
+
if (KNOWN_BUILD_STATUSES.has(status)) {
|
|
86
|
+
return status as EASBuildStatus
|
|
87
|
+
}
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function dashboardBuildUrl(
|
|
92
|
+
record: Record<string, unknown>
|
|
93
|
+
): string | undefined {
|
|
94
|
+
const ownerName = nestedString(record, 'app', 'ownerAccount', 'name')
|
|
95
|
+
const slug = nestedString(record, 'app', 'slug')
|
|
96
|
+
if (typeof record.id !== 'string' || !ownerName || !slug) {
|
|
97
|
+
return undefined
|
|
98
|
+
}
|
|
99
|
+
return `https://expo.dev/accounts/${ownerName}/projects/${slug}/builds/${record.id}`
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function parseBuildList(output: string): EASBuild[] {
|
|
103
|
+
const parsed = parseJson(output)
|
|
104
|
+
if (!Array.isArray(parsed)) {
|
|
105
|
+
throw new Error('EAS build:list did not return an array')
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const builds: EASBuild[] = []
|
|
109
|
+
for (const value of parsed) {
|
|
110
|
+
const record = getRecord(value, 'EAS build')
|
|
111
|
+
if (typeof record.id !== 'string' || typeof record.status !== 'string') {
|
|
112
|
+
throw new Error('EAS build must include string id and status fields')
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const status = normalizeBuildStatus(record.status)
|
|
116
|
+
if (!status) {
|
|
117
|
+
process.stderr.write(
|
|
118
|
+
`Warning: ignoring EAS build ${record.id} with unsupported status ${record.status}\n`
|
|
119
|
+
)
|
|
120
|
+
continue
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const buildUrl = dashboardBuildUrl(record)
|
|
124
|
+
const fingerprint = nestedString(record, 'fingerprint', 'hash')
|
|
125
|
+
const runtimeVersion = nestedString(record, 'runtime', 'version')
|
|
126
|
+
|
|
127
|
+
builds.push({
|
|
128
|
+
id: record.id,
|
|
129
|
+
status,
|
|
130
|
+
...(buildUrl ? { buildUrl } : {}),
|
|
131
|
+
...(fingerprint ? { fingerprint } : {}),
|
|
132
|
+
...(runtimeVersion ? { runtimeVersion } : {})
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return builds
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function isExpoImpactPath(
|
|
140
|
+
path: string,
|
|
141
|
+
impactPaths: readonly string[] = DEFAULT_IMPACT_PATHS
|
|
142
|
+
): boolean {
|
|
143
|
+
return impactPaths.some((impactPath) => {
|
|
144
|
+
const prefix = impactPath.endsWith('/')
|
|
145
|
+
? impactPath.slice(0, -1)
|
|
146
|
+
: impactPath
|
|
147
|
+
return (
|
|
148
|
+
path === prefix || path === impactPath || path.startsWith(`${prefix}/`)
|
|
149
|
+
)
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function hasExpoImpact(
|
|
154
|
+
paths: string[],
|
|
155
|
+
impactPaths: readonly string[] = DEFAULT_IMPACT_PATHS
|
|
156
|
+
): boolean {
|
|
157
|
+
return paths.some((path) => isExpoImpactPath(path, impactPaths))
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function resolveBuildDecision(builds: EASBuild[]): {
|
|
161
|
+
deployType: Exclude<DeployType, 'none'>
|
|
162
|
+
nativeBuildPending: boolean
|
|
163
|
+
build: EASBuild | null
|
|
164
|
+
} {
|
|
165
|
+
const finished = builds.find((build) => build.status === 'finished')
|
|
166
|
+
if (finished) {
|
|
167
|
+
return { deployType: 'ota', nativeBuildPending: false, build: finished }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const inProgress = builds.find((build) =>
|
|
171
|
+
ACTIVE_BUILD_STATUSES.includes(build.status)
|
|
172
|
+
)
|
|
173
|
+
if (inProgress) {
|
|
174
|
+
return {
|
|
175
|
+
deployType: 'ota',
|
|
176
|
+
nativeBuildPending: true,
|
|
177
|
+
build: inProgress
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const newest = builds[0]
|
|
182
|
+
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
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return { deployType: 'native', nativeBuildPending: false, build: null }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function getChangedFiles(
|
|
193
|
+
input: DecisionInput,
|
|
194
|
+
commandRunner: CommandRunner
|
|
195
|
+
): Promise<{ files: string[]; unreachable: boolean }> {
|
|
196
|
+
const head = input.head
|
|
197
|
+
if (!input.base || !head || /^0+$/.test(input.base)) {
|
|
198
|
+
return { files: [], unreachable: true }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
const output = await commandRunner([
|
|
203
|
+
'git',
|
|
204
|
+
'-c',
|
|
205
|
+
'diff.relative=false',
|
|
206
|
+
'diff',
|
|
207
|
+
'--name-only',
|
|
208
|
+
input.base,
|
|
209
|
+
head
|
|
210
|
+
])
|
|
211
|
+
return {
|
|
212
|
+
files: output
|
|
213
|
+
.split('\n')
|
|
214
|
+
.map((path) => path.trim())
|
|
215
|
+
.filter(Boolean),
|
|
216
|
+
unreachable: false
|
|
217
|
+
}
|
|
218
|
+
} catch {
|
|
219
|
+
return { files: [], unreachable: true }
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function getMatchingBuilds(
|
|
224
|
+
input: DecisionInput,
|
|
225
|
+
fingerprint: string,
|
|
226
|
+
commandRunner: CommandRunner
|
|
227
|
+
): Promise<EASBuild[]> {
|
|
228
|
+
return parseBuildList(
|
|
229
|
+
await commandRunner(
|
|
230
|
+
easCommand(
|
|
231
|
+
'build:list',
|
|
232
|
+
'--platform',
|
|
233
|
+
input.platform,
|
|
234
|
+
'--build-profile',
|
|
235
|
+
input.profile,
|
|
236
|
+
'--fingerprint-hash',
|
|
237
|
+
fingerprint,
|
|
238
|
+
'--limit',
|
|
239
|
+
'20',
|
|
240
|
+
'--json',
|
|
241
|
+
'--non-interactive'
|
|
242
|
+
)
|
|
243
|
+
)
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export async function decideRelease(
|
|
248
|
+
input: DecisionInput,
|
|
249
|
+
commandRunner: CommandRunner = runCaptured
|
|
250
|
+
): Promise<ReleaseDecision> {
|
|
251
|
+
const { files: changedFiles, unreachable } = await getChangedFiles(
|
|
252
|
+
input,
|
|
253
|
+
commandRunner
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
if (
|
|
257
|
+
!input.ignoreExpoImpact &&
|
|
258
|
+
!unreachable &&
|
|
259
|
+
!hasExpoImpact(changedFiles, input.impactPaths)
|
|
260
|
+
) {
|
|
261
|
+
return {
|
|
262
|
+
deployType: 'none',
|
|
263
|
+
nativeBuildPending: false,
|
|
264
|
+
changedFiles,
|
|
265
|
+
fingerprint: null,
|
|
266
|
+
build: null
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const fingerprint = await generateFingerprint(input, commandRunner)
|
|
271
|
+
const resolved = resolveBuildDecision(
|
|
272
|
+
await getMatchingBuilds(input, fingerprint, commandRunner)
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
deployType: resolved.deployType,
|
|
277
|
+
nativeBuildPending: resolved.nativeBuildPending,
|
|
278
|
+
changedFiles,
|
|
279
|
+
fingerprint,
|
|
280
|
+
build: resolved.build
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export async function writeDecideGitHubOutput(decision: ReleaseDecision) {
|
|
285
|
+
await appendGitHubOutput({
|
|
286
|
+
deploy_type: decision.deployType,
|
|
287
|
+
native_build_pending: String(decision.nativeBuildPending),
|
|
288
|
+
changed: String(decision.deployType !== 'none'),
|
|
289
|
+
fingerprint: decision.fingerprint ?? '',
|
|
290
|
+
build_id: decision.build?.id ?? '',
|
|
291
|
+
build_url: decision.build?.buildUrl ?? ''
|
|
292
|
+
})
|
|
293
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { appendFile } from 'node:fs/promises'
|
|
2
|
+
|
|
3
|
+
export async function appendGitHubOutput(entries: Record<string, string>) {
|
|
4
|
+
const outputPath = process.env.GITHUB_OUTPUT
|
|
5
|
+
if (!outputPath) return
|
|
6
|
+
|
|
7
|
+
const lines = Object.entries(entries).map(([key, value]) => {
|
|
8
|
+
const delimiter = `EOF_${key}_${crypto.randomUUID().replaceAll('-', '')}`
|
|
9
|
+
return `${key}<<${delimiter}\n${value}\n${delimiter}`
|
|
10
|
+
})
|
|
11
|
+
await appendFile(outputPath, `${lines.join('\n')}\n`)
|
|
12
|
+
}
|