Compare commits

...
9 Commits
Author SHA1 Message Date
Expo CI bc25602d65 v27.3.0 2026-11-02 19:28:08 +00:00
Mustafa Abban 261836aaf6 [eas-build-job] Add Observe workflow event context (#4787) 2026-11-02 13:57:12 -05:00
55e746a0e7 [worker][build-tools] Stop warmup in the background for jobs that do not use a warm Simulator (#4721)
* [worker] Manage warm Simulator services

Edited-by: Codex

* [worker] Use the shared warm Simulator terms in the changeset

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* [worker] Stop the recorded Simulator launcher version

* [worker] Skip Simulator stop on images without a startup script

* [worker] Stop current-boot warmup when the script is absent

* [worker] Skip launcher resolution after confirmed cleanup

* [worker] Recognize Bun cache misses alongside warning output

* [worker] Honor the package manager for versioned launcher stop

* [worker] Ignore invalid launcher state when no startup script exists

* [worker] Use existing package-manager helpers directly

Co-authored-by: Codex <[email protected]>

* [worker] Keep the warmup stop changeset scoped to worker

Edited-by: Codex

* [worker] Let the launcher handle Simulator stop state

Edited-by: Codex

* [worker] Stop warm Simulator services in the background

Edited-by: Codex

* [worker] Fix warm stop job fixtures

Edited-by: Codex

* [worker] Let build-tools wait on the launcher state for warm Simulator cleanup

The worker stops warmup in the background and shares nothing with build-tools. Before it boots,
starts or clones a Simulator, build-tools waits until the launcher's state file says disabled.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* [build-tools] Require cleanup confirmation from the current boot

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Codex <[email protected]>
Co-authored-by: Codex <[email protected]>
2026-11-02 10:56:53 -07:00
Gabe DebesandGPT-6.1-Sol 821e7f85f5 [build-tools] Recover failed device-session tunnels (#4526)
* [build-tools] Supervise device-session tunnels

Co-Authored-By: GPT-6.1-Sol <[email protected]>

* [build-tools] Retire tunnel listeners while reopening

* [build-tools] Wait for replacement tunnel retirement

* [build-tools] Block tunnel forwarding when shutdown starts

* [build-tools] Shorten tunnel recovery changeset

---------

Co-authored-by: GPT-6.1-Sol <[email protected]>
2026-10-30 15:47:16 -07:00
Douglas LowderandClaude Opus 5.5 6f0f90bd99 [eas-cli] Bump @expo/apple-utils to 2.3.0 (#4790)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-30 05:51:23 -07:00
Tomasz Mazur 6c545d3460 [build-tools] Stop sending the deprecated SSH host to the API (#4784) 2026-10-30 12:26:09 +01:00
Ash Wu 243c8b8023 [worker] mask secrets in the records written to worker stdout (#4782) 2026-10-30 16:53:29 +08:00
Ceyda AkınandDouglas Lowder fe6c35ef73 [eas-cli] Keep linked capability identifiers when syncing another one (#4584)
The capability update replaces the identifiers linked to a bundle ID, but only the newly linked ones were sent. Adding a second App Group, Merchant ID or iCloud container unlinked the ones that were already linked.

Fixes #4445

Co-authored-by: Douglas Lowder <[email protected]>
2026-10-29 20:16:03 -07:00
Expo CI a29b1d1d70 bump STAGING and PRODUCTION to 27.2.0 in cli-versions.json 2026-10-30 00:41:43 +00:00
61 changed files with 2387 additions and 454 deletions

No files matched your search

+15
View File
@@ -2,6 +2,21 @@
This is the log of notable changes to EAS CLI and related packages.
## [27.3.0](https://github.com/expo/eas-cli/releases/tag/v27.3.0) - 2026-11-02
### 🎉 New features
- [eas-build-job] Add optional Observe error context to workflow jobs. ([#4787](https://github.com/expo/eas-cli/pull/4787) by [@AbbanMustafa](https://github.com/AbbanMustafa))
### 🐛 Bug fixes
- [eas-cli] Bump `@expo/apple-utils` to `2.3.0` to fix iOS capability sync when App Store Connect rejects enabling, disabling or changing a capability. ([#4790](https://github.com/expo/eas-cli/pull/4790) by [@douglowder](https://github.com/douglowder))
- [eas-cli] Keep already linked App Groups, Merchant IDs and iCloud containers when capability sync links another identifier. ([#4584](https://github.com/expo/eas-cli/pull/4584) by [@ceydaakin](https://github.com/ceydaakin))
- [worker] Mask secrets in every record the worker process writes to its stdout: the build log records and the worker's own records. ([#4782](https://github.com/expo/eas-cli/pull/4782) by [@hSATAC](https://github.com/hSATAC))
- [worker][build-tools] Stop warmup in the background for jobs that do not use the warm Simulator, so job setup does not wait for it. Before build-tools boots or clones a Simulator, it waits until the launcher's state file records that cleanup finished, and fails if cleanup does not finish in time. ([#4721](https://github.com/expo/eas-cli/pull/4721) by [@gwdp](https://github.com/gwdp))
- [build-tools] Recover unhealthy device-session tunnels without changing their URLs and block further traffic when shutdown starts, even if ngrok cleanup times out. ([#4526](https://github.com/expo/eas-cli/pull/4526) by [@gwdp](https://github.com/gwdp))
- [build-tools] Stop sending the SSH host when registering an SSH session. ([#4784](https://github.com/expo/eas-cli/pull/4784) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
## [27.2.0](https://github.com/expo/eas-cli/releases/tag/v27.2.0) - 2026-10-29
### 🎉 New features
+2 -2
View File
@@ -1,4 +1,4 @@
{
"STAGING": "27.1.0",
"PRODUCTION": "27.1.0"
"STAGING": "27.2.0",
"PRODUCTION": "27.2.0"
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "27.2.0",
"version": "27.3.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+8
View File
@@ -1,5 +1,13 @@
# @expo/build-tools
## 27.3.0
### Patch Changes
- Stop warmup in the background for jobs that do not use the warm Simulator, so job setup does not wait for it. Before build-tools boots or clones a Simulator, it waits until the launcher's state file records that cleanup finished, and fails if cleanup does not finish in time. ([#4721](https://github.com/expo/eas-cli/pull/4721) by [@gwdp](https://github.com/gwdp))
- Recover unhealthy device-session tunnels without changing their URLs and block further traffic when shutdown starts, even if ngrok cleanup times out. ([#4526](https://github.com/expo/eas-cli/pull/4526) by [@gwdp](https://github.com/gwdp))
- Stop sending the SSH host when registering an SSH session. ([#4784](https://github.com/expo/eas-cli/pull/4784) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
## 27.2.0
### Minor Changes
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "27.2.0",
"version": "27.3.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -42,17 +42,17 @@
"dependencies": {
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/downloader": "27.2.0",
"@expo/eas-build-job": "27.2.0",
"@expo/downloader": "27.3.0",
"@expo/eas-build-job": "27.3.0",
"@expo/env": "^0.4.0",
"@expo/logger": "27.2.0",
"@expo/logger": "27.3.0",
"@expo/package-manager": "1.9.10",
"@expo/plist": "^0.3.5",
"@expo/results": "^1.0.0",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "27.2.0",
"@expo/template-file": "27.2.0",
"@expo/turtle-spawn": "27.2.0",
"@expo/steps": "27.3.0",
"@expo/template-file": "27.3.0",
"@expo/turtle-spawn": "27.3.0",
"@expo/xcpretty": "^4.4.7",
"@ngrok/ngrok": "1.7.0",
"@sentry/node": "7.77.0",
@@ -167,6 +167,7 @@ export async function runAgentDeviceRemoteSessionAsync(
subdomainPrefix: 'agent-device',
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
healthCheck: { path: '/health' },
logger: taskLogger,
});
tasks.signal.throwIfAborted();
@@ -274,6 +274,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
subdomainPrefix: 'appium',
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
healthCheck: { path: '/status' },
logger,
});
@@ -340,6 +340,7 @@ export function createStartArgentRemoteSessionBuildFunction(
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
rewriteHostHeader: true,
healthCheck: { path: '/tools', statuses: [200, 401] },
logger,
});
const publicToolsUrl = toolsTunnel.url;
@@ -0,0 +1,873 @@
import { type bunyan } from '@expo/logger';
import * as ngrok from '@ngrok/ngrok';
import { once } from 'node:events';
import { createServer } from 'node:http';
import WebSocket, { WebSocketServer } from 'ws';
import { type AddressInfo, createConnection, createServer as createTcpServer } from 'node:net';
import { turtleFetch } from '../../../utils/turtleFetch';
import { startNgrokTunnelAsync } from '../ngrokTunnel';
jest.mock('@ngrok/ngrok');
jest.mock('node:timers', () => ({
setTimeout: (...args: Parameters<typeof setTimeout>) => setTimeout(...args),
clearTimeout: (...args: Parameters<typeof clearTimeout>) => clearTimeout(...args),
}));
jest.mock('../../../utils/turtleFetch');
const { Response } = jest.requireActual<typeof import('node-fetch')>('node-fetch');
const url = 'https://web-preview-fixed.example.test';
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
const options = {
port: 4321,
subdomainPrefix: 'web-preview',
subdomainId: 'fixed',
baseDomain: 'example.test',
authtoken: 'token',
rewriteHostHeader: true,
healthCheck: { path: '/healthz' },
logger,
};
function listener(publicUrl = url) {
return { url: () => publicUrl, close: jest.fn().mockResolvedValue(undefined) };
}
let initial: ReturnType<typeof listener>;
let replacement: ReturnType<typeof listener>;
beforeEach(() => {
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] });
jest.clearAllMocks();
initial = listener();
replacement = listener();
jest
.mocked(ngrok.forward)
.mockReset()
.mockResolvedValueOnce(initial as never)
.mockResolvedValue(replacement as never);
jest
.mocked(turtleFetch)
.mockReset()
.mockResolvedValue(new Response(undefined, { status: 200 }));
});
afterEach(() => {
jest.useRealTimers();
});
function failPublicProbes() {
jest.mocked(turtleFetch).mockImplementation(
async target =>
new Response(undefined, {
status: target.startsWith('https:') ? 503 : 200,
})
);
}
it('keeps tunnels without a health check unsupervised and closes them on stop', async () => {
const tunnel = await startNgrokTunnelAsync({ ...options, healthCheck: undefined });
await jest.advanceTimersByTimeAsync(90_000);
expect(turtleFetch).not.toHaveBeenCalled();
expect(ngrok.forward).toHaveBeenCalledTimes(1);
await tunnel.stopAsync();
expect(initial.close).toHaveBeenCalledTimes(1);
});
it('probes the public service and keeps a healthy listener open', async () => {
const tunnel = await startNgrokTunnelAsync(options);
try {
await jest.advanceTimersByTimeAsync(45_000);
expect(turtleFetch).toHaveBeenCalledTimes(3);
expect(turtleFetch).toHaveBeenCalledWith(
`${url}/healthz`,
'GET',
expect.objectContaining({
retries: 0,
redirect: 'manual',
headers: expect.objectContaining({ 'ngrok-skip-browser-warning': 'true' }),
})
);
expect(ngrok.forward).toHaveBeenCalledTimes(1);
} finally {
await tunnel.stopAsync();
}
expect(initial.close).toHaveBeenCalledTimes(1);
await jest.advanceTimersByTimeAsync(60_000);
expect(turtleFetch).toHaveBeenCalledTimes(3);
});
it('reopens after three failed public probes with a healthy local service, preserving the endpoint', async () => {
failPublicProbes();
const tunnel = await startNgrokTunnelAsync(options);
try {
await jest.advanceTimersByTimeAsync(30_000);
expect(initial.close).not.toHaveBeenCalled();
await jest.advanceTimersByTimeAsync(15_000);
expect(initial.close).toHaveBeenCalledTimes(1);
expect(ngrok.forward).toHaveBeenCalledTimes(2);
expect(jest.mocked(ngrok.forward).mock.calls[1][0]).toEqual({
addr: expect.any(Number),
authtoken: 'token',
domain: 'web-preview-fixed.example.test',
request_header_add: ['Host:localhost:4321'],
force_new_session: true,
});
expect(tunnel.url).toBe(url);
jest.mocked(turtleFetch).mockResolvedValue(new Response(undefined, { status: 200 }));
await jest.advanceTimersByTimeAsync(15_000);
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('healthy again'));
} finally {
await tunnel.stopAsync();
}
expect(replacement.close).toHaveBeenCalledTimes(1);
});
it('does not reopen when the local service is unhealthy', async () => {
jest.mocked(turtleFetch).mockResolvedValue(new Response(undefined, { status: 503 }));
const tunnel = await startNgrokTunnelAsync(options);
try {
await jest.advanceTimersByTimeAsync(90_000);
expect(ngrok.forward).toHaveBeenCalledTimes(1);
expect(initial.close).not.toHaveBeenCalled();
} finally {
await tunnel.stopAsync();
}
});
it('resets consecutive failures after a successful public probe', async () => {
failPublicProbes();
const tunnel = await startNgrokTunnelAsync(options);
try {
await jest.advanceTimersByTimeAsync(30_000);
jest.mocked(turtleFetch).mockResolvedValueOnce(new Response(undefined, { status: 200 }));
await jest.advanceTimersByTimeAsync(15_000);
await jest.advanceTimersByTimeAsync(30_000);
expect(ngrok.forward).toHaveBeenCalledTimes(1);
} finally {
await tunnel.stopAsync();
}
});
it('retries a failed reopen with backoff', async () => {
failPublicProbes();
jest
.mocked(ngrok.forward)
.mockReset()
.mockResolvedValueOnce(initial as never)
.mockRejectedValueOnce(new Error('offline'))
.mockResolvedValue(replacement as never);
const tunnel = await startNgrokTunnelAsync(options);
try {
await jest.advanceTimersByTimeAsync(45_000);
expect(ngrok.forward).toHaveBeenCalledTimes(2);
await jest.advanceTimersByTimeAsync(1_999);
expect(ngrok.forward).toHaveBeenCalledTimes(2);
await jest.advanceTimersByTimeAsync(1);
expect(ngrok.forward).toHaveBeenCalledTimes(3);
} finally {
await tunnel.stopAsync();
}
});
it('closes a listener that finishes opening after shutdown, with idempotent cleanup', async () => {
failPublicProbes();
let resolveOpening!: (value: ngrok.Listener) => void;
jest
.mocked(ngrok.forward)
.mockReset()
.mockResolvedValueOnce(initial as never)
.mockReturnValueOnce(
new Promise(resolve => {
resolveOpening = resolve;
})
);
const tunnel = await startNgrokTunnelAsync(options);
await jest.advanceTimersByTimeAsync(45_000);
const stopping = tunnel.stopAsync();
expect(tunnel.stopAsync()).toBe(stopping);
const stopped = jest.fn();
void stopping.then(stopped);
await jest.advanceTimersByTimeAsync(0);
expect(stopped).not.toHaveBeenCalled();
resolveOpening(replacement as never);
await stopping;
expect(replacement.close).toHaveBeenCalledTimes(1);
await jest.advanceTimersByTimeAsync(120_000);
expect(ngrok.forward).toHaveBeenCalledTimes(2);
});
it('waits for a replacement acquired during shutdown to finish closing', async () => {
failPublicProbes();
let resolveOpening!: (value: ngrok.Listener) => void;
let resolveClose!: () => void;
jest
.mocked(ngrok.forward)
.mockReset()
.mockResolvedValueOnce(initial as never)
.mockReturnValueOnce(new Promise(resolve => (resolveOpening = resolve)));
replacement.close.mockReturnValue(new Promise<void>(resolve => (resolveClose = resolve)));
const tunnel = await startNgrokTunnelAsync(options);
await jest.advanceTimersByTimeAsync(45_000);
let stopped = false;
const stopping = tunnel.stopAsync().then(() => {
stopped = true;
});
await jest.advanceTimersByTimeAsync(0);
resolveOpening(replacement as never);
await jest.advanceTimersByTimeAsync(0);
const stoppedBeforeClose = stopped;
resolveClose();
await jest.advanceTimersByTimeAsync(250);
await stopping;
expect(stoppedBeforeClose).toBe(false);
expect(replacement.close).toHaveBeenCalledTimes(1);
});
it.each([0, 4, 5, 10])('retires a reopen cancelled after %s promise turns', async turns => {
failPublicProbes();
let resolveOpening!: (value: ngrok.Listener) => void;
jest
.mocked(ngrok.forward)
.mockReset()
.mockResolvedValueOnce(initial as never)
.mockReturnValueOnce(new Promise(resolve => (resolveOpening = resolve)));
const tunnel = await startNgrokTunnelAsync(options);
await jest.advanceTimersByTimeAsync(45_000);
resolveOpening(replacement as never);
for (let turn = 0; turn < turns; turn++) {
await Promise.resolve();
}
await tunnel.stopAsync();
await jest.advanceTimersByTimeAsync(0);
expect(replacement.close).toHaveBeenCalledTimes(1);
});
it('retries a rejected close when a reopen finishes after shutdown', async () => {
failPublicProbes();
let resolveOpening!: (value: ngrok.Listener) => void;
jest
.mocked(ngrok.forward)
.mockReset()
.mockResolvedValueOnce(initial as never)
.mockReturnValueOnce(new Promise(resolve => (resolveOpening = resolve)));
const tunnel = await startNgrokTunnelAsync(options);
await jest.advanceTimersByTimeAsync(45_000);
const stopping = tunnel.stopAsync();
replacement.close.mockRejectedValueOnce(new Error('temporary unlisten failure'));
resolveOpening(replacement as never);
await jest.advanceTimersByTimeAsync(250);
await stopping;
expect(replacement.close).toHaveBeenCalledTimes(2);
const warnings = jest.mocked(logger.warn).mock.calls.length;
await jest.advanceTimersByTimeAsync(120_000);
expect(logger.warn).toHaveBeenCalledTimes(warnings);
});
it('fails stop if an in-flight reopen still has not settled by the stop deadline', async () => {
failPublicProbes();
let resolveOpening!: (value: ngrok.Listener) => void;
jest
.mocked(ngrok.forward)
.mockReset()
.mockResolvedValueOnce(initial as never)
.mockReturnValueOnce(new Promise(resolve => (resolveOpening = resolve)));
const tunnel = await startNgrokTunnelAsync(options);
await jest.advanceTimersByTimeAsync(45_000);
const rejected = expect(tunnel.stopAsync()).rejects.toThrow(
'Ngrok tunnel web-preview-fixed.example.test stop timed out after 4000ms'
);
await jest.advanceTimersByTimeAsync(4_000);
await rejected;
expect(ngrok.forward).toHaveBeenCalledTimes(2);
resolveOpening(replacement as never);
await jest.advanceTimersByTimeAsync(0);
expect(replacement.close).toHaveBeenCalledTimes(1);
});
it('retries an old listener while a reopen outlasts shutdown', async () => {
failPublicProbes();
initial.close.mockRejectedValueOnce(new Error('agent disconnected'));
let resolveOpening!: (value: ngrok.Listener) => void;
jest
.mocked(ngrok.forward)
.mockReset()
.mockResolvedValueOnce(initial as never)
.mockReturnValueOnce(new Promise(resolve => (resolveOpening = resolve)));
const tunnel = await startNgrokTunnelAsync(options);
await jest.advanceTimersByTimeAsync(45_000);
expect(initial.close).toHaveBeenCalledTimes(1);
const rejected = expect(tunnel.stopAsync()).rejects.toThrow(
'Ngrok tunnel web-preview-fixed.example.test stop timed out after 4000ms'
);
await jest.advanceTimersByTimeAsync(4_000);
await rejected;
const oldCloseCalls = initial.close.mock.calls.length;
resolveOpening(replacement as never);
await jest.advanceTimersByTimeAsync(0);
expect(oldCloseCalls).toBe(2);
expect(replacement.close).toHaveBeenCalledTimes(1);
expect(ngrok.forward).toHaveBeenCalledTimes(2);
});
it('fails stop if a pending reopen arrives but cannot be retired', async () => {
failPublicProbes();
let resolveOpening!: (value: ngrok.Listener) => void;
jest
.mocked(ngrok.forward)
.mockReset()
.mockResolvedValueOnce(initial as never)
.mockReturnValueOnce(new Promise(resolve => (resolveOpening = resolve)));
replacement.close.mockRejectedValue(new Error('unlisten failed'));
const tunnel = await startNgrokTunnelAsync(options);
await jest.advanceTimersByTimeAsync(45_000);
const rejected = expect(tunnel.stopAsync()).rejects.toThrow(
'Ngrok tunnel web-preview-fixed.example.test stop timed out'
);
resolveOpening(replacement as never);
await jest.advanceTimersByTimeAsync(4_000);
await rejected;
expect(replacement.close.mock.calls.length).toBeGreaterThan(1);
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
'Could not retire late ngrok tunnel web-preview-fixed.example.test.'
);
const warnings = jest.mocked(logger.warn).mock.calls.length;
await jest.advanceTimersByTimeAsync(120_000);
expect(logger.warn).toHaveBeenCalledTimes(warnings);
expect(ngrok.forward).toHaveBeenCalledTimes(2);
});
it('rejects a changed public URL and closes the replacement', async () => {
failPublicProbes();
const changed = listener('https://different.example.test');
jest
.mocked(ngrok.forward)
.mockReset()
.mockResolvedValueOnce(initial as never)
.mockResolvedValue(changed as never);
const tunnel = await startNgrokTunnelAsync(options);
try {
await jest.advanceTimersByTimeAsync(45_000);
expect(changed.close).toHaveBeenCalledTimes(1);
expect(tunnel.url).toBe(url);
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
expect.stringContaining('Could not reopen')
);
} finally {
await tunnel.stopAsync();
}
});
it('treats ngrok error responses as failures even if their status is accepted', async () => {
jest.mocked(turtleFetch).mockImplementation(
async target =>
new Response(undefined, {
status: 200,
headers: target.startsWith('https:') ? { 'ngrok-error-code': 'ERR_NGROK_3200' } : {},
})
);
const tunnel = await startNgrokTunnelAsync(options);
try {
await jest.advanceTimersByTimeAsync(45_000);
expect(ngrok.forward).toHaveBeenCalledTimes(2);
} finally {
await tunnel.stopAsync();
}
});
it('reopens on a fresh agent session even if closing the old listener fails', async () => {
failPublicProbes();
initial.close.mockRejectedValueOnce(new Error('agent disconnected'));
const tunnel = await startNgrokTunnelAsync(options);
try {
await jest.advanceTimersByTimeAsync(45_000);
expect(ngrok.forward).toHaveBeenCalledTimes(2);
expect(ngrok.forward).toHaveBeenLastCalledWith(
expect.objectContaining({ force_new_session: true })
);
expect(ngrok.disconnect).not.toHaveBeenCalled();
} finally {
await tunnel.stopAsync();
}
expect(initial.close).toHaveBeenCalledTimes(2);
expect(replacement.close).toHaveBeenCalledTimes(1);
});
it('retries a rejected close before reopening an endpoint that remains bound', async () => {
failPublicProbes();
let domainOccupied = true;
initial.close
.mockRejectedValueOnce(new Error('agent disconnected'))
.mockImplementation(async () => {
domainOccupied = false;
});
jest
.mocked(ngrok.forward)
.mockReset()
.mockResolvedValueOnce(initial as never)
.mockImplementation(async () => {
if (domainOccupied) {
throw new Error('endpoint is still bound');
}
return replacement as never;
});
const tunnel = await startNgrokTunnelAsync(options);
try {
await jest.advanceTimersByTimeAsync(45_000);
expect(initial.close).toHaveBeenCalledTimes(1);
expect(ngrok.forward).toHaveBeenCalledTimes(2);
await jest.advanceTimersByTimeAsync(2_000);
expect(initial.close).toHaveBeenCalledTimes(2);
expect(ngrok.forward).toHaveBeenCalledTimes(3);
expect(tunnel.url).toBe(url);
} finally {
await tunnel.stopAsync();
}
expect(replacement.close).toHaveBeenCalledTimes(1);
});
it('retires an old listener even after its replacement is healthy', async () => {
failPublicProbes();
initial.close.mockRejectedValueOnce(new Error('agent disconnected'));
const tunnel = await startNgrokTunnelAsync(options);
try {
await jest.advanceTimersByTimeAsync(45_000);
expect(initial.close).toHaveBeenCalledTimes(1);
jest.mocked(turtleFetch).mockResolvedValue(new Response(undefined, { status: 200 }));
await jest.advanceTimersByTimeAsync(15_000);
expect(initial.close).toHaveBeenCalledTimes(2);
expect(ngrok.forward).toHaveBeenCalledTimes(2);
} finally {
await tunnel.stopAsync();
}
});
it('retries an old listener without repeating its close warning on every probe', async () => {
failPublicProbes();
initial.close.mockRejectedValue(new Error('old session disconnected'));
const tunnel = await startNgrokTunnelAsync(options);
await jest.advanceTimersByTimeAsync(45_000);
const warnings = jest.mocked(logger.warn).mock.calls.length;
expect(warnings).toBeGreaterThan(0);
jest.mocked(turtleFetch).mockResolvedValue(new Response(undefined, { status: 200 }));
await jest.advanceTimersByTimeAsync(120_000);
expect(initial.close.mock.calls.length).toBeGreaterThan(1);
expect(ngrok.forward).toHaveBeenCalledTimes(2);
expect(logger.warn).toHaveBeenCalledTimes(warnings);
const rejected = expect(tunnel.stopAsync()).rejects.toThrow(
'Ngrok tunnel web-preview-fixed.example.test stop timed out'
);
await jest.advanceTimersByTimeAsync(4_000);
await rejected;
expect(replacement.close).toHaveBeenCalledTimes(1);
});
it('retries a rejected close during shutdown', async () => {
const failure = new Error('agent disconnected');
initial.close.mockRejectedValueOnce(failure);
const tunnel = await startNgrokTunnelAsync(options);
const stopping = tunnel.stopAsync();
await jest.advanceTimersByTimeAsync(250);
await stopping;
expect(initial.close).toHaveBeenCalledTimes(2);
expect(logger.warn).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
{ err: failure },
'Could not stop ngrok tunnel web-preview-fixed.example.test.'
);
});
it('logs the first close rejection once while bounded shutdown retries it', async () => {
const failure = new Error('agent disconnected');
initial.close.mockRejectedValue(failure);
const tunnel = await startNgrokTunnelAsync(options);
const rejected = expect(tunnel.stopAsync()).rejects.toThrow('stop timed out');
await jest.advanceTimersByTimeAsync(4_000);
await rejected;
expect(initial.close.mock.calls.length).toBeGreaterThan(1);
expect(logger.warn).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
{ err: failure },
'Could not stop ngrok tunnel web-preview-fixed.example.test.'
);
});
it('bounds shutdown during recovery without duplicating a pending native close', async () => {
failPublicProbes();
let finishClose!: () => void;
initial.close.mockReturnValueOnce(new Promise<void>(resolve => (finishClose = resolve)));
const tunnel = await startNgrokTunnelAsync(options);
await jest.advanceTimersByTimeAsync(45_000);
const stopping = tunnel.stopAsync();
expect(tunnel.stopAsync()).toBe(stopping);
const rejected = expect(stopping).rejects.toThrow(
'Ngrok tunnel web-preview-fixed.example.test stop timed out after 4000ms'
);
await jest.advanceTimersByTimeAsync(4_000);
await rejected;
expect(initial.close).toHaveBeenCalledTimes(1);
expect(ngrok.forward).toHaveBeenCalledTimes(1);
const warnings = jest.mocked(logger.warn).mock.calls.length;
finishClose();
await jest.advanceTimersByTimeAsync(120_000);
expect(logger.warn).toHaveBeenCalledTimes(warnings);
expect(initial.close).toHaveBeenCalledTimes(1);
});
it('bounds a stalled listener close before reopening', async () => {
failPublicProbes();
initial.close.mockImplementationOnce(async () => await new Promise(() => {}));
const tunnel = await startNgrokTunnelAsync(options);
try {
await jest.advanceTimersByTimeAsync(49_999);
expect(ngrok.forward).toHaveBeenCalledTimes(1);
await jest.advanceTimersByTimeAsync(1);
expect(ngrok.forward).toHaveBeenCalledTimes(2);
} finally {
const stopping = tunnel.stopAsync();
const rejected = expect(stopping).rejects.toThrow(
'Ngrok tunnel web-preview-fixed.example.test stop timed out'
);
await jest.advanceTimersByTimeAsync(4_000);
await rejected;
}
});
it('does not queue more native opens while a timed-out open is still pending', async () => {
failPublicProbes();
let resolveOpening!: (value: ngrok.Listener) => void;
jest
.mocked(ngrok.forward)
.mockReset()
.mockResolvedValueOnce(initial as never)
.mockReturnValueOnce(
new Promise(resolve => {
resolveOpening = resolve;
})
)
.mockResolvedValue(replacement as never);
const tunnel = await startNgrokTunnelAsync(options);
const late = listener();
try {
await jest.advanceTimersByTimeAsync(180_000);
expect(ngrok.forward).toHaveBeenCalledTimes(2);
resolveOpening(late as never);
await jest.advanceTimersByTimeAsync(30_000);
expect(late.close).toHaveBeenCalledTimes(1);
expect(ngrok.forward).toHaveBeenCalledTimes(3);
} finally {
await tunnel.stopAsync();
}
expect(replacement.close).toHaveBeenCalledTimes(1);
});
it('bounds initial tunnel creation and retires a late listener', async () => {
let resolveOpening!: (value: ngrok.Listener) => void;
jest
.mocked(ngrok.forward)
.mockReset()
.mockReturnValueOnce(
new Promise(resolve => {
resolveOpening = resolve;
})
);
const opening = startNgrokTunnelAsync(options);
const rejected = expect(opening).rejects.toThrow('Ngrok tunnel open timed out');
await jest.advanceTimersByTimeAsync(15_000);
await rejected;
resolveOpening(initial as never);
await jest.advanceTimersByTimeAsync(0);
expect(initial.close).toHaveBeenCalledTimes(1);
});
it('retries a rejected close after initial tunnel creation timed out', async () => {
let resolveOpening!: (value: ngrok.Listener) => void;
jest
.mocked(ngrok.forward)
.mockReset()
.mockReturnValueOnce(new Promise(resolve => (resolveOpening = resolve)));
const opening = startNgrokTunnelAsync(options);
const rejected = expect(opening).rejects.toThrow('Ngrok tunnel open timed out');
await jest.advanceTimersByTimeAsync(15_000);
await rejected;
initial.close.mockRejectedValueOnce(new Error('temporary unlisten failure'));
resolveOpening(initial as never);
await jest.advanceTimersByTimeAsync(250);
expect(initial.close).toHaveBeenCalledTimes(2);
});
it('retires only the late listener without blaming it for an older stuck listener', async () => {
failPublicProbes();
initial.close.mockRejectedValue(new Error('old session disconnected'));
let resolveOpening!: (value: ngrok.Listener) => void;
jest
.mocked(ngrok.forward)
.mockReset()
.mockResolvedValueOnce(initial as never)
.mockReturnValueOnce(new Promise(resolve => (resolveOpening = resolve)))
.mockResolvedValue(replacement as never);
const tunnel = await startNgrokTunnelAsync(options);
try {
await jest.advanceTimersByTimeAsync(60_000);
const late = listener();
resolveOpening(late as never);
jest.mocked(turtleFetch).mockResolvedValue(new Response(undefined, { status: 200 }));
await jest.advanceTimersByTimeAsync(4_000);
expect(late.close).toHaveBeenCalledTimes(1);
expect(
jest
.mocked(logger.warn)
.mock.calls.some(([, message]) => message?.includes('Could not retire late'))
).toBe(false);
} finally {
initial.close.mockResolvedValue(undefined);
await tunnel.stopAsync();
}
});
it('recovers using actual HTTP health probes with a local ngrok adapter', async () => {
const actualFetch = jest.requireActual<typeof import('node-fetch')>('node-fetch').default;
const realSetTimeout = jest.requireActual<typeof import('node:timers')>('node:timers').setTimeout;
let broken = true;
const local = createServer((_req, res) => {
res.writeHead(200);
res.end('healthy');
});
const publicServer = createServer((_req, res) => {
res.writeHead(broken ? 503 : 200);
res.end('probe');
});
await Promise.all(
[local, publicServer].map(
server => new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
)
);
const publicUrl = `http://127.0.0.1:${(publicServer.address() as AddressInfo).port}`;
let completedRequests = 0;
jest.mocked(turtleFetch).mockImplementation(async (target, method, requestOptions) => {
const response = await actualFetch(target, { ...requestOptions, method });
completedRequests++;
return response;
});
const first = listener(publicUrl);
const next = listener(publicUrl);
jest
.mocked(ngrok.forward)
.mockReset()
.mockResolvedValueOnce(first as never)
.mockImplementation(async () => {
broken = false;
return next as never;
});
const tunnel = await startNgrokTunnelAsync({
...options,
port: (local.address() as AddressInfo).port,
});
try {
for (let count = 0; count < 3; count++) {
await jest.advanceTimersByTimeAsync(15_000);
for (let turns = 0; turns < 50 && completedRequests < (count + 1) * 2; turns++) {
await new Promise(resolve => realSetTimeout(resolve, 10));
}
expect(completedRequests).toBe((count + 1) * 2);
}
expect(ngrok.forward).toHaveBeenCalledTimes(2);
expect(first.close).toHaveBeenCalledTimes(1);
expect((await actualFetch(`${tunnel.url}/healthz`)).status).toBe(200);
} finally {
await tunnel.stopAsync();
await Promise.all(
[local, publicServer].map(
server =>
new Promise<void>((resolve, reject) =>
server.close(err => (err ? reject(err) : resolve()))
)
)
);
}
expect(next.close).toHaveBeenCalledTimes(1);
});
it('blocks HTTP and existing WebSockets on Stop even when ngrok cannot close', async () => {
jest.useRealTimers();
const actualFetch = jest.requireActual<typeof import('node-fetch')>('node-fetch').default;
let requests = 0;
let messages = 0;
const backend = createServer((_request, response) => {
requests++;
response.end('owned backend');
});
const websockets = new WebSocketServer({ server: backend });
websockets.on('connection', socket => {
socket.on('message', data => {
messages++;
socket.send(data);
});
});
backend.listen(0, '127.0.0.1');
await once(backend, 'listening');
const backendPort = (backend.address() as AddressInfo).port;
const tunnel = await startNgrokTunnelAsync({
...options,
port: backendPort,
healthCheck: undefined,
});
const targetPort = (jest.mocked(ngrok.forward).mock.calls[0][0] as ngrok.Config).addr;
const targetUrl = `http://127.0.0.1:${targetPort}`;
const socket = new WebSocket(`ws://127.0.0.1:${targetPort}`);
const opened = once(socket, 'open');
try {
expect(await (await actualFetch(targetUrl)).text()).toBe('owned backend');
await opened;
const echoed = once(socket, 'message');
socket.send('before-stop');
expect((await echoed)[0].toString()).toBe('before-stop');
const closed = once(socket, 'close');
initial.close.mockRejectedValue(new Error('agent disconnected'));
const stopping = tunnel.stopAsync();
const rejected = expect(stopping).rejects.toThrow('stop timed out after 4000ms');
await expect(actualFetch(targetUrl, { timeout: 1_000 })).rejects.toThrow();
await closed;
expect(requests).toBe(1);
expect(messages).toBe(1);
expect(await (await actualFetch(`http://127.0.0.1:${backendPort}`)).text()).toBe(
'owned backend'
);
await rejected;
await expect(actualFetch(targetUrl, { timeout: 1_000 })).rejects.toThrow();
expect(requests).toBe(2);
expect(messages).toBe(1);
const competing = createTcpServer();
const conflict = once(competing, 'error');
competing.listen(targetPort as number, '127.0.0.1');
expect((await conflict)[0]).toMatchObject({ code: 'EADDRINUSE' });
} finally {
socket.terminate();
for (const client of websockets.clients) {
client.terminate();
}
websockets.close();
backend.closeAllConnections();
await new Promise<void>(resolve => backend.close(() => resolve()));
await tunnel.stopAsync().catch(() => {});
}
});
it('releases its local target when initial tunnel creation fails', async () => {
jest.useRealTimers();
jest.mocked(ngrok.forward).mockReset().mockRejectedValue(new Error('forward failed'));
await expect(startNgrokTunnelAsync(options)).rejects.toThrow('forward failed');
const targetPort = (jest.mocked(ngrok.forward).mock.calls[0][0] as ngrok.Config).addr;
const server = createServer();
server.listen(targetPort as number, '127.0.0.1');
await once(server, 'listening');
await new Promise<void>(resolve => server.close(() => resolve()));
});
it('preserves a full response for a half-closed slow reader', async () => {
jest.useRealTimers();
const payload = Buffer.alloc(4 * 1024 * 1024, 37);
const backend = createTcpServer({ allowHalfOpen: true }, socket => {
socket.resume();
socket.on('end', () => socket.end(payload));
});
backend.listen(0, '127.0.0.1');
await once(backend, 'listening');
const tunnel = await startNgrokTunnelAsync({
...options,
port: (backend.address() as AddressInfo).port,
healthCheck: undefined,
});
const targetPort = (jest.mocked(ngrok.forward).mock.calls[0][0] as ngrok.Config).addr as number;
const socket = createConnection({ host: '127.0.0.1', port: targetPort, allowHalfOpen: true });
try {
const chunks: Buffer[] = [];
socket.on('data', chunk => chunks.push(chunk));
const ended = once(socket, 'end');
socket.pause();
socket.end('request');
await new Promise(resolve => setTimeout(resolve, 100));
socket.resume();
await ended;
expect(Buffer.concat(chunks).equals(payload)).toBe(true);
} finally {
socket.destroy();
await tunnel.stopAsync();
await new Promise<void>(resolve => backend.close(() => resolve()));
}
});
it.each(['resolve', 'reject'])(
'releases the target after timed-out startup later %s',
async outcome => {
let resolveOpening!: (value: ngrok.Listener) => void;
let rejectOpening!: (error: Error) => void;
jest
.mocked(ngrok.forward)
.mockReset()
.mockReturnValue(
new Promise((resolve, reject) => {
resolveOpening = resolve;
rejectOpening = reject;
})
);
const opening = startNgrokTunnelAsync(options);
const rejected = expect(opening).rejects.toThrow('open timed out');
await new Promise<void>(resolve => setImmediate(resolve));
await jest.advanceTimersByTimeAsync(15_000);
await rejected;
const targetPort = (jest.mocked(ngrok.forward).mock.calls[0][0] as ngrok.Config).addr as number;
if (outcome === 'resolve') {
resolveOpening(replacement as never);
} else {
rejectOpening(new Error('forward failed'));
}
await jest.advanceTimersByTimeAsync(0);
const server = createTcpServer();
server.listen(targetPort, '127.0.0.1');
await once(server, 'listening');
await new Promise<void>(resolve => server.close(() => resolve()));
}
);
it('drains a full upload when the backend ends its response early', async () => {
jest.useRealTimers();
const payload = Buffer.alloc(4 * 1024 * 1024, 51);
let received!: Buffer;
let receivedRequest!: () => void;
const completed = new Promise<void>(resolve => {
receivedRequest = resolve;
});
const backend = createTcpServer({ allowHalfOpen: true }, socket => {
const chunks: Buffer[] = [];
socket.on('data', chunk => chunks.push(chunk));
socket.on('end', () => {
received = Buffer.concat(chunks);
receivedRequest();
});
socket.pause();
socket.end('early response');
setTimeout(() => socket.resume(), 100);
});
backend.listen(0, '127.0.0.1');
await once(backend, 'listening');
const tunnel = await startNgrokTunnelAsync({
...options,
port: (backend.address() as AddressInfo).port,
healthCheck: undefined,
});
const targetPort = (jest.mocked(ngrok.forward).mock.calls[0][0] as ngrok.Config).addr as number;
const socket = createConnection({ host: '127.0.0.1', port: targetPort, allowHalfOpen: true });
try {
socket.resume();
socket.end(payload);
await completed;
expect(received.equals(payload)).toBe(true);
} finally {
socket.destroy();
await tunnel.stopAsync();
await new Promise<void>(resolve => backend.close(() => resolve()));
}
});
@@ -932,7 +932,7 @@ describe(waitForWebPreviewReadyAsync, () => {
describe(startNgrokTunnelAsync, () => {
it.each(['rejected', 'stalled'] as const)(
'retains a %s close failure on repeated stops',
'bounds %s listener retirement and reuses the stop promise',
async mode => {
jest.useFakeTimers();
try {
@@ -954,14 +954,12 @@ describe(startNgrokTunnelAsync, () => {
logger: createLoggerMock(),
});
const stopping = tunnel.stopAsync();
const assertion = expect(stopping).rejects.toThrow(
mode === 'rejected' ? 'close failed' : 'Ngrok tunnel stop timed out after 4000ms.'
);
const assertion = expect(stopping).rejects.toThrow(/stop timed out after 4000ms/);
expect(tunnel.stopAsync()).toBe(stopping);
await jest.advanceTimersByTimeAsync(4_000);
await assertion;
await expect(tunnel.stopAsync()).rejects.toThrow();
expect(close).toHaveBeenCalledTimes(1);
expect(close).toHaveBeenCalled();
} finally {
jest.useRealTimers();
}
@@ -985,7 +983,7 @@ describe(startNgrokTunnelAsync, () => {
expect(ngrok.forward).toHaveBeenCalledWith(
expect.objectContaining({
addr: 4321,
addr: expect.any(Number),
authtoken: 'token',
domain: expect.stringMatching(/^web-preview-[a-f0-9]{32}\.eas-simulator\.ngrok\.dev$/),
})
@@ -1302,7 +1300,10 @@ describe(startDeviceSessionHostAsync, () => {
recordingDirectory,
}),
]);
expect(ngrok.forward).toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
expect(ngrok.forward).toHaveBeenCalledWith(
expect.objectContaining({ addr: expect.any(Number) })
);
expect(ngrok.forward).not.toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
expect(preview.apiUrl).toBe('https://android-preview.example.test');
await host.finishAsync();
@@ -1438,7 +1439,10 @@ describe(startDeviceSessionHostAsync, () => {
packageVersion,
}),
]);
expect(ngrok.forward).toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
expect(ngrok.forward).toHaveBeenCalledWith(
expect.objectContaining({ addr: expect.any(Number) })
);
expect(ngrok.forward).not.toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
expect(preview.apiUrl).toBe('https://ios-preview.example.test');
await host.finishAsync();
@@ -623,6 +623,7 @@ async function startDeviceSessionHostInternalAsync(
subdomainId,
baseDomain,
authtoken,
healthCheck: { path: isAndroid ? '/readyz' : '/healthz' },
logger,
});
if (finishTask) {
@@ -0,0 +1,368 @@
import { SystemError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import * as ngrok from '@ngrok/ngrok';
import { randomBytes } from 'node:crypto';
import { once } from 'node:events';
import { type AddressInfo, type Socket, createConnection, createServer } from 'node:net';
import { clearTimeout, setTimeout } from 'node:timers';
import { turtleFetch } from '../../utils/turtleFetch';
import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout';
const PROBE_INTERVAL_MS = 15_000;
const FAILURE_THRESHOLD = 3;
const STOP_TIMEOUT_MS = 4_000;
export type NgrokTunnelHandle = {
url: string;
subdomainId: string;
stopAsync: () => Promise<void>;
};
export async function startNgrokTunnelAsync({
port,
subdomainPrefix,
subdomainId = randomBytes(16).toString('hex'),
baseDomain,
authtoken,
rewriteHostHeader,
healthCheck,
logger,
}: {
port: number;
subdomainPrefix: string;
subdomainId?: string;
baseDomain: string;
authtoken: string;
rewriteHostHeader?: boolean;
healthCheck?: { path: string; statuses?: number[] };
logger: bunyan;
}): Promise<NgrokTunnelHandle> {
const domain = `${subdomainPrefix}-${subdomainId}.${baseDomain}`;
const controller = new AbortController();
const { signal } = controller;
const sockets = new Set<Socket>();
const target = createServer({ allowHalfOpen: true }, client => {
if (signal.aborted) {
client.destroy();
return;
}
const upstream = createConnection({ host: '127.0.0.1', port, allowHalfOpen: true });
for (const socket of [client, upstream]) {
sockets.add(socket);
socket.on('error', () => {
client.destroy();
upstream.destroy();
});
socket.on('close', () => sockets.delete(socket));
}
client.pipe(upstream).pipe(client);
});
target.listen(0, '127.0.0.1');
await once(target, 'listening');
target.unref();
signal.addEventListener(
'abort',
() => {
for (const socket of sockets) {
socket.destroy();
}
},
{ once: true }
);
const config: ngrok.Config = {
addr: (target.address() as AddressInfo).port,
authtoken,
domain,
...(rewriteHostHeader ? { request_header_add: [`Host:localhost:${port}`] } : {}),
};
const ownedListeners = new Set<ngrok.Listener>();
const retiredListeners = new Set<ngrok.Listener>();
const closingListeners = new Map<ngrok.Listener, Promise<void>>();
let pendingOpen: Promise<ngrok.Listener> | undefined;
let currentListener: ngrok.Listener | undefined;
function releaseTarget(): void {
if (signal.aborted && !pendingOpen && ownedListeners.size === 0 && target.listening) {
target.close();
}
}
async function closeAsync(
listener: ngrok.Listener,
closeSignal: AbortSignal,
logFailure = true
): Promise<void> {
retiredListeners.add(listener);
let closing = closingListeners.get(listener);
if (!closing) {
closing = Promise.resolve().then(async () => await listener.close());
closingListeners.set(listener, closing);
void closing.then(
() => {
closingListeners.delete(listener);
retiredListeners.delete(listener);
ownedListeners.delete(listener);
releaseTarget();
},
() => closingListeners.delete(listener)
);
}
try {
await withDeviceRunSessionTimeoutAsync(
{ name: 'Ngrok tunnel close', timeoutMs: 5_000, signal: closeSignal },
async () => await closing
);
} catch (err) {
if (logFailure && !closeSignal.aborted) {
logger.warn({ err }, `Could not stop ngrok tunnel ${domain}.`);
}
}
}
async function retireListenersAsync(retirementSignal: AbortSignal): Promise<void> {
let logFailure = true;
while (retiredListeners.size > 0) {
retirementSignal.throwIfAborted();
await Promise.all(
[...retiredListeners].map(retired => closeAsync(retired, retirementSignal, logFailure))
);
logFailure = false;
if (retiredListeners.size > 0) {
await waitAsync(250, retirementSignal);
}
}
}
async function retireLateListenerAsync(listener: ngrok.Listener): Promise<void> {
retiredListeners.add(listener);
try {
await withDeviceRunSessionTimeoutAsync(
{ name: 'Late ngrok tunnel retirement', timeoutMs: STOP_TIMEOUT_MS },
async retirementSignal => {
while (retiredListeners.has(listener)) {
retirementSignal.throwIfAborted();
await closeAsync(listener, retirementSignal, false);
if (retiredListeners.has(listener)) {
await waitAsync(250, retirementSignal);
}
}
}
);
} catch (err) {
logger.warn({ err }, `Could not retire late ngrok tunnel ${domain}.`);
}
}
async function openAsync(forceNewSession = false): Promise<ngrok.Listener> {
if (pendingOpen) {
throw new SystemError(`A previous ngrok tunnel open for ${domain} is still pending.`);
}
let acquired: ngrok.Listener | undefined;
let retirement: Promise<void> | undefined;
const retireAcquired = () => {
if (acquired) {
retirement ??= retireLateListenerAsync(acquired);
}
};
try {
return await withDeviceRunSessionTimeoutAsync(
{ name: 'Ngrok tunnel open', timeoutMs: 15_000, signal },
async openSignal => {
openSignal.throwIfAborted();
const opening = ngrok
.forward({
...config,
...(forceNewSession ? { force_new_session: true } : {}),
})
.then(listener => {
acquired = listener;
ownedListeners.add(listener);
return listener;
})
.finally(() => {
pendingOpen = undefined;
});
pendingOpen = opening;
void opening
.then(() => {
if (openSignal.aborted) {
retireAcquired();
}
})
.catch(() => releaseTarget());
const listener = await opening;
openSignal.throwIfAborted();
return listener;
}
);
} catch (err) {
retireAcquired();
throw err;
}
}
logger.info(`Starting ngrok tunnel ${domain} -> http://localhost:${port}.`);
let publicUrl: string | null;
try {
currentListener = await openAsync();
publicUrl = currentListener.url();
if (!publicUrl) {
await closeAsync(currentListener, signal);
currentListener = undefined;
throw new SystemError(`ngrok tunnel for ${domain} did not return a public URL.`);
}
} catch (error) {
controller.abort();
releaseTarget();
throw error;
}
const url = publicUrl;
async function probeAsync(
baseUrl: string,
{ path, statuses = [200] }: NonNullable<typeof healthCheck>
): Promise<boolean> {
try {
return await withDeviceRunSessionTimeoutAsync(
{ name: 'Ngrok tunnel health probe', timeoutMs: 5_000, signal },
async probeSignal => {
const requestController = new AbortController();
try {
const response = await turtleFetch(new URL(path, baseUrl).href, 'GET', {
retries: 0,
redirect: 'manual',
shouldThrowOnNotOk: false,
headers: { 'ngrok-skip-browser-warning': 'true', 'Cache-Control': 'no-cache' },
signal: AbortSignal.any([probeSignal, requestController.signal]),
});
return !response.headers.has('ngrok-error-code') && statuses.includes(response.status);
} finally {
requestController.abort();
}
}
);
} catch {
return false;
}
}
async function superviseAsync(check: NonNullable<typeof healthCheck>): Promise<void> {
let failures = 0;
let attempts = 0;
let localUnhealthy = false;
while (
await waitAsync(
currentListener || localUnhealthy
? PROBE_INTERVAL_MS
: Math.min(1_000 * 2 ** Math.min(attempts, 5), 30_000),
signal
)
) {
await Promise.all([...retiredListeners].map(retired => closeAsync(retired, signal, false)));
if (signal.aborted) {
return;
}
if (currentListener && (await probeAsync(url, check))) {
if (failures > 0 || attempts > 0 || localUnhealthy) {
logger.info(`Ngrok tunnel ${domain} is healthy again.`);
}
failures = 0;
attempts = 0;
localUnhealthy = false;
continue;
}
if (signal.aborted) {
return;
}
if (!(await probeAsync(`http://127.0.0.1:${port}`, check))) {
if (!localUnhealthy && !signal.aborted) {
logger.warn(`Local service on port ${port} is unhealthy; waiting for it to recover.`);
}
localUnhealthy = true;
failures = 0;
continue;
}
localUnhealthy = false;
if (currentListener) {
failures++;
if (failures < FAILURE_THRESHOLD) {
continue;
}
logger.warn(`Ngrok tunnel ${domain} failed ${failures} health probes; reopening it.`);
await closeAsync(currentListener, signal);
currentListener = undefined;
}
if (signal.aborted) {
return;
}
attempts++;
try {
const reopened = await openAsync(/* forceNewSession */ true);
if (reopened.url() !== url) {
await closeAsync(reopened, signal);
throw new SystemError(
`Reopened ngrok tunnel for ${domain} returned a different public URL.`
);
}
currentListener = reopened;
failures = 0;
} catch (err) {
if (!signal.aborted && (attempts === 1 || attempts % 5 === 0)) {
logger.warn(
{ err, attempt: attempts },
`Could not reopen ngrok tunnel ${domain}; will retry.`
);
}
}
}
}
const supervision = healthCheck
? superviseAsync(healthCheck).catch(err => {
if (!signal.aborted) {
logger.warn({ err }, `Ngrok tunnel supervision stopped for ${domain}.`);
}
})
: Promise.resolve();
let stopTask: Promise<void> | undefined;
return {
url,
subdomainId,
stopAsync: () =>
(stopTask ??= (async () => {
controller.abort();
await withDeviceRunSessionTimeoutAsync(
{ name: `Ngrok tunnel ${domain} stop`, timeoutMs: STOP_TIMEOUT_MS },
async stopSignal => {
await supervision;
if (currentListener) {
retiredListeners.add(currentListener);
currentListener = undefined;
}
await Promise.all([pendingOpen?.catch(() => {}), retireListenersAsync(stopSignal)]);
await retireListenersAsync(stopSignal);
}
);
releaseTarget();
})()),
};
}
function waitAsync(ms: number, signal: AbortSignal): Promise<boolean> {
if (signal.aborted) {
return Promise.resolve(false);
}
return new Promise(resolve => {
const onAbort = () => {
clearTimeout(timer);
resolve(false);
};
const timer = setTimeout(() => {
signal.removeEventListener('abort', onAbort);
resolve(true);
}, ms);
signal.addEventListener('abort', onAbort, { once: true });
});
}
@@ -9,9 +9,7 @@ import {
spawnAsync,
} from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import * as ngrok from '@ngrok/ngrok';
import { z } from 'zod';
import { randomBytes } from 'node:crypto';
import fs from 'node:fs';
import { createServer } from 'node:net';
import path from 'node:path';
@@ -967,63 +965,7 @@ export async function findAvailablePortAsync(): Promise<number> {
return address.port;
}
export type NgrokTunnelHandle = {
url: string;
subdomainId: string;
stopAsync: () => Promise<void>;
};
export async function startNgrokTunnelAsync({
port,
subdomainPrefix,
subdomainId: subdomainIdArg,
baseDomain,
authtoken,
rewriteHostHeader,
logger,
}: {
port: number;
subdomainPrefix: string;
subdomainId?: string;
baseDomain: string;
authtoken: string;
rewriteHostHeader?: boolean;
logger: bunyan;
}): Promise<NgrokTunnelHandle> {
const subdomainId = subdomainIdArg ?? randomBytes(16).toString('hex');
const domain = `${subdomainPrefix}-${subdomainId}.${baseDomain}`;
logger.info(`Starting ngrok tunnel ${domain} -> http://localhost:${port}.`);
// Run the ngrok agent in-process via the SDK; it keeps the session alive until
// the process exits, and the step blocks forever to hold it open.
const listener = await ngrok.forward({
addr: port,
authtoken,
domain,
...(rewriteHostHeader ? { request_header_add: [`Host:localhost:${port}`] } : {}),
});
const url = listener.url();
if (!url) {
await listener.close();
throw new SystemError(`ngrok tunnel for ${domain} did not return a public URL.`);
}
let stopTask: Promise<void> | undefined;
return {
url,
subdomainId,
stopAsync: () =>
(stopTask ??= (async () => {
try {
await withDeviceRunSessionTimeoutAsync(
{ name: 'Ngrok tunnel stop', timeoutMs: 4_000 },
async () => await listener.close()
);
} catch (error) {
logger.warn({ err: error }, `Could not stop ngrok tunnel ${domain}.`);
throw error;
}
})()),
};
}
export { type NgrokTunnelHandle, startNgrokTunnelAsync } from './ngrokTunnel';
export async function waitForFileAsync<T>({
filePath,
@@ -7,6 +7,7 @@ import { setTimeout } from 'node:timers/promises';
import { z } from 'zod';
import { retryAsync } from './retry';
import { waitForWarmSimulatorCleanupAsync } from './warmSimulatorCleanup';
export type IosSimulatorUuid = string & z.BRAND<'IosSimulatorUuid'>;
export type IosSimulatorName = string & z.BRAND<'IosSimulatorName'>;
@@ -119,6 +120,7 @@ export namespace IosSimulatorUtils {
destinationDeviceName: IosSimulatorName;
env: NodeJS.ProcessEnv;
}): Promise<void> {
await waitForWarmSimulatorCleanupAsync(env);
await spawn('xcrun', ['simctl', 'clone', sourceDeviceIdentifier, destinationDeviceName], {
env,
});
@@ -131,6 +133,7 @@ export namespace IosSimulatorUtils {
deviceIdentifier: IosSimulatorUuid | IosSimulatorName;
env: NodeJS.ProcessEnv;
}): Promise<void> {
await waitForWarmSimulatorCleanupAsync(env);
try {
const devices = await getAvailableDevicesAsync({ env, filter: 'available' });
const device = devices.find(
@@ -231,6 +234,7 @@ export namespace IosSimulatorUtils {
env: NodeJS.ProcessEnv;
launchdEnvironment?: Record<string, string>;
}): Promise<void> {
await waitForWarmSimulatorCleanupAsync(env);
const bootEnv = { ...env };
for (const [name, value] of Object.entries(launchdEnvironment)) {
bootEnv[`SIMCTL_CHILD_${name}`] = value;
@@ -253,6 +257,7 @@ export namespace IosSimulatorUtils {
deviceIdentifier: IosSimulatorUuid | IosSimulatorName;
env: NodeJS.ProcessEnv;
}): Promise<{ udid: IosSimulatorUuid }> {
await waitForWarmSimulatorCleanupAsync(env);
const bootstatusResult = await spawn(
'xcrun',
['simctl', 'bootstatus', deviceIdentifier, '-b'],
@@ -5,6 +5,7 @@ import path from 'node:path';
import { IosSimulatorUtils } from '../IosSimulatorUtils';
import { retryAsync } from '../retry';
import { waitForWarmSimulatorCleanupAsync } from '../warmSimulatorCleanup';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
@@ -14,6 +15,7 @@ jest.mock('@expo/turtle-spawn', () => ({
jest.mock('../retry', () => ({
retryAsync: jest.fn(async fn => await fn(0)),
}));
jest.mock('../warmSimulatorCleanup');
const mockedSpawn = jest.mocked(spawn);
@@ -21,6 +23,7 @@ describe('IosSimulatorUtils', () => {
beforeEach(() => {
mockedSpawn.mockReset();
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
jest.mocked(waitForWarmSimulatorCleanupAsync).mockReset().mockResolvedValue(undefined);
});
describe(IosSimulatorUtils.getAvailableRuntimesAsync, () => {
@@ -151,6 +154,30 @@ describe('IosSimulatorUtils', () => {
}) as any);
}
it('waits for warm cleanup before checking device state and writing accessibility prefs', async () => {
let finishCleanup: (() => void) | undefined;
jest.mocked(waitForWarmSimulatorCleanupAsync).mockImplementationOnce(
async () =>
await new Promise<void>(resolve => {
finishCleanup = resolve;
})
);
mockAvailableDevices([device()]);
const setup = IosSimulatorUtils.enableAccessibilitySettingsAsync({
deviceIdentifier: device().udid as any,
env: {},
});
expect(mockedSpawn).not.toHaveBeenCalled();
expect(finishCleanup).toBeDefined();
finishCleanup?.();
await setup;
expect(mockedSpawn).toHaveBeenCalledWith('plutil', expect.anything(), {
env: {},
});
});
it('writes accessibility prefs for an exact shutdown simulator match', async () => {
mockAvailableDevices([
device({
@@ -76,11 +76,7 @@ describe(getSshRelayServerUrl, () => {
});
describe(startSshSessionAsync, () => {
const config1: SshConnectionConfig = {
type: 'upterm-v1',
host: 'relay.expo.dev',
secret: 'secret-1',
};
const config1: SshConnectionConfig = { type: 'upterm-v1', secret: 'secret-1' };
const config2: SshConnectionConfig = { ...config1, secret: 'secret-2' };
const target = { type: 'JOB_RUN', id: 'jr-1' } as const;
@@ -133,7 +129,7 @@ describe(startSshSessionAsync, () => {
expect(mutation).toHaveBeenCalledWith(expect.anything(), {
target: { type: 'JOB_RUN', id: 'jr-1' },
connectionConfig: { ...config1, reconnecting: false, type: 'UPTERM_V1' },
connectionConfig: { type: 'UPTERM_V1', secret: 'secret-1', reconnecting: false },
sessionSettings: { idleTimeoutSeconds: 900 },
});
expect(idleTimeoutSeconds).toBe(900);
@@ -155,7 +151,7 @@ describe(startSshSessionAsync, () => {
expect(mutation).toHaveBeenCalledWith(expect.anything(), {
target: { type: 'BUILD', id: 'b-1' },
connectionConfig: { ...config1, reconnecting: false, type: 'UPTERM_V1' },
connectionConfig: { type: 'UPTERM_V1', secret: 'secret-1', reconnecting: false },
sessionSettings: { idleTimeoutSeconds: 900 },
});
});
@@ -242,7 +238,7 @@ describe(startSshSessionAsync, () => {
);
expect(mutation).toHaveBeenLastCalledWith(expect.anything(), {
target: { type: 'JOB_RUN', id: 'jr-1' },
connectionConfig: { ...config2, reconnecting: false, type: 'UPTERM_V1' },
connectionConfig: { type: 'UPTERM_V1', secret: 'secret-2', reconnecting: false },
sessionSettings: { idleTimeoutSeconds: 300 },
});
});
@@ -298,7 +294,7 @@ describe(startSshSessionAsync, () => {
expect(host.redialAsync).toHaveBeenCalledTimes(1);
expect(mutation).toHaveBeenLastCalledWith(expect.anything(), {
target: { type: 'JOB_RUN', id: 'jr-1' },
connectionConfig: { ...config2, reconnecting: false, type: 'UPTERM_V1' },
connectionConfig: { type: 'UPTERM_V1', secret: 'secret-2', reconnecting: false },
sessionSettings: { idleTimeoutSeconds: 300 },
});
});
@@ -7,7 +7,6 @@ import { createMockLogger } from '../../__tests__/utils/logger';
import { BuildContext } from '../../context';
import { sleepAsync } from '../retry';
import {
connectionConfigFromUptermSession,
redactConnectionSecrets,
redactSpawnErrorForLog,
resolveUptermGcsObjectName,
@@ -42,49 +41,6 @@ describe(resolveUptermGcsObjectName, () => {
});
});
describe(connectionConfigFromUptermSession, () => {
it('parses sessionId and host from session current fields', () => {
expect(
connectionConfigFromUptermSession({
sessionId: 'TOKENabc123',
host: 'ssh://relay.expo.dev:22',
})
).toEqual({
type: 'upterm-v1',
host: 'relay.expo.dev',
secret: 'TOKENabc123',
});
});
it('accepts a bare hostname', () => {
expect(connectionConfigFromUptermSession({ sessionId: 'tok', host: 'relay.expo.dev' })).toEqual(
{
type: 'upterm-v1',
host: 'relay.expo.dev',
secret: 'tok',
}
);
});
it('keeps a non-default port so the CLI can pass it to ssh -p', () => {
expect(
connectionConfigFromUptermSession({
sessionId: 'tok',
host: 'ssh://relay.expo.dev:2222',
})
).toEqual({
type: 'upterm-v1',
host: 'relay.expo.dev:2222',
secret: 'tok',
});
});
it('returns null when the host is not a parseable url', () => {
expect(connectionConfigFromUptermSession({ sessionId: 'tok', host: 'ssh://' })).toBeNull();
expect(connectionConfigFromUptermSession({ sessionId: 'tok', host: 'ssh://[' })).toBeNull();
});
});
describe(redactConnectionSecrets, () => {
it('strips the userinfo secret from a connection string', () => {
expect(
@@ -257,11 +213,7 @@ describe(startUptermHostAsync, () => {
relayServerUrl: 'wss://relay.expo.dev',
});
expect(host.connectionConfig).toEqual({
type: 'upterm-v1',
host: 'relay.expo.dev',
secret: 'TOKENx',
});
expect(host.connectionConfig).toEqual({ type: 'upterm-v1', secret: 'TOKENx' });
expect(host.isAlive()).toBe(true);
expect(mockedDownloadFile).not.toHaveBeenCalled();
expect(mockedSpawn).toHaveBeenCalledWith('upterm', ['version'], {
@@ -369,6 +321,21 @@ describe(startUptermHostAsync, () => {
);
});
it('keeps polling until upterm reports a session id', async () => {
sessionStdout = JSON.stringify({ host: 'ssh://relay.expo.dev:22', clientCount: 0 });
mockedSleep.mockImplementationOnce(async () => {
sessionStdout = JSON.stringify({
sessionId: 'TOKENy',
host: 'ssh://relay.expo.dev:22',
clientCount: 0,
});
});
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
expect(host.connectionConfig).toEqual({ type: 'upterm-v1', secret: 'TOKENy' });
});
it('tears down and rethrows when the host never registers with the relay', async () => {
jest.useFakeTimers();
jest.setSystemTime(0);
@@ -420,11 +387,7 @@ describe(startUptermHostAsync, () => {
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
const config = await host.redialAsync();
expect(config).toEqual({
type: 'upterm-v1',
host: 'relay.expo.dev',
secret: 'TOKENx',
});
expect(config).toEqual({ type: 'upterm-v1', secret: 'TOKENx' });
expect(host.connectionConfig).toEqual(config);
});
@@ -462,11 +425,7 @@ describe(startUptermHostAsync, () => {
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
hostProcess.catch = () => new Promise<void>(() => {});
await expect(host.redialAsync()).resolves.toEqual({
type: 'upterm-v1',
host: 'relay.expo.dev',
secret: 'TOKENx',
});
await expect(host.redialAsync()).resolves.toEqual({ type: 'upterm-v1', secret: 'TOKENx' });
expect(mockedSleep).toHaveBeenCalledWith(5_000);
});
@@ -0,0 +1,125 @@
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs/promises';
import { waitForWarmSimulatorCleanupAsync } from '../warmSimulatorCleanup';
const STATE_FILE = '/tmp/expo-simulator-startup.json';
const STARTUP_SCRIPT = '/usr/local/share/vm-tools/startup.sh';
const platform = Object.getOwnPropertyDescriptor(process, 'platform')!;
jest.mock('@expo/turtle-spawn');
async function writeStateAsync(state: object): Promise<void> {
await fs.mkdir('/tmp', { recursive: true });
await fs.writeFile(STATE_FILE, JSON.stringify({ bootUuid: 'current-boot', ...state }));
}
beforeEach(() => {
Object.defineProperty(process, 'platform', { value: 'darwin' });
jest.mocked(spawn).mockResolvedValue({ stdout: 'current-boot\n' } as never);
});
afterEach(() => {
Object.defineProperty(process, 'platform', platform);
});
it('continues at once on a VM without warmup', async () => {
await expect(waitForWarmSimulatorCleanupAsync({})).resolves.toBeUndefined();
expect(spawn).not.toHaveBeenCalled();
});
it('ignores a previous boot cleanup record and reads the current boot only once', async () => {
await writeStateAsync({ status: 'disabled', bootUuid: 'old-boot' });
let finished = false;
const waiting = waitForWarmSimulatorCleanupAsync({}, { pollMs: 5 }).then(() => {
finished = true;
});
await new Promise(resolve => setTimeout(resolve, 30));
expect(finished).toBe(false);
await writeStateAsync({ status: 'disabled' });
await waiting;
expect(spawn).toHaveBeenCalledTimes(1);
});
it('does not accept a cleanup record without a boot ID', async () => {
await writeStateAsync({ status: 'disabled', bootUuid: undefined });
await expect(waitForWarmSimulatorCleanupAsync({}, { timeoutMs: 20, pollMs: 5 })).rejects.toThrow(
'Warm Simulator cleanup did not finish'
);
});
it.each(['failed', 'empty'])('fails closed when the current boot ID is %s', async mode => {
await writeStateAsync({ status: 'disabled' });
if (mode === 'failed') {
jest.mocked(spawn).mockRejectedValueOnce(new Error('sysctl failed'));
} else {
jest.mocked(spawn).mockResolvedValueOnce({ stdout: '' } as never);
}
await expect(waitForWarmSimulatorCleanupAsync({})).rejects.toThrow();
});
it('waits for cleanup when warmup is installed but has not written state yet', async () => {
await fs.mkdir('/usr/local/share/vm-tools', { recursive: true });
await fs.writeFile(STARTUP_SCRIPT, '');
let finished = false;
const waiting = waitForWarmSimulatorCleanupAsync({}, { pollMs: 5 }).then(() => {
finished = true;
});
await new Promise(resolve => setTimeout(resolve, 30));
expect(finished).toBe(false);
await writeStateAsync({ status: 'starting' });
await new Promise(resolve => setTimeout(resolve, 30));
expect(finished).toBe(false);
await writeStateAsync({ status: 'disabled' });
await waiting;
});
it('times out when installed warmup never writes state', async () => {
await fs.mkdir('/usr/local/share/vm-tools', { recursive: true });
await fs.writeFile(STARTUP_SCRIPT, '');
await expect(waitForWarmSimulatorCleanupAsync({}, { timeoutMs: 20, pollMs: 5 })).rejects.toThrow(
'Warm Simulator cleanup did not finish; the Simulator cannot be used.'
);
});
it('waits until the launcher records that cleanup finished', async () => {
await writeStateAsync({ status: 'ready' });
let finished = false;
const waiting = waitForWarmSimulatorCleanupAsync({}, { pollMs: 5 }).then(() => {
finished = true;
});
await new Promise(resolve => setTimeout(resolve, 30));
expect(finished).toBe(false);
await writeStateAsync({ status: 'stopping' });
await new Promise(resolve => setTimeout(resolve, 30));
expect(finished).toBe(false);
await writeStateAsync({ status: 'disabled' });
await waiting;
expect(finished).toBe(true);
});
it('fails with the recorded error when cleanup does not finish in time', async () => {
await writeStateAsync({ status: 'stopping', error: 'serve-sim did not exit' });
await expect(waitForWarmSimulatorCleanupAsync({}, { timeoutMs: 20, pollMs: 5 })).rejects.toThrow(
'Warm Simulator cleanup did not finish (serve-sim did not exit); the Simulator cannot be used.'
);
});
it('stops waiting when the job is cancelled', async () => {
await writeStateAsync({ status: 'stopping' });
const controller = new AbortController();
const waiting = waitForWarmSimulatorCleanupAsync({}, { pollMs: 5, signal: controller.signal });
controller.abort(new Error('cancelled'));
await expect(waiting).rejects.toThrow('cancelled');
});
it('does not wait for a job that uses the warm Simulator', async () => {
await writeStateAsync({ status: 'claimed' });
await expect(
waitForWarmSimulatorCleanupAsync({ EAS_USE_WARM_SIMULATOR: '1' }, { timeoutMs: 0 })
).resolves.toBeUndefined();
});
it('does nothing outside macOS', async () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
await writeStateAsync({ status: 'ready' });
await expect(waitForWarmSimulatorCleanupAsync({}, { timeoutMs: 0 })).resolves.toBeUndefined();
});
+1 -26
View File
@@ -37,11 +37,9 @@ const CONNECTION_STARTUP_TIMEOUT_MS = 60_000;
const PROCESS_EXIT_TIMEOUT_MS = 5_000;
const CLIENT_COUNT_READ_ATTEMPTS = 4;
const CLIENT_COUNT_READ_RETRY_MS = 500;
const DEFAULT_SSH_PORT = '22';
export type SshConnectionConfig = {
type: 'upterm-v1';
host: string;
secret: string;
};
@@ -55,30 +53,10 @@ export type UptermHost = {
const UptermSessionJsonZ = z.object({
sessionId: z.string().min(1),
host: z.string().min(1),
clientCount: z.number().optional(),
});
type UptermSessionJson = z.infer<typeof UptermSessionJsonZ>;
export function connectionConfigFromUptermSession(
parsed: Pick<UptermSessionJson, 'sessionId' | 'host'>
): SshConnectionConfig | null {
let host = parsed.host;
if (host.includes('://')) {
let url: URL;
try {
url = new URL(host);
} catch {
return null;
}
host = url.port && url.port !== DEFAULT_SSH_PORT ? `${url.hostname}:${url.port}` : url.hostname;
}
if (!host) {
return null;
}
return { type: 'upterm-v1', host, secret: parsed.sessionId };
}
/**
* Strip upterm session secrets out of text before it reaches a log or an error message. upterm
* prints the session id as the userinfo of the connect line it advertises
@@ -196,10 +174,7 @@ async function waitForConnectionConfigAsync(
if (adminSocketPath) {
const session = await readCurrentSessionJsonAsync(uptermPath, adminSocketPath);
if (session) {
const connectionConfig = connectionConfigFromUptermSession(session);
if (connectionConfig) {
return connectionConfig;
}
return { type: 'upterm-v1', secret: session.sessionId };
}
}
if (Date.now() >= deadline) {
@@ -0,0 +1,61 @@
import { SystemError } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { existsSync } from 'node:fs';
import fs from 'node:fs/promises';
import { setTimeout as sleepAsync } from 'node:timers/promises';
const LAUNCHER_STATE_FILE = '/tmp/expo-simulator-startup.json';
const STARTUP_SCRIPT = '/usr/local/share/vm-tools/startup.sh';
/**
* The worker stops warmup in the background for a job that does not use the warm Simulator. Wait
* here, before the job changes a Simulator, until the launcher records that its cleanup finished.
*/
export async function waitForWarmSimulatorCleanupAsync(
env: NodeJS.ProcessEnv,
{
timeoutMs = 150_000,
pollMs = 250,
signal,
}: { timeoutMs?: number; pollMs?: number; signal?: AbortSignal } = {}
): Promise<void> {
if (process.platform !== 'darwin' || env.EAS_USE_WARM_SIMULATOR === '1') {
return;
}
const deadline = Date.now() + timeoutMs;
let bootUuid: string | undefined;
for (;;) {
signal?.throwIfAborted();
let state: { status?: unknown; error?: unknown; bootUuid?: unknown };
try {
state = JSON.parse(await fs.readFile(LAUNCHER_STATE_FILE, 'utf8'));
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT' && !existsSync(STARTUP_SCRIPT)) {
return;
}
// Warmup may still be loading; only disabled confirms cleanup on an enabled VM.
state = {};
}
if (state.status === 'disabled' && typeof state.bootUuid === 'string') {
bootUuid ??= (
await spawn('/usr/sbin/sysctl', ['-n', 'kern.bootsessionuuid'], {
signal,
timeout: Math.max(1, deadline - Date.now()),
})
).stdout.trim();
if (!bootUuid) {
throw new SystemError('Could not read the current VM boot ID.');
}
if (state.bootUuid === bootUuid) {
return;
}
}
if (Date.now() >= deadline) {
throw new SystemError(
`Warm Simulator cleanup did not finish${typeof state.error === 'string' ? ` (${state.error})` : ''}; the Simulator cannot be used.`
);
}
// The signal is checked at each poll, so a cancelled job stops within one interval.
await sleepAsync(pollMs);
}
}
@@ -1,5 +1,9 @@
# create-eas-build-function
## 27.3.0
No changes in this release.
## 27.2.0
No changes in this release.
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "27.2.0",
"version": "27.3.0",
"description": "Create functions for use in EAS Build custom builds.",
"keywords": [
"expo",
@@ -35,7 +35,7 @@
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/steps": "27.2.0"
"@expo/steps": "27.3.0"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
+4
View File
@@ -1,5 +1,9 @@
# @expo/downloader
## 27.3.0
No changes in this release.
## 27.2.0
No changes in this release.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/downloader",
"version": "27.2.0",
"version": "27.3.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -1,5 +1,9 @@
# eas-build-cache-provider
## 27.3.0
No changes in this release.
## 27.2.0
No changes in this release.
@@ -1,6 +1,6 @@
{
"name": "eas-build-cache-provider",
"version": "27.2.0",
"version": "27.3.0",
"description": "A build cache provider plugin for the Expo CLI",
"homepage": "https://github.com/expo/eas-cli",
"bugs": "https://github.com/expo/eas-cli/issues",
+6
View File
@@ -1,5 +1,11 @@
# @expo/eas-build-job
## 27.3.0
### Minor Changes
- Add optional Observe error context to workflow jobs. ([#4787](https://github.com/expo/eas-cli/pull/4787) by [@AbbanMustafa](https://github.com/AbbanMustafa))
## 27.2.0
No changes in this release.
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-build-job",
"version": "27.2.0",
"version": "27.3.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "MIT",
"author": "Expo <[email protected]>",
@@ -25,9 +25,9 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/logger": "27.2.0",
"@expo/logger": "27.3.0",
"@expo/results": "1.0.0",
"@expo/turtle-spawn": "27.2.0",
"@expo/turtle-spawn": "27.3.0",
"joi": "^17.13.1",
"semver": "^7.6.2",
"zod": "^4.3.5"
@@ -0,0 +1,95 @@
import { ArchiveSourceType, BuildTrigger, StaticWorkflowInterpolationContextZ } from '../common';
import { Generic } from '../generic';
function createContext() {
return {
after: {},
needs: {},
workflow: {
id: 'workflow-id',
name: 'Investigate error',
filename: 'observe.yml',
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
},
app: { id: '123e4567-e89b-42d3-a456-426614174000', slug: 'example' },
account: { id: 'account-id', name: 'example' },
observe: {
event: {
id: '123e4567-e89b-42d3-a456-426614174001',
type: 'new_error' as const,
occurredAt: '2026-10-29T12:00:00.000Z',
appId: '123e4567-e89b-42d3-a456-426614174000',
fingerprint: { value: 'error-fingerprint', version: 'v1' },
severity: 'fatal' as const,
environment: 'production',
platform: 'ios' as const,
deviceOs: 'iOS',
exception: { type: 'Error', message: 'Example error' },
},
},
};
}
describe('Observe workflow context', () => {
it('preserves the event when validating a generic job', () => {
const context = createContext();
const job = {
projectArchive: { type: ArchiveSourceType.GIT, gitCommitHash: 'abc123', gitRef: null },
steps: [{ run: 'echo "${{ observe.event.exception.message }}"', shell: 'sh' }],
secrets: { robotAccessToken: 'token', environmentSecrets: [] },
expoDevUrl: context.workflow.url,
builderEnvironment: { image: 'ubuntu', env: {} },
triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION,
appId: context.app.id,
initiatingUserId: '123e4567-e89b-42d3-a456-426614174002',
workflowInterpolationContext: context,
};
const parsed = Generic.JobZ.parse(job);
expect(parsed.workflowInterpolationContext).toEqual(context);
});
it('accepts nullable event metadata and an omitted device OS', () => {
const context = createContext();
const nullableContext = {
...context,
observe: {
event: {
...context.observe.event,
fingerprint: { value: 'error-fingerprint', version: null },
severity: 'error',
environment: null,
platform: null,
deviceOs: undefined,
exception: { type: null, message: null },
},
},
};
expect(StaticWorkflowInterpolationContextZ.parse(nullableContext)).toEqual(nullableContext);
});
it('preserves additional event fields from a newer producer', () => {
const context = createContext();
const extendedContext = {
...context,
observe: {
...context.observe,
event: {
...context.observe.event,
additionalMetadata: 'future value',
exception: { ...context.observe.event.exception, stacktrace: 'future stack trace' },
},
},
};
expect(StaticWorkflowInterpolationContextZ.parse(extendedContext)).toEqual(extendedContext);
});
it('rejects an Observe context without an event', () => {
expect(
StaticWorkflowInterpolationContextZ.safeParse({ ...createContext(), observe: {} }).success
).toBe(false);
});
});
+19
View File
@@ -344,6 +344,24 @@ const SentryContextZ = z.looseObject({
resolution_type: z.string().optional(),
});
const ObserveContextZ = z.looseObject({
event: z.looseObject({
id: z.uuid(),
type: z.literal('new_error'),
occurredAt: z.iso.datetime(),
appId: z.uuid(),
fingerprint: z.looseObject({
value: z.string().min(1),
version: z.string().min(1).nullable(),
}),
severity: z.enum(['fatal', 'error']),
environment: z.string().nullable(),
platform: z.enum(['ios', 'android']).nullable(),
deviceOs: z.string().optional(),
exception: z.looseObject({ type: z.string().nullable(), message: z.string().nullable() }),
}),
});
export const StaticWorkflowInterpolationContextZ = z.object({
after: z.record(
z.string(),
@@ -381,6 +399,7 @@ export const StaticWorkflowInterpolationContextZ = z.object({
app_store_connect: AppStoreConnectContextZ.optional(),
// We need to .optional() to support jobs that are not triggered by a Sentry event.
sentry: SentryContextZ.optional(),
observe: ObserveContextZ.optional(),
});
export type StaticWorkflowInterpolationContext = z.infer<
+7
View File
@@ -1,5 +1,12 @@
# eas-cli
## 27.3.0
### Patch Changes
- Bump `@expo/apple-utils` to `2.3.0` to fix iOS capability sync when App Store Connect rejects enabling, disabling or changing a capability. ([#4790](https://github.com/expo/eas-cli/pull/4790) by [@douglowder](https://github.com/douglowder))
- Keep already linked App Groups, Merchant IDs and iCloud containers when capability sync links another identifier. ([#4584](https://github.com/expo/eas-cli/pull/4584) by [@ceydaakin](https://github.com/ceydaakin))
## 27.2.0
### Minor Changes
+146 -146
View File
File diff suppressed because it is too large. Load diff
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "27.2.0",
"version": "27.3.0",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -44,16 +44,16 @@
"copy-new-templates": "rimraf build/commandUtils/new/templates && mkdir -p build/commandUtils/new && cp -r src/commandUtils/new/templates build/commandUtils/new"
},
"dependencies": {
"@expo/apple-utils": "2.2.1",
"@expo/apple-utils": "2.3.0",
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/eas-build-job": "27.2.0",
"@expo/eas-json": "27.2.0",
"@expo/eas-build-job": "27.3.0",
"@expo/eas-json": "27.3.0",
"@expo/env": "^1.0.0",
"@expo/experimentation": "0.0.5",
"@expo/json-file": "8.3.3",
"@expo/logger": "27.2.0",
"@expo/logger": "27.3.0",
"@expo/multipart-body-parser": "2.0.0",
"@expo/osascript": "2.1.4",
"@expo/package-manager": "1.9.10",
@@ -65,7 +65,7 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "27.2.0",
"@expo/steps": "27.3.0",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
@@ -187,6 +187,69 @@ describe(syncCapabilityIdentifiersForEntitlementsAsync, () => {
expect(bundleId.updateBundleIdCapabilityAsync).toHaveBeenCalledTimes(1);
});
it(`keeps already linked capability identifiers when linking another one`, async () => {
const Apple = require('@expo/apple-utils');
mockCapabilities(Apple);
const bundleId = {
context: {},
updateBundleIdCapabilityAsync: jest.fn(),
id: 'XXX',
} as any;
const result = await syncCapabilityIdentifiersForEntitlementsAsync(bundleId, {
'com.apple.security.application-groups': ['group.expo', 'group.bacon'],
});
expect(result).toStrictEqual({
created: ['group.bacon'],
linked: ['group.bacon'],
});
expect(bundleId.updateBundleIdCapabilityAsync).toHaveBeenCalledTimes(1);
expect(bundleId.updateBundleIdCapabilityAsync).toHaveBeenLastCalledWith([
{
capabilityType: 'APP_GROUPS',
option: 'ON',
relationships: {
appGroups: ['XXX-group-1', 'XXX-group-2'],
},
},
]);
});
it(`keeps already linked capability identifiers when linking an existing unlinked one`, async () => {
const Apple = require('@expo/apple-utils');
mockCapabilities(Apple);
Apple.AppGroup.getAsync.mockResolvedValue([
mockItems.appGroup.expo,
{ id: 'XXX-group-2', attributes: { identifier: 'group.bacon' } },
]);
const bundleId = {
context: {},
updateBundleIdCapabilityAsync: jest.fn(),
id: 'XXX',
} as any;
const result = await syncCapabilityIdentifiersForEntitlementsAsync(bundleId, {
'com.apple.security.application-groups': ['group.expo', 'group.bacon'],
});
expect(result).toStrictEqual({
created: [],
linked: ['group.bacon'],
});
expect(Apple.AppGroup.createAsync).not.toHaveBeenCalled();
expect(bundleId.updateBundleIdCapabilityAsync).toHaveBeenCalledTimes(1);
expect(bundleId.updateBundleIdCapabilityAsync).toHaveBeenLastCalledWith([
{
capabilityType: 'APP_GROUPS',
option: 'ON',
relationships: {
appGroups: ['XXX-group-1', 'XXX-group-2'],
},
},
]);
});
it(`doesn't perform duplicate CapabilityModel.getAsync calls`, async () => {
const Apple = require('@expo/apple-utils');
mockCapabilities(Apple);
@@ -94,7 +94,8 @@ export async function syncCapabilityIdentifiersForEntitlementsAsync(
(remoteLinkedIds?.[CapabilityModel.type] ?? []).map(model => model.id)
);
// A list of server IDs for linking.
// A list of server IDs for linking. The update replaces the linked identifiers,
// so this includes the ones that are already linked.
const capabilityIdOpaqueIds: string[] = [];
// Iterate through all the local IDs and see if they exist on the server.
@@ -106,8 +107,8 @@ export async function syncCapabilityIdentifiersForEntitlementsAsync(
if (!alreadyLinkedOpaqueIds.has(remoteIdModel.id)) {
// Link the existing identifier to this bundle.
linkedIds.push(remoteIdModel.attributes.identifier);
capabilityIdOpaqueIds.push(remoteIdModel.id);
}
capabilityIdOpaqueIds.push(remoteIdModel.id);
continue;
}
@@ -133,7 +134,9 @@ export async function syncCapabilityIdentifiersForEntitlementsAsync(
capabilityIdOpaqueIds.push(remoteIdModel.id);
}
if (capabilityIdOpaqueIds.length) {
// The update replaces the linked identifiers, so it is sent only when one of them is not
// linked yet. An identifier removed from the entitlements therefore stays linked.
if (capabilityIdOpaqueIds.some(id => !alreadyLinkedOpaqueIds.has(id))) {
updateRequest.push({
capabilityType: classifier.capability,
option: CapabilityTypeOption.ON,
+4
View File
@@ -1,5 +1,9 @@
# @expo/eas-json
## 27.3.0
No changes in this release.
## 27.2.0
No changes in this release.
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-json",
"version": "27.2.0",
"version": "27.3.0",
"description": "A library for interacting with eas.json",
"homepage": "https://github.com/expo/eas-cli",
"bugs": "https://github.com/expo/eas-cli/issues",
@@ -32,7 +32,7 @@
},
"dependencies": {
"@babel/code-frame": "7.23.5",
"@expo/eas-build-job": "27.2.0",
"@expo/eas-build-job": "27.3.0",
"chalk": "4.1.2",
"env-string": "1.0.1",
"fs-extra": "11.2.0",
+4
View File
@@ -1,5 +1,9 @@
# eas-cli-local-build-plugin
## 27.3.0
No changes in this release.
## 27.2.0
No changes in this release.
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli-local-build-plugin",
"version": "27.2.0",
"version": "27.3.0",
"description": "Tool for running EAS compatible builds on a local machine.",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
@@ -26,12 +26,12 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/build-tools": "27.2.0",
"@expo/build-tools": "27.3.0",
"@expo/bunyan": "^4.0.1",
"@expo/eas-build-job": "27.2.0",
"@expo/logger": "27.2.0",
"@expo/eas-build-job": "27.3.0",
"@expo/logger": "27.3.0",
"@expo/spawn-async": "^1.7.2",
"@expo/turtle-spawn": "27.2.0",
"@expo/turtle-spawn": "27.3.0",
"chalk": "4.1.2",
"env-paths": "^2.2.1",
"fs-extra": "^11.2.0",
+4
View File
@@ -1,5 +1,9 @@
# @expo/logger
## 27.3.0
No changes in this release.
## 27.2.0
No changes in this release.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/logger",
"version": "27.2.0",
"version": "27.3.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
+4
View File
@@ -1,5 +1,9 @@
# @expo/simulator-launcher
## 27.3.0
No changes in this release.
## 27.2.0
### Minor Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/simulator-launcher",
"version": "27.2.0",
"version": "27.3.0",
"description": "Launch and claim Simulator sessions on EAS workers",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
+4
View File
@@ -1,5 +1,9 @@
# @expo/steps
## 27.3.0
No changes in this release.
## 27.2.0
No changes in this release.
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/steps",
"version": "27.2.0",
"version": "27.3.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -27,8 +27,8 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/eas-build-job": "27.2.0",
"@expo/logger": "27.2.0",
"@expo/eas-build-job": "27.3.0",
"@expo/logger": "27.3.0",
"@expo/spawn-async": "^1.7.2",
"arg": "^5.0.2",
"fast-glob": "^3.3.2",
+4
View File
@@ -1,5 +1,9 @@
# @expo/template-file
## 27.3.0
No changes in this release.
## 27.2.0
No changes in this release.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/template-file",
"version": "27.2.0",
"version": "27.3.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
+4
View File
@@ -1,5 +1,9 @@
# @expo/turtle-spawn
## 27.3.0
No changes in this release.
## 27.2.0
No changes in this release.
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/turtle-spawn",
"version": "27.2.0",
"version": "27.3.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -25,7 +25,7 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/logger": "27.2.0",
"@expo/logger": "27.3.0",
"@expo/spawn-async": "^1.7.2"
},
"devDependencies": {
+4
View File
@@ -1,5 +1,9 @@
# @expo/vm-tools
## 27.3.0
No changes in this release.
## 27.2.0
### Minor Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/vm-tools",
"version": "27.2.0",
"version": "27.3.0",
"description": "VM startup script loader",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
+7
View File
@@ -1,5 +1,12 @@
# @expo/worker
## 27.3.0
### Patch Changes
- Mask secrets in every record the worker process writes to its stdout: the build log records and the worker's own records. ([#4782](https://github.com/expo/eas-cli/pull/4782) by [@hSATAC](https://github.com/hSATAC))
- Stop warmup in the background for jobs that do not use the warm Simulator, so job setup does not wait for it. Before build-tools boots or clones a Simulator, it waits until the launcher's state file records that cleanup finished, and fails if cleanup does not finish in time. ([#4721](https://github.com/expo/eas-cli/pull/4721) by [@gwdp](https://github.com/gwdp))
## 27.2.0
### Patch Changes
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/worker",
"version": "27.2.0",
"version": "27.3.0",
"private": true,
"scripts": {
"start": "yarn watch",
@@ -19,17 +19,17 @@
},
"dependencies": {
"@babel/core": "7.24.7",
"@expo/build-tools": "27.2.0",
"@expo/build-tools": "27.3.0",
"@expo/config-plugins": "55.0.7",
"@expo/config-types": "55.0.5",
"@expo/eas-build-job": "27.2.0",
"@expo/logger": "27.2.0",
"@expo/eas-build-job": "27.3.0",
"@expo/logger": "27.3.0",
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "27.2.0",
"@expo/template-file": "27.2.0",
"@expo/turtle-spawn": "27.2.0",
"@expo/steps": "27.3.0",
"@expo/template-file": "27.3.0",
"@expo/turtle-spawn": "27.3.0",
"@hapi/boom": "10.0.1",
"check-disk-space": "3.4.0",
"fast-glob": "3.3.2",
@@ -4,9 +4,11 @@ import { ManagedArtifactType, Platform, Workflow } from '@expo/eas-build-job';
import config from '../config';
import { createBuildContext } from '../context';
import { getBuildEnv } from '../env';
import { maybeStopWarmSimulatorAsync } from '../warmSimulator';
import { prepareRuntimeEnvironmentConfigFiles } from '../runtimeEnvironment';
import { uploadSourceMapAsync, uploadWorkflowArtifactAsync } from '../upload';
jest.mock('../warmSimulator');
jest.mock('../env', () => ({
getBuildEnv: jest.fn(() => ({
__API_SERVER_URL: 'http://api.expo.test',
@@ -44,6 +46,8 @@ describe(createBuildContext.name, () => {
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(maybeStopWarmSimulatorAsync).mockReset().mockResolvedValue(undefined);
jest.mocked(prepareRuntimeEnvironmentConfigFiles).mockReset().mockResolvedValue();
jest.spyOn(RuntimeSettings, 'loadAsync').mockResolvedValue();
});
@@ -51,6 +55,70 @@ describe(createBuildContext.name, () => {
jest.restoreAllMocks();
});
it('starts shutdown after runtime settings without blocking setup or context creation', async () => {
let finishStop!: () => void;
let failStop!: (error: Error) => void;
const stopping = new Promise<void>((resolve, reject) => {
finishStop = resolve;
failStop = reject;
});
jest.mocked(maybeStopWarmSimulatorAsync).mockReturnValue(stopping);
const job = { platform: Platform.IOS } as any;
let created = false;
const creating = createBuildContext({ ...baseOptions, job }).then(ctx => {
created = true;
return ctx;
});
try {
for (let turn = 0; turn < 20 && !created; turn++) {
await Promise.resolve();
}
expect(created).toBe(true);
expect(maybeStopWarmSimulatorAsync).toHaveBeenCalledWith(job);
expect(jest.mocked(RuntimeSettings.loadAsync).mock.invocationCallOrder[0]).toBeLessThan(
jest.mocked(maybeStopWarmSimulatorAsync).mock.invocationCallOrder[0]
);
expect(prepareRuntimeEnvironmentConfigFiles).toHaveBeenCalledTimes(1);
const error = new Error('Late Simulator shutdown failure');
failStop(error);
await new Promise(resolve => setImmediate(resolve));
expect(childLogger.warn).toHaveBeenCalledWith(
{ err: error },
'Could not stop warm Simulator services.'
);
} finally {
finishStop();
await creating;
}
});
it('warns and continues setup when shutdown rejects immediately', async () => {
const error = new Error('Simulator stop failed');
jest.mocked(maybeStopWarmSimulatorAsync).mockRejectedValue(error);
await createBuildContext({ ...baseOptions, job: { platform: Platform.IOS } as any });
expect(childLogger.warn).toHaveBeenCalledWith(
{ err: error },
'Could not stop warm Simulator services.'
);
expect(prepareRuntimeEnvironmentConfigFiles).toHaveBeenCalledTimes(1);
expect(getBuildEnv).toHaveBeenCalledTimes(1);
});
it('preserves an unrelated setup failure even if background shutdown also fails', async () => {
const stopError = new Error('Simulator stop failed');
const setupError = new Error('Could not prepare runtime config');
jest.mocked(maybeStopWarmSimulatorAsync).mockRejectedValue(stopError);
jest.mocked(prepareRuntimeEnvironmentConfigFiles).mockRejectedValueOnce(setupError);
await expect(
createBuildContext({ ...baseOptions, job: { platform: Platform.IOS } as any })
).rejects.toBe(setupError);
expect(childLogger.warn).toHaveBeenCalledWith(
{ err: stopError },
'Could not stop warm Simulator services.'
);
expect(getBuildEnv).not.toHaveBeenCalled();
});
it('loads runtime settings with the job environment before creating build env', async () => {
const env = { EAS_USE_NPM_CACHE: '1' };
+104 -26
View File
@@ -1,5 +1,6 @@
import { TransformCallback, Writable } from 'node:stream';
import { EnvironmentSecretType } from '@expo/eas-build-job';
import { LoggerLevel } from '@expo/logger';
import z from 'zod';
jest.mock('@expo/build-tools', () => {
@@ -8,14 +9,12 @@ jest.mock('@expo/build-tools', () => {
class MockRemoteLoggerStream extends Writable {
public static readonly CompressionMethod = actual.RemoteLoggerStream.CompressionMethod;
public static readonly instances: MockRemoteLoggerStream[] = [];
public readonly writes: any[] = [];
constructor(...args: any[]) {
super({ objectMode: true });
void args;
MockRemoteLoggerStream.instances.push(this);
}
public async init(): Promise<void> {}
@@ -62,41 +61,37 @@ jest.mock('@expo/build-tools', () => {
};
});
import config from '../config';
import { createBuildLoggerWithSecretsFilter } from '../logger';
type BuildToolsMock = {
HttpLogStream: { instances: Array<{ config: Record<string, unknown>; writes: any[] }> };
};
let config: typeof import('../config').default;
let defaultLogger: typeof import('../logger').default;
let createBuildLoggerWithSecretsFilter: typeof import('../logger').createBuildLoggerWithSecretsFilter;
let HttpLogStream: BuildToolsMock['HttpLogStream'];
async function waitForStreamFlush(): Promise<void> {
await new Promise(resolve => setImmediate(resolve));
}
const { HttpLogStream, RemoteLoggerStream } = jest.requireMock('@expo/build-tools') as {
HttpLogStream: {
instances: Array<{
config: Record<string, unknown>;
writes: any[];
}>;
};
RemoteLoggerStream: {
instances: Array<{
writes: any[];
}>;
};
};
describe('logger', () => {
const originalHttpBaseUrl = config.loggers.http.baseUrl;
const originalBuildId = config.buildId;
const originalGcsSignedUploadUrlForLogs = config.loggers.gcs.signedUploadUrlForLogs;
let stdoutRecords: any[];
let stdoutWrite: jest.SpyInstance;
beforeEach(() => {
HttpLogStream.instances.length = 0;
RemoteLoggerStream.instances.length = 0;
jest.resetModules();
config = require('../config').default;
({ default: defaultLogger, createBuildLoggerWithSecretsFilter } = require('../logger'));
({ HttpLogStream } = jest.requireMock('@expo/build-tools'));
stdoutRecords = [];
stdoutWrite = jest.spyOn(process.stdout, 'write').mockImplementation(chunk => {
stdoutRecords.push(JSON.parse(String(chunk)));
return true;
});
});
afterEach(() => {
config.loggers.http.baseUrl = originalHttpBaseUrl;
config.buildId = originalBuildId;
config.loggers.gcs.signedUploadUrlForLogs = originalGcsSignedUploadUrlForLogs;
stdoutWrite.mockRestore();
});
it('obfuscates secrets in logs', async () => {
@@ -183,6 +178,89 @@ describe('logger', () => {
expect(logs.map(log => log.msg)).toEqual(['token: ***************, prefix: ***']);
});
it('masks command output before writing it to process stdout', async () => {
const { logger, addBuildLogMask } = await createBuildLoggerWithSecretsFilter({});
addBuildLogMask('leased-token');
logger.child({ source: 'stdout' }).info('token: leased-token');
await waitForStreamFlush();
expect(stdoutRecords).toMatchObject([{ source: 'stdout', msg: 'token: ************' }]);
});
it('keeps the other streams flowing while stdout is not drained', async () => {
stdoutWrite.mockImplementation(() => false);
const { logger, outputStream } = await createBuildLoggerWithSecretsFilter({});
const logs: any[] = [];
outputStream.pipe(
new Writable({
objectMode: true,
write(chunk: any, _encoding: BufferEncoding, callback: TransformCallback) {
logs.push(chunk);
callback(null, chunk);
},
})
);
const lineCount = 2 * outputStream.readableHighWaterMark;
for (let i = 0; i < lineCount; i++) {
logger.info(`line ${i}`);
}
await waitForStreamFlush();
expect(logs).toHaveLength(lineCount);
});
it("masks the worker's own records on stdout", async () => {
const { addBuildLogMask } = await createBuildLoggerWithSecretsFilter({});
addBuildLogMask('leased-token');
defaultLogger.error({ err: new Error('request with leased-token failed') }, 'Job failed');
await waitForStreamFlush();
expect(stdoutRecords).toMatchObject([
{ msg: 'Job failed', err: { message: 'request with ************ failed' } },
]);
});
it('writes a circular field as unserializable when no secret is registered and keeps going', async () => {
const { logger } = await createBuildLoggerWithSecretsFilter({});
const circular: Record<string, unknown> = {};
circular.self = circular;
logger.info({ circular }, 'first');
logger.info('second');
await waitForStreamFlush();
expect(stdoutRecords).toMatchObject([
{ msg: 'first', circular: '[Unserializable]' },
{ msg: 'second' },
]);
});
it('keeps a deeply nested value when no secret is registered', async () => {
const { logger } = await createBuildLoggerWithSecretsFilter({});
let nested: any = { leaf: 'kept' };
for (let index = 0; index < 30; index++) {
nested = { nested };
}
logger.info({ nested }, 'deep');
await waitForStreamFlush();
expect(JSON.stringify(stdoutRecords[0].nested)).toContain('"leaf":"kept"');
});
it("copies the default logger's level when the build logger is created", async () => {
defaultLogger.level(LoggerLevel.DEBUG);
const { logger } = await createBuildLoggerWithSecretsFilter({});
logger.debug('debug line');
await waitForStreamFlush();
expect(stdoutRecords).toMatchObject([{ msg: 'debug line' }]);
});
describe('structured fields', () => {
async function logWithSecretAsync(
log: (
@@ -0,0 +1,135 @@
import { type Job } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs/promises';
import { maybeStopWarmSimulatorAsync } from '../warmSimulator';
jest.mock('@expo/turtle-spawn');
jest.mock('node:fs/promises', () => ({ access: jest.fn(), readFile: jest.fn() }));
const baseJob = {} as Job;
const originalPlatform = process.platform;
const startup = '/usr/local/share/vm-tools/startup.sh';
const state = '/tmp/expo-simulator-startup.json';
let environment: NodeJS.ProcessEnv;
beforeEach(() => {
environment = { ...process.env };
delete process.env.EAS_OVERRIDE_PACKAGE_MANAGER;
delete process.env.EAS_FALLBACK_PACKAGE_MANAGER;
jest.mocked(fs.access).mockReset().mockResolvedValue();
jest
.mocked(fs.readFile)
.mockReset()
.mockResolvedValue(
JSON.stringify({
bootUuid: 'current-boot',
status: 'disabled',
launcherVersion: '25.3.0',
})
);
jest
.mocked(spawn)
.mockReset()
.mockResolvedValue({ stdout: '' } as Awaited<ReturnType<typeof spawn>>);
Object.defineProperty(process, 'platform', { value: 'darwin' });
});
afterEach(() => {
process.env = environment;
Object.defineProperty(process, 'platform', { value: originalPlatform });
});
it('delegates state and boot handling to one launcher stop command', async () => {
await maybeStopWarmSimulatorAsync({} as Job);
expect(fs.readFile).not.toHaveBeenCalled();
expect(spawn).toHaveBeenCalledTimes(1);
expect(spawn).toHaveBeenCalledWith('bun', ['x', '@expo/simulator-launcher@latest', 'stop'], {
env: expect.objectContaining({ PATH: process.env.PATH }),
stdio: 'pipe',
timeout: 150_000,
});
});
it('waits for the launcher to confirm cleanup', async () => {
let finish!: (value: Awaited<ReturnType<typeof spawn>>) => void;
jest.mocked(spawn).mockReturnValue(
new Promise(resolve => {
finish = resolve;
}) as ReturnType<typeof spawn>
);
let finished = false;
const stopping = maybeStopWarmSimulatorAsync({} as Job).then(() => {
finished = true;
});
for (let turn = 0; turn < 20 && !finish; turn++) {
await Promise.resolve();
}
expect(finish).toBeDefined();
expect(finished).toBe(false);
finish({ stdout: '' } as Awaited<ReturnType<typeof spawn>>);
await stopping;
expect(finished).toBe(true);
});
it.each(['cleanup failed', "Could not find an existing 'simulator-launcher' binary --no-install"])(
'propagates stop failure without retrying: %s',
async stderr => {
const error = Object.assign(new Error(stderr), { status: 1, stdout: '', stderr });
jest.mocked(spawn).mockRejectedValueOnce(error);
await expect(maybeStopWarmSimulatorAsync({} as Job)).rejects.toBe(error);
expect(spawn).toHaveBeenCalledTimes(1);
}
);
it('does nothing on non-Darwin', async () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
await maybeStopWarmSimulatorAsync({} as Job);
expect(fs.access).not.toHaveBeenCalled();
expect(spawn).not.toHaveBeenCalled();
});
it('keeps warmup only for the job opt-in, independently of the host flag', async () => {
await maybeStopWarmSimulatorAsync({
...baseJob,
builderEnvironment: { image: 'test-image', env: { EAS_USE_WARM_SIMULATOR: '1' } },
});
expect(fs.access).not.toHaveBeenCalled();
expect(spawn).not.toHaveBeenCalled();
process.env.EAS_USE_WARM_SIMULATOR = '1';
await maybeStopWarmSimulatorAsync({} as Job);
expect(spawn).toHaveBeenCalledTimes(1);
});
it.each([startup, state])('stops when the image has only %s', async present => {
jest.mocked(fs.access).mockImplementation(async file => {
if (file !== present) {
throw Object.assign(new Error('missing'), { code: 'ENOENT' });
}
});
await maybeStopWarmSimulatorAsync({} as Job);
expect(spawn).toHaveBeenCalledTimes(1);
});
it('makes no package-manager call on an image with neither warmup file', async () => {
jest.mocked(fs.access).mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }));
await maybeStopWarmSimulatorAsync({} as Job);
expect(spawn).not.toHaveBeenCalled();
});
it.each([startup, state])('fails closed if checking %s fails', async inaccessible => {
const error = Object.assign(new Error('permission denied'), { code: 'EACCES' });
jest.mocked(fs.access).mockImplementation(async file => {
if (file === inaccessible) {
throw error;
}
});
await expect(maybeStopWarmSimulatorAsync({} as Job)).rejects.toBe(error);
expect(spawn).not.toHaveBeenCalled();
});
it('preserves the job package-manager override and environment', async () => {
process.env.EAS_OVERRIDE_PACKAGE_MANAGER = 'bun';
await maybeStopWarmSimulatorAsync({
...baseJob,
builderEnvironment: { image: 'test-image', env: { EAS_OVERRIDE_PACKAGE_MANAGER: 'npm' } },
});
expect(spawn).toHaveBeenCalledWith('npx', ['--yes', '@expo/simulator-launcher@latest', 'stop'], {
env: expect.objectContaining({ EAS_OVERRIDE_PACKAGE_MANAGER: 'npm', PATH: process.env.PATH }),
stdio: 'pipe',
timeout: 150_000,
});
});
+5
View File
@@ -8,6 +8,7 @@ import config from './config';
import { getBuildEnv } from './env';
import { Analytics } from './external/analytics';
import { uploadXcodeBuildLogs } from './ios/xcodeLogs';
import { maybeStopWarmSimulatorAsync } from './warmSimulator';
import { prepareRuntimeEnvironmentConfigFiles } from './runtimeEnvironment';
import sentry from './sentry';
import {
@@ -42,6 +43,10 @@ export async function createBuildContext<TJob extends Job>({
environment: config.env,
env: job.builderEnvironment?.env,
});
// Build-tools waits for the launcher's own cleanup record before it changes a Simulator.
void maybeStopWarmSimulatorAsync(job).catch(err => {
childLogger.warn({ err }, 'Could not stop warm Simulator services.');
});
await prepareRuntimeEnvironmentConfigFiles();
const env = getBuildEnv({ job, projectId, metadata, buildId });
+33 -31
View File
@@ -7,11 +7,6 @@ import config from './config';
import { maybeStringBase64Decode, simpleSecretsWhitelist } from './secrets';
import { uuidv7 } from 'uuidv7';
const defaultLogger = createLogger({
name: config.loggers.base.name,
level: LoggerLevel.INFO,
});
const MAX_LINES_IN_BUFFER = 100;
interface CachedLog {
@@ -90,10 +85,7 @@ function createTransformStream(secretPatterns: ReadonlySet<string>): Transform {
const chunk: Record<string, unknown> = {};
for (const [key, value] of Object.entries(rest)) {
// Secrets can be in any field, such as a serialized error's message and stack.
chunk[key] =
secretPatterns.size === 0 || UNMASKED_FIELDS.has(key)
? value
: maskSecretsInValue(value, secretPatterns);
chunk[key] = UNMASKED_FIELDS.has(key) ? value : maskSecretsInValue(value, secretPatterns);
}
// Add logId to each log.
chunk.logId = uuidv7();
@@ -127,7 +119,7 @@ function maskSecretsInValue(value: unknown, secrets: ReadonlySet<string>): unkno
} catch {
return '[Unserializable]';
}
return maskSecretsInJsonValue(json, secrets, 0);
return secrets.size === 0 ? json : maskSecretsInJsonValue(json, secrets, 0);
}
function maskSecretsInJsonValue(
@@ -158,15 +150,38 @@ function maskSecretsInJsonValue(
);
}
function createDiscardStream(): Writable {
function createStdoutStream(): Writable {
return new Writable({
objectMode: true,
write(_chunk: any, _encoding: BufferEncoding, callback: (error?: Error | null) => void) {
write(chunk: any, _encoding: BufferEncoding, callback: (error?: Error | null) => void) {
process.stdout.write(`${JSON.stringify(chunk)}\n`);
callback(null);
},
});
}
const secretPatterns = new Set<string>();
function addBuildLogMask(secret: string): void {
for (const pattern of [secret, maybeStringBase64Decode(secret)]) {
if (pattern && pattern.length > 1 && !simpleSecretsWhitelist.includes(pattern)) {
secretPatterns.add(pattern);
}
}
}
function createStdoutMaskingStream(): Transform {
const transformStream = createTransformStream(secretPatterns);
transformStream.pipe(createStdoutStream());
return transformStream;
}
const defaultLogger = createLogger({
name: config.loggers.base.name,
level: LoggerLevel.INFO,
streams: [{ type: 'raw', stream: createStdoutMaskingStream(), reemitErrorEvents: true }],
});
export async function createBuildLoggerWithSecretsFilter(secrets: Job['secrets']): Promise<{
logger: bunyan;
addBuildLogMask: (secret: string) => void;
@@ -174,29 +189,16 @@ export async function createBuildLoggerWithSecretsFilter(secrets: Job['secrets']
logBuffer: WorkerLogBuffer;
outputStream: Readable;
}> {
const buildLogger = defaultLogger.child({ phase: BuildPhase.UNKNOWN });
const secretPatterns = new Set<string>();
const addBuildLogMask = (secret: string): void => {
for (const pattern of [secret, maybeStringBase64Decode(secret)]) {
if (pattern && pattern.length > 1 && !simpleSecretsWhitelist.includes(pattern)) {
secretPatterns.add(pattern);
}
}
};
for (const { value } of secrets?.environmentSecrets ?? []) {
addBuildLogMask(value);
}
const transformStream = createTransformStream(secretPatterns);
const discardStream = createDiscardStream();
transformStream.pipe(discardStream);
buildLogger.addStream({
type: 'raw',
stream: transformStream,
reemitErrorEvents: true,
level: buildLogger.level(),
const transformStream = createStdoutMaskingStream();
const buildLogger = createLogger({
name: config.loggers.base.name,
level: defaultLogger.level(),
phase: BuildPhase.UNKNOWN,
streams: [{ type: 'raw', stream: transformStream, reemitErrorEvents: true }],
});
let remoteLoggerStream: RemoteLoggerStream | null = null;
+40
View File
@@ -0,0 +1,40 @@
import {
PackageManager,
resolveConfiguredPackageManager,
resolvePackageExec,
} from '@expo/build-tools/dist/utils/packageManager';
import { type Job } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs/promises';
/** Request warmup shutdown unless this job opts into warm Simulator adoption. */
export async function maybeStopWarmSimulatorAsync(job: Job): Promise<void> {
if (
process.platform !== 'darwin' ||
job.builderEnvironment?.env?.EAS_USE_WARM_SIMULATOR === '1'
) {
return;
}
const warmupFiles = await Promise.all(
['/usr/local/share/vm-tools/startup.sh', '/tmp/expo-simulator-startup.json'].map(file =>
fs.access(file).then(
() => true,
error => {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return false;
}
throw error;
}
)
)
);
if (!warmupFiles.some(Boolean)) {
return;
}
const env = { ...process.env, ...job.builderEnvironment?.env };
const { command, args } = resolvePackageExec(
resolveConfiguredPackageManager(env, PackageManager.BUN),
['@expo/simulator-launcher@latest', 'stop']
);
await spawn(command, args, { env, stdio: 'pipe', timeout: 150_000 });
}
-1
View File
@@ -47,7 +47,6 @@ function handleConnection(socket: WebSocket, service: BuildService): void {
wsServer.onMessage((message: LauncherMessage.Message) => {
try {
logger.debug({ message }, 'WebSocket message received');
const { handled } = handleMessage(service, message) || { handled: false };
if (!handled) {
throw new Error(
Generated
+40 -40
View File
@@ -2506,33 +2506,33 @@ __metadata:
languageName: node
linkType: hard
"@expo/apple-utils@npm:2.2.1":
version: 2.2.1
resolution: "@expo/apple-utils@npm:2.2.1"
"@expo/apple-utils@npm:2.3.0":
version: 2.3.0
resolution: "@expo/apple-utils@npm:2.3.0"
bin:
apple-utils: bin.js
checksum: 10c0/c23cac8b1b96d42dfc5f7c63a1fe1c0cb19d097b9357fd2f545c14e6cfa98725f0fca1334446b3112e66b6822ffd45ad52cb8eb4bfd29ab9f8e462abf1593cc2
checksum: 10c0/150edaa141c220c22e14791bd16f97dd7906f9f6441e3e0475eb6327a89df7e57e67beb911b0edfed72e0daedc6000c1f1e40141d485e284ce7b9a62f5060638
languageName: node
linkType: hard
"@expo/build-tools@npm:27.2.0, @expo/build-tools@workspace:packages/build-tools":
"@expo/build-tools@npm:27.3.0, @expo/build-tools@workspace:packages/build-tools":
version: 0.0.0-use.local
resolution: "@expo/build-tools@workspace:packages/build-tools"
dependencies:
"@expo/config": "npm:55.0.10"
"@expo/config-plugins": "npm:55.0.7"
"@expo/downloader": "npm:27.2.0"
"@expo/eas-build-job": "npm:27.2.0"
"@expo/downloader": "npm:27.3.0"
"@expo/eas-build-job": "npm:27.3.0"
"@expo/env": "npm:^0.4.0"
"@expo/logger": "npm:27.2.0"
"@expo/logger": "npm:27.3.0"
"@expo/package-manager": "npm:1.9.10"
"@expo/plist": "npm:^0.3.5"
"@expo/repack-app": "npm:~0.9.0"
"@expo/results": "npm:^1.0.0"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:27.2.0"
"@expo/template-file": "npm:27.2.0"
"@expo/turtle-spawn": "npm:27.2.0"
"@expo/steps": "npm:27.3.0"
"@expo/template-file": "npm:27.3.0"
"@expo/turtle-spawn": "npm:27.3.0"
"@expo/xcpretty": "npm:^4.4.7"
"@ngrok/ngrok": "npm:1.7.0"
"@sentry/node": "npm:7.77.0"
@@ -2768,7 +2768,7 @@ __metadata:
languageName: node
linkType: hard
"@expo/downloader@npm:27.2.0, @expo/downloader@workspace:packages/downloader":
"@expo/downloader@npm:27.3.0, @expo/downloader@workspace:packages/downloader":
version: 0.0.0-use.local
resolution: "@expo/downloader@workspace:packages/downloader"
dependencies:
@@ -2784,13 +2784,13 @@ __metadata:
languageName: unknown
linkType: soft
"@expo/eas-build-job@npm:27.2.0, @expo/eas-build-job@workspace:packages/eas-build-job":
"@expo/eas-build-job@npm:27.3.0, @expo/eas-build-job@workspace:packages/eas-build-job":
version: 0.0.0-use.local
resolution: "@expo/eas-build-job@workspace:packages/eas-build-job"
dependencies:
"@expo/logger": "npm:27.2.0"
"@expo/logger": "npm:27.3.0"
"@expo/results": "npm:1.0.0"
"@expo/turtle-spawn": "npm:27.2.0"
"@expo/turtle-spawn": "npm:27.3.0"
"@types/jest": "npm:^29.5.12"
"@types/node": "npm:22.20.5"
jest: "npm:^29.7.0"
@@ -2803,12 +2803,12 @@ __metadata:
languageName: unknown
linkType: soft
"@expo/eas-json@npm:27.2.0, @expo/eas-json@workspace:packages/eas-json":
"@expo/eas-json@npm:27.3.0, @expo/eas-json@workspace:packages/eas-json":
version: 0.0.0-use.local
resolution: "@expo/eas-json@workspace:packages/eas-json"
dependencies:
"@babel/code-frame": "npm:7.23.5"
"@expo/eas-build-job": "npm:27.2.0"
"@expo/eas-build-job": "npm:27.3.0"
"@tsconfig/node22": "npm:22.0.6"
"@types/babel__code-frame": "npm:7.0.3"
"@types/fs-extra": "npm:11.0.4"
@@ -2931,7 +2931,7 @@ __metadata:
languageName: node
linkType: hard
"@expo/logger@npm:27.2.0, @expo/logger@workspace:packages/logger":
"@expo/logger@npm:27.3.0, @expo/logger@workspace:packages/logger":
version: 0.0.0-use.local
resolution: "@expo/logger@workspace:packages/logger"
dependencies:
@@ -3147,12 +3147,12 @@ __metadata:
languageName: node
linkType: hard
"@expo/steps@npm:27.2.0, @expo/steps@workspace:packages/steps":
"@expo/steps@npm:27.3.0, @expo/steps@workspace:packages/steps":
version: 0.0.0-use.local
resolution: "@expo/steps@workspace:packages/steps"
dependencies:
"@expo/eas-build-job": "npm:27.2.0"
"@expo/logger": "npm:27.2.0"
"@expo/eas-build-job": "npm:27.3.0"
"@expo/logger": "npm:27.3.0"
"@expo/spawn-async": "npm:^1.7.2"
"@jest/globals": "npm:^29.7.0"
"@types/jest": "npm:^29.5.12"
@@ -3178,7 +3178,7 @@ __metadata:
languageName: unknown
linkType: soft
"@expo/template-file@npm:27.2.0, @expo/template-file@workspace:packages/template-file":
"@expo/template-file@npm:27.3.0, @expo/template-file@workspace:packages/template-file":
version: 0.0.0-use.local
resolution: "@expo/template-file@workspace:packages/template-file"
dependencies:
@@ -3201,11 +3201,11 @@ __metadata:
languageName: node
linkType: hard
"@expo/turtle-spawn@npm:27.2.0, @expo/turtle-spawn@workspace:packages/turtle-spawn":
"@expo/turtle-spawn@npm:27.3.0, @expo/turtle-spawn@workspace:packages/turtle-spawn":
version: 0.0.0-use.local
resolution: "@expo/turtle-spawn@workspace:packages/turtle-spawn"
dependencies:
"@expo/logger": "npm:27.2.0"
"@expo/logger": "npm:27.3.0"
"@expo/spawn-async": "npm:^1.7.2"
"@types/jest": "npm:^29.5.12"
"@types/node": "npm:22.20.5"
@@ -3236,17 +3236,17 @@ __metadata:
resolution: "@expo/worker@workspace:packages/worker"
dependencies:
"@babel/core": "npm:7.24.7"
"@expo/build-tools": "npm:27.2.0"
"@expo/build-tools": "npm:27.3.0"
"@expo/config-plugins": "npm:55.0.7"
"@expo/config-types": "npm:55.0.5"
"@expo/eas-build-job": "npm:27.2.0"
"@expo/logger": "npm:27.2.0"
"@expo/eas-build-job": "npm:27.3.0"
"@expo/logger": "npm:27.3.0"
"@expo/results": "npm:1.0.0"
"@expo/rudder-sdk-node": "npm:1.1.1"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:27.2.0"
"@expo/template-file": "npm:27.2.0"
"@expo/turtle-spawn": "npm:27.2.0"
"@expo/steps": "npm:27.3.0"
"@expo/template-file": "npm:27.3.0"
"@expo/turtle-spawn": "npm:27.3.0"
"@hapi/boom": "npm:10.0.1"
"@types/babel__core": "npm:7.20.5"
"@types/fs-extra": "npm:11.0.4"
@@ -10130,7 +10130,7 @@ __metadata:
resolution: "create-eas-build-function@workspace:packages/create-eas-build-function"
dependencies:
"@expo/package-manager": "npm:1.9.10"
"@expo/steps": "npm:27.2.0"
"@expo/steps": "npm:27.3.0"
"@jest/globals": "npm:^29.7.0"
"@types/jest": "npm:^29.5.12"
"@types/node": "npm:22.20.5"
@@ -10714,12 +10714,12 @@ __metadata:
version: 0.0.0-use.local
resolution: "eas-cli-local-build-plugin@workspace:packages/local-build-plugin"
dependencies:
"@expo/build-tools": "npm:27.2.0"
"@expo/build-tools": "npm:27.3.0"
"@expo/bunyan": "npm:^4.0.1"
"@expo/eas-build-job": "npm:27.2.0"
"@expo/logger": "npm:27.2.0"
"@expo/eas-build-job": "npm:27.3.0"
"@expo/logger": "npm:27.3.0"
"@expo/spawn-async": "npm:^1.7.2"
"@expo/turtle-spawn": "npm:27.2.0"
"@expo/turtle-spawn": "npm:27.3.0"
"@types/fs-extra": "npm:^11.0.4"
"@types/hapi__joi": "npm:^17.1.14"
"@types/lodash": "npm:^4.17.4"
@@ -10769,16 +10769,16 @@ __metadata:
version: 0.0.0-use.local
resolution: "eas-cli@workspace:packages/eas-cli"
dependencies:
"@expo/apple-utils": "npm:2.2.1"
"@expo/apple-utils": "npm:2.3.0"
"@expo/code-signing-certificates": "npm:0.0.5"
"@expo/config": "npm:55.0.10"
"@expo/config-plugins": "npm:55.0.7"
"@expo/eas-build-job": "npm:27.2.0"
"@expo/eas-json": "npm:27.2.0"
"@expo/eas-build-job": "npm:27.3.0"
"@expo/eas-json": "npm:27.3.0"
"@expo/env": "npm:^1.0.0"
"@expo/experimentation": "npm:0.0.5"
"@expo/json-file": "npm:8.3.3"
"@expo/logger": "npm:27.2.0"
"@expo/logger": "npm:27.3.0"
"@expo/multipart-body-parser": "npm:2.0.0"
"@expo/osascript": "npm:2.1.4"
"@expo/package-manager": "npm:1.9.10"
@@ -10790,7 +10790,7 @@ __metadata:
"@expo/results": "npm:1.0.0"
"@expo/rudder-sdk-node": "npm:1.1.1"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:27.2.0"
"@expo/steps": "npm:27.3.0"
"@expo/timeago.js": "npm:1.0.0"
"@graphql-codegen/cli": "npm:6.3.1"
"@graphql-codegen/introspection": "npm:5.0.2"