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/dist/index.js
ADDED
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/args.ts
|
|
3
|
+
import { parseArgs } from "util";
|
|
4
|
+
var DEFAULT_IMPACT_PATHS = ["apps/expo/"];
|
|
5
|
+
var USAGE = "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]";
|
|
6
|
+
function fromEnv(env, name) {
|
|
7
|
+
const value = env[name]?.trim();
|
|
8
|
+
return value ? value : undefined;
|
|
9
|
+
}
|
|
10
|
+
function firstDefined(...values) {
|
|
11
|
+
return values.find((value) => value !== undefined && value.length > 0);
|
|
12
|
+
}
|
|
13
|
+
function optionalString(value) {
|
|
14
|
+
const trimmed = value?.trim();
|
|
15
|
+
return trimmed ? trimmed : undefined;
|
|
16
|
+
}
|
|
17
|
+
function parseImpactPaths(value) {
|
|
18
|
+
const paths = (value ?? DEFAULT_IMPACT_PATHS.join(",")).split(",").map((path) => path.trim()).filter(Boolean);
|
|
19
|
+
if (paths.length === 0) {
|
|
20
|
+
throw new Error("At least one --impact-paths entry is required");
|
|
21
|
+
}
|
|
22
|
+
return paths;
|
|
23
|
+
}
|
|
24
|
+
function parsePlatform(value) {
|
|
25
|
+
if (value === undefined || value === "ios")
|
|
26
|
+
return "ios";
|
|
27
|
+
if (value === "android")
|
|
28
|
+
return "android";
|
|
29
|
+
throw new Error("Usage: --platform ios|android");
|
|
30
|
+
}
|
|
31
|
+
function parseBoolean(value, fallback) {
|
|
32
|
+
if (value === undefined)
|
|
33
|
+
return fallback;
|
|
34
|
+
if (value === "true")
|
|
35
|
+
return true;
|
|
36
|
+
if (value === "false")
|
|
37
|
+
return false;
|
|
38
|
+
throw new Error(`Expected true or false, received: ${value}`);
|
|
39
|
+
}
|
|
40
|
+
function parseOperation(value) {
|
|
41
|
+
if (value === undefined || value === "deploy" || value === "force-native" || value === "retry-ota" || value === "republish" || value === "rollback-embedded") {
|
|
42
|
+
return value ?? "deploy";
|
|
43
|
+
}
|
|
44
|
+
throw new Error("Usage: --operation deploy|force-native|retry-ota|republish|rollback-embedded");
|
|
45
|
+
}
|
|
46
|
+
function parseDeployType(value) {
|
|
47
|
+
if (value === undefined || value === "none")
|
|
48
|
+
return "none";
|
|
49
|
+
if (value === "ota" || value === "native")
|
|
50
|
+
return value;
|
|
51
|
+
throw new Error("Usage: --deploy-type none|ota|native");
|
|
52
|
+
}
|
|
53
|
+
function parseCommandName(value) {
|
|
54
|
+
if (value === "release" || value === "decide" || value === "operate") {
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
throw new Error(USAGE);
|
|
58
|
+
}
|
|
59
|
+
function needsDecide(operation) {
|
|
60
|
+
switch (operation) {
|
|
61
|
+
case "deploy":
|
|
62
|
+
case "retry-ota":
|
|
63
|
+
return true;
|
|
64
|
+
case "force-native":
|
|
65
|
+
case "republish":
|
|
66
|
+
case "rollback-embedded":
|
|
67
|
+
return false;
|
|
68
|
+
default: {
|
|
69
|
+
const _exhaustive = operation;
|
|
70
|
+
throw new Error(`Unsupported operation: ${_exhaustive}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function shouldIgnoreExpoImpact(options) {
|
|
75
|
+
if (options.ignoreExpoImpact)
|
|
76
|
+
return true;
|
|
77
|
+
if (options.operation === "retry-ota")
|
|
78
|
+
return true;
|
|
79
|
+
return process.env.GITHUB_EVENT_NAME === "workflow_dispatch" && options.operation === "deploy";
|
|
80
|
+
}
|
|
81
|
+
function parseReleaseOptions(args, env = process.env) {
|
|
82
|
+
let values;
|
|
83
|
+
let positionals;
|
|
84
|
+
try {
|
|
85
|
+
const parsed = parseArgs({
|
|
86
|
+
args,
|
|
87
|
+
allowPositionals: true,
|
|
88
|
+
strict: true,
|
|
89
|
+
options: {
|
|
90
|
+
profile: { type: "string" },
|
|
91
|
+
base: { type: "string" },
|
|
92
|
+
head: { type: "string" },
|
|
93
|
+
platform: { type: "string" },
|
|
94
|
+
"impact-paths": { type: "string" },
|
|
95
|
+
"ignore-expo-impact": { type: "string" },
|
|
96
|
+
operation: { type: "string" },
|
|
97
|
+
"deploy-type": { type: "string" },
|
|
98
|
+
channel: { type: "string" },
|
|
99
|
+
environment: { type: "string" },
|
|
100
|
+
"auto-submit": { type: "string" },
|
|
101
|
+
"pre-update": { type: "string" },
|
|
102
|
+
"post-update": { type: "string" },
|
|
103
|
+
"update-extra-args": { type: "string" },
|
|
104
|
+
group: { type: "string" },
|
|
105
|
+
"runtime-version": { type: "string" },
|
|
106
|
+
message: { type: "string" },
|
|
107
|
+
fingerprint: { type: "string" },
|
|
108
|
+
"dry-run": { type: "boolean", default: false }
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
values = parsed.values;
|
|
112
|
+
positionals = parsed.positionals;
|
|
113
|
+
} catch (error) {
|
|
114
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
115
|
+
throw new Error(`${USAGE}
|
|
116
|
+
${message}`);
|
|
117
|
+
}
|
|
118
|
+
if (positionals.length > 1) {
|
|
119
|
+
throw new Error(USAGE);
|
|
120
|
+
}
|
|
121
|
+
const command = parseCommandName(positionals[0] ?? fromEnv(env, "EXPO_RELEASE_COMMAND"));
|
|
122
|
+
const profile = firstDefined(values.profile, fromEnv(env, "EXPO_RELEASE_PROFILE"));
|
|
123
|
+
if (!profile) {
|
|
124
|
+
throw new Error(USAGE);
|
|
125
|
+
}
|
|
126
|
+
const operation = parseOperation(firstDefined(values.operation, fromEnv(env, "EXPO_RELEASE_OPERATION")));
|
|
127
|
+
const group = optionalString(firstDefined(values.group, fromEnv(env, "EXPO_RELEASE_GROUP")));
|
|
128
|
+
if (operation === "republish" && !group) {
|
|
129
|
+
throw new Error("Usage: --operation republish requires --group <update-group-id>");
|
|
130
|
+
}
|
|
131
|
+
const channel = firstDefined(values.channel, fromEnv(env, "EXPO_RELEASE_CHANNEL")) ?? profile;
|
|
132
|
+
return {
|
|
133
|
+
command,
|
|
134
|
+
profile,
|
|
135
|
+
base: optionalString(firstDefined(values.base, fromEnv(env, "EXPO_RELEASE_BASE"), fromEnv(env, "GITHUB_EVENT_BEFORE"))),
|
|
136
|
+
head: optionalString(firstDefined(values.head, fromEnv(env, "EXPO_RELEASE_HEAD"), fromEnv(env, "GITHUB_SHA"))),
|
|
137
|
+
platform: parsePlatform(firstDefined(values.platform, fromEnv(env, "EXPO_RELEASE_PLATFORM"))),
|
|
138
|
+
impactPaths: parseImpactPaths(firstDefined(values["impact-paths"], fromEnv(env, "EXPO_RELEASE_IMPACT_PATHS"))),
|
|
139
|
+
ignoreExpoImpact: parseBoolean(firstDefined(values["ignore-expo-impact"], fromEnv(env, "EXPO_RELEASE_IGNORE_EXPO_IMPACT")), false),
|
|
140
|
+
operation,
|
|
141
|
+
deployType: parseDeployType(firstDefined(values["deploy-type"], fromEnv(env, "EXPO_RELEASE_DEPLOY_TYPE"))),
|
|
142
|
+
channel,
|
|
143
|
+
environment: optionalString(firstDefined(values.environment, fromEnv(env, "EXPO_RELEASE_ENVIRONMENT"))),
|
|
144
|
+
autoSubmit: parseBoolean(firstDefined(values["auto-submit"], fromEnv(env, "EXPO_RELEASE_AUTO_SUBMIT")), true),
|
|
145
|
+
preUpdate: optionalString(firstDefined(values["pre-update"], fromEnv(env, "EXPO_RELEASE_PRE_UPDATE"))),
|
|
146
|
+
postUpdate: optionalString(firstDefined(values["post-update"], fromEnv(env, "EXPO_RELEASE_POST_UPDATE"))),
|
|
147
|
+
updateExtraArgs: optionalString(firstDefined(values["update-extra-args"], fromEnv(env, "EXPO_RELEASE_UPDATE_EXTRA_ARGS"))),
|
|
148
|
+
group,
|
|
149
|
+
runtimeVersion: optionalString(firstDefined(values["runtime-version"], fromEnv(env, "EXPO_RELEASE_RUNTIME_VERSION"))),
|
|
150
|
+
message: optionalString(firstDefined(values.message, fromEnv(env, "EXPO_RELEASE_MESSAGE"))),
|
|
151
|
+
fingerprint: optionalString(firstDefined(values.fingerprint, fromEnv(env, "EXPO_RELEASE_FINGERPRINT"))),
|
|
152
|
+
dryRun: values["dry-run"] === true || parseBoolean(fromEnv(env, "EXPO_RELEASE_DRY_RUN"), false)
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
// src/github-output.ts
|
|
156
|
+
import { appendFile } from "fs/promises";
|
|
157
|
+
async function appendGitHubOutput(entries) {
|
|
158
|
+
const outputPath = process.env.GITHUB_OUTPUT;
|
|
159
|
+
if (!outputPath)
|
|
160
|
+
return;
|
|
161
|
+
const lines = Object.entries(entries).map(([key, value]) => {
|
|
162
|
+
const delimiter = `EOF_${key}_${crypto.randomUUID().replaceAll("-", "")}`;
|
|
163
|
+
return `${key}<<${delimiter}
|
|
164
|
+
${value}
|
|
165
|
+
${delimiter}`;
|
|
166
|
+
});
|
|
167
|
+
await appendFile(outputPath, `${lines.join(`
|
|
168
|
+
`)}
|
|
169
|
+
`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// src/run.ts
|
|
173
|
+
function easCommand(...args) {
|
|
174
|
+
const bin = process.env.EAS_BIN?.trim();
|
|
175
|
+
return [bin && bin.length > 0 ? bin : "eas", ...args];
|
|
176
|
+
}
|
|
177
|
+
async function runCaptured(command) {
|
|
178
|
+
const child = Bun.spawn(command, {
|
|
179
|
+
stdout: "pipe",
|
|
180
|
+
stderr: "pipe"
|
|
181
|
+
});
|
|
182
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
183
|
+
new Response(child.stdout).text(),
|
|
184
|
+
new Response(child.stderr).text(),
|
|
185
|
+
child.exited
|
|
186
|
+
]);
|
|
187
|
+
if (exitCode !== 0) {
|
|
188
|
+
throw new Error(`Command failed (${command.join(" ")}):
|
|
189
|
+
${stderr || stdout}`);
|
|
190
|
+
}
|
|
191
|
+
return stdout;
|
|
192
|
+
}
|
|
193
|
+
async function runInherited(command) {
|
|
194
|
+
const child = Bun.spawn(command, { stdout: "inherit", stderr: "inherit" });
|
|
195
|
+
const exitCode = await child.exited;
|
|
196
|
+
if (exitCode !== 0) {
|
|
197
|
+
throw new Error(`Command failed (${command.join(" ")})`);
|
|
198
|
+
}
|
|
199
|
+
return "";
|
|
200
|
+
}
|
|
201
|
+
function parseJson(output) {
|
|
202
|
+
try {
|
|
203
|
+
return JSON.parse(output);
|
|
204
|
+
} catch {
|
|
205
|
+
throw new Error(`Expected JSON output, received:
|
|
206
|
+
${output}`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
function getRecord(value, context) {
|
|
210
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
211
|
+
throw new Error(`${context} must be an object`);
|
|
212
|
+
}
|
|
213
|
+
return value;
|
|
214
|
+
}
|
|
215
|
+
function parseFingerprintHash(output) {
|
|
216
|
+
const record = getRecord(parseJson(output), "EAS fingerprint");
|
|
217
|
+
if (typeof record.hash !== "string" || record.hash.length === 0) {
|
|
218
|
+
throw new Error("EAS fingerprint output did not contain a full hash");
|
|
219
|
+
}
|
|
220
|
+
return record.hash;
|
|
221
|
+
}
|
|
222
|
+
function quoteCommand(command) {
|
|
223
|
+
return command.map((part) => /[\s"]/.test(part) ? `"${part.replaceAll('"', "\\\"")}"` : part).join(" ");
|
|
224
|
+
}
|
|
225
|
+
function dryRunCommand(command) {
|
|
226
|
+
process.stderr.write(`[dry-run] ${quoteCommand(command)}
|
|
227
|
+
`);
|
|
228
|
+
}
|
|
229
|
+
async function generateFingerprint(input, commandRunner) {
|
|
230
|
+
return parseFingerprintHash(await commandRunner(easCommand("fingerprint:generate", "--platform", input.platform, "--build-profile", input.profile, "--json", "--non-interactive")));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// src/decide.ts
|
|
234
|
+
var ACTIVE_BUILD_STATUSES = [
|
|
235
|
+
"in-progress",
|
|
236
|
+
"new",
|
|
237
|
+
"in-queue",
|
|
238
|
+
"pending-cancel"
|
|
239
|
+
];
|
|
240
|
+
var KNOWN_BUILD_STATUSES = new Set([
|
|
241
|
+
"finished",
|
|
242
|
+
"in-progress",
|
|
243
|
+
"new",
|
|
244
|
+
"in-queue",
|
|
245
|
+
"pending-cancel",
|
|
246
|
+
"errored",
|
|
247
|
+
"canceled"
|
|
248
|
+
]);
|
|
249
|
+
function isRecord(value) {
|
|
250
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
251
|
+
}
|
|
252
|
+
function nestedString(record, ...path) {
|
|
253
|
+
let current = record;
|
|
254
|
+
for (const key of path) {
|
|
255
|
+
if (!isRecord(current))
|
|
256
|
+
return;
|
|
257
|
+
current = current[key];
|
|
258
|
+
}
|
|
259
|
+
return typeof current === "string" && current.length > 0 ? current : undefined;
|
|
260
|
+
}
|
|
261
|
+
function normalizeBuildStatus(value) {
|
|
262
|
+
const status = value.toLowerCase().replaceAll("_", "-");
|
|
263
|
+
if (KNOWN_BUILD_STATUSES.has(status)) {
|
|
264
|
+
return status;
|
|
265
|
+
}
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
function dashboardBuildUrl(record) {
|
|
269
|
+
const ownerName = nestedString(record, "app", "ownerAccount", "name");
|
|
270
|
+
const slug = nestedString(record, "app", "slug");
|
|
271
|
+
if (typeof record.id !== "string" || !ownerName || !slug) {
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
return `https://expo.dev/accounts/${ownerName}/projects/${slug}/builds/${record.id}`;
|
|
275
|
+
}
|
|
276
|
+
function parseBuildList(output) {
|
|
277
|
+
const parsed = parseJson(output);
|
|
278
|
+
if (!Array.isArray(parsed)) {
|
|
279
|
+
throw new Error("EAS build:list did not return an array");
|
|
280
|
+
}
|
|
281
|
+
const builds = [];
|
|
282
|
+
for (const value of parsed) {
|
|
283
|
+
const record = getRecord(value, "EAS build");
|
|
284
|
+
if (typeof record.id !== "string" || typeof record.status !== "string") {
|
|
285
|
+
throw new Error("EAS build must include string id and status fields");
|
|
286
|
+
}
|
|
287
|
+
const status = normalizeBuildStatus(record.status);
|
|
288
|
+
if (!status) {
|
|
289
|
+
process.stderr.write(`Warning: ignoring EAS build ${record.id} with unsupported status ${record.status}
|
|
290
|
+
`);
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
const buildUrl = dashboardBuildUrl(record);
|
|
294
|
+
const fingerprint = nestedString(record, "fingerprint", "hash");
|
|
295
|
+
const runtimeVersion = nestedString(record, "runtime", "version");
|
|
296
|
+
builds.push({
|
|
297
|
+
id: record.id,
|
|
298
|
+
status,
|
|
299
|
+
...buildUrl ? { buildUrl } : {},
|
|
300
|
+
...fingerprint ? { fingerprint } : {},
|
|
301
|
+
...runtimeVersion ? { runtimeVersion } : {}
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
return builds;
|
|
305
|
+
}
|
|
306
|
+
function isExpoImpactPath(path, impactPaths = DEFAULT_IMPACT_PATHS) {
|
|
307
|
+
return impactPaths.some((impactPath) => {
|
|
308
|
+
const prefix = impactPath.endsWith("/") ? impactPath.slice(0, -1) : impactPath;
|
|
309
|
+
return path === prefix || path === impactPath || path.startsWith(`${prefix}/`);
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
function hasExpoImpact(paths, impactPaths = DEFAULT_IMPACT_PATHS) {
|
|
313
|
+
return paths.some((path) => isExpoImpactPath(path, impactPaths));
|
|
314
|
+
}
|
|
315
|
+
function resolveBuildDecision(builds) {
|
|
316
|
+
const finished = builds.find((build) => build.status === "finished");
|
|
317
|
+
if (finished) {
|
|
318
|
+
return { deployType: "ota", nativeBuildPending: false, build: finished };
|
|
319
|
+
}
|
|
320
|
+
const inProgress = builds.find((build) => ACTIVE_BUILD_STATUSES.includes(build.status));
|
|
321
|
+
if (inProgress) {
|
|
322
|
+
return {
|
|
323
|
+
deployType: "ota",
|
|
324
|
+
nativeBuildPending: true,
|
|
325
|
+
build: inProgress
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
const newest = builds[0];
|
|
329
|
+
if (newest?.status === "errored") {
|
|
330
|
+
const location = newest.buildUrl ?? newest.id;
|
|
331
|
+
throw new Error(`The most recent native build for this fingerprint failed (${location}). Fix the failure or use --operation force-native to queue another build.`);
|
|
332
|
+
}
|
|
333
|
+
return { deployType: "native", nativeBuildPending: false, build: null };
|
|
334
|
+
}
|
|
335
|
+
async function getChangedFiles(input, commandRunner) {
|
|
336
|
+
const head = input.head;
|
|
337
|
+
if (!input.base || !head || /^0+$/.test(input.base)) {
|
|
338
|
+
return { files: [], unreachable: true };
|
|
339
|
+
}
|
|
340
|
+
try {
|
|
341
|
+
const output = await commandRunner([
|
|
342
|
+
"git",
|
|
343
|
+
"-c",
|
|
344
|
+
"diff.relative=false",
|
|
345
|
+
"diff",
|
|
346
|
+
"--name-only",
|
|
347
|
+
input.base,
|
|
348
|
+
head
|
|
349
|
+
]);
|
|
350
|
+
return {
|
|
351
|
+
files: output.split(`
|
|
352
|
+
`).map((path) => path.trim()).filter(Boolean),
|
|
353
|
+
unreachable: false
|
|
354
|
+
};
|
|
355
|
+
} catch {
|
|
356
|
+
return { files: [], unreachable: true };
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
async function getMatchingBuilds(input, fingerprint, commandRunner) {
|
|
360
|
+
return parseBuildList(await commandRunner(easCommand("build:list", "--platform", input.platform, "--build-profile", input.profile, "--fingerprint-hash", fingerprint, "--limit", "20", "--json", "--non-interactive")));
|
|
361
|
+
}
|
|
362
|
+
async function decideRelease(input, commandRunner = runCaptured) {
|
|
363
|
+
const { files: changedFiles, unreachable } = await getChangedFiles(input, commandRunner);
|
|
364
|
+
if (!input.ignoreExpoImpact && !unreachable && !hasExpoImpact(changedFiles, input.impactPaths)) {
|
|
365
|
+
return {
|
|
366
|
+
deployType: "none",
|
|
367
|
+
nativeBuildPending: false,
|
|
368
|
+
changedFiles,
|
|
369
|
+
fingerprint: null,
|
|
370
|
+
build: null
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
const fingerprint = await generateFingerprint(input, commandRunner);
|
|
374
|
+
const resolved = resolveBuildDecision(await getMatchingBuilds(input, fingerprint, commandRunner));
|
|
375
|
+
return {
|
|
376
|
+
deployType: resolved.deployType,
|
|
377
|
+
nativeBuildPending: resolved.nativeBuildPending,
|
|
378
|
+
changedFiles,
|
|
379
|
+
fingerprint,
|
|
380
|
+
build: resolved.build
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
async function writeDecideGitHubOutput(decision) {
|
|
384
|
+
await appendGitHubOutput({
|
|
385
|
+
deploy_type: decision.deployType,
|
|
386
|
+
native_build_pending: String(decision.nativeBuildPending),
|
|
387
|
+
changed: String(decision.deployType !== "none"),
|
|
388
|
+
fingerprint: decision.fingerprint ?? "",
|
|
389
|
+
build_id: decision.build?.id ?? "",
|
|
390
|
+
build_url: decision.build?.buildUrl ?? ""
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
// src/operate.ts
|
|
394
|
+
function extraUpdateArgs(value) {
|
|
395
|
+
return value?.split(/\s+/).filter(Boolean) ?? [];
|
|
396
|
+
}
|
|
397
|
+
function updateMessage(input) {
|
|
398
|
+
if (input.message)
|
|
399
|
+
return input.message;
|
|
400
|
+
const sha = process.env.GITHUB_SHA?.slice(0, 7) ?? "manual";
|
|
401
|
+
const fingerprint = input.fingerprint?.slice(0, 8);
|
|
402
|
+
if (fingerprint) {
|
|
403
|
+
return `${input.profile} ${sha} (fp ${fingerprint})`;
|
|
404
|
+
}
|
|
405
|
+
return `${input.profile} ${sha}`;
|
|
406
|
+
}
|
|
407
|
+
function parseExpoSdkMajor(version) {
|
|
408
|
+
if (!version)
|
|
409
|
+
return null;
|
|
410
|
+
const match = version.match(/(\d+)/);
|
|
411
|
+
if (!match?.[1])
|
|
412
|
+
return null;
|
|
413
|
+
return Number(match[1]);
|
|
414
|
+
}
|
|
415
|
+
async function defaultReadPackageJson() {
|
|
416
|
+
const file = Bun.file("package.json");
|
|
417
|
+
if (!await file.exists())
|
|
418
|
+
return null;
|
|
419
|
+
return file.json();
|
|
420
|
+
}
|
|
421
|
+
async function requireEnvironmentIfNeeded(input) {
|
|
422
|
+
if (input.environment)
|
|
423
|
+
return;
|
|
424
|
+
const pkg = await (input.readPackageJson ?? defaultReadPackageJson)();
|
|
425
|
+
if (!pkg || typeof pkg !== "object" || pkg === null)
|
|
426
|
+
return;
|
|
427
|
+
const record = pkg;
|
|
428
|
+
const version = record.dependencies?.expo ?? record.devDependencies?.expo;
|
|
429
|
+
const major = parseExpoSdkMajor(version);
|
|
430
|
+
if (major !== null && major >= 55) {
|
|
431
|
+
throw new Error("EAS Update requires --environment for Expo SDK 55+. Pass --environment <name>.");
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
async function runHook(command, commandRunner) {
|
|
435
|
+
if (!command)
|
|
436
|
+
return;
|
|
437
|
+
await commandRunner(["bash", "-c", command]);
|
|
438
|
+
}
|
|
439
|
+
function mutatingRunner(commandRunner, dryRun) {
|
|
440
|
+
return async (command) => {
|
|
441
|
+
if (dryRun) {
|
|
442
|
+
dryRunCommand(command);
|
|
443
|
+
return "";
|
|
444
|
+
}
|
|
445
|
+
return commandRunner(command);
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
async function publishUpdate(input, commandRunner) {
|
|
449
|
+
await requireEnvironmentIfNeeded(input);
|
|
450
|
+
await runHook(input.preUpdate, commandRunner);
|
|
451
|
+
const command = easCommand("update", "--channel", input.channel, "--platform", input.platform, "--message", updateMessage(input), "--non-interactive");
|
|
452
|
+
if (input.environment) {
|
|
453
|
+
command.push("--environment", input.environment);
|
|
454
|
+
}
|
|
455
|
+
command.push(...extraUpdateArgs(input.updateExtraArgs));
|
|
456
|
+
await commandRunner(command);
|
|
457
|
+
await runHook(input.postUpdate, commandRunner);
|
|
458
|
+
}
|
|
459
|
+
async function queueNativeBuild(input, commandRunner) {
|
|
460
|
+
const command = easCommand("build", "--platform", input.platform, "--profile", input.profile, "--non-interactive", "--no-wait");
|
|
461
|
+
if (input.autoSubmit) {
|
|
462
|
+
command.push("--auto-submit");
|
|
463
|
+
}
|
|
464
|
+
await commandRunner(command);
|
|
465
|
+
}
|
|
466
|
+
async function resolveRuntimeVersion(input, commandRunner) {
|
|
467
|
+
if (input.runtimeVersion)
|
|
468
|
+
return input.runtimeVersion;
|
|
469
|
+
if (input.fingerprint)
|
|
470
|
+
return input.fingerprint;
|
|
471
|
+
return generateFingerprint(input, commandRunner);
|
|
472
|
+
}
|
|
473
|
+
async function operateRelease(input, commandRunner = runInherited) {
|
|
474
|
+
const execute = mutatingRunner(commandRunner, input.dryRun === true);
|
|
475
|
+
switch (input.operation) {
|
|
476
|
+
case "republish": {
|
|
477
|
+
if (!input.group) {
|
|
478
|
+
throw new Error("Usage: --operation republish requires --group <update-group-id>");
|
|
479
|
+
}
|
|
480
|
+
await requireEnvironmentIfNeeded(input);
|
|
481
|
+
await execute(easCommand("update:republish", "--group", input.group, "--platform", input.platform, "--message", updateMessage(input), "--non-interactive"));
|
|
482
|
+
return "recovery";
|
|
483
|
+
}
|
|
484
|
+
case "rollback-embedded": {
|
|
485
|
+
await requireEnvironmentIfNeeded(input);
|
|
486
|
+
const runtimeVersion = await resolveRuntimeVersion(input, commandRunner);
|
|
487
|
+
await execute(easCommand("update:roll-back-to-embedded", "--channel", input.channel, "--platform", input.platform, "--runtime-version", runtimeVersion, "--message", updateMessage(input), "--non-interactive"));
|
|
488
|
+
return "recovery";
|
|
489
|
+
}
|
|
490
|
+
case "force-native":
|
|
491
|
+
await queueNativeBuild(input, execute);
|
|
492
|
+
return "native-build";
|
|
493
|
+
case "retry-ota":
|
|
494
|
+
await publishUpdate(input, execute);
|
|
495
|
+
return "ota";
|
|
496
|
+
case "deploy":
|
|
497
|
+
break;
|
|
498
|
+
default: {
|
|
499
|
+
const _exhaustive = input.operation;
|
|
500
|
+
throw new Error(`Unsupported operation: ${_exhaustive}`);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
switch (input.deployType) {
|
|
504
|
+
case "none":
|
|
505
|
+
return "skipped";
|
|
506
|
+
case "native":
|
|
507
|
+
await queueNativeBuild(input, execute);
|
|
508
|
+
return "native-build";
|
|
509
|
+
case "ota":
|
|
510
|
+
await publishUpdate(input, execute);
|
|
511
|
+
return "ota";
|
|
512
|
+
default: {
|
|
513
|
+
const _exhaustive = input.deployType;
|
|
514
|
+
throw new Error(`Unsupported deploy type: ${_exhaustive}`);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
async function writeOperateGitHubOutput(result) {
|
|
519
|
+
await appendGitHubOutput({ result });
|
|
520
|
+
}
|
|
521
|
+
export {
|
|
522
|
+
operateRelease,
|
|
523
|
+
decideRelease,
|
|
524
|
+
DEFAULT_IMPACT_PATHS
|
|
525
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { DeployType, Operation, ReleasePlatform } from './args';
|
|
2
|
+
import { type CommandRunner } from './run';
|
|
3
|
+
export type { Operation };
|
|
4
|
+
export type OperationInput = {
|
|
5
|
+
profile: string;
|
|
6
|
+
deployType: DeployType;
|
|
7
|
+
operation: Operation;
|
|
8
|
+
channel: string;
|
|
9
|
+
environment?: string;
|
|
10
|
+
platform: ReleasePlatform;
|
|
11
|
+
autoSubmit: boolean;
|
|
12
|
+
fingerprint?: string;
|
|
13
|
+
group?: string;
|
|
14
|
+
runtimeVersion?: string;
|
|
15
|
+
message?: string;
|
|
16
|
+
preUpdate?: string;
|
|
17
|
+
postUpdate?: string;
|
|
18
|
+
updateExtraArgs?: string;
|
|
19
|
+
dryRun?: boolean;
|
|
20
|
+
readPackageJson?: () => Promise<unknown>;
|
|
21
|
+
};
|
|
22
|
+
export type OperationResult = 'skipped' | 'ota' | 'native-build' | 'recovery';
|
|
23
|
+
export declare function updateMessage(input: {
|
|
24
|
+
profile: string;
|
|
25
|
+
fingerprint?: string;
|
|
26
|
+
message?: string;
|
|
27
|
+
}): string;
|
|
28
|
+
export declare function parseExpoSdkMajor(version: string | undefined): number | null;
|
|
29
|
+
export declare function requireEnvironmentIfNeeded(input: Pick<OperationInput, 'environment' | 'readPackageJson'>): Promise<void>;
|
|
30
|
+
export declare function operateRelease(input: OperationInput, commandRunner?: CommandRunner): Promise<OperationResult>;
|
|
31
|
+
export declare function writeOperateGitHubOutput(result: OperationResult): Promise<void>;
|
package/dist/run.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type CommandRunner = (command: string[]) => Promise<string>;
|
|
2
|
+
export declare function easCommand(...args: string[]): string[];
|
|
3
|
+
export declare function runCaptured(command: string[]): Promise<string>;
|
|
4
|
+
export declare function runInherited(command: string[]): Promise<string>;
|
|
5
|
+
export declare function parseJson(output: string): unknown;
|
|
6
|
+
export declare function getRecord(value: unknown, context: string): Record<string, unknown>;
|
|
7
|
+
export declare function parseFingerprintHash(output: string): string;
|
|
8
|
+
export declare function quoteCommand(command: string[]): string;
|
|
9
|
+
export declare function dryRunCommand(command: string[]): void;
|
|
10
|
+
export declare function generateFingerprint(input: {
|
|
11
|
+
platform: string;
|
|
12
|
+
profile: string;
|
|
13
|
+
}, commandRunner: CommandRunner): Promise<string>;
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "expo-release",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "Fingerprint-driven Expo EAS release: OTA update or native store build",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"module": "./dist/index.js",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/HansKristoffer/expo-release.git"
|
|
13
|
+
},
|
|
14
|
+
"author": "Kristoffer Hansen",
|
|
15
|
+
"keywords": [
|
|
16
|
+
"expo",
|
|
17
|
+
"eas",
|
|
18
|
+
"eas-update",
|
|
19
|
+
"fingerprint",
|
|
20
|
+
"ota",
|
|
21
|
+
"testflight",
|
|
22
|
+
"github-actions"
|
|
23
|
+
],
|
|
24
|
+
"engines": {
|
|
25
|
+
"bun": ">=1.0.0"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"src/**/*.ts",
|
|
29
|
+
"dist",
|
|
30
|
+
"action.yml",
|
|
31
|
+
"!src/**/*.test.ts"
|
|
32
|
+
],
|
|
33
|
+
"bin": {
|
|
34
|
+
"expo-release": "./dist/cli.js"
|
|
35
|
+
},
|
|
36
|
+
"exports": {
|
|
37
|
+
".": {
|
|
38
|
+
"types": "./dist/index.d.ts",
|
|
39
|
+
"bun": "./src/index.ts",
|
|
40
|
+
"import": "./dist/index.js",
|
|
41
|
+
"default": "./dist/index.js"
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"build": "bun run build:js && bun run build:types",
|
|
46
|
+
"build:js": "bun build ./src/index.ts ./src/cli.ts --outdir ./dist --target bun --packages external",
|
|
47
|
+
"build:types": "tsgo -p tsconfig.build.json",
|
|
48
|
+
"prepublishOnly": "bun run build",
|
|
49
|
+
"publish:patch": "npm version patch && npm publish",
|
|
50
|
+
"publish:minor": "npm version minor && npm publish",
|
|
51
|
+
"publish:major": "npm version major && npm publish",
|
|
52
|
+
"test": "bun test",
|
|
53
|
+
"lint": "bun run typecheck && bun biome check",
|
|
54
|
+
"lint:write": "bun run typecheck && bun biome check --write",
|
|
55
|
+
"typecheck": "tsgo --noEmit"
|
|
56
|
+
},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"@biomejs/biome": "^2.4.6",
|
|
59
|
+
"@types/bun": "latest",
|
|
60
|
+
"@typescript/native-preview": "^7.0.0-dev.20260306.1",
|
|
61
|
+
"typescript": "^5.7.0"
|
|
62
|
+
}
|
|
63
|
+
}
|