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/src/index.ts ADDED
@@ -0,0 +1,19 @@
1
+ export {
2
+ DEFAULT_IMPACT_PATHS,
3
+ type CommandName,
4
+ type DeployType,
5
+ type Operation,
6
+ type ReleaseOptions,
7
+ type ReleasePlatform
8
+ } from './args'
9
+ export {
10
+ type DecisionInput,
11
+ type EASBuild,
12
+ type ReleaseDecision,
13
+ decideRelease
14
+ } from './decide'
15
+ export {
16
+ type OperationInput,
17
+ type OperationResult,
18
+ operateRelease
19
+ } from './operate'
package/src/operate.ts ADDED
@@ -0,0 +1,236 @@
1
+ import type { DeployType, Operation, ReleasePlatform } from './args'
2
+ import { appendGitHubOutput } from './github-output'
3
+ import {
4
+ type CommandRunner,
5
+ dryRunCommand,
6
+ easCommand,
7
+ generateFingerprint,
8
+ runInherited
9
+ } from './run'
10
+
11
+ export type { Operation }
12
+
13
+ export type OperationInput = {
14
+ profile: string
15
+ deployType: DeployType
16
+ operation: Operation
17
+ channel: string
18
+ environment?: string
19
+ platform: ReleasePlatform
20
+ autoSubmit: boolean
21
+ fingerprint?: string
22
+ group?: string
23
+ runtimeVersion?: string
24
+ message?: string
25
+ preUpdate?: string
26
+ postUpdate?: string
27
+ updateExtraArgs?: string
28
+ dryRun?: boolean
29
+ readPackageJson?: () => Promise<unknown>
30
+ }
31
+
32
+ export type OperationResult = 'skipped' | 'ota' | 'native-build' | 'recovery'
33
+
34
+ function extraUpdateArgs(value: string | undefined): string[] {
35
+ return value?.split(/\s+/).filter(Boolean) ?? []
36
+ }
37
+
38
+ export function updateMessage(input: {
39
+ profile: string
40
+ fingerprint?: string
41
+ message?: string
42
+ }): string {
43
+ if (input.message) return input.message
44
+ const sha = process.env.GITHUB_SHA?.slice(0, 7) ?? 'manual'
45
+ const fingerprint = input.fingerprint?.slice(0, 8)
46
+ if (fingerprint) {
47
+ return `${input.profile} ${sha} (fp ${fingerprint})`
48
+ }
49
+ return `${input.profile} ${sha}`
50
+ }
51
+
52
+ export function parseExpoSdkMajor(version: string | undefined): number | null {
53
+ if (!version) return null
54
+ const match = version.match(/(\d+)/)
55
+ if (!match?.[1]) return null
56
+ return Number(match[1])
57
+ }
58
+
59
+ async function defaultReadPackageJson(): Promise<unknown> {
60
+ const file = Bun.file('package.json')
61
+ if (!(await file.exists())) return null
62
+ return file.json()
63
+ }
64
+
65
+ export async function requireEnvironmentIfNeeded(
66
+ input: Pick<OperationInput, 'environment' | 'readPackageJson'>
67
+ ) {
68
+ if (input.environment) return
69
+ const pkg = await (input.readPackageJson ?? defaultReadPackageJson)()
70
+ if (!pkg || typeof pkg !== 'object' || pkg === null) return
71
+ const record = pkg as {
72
+ dependencies?: { expo?: string }
73
+ devDependencies?: { expo?: string }
74
+ }
75
+ const version = record.dependencies?.expo ?? record.devDependencies?.expo
76
+ const major = parseExpoSdkMajor(version)
77
+ if (major !== null && major >= 55) {
78
+ throw new Error(
79
+ 'EAS Update requires --environment for Expo SDK 55+. Pass --environment <name>.'
80
+ )
81
+ }
82
+ }
83
+
84
+ async function runHook(
85
+ command: string | undefined,
86
+ commandRunner: CommandRunner
87
+ ) {
88
+ if (!command) return
89
+ await commandRunner(['bash', '-c', command])
90
+ }
91
+
92
+ function mutatingRunner(
93
+ commandRunner: CommandRunner,
94
+ dryRun: boolean
95
+ ): CommandRunner {
96
+ return async (command) => {
97
+ if (dryRun) {
98
+ dryRunCommand(command)
99
+ return ''
100
+ }
101
+ return commandRunner(command)
102
+ }
103
+ }
104
+
105
+ async function publishUpdate(
106
+ input: OperationInput,
107
+ commandRunner: CommandRunner
108
+ ) {
109
+ await requireEnvironmentIfNeeded(input)
110
+ await runHook(input.preUpdate, commandRunner)
111
+ const command = easCommand(
112
+ 'update',
113
+ '--channel',
114
+ input.channel,
115
+ '--platform',
116
+ input.platform,
117
+ '--message',
118
+ updateMessage(input),
119
+ '--non-interactive'
120
+ )
121
+ if (input.environment) {
122
+ command.push('--environment', input.environment)
123
+ }
124
+ command.push(...extraUpdateArgs(input.updateExtraArgs))
125
+ await commandRunner(command)
126
+ await runHook(input.postUpdate, commandRunner)
127
+ }
128
+
129
+ async function queueNativeBuild(
130
+ input: OperationInput,
131
+ commandRunner: CommandRunner
132
+ ) {
133
+ const command = easCommand(
134
+ 'build',
135
+ '--platform',
136
+ input.platform,
137
+ '--profile',
138
+ input.profile,
139
+ '--non-interactive',
140
+ '--no-wait'
141
+ )
142
+ if (input.autoSubmit) {
143
+ command.push('--auto-submit')
144
+ }
145
+ await commandRunner(command)
146
+ }
147
+
148
+ async function resolveRuntimeVersion(
149
+ input: OperationInput,
150
+ commandRunner: CommandRunner
151
+ ): Promise<string> {
152
+ if (input.runtimeVersion) return input.runtimeVersion
153
+ if (input.fingerprint) return input.fingerprint
154
+ return generateFingerprint(input, commandRunner)
155
+ }
156
+
157
+ export async function operateRelease(
158
+ input: OperationInput,
159
+ commandRunner: CommandRunner = runInherited
160
+ ): Promise<OperationResult> {
161
+ const execute = mutatingRunner(commandRunner, input.dryRun === true)
162
+
163
+ switch (input.operation) {
164
+ case 'republish': {
165
+ if (!input.group) {
166
+ throw new Error(
167
+ 'Usage: --operation republish requires --group <update-group-id>'
168
+ )
169
+ }
170
+ await requireEnvironmentIfNeeded(input)
171
+ await execute(
172
+ easCommand(
173
+ 'update:republish',
174
+ '--group',
175
+ input.group,
176
+ '--platform',
177
+ input.platform,
178
+ '--message',
179
+ updateMessage(input),
180
+ '--non-interactive'
181
+ )
182
+ )
183
+ return 'recovery'
184
+ }
185
+ case 'rollback-embedded': {
186
+ await requireEnvironmentIfNeeded(input)
187
+ const runtimeVersion = await resolveRuntimeVersion(input, commandRunner)
188
+ await execute(
189
+ easCommand(
190
+ 'update:roll-back-to-embedded',
191
+ '--channel',
192
+ input.channel,
193
+ '--platform',
194
+ input.platform,
195
+ '--runtime-version',
196
+ runtimeVersion,
197
+ '--message',
198
+ updateMessage(input),
199
+ '--non-interactive'
200
+ )
201
+ )
202
+ return 'recovery'
203
+ }
204
+ case 'force-native':
205
+ await queueNativeBuild(input, execute)
206
+ return 'native-build'
207
+ case 'retry-ota':
208
+ await publishUpdate(input, execute)
209
+ return 'ota'
210
+ case 'deploy':
211
+ break
212
+ default: {
213
+ const _exhaustive: never = input.operation
214
+ throw new Error(`Unsupported operation: ${_exhaustive}`)
215
+ }
216
+ }
217
+
218
+ switch (input.deployType) {
219
+ case 'none':
220
+ return 'skipped'
221
+ case 'native':
222
+ await queueNativeBuild(input, execute)
223
+ return 'native-build'
224
+ case 'ota':
225
+ await publishUpdate(input, execute)
226
+ return 'ota'
227
+ default: {
228
+ const _exhaustive: never = input.deployType
229
+ throw new Error(`Unsupported deploy type: ${_exhaustive}`)
230
+ }
231
+ }
232
+ }
233
+
234
+ export async function writeOperateGitHubOutput(result: OperationResult) {
235
+ await appendGitHubOutput({ result })
236
+ }
package/src/run.ts ADDED
@@ -0,0 +1,90 @@
1
+ export type CommandRunner = (command: string[]) => Promise<string>
2
+
3
+ export function easCommand(...args: string[]): string[] {
4
+ const bin = process.env.EAS_BIN?.trim()
5
+ return [bin && bin.length > 0 ? bin : 'eas', ...args]
6
+ }
7
+
8
+ export async function runCaptured(command: string[]): Promise<string> {
9
+ const child = Bun.spawn(command, {
10
+ stdout: 'pipe',
11
+ stderr: 'pipe'
12
+ })
13
+ const [stdout, stderr, exitCode] = await Promise.all([
14
+ new Response(child.stdout).text(),
15
+ new Response(child.stderr).text(),
16
+ child.exited
17
+ ])
18
+ if (exitCode !== 0) {
19
+ throw new Error(
20
+ `Command failed (${command.join(' ')}):\n${stderr || stdout}`
21
+ )
22
+ }
23
+ return stdout
24
+ }
25
+
26
+ export async function runInherited(command: string[]): Promise<string> {
27
+ const child = Bun.spawn(command, { stdout: 'inherit', stderr: 'inherit' })
28
+ const exitCode = await child.exited
29
+ if (exitCode !== 0) {
30
+ throw new Error(`Command failed (${command.join(' ')})`)
31
+ }
32
+ return ''
33
+ }
34
+
35
+ export function parseJson(output: string): unknown {
36
+ try {
37
+ return JSON.parse(output) as unknown
38
+ } catch {
39
+ throw new Error(`Expected JSON output, received:\n${output}`)
40
+ }
41
+ }
42
+
43
+ export function getRecord(
44
+ value: unknown,
45
+ context: string
46
+ ): Record<string, unknown> {
47
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
48
+ throw new Error(`${context} must be an object`)
49
+ }
50
+ return value as Record<string, unknown>
51
+ }
52
+
53
+ export function parseFingerprintHash(output: string): string {
54
+ const record = getRecord(parseJson(output), 'EAS fingerprint')
55
+ if (typeof record.hash !== 'string' || record.hash.length === 0) {
56
+ throw new Error('EAS fingerprint output did not contain a full hash')
57
+ }
58
+ return record.hash
59
+ }
60
+
61
+ export function quoteCommand(command: string[]): string {
62
+ return command
63
+ .map((part) =>
64
+ /[\s"]/.test(part) ? `"${part.replaceAll('"', '\\"')}"` : part
65
+ )
66
+ .join(' ')
67
+ }
68
+
69
+ export function dryRunCommand(command: string[]): void {
70
+ process.stderr.write(`[dry-run] ${quoteCommand(command)}\n`)
71
+ }
72
+
73
+ export async function generateFingerprint(
74
+ input: { platform: string; profile: string },
75
+ commandRunner: CommandRunner
76
+ ): Promise<string> {
77
+ return parseFingerprintHash(
78
+ await commandRunner(
79
+ easCommand(
80
+ 'fingerprint:generate',
81
+ '--platform',
82
+ input.platform,
83
+ '--build-profile',
84
+ input.profile,
85
+ '--json',
86
+ '--non-interactive'
87
+ )
88
+ )
89
+ )
90
+ }