looply-cli 0.5.14 → 0.5.18

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,670 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.nodeReleaseExec = exports.APP_RELEASES_FILE = exports.SUBMIT_TIMEOUT_MS = exports.EAS_TIMEOUT_MS = exports.RESOLVE_TIMEOUT_MS = exports.JEST_TIMEOUT_MS = exports.INSTALL_TIMEOUT_MS = exports.IOS_PROCESSING_MS = void 0;
4
+ exports.fileAppReleaseStore = fileAppReleaseStore;
5
+ exports.memoryAppReleaseStore = memoryAppReleaseStore;
6
+ exports.lastJson = lastJson;
7
+ exports.bumpVersion = bumpVersion;
8
+ exports.realNodeModules = realNodeModules;
9
+ exports.removeCheckout = removeCheckout;
10
+ exports.planRelease = planRelease;
11
+ exports.startRelease = startRelease;
12
+ exports.followRelease = followRelease;
13
+ exports.cleanup = cleanup;
14
+ exports.easCliScript = easCliScript;
15
+ exports.realEasRunner = realEasRunner;
16
+ exports.dryRunEas = dryRunEas;
17
+ const node_child_process_1 = require("node:child_process");
18
+ const node_fs_1 = require("node:fs");
19
+ const node_path_1 = require("node:path");
20
+ const core_1 = require("@looply/core");
21
+ const expo_release_rules_1 = require("./expo-release-rules");
22
+ const delivery_rules_1 = require("./delivery-rules");
23
+ /** iOS: TestFlight processing after EAS's upload; Apple says nothing to EAS, so a window is waited out. */
24
+ exports.IOS_PROCESSING_MS = 20 * 60_000;
25
+ exports.INSTALL_TIMEOUT_MS = 30 * 60_000;
26
+ exports.JEST_TIMEOUT_MS = 60 * 60_000;
27
+ exports.RESOLVE_TIMEOUT_MS = 5 * 60_000;
28
+ exports.EAS_TIMEOUT_MS = 15 * 60_000;
29
+ exports.SUBMIT_TIMEOUT_MS = 45 * 60_000;
30
+ // ─── local state: what is in flight, so a restart resumes instead of repeating ──
31
+ exports.APP_RELEASES_FILE = 'app-releases.json';
32
+ function fileAppReleaseStore(stateDir) {
33
+ const file = (0, node_path_1.join)(stateDir, exports.APP_RELEASES_FILE);
34
+ return {
35
+ read() {
36
+ try {
37
+ const raw = JSON.parse((0, node_fs_1.readFileSync)(file, 'utf8'));
38
+ return { releases: raw.releases ?? {}, lastReleasedSha: raw.lastReleasedSha ?? null, credentials: raw.credentials ?? {} };
39
+ }
40
+ catch {
41
+ return { releases: {}, lastReleasedSha: null, credentials: {} };
42
+ }
43
+ },
44
+ write(s) {
45
+ (0, node_fs_1.mkdirSync)(stateDir, { recursive: true });
46
+ const tmp = `${file}.${process.pid}.tmp`;
47
+ (0, node_fs_1.writeFileSync)(tmp, JSON.stringify(s, null, 2) + '\n', 'utf8');
48
+ (0, node_fs_1.renameSync)(tmp, file);
49
+ },
50
+ };
51
+ }
52
+ function memoryAppReleaseStore() {
53
+ let s = { releases: {}, lastReleasedSha: null, credentials: {} };
54
+ return { read: () => JSON.parse(JSON.stringify(s)), write: (n) => (s = JSON.parse(JSON.stringify(n))) };
55
+ }
56
+ // ─── helpers ────────────────────────────────────────────────────────────────
57
+ const sentence = (s, n = 400) => {
58
+ const t = s.replace(/\s+/g, ' ').trim().slice(0, n);
59
+ return t === '' ? t : /[.!?…]$/.test(t) ? t : `${t}.`;
60
+ };
61
+ function readJson(path) {
62
+ try {
63
+ return JSON.parse((0, node_fs_1.readFileSync)(path, 'utf8'));
64
+ }
65
+ catch {
66
+ return null;
67
+ }
68
+ }
69
+ /** The last JSON value in a command's output (eas prints warnings around it). */
70
+ function lastJson(stdout) {
71
+ const s = stdout.trim();
72
+ try {
73
+ return JSON.parse(s);
74
+ }
75
+ catch {
76
+ /* look for the last top-level array/object */
77
+ }
78
+ for (const open of ['[', '{']) {
79
+ for (let i = s.lastIndexOf(open); i >= 0; i = s.lastIndexOf(open, i - 1)) {
80
+ try {
81
+ return JSON.parse(s.slice(i));
82
+ }
83
+ catch {
84
+ /* keep looking */
85
+ }
86
+ if (i === 0)
87
+ break;
88
+ }
89
+ }
90
+ return null;
91
+ }
92
+ function bumpVersion(v, how) {
93
+ if (how === 'none')
94
+ return v;
95
+ const m = /^(\d+)\.(\d+)\.(\d+)(.*)$/.exec(v.trim());
96
+ if (m === null)
97
+ return v;
98
+ const [maj, min, pat] = [Number(m[1]), Number(m[2]), Number(m[3])];
99
+ return how === 'minor' ? `${maj}.${min + 1}.0` : `${maj}.${min}.${pat + 1}`;
100
+ }
101
+ /** node_modules is a real directory, not a symlink or a junction. */
102
+ function realNodeModules(dir) {
103
+ const nm = (0, node_path_1.join)(dir, 'node_modules');
104
+ let st;
105
+ try {
106
+ st = (0, node_fs_1.lstatSync)(nm);
107
+ }
108
+ catch {
109
+ return 'there is no node_modules after the install';
110
+ }
111
+ if (st.isSymbolicLink())
112
+ return 'node_modules is a link (a junction moves the Expo fingerprint); the release needs a real copy';
113
+ if (!st.isDirectory())
114
+ return 'node_modules is not a directory';
115
+ return null;
116
+ }
117
+ /** Remove a clean checkout. Links at its top and in node_modules are unlinked first, so nothing they point at is touched. */
118
+ function removeCheckout(dir) {
119
+ if (!(0, node_fs_1.existsSync)(dir))
120
+ return;
121
+ for (const p of [(0, node_path_1.join)(dir, 'node_modules'), ...safeList((0, node_path_1.join)(dir, 'node_modules')).map((n) => (0, node_path_1.join)(dir, 'node_modules', n))]) {
122
+ try {
123
+ if ((0, node_fs_1.lstatSync)(p).isSymbolicLink())
124
+ (0, node_fs_1.unlinkSync)(p);
125
+ }
126
+ catch {
127
+ /* gone already */
128
+ }
129
+ }
130
+ (0, node_fs_1.rmSync)(dir, { recursive: true, force: true, maxRetries: 3 });
131
+ }
132
+ function safeList(dir) {
133
+ try {
134
+ return (0, node_fs_1.readdirSync)(dir);
135
+ }
136
+ catch {
137
+ return [];
138
+ }
139
+ }
140
+ function installCommand(dir) {
141
+ if ((0, node_fs_1.existsSync)((0, node_path_1.join)(dir, 'package-lock.json')))
142
+ return { bin: 'npm', args: ['ci', '--no-audit', '--no-fund'] };
143
+ if ((0, node_fs_1.existsSync)((0, node_path_1.join)(dir, 'yarn.lock')))
144
+ return { bin: 'yarn', args: ['install', '--frozen-lockfile'] };
145
+ if ((0, node_fs_1.existsSync)((0, node_path_1.join)(dir, 'pnpm-lock.yaml')))
146
+ return { bin: 'pnpm', args: ['install', '--frozen-lockfile'] };
147
+ return null;
148
+ }
149
+ // ─── PLAN ───────────────────────────────────────────────────────────────────
150
+ async function planRelease(deps, input) {
151
+ const s = input.settings;
152
+ const platforms = (0, core_1.platformsOf)(s);
153
+ const token = deps.expoToken();
154
+ const secrets = [token];
155
+ const say = (line) => deps.log((0, expo_release_rules_1.redact)(`app-release ${input.id} : ${line}`, secrets));
156
+ // The suite runs alone.
157
+ const busy = await deps.othersRunning();
158
+ if (busy !== null)
159
+ return { kind: 'wait', detail: `waiting to run the full test suite alone: ${busy}` };
160
+ // 1. origin's base branch, exactly.
161
+ let base = deps.baseBranch;
162
+ if (base === undefined) {
163
+ const head = await deps.git(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD']);
164
+ base = head.code === 0 ? head.stdout.trim().replace(/^origin\//, '') : 'main';
165
+ }
166
+ const fetched = await deps.git(['fetch', '--quiet', 'origin', `refs/heads/${base}:refs/remotes/origin/${base}`]);
167
+ if (fetched.code !== 0)
168
+ return { kind: 'wait', detail: `could not fetch origin/${base}: ${sentence(fetched.stderr, 200)}` };
169
+ // A dry run may plan at another commit (`looply app-release plan --at`), to show a decision against an older release.
170
+ if (deps.at !== undefined && !deps.eas.dryRun)
171
+ return { kind: 'end', state: 'refused', detail: 'planning at another commit is for dry runs only' };
172
+ const rev = await deps.git(['rev-parse', deps.at ?? `refs/remotes/origin/${base}`]);
173
+ const sha = rev.stdout.trim();
174
+ if (rev.code !== 0 || !/^[0-9a-f]{40}$/.test(sha))
175
+ return { kind: 'end', state: 'failed', detail: `origin has no ${base} branch` };
176
+ const store = deps.store.read();
177
+ if (store.lastReleasedSha === sha) {
178
+ return { kind: 'end', state: 'nothing-to-release', detail: `origin/${base} is still at ${sha.slice(0, 7)}, which the last release already shipped` };
179
+ }
180
+ // 2. a clean checkout with a real node_modules.
181
+ const dir = (0, node_path_1.join)(deps.workRoot, `app-release-${input.id.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 40)}`);
182
+ removeCheckout(dir);
183
+ await deps.git(['worktree', 'prune']);
184
+ (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(dir), { recursive: true });
185
+ const added = await deps.git(['worktree', 'add', '--detach', dir, sha]);
186
+ if (added.code !== 0)
187
+ return { kind: 'end', state: 'failed', detail: `could not make a clean checkout: ${sentence(added.stderr, 200)}` };
188
+ remember(deps, input.id, { checkoutDir: dir });
189
+ say(`clean checkout of ${deps.at === undefined ? `origin/${base}` : deps.at} (${sha.slice(0, 7)}) in ${dir}`);
190
+ if (deps.install === 'copy') {
191
+ const from = (0, node_path_1.join)(deps.repoDir, 'node_modules');
192
+ if (realNodeModules(deps.repoDir) !== null)
193
+ return { kind: 'end', state: 'failed', detail: 'the repository’s own node_modules is missing or linked, so it cannot be copied' };
194
+ const cp = await deps.exec(process.platform === 'win32' ? 'robocopy' : 'cp', process.platform === 'win32'
195
+ ? [from, (0, node_path_1.join)(dir, 'node_modules'), '/E', '/SL', '/NFL', '/NDL', '/NJH', '/NJS', '/NP', '/R:1', '/W:1']
196
+ : ['-a', from, (0, node_path_1.join)(dir, 'node_modules')], { cwd: dir, timeoutMs: exports.INSTALL_TIMEOUT_MS });
197
+ // robocopy: 0–7 are success codes.
198
+ if (process.platform === 'win32' ? cp.code > 7 : cp.code !== 0)
199
+ return { kind: 'end', state: 'failed', detail: `copying node_modules failed: ${sentence(cp.stderr || cp.stdout, 200)}` };
200
+ }
201
+ else {
202
+ const inst = installCommand(dir);
203
+ if (inst === null)
204
+ return { kind: 'end', state: 'failed', detail: 'the app has no lockfile (package-lock.json, yarn.lock or pnpm-lock.yaml) to install from' };
205
+ say(`${inst.bin} ${inst.args.join(' ')}`);
206
+ const r = await deps.exec(inst.bin, inst.args, { cwd: dir, timeoutMs: exports.INSTALL_TIMEOUT_MS });
207
+ if (r.code !== 0)
208
+ return { kind: 'end', state: 'failed', detail: `${inst.bin} ${inst.args[0]} failed: ${sentence((0, expo_release_rules_1.redact)(r.stderr || r.stdout, secrets).slice(-300), 300)}` };
209
+ }
210
+ const linked = realNodeModules(dir);
211
+ if (linked !== null)
212
+ return { kind: 'end', state: 'failed', detail: linked };
213
+ // 3. the full jest suite, alone.
214
+ const jestBin = (0, node_path_1.join)(dir, 'node_modules', 'jest', 'bin', 'jest.js');
215
+ if (!(0, node_fs_1.existsSync)(jestBin))
216
+ return { kind: 'end', state: 'refused', detail: 'the app has no jest; a release needs its full test suite to pass first' };
217
+ const busyAgain = await deps.othersRunning();
218
+ if (busyAgain !== null)
219
+ return { kind: 'wait', detail: `waiting to run the full test suite alone: ${busyAgain}` };
220
+ // Hold the machine while the suite runs, so no Looply run starts beside it.
221
+ const held = deps.reserve?.() ?? (() => undefined);
222
+ if (typeof held === 'string')
223
+ return { kind: 'wait', detail: `waiting to run the full test suite alone: ${held}` };
224
+ const out = (0, node_path_1.join)(dir, '.looply-jest-result.json');
225
+ const command = 'jest --ci (the full suite)';
226
+ say(command);
227
+ let jr;
228
+ try {
229
+ jr = await deps.exec(process.execPath, [jestBin, '--ci', '--json', `--outputFile=${out}`], { cwd: dir, env: { CI: '1' }, timeoutMs: exports.JEST_TIMEOUT_MS });
230
+ }
231
+ finally {
232
+ held();
233
+ }
234
+ const result = readJson(out);
235
+ const tests = { passed: result?.numPassedTests ?? 0, failed: result?.numFailedTests ?? 0, total: result?.numTotalTests ?? 0, command };
236
+ if (jr.code !== 0 || result === null || result.success !== true) {
237
+ const failing = (result?.testResults ?? []).filter((t) => t.status === 'failed').map((t) => (0, node_path_1.basename)(t.name ?? '?')).slice(0, 5);
238
+ return {
239
+ kind: 'end',
240
+ state: 'failed',
241
+ detail: result === null
242
+ ? `the test suite did not finish: ${sentence((0, expo_release_rules_1.redact)(jr.stderr, secrets).slice(-300), 300)}`
243
+ : `${tests.failed} of ${tests.total} tests failed${failing.length ? ` (${failing.join(', ')})` : ''}; nothing was released`,
244
+ };
245
+ }
246
+ say(`${tests.passed}/${tests.total} tests passed`);
247
+ // 4. the runtime version (the fingerprint) per platform, and a build that has it.
248
+ const eas = readJson((0, node_path_1.join)(dir, 'eas.json'));
249
+ if (eas === null)
250
+ return { kind: 'end', state: 'refused', detail: 'the app has no readable eas.json' };
251
+ const profile = (0, expo_release_rules_1.buildProfile)(eas, s.buildProfile);
252
+ if (profile === null)
253
+ return { kind: 'end', state: 'refused', detail: `eas.json has no build profile "${s.buildProfile}"` };
254
+ const appJson = readJson((0, node_path_1.join)(dir, 'app.json'));
255
+ const fingerprints = {};
256
+ const env = token ? { EXPO_TOKEN: token } : {};
257
+ const facts = (0, expo_release_rules_1.channelFacts)(eas, s.channel);
258
+ const ctx = argvContext(s, facts, false, eas);
259
+ for (const p of platforms) {
260
+ const hash = await runtimeVersion(deps, dir, p);
261
+ if (hash === null)
262
+ return { kind: 'end', state: 'failed', detail: `could not work out the ${p} runtime version (fingerprint)` };
263
+ let buildId = null;
264
+ const argv = (0, expo_release_rules_1.easBuildListArgv)({ platform: p, profile: s.buildProfile, runtimeVersion: hash });
265
+ const bad = (0, expo_release_rules_1.unsafeEasArgv)(argv, ctx);
266
+ if (bad !== null)
267
+ return { kind: 'end', state: 'refused', detail: bad };
268
+ if (token === null && !deps.eas.dryRun)
269
+ return { kind: 'end', state: 'failed', detail: 'there is no EXPO_TOKEN on this machine, so EAS cannot be asked which builds exist' };
270
+ const listed = await deps.eas.run(argv, { cwd: dir, env });
271
+ const builds = lastJson(listed.stdout);
272
+ if (listed.code === 0 && Array.isArray(builds) && builds.length > 0 && typeof builds[0]?.id === 'string')
273
+ buildId = builds[0].id;
274
+ fingerprints[p] = { hash, buildId };
275
+ }
276
+ // 5. the decision.
277
+ const unmatched = platforms.filter((p) => fingerprints[p]?.buildId === null);
278
+ let decision;
279
+ let reason;
280
+ if (unmatched.length === 0 && s.ota === 'allowed') {
281
+ decision = 'ota';
282
+ reason = `only JavaScript changed: every platform's fingerprint matches a finished ${s.buildProfile} build, so an OTA update to ${s.channel} reaches them`;
283
+ }
284
+ else {
285
+ decision = 'build';
286
+ reason = s.ota === 'builds-only'
287
+ ? 'this app is set to builds only'
288
+ : `native code changed: no finished ${s.buildProfile} build has the ${unmatched.join(' and ')} fingerprint, so a new store build is needed`;
289
+ }
290
+ const from = appJson?.expo?.version ?? '0.0.0';
291
+ const to = decision === 'build' ? bumpVersion(from, s.versionBump) : from;
292
+ if (decision === 'build' && !(0, expo_release_rules_1.remoteVersions)(eas) && appJson?.expo === undefined) {
293
+ return { kind: 'end', state: 'refused', detail: 'the build numbers live in the app config, and this app has no app.json to move them in' };
294
+ }
295
+ // 6. what it must not do, and who must say yes.
296
+ const submitName = s.submitProfile ?? s.buildProfile;
297
+ if (decision === 'build') {
298
+ const refusal = (0, expo_release_rules_1.submitProfileRefusal)(eas, submitName, platforms, s.android, deps.repoDir);
299
+ if (refusal !== null)
300
+ return { kind: 'end', state: 'refused', detail: refusal };
301
+ }
302
+ const originUrl = await deps.git(['remote', 'get-url', 'origin']);
303
+ const gh = originUrl.code === 0 ? (0, delivery_rules_1.githubRepoOf)(originUrl.stdout.trim()) : null;
304
+ const locked = (0, core_1.isApprovalLocked)({
305
+ repoNames: [(0, node_path_1.basename)(deps.repoDir), gh?.repo],
306
+ appIds: [appJson?.expo?.ios?.bundleIdentifier, appJson?.expo?.android?.package],
307
+ });
308
+ const reasons = (0, core_1.approvalReasons)({ settings: s, decision, locked, channel: facts });
309
+ const store2 = deps.store.read();
310
+ const credentials = {
311
+ expoToken: token ? 'connected' : 'missing',
312
+ appStoreConnect: s.ios ? store2.credentials.appStoreConnect ?? 'unknown' : 'not-needed',
313
+ playServiceAccount: s.android !== null ? store2.credentials.playServiceAccount ?? 'unknown' : 'not-needed',
314
+ };
315
+ const partial = { sha, decision, platforms, version: { from, to }, channel: s.channel };
316
+ const plan = {
317
+ ...partial,
318
+ base,
319
+ reason,
320
+ fingerprints,
321
+ tests,
322
+ notes: (0, core_1.releaseNotes)(input.tickets),
323
+ approvalReasons: reasons,
324
+ planKey: (0, core_1.planKeyOf)(partial),
325
+ credentials,
326
+ dryRun: deps.eas.dryRun,
327
+ };
328
+ remember(deps, input.id, { plan });
329
+ say(`plan: ${decision} (${reason})${reasons.length ? `; needs approval: ${reasons.join('; ')}` : ''}`);
330
+ return { kind: 'plan', plan };
331
+ }
332
+ async function runtimeVersion(deps, dir, p) {
333
+ const updates = (0, node_path_1.join)(dir, 'node_modules', 'expo-updates', 'bin', 'cli.js');
334
+ if ((0, node_fs_1.existsSync)(updates)) {
335
+ const r = await deps.exec(process.execPath, [updates, 'runtimeversion:resolve', '--platform', p], { cwd: dir, timeoutMs: exports.RESOLVE_TIMEOUT_MS });
336
+ const j = lastJson(r.stdout);
337
+ if (r.code === 0 && typeof j?.runtimeVersion === 'string' && j.runtimeVersion !== '')
338
+ return j.runtimeVersion;
339
+ }
340
+ const fp = (0, node_path_1.join)(dir, 'node_modules', '@expo', 'fingerprint', 'bin', 'cli.js');
341
+ if ((0, node_fs_1.existsSync)(fp)) {
342
+ const r = await deps.exec(process.execPath, [fp, 'fingerprint:generate', '--platform', p], { cwd: dir, timeoutMs: exports.RESOLVE_TIMEOUT_MS });
343
+ const j = lastJson(r.stdout);
344
+ if (r.code === 0 && typeof j?.hash === 'string')
345
+ return j.hash;
346
+ }
347
+ return null;
348
+ }
349
+ function argvContext(s, facts, approved, eas, submitAllowed = false) {
350
+ void eas;
351
+ return { channel: s.channel, channelFacts: facts, approved, buildProfile: s.buildProfile, submitProfile: s.submitProfile ?? s.buildProfile, submitAllowed };
352
+ }
353
+ function remember(deps, id, patch) {
354
+ const st = deps.store.read();
355
+ const cur = st.releases[id] ?? { id, checkoutDir: null, plan: null, platforms: [], branch: null, processingSince: {}, updatedAt: 0 };
356
+ const next = { ...cur, ...patch, updatedAt: deps.now() };
357
+ st.releases[id] = next;
358
+ deps.store.write(st);
359
+ return next;
360
+ }
361
+ // ─── RUN ────────────────────────────────────────────────────────────────────
362
+ /**
363
+ * Start a planned release. `approved` is the server's word that the owner
364
+ * approved THIS plan; the rules re-check a production channel against it.
365
+ */
366
+ async function startRelease(deps, input, plan, approved) {
367
+ const s = input.settings;
368
+ const token = deps.expoToken();
369
+ const secrets = [token];
370
+ const say = (line) => deps.log((0, expo_release_rules_1.redact)(`app-release ${input.id} : ${line}`, secrets));
371
+ if (plan.approvalReasons.length > 0 && !approved)
372
+ return { state: 'awaiting-approval', plan };
373
+ if (plan.dryRun || deps.eas.dryRun)
374
+ return { state: 'refused', plan, detail: 'dry run: nothing was published' };
375
+ if (token === null)
376
+ return { state: 'failed', plan, detail: 'there is no EXPO_TOKEN on this machine' };
377
+ const local = deps.store.read().releases[input.id];
378
+ const dir = local?.checkoutDir ?? null;
379
+ if (dir === null || !(0, node_fs_1.existsSync)(dir) || realNodeModules(dir) !== null)
380
+ return { state: 'planning', detail: 'the clean checkout is gone; planning again' };
381
+ // Still the commit that was planned (and approved)?
382
+ await deps.git(['fetch', '--quiet', 'origin', `refs/heads/${plan.base}:refs/remotes/origin/${plan.base}`]);
383
+ const now = (await deps.git(['rev-parse', `refs/remotes/origin/${plan.base}`])).stdout.trim();
384
+ if (now !== plan.sha)
385
+ return { state: 'planning', detail: `origin/${plan.base} moved on to ${now.slice(0, 7)} since the plan; planning again` };
386
+ const eas = readJson((0, node_path_1.join)(dir, 'eas.json'));
387
+ if (eas === null)
388
+ return { state: 'refused', plan, detail: 'eas.json is unreadable' };
389
+ const facts = (0, expo_release_rules_1.channelFacts)(eas, s.channel);
390
+ const submitName = s.submitProfile ?? s.buildProfile;
391
+ const submitAllowed = plan.decision === 'build' && (0, expo_release_rules_1.submitProfileRefusal)(eas, submitName, plan.platforms, s.android, deps.repoDir) === null;
392
+ const ctx = argvContext(s, facts, approved, eas, submitAllowed);
393
+ const env = { EXPO_TOKEN: token };
394
+ const run = async (argv, extraEnv = {}) => {
395
+ const bad = (0, expo_release_rules_1.unsafeEasArgv)(argv, ctx);
396
+ if (bad !== null)
397
+ return bad;
398
+ say(`eas ${argv.slice(0, 3).join(' ')} …`);
399
+ return deps.eas.run(argv, { cwd: dir, env: { ...extraEnv, ...env } });
400
+ };
401
+ if (plan.decision === 'ota') {
402
+ const appJson = readJson((0, node_path_1.join)(dir, 'app.json'));
403
+ const sdk = Number((readJson((0, node_path_1.join)(dir, 'node_modules', 'expo', 'package.json'))?.version ?? appJson?.expo?.sdkVersion ?? '').split('.')[0]);
404
+ const environment = Number.isFinite(sdk) && sdk >= 55 ? (0, expo_release_rules_1.buildProfile)(eas, s.buildProfile)?.environment ?? 'production' : undefined;
405
+ const r = await run((0, expo_release_rules_1.easUpdateArgv)({ channel: s.channel, platform: plan.platforms.length === 2 ? 'all' : plan.platforms[0], message: plan.notes, ...(environment ? { environment } : {}) }),
406
+ // The build profile's variables (EXPO_PUBLIC_BACKEND_URL…): without them the bundle points elsewhere.
407
+ (0, expo_release_rules_1.buildProfile)(eas, s.buildProfile)?.env ?? {});
408
+ if (typeof r === 'string')
409
+ return { state: 'refused', plan, detail: r };
410
+ if (r.code !== 0)
411
+ return { state: 'failed', plan, detail: `eas update failed: ${sentence((0, expo_release_rules_1.redact)(r.stderr || r.stdout, secrets).slice(-300), 300)}` };
412
+ const ups = lastJson(r.stdout) ?? [];
413
+ const owner = appJson?.expo?.owner;
414
+ const slug = appJson?.expo?.slug;
415
+ const platforms = plan.platforms.map((p) => {
416
+ const u = ups.find((x) => x.platform === p) ?? ups[0];
417
+ const group = u?.group ?? null;
418
+ return {
419
+ platform: p,
420
+ kind: 'ota',
421
+ state: 'live',
422
+ channel: s.channel,
423
+ updateGroupId: group,
424
+ url: group && owner && slug ? `https://expo.dev/accounts/${owner}/projects/${slug}/updates/${group}` : null,
425
+ };
426
+ });
427
+ return finish(deps, input, plan, platforms);
428
+ }
429
+ // A build: the version moves on a release branch, then one build per platform.
430
+ const g = deps.gitAt(dir);
431
+ const branch = `looply/run-release-${plan.version.to.replace(/[^0-9A-Za-z.]/g, '')}-${deps.now().toString(36)}`;
432
+ const appPath = (0, node_path_1.join)(dir, 'app.json');
433
+ const appJson = readJson(appPath);
434
+ if (appJson?.expo !== undefined) {
435
+ appJson.expo.version = plan.version.to;
436
+ if (!(0, expo_release_rules_1.remoteVersions)(eas)) {
437
+ if (plan.platforms.includes('ios'))
438
+ appJson.expo.ios = { ...appJson.expo.ios, buildNumber: String((Number(appJson.expo.ios?.buildNumber ?? '0') || 0) + 1) };
439
+ if (plan.platforms.includes('android'))
440
+ appJson.expo.android = { ...appJson.expo.android, versionCode: (appJson.expo.android?.versionCode ?? 0) + 1 };
441
+ }
442
+ (0, node_fs_1.writeFileSync)(appPath, JSON.stringify(appJson, null, 2) + '\n', 'utf8');
443
+ }
444
+ await g(['switch', '-c', branch]);
445
+ const committed = await g(['-c', 'user.name=Looply', '-c', 'user.email=looply@users.noreply.github.com', 'commit', '-am', `Release ${plan.version.to}\n\n${plan.notes}`]);
446
+ if (committed.code !== 0 && plan.version.to !== plan.version.from)
447
+ return { state: 'failed', plan, detail: `could not commit the version bump: ${sentence(committed.stderr, 200)}` };
448
+ remember(deps, input.id, { branch });
449
+ const platforms = [];
450
+ for (const p of plan.platforms) {
451
+ const r = await run((0, expo_release_rules_1.easBuildArgv)({ platform: p, profile: s.buildProfile, message: `${input.title}: ${plan.version.to}` }));
452
+ if (typeof r === 'string')
453
+ return { state: 'refused', plan, detail: r };
454
+ const builds = lastJson(r.stdout);
455
+ const id = Array.isArray(builds) ? builds[0]?.id : undefined;
456
+ if (r.code !== 0 || typeof id !== 'string')
457
+ return { state: 'failed', plan, platforms, detail: `eas build (${p}) did not start: ${sentence((0, expo_release_rules_1.redact)(r.stderr || r.stdout, secrets).slice(-300), 300)}` };
458
+ platforms.push({ platform: p, kind: 'build', state: 'building', buildId: id, appVersion: plan.version.to, ...(p === 'android' ? { track: s.android } : {}) });
459
+ }
460
+ remember(deps, input.id, { platforms });
461
+ return { state: 'releasing', plan, platforms };
462
+ }
463
+ /** One step of following a build release: build → submit → processing → available. */
464
+ async function followRelease(deps, input, plan) {
465
+ const s = input.settings;
466
+ const token = deps.expoToken();
467
+ const secrets = [token];
468
+ const local = deps.store.read().releases[input.id];
469
+ if (local === undefined || local.platforms.length === 0)
470
+ return { state: 'failed', plan, detail: 'this machine has no record of the builds it started (its state was lost); check EAS' };
471
+ const dir = local.checkoutDir ?? deps.repoDir;
472
+ const eas = readJson((0, node_path_1.join)(dir, 'eas.json')) ?? {};
473
+ const submitName = s.submitProfile ?? s.buildProfile;
474
+ const submitAllowed = (0, expo_release_rules_1.submitProfileRefusal)(eas, submitName, plan.platforms, s.android, deps.repoDir) === null;
475
+ const ctx = argvContext(s, (0, expo_release_rules_1.channelFacts)(eas, s.channel), false, eas, submitAllowed);
476
+ const env = token ? { EXPO_TOKEN: token } : {};
477
+ const platforms = local.platforms.map((p) => ({ ...p }));
478
+ const processing = { ...local.processingSince };
479
+ for (const p of platforms) {
480
+ if (p.kind !== 'build')
481
+ continue;
482
+ if (p.state === 'submitting') {
483
+ // A restart lost the submit's outcome: say so rather than upload twice.
484
+ p.state = 'failed';
485
+ p.detail = 'the submit was interrupted (the machine restarted); check it in EAS';
486
+ continue;
487
+ }
488
+ if (p.state === 'building' && p.buildId) {
489
+ const argv = (0, expo_release_rules_1.easBuildViewArgv)(p.buildId);
490
+ const bad = (0, expo_release_rules_1.unsafeEasArgv)(argv, ctx);
491
+ if (bad !== null)
492
+ return { state: 'refused', plan, platforms, detail: bad };
493
+ const r = await deps.eas.run(argv, { cwd: dir, env });
494
+ const b = lastJson(r.stdout);
495
+ const status = String(b?.status ?? '').toUpperCase();
496
+ if (status === 'ERRORED' || status === 'CANCELED') {
497
+ p.state = 'failed';
498
+ p.detail = sentence((0, expo_release_rules_1.redact)(b?.error?.message ?? `the build was ${status.toLowerCase()}`, secrets), 300);
499
+ }
500
+ else if (status === 'FINISHED') {
501
+ p.buildNumber = b?.appBuildVersion ?? p.buildNumber ?? null;
502
+ p.appVersion = b?.appVersion ?? p.appVersion ?? null;
503
+ p.url = `https://expo.dev/builds/${p.buildId}`;
504
+ p.state = 'submitting';
505
+ remember(deps, input.id, { platforms });
506
+ const sargv = (0, expo_release_rules_1.easSubmitArgv)({ platform: p.platform, profile: submitName, buildId: p.buildId, whatToTest: plan.notes, ...(s.testflightGroups ? { groups: s.testflightGroups } : {}) });
507
+ const sbad = (0, expo_release_rules_1.unsafeEasArgv)(sargv, ctx);
508
+ if (sbad !== null) {
509
+ p.state = 'failed';
510
+ p.detail = sbad;
511
+ continue;
512
+ }
513
+ deps.log(`app-release ${input.id} : eas submit ${p.platform} ${p.buildId}`);
514
+ const sr = await deps.eas.run(sargv, { cwd: dir, env });
515
+ const cred = p.platform === 'ios' ? 'appStoreConnect' : 'playServiceAccount';
516
+ const st = deps.store.read();
517
+ if (sr.code === 0) {
518
+ st.credentials[cred] = 'connected';
519
+ deps.store.write(st);
520
+ if (p.platform === 'ios') {
521
+ p.state = 'processing';
522
+ processing.ios = deps.now();
523
+ p.detail = 'uploaded to App Store Connect; TestFlight is processing it';
524
+ }
525
+ else {
526
+ p.state = 'available';
527
+ p.detail = p.track === 'internal' ? null : 'Google may review it before testers on this track see it';
528
+ }
529
+ }
530
+ else {
531
+ const text = (0, expo_release_rules_1.redact)(`${sr.stderr}\n${sr.stdout}`, secrets);
532
+ if (/credential|service account|api key|unauthori[sz]ed|forbidden|permission/i.test(text)) {
533
+ st.credentials[cred] = 'missing';
534
+ deps.store.write(st);
535
+ }
536
+ p.state = 'failed';
537
+ p.detail = `eas submit (${p.platform}) failed: ${sentence(text.slice(-300), 300)}`;
538
+ }
539
+ }
540
+ }
541
+ if (p.state === 'processing' && p.platform === 'ios' && deps.now() - (processing.ios ?? deps.now()) >= exports.IOS_PROCESSING_MS) {
542
+ p.state = 'available';
543
+ p.detail = null;
544
+ }
545
+ }
546
+ remember(deps, input.id, { platforms, processingSince: processing });
547
+ if (platforms.some((p) => p.state === 'failed')) {
548
+ const why = platforms.filter((p) => p.state === 'failed').map((p) => `${p.platform}: ${p.detail ?? 'failed'}`).join('; ');
549
+ cleanup(deps, input.id);
550
+ return { state: 'failed', plan, platforms, detail: sentence(why, 580), testerLine: (0, core_1.testerLine)(platforms) };
551
+ }
552
+ if (platforms.every((p) => p.state === 'available'))
553
+ return finish(deps, input, plan, platforms);
554
+ return { state: 'releasing', plan, platforms, testerLine: (0, core_1.testerLine)(platforms) };
555
+ }
556
+ async function finish(deps, input, plan, platforms) {
557
+ const st = deps.store.read();
558
+ st.lastReleasedSha = plan.sha;
559
+ deps.store.write(st);
560
+ remember(deps, input.id, { platforms });
561
+ let detail = null;
562
+ const local = deps.store.read().releases[input.id];
563
+ if (local?.branch && deps.publishBranch && local.checkoutDir) {
564
+ const tip = (await deps.gitAt(local.checkoutDir)(['rev-parse', 'HEAD'])).stdout.trim();
565
+ try {
566
+ detail = await deps.publishBranch(local.branch, tip, `Release ${plan.version.to}`, `${plan.notes}\n\nThe version bump of the app release ${input.title}. Merge it so the next release starts from this version.`);
567
+ }
568
+ catch (e) {
569
+ detail = `the version bump branch was not pushed: ${e.message}`;
570
+ }
571
+ }
572
+ cleanup(deps, input.id);
573
+ return { state: 'live', plan, platforms, testerLine: (0, core_1.testerLine)(platforms), detail };
574
+ }
575
+ function cleanup(deps, id) {
576
+ const st = deps.store.read();
577
+ const r = st.releases[id];
578
+ if (r?.checkoutDir) {
579
+ try {
580
+ removeCheckout(r.checkoutDir);
581
+ }
582
+ catch (e) {
583
+ deps.log(`app-release ${id} : could not remove ${r.checkoutDir} (${e.message})`);
584
+ }
585
+ r.checkoutDir = null;
586
+ deps.store.write(st);
587
+ // The repository forgets the removed checkout too.
588
+ void deps.git?.(['worktree', 'prune']).catch(() => undefined);
589
+ }
590
+ }
591
+ // ─── real processes ─────────────────────────────────────────────────────────
592
+ /** Spawn without a shell; on Windows `npm`/`yarn`/`pnpm` are .cmd files and go through cmd /c. Hidden windows. */
593
+ const nodeReleaseExec = (bin, args, o) => new Promise((resolveP) => {
594
+ const win = process.platform === 'win32';
595
+ const viaCmd = win && ['npm', 'yarn', 'pnpm'].includes(bin);
596
+ const file = viaCmd ? (process.env['ComSpec'] ?? 'cmd.exe') : bin;
597
+ const argv = viaCmd ? ['/d', '/s', '/c', bin, ...args] : [...args];
598
+ let out = '';
599
+ let err = '';
600
+ let child;
601
+ try {
602
+ child = (0, node_child_process_1.spawn)(file, argv, { cwd: o.cwd, env: { ...process.env, ...(o.env ?? {}) }, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
603
+ }
604
+ catch (e) {
605
+ resolveP({ code: -1, stdout: '', stderr: e.message });
606
+ return;
607
+ }
608
+ const cap = (s, d) => (s + d.toString('utf8')).slice(-400_000);
609
+ child.stdout?.on('data', (d) => (out = cap(out, d)));
610
+ child.stderr?.on('data', (d) => (err = cap(err, d)));
611
+ const timer = setTimeout(() => child.kill(), o.timeoutMs);
612
+ child.on('error', (e) => {
613
+ clearTimeout(timer);
614
+ resolveP({ code: -1, stdout: out, stderr: `${err}\n${e.message}` });
615
+ });
616
+ child.on('close', (code) => {
617
+ clearTimeout(timer);
618
+ resolveP({ code: code ?? -1, stdout: out, stderr: err });
619
+ });
620
+ });
621
+ exports.nodeReleaseExec = nodeReleaseExec;
622
+ /** The installed eas-cli's entry script, run with this node (no shell, no PATH lookup in the repository). */
623
+ function easCliScript(env = process.env) {
624
+ const own = env['LOOPLY_EAS_CLI'];
625
+ if (own && (0, node_fs_1.existsSync)(own))
626
+ return own;
627
+ const nodeDir = (0, node_path_1.dirname)(process.execPath);
628
+ for (const root of [(0, node_path_1.join)(nodeDir, 'node_modules'), (0, node_path_1.join)(nodeDir, '..', 'lib', 'node_modules'), env['APPDATA'] ? (0, node_path_1.join)(env['APPDATA'], 'npm', 'node_modules') : '']) {
629
+ if (!root)
630
+ continue;
631
+ const p = (0, node_path_1.join)(root, 'eas-cli', 'bin', 'run');
632
+ if ((0, node_fs_1.existsSync)(p))
633
+ return p;
634
+ }
635
+ return null;
636
+ }
637
+ function realEasRunner(exec = exports.nodeReleaseExec) {
638
+ return {
639
+ dryRun: false,
640
+ async run(argv, o) {
641
+ const script = easCliScript();
642
+ if (script === null)
643
+ return { code: -1, stdout: '', stderr: 'eas-cli is not installed on this machine (npm i -g eas-cli)' };
644
+ const timeoutMs = argv[0] === 'submit' ? exports.SUBMIT_TIMEOUT_MS : exports.EAS_TIMEOUT_MS;
645
+ return exec(process.execPath, [script, ...argv], { cwd: o.cwd, env: { ...o.env, CI: '1' }, timeoutMs });
646
+ },
647
+ };
648
+ }
649
+ /**
650
+ * THE DRY RUN'S EAS: records every argv it was asked to run and runs none of
651
+ * them. `builds` answers `build:list` (runtime version → build id), so a dry
652
+ * run can show both the OTA and the build decision.
653
+ */
654
+ function dryRunEas(builds = {}) {
655
+ const calls = [];
656
+ return {
657
+ dryRun: true,
658
+ calls,
659
+ async run(argv) {
660
+ calls.push([...argv]);
661
+ if (argv[0] === 'build:list') {
662
+ const i = argv.indexOf('--runtime-version');
663
+ const id = i >= 0 ? builds[argv[i + 1] ?? ''] : undefined;
664
+ return { code: 0, stdout: JSON.stringify(id ? [{ id, status: 'FINISHED' }] : []), stderr: '' };
665
+ }
666
+ return { code: 0, stdout: '[]', stderr: 'dry run: not run' };
667
+ },
668
+ };
669
+ }
670
+ //# sourceMappingURL=expo-release.js.map