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/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
+ }