mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
203e9997ee | ||
|
|
792af28a5d | ||
|
|
e62bb1a840 | ||
|
|
f8c2b2daf4 | ||
|
|
22fc4da732 | ||
|
|
1c7c79d445 | ||
|
|
b30dd9054d | ||
|
|
7af74c605e | ||
|
|
a5fccedf4e | ||
|
|
f1aa26d9f3 | ||
|
|
68a0847058 | ||
|
|
1431a20b86 |
No files matched your search
@@ -8,6 +8,9 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Non-interactive iOS App Store and Enterprise builds can now use the App Store Connect API key stored in EAS credentials as a submission key to validate and repair provisioning profiles on Apple servers, without requiring `EXPO_ASC_*` environment variables or an interactive Apple login. ([#3805](https://github.com/expo/eas-cli/pull/3805) by [@sswrk](https://github.com/sswrk))
|
||||
- [eas-cli] Add support for non-interactive `eas update:rollback [GROUP_ID]`. The given update group must be the latest for its branch and runtime version; the previous update group is republished, or a roll back to the embedded update is published if there is none. ([#3825](https://github.com/expo/eas-cli/pull/3825) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [expo-cocoapods-proxy] Fix iOS worker tarball build failing on macOS Tahoe due to bundler incompatibility with RubyGems 4. ([#3824](https://github.com/expo/eas-cli/pull/3824) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "20.1.0",
|
||||
"version": "20.2.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "20.1.0",
|
||||
"version": "20.2.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -14,10 +14,6 @@ jest.mock('../sentry', () => ({
|
||||
}));
|
||||
|
||||
describe('runtimeSettings', () => {
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
};
|
||||
const originalCacheUrls = {
|
||||
EAS_NPM_CACHE_URL: process.env.EAS_NPM_CACHE_URL,
|
||||
EAS_NODEJS_CACHE_URL: process.env.EAS_NODEJS_CACHE_URL,
|
||||
@@ -30,8 +26,6 @@ describe('runtimeSettings', () => {
|
||||
jest.mocked(fetch).mockReset();
|
||||
jest.restoreAllMocks();
|
||||
jest.mocked(Sentry.capture).mockReset();
|
||||
logger.info.mockReset();
|
||||
logger.warn.mockReset();
|
||||
restoreEnv('EAS_NPM_CACHE_URL', originalCacheUrls.EAS_NPM_CACHE_URL);
|
||||
restoreEnv('EAS_NODEJS_CACHE_URL', originalCacheUrls.EAS_NODEJS_CACHE_URL);
|
||||
restoreEnv('EAS_MAVEN_CACHE_URL', originalCacheUrls.EAS_MAVEN_CACHE_URL);
|
||||
@@ -41,24 +35,16 @@ describe('runtimeSettings', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.mocked(fetch).mockResolvedValue(response({}));
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging', logger: logger as any });
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging' });
|
||||
jest.mocked(fetch).mockReset();
|
||||
logger.info.mockReset();
|
||||
logger.warn.mockReset();
|
||||
});
|
||||
|
||||
it('fetches runtime settings from the staging and production buckets', async () => {
|
||||
jest.mocked(fetch).mockResolvedValue(response({}));
|
||||
|
||||
await RuntimeSettings.loadAsync({
|
||||
environment: 'staging',
|
||||
logger: logger as any,
|
||||
cacheUrlFallbacks: {
|
||||
npm: 'https://fallback-npm.example',
|
||||
},
|
||||
});
|
||||
await RuntimeSettings.loadAsync({ environment: 'production', logger: logger as any });
|
||||
await RuntimeSettings.loadAsync({ environment: 'test', logger: logger as any });
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging' });
|
||||
await RuntimeSettings.loadAsync({ environment: 'production' });
|
||||
await RuntimeSettings.loadAsync({ environment: 'test' });
|
||||
|
||||
expect(fetch).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
@@ -81,12 +67,11 @@ describe('runtimeSettings', () => {
|
||||
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
|
||||
jest.mocked(fetch).mockResolvedValue(response({}, 404));
|
||||
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging', logger: logger as any });
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging' });
|
||||
|
||||
mockProcessPlatform('linux');
|
||||
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
|
||||
expect(RuntimeSettings.isUsingIosPrecompiledModulesEnabled()).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
expect(Sentry.capture).toHaveBeenCalledWith('Failed to fetch worker runtime settings', {
|
||||
extras: {
|
||||
status: 404,
|
||||
@@ -103,13 +88,12 @@ describe('runtimeSettings', () => {
|
||||
.mockResolvedValueOnce(response({ caches: { linux: { npm: true } } }))
|
||||
.mockResolvedValueOnce(response({ iosPrecompiledModules: 'enabled' }));
|
||||
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging', logger: logger as any });
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging', logger: logger as any });
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging' });
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging' });
|
||||
|
||||
mockProcessPlatform('linux');
|
||||
expect(RuntimeSettings.getNpmCacheUrl()).toBe('https://npm.example');
|
||||
expect(RuntimeSettings.isUsingIosPrecompiledModulesEnabled()).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Failed to load worker runtime settings',
|
||||
expect.any(Error),
|
||||
@@ -138,7 +122,7 @@ describe('runtimeSettings', () => {
|
||||
})
|
||||
);
|
||||
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging', logger: logger as any });
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging' });
|
||||
|
||||
mockProcessPlatform('linux');
|
||||
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
|
||||
@@ -163,7 +147,7 @@ describe('runtimeSettings', () => {
|
||||
})
|
||||
);
|
||||
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging', logger: logger as any });
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging' });
|
||||
|
||||
mockProcessPlatform('linux');
|
||||
expect(RuntimeSettings.getMavenCacheUrl()).toBe('https://maven.example');
|
||||
@@ -172,50 +156,13 @@ describe('runtimeSettings', () => {
|
||||
expect(RuntimeSettings.getCocoapodsCacheUrl()).toBe('https://pods.example');
|
||||
});
|
||||
|
||||
it('uses cache URL fallbacks when enabled cache environment variables are not set', async () => {
|
||||
jest.mocked(fetch).mockResolvedValue(
|
||||
response({
|
||||
caches: {
|
||||
linux: { npm: true, nodejs: true, maven: true },
|
||||
darwin: { cocoapods: true },
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await RuntimeSettings.loadAsync({
|
||||
environment: 'staging',
|
||||
logger: logger as any,
|
||||
cacheUrlFallbacks: {
|
||||
npm: 'https://fallback-npm.example',
|
||||
nodejs: 'https://fallback-node.example',
|
||||
maven: 'https://fallback-maven.example',
|
||||
cocoapods: 'https://fallback-pods.example',
|
||||
},
|
||||
});
|
||||
|
||||
mockProcessPlatform('linux');
|
||||
expect(RuntimeSettings.getNpmCacheUrl()).toBe('https://fallback-npm.example');
|
||||
expect(RuntimeSettings.getNodeJsCacheUrl()).toBe('https://fallback-node.example');
|
||||
expect(RuntimeSettings.getMavenCacheUrl()).toBe('https://fallback-maven.example');
|
||||
mockProcessPlatform('darwin');
|
||||
expect(RuntimeSettings.getCocoapodsCacheUrl()).toBe('https://fallback-pods.example');
|
||||
});
|
||||
|
||||
it('prefers cache URL environment variables over fallbacks', async () => {
|
||||
it('uses job environment flags to enable cache URLs from worker environment variables', async () => {
|
||||
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
|
||||
jest.mocked(fetch).mockResolvedValue(
|
||||
response({
|
||||
caches: {
|
||||
linux: { npm: true },
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await RuntimeSettings.loadAsync({
|
||||
environment: 'staging',
|
||||
logger: logger as any,
|
||||
cacheUrlFallbacks: {
|
||||
npm: 'https://fallback-npm.example',
|
||||
environment: 'test',
|
||||
env: {
|
||||
EAS_USE_NPM_CACHE: '1',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -223,20 +170,13 @@ describe('runtimeSettings', () => {
|
||||
expect(RuntimeSettings.getNpmCacheUrl()).toBe('https://npm.example');
|
||||
});
|
||||
|
||||
it('does not use cache URL fallbacks when runtime settings disable caches', async () => {
|
||||
jest.mocked(fetch).mockResolvedValue(
|
||||
response({
|
||||
caches: {
|
||||
linux: { npm: false },
|
||||
},
|
||||
})
|
||||
);
|
||||
it('returns null when job env enables cache but worker env var is empty', async () => {
|
||||
process.env.EAS_NPM_CACHE_URL = '';
|
||||
|
||||
await RuntimeSettings.loadAsync({
|
||||
environment: 'staging',
|
||||
logger: logger as any,
|
||||
cacheUrlFallbacks: {
|
||||
npm: 'https://fallback-npm.example',
|
||||
environment: 'test',
|
||||
env: {
|
||||
EAS_USE_NPM_CACHE: '1',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -244,17 +184,89 @@ describe('runtimeSettings', () => {
|
||||
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
|
||||
});
|
||||
|
||||
it('clears cache URL fallbacks when loading without them', async () => {
|
||||
it('does not use cache URLs from the job environment', async () => {
|
||||
await RuntimeSettings.loadAsync({
|
||||
environment: 'test',
|
||||
env: {
|
||||
EAS_USE_NPM_CACHE: '1',
|
||||
EAS_NPM_CACHE_URL: 'https://job-npm.example',
|
||||
},
|
||||
});
|
||||
|
||||
mockProcessPlatform('linux');
|
||||
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
|
||||
});
|
||||
|
||||
it('does not enable cache when runtime settings explicitly disable it, even with job env flag', async () => {
|
||||
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
|
||||
jest.mocked(fetch).mockResolvedValue(response({ caches: { linux: { npm: false } } }));
|
||||
|
||||
await RuntimeSettings.loadAsync({
|
||||
environment: 'staging',
|
||||
env: {
|
||||
EAS_USE_NPM_CACHE: '1',
|
||||
},
|
||||
});
|
||||
|
||||
mockProcessPlatform('linux');
|
||||
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
|
||||
});
|
||||
|
||||
it('disables cache when EAS_BUILD_DISABLE_NPM_CACHE is set', async () => {
|
||||
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
|
||||
jest.mocked(fetch).mockResolvedValue(response({ caches: { linux: { npm: true } } }));
|
||||
|
||||
await RuntimeSettings.loadAsync({
|
||||
environment: 'staging',
|
||||
logger: logger as any,
|
||||
cacheUrlFallbacks: {
|
||||
npm: 'https://fallback-npm.example',
|
||||
env: {
|
||||
EAS_BUILD_DISABLE_NPM_CACHE: '1',
|
||||
},
|
||||
});
|
||||
|
||||
mockProcessPlatform('linux');
|
||||
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
|
||||
});
|
||||
|
||||
it('disables cache when EAS_BUILD_DISABLE_MAVEN_CACHE is set', async () => {
|
||||
process.env.EAS_MAVEN_CACHE_URL = 'https://maven.example';
|
||||
jest.mocked(fetch).mockResolvedValue(response({ caches: { linux: { maven: true } } }));
|
||||
|
||||
await RuntimeSettings.loadAsync({
|
||||
environment: 'staging',
|
||||
env: {
|
||||
EAS_BUILD_DISABLE_MAVEN_CACHE: '1',
|
||||
},
|
||||
});
|
||||
|
||||
mockProcessPlatform('linux');
|
||||
expect(RuntimeSettings.getMavenCacheUrl()).toBeNull();
|
||||
});
|
||||
|
||||
it('disables cache when EAS_BUILD_DISABLE_COCOAPODS_CACHE is set', async () => {
|
||||
process.env.EAS_COCOAPODS_CACHE_URL = 'https://pods.example';
|
||||
jest.mocked(fetch).mockResolvedValue(response({ caches: { darwin: { cocoapods: true } } }));
|
||||
|
||||
await RuntimeSettings.loadAsync({
|
||||
environment: 'staging',
|
||||
env: {
|
||||
EAS_BUILD_DISABLE_COCOAPODS_CACHE: '1',
|
||||
},
|
||||
});
|
||||
|
||||
mockProcessPlatform('darwin');
|
||||
expect(RuntimeSettings.getCocoapodsCacheUrl()).toBeNull();
|
||||
});
|
||||
|
||||
it('allows job environment flags to disable remotely enabled cache URLs', async () => {
|
||||
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
|
||||
jest.mocked(fetch).mockResolvedValue(response({ caches: { linux: { npm: true } } }));
|
||||
|
||||
await RuntimeSettings.loadAsync({
|
||||
environment: 'staging',
|
||||
env: {
|
||||
EAS_USE_NPM_CACHE: '0',
|
||||
},
|
||||
});
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging', logger: logger as any });
|
||||
|
||||
mockProcessPlatform('linux');
|
||||
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
|
||||
@@ -275,7 +287,7 @@ describe('runtimeSettings', () => {
|
||||
jest.mocked(fetch).mockResolvedValue(response({ caches: { linux: { npm: true } } }));
|
||||
mockProcessPlatform('darwin');
|
||||
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging', logger: logger as any });
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging' });
|
||||
|
||||
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
|
||||
@@ -7,6 +7,7 @@ import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { prepareProjectSourcesAsync } from '../../common/projectSources';
|
||||
import { BuildContext } from '../../context';
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { Datadog } from '../../datadog';
|
||||
import { findAndUploadXcodeBuildLogsAsync } from '../../ios/xcodeBuildLogs';
|
||||
import { runCustomBuildAsync } from '../custom';
|
||||
|
||||
@@ -17,6 +18,7 @@ const findAndUploadXcodeBuildLogsAsyncMock = jest.mocked(findAndUploadXcodeBuild
|
||||
|
||||
describe(runCustomBuildAsync, () => {
|
||||
let ctx: BuildContext<BuildJob>;
|
||||
const datadogLogSpy = jest.spyOn(Datadog, 'log');
|
||||
|
||||
beforeEach(() => {
|
||||
const job = createTestIosJob();
|
||||
@@ -27,10 +29,24 @@ describe(runCustomBuildAsync, () => {
|
||||
vol.fromJSON(
|
||||
{
|
||||
'test.yaml': `
|
||||
functions:
|
||||
test_function:
|
||||
name: Test function
|
||||
path: ./custom-function
|
||||
shell: /bin/zsh
|
||||
supported_platforms:
|
||||
- darwin
|
||||
- linux
|
||||
inputs:
|
||||
- name
|
||||
- profile
|
||||
outputs:
|
||||
- artifact_path
|
||||
build:
|
||||
steps:
|
||||
- eas/checkout
|
||||
`,
|
||||
'custom-function/package.json': '{}',
|
||||
},
|
||||
'/workingdir/temporary-custom-build'
|
||||
);
|
||||
@@ -77,6 +93,21 @@ describe(runCustomBuildAsync, () => {
|
||||
expect(prepareProjectSourcesAsync).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('reports user-provided custom functions when parsing the custom build config', async () => {
|
||||
await runCustomBuildAsync(ctx);
|
||||
|
||||
expect(datadogLogSpy).toHaveBeenCalledWith('Custom build saw user-provided function', {
|
||||
event: 'custom_build_user_provided_function',
|
||||
custom_function_id: 'test_function',
|
||||
custom_function_input_count: '2',
|
||||
custom_function_module_path: '/workingdir/temporary-custom-build/custom-function',
|
||||
custom_function_name: 'Test function',
|
||||
custom_function_output_count: '1',
|
||||
custom_function_shell: '/bin/zsh',
|
||||
custom_function_supported_runtime_platforms: 'darwin,linux',
|
||||
});
|
||||
});
|
||||
|
||||
it('awaits drainPendingMetricUploads after workflow execution', async () => {
|
||||
let resolveDrain!: () => void;
|
||||
const drainSpy = jest
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { BuildJob, BuildPhase, BuildTrigger, Ios, Platform } from '@expo/eas-build-job';
|
||||
import { BuildConfigParser, BuildStepGlobalContext, StepsConfigParser, errors } from '@expo/steps';
|
||||
import {
|
||||
BuildConfigParser,
|
||||
BuildStepGlobalContext,
|
||||
BuildWorkflow,
|
||||
StepsConfigParser,
|
||||
errors,
|
||||
} from '@expo/steps';
|
||||
import assert from 'assert';
|
||||
import fs from 'fs/promises';
|
||||
import nullthrows from 'nullthrows';
|
||||
@@ -9,6 +15,7 @@ import { resolveEnvFromBuildProfileAsync } from '../common/easBuildInternal';
|
||||
import { prepareProjectSourcesAsync } from '../common/projectSources';
|
||||
import { Artifacts, BuildContext } from '../context';
|
||||
import { CustomBuildContext } from '../customBuildContext';
|
||||
import { Datadog } from '../datadog';
|
||||
import { findAndUploadXcodeBuildLogsAsync } from '../ios/xcodeBuildLogs';
|
||||
import { getEasFunctionGroups } from '../steps/easFunctionGroups';
|
||||
import { getEasFunctions } from '../steps/easFunctions';
|
||||
@@ -81,6 +88,7 @@ export async function runCustomBuildAsync(ctx: BuildContext<BuildJob>): Promise<
|
||||
throw parseError;
|
||||
}
|
||||
});
|
||||
logUserProvidedCustomFunctions(workflow);
|
||||
try {
|
||||
try {
|
||||
await workflow.executeAsync();
|
||||
@@ -103,3 +111,26 @@ export async function runCustomBuildAsync(ctx: BuildContext<BuildJob>): Promise<
|
||||
|
||||
return ctx.artifacts;
|
||||
}
|
||||
|
||||
function logUserProvidedCustomFunctions(workflow: BuildWorkflow): void {
|
||||
for (const buildFunction of Object.values(workflow.buildFunctions)) {
|
||||
if (!buildFunction.customFunctionModulePath) {
|
||||
continue;
|
||||
}
|
||||
Datadog.log('Custom build saw user-provided function', {
|
||||
event: 'custom_build_user_provided_function',
|
||||
custom_function_id: buildFunction.getFullId(),
|
||||
custom_function_module_path: buildFunction.customFunctionModulePath,
|
||||
custom_function_input_count: String(buildFunction.inputProviders?.length ?? 0),
|
||||
custom_function_output_count: String(buildFunction.outputProviders?.length ?? 0),
|
||||
...(buildFunction.name ? { custom_function_name: buildFunction.name } : {}),
|
||||
...(buildFunction.shell ? { custom_function_shell: buildFunction.shell } : {}),
|
||||
...(buildFunction.supportedRuntimePlatforms
|
||||
? {
|
||||
custom_function_supported_runtime_platforms:
|
||||
buildFunction.supportedRuntimePlatforms.join(','),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
|
||||
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { PackageManager } from '../../utils/packageManager';
|
||||
import { installDependenciesWithNpmCacheFallbackAsync } from '../installDependencies';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => jest.fn());
|
||||
jest.mock('../../sentry', () => ({
|
||||
Sentry: {
|
||||
capture: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe(installDependenciesWithNpmCacheFallbackAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('retries without NPM_CONFIG_REGISTRY when npm cache install fails', async () => {
|
||||
const logger = createMockLogger();
|
||||
const npmCacheUrl = 'http://npm.staging.caches.eas-build.internal';
|
||||
const error = Object.assign(
|
||||
new Error('npm install --include=dev exited with non-zero code: 1'),
|
||||
{
|
||||
status: 1,
|
||||
signal: null,
|
||||
stdout: '',
|
||||
stderr: `npm error request to ${npmCacheUrl}/left-pad failed, reason: getaddrinfo ENOTFOUND npm.staging.caches.eas-build.internal`,
|
||||
}
|
||||
);
|
||||
|
||||
jest
|
||||
.mocked(spawn)
|
||||
.mockReturnValueOnce(createSpawnPromise(Promise.reject(error)))
|
||||
.mockReturnValueOnce(createSpawnPromise(Promise.resolve(createSpawnResult())));
|
||||
|
||||
await installDependenciesWithNpmCacheFallbackAsync({
|
||||
packageManager: PackageManager.NPM,
|
||||
env: {
|
||||
EAS_VERBOSE: '1',
|
||||
EAS_USE_NPM_CACHE: '1',
|
||||
NPM_CONFIG_REGISTRY: npmCacheUrl,
|
||||
},
|
||||
logger,
|
||||
cwd: '/tmp/build',
|
||||
useFrozenLockfile: false,
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledTimes(2);
|
||||
expect(spawn).toHaveBeenNthCalledWith(1, 'npm', ['install', '--include=dev', '--verbose'], {
|
||||
cwd: '/tmp/build',
|
||||
logger,
|
||||
infoCallbackFn: undefined,
|
||||
lineTransformer: expect.any(Function),
|
||||
env: {
|
||||
EAS_VERBOSE: '1',
|
||||
EAS_USE_NPM_CACHE: '1',
|
||||
NPM_CONFIG_REGISTRY: npmCacheUrl,
|
||||
},
|
||||
});
|
||||
expect(spawn).toHaveBeenNthCalledWith(2, 'npm', ['install', '--include=dev', '--verbose'], {
|
||||
cwd: '/tmp/build',
|
||||
logger,
|
||||
infoCallbackFn: undefined,
|
||||
env: {
|
||||
EAS_VERBOSE: '1',
|
||||
EAS_USE_NPM_CACHE: '1',
|
||||
},
|
||||
});
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
`Failed to install dependencies using the npm cache registry (${npmCacheUrl}). Retrying without the npm cache registry.`
|
||||
);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: 'Failed to install dependencies using npm cache registry',
|
||||
name: 'NpmCacheRegistryInstallError',
|
||||
}),
|
||||
{
|
||||
level: 'warning',
|
||||
tags: {
|
||||
packageManager: 'npm',
|
||||
},
|
||||
extras: {
|
||||
cwd: '/tmp/build',
|
||||
npmCacheUrl,
|
||||
useFrozenLockfile: false,
|
||||
originalErrorMessage: 'npm install --include=dev exited with non-zero code: 1',
|
||||
status: 1,
|
||||
signal: null,
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('reports non-fatal npm cache registry errors from successful installs', async () => {
|
||||
const logger = createMockLogger();
|
||||
const npmCacheUrl = 'http://npm.staging.caches.eas-build.internal';
|
||||
const auditErrorLine = `npm verbose audit error FetchError: request to ${npmCacheUrl}/-/npm/v1/security/advisories/bulk failed, reason: getaddrinfo ENOTFOUND npm.staging.caches.eas-build.internal`;
|
||||
|
||||
jest.mocked(spawn).mockImplementationOnce((_command, _args, options: any) => {
|
||||
options.lineTransformer(auditErrorLine);
|
||||
options.lineTransformer(
|
||||
`npm http cache left-pad@${npmCacheUrl}/left-pad/-/left-pad-1.3.0.tgz 0ms (cache hit)`
|
||||
);
|
||||
return createSpawnPromise(Promise.resolve(createSpawnResult()));
|
||||
});
|
||||
|
||||
await installDependenciesWithNpmCacheFallbackAsync({
|
||||
packageManager: PackageManager.NPM,
|
||||
env: {
|
||||
EAS_USE_NPM_CACHE: '1',
|
||||
NPM_CONFIG_REGISTRY: npmCacheUrl,
|
||||
},
|
||||
logger,
|
||||
cwd: '/tmp/build',
|
||||
useFrozenLockfile: false,
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledTimes(1);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: 'Non-fatal npm cache registry error during dependency install',
|
||||
name: 'NpmCacheRegistryNonFatalError',
|
||||
}),
|
||||
{
|
||||
level: 'warning',
|
||||
tags: {
|
||||
packageManager: 'npm',
|
||||
},
|
||||
extras: {
|
||||
cwd: '/tmp/build',
|
||||
npmCacheUrl,
|
||||
useFrozenLockfile: false,
|
||||
firstErrorLine: auditErrorLine,
|
||||
errorLineCount: 1,
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('does not retry when install failure does not reference npm cache registry', async () => {
|
||||
const logger = createMockLogger();
|
||||
const error = Object.assign(
|
||||
new Error('npm install --include=dev exited with non-zero code: 1'),
|
||||
{
|
||||
status: 1,
|
||||
signal: null,
|
||||
stdout: '',
|
||||
stderr: 'npm error command sh -c node postinstall.js',
|
||||
}
|
||||
);
|
||||
|
||||
jest.mocked(spawn).mockReturnValueOnce(createSpawnPromise(Promise.reject(error)));
|
||||
|
||||
await expect(
|
||||
installDependenciesWithNpmCacheFallbackAsync({
|
||||
packageManager: PackageManager.NPM,
|
||||
env: {
|
||||
EAS_USE_NPM_CACHE: '1',
|
||||
NPM_CONFIG_REGISTRY: 'http://npm.staging.caches.eas-build.internal',
|
||||
},
|
||||
logger,
|
||||
cwd: '/tmp/build',
|
||||
useFrozenLockfile: false,
|
||||
})
|
||||
).rejects.toThrow(error);
|
||||
|
||||
expect(spawn).toHaveBeenCalledTimes(1);
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not retry when EAS_USE_NPM_CACHE is not enabled', async () => {
|
||||
const logger = createMockLogger();
|
||||
const npmCacheUrl = 'http://npm.staging.caches.eas-build.internal';
|
||||
const error = Object.assign(
|
||||
new Error('npm install --include=dev exited with non-zero code: 1'),
|
||||
{
|
||||
status: 1,
|
||||
signal: null,
|
||||
stdout: '',
|
||||
stderr: `npm error request to ${npmCacheUrl}/left-pad failed, reason: getaddrinfo ENOTFOUND npm.staging.caches.eas-build.internal`,
|
||||
}
|
||||
);
|
||||
|
||||
jest.mocked(spawn).mockReturnValueOnce(createSpawnPromise(Promise.reject(error)));
|
||||
|
||||
await expect(
|
||||
installDependenciesWithNpmCacheFallbackAsync({
|
||||
packageManager: PackageManager.NPM,
|
||||
env: {
|
||||
NPM_CONFIG_REGISTRY: npmCacheUrl,
|
||||
},
|
||||
logger,
|
||||
cwd: '/tmp/build',
|
||||
useFrozenLockfile: false,
|
||||
})
|
||||
).rejects.toThrow(error);
|
||||
|
||||
expect(spawn).toHaveBeenCalledTimes(1);
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
function createSpawnPromise(result: Promise<SpawnResult>): SpawnPromise<SpawnResult> {
|
||||
return Object.assign(result, {
|
||||
child: {
|
||||
pid: 123,
|
||||
},
|
||||
}) as SpawnPromise<SpawnResult>;
|
||||
}
|
||||
|
||||
function createSpawnResult(): SpawnResult {
|
||||
return {
|
||||
pid: 123,
|
||||
output: ['', ''],
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
status: 0,
|
||||
signal: null,
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Job } from '@expo/eas-build-job';
|
||||
import { Job, SystemError } from '@expo/eas-build-job';
|
||||
import spawn, { SpawnOptions, SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
|
||||
import path from 'path';
|
||||
|
||||
import { BuildContext } from '../context';
|
||||
import { Sentry } from '../sentry';
|
||||
import { PackageManager, findPackagerRootDir } from '../utils/packageManager';
|
||||
import { isUsingModernYarnVersion } from '../utils/project';
|
||||
|
||||
@@ -11,6 +12,7 @@ export async function installDependenciesAsync({
|
||||
env,
|
||||
logger,
|
||||
infoCallbackFn,
|
||||
lineTransformer,
|
||||
cwd,
|
||||
useFrozenLockfile,
|
||||
}: {
|
||||
@@ -19,6 +21,7 @@ export async function installDependenciesAsync({
|
||||
cwd: string;
|
||||
logger: Exclude<SpawnOptions['logger'], undefined>;
|
||||
infoCallbackFn?: SpawnOptions['infoCallbackFn'];
|
||||
lineTransformer?: SpawnOptions['lineTransformer'];
|
||||
useFrozenLockfile: boolean;
|
||||
}): Promise<{ spawnPromise: SpawnPromise<SpawnResult> }> {
|
||||
let args: string[];
|
||||
@@ -69,11 +72,92 @@ export async function installDependenciesAsync({
|
||||
cwd,
|
||||
logger,
|
||||
infoCallbackFn,
|
||||
lineTransformer,
|
||||
env,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function installDependenciesWithNpmCacheFallbackAsync({
|
||||
packageManager,
|
||||
env,
|
||||
logger,
|
||||
infoCallbackFn,
|
||||
cwd,
|
||||
useFrozenLockfile,
|
||||
}: {
|
||||
packageManager: PackageManager;
|
||||
env: Record<string, string | undefined>;
|
||||
cwd: string;
|
||||
logger: Exclude<SpawnOptions['logger'], undefined>;
|
||||
infoCallbackFn?: SpawnOptions['infoCallbackFn'];
|
||||
useFrozenLockfile: boolean;
|
||||
}): Promise<void> {
|
||||
const npmCacheUrl = env.NPM_CONFIG_REGISTRY;
|
||||
let firstErrorLine: string | undefined;
|
||||
let errorLineCount = 0;
|
||||
|
||||
try {
|
||||
await (
|
||||
await installDependenciesAsync({
|
||||
packageManager,
|
||||
env,
|
||||
logger,
|
||||
infoCallbackFn,
|
||||
lineTransformer: (line: string) => {
|
||||
if (isNpmCacheRegistryErrorLine(line, { env, npmCacheUrl })) {
|
||||
firstErrorLine ??= line;
|
||||
errorLineCount += 1;
|
||||
}
|
||||
return line;
|
||||
},
|
||||
cwd,
|
||||
useFrozenLockfile,
|
||||
})
|
||||
).spawnPromise;
|
||||
|
||||
if (firstErrorLine) {
|
||||
Sentry.capture(new NpmCacheRegistryNonFatalError(), {
|
||||
level: 'warning',
|
||||
tags: { packageManager },
|
||||
extras: { cwd, npmCacheUrl, useFrozenLockfile, firstErrorLine, errorLineCount },
|
||||
});
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (!isNpmCacheInstallFailure(err, { env, npmCacheUrl })) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
`Failed to install dependencies using the npm cache registry (${npmCacheUrl}). Retrying without the npm cache registry.`
|
||||
);
|
||||
Sentry.capture(new NpmCacheRegistryInstallError(err), {
|
||||
level: 'warning',
|
||||
tags: { packageManager },
|
||||
extras: {
|
||||
cwd,
|
||||
npmCacheUrl,
|
||||
useFrozenLockfile,
|
||||
originalErrorMessage: err instanceof Error ? err.message : String(err),
|
||||
status: err instanceof Error ? (err as any).status : undefined,
|
||||
signal: err instanceof Error ? (err as any).signal : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const { NPM_CONFIG_REGISTRY: _NPM_CONFIG_REGISTRY, ...fallbackEnv } = env;
|
||||
await (
|
||||
await installDependenciesAsync({
|
||||
packageManager,
|
||||
env: fallbackEnv,
|
||||
logger,
|
||||
infoCallbackFn,
|
||||
cwd,
|
||||
useFrozenLockfile,
|
||||
})
|
||||
).spawnPromise;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolvePackagerDir(ctx: BuildContext<Job>): string {
|
||||
const packagerRunDir = findPackagerRootDir(ctx.getReactNativeProjectDirectory());
|
||||
if (packagerRunDir !== ctx.getReactNativeProjectDirectory()) {
|
||||
@@ -87,3 +171,54 @@ export function resolvePackagerDir(ctx: BuildContext<Job>): string {
|
||||
}
|
||||
return packagerRunDir;
|
||||
}
|
||||
|
||||
function isNpmCacheInstallFailure(
|
||||
err: unknown,
|
||||
{ env, npmCacheUrl }: { env: Record<string, string | undefined>; npmCacheUrl: string | undefined }
|
||||
): boolean {
|
||||
if (!isNpmCacheRegistryEnabled(env, npmCacheUrl)) {
|
||||
return false;
|
||||
}
|
||||
const errorOutput = getErrorOutput(err);
|
||||
return errorOutput.includes(npmCacheUrl);
|
||||
}
|
||||
|
||||
function isNpmCacheRegistryErrorLine(
|
||||
line: string,
|
||||
{ env, npmCacheUrl }: { env: Record<string, string | undefined>; npmCacheUrl: string | undefined }
|
||||
): boolean {
|
||||
return (
|
||||
isNpmCacheRegistryEnabled(env, npmCacheUrl) &&
|
||||
line.includes(npmCacheUrl) &&
|
||||
/(?:error|failed|ENOTFOUND|ECONN|ETIMEDOUT|EAI_AGAIN|FetchError)/i.test(line)
|
||||
);
|
||||
}
|
||||
|
||||
function isNpmCacheRegistryEnabled(
|
||||
env: Record<string, string | undefined>,
|
||||
npmCacheUrl: string | undefined
|
||||
): npmCacheUrl is string {
|
||||
return env.EAS_USE_NPM_CACHE === '1' && !!npmCacheUrl;
|
||||
}
|
||||
|
||||
function getErrorOutput(err: unknown): string {
|
||||
if (!(err instanceof Error)) {
|
||||
return '';
|
||||
}
|
||||
const { stdout, stderr } = err as Error & { stdout?: string; stderr?: string };
|
||||
return [stdout, stderr].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
class NpmCacheRegistryNonFatalError extends SystemError {
|
||||
override name = 'NpmCacheRegistryNonFatalError';
|
||||
constructor() {
|
||||
super('Non-fatal npm cache registry error during dependency install');
|
||||
}
|
||||
}
|
||||
|
||||
class NpmCacheRegistryInstallError extends SystemError {
|
||||
override name = 'NpmCacheRegistryInstallError';
|
||||
constructor(cause: unknown) {
|
||||
super('Failed to install dependencies using npm cache registry', { cause });
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,10 @@ import { BuildJob } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { SpawnOptions } from '@expo/turtle-spawn';
|
||||
|
||||
import { installDependenciesAsync, resolvePackagerDir } from './installDependencies';
|
||||
import {
|
||||
installDependenciesWithNpmCacheFallbackAsync,
|
||||
resolvePackagerDir,
|
||||
} from './installDependencies';
|
||||
import { BuildContext } from '../context';
|
||||
import { runExpoCliCommand } from '../utils/project';
|
||||
|
||||
@@ -30,17 +33,14 @@ export async function prebuildAsync<TJob extends BuildJob>(
|
||||
options: spawnOptions,
|
||||
packageManager: ctx.packageManager,
|
||||
});
|
||||
const installDependenciesSpawnPromise = (
|
||||
await installDependenciesAsync({
|
||||
packageManager: ctx.packageManager,
|
||||
env: ctx.env,
|
||||
logger,
|
||||
cwd: resolvePackagerDir(ctx),
|
||||
// prebuild sometimes modifies package.json, so we don't want to use frozen lockfile
|
||||
useFrozenLockfile: false,
|
||||
})
|
||||
).spawnPromise;
|
||||
await installDependenciesSpawnPromise;
|
||||
await installDependenciesWithNpmCacheFallbackAsync({
|
||||
packageManager: ctx.packageManager,
|
||||
env: ctx.env,
|
||||
logger,
|
||||
cwd: resolvePackagerDir(ctx),
|
||||
// prebuild sometimes modifies package.json, so we don't want to use frozen lockfile
|
||||
useFrozenLockfile: false,
|
||||
});
|
||||
}
|
||||
|
||||
function getPrebuildCommandArgs<TJob extends BuildJob>(ctx: BuildContext<TJob>): string[] {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { Env } from '@expo/eas-build-job';
|
||||
import fetch from 'node-fetch';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -26,27 +26,18 @@ const RuntimeSettingsSchema = z
|
||||
})
|
||||
.partial();
|
||||
|
||||
type CacheUrlFallbacks = {
|
||||
npm?: string | null;
|
||||
nodejs?: string | null;
|
||||
maven?: string | null;
|
||||
cocoapods?: string | null;
|
||||
};
|
||||
|
||||
let runtimeSettings: z.infer<typeof RuntimeSettingsSchema> = {};
|
||||
let cacheUrlFallbacks: CacheUrlFallbacks = {};
|
||||
let runtimeEnvironment: Env = {};
|
||||
|
||||
export namespace RuntimeSettings {
|
||||
export async function loadAsync({
|
||||
environment,
|
||||
logger,
|
||||
cacheUrlFallbacks: nextCacheUrlFallbacks = {},
|
||||
env: nextRuntimeEnvironment = {},
|
||||
}: {
|
||||
environment: string;
|
||||
logger: bunyan;
|
||||
cacheUrlFallbacks?: CacheUrlFallbacks;
|
||||
env?: Env;
|
||||
}): Promise<void> {
|
||||
cacheUrlFallbacks = nextCacheUrlFallbacks;
|
||||
runtimeEnvironment = nextRuntimeEnvironment;
|
||||
const url = ENVIRONMENT_TO_RUNTIME_SETTINGS_URL[environment];
|
||||
if (!url) {
|
||||
return;
|
||||
@@ -58,10 +49,6 @@ export namespace RuntimeSettings {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn(
|
||||
{ url, status: response.status },
|
||||
'Failed to fetch worker runtime settings, using defaults'
|
||||
);
|
||||
Sentry.capture('Failed to fetch worker runtime settings', {
|
||||
extras: { url, status: response.status },
|
||||
level: 'warning',
|
||||
@@ -84,9 +71,7 @@ export namespace RuntimeSettings {
|
||||
}
|
||||
);
|
||||
}
|
||||
logger.info({ url, settings: runtimeSettings }, 'Loaded worker runtime settings');
|
||||
} catch (err) {
|
||||
logger.warn({ err, url }, 'Failed to load worker runtime settings, using defaults');
|
||||
Sentry.capture(
|
||||
'Failed to load worker runtime settings',
|
||||
err instanceof Error ? err : new Error(String(err)),
|
||||
@@ -103,26 +88,51 @@ export namespace RuntimeSettings {
|
||||
}
|
||||
|
||||
export function getNpmCacheUrl(): string | null {
|
||||
return runtimeSettings.caches?.[process.platform]?.npm
|
||||
? process.env.EAS_NPM_CACHE_URL || cacheUrlFallbacks.npm || null
|
||||
: null;
|
||||
if (runtimeEnvironment['EAS_BUILD_DISABLE_NPM_CACHE'] === '1') {
|
||||
return null;
|
||||
}
|
||||
const runtimeEnabled = runtimeSettings.caches?.[process.platform]?.npm;
|
||||
if (runtimeEnabled === false) {
|
||||
return null;
|
||||
}
|
||||
const envOverride = runtimeEnvironment['EAS_USE_NPM_CACHE'];
|
||||
const enabled = envOverride === '1' || (envOverride !== '0' && runtimeEnabled);
|
||||
return enabled ? process.env.EAS_NPM_CACHE_URL || null : null;
|
||||
}
|
||||
|
||||
export function getNodeJsCacheUrl(): string | null {
|
||||
return runtimeSettings.caches?.[process.platform]?.nodejs
|
||||
? process.env.EAS_NODEJS_CACHE_URL || cacheUrlFallbacks.nodejs || null
|
||||
: null;
|
||||
const runtimeEnabled = runtimeSettings.caches?.[process.platform]?.nodejs;
|
||||
if (runtimeEnabled === false) {
|
||||
return null;
|
||||
}
|
||||
const envOverride = runtimeEnvironment['EAS_USE_NODEJS_CACHE'];
|
||||
const enabled = envOverride === '1' || (envOverride !== '0' && runtimeEnabled);
|
||||
return enabled ? process.env.EAS_NODEJS_CACHE_URL || null : null;
|
||||
}
|
||||
|
||||
export function getMavenCacheUrl(): string | null {
|
||||
return runtimeSettings.caches?.[process.platform]?.maven
|
||||
? process.env.EAS_MAVEN_CACHE_URL || cacheUrlFallbacks.maven || null
|
||||
: null;
|
||||
if (runtimeEnvironment['EAS_BUILD_DISABLE_MAVEN_CACHE'] === '1') {
|
||||
return null;
|
||||
}
|
||||
const runtimeEnabled = runtimeSettings.caches?.[process.platform]?.maven;
|
||||
if (runtimeEnabled === false) {
|
||||
return null;
|
||||
}
|
||||
const envOverride = runtimeEnvironment['EAS_USE_MAVEN_CACHE'];
|
||||
const enabled = envOverride === '1' || (envOverride !== '0' && runtimeEnabled);
|
||||
return enabled ? process.env.EAS_MAVEN_CACHE_URL || null : null;
|
||||
}
|
||||
|
||||
export function getCocoapodsCacheUrl(): string | null {
|
||||
return runtimeSettings.caches?.[process.platform]?.cocoapods
|
||||
? process.env.EAS_COCOAPODS_CACHE_URL || cacheUrlFallbacks.cocoapods || null
|
||||
: null;
|
||||
if (runtimeEnvironment['EAS_BUILD_DISABLE_COCOAPODS_CACHE'] === '1') {
|
||||
return null;
|
||||
}
|
||||
const runtimeEnabled = runtimeSettings.caches?.[process.platform]?.cocoapods;
|
||||
if (runtimeEnabled === false) {
|
||||
return null;
|
||||
}
|
||||
const envOverride = runtimeEnvironment['EAS_USE_COCOAPODS_CACHE'];
|
||||
const enabled = envOverride === '1' || (envOverride !== '0' && runtimeEnabled);
|
||||
return enabled ? process.env.EAS_COCOAPODS_CACHE_URL || null : null;
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
createInstallNodeModulesBuildFunction(),
|
||||
createPrebuildBuildFunction(),
|
||||
createReadIpaInfoBuildFunction(),
|
||||
createDownloadBuildFunction(),
|
||||
createDownloadBuildFunction(ctx),
|
||||
createEasExportBuildFunction(),
|
||||
createEasDeployBuildFunction(),
|
||||
createRepackBuildFunction(),
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { createLogger } from '@expo/logger';
|
||||
import { Client, CombinedError } from '@urql/core';
|
||||
import fetch, { Response } from 'node-fetch';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
@@ -17,22 +19,63 @@ const APP_TAR_GZ_BUFFER = Buffer.from(
|
||||
'base64'
|
||||
);
|
||||
|
||||
const APPLICATION_ARCHIVE_URL = `https://expo.dev/artifacts/eas/${randomUUID()}.tar.gz`;
|
||||
|
||||
function createMockGraphqlClient({
|
||||
applicationArchiveUrl,
|
||||
error,
|
||||
}: {
|
||||
applicationArchiveUrl?: string | null;
|
||||
error?: Error;
|
||||
}): Client {
|
||||
const toPromise = jest.fn().mockResolvedValue(
|
||||
error
|
||||
? { error, data: undefined }
|
||||
: {
|
||||
data: {
|
||||
builds: {
|
||||
byId: {
|
||||
id: randomUUID(),
|
||||
platform: 'IOS',
|
||||
artifacts: {
|
||||
applicationArchiveUrl: applicationArchiveUrl ?? null,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
query: jest.fn().mockReturnValue({ toPromise }),
|
||||
} as unknown as Client;
|
||||
}
|
||||
|
||||
describe('downloadBuild', () => {
|
||||
it('should handle an archive', async () => {
|
||||
it('downloads from applicationArchiveUrl returned by GraphQL', async () => {
|
||||
const buildId = randomUUID();
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
});
|
||||
|
||||
jest.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
body: Readable.from(APP_TAR_GZ_BUFFER),
|
||||
url: `https://storage.googleapis.com/eas-workflows-production/artifacts/${randomUUID()}/${randomUUID()}/application-${randomUUID()}.tar.gz?X-Goog-Algorithm=GOOG4-RSA-SHA256`,
|
||||
url: APPLICATION_ARCHIVE_URL,
|
||||
} as unknown as Response);
|
||||
|
||||
const { artifactPath } = await downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
buildId: randomUUID(),
|
||||
expoApiServerURL: 'http://api.expo.test',
|
||||
buildId,
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['app'],
|
||||
});
|
||||
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
|
||||
APPLICATION_ARCHIVE_URL,
|
||||
expect.objectContaining({ headers: undefined })
|
||||
);
|
||||
expect(artifactPath).toBeDefined();
|
||||
expect(await fs.promises.readFile(path.join(artifactPath, 'TestApp'), 'utf8')).toBe(
|
||||
'i am executable\n'
|
||||
@@ -40,16 +83,21 @@ describe('downloadBuild', () => {
|
||||
});
|
||||
|
||||
it('should handle a straight-up file', async () => {
|
||||
const applicationArchiveUrl = `https://expo.dev/artifacts/eas/${randomUUID()}.apk`;
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
applicationArchiveUrl,
|
||||
});
|
||||
|
||||
jest.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
body: Readable.from(Buffer.from('hello')),
|
||||
url: `https://storage.googleapis.com/eas-workflows-production/artifacts/${randomUUID()}/${randomUUID()}/application-${randomUUID()}.txt?X-Goog-Algorithm=GOOG4-RSA-SHA256`,
|
||||
url: applicationArchiveUrl,
|
||||
} as unknown as Response);
|
||||
|
||||
const { artifactPath } = await downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
buildId: randomUUID(),
|
||||
expoApiServerURL: 'http://api.expo.test',
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['app'],
|
||||
});
|
||||
@@ -58,18 +106,114 @@ describe('downloadBuild', () => {
|
||||
expect(await fs.promises.readFile(artifactPath, 'utf-8')).toBe('hello');
|
||||
});
|
||||
|
||||
it('throws UserError when the build has no application archive url', async () => {
|
||||
const graphqlClient = createMockGraphqlClient({ applicationArchiveUrl: null });
|
||||
|
||||
await expect(
|
||||
downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
buildId: randomUUID(),
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['app'],
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
errorCode: 'EAS_DOWNLOAD_BUILD_NO_APPLICATION_ARCHIVE',
|
||||
message: 'Build does not have an application archive url',
|
||||
});
|
||||
|
||||
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws SystemError when GraphQL request fails with a network error', async () => {
|
||||
const buildId = randomUUID();
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
error: new CombinedError({
|
||||
networkError: new Error('Network request failed'),
|
||||
}),
|
||||
});
|
||||
|
||||
const promise = downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
buildId,
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['app'],
|
||||
});
|
||||
|
||||
await expect(promise).rejects.toBeInstanceOf(SystemError);
|
||||
await expect(promise).rejects.toMatchObject({
|
||||
message: `Could not fetch build ${buildId}: [Network] Network request failed`,
|
||||
});
|
||||
|
||||
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws SystemError when GraphQL request returns 5xx', async () => {
|
||||
const buildId = randomUUID();
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
error: Object.assign(new Error('Internal Server Error'), {
|
||||
response: { status: 500 },
|
||||
}),
|
||||
});
|
||||
|
||||
const promise = downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
buildId,
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['app'],
|
||||
});
|
||||
|
||||
await expect(promise).rejects.toBeInstanceOf(SystemError);
|
||||
await expect(promise).rejects.toMatchObject({
|
||||
message: `Could not fetch build ${buildId}: Internal Server Error`,
|
||||
});
|
||||
|
||||
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws UserError when GraphQL returns a client error', async () => {
|
||||
const buildId = randomUUID();
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
error: new CombinedError({
|
||||
graphQLErrors: [{ message: 'Build not found' }],
|
||||
}),
|
||||
});
|
||||
|
||||
const promise = downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
buildId,
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['app'],
|
||||
});
|
||||
|
||||
await expect(promise).rejects.toBeInstanceOf(UserError);
|
||||
await expect(promise).rejects.toMatchObject({
|
||||
errorCode: 'EAS_DOWNLOAD_BUILD_FETCH_FAILED',
|
||||
message: `Could not fetch build ${buildId}: [GraphQL] Build not found`,
|
||||
});
|
||||
|
||||
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw an error if no matching files are found', async () => {
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
});
|
||||
|
||||
jest.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
body: Readable.from(APP_TAR_GZ_BUFFER),
|
||||
url: `https://storage.googleapis.com/eas-workflows-production/artifacts/${randomUUID()}/${randomUUID()}/application-${randomUUID()}.tar.gz?X-Goog-Algorithm=GOOG4-RSA-SHA256`,
|
||||
url: APPLICATION_ARCHIVE_URL,
|
||||
} as unknown as Response);
|
||||
|
||||
await expect(
|
||||
downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
buildId: randomUUID(),
|
||||
expoApiServerURL: 'http://api.expo.test',
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['apk'],
|
||||
})
|
||||
@@ -79,7 +223,11 @@ describe('downloadBuild', () => {
|
||||
|
||||
describe('createDownloadBuildFunction', () => {
|
||||
it('should download a build', async () => {
|
||||
const downloadBuild = createDownloadBuildFunction();
|
||||
const buildId = randomUUID();
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
});
|
||||
const downloadBuild = createDownloadBuildFunction({ graphqlClient } as any);
|
||||
const logger = createMockLogger();
|
||||
|
||||
const buildStep = downloadBuild.createBuildStepFromFunctionCall(
|
||||
@@ -92,7 +240,7 @@ describe('createDownloadBuildFunction', () => {
|
||||
}),
|
||||
{
|
||||
callInputs: {
|
||||
build_id: randomUUID(),
|
||||
build_id: buildId,
|
||||
extensions: ['app'],
|
||||
},
|
||||
}
|
||||
@@ -105,5 +253,9 @@ describe('createDownloadBuildFunction', () => {
|
||||
} as unknown as Response);
|
||||
|
||||
await expect(buildStep.executeAsync()).rejects.toThrow('Internal Server Error');
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
|
||||
APPLICATION_ARCHIVE_URL,
|
||||
expect.objectContaining({ headers: undefined })
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -4,10 +4,14 @@ import { vol } from 'memfs';
|
||||
|
||||
import {
|
||||
copyLatestAttemptXml,
|
||||
isFileAttrRun,
|
||||
junitFileHasFileAttrs,
|
||||
mergeJUnitReports,
|
||||
parseFailedFlowsFromFileAttrs,
|
||||
parseFailedFlowsFromJUnit,
|
||||
parseJUnitTestCases,
|
||||
parseMaestroResults,
|
||||
parseMaestroResultsFromFileAttrs,
|
||||
} from '../maestroResultParser';
|
||||
|
||||
describe(parseMaestroResults, () => {
|
||||
@@ -298,7 +302,169 @@ describe(parseMaestroResults, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseMaestroResultsFromFileAttrs, () => {
|
||||
it('uses file= as the path', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites><testsuite>',
|
||||
' <testcase name="login" file=".maestro/login.yaml" status="SUCCESS" time="1.0"/>',
|
||||
'</testsuite></testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
const results = await parseMaestroResultsFromFileAttrs('/junit');
|
||||
expect(results[0]).toEqual(
|
||||
expect.objectContaining({ name: 'login', path: '.maestro/login.yaml' })
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps two same-named flows separate by their file= path', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites><testsuite>',
|
||||
' <testcase name="login" file="a/login.yaml" status="SUCCESS" time="1.0"/>',
|
||||
' <testcase name="login" file="b/login.yaml" status="ERROR" time="1.0"><failure>x</failure></testcase>',
|
||||
'</testsuite></testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
const results = await parseMaestroResultsFromFileAttrs('/junit');
|
||||
expect(results.map(r => r.path).sort()).toEqual(['a/login.yaml', 'b/login.yaml']);
|
||||
});
|
||||
|
||||
it('returns per-attempt results keyed by file= across attempt files', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/android-maestro-junit-attempt-0.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites><testsuite>',
|
||||
' <testcase name="login" file="flows/login.yaml" time="1.0"><failure>x</failure></testcase>',
|
||||
'</testsuite></testsuites>',
|
||||
].join('\n'),
|
||||
'/junit/android-maestro-junit-attempt-1.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites><testsuite>',
|
||||
' <testcase name="login" file="flows/login.yaml" status="SUCCESS" time="1.0"/>',
|
||||
'</testsuite></testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
const results = await parseMaestroResultsFromFileAttrs('/junit');
|
||||
expect(results).toEqual([
|
||||
expect.objectContaining({ path: 'flows/login.yaml', status: 'failed', retryCount: 0 }),
|
||||
expect.objectContaining({ path: 'flows/login.yaml', status: 'passed', retryCount: 1 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns empty array when the directory does not exist', async () => {
|
||||
const results = await parseMaestroResultsFromFileAttrs('/nope');
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips testcases without file= instead of emitting an undefined path (contract guard)', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites><testsuite>',
|
||||
' <testcase name="a" file="a.yaml" status="SUCCESS" time="1.0"/>',
|
||||
' <testcase name="b" status="SUCCESS" time="1.0"/>',
|
||||
'</testsuite></testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
const results = await parseMaestroResultsFromFileAttrs('/junit');
|
||||
expect(results).toEqual([expect.objectContaining({ name: 'a', path: 'a.yaml' })]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('junitFileHasFileAttrs', () => {
|
||||
it('returns true when every testcase has a non-empty file= attribute', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites><testsuite>',
|
||||
' <testcase name="a" file="a.yaml" status="SUCCESS" time="1.0"/>',
|
||||
' <testcase name="b" file="b.yaml" status="SUCCESS" time="1.0"/>',
|
||||
'</testsuite></testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
expect(await junitFileHasFileAttrs('/junit/report.xml')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when any testcase lacks file= (mixed report)', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites><testsuite>',
|
||||
' <testcase name="a" file="a.yaml" status="SUCCESS" time="1.0"/>',
|
||||
' <testcase name="b" status="SUCCESS" time="1.0"/>',
|
||||
'</testsuite></testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
expect(await junitFileHasFileAttrs('/junit/report.xml')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when file= is an empty string', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites><testsuite>',
|
||||
' <testcase name="a" file="" status="SUCCESS" time="1.0"/>',
|
||||
'</testsuite></testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
expect(await junitFileHasFileAttrs('/junit/report.xml')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for a legacy report with no file= attributes', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites><testsuite>',
|
||||
' <testcase name="a" status="SUCCESS" time="1.0"/>',
|
||||
'</testsuite></testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
expect(await junitFileHasFileAttrs('/junit/report.xml')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when the file cannot be read', async () => {
|
||||
expect(await junitFileHasFileAttrs('/missing.xml')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isFileAttrRun', () => {
|
||||
it('returns false for an empty list (an empty report is not a file-attr run)', () => {
|
||||
expect(isFileAttrRun([])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseJUnitTestCases, () => {
|
||||
it('extracts the file= attribute when present', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites><testsuite>',
|
||||
' <testcase name="login" file=".maestro/login.yaml" status="SUCCESS" time="1.0"/>',
|
||||
'</testsuite></testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
const results = await parseJUnitTestCases('/junit');
|
||||
expect(results[0].file).toBe('.maestro/login.yaml');
|
||||
});
|
||||
|
||||
it('treats a missing or empty file= attribute as undefined', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites><testsuite>',
|
||||
' <testcase name="a" status="SUCCESS" time="1.0"/>',
|
||||
' <testcase name="b" file="" status="SUCCESS" time="1.0"/>',
|
||||
'</testsuite></testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
const results = await parseJUnitTestCases('/junit');
|
||||
expect(results.find(r => r.name === 'a')!.file).toBeUndefined();
|
||||
expect(results.find(r => r.name === 'b')!.file).toBeUndefined();
|
||||
});
|
||||
|
||||
it('parses a single testcase with SUCCESS status', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
@@ -582,6 +748,100 @@ describe(parseJUnitTestCases, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseFailedFlowsFromFileAttrs, () => {
|
||||
it('returns the failing flows by file= (duplicate names retry correctly)', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/jr/attempt-0.xml': `<?xml version="1.0"?>
|
||||
<testsuites><testsuite>
|
||||
<testcase name="login" file="a/login.yaml" status="SUCCESS" time="1.0"/>
|
||||
<testcase name="login" file="b/login.yaml" time="1.0"><failure>x</failure></testcase>
|
||||
</testsuite></testsuites>`,
|
||||
'/proj/b/login.yaml': '',
|
||||
});
|
||||
const result = await parseFailedFlowsFromFileAttrs({
|
||||
junitFile: '/tmp/jr/attempt-0.xml',
|
||||
workingDirectory: '/proj',
|
||||
});
|
||||
expect(result).toEqual(['b/login.yaml']);
|
||||
});
|
||||
|
||||
it('returns null when a file= path does not exist on disk (dumb retry, never legacy)', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/jr/attempt-0.xml': `<?xml version="1.0"?>
|
||||
<testsuites><testsuite>
|
||||
<testcase name="gone" file="missing/flow.yaml" time="1.0"><failure>x</failure></testcase>
|
||||
</testsuite></testsuites>`,
|
||||
});
|
||||
const result = await parseFailedFlowsFromFileAttrs({
|
||||
junitFile: '/tmp/jr/attempt-0.xml',
|
||||
workingDirectory: '/proj',
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the report lacks file= attributes (callers must pre-check)', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/jr/attempt-0.xml': `<?xml version="1.0"?>
|
||||
<testsuites><testsuite>
|
||||
<testcase name="A" file="a.yaml" time="1.0"><failure>x</failure></testcase>
|
||||
<testcase name="B" time="1.0"><failure>y</failure></testcase>
|
||||
</testsuite></testsuites>`,
|
||||
'/proj/a.yaml': '',
|
||||
});
|
||||
const result = await parseFailedFlowsFromFileAttrs({
|
||||
junitFile: '/tmp/jr/attempt-0.xml',
|
||||
workingDirectory: '/proj',
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('dedupes repeated failing file= paths (same flow failed twice in one attempt)', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/jr/attempt-0.xml': `<?xml version="1.0"?>
|
||||
<testsuites><testsuite>
|
||||
<testcase name="login" file="flows/login.yaml" time="1.0"><failure>x</failure></testcase>
|
||||
<testcase name="login" file="flows/login.yaml" time="1.0"><failure>y</failure></testcase>
|
||||
</testsuite></testsuites>`,
|
||||
'/proj/flows/login.yaml': '',
|
||||
});
|
||||
const result = await parseFailedFlowsFromFileAttrs({
|
||||
junitFile: '/tmp/jr/attempt-0.xml',
|
||||
workingDirectory: '/proj',
|
||||
});
|
||||
expect(result).toEqual(['flows/login.yaml']);
|
||||
});
|
||||
|
||||
it('resolves and existence-checks an absolute file= path', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/jr/attempt-0.xml': `<?xml version="1.0"?>
|
||||
<testsuites><testsuite>
|
||||
<testcase name="ext" file="/outside/flow.yaml" time="1.0"><failure>x</failure></testcase>
|
||||
</testsuite></testsuites>`,
|
||||
'/outside/flow.yaml': '',
|
||||
});
|
||||
const result = await parseFailedFlowsFromFileAttrs({
|
||||
junitFile: '/tmp/jr/attempt-0.xml',
|
||||
workingDirectory: '/proj',
|
||||
});
|
||||
expect(result).toEqual(['/outside/flow.yaml']);
|
||||
});
|
||||
|
||||
it('returns null when junit XML is truncated mid-tag (partial parse risk)', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/jr/attempt-0.xml': `<?xml version="1.0"?>
|
||||
<testsuites><testsuite>
|
||||
<testcase name="A" file="a.yaml" time="1.0"><failure>x</failure></testcase>
|
||||
<testcase name="B" file="b.yaml"`, // intentionally truncated mid-tag
|
||||
'/proj/a.yaml': '',
|
||||
});
|
||||
const result = await parseFailedFlowsFromFileAttrs({
|
||||
junitFile: '/tmp/jr/attempt-0.xml',
|
||||
workingDirectory: '/proj',
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseFailedFlowsFromJUnit', () => {
|
||||
it('returns the subset of input flow paths whose testcases failed', async () => {
|
||||
vol.fromJSON({
|
||||
@@ -765,6 +1025,28 @@ describe('mergeJUnitReports', () => {
|
||||
expect(b.failure).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps a same-named flow from an earlier attempt when keyed by file=', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/r/android-maestro-junit-attempt-0.xml': `<?xml version="1.0"?>
|
||||
<testsuites><testsuite>
|
||||
<testcase name="login" file="a/login.yaml" status="SUCCESS" time="1.0"/>
|
||||
<testcase name="login" file="b/login.yaml" time="1.0"><failure>x</failure></testcase>
|
||||
</testsuite></testsuites>`,
|
||||
'/tmp/r/android-maestro-junit-attempt-1.xml': `<?xml version="1.0"?>
|
||||
<testsuites><testsuite>
|
||||
<testcase name="login" file="b/login.yaml" status="SUCCESS" time="1.0"/>
|
||||
</testsuite></testsuites>`,
|
||||
});
|
||||
|
||||
await mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' });
|
||||
|
||||
const out = await fs.readFile('/tmp/final.xml', 'utf-8');
|
||||
const parsed = new XMLParser({ ignoreAttributes: false }).parse(out);
|
||||
const testcases = parsed.testsuites.testsuite.testcase;
|
||||
const files = testcases.map((t: any) => t['@_file']).sort();
|
||||
expect(files).toEqual(['a/login.yaml', 'b/login.yaml']);
|
||||
});
|
||||
|
||||
it('throws when source directory contains no *.xml files', async () => {
|
||||
// No per-attempt JUnit files were ever written (maestro crashed before
|
||||
// producing output). Phase 3 in runMaestroTests treats this as a SystemError
|
||||
|
||||
@@ -273,6 +273,33 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the file= path on reports that carry it, without scanning flow files', async () => {
|
||||
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
|
||||
jest.spyOn(parser, 'junitFileHasFileAttrs').mockResolvedValue(true);
|
||||
const discoverySpy = jest.spyOn(discovery, 'buildFlowNameToPathMap');
|
||||
const legacyParseSpy = jest.spyOn(parser, 'parseFailedFlowsFromJUnit');
|
||||
const newParseSpy = jest
|
||||
.spyOn(parser, 'parseFailedFlowsFromFileAttrs')
|
||||
.mockResolvedValue(['flows/b.yaml']);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
|
||||
retries: 1,
|
||||
output_format: 'junit',
|
||||
retry_failed_only: true,
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
expect(newParseSpy).toHaveBeenCalledTimes(1);
|
||||
// The new branch needs no name→path map: no scan, no legacy parser.
|
||||
expect(discoverySpy).not.toHaveBeenCalled();
|
||||
expect(legacyParseSpy).not.toHaveBeenCalled();
|
||||
const a1 = mockedSpawn.mock.calls[1][1]!;
|
||||
expect(a1).toContain('flows/b.yaml');
|
||||
expect(a1).not.toContain('flows/a.yaml');
|
||||
});
|
||||
|
||||
it('falls back to all-flows retry when nameToPath is null (duplicate flow names)', async () => {
|
||||
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
|
||||
jest.spyOn(discovery, 'buildFlowNameToPathMap').mockResolvedValue(null);
|
||||
@@ -293,6 +320,44 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('never scans flow files when all attempts succeed', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
const discoverySpy = jest.spyOn(discovery, 'buildFlowNameToPathMap');
|
||||
|
||||
const step = createStep({ flow_path: ['flows/a.yaml'], platform: 'android' });
|
||||
await step.executeAsync();
|
||||
|
||||
expect(discoverySpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('memoizes the flow scan across retries (legacy reports)', async () => {
|
||||
// Routing note: the step lands in the legacy arm because the real
|
||||
// junitFileHasFileAttrs returns false — the attempt XML does not exist on
|
||||
// the mock fs. Creating that file with file= attrs would flip the branch.
|
||||
mockedSpawn
|
||||
.mockRejectedValueOnce(rejectExit1())
|
||||
.mockRejectedValueOnce(rejectExit1())
|
||||
.mockResolvedValueOnce(SPAWN_SUCCESS);
|
||||
const discoverySpy = jest
|
||||
.spyOn(discovery, 'buildFlowNameToPathMap')
|
||||
.mockResolvedValue(new Map([['a', 'flows/a.yaml']]));
|
||||
const parseSpy = jest
|
||||
.spyOn(parser, 'parseFailedFlowsFromJUnit')
|
||||
.mockResolvedValue(['flows/a.yaml']);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
retries: 2,
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
// Two failed attempts hit the legacy branch twice; the scan runs once.
|
||||
expect(parseSpy).toHaveBeenCalledTimes(2);
|
||||
expect(discoverySpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('defaults retry_failed_only to true when omitted from inputs', async () => {
|
||||
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
|
||||
jest.spyOn(parser, 'parseFailedFlowsFromJUnit').mockResolvedValue(['flows/b.yaml']);
|
||||
|
||||
+80
-3
@@ -3,6 +3,7 @@ import { vol } from 'memfs';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import * as discovery from '../maestroFlowDiscovery';
|
||||
import { createReportMaestroTestResultsFunction } from '../reportMaestroTestResults';
|
||||
|
||||
const JUNIT_PASS = [
|
||||
@@ -43,15 +44,24 @@ describe(createReportMaestroTestResultsFunction, () => {
|
||||
} as unknown as Client;
|
||||
});
|
||||
|
||||
// Returns a mock logger whose child() is itself, so step-level log calls can
|
||||
// be asserted on the returned object.
|
||||
function createSelfChildedLogger(): ReturnType<typeof createMockLogger> {
|
||||
const logger = createMockLogger();
|
||||
(logger.child as jest.Mock).mockReturnValue(logger);
|
||||
return logger;
|
||||
}
|
||||
|
||||
function createStep(overrides?: {
|
||||
callInputs?: Record<string, unknown>;
|
||||
staticContextContent?: Record<string, unknown>;
|
||||
env?: Record<string, string>;
|
||||
logger?: ReturnType<typeof createMockLogger>;
|
||||
}) {
|
||||
const ctx = { graphqlClient: mockGraphqlClient };
|
||||
const fn = createReportMaestroTestResultsFunction(ctx as any);
|
||||
const globalCtx = createGlobalContextMock({
|
||||
logger: createMockLogger(),
|
||||
logger: overrides?.logger ?? createMockLogger(),
|
||||
projectTargetDirectory: '/root/project',
|
||||
staticContextContent: {
|
||||
expoApiServerURL: 'https://api.expo.test',
|
||||
@@ -105,6 +115,54 @@ describe(createReportMaestroTestResultsFunction, () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports both flows when two share a name but have distinct file= paths', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites><testsuite>',
|
||||
' <testcase name="login" file="a/login.yaml" status="SUCCESS" time="1.0"/>',
|
||||
' <testcase name="login" file="b/login.yaml" status="SUCCESS" time="1.0"/>',
|
||||
'</testsuite></testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
mockMutationFn.mockResolvedValue({
|
||||
data: {
|
||||
workflowDeviceTestCaseResult: {
|
||||
createWorkflowDeviceTestCaseResults: [{ id: '1' }, { id: '2' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
await createStep().executeAsync();
|
||||
expect(mockGraphqlClient.mutation).toHaveBeenCalledTimes(1);
|
||||
const [, variables] = (mockGraphqlClient.mutation as jest.Mock).mock.calls[0];
|
||||
expect(variables.input.testCaseResults.map((r: any) => r.path).sort()).toEqual([
|
||||
'a/login.yaml',
|
||||
'b/login.yaml',
|
||||
]);
|
||||
});
|
||||
|
||||
it('never scans flow files for a file= report, even when flow_path is provided', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites><testsuite>',
|
||||
' <testcase name="login" file="flows/login.yaml" status="SUCCESS" time="1.0"/>',
|
||||
'</testsuite></testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
const discoverySpy = jest.spyOn(discovery, 'buildFlowNameToPathMap');
|
||||
mockMutationFn.mockResolvedValue({
|
||||
data: {
|
||||
workflowDeviceTestCaseResult: { createWorkflowDeviceTestCaseResults: [{ id: '1' }] },
|
||||
},
|
||||
});
|
||||
|
||||
await createStep({ callInputs: { flow_path: ['flows/login.yaml'] } }).executeAsync();
|
||||
|
||||
expect(mockGraphqlClient.mutation).toHaveBeenCalledTimes(1);
|
||||
expect(discoverySpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends tags and properties from JUnit XML', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
@@ -221,7 +279,7 @@ describe(createReportMaestroTestResultsFunction, () => {
|
||||
await createStep().executeAsync();
|
||||
});
|
||||
|
||||
it('skips report when duplicate testcase names found', async () => {
|
||||
it('skips report when duplicate testcase names found (legacy), advising unique names', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
@@ -238,8 +296,27 @@ describe(createReportMaestroTestResultsFunction, () => {
|
||||
}),
|
||||
});
|
||||
|
||||
await createStep().executeAsync();
|
||||
const logger = createSelfChildedLogger();
|
||||
await createStep({ logger }).executeAsync();
|
||||
expect(mockGraphqlClient.mutation).not.toHaveBeenCalled();
|
||||
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('unique name'));
|
||||
});
|
||||
|
||||
it('skips report when the same flow file appears twice in one attempt, advising flow_path check', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites><testsuite>',
|
||||
' <testcase name="login" file="flows/login.yaml" status="SUCCESS" time="1.0"/>',
|
||||
' <testcase name="login" file="flows/login.yaml" status="SUCCESS" time="2.0"/>',
|
||||
'</testsuite></testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
const logger = createSelfChildedLogger();
|
||||
await createStep({ logger }).executeAsync();
|
||||
expect(mockGraphqlClient.mutation).not.toHaveBeenCalled();
|
||||
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('flow_path entries'));
|
||||
});
|
||||
|
||||
it('sends per-attempt results with same name but different retryCount', async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import {
|
||||
@@ -7,7 +7,9 @@ import {
|
||||
BuildStepInputValueTypeName,
|
||||
BuildStepOutput,
|
||||
} from '@expo/steps';
|
||||
import { Client } from '@urql/core';
|
||||
import { glob } from 'fast-glob';
|
||||
import { graphql } from 'gql.tada';
|
||||
import fetch from 'node-fetch';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
@@ -16,6 +18,7 @@ import stream from 'stream';
|
||||
import { promisify } from 'util';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { formatBytes } from '../../utils/artifacts';
|
||||
import { decompressTarAsync, isFileTarGzAsync } from '../../utils/files';
|
||||
import { retryOnDNSFailure } from '../../utils/retryOnDNSFailure';
|
||||
@@ -23,7 +26,21 @@ import { pluralize } from '../../utils/strings';
|
||||
|
||||
const streamPipeline = promisify(stream.pipeline);
|
||||
|
||||
export function createDownloadBuildFunction(): BuildFunction {
|
||||
const BUILD_BY_ID_QUERY = graphql(`
|
||||
query DownloadBuildByIdQuery($buildId: ID!) {
|
||||
builds {
|
||||
byId(buildId: $buildId) {
|
||||
id
|
||||
platform
|
||||
artifacts {
|
||||
applicationArchiveUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export function createDownloadBuildFunction(ctx: CustomBuildContext): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'download_build',
|
||||
@@ -59,7 +76,7 @@ export function createDownloadBuildFunction(): BuildFunction {
|
||||
const { artifactPath } = await downloadBuildAsync({
|
||||
logger,
|
||||
buildId,
|
||||
expoApiServerURL: stepsCtx.global.staticContext.expoApiServerURL,
|
||||
graphqlClient: ctx.graphqlClient,
|
||||
robotAccessToken: stepsCtx.global.staticContext.job.secrets?.robotAccessToken ?? null,
|
||||
extensions,
|
||||
});
|
||||
@@ -69,16 +86,45 @@ export function createDownloadBuildFunction(): BuildFunction {
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchApplicationArchiveUrlAsync({
|
||||
buildId,
|
||||
graphqlClient,
|
||||
}: {
|
||||
buildId: string;
|
||||
graphqlClient: Client;
|
||||
}): Promise<string> {
|
||||
const result = await graphqlClient.query(BUILD_BY_ID_QUERY, { buildId }).toPromise();
|
||||
|
||||
if (result.error) {
|
||||
const { error } = result;
|
||||
const message = `Could not fetch build ${buildId}: ${error.message}`;
|
||||
|
||||
throw error.networkError || result.error.response?.status >= 500
|
||||
? new SystemError(message, { cause: error })
|
||||
: new UserError('EAS_DOWNLOAD_BUILD_FETCH_FAILED', message, { cause: error });
|
||||
}
|
||||
|
||||
const applicationArchiveUrl = result.data?.builds.byId?.artifacts?.applicationArchiveUrl;
|
||||
if (!applicationArchiveUrl) {
|
||||
throw new UserError(
|
||||
'EAS_DOWNLOAD_BUILD_NO_APPLICATION_ARCHIVE',
|
||||
'Build does not have an application archive url'
|
||||
);
|
||||
}
|
||||
|
||||
return applicationArchiveUrl;
|
||||
}
|
||||
|
||||
export async function downloadBuildAsync({
|
||||
logger,
|
||||
buildId,
|
||||
expoApiServerURL,
|
||||
graphqlClient,
|
||||
robotAccessToken,
|
||||
extensions,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
buildId: string;
|
||||
expoApiServerURL: string;
|
||||
graphqlClient: Client;
|
||||
robotAccessToken: string | null;
|
||||
extensions: string[];
|
||||
}): Promise<{ artifactPath: string }> {
|
||||
@@ -86,12 +132,11 @@ export async function downloadBuildAsync({
|
||||
path.join(os.tmpdir(), 'download_build-downloaded-')
|
||||
);
|
||||
|
||||
const response = await retryOnDNSFailure(fetch)(
|
||||
new URL(`/v2/artifacts/eas/${buildId}`, expoApiServerURL),
|
||||
{
|
||||
headers: robotAccessToken ? { Authorization: `Bearer ${robotAccessToken}` } : undefined,
|
||||
}
|
||||
);
|
||||
const downloadUrl = await fetchApplicationArchiveUrlAsync({ buildId, graphqlClient });
|
||||
|
||||
const response = await retryOnDNSFailure(fetch)(downloadUrl, {
|
||||
headers: robotAccessToken ? { Authorization: `Bearer ${robotAccessToken}` } : undefined,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const textResult = await asyncResult(response.text());
|
||||
|
||||
@@ -26,14 +26,14 @@ export function createFindAndUploadBuildArtifactsBuildFunction(
|
||||
try {
|
||||
await uploadApplicationArchivesAsync({ ctx, stepCtx });
|
||||
} catch (err: unknown) {
|
||||
stepCtx.logger.error(`Failed to upload application archives.`, err);
|
||||
stepCtx.logger.error({ err }, `Failed to upload application archives.`);
|
||||
firstError ||= err;
|
||||
}
|
||||
|
||||
try {
|
||||
await uploadBuildArtifacts({ ctx, stepCtx });
|
||||
} catch (err: unknown) {
|
||||
stepCtx.logger.error(`Failed to upload build artifacts.`, err);
|
||||
stepCtx.logger.error({ err }, `Failed to upload build artifacts.`);
|
||||
firstError ||= err;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { asyncResult } from '@expo/results';
|
||||
import { XMLBuilder, XMLParser, XMLValidator } from 'fast-xml-parser';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
@@ -18,6 +19,7 @@ const ATTEMPT_PATTERN = /attempt-(\d+)/;
|
||||
|
||||
export interface JUnitTestCaseResult {
|
||||
name: string;
|
||||
file: string | undefined;
|
||||
status: 'passed' | 'failed';
|
||||
duration: number; // milliseconds
|
||||
errorMessage: string | null;
|
||||
@@ -32,6 +34,12 @@ const xmlParser = new XMLParser({
|
||||
isArray: name => ['testsuite', 'testcase', 'property'].includes(name),
|
||||
});
|
||||
|
||||
// A `file=` attribute counts as present only when it is a non-empty string.
|
||||
function fileAttrOf(tc: any): string | undefined {
|
||||
const f = tc?.['@_file'];
|
||||
return typeof f === 'string' && f.length > 0 ? f : undefined;
|
||||
}
|
||||
|
||||
function parseJUnitContent(content: string): JUnitTestCaseResult[] {
|
||||
const results: JUnitTestCaseResult[] = [];
|
||||
try {
|
||||
@@ -54,6 +62,8 @@ function parseJUnitContent(content: string): JUnitTestCaseResult[] {
|
||||
continue;
|
||||
}
|
||||
|
||||
const file = fileAttrOf(tc);
|
||||
|
||||
const timeStr = tc['@_time'];
|
||||
const timeSeconds = timeStr ? parseFloat(timeStr) : 0;
|
||||
const duration = Number.isFinite(timeSeconds) ? Math.round(timeSeconds * 1000) : 0;
|
||||
@@ -94,7 +104,7 @@ function parseJUnitContent(content: string): JUnitTestCaseResult[] {
|
||||
: [];
|
||||
delete properties['tags'];
|
||||
|
||||
results.push({ name, status, duration, errorMessage, tags, properties });
|
||||
results.push({ name, file, status, duration, errorMessage, tags, properties });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -131,6 +141,142 @@ export async function parseJUnitTestCases(junitDirectory: string): Promise<JUnit
|
||||
return perFile.flat();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Maestro >= 2.6.0 code path: every <testcase> carries a `file=` attribute with
|
||||
// its flow's own path (all-or-none per run). Callers check the report first
|
||||
// (isFileAttrRun / junitFileHasFileAttrs) and route here; the legacy name→path
|
||||
// scan is then never consulted.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function isFileAttrRun(
|
||||
testcases: JUnitTestCaseResult[]
|
||||
): testcases is (JUnitTestCaseResult & { file: string })[] {
|
||||
return testcases.length > 0 && testcases.every(tc => tc.file !== undefined);
|
||||
}
|
||||
|
||||
// Unreadable or attribute-less reports ⇒ false: the caller then takes the
|
||||
// legacy path, whose own guards degrade safely.
|
||||
export async function junitFileHasFileAttrs(junitFile: string): Promise<boolean> {
|
||||
return isFileAttrRun(await parseJUnitFile(junitFile));
|
||||
}
|
||||
|
||||
async function fileExists(absPath: string): Promise<boolean> {
|
||||
return (await asyncResult(fs.stat(absPath))).ok;
|
||||
}
|
||||
|
||||
// Group by `file=` so two same-named flows in different files stay separate.
|
||||
export async function parseMaestroResultsFromFileAttrs(
|
||||
junitDirectory: string
|
||||
): Promise<MaestroFlowResult[]> {
|
||||
interface JUnitEntry {
|
||||
result: JUnitTestCaseResult;
|
||||
sourceFile: string;
|
||||
}
|
||||
|
||||
let junitEntries: string[];
|
||||
try {
|
||||
junitEntries = await fs.readdir(junitDirectory);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const xmlFiles = junitEntries.filter(f => f.endsWith('.xml')).sort();
|
||||
|
||||
const entries: JUnitEntry[] = [];
|
||||
for (const xmlFile of xmlFiles) {
|
||||
const fileResults = await parseJUnitFile(path.join(junitDirectory, xmlFile));
|
||||
for (const result of fileResults) {
|
||||
entries.push({ result, sourceFile: xmlFile });
|
||||
}
|
||||
}
|
||||
|
||||
const byPath = new Map<string, JUnitEntry[]>();
|
||||
for (const entry of entries) {
|
||||
// Callers verify the report with isFileAttrRun/junitFileHasFileAttrs first;
|
||||
// skip (rather than emit an undefined path) if that contract is violated.
|
||||
const flowPath = entry.result.file;
|
||||
if (flowPath === undefined) {
|
||||
continue;
|
||||
}
|
||||
const group = byPath.get(flowPath) ?? [];
|
||||
group.push(entry);
|
||||
byPath.set(flowPath, group);
|
||||
}
|
||||
|
||||
const results: MaestroFlowResult[] = [];
|
||||
for (const [flowPath, group] of byPath) {
|
||||
// retryCount is derived from each entry's source filename (`attempt-N`);
|
||||
// a single entry from `report.xml` (no marker) collapses to attempt 0.
|
||||
const sorted = group
|
||||
.map(entry => {
|
||||
const match = entry.sourceFile.match(ATTEMPT_PATTERN);
|
||||
const attemptIndex = match ? parseInt(match[1], 10) : 0;
|
||||
return { ...entry, attemptIndex };
|
||||
})
|
||||
.sort((a, b) => a.attemptIndex - b.attemptIndex);
|
||||
|
||||
for (const { result, attemptIndex } of sorted) {
|
||||
results.push({
|
||||
name: result.name,
|
||||
path: flowPath,
|
||||
status: result.status,
|
||||
errorMessage: result.errorMessage,
|
||||
duration: result.duration,
|
||||
retryCount: attemptIndex,
|
||||
tags: result.tags,
|
||||
properties: result.properties,
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the `file=` paths of the failing testcases in the given attempt's
|
||||
* JUnit file, or `null` when the result cannot be trusted (caller then falls
|
||||
* back to dumb retry — re-run everything; never the legacy scan).
|
||||
*
|
||||
* Callers verify the report carries `file=` attributes first
|
||||
* (junitFileHasFileAttrs).
|
||||
*/
|
||||
export async function parseFailedFlowsFromFileAttrs(args: {
|
||||
junitFile: string;
|
||||
workingDirectory: string;
|
||||
}): Promise<string[] | null> {
|
||||
// Same hardening as the legacy parser: fast-xml-parser is lenient — a
|
||||
// truncated XML could drop testcases, and trusting that partial failing set
|
||||
// would skip retries for cut-off flows.
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.readFile(args.junitFile, 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (XMLValidator.validate(content) !== true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const testcases = parseJUnitContent(content);
|
||||
if (!isFileAttrRun(testcases)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const failing = testcases.filter(tc => tc.status === 'failed');
|
||||
if (failing.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Each failing testcase is self-identifying via `file=`, so duplicate flow
|
||||
// names retry correctly. path.resolve (not join): `file=` may be absolute
|
||||
// when the flow lives outside the working directory.
|
||||
const paths = [...new Set(failing.map(tc => tc.file))];
|
||||
for (const p of paths) {
|
||||
if (!(await fileExists(path.resolve(args.workingDirectory, p)))) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
export async function parseMaestroResults(
|
||||
junitDirectory: string,
|
||||
nameToPath: Map<string, string> | null
|
||||
@@ -313,6 +459,13 @@ export async function mergeJUnitReports(args: {
|
||||
}
|
||||
const match = filename.match(ATTEMPT_PATTERN);
|
||||
const attemptIndex = match ? parseInt(match[1], 10) : 0;
|
||||
// Maestro >= 2.6.0 reports: key by `file=` so two same-named flows in
|
||||
// different files merge separately. Legacy reports keep name keying.
|
||||
const allCases = testsuites.flatMap((suite: any) =>
|
||||
Array.isArray(suite?.testcase) ? suite.testcase : []
|
||||
);
|
||||
const useFileKeys =
|
||||
allCases.length > 0 && allCases.every((tc: any) => fileAttrOf(tc) !== undefined);
|
||||
const testcasesByName = new Map<string, unknown[]>();
|
||||
for (const suite of testsuites) {
|
||||
const cases = suite?.testcase;
|
||||
@@ -324,9 +477,10 @@ export async function mergeJUnitReports(args: {
|
||||
if (typeof name !== 'string') {
|
||||
continue;
|
||||
}
|
||||
const group = testcasesByName.get(name) ?? [];
|
||||
const key = useFileKeys ? fileAttrOf(tc)! : name;
|
||||
const group = testcasesByName.get(key) ?? [];
|
||||
group.push(tc);
|
||||
testcasesByName.set(name, group);
|
||||
testcasesByName.set(key, group);
|
||||
}
|
||||
}
|
||||
if (testcasesByName.size === 0) {
|
||||
|
||||
@@ -14,7 +14,9 @@ import { z } from 'zod';
|
||||
import { buildFlowNameToPathMap } from './maestroFlowDiscovery';
|
||||
import {
|
||||
copyLatestAttemptXml,
|
||||
junitFileHasFileAttrs,
|
||||
mergeJUnitReports,
|
||||
parseFailedFlowsFromFileAttrs,
|
||||
parseFailedFlowsFromJUnit,
|
||||
} from './maestroResultParser';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
@@ -200,13 +202,10 @@ export function createMaestroTestsBuildFunction(): BuildFunction {
|
||||
throw new SystemError('Failed to create JUnit report directory', { cause: err });
|
||||
}
|
||||
|
||||
// null → duplicate flow names; retry-failed-only disabled, fall through to
|
||||
// dumb retry (re-run everything) on failure.
|
||||
const nameToPath = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: flowPaths,
|
||||
projectRoot: stepCtx.workingDirectory,
|
||||
logger,
|
||||
});
|
||||
// Legacy-only (Maestro < 2.6.0 reports carry no `file=` attribute): the
|
||||
// flow scan is built lazily in the retry branch below and memoized so
|
||||
// retries share one scan. Never runs when the report has `file=`.
|
||||
let nameToPathPromise: Promise<Map<string, string> | null> | undefined;
|
||||
|
||||
// Retry loop. spawn-async error shapes:
|
||||
// ENOENT/EACCES → infra (binary missing/not executable) → SystemError.
|
||||
@@ -214,7 +213,7 @@ export function createMaestroTestsBuildFunction(): BuildFunction {
|
||||
// else (signal-only, OOM kill, unknown) → infra → SystemError, never
|
||||
// downgraded to "tests failed".
|
||||
// Retry-failed-only (junit mode): after a failed attempt, subset to the failing
|
||||
// flows. parseFailedFlowsFromJUnit returns null when the JUnit cannot be
|
||||
// flows. The failed-flow parsers return null when the JUnit cannot be
|
||||
// trusted; we then fall through to dumb retry (re-run everything).
|
||||
let flowsToRun: string[] = flowPaths;
|
||||
let lastAttemptExitCode: number | null = null;
|
||||
@@ -263,11 +262,26 @@ export function createMaestroTestsBuildFunction(): BuildFunction {
|
||||
break;
|
||||
}
|
||||
|
||||
if (retryFailedOnly && outputFormat === 'junit' && outputPath && nameToPath) {
|
||||
const failed = await parseFailedFlowsFromJUnit({
|
||||
junitFile: outputPath,
|
||||
nameToPath,
|
||||
});
|
||||
if (retryFailedOnly && outputFormat === 'junit' && outputPath) {
|
||||
let failed: string[] | null;
|
||||
if (await junitFileHasFileAttrs(outputPath)) {
|
||||
failed = await parseFailedFlowsFromFileAttrs({
|
||||
junitFile: outputPath,
|
||||
workingDirectory: stepCtx.workingDirectory,
|
||||
});
|
||||
} else {
|
||||
// Legacy (Maestro < 2.6.0): map failed testcase names back to flow
|
||||
// paths via the flow-file scan. DELETE this arm once the fleet is
|
||||
// on >= 2.6.0.
|
||||
const nameToPath = await (nameToPathPromise ??= buildFlowNameToPathMap({
|
||||
inputFlowPaths: flowPaths,
|
||||
projectRoot: stepCtx.workingDirectory,
|
||||
logger,
|
||||
}));
|
||||
failed = nameToPath
|
||||
? await parseFailedFlowsFromJUnit({ junitFile: outputPath, nameToPath })
|
||||
: null;
|
||||
}
|
||||
if (failed !== null && failed.length > 0) {
|
||||
flowsToRun = failed;
|
||||
logger.info(
|
||||
|
||||
@@ -3,7 +3,13 @@ import { graphql } from 'gql.tada';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { buildFlowNameToPathMap } from './maestroFlowDiscovery';
|
||||
import { MaestroFlowResult, parseMaestroResults } from './maestroResultParser';
|
||||
import {
|
||||
MaestroFlowResult,
|
||||
isFileAttrRun,
|
||||
parseJUnitTestCases,
|
||||
parseMaestroResults,
|
||||
parseMaestroResultsFromFileAttrs,
|
||||
} from './maestroResultParser';
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
|
||||
const FlowPathSchema = z.array(z.string().min(1)).min(1);
|
||||
@@ -61,47 +67,64 @@ export function createReportMaestroTestResultsFunction(ctx: CustomBuildContext):
|
||||
return;
|
||||
}
|
||||
|
||||
const flowPathRaw = inputs.flow_path.value;
|
||||
let nameToPath: Map<string, string> | null = null;
|
||||
if (flowPathRaw !== undefined) {
|
||||
const parsed = FlowPathSchema.safeParse(flowPathRaw);
|
||||
if (parsed.success) {
|
||||
nameToPath = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: parsed.data,
|
||||
projectRoot: stepsCtx.workingDirectory,
|
||||
logger,
|
||||
});
|
||||
} else {
|
||||
logger.warn(
|
||||
'Ignoring malformed flow_path input (expected a non-empty array of non-empty strings).'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const flowResults = await parseMaestroResults(junitDirectory, nameToPath);
|
||||
// Maestro >= 2.6.0 stamps every <testcase> with its flow's path in a
|
||||
// `file=` attribute; older reports need the legacy flow-file scan to
|
||||
// map testcase names back to paths.
|
||||
const usedFileAttrs = isFileAttrRun(await parseJUnitTestCases(junitDirectory));
|
||||
|
||||
let flowResults: MaestroFlowResult[];
|
||||
if (usedFileAttrs) {
|
||||
flowResults = await parseMaestroResultsFromFileAttrs(junitDirectory);
|
||||
} else {
|
||||
// Legacy (Maestro < 2.6.0) — DELETE this arm once the fleet is on >= 2.6.0.
|
||||
const flowPathRaw = inputs.flow_path.value;
|
||||
let nameToPath: Map<string, string> | null = null;
|
||||
if (flowPathRaw !== undefined) {
|
||||
const parsed = FlowPathSchema.safeParse(flowPathRaw);
|
||||
if (parsed.success) {
|
||||
nameToPath = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: parsed.data,
|
||||
projectRoot: stepsCtx.workingDirectory,
|
||||
logger,
|
||||
});
|
||||
} else {
|
||||
logger.warn(
|
||||
'Ignoring malformed flow_path input (expected a non-empty array of non-empty strings).'
|
||||
);
|
||||
}
|
||||
}
|
||||
flowResults = await parseMaestroResults(junitDirectory, nameToPath);
|
||||
}
|
||||
|
||||
if (flowResults.length === 0) {
|
||||
logger.info('No maestro test results found, skipping report');
|
||||
return;
|
||||
}
|
||||
|
||||
// Detect truly conflicting results: same (name, retryCount) pair means different flow files
|
||||
// share the same name (Maestro config override), which we can't disambiguate.
|
||||
// Same name with different retryCount is expected (per-attempt results from retries).
|
||||
// Detect truly conflicting results: the same (path, retryCount) twice means two
|
||||
// flow files resolved to the same path, which we can't disambiguate and the API
|
||||
// rejects as a duplicate. Same path with different retryCount is expected
|
||||
// (per-attempt results from retries).
|
||||
const seen = new Set<string>();
|
||||
const conflicting = new Set<string>();
|
||||
for (const r of flowResults) {
|
||||
const key = `${r.name}:${r.retryCount}`;
|
||||
const key = `${r.path}:${r.retryCount}`;
|
||||
if (seen.has(key)) {
|
||||
conflicting.add(r.name);
|
||||
conflicting.add(r.path);
|
||||
}
|
||||
seen.add(key);
|
||||
}
|
||||
if (conflicting.size > 0) {
|
||||
const conflictList = [...conflicting].join(', ');
|
||||
logger.error(
|
||||
`Duplicate test case names found in JUnit output: ${[...conflicting].join(
|
||||
', '
|
||||
)}. Skipping report. Ensure each Maestro flow has a unique name.`
|
||||
usedFileAttrs
|
||||
? `The same flow file was reported more than once in a single attempt: ` +
|
||||
`${conflictList}. Skipping report. Check for duplicate flow_path entries ` +
|
||||
`or leftover XML files in the JUnit report directory.`
|
||||
: `Duplicate Maestro flow names found: ${conflictList}. Skipping report. ` +
|
||||
`Give each flow a unique name, or upgrade Maestro to >= 2.6.0 (flows are ` +
|
||||
`then identified by file path, so duplicate names are fine).`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -206,16 +206,16 @@ export async function restoreGradleCacheAsync({
|
||||
}
|
||||
|
||||
try {
|
||||
const gradlePropertiesPath = path.join(workingDirectory, 'android', 'gradle.properties');
|
||||
const gradlePropertiesContent = await fs.promises.readFile(gradlePropertiesPath, 'utf-8');
|
||||
await fs.promises.writeFile(
|
||||
gradlePropertiesPath,
|
||||
`${gradlePropertiesContent}\n\norg.gradle.caching=true\n`
|
||||
);
|
||||
// Enable build cache via user-level gradle.properties.
|
||||
// This avoids mutating android/gradle.properties which affects fingerprinting.
|
||||
const gradleUserHome = path.join(os.homedir(), '.gradle');
|
||||
await fs.promises.mkdir(gradleUserHome, { recursive: true });
|
||||
const userGradlePropertiesPath = path.join(gradleUserHome, 'gradle.properties');
|
||||
await fs.promises.appendFile(userGradlePropertiesPath, '\norg.gradle.caching=true\n');
|
||||
|
||||
// Configure cache cleanup via init script (works with both Gradle 8 and 9,
|
||||
// org.gradle.cache.cleanup property was removed in Gradle 9)
|
||||
const initScriptDir = path.join(os.homedir(), '.gradle', 'init.d');
|
||||
// Configure cache cleanup via init script.
|
||||
// Works with both Gradle 8 and 9 (org.gradle.cache.cleanup property was removed in Gradle 9).
|
||||
const initScriptDir = path.join(gradleUserHome, 'init.d');
|
||||
await fs.promises.mkdir(initScriptDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(initScriptDir, 'eas-cache-cleanup.gradle'),
|
||||
|
||||
@@ -90,7 +90,7 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
// on Darwin. Android sessions go without a preview URL.
|
||||
let webPreviewUrl: string | undefined;
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
const { previewUrl } = await startServeSimWithTunnelAsync({
|
||||
const { previewUrl } = await startServeSimWithTunnelAsync(ctx, {
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
|
||||
@@ -101,7 +101,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
// serve-sim is iOS-only — Android sessions go without a preview URL.
|
||||
let webPreviewUrl: string | undefined;
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
const serveSim = await startServeSimWithTunnelAsync({
|
||||
const serveSim = await startServeSimWithTunnelAsync(ctx, {
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
|
||||
@@ -30,7 +30,7 @@ export function createStartServeSimRemoteSessionBuildFunction(
|
||||
logger.info(`Selecting Xcode developer directory: ${XCODE_DEVELOPER_DIR}.`);
|
||||
await spawn('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], { env, logger });
|
||||
|
||||
const { previewUrl, streamUrl } = await startServeSimWithTunnelAsync({
|
||||
const { previewUrl, streamUrl } = await startServeSimWithTunnelAsync(ctx, {
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
|
||||
@@ -128,7 +128,7 @@ export function createUploadArtifactBuildFunction(ctx: CustomBuildContext): Buil
|
||||
}
|
||||
} catch (error) {
|
||||
if (inputs.ignore_error.value) {
|
||||
logger.error(`Failed to upload ${artifact.type}. Ignoring error.`, error);
|
||||
logger.error({ err: error }, `Failed to upload ${artifact.type}. Ignoring error.`);
|
||||
// Ignoring error.
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { BuildStepEnv } from '@expo/steps';
|
||||
|
||||
import { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import {
|
||||
fetchServeSimTurnArgsAsync,
|
||||
turnIceServersToServeSimArgs,
|
||||
} from '../remoteDeviceRunSession';
|
||||
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
jest.mock('../../../sentry');
|
||||
|
||||
function createLoggerMock(): bunyan {
|
||||
return {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
} as unknown as bunyan;
|
||||
}
|
||||
|
||||
function createCtxMock(): CustomBuildContext {
|
||||
return {
|
||||
env: {
|
||||
__API_SERVER_URL: 'https://api.expo.test',
|
||||
},
|
||||
job: {
|
||||
secrets: { robotAccessToken: 'robot-token' },
|
||||
},
|
||||
} as unknown as CustomBuildContext;
|
||||
}
|
||||
|
||||
function createEnvMock(): BuildStepEnv {
|
||||
return { DEVICE_RUN_SESSION_ID: 'drs-id' } as unknown as BuildStepEnv;
|
||||
}
|
||||
|
||||
describe(turnIceServersToServeSimArgs, () => {
|
||||
it('returns no args for an empty ICE server list', () => {
|
||||
expect(turnIceServersToServeSimArgs([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('builds --stun-url and --turn-url flags from Cloudflare ICE servers', () => {
|
||||
expect(
|
||||
turnIceServersToServeSimArgs([
|
||||
{ urls: ['stun:stun.cloudflare.com:3478', 'stun:stun.cloudflare.com:53'] },
|
||||
{
|
||||
urls: [
|
||||
'turn:turn.cloudflare.com:3478?transport=udp',
|
||||
'turns:turn.cloudflare.com:443?transport=tcp',
|
||||
],
|
||||
username: 'user-123',
|
||||
credential: 'cred-456',
|
||||
},
|
||||
])
|
||||
).toEqual([
|
||||
'--stun-url',
|
||||
'stun:stun.cloudflare.com:3478,stun:stun.cloudflare.com:53',
|
||||
'--turn-url',
|
||||
'turn:turn.cloudflare.com:3478?transport=udp,turns:turn.cloudflare.com:443?transport=tcp',
|
||||
'--turn-username',
|
||||
'user-123',
|
||||
'--turn-credential',
|
||||
'cred-456',
|
||||
]);
|
||||
});
|
||||
|
||||
it('emits only --turn-url flags when no credential-less (STUN) entry is present', () => {
|
||||
expect(
|
||||
turnIceServersToServeSimArgs([
|
||||
{
|
||||
urls: ['turns:turn.cloudflare.com:443?transport=tcp'],
|
||||
username: 'u',
|
||||
credential: 'c',
|
||||
},
|
||||
])
|
||||
).toEqual([
|
||||
'--turn-url',
|
||||
'turns:turn.cloudflare.com:443?transport=tcp',
|
||||
'--turn-username',
|
||||
'u',
|
||||
'--turn-credential',
|
||||
'c',
|
||||
]);
|
||||
});
|
||||
|
||||
it('emits only --stun-url when there is no credentialed TURN entry', () => {
|
||||
expect(turnIceServersToServeSimArgs([{ urls: ['stun:stun.cloudflare.com:3478'] }])).toEqual([
|
||||
'--stun-url',
|
||||
'stun:stun.cloudflare.com:3478',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(fetchServeSimTurnArgsAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(turtleFetch).mockReset();
|
||||
});
|
||||
|
||||
it('requests TURN ICE servers from the device run session endpoint and returns serve-sim args', async () => {
|
||||
jest.mocked(turtleFetch).mockResolvedValue({
|
||||
json: async () => ({
|
||||
data: {
|
||||
iceServers: [
|
||||
{ urls: ['stun:stun.cloudflare.com:3478'] },
|
||||
{
|
||||
urls: ['turns:turn.cloudflare.com:443?transport=tcp'],
|
||||
username: 'u',
|
||||
credential: 'c',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
} as unknown as Awaited<ReturnType<typeof turtleFetch>>);
|
||||
|
||||
const args = await fetchServeSimTurnArgsAsync(createCtxMock(), {
|
||||
env: createEnvMock(),
|
||||
logger: createLoggerMock(),
|
||||
});
|
||||
|
||||
expect(args).toEqual([
|
||||
'--stun-url',
|
||||
'stun:stun.cloudflare.com:3478',
|
||||
'--turn-url',
|
||||
'turns:turn.cloudflare.com:443?transport=tcp',
|
||||
'--turn-username',
|
||||
'u',
|
||||
'--turn-credential',
|
||||
'c',
|
||||
]);
|
||||
expect(jest.mocked(turtleFetch)).toHaveBeenCalledWith(
|
||||
'https://api.expo.test/v2/device-run-sessions/drs-id/turn-ice-servers',
|
||||
'POST',
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: 'Bearer robot-token' },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('returns [] and warns when the request fails so serve-sim falls back to P2P/STUN', async () => {
|
||||
jest.mocked(turtleFetch).mockRejectedValue(new Error('boom'));
|
||||
const logger = createLoggerMock();
|
||||
|
||||
const args = await fetchServeSimTurnArgsAsync(createCtxMock(), {
|
||||
env: createEnvMock(),
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(args).toEqual([]);
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
expect(jest.mocked(Sentry).capture).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -4,11 +4,15 @@ import { BuildStepEnv } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import * as ngrok from '@ngrok/ngrok';
|
||||
import { graphql } from 'gql.tada';
|
||||
import nullthrows from 'nullthrows';
|
||||
import { z } from 'zod';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
import { turtleFetch } from '../../utils/turtleFetch';
|
||||
|
||||
const START_DEVICE_RUN_SESSION_MUTATION = graphql(`
|
||||
mutation StartDeviceRunSession($deviceRunSessionId: ID!, $remoteConfig: JSONObject!) {
|
||||
@@ -60,6 +64,105 @@ export function getNgrokAuthtokenOrThrow(env: BuildStepEnv): string {
|
||||
return authtoken;
|
||||
}
|
||||
|
||||
const TurnIceServersSchema = z.array(
|
||||
z.object({
|
||||
urls: z.array(z.string()),
|
||||
username: z.string().optional(),
|
||||
credential: z.string().optional(),
|
||||
})
|
||||
);
|
||||
|
||||
export type TurnIceServers = z.infer<typeof TurnIceServersSchema>;
|
||||
|
||||
const TurnIceServersResponseSchema = z.object({
|
||||
data: z.object({
|
||||
iceServers: TurnIceServersSchema,
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Translate Cloudflare ICE servers into serve-sim CLI flags: `--stun-url` (the
|
||||
* credential-less entries) and `--turn-url`/`--turn-username`/`--turn-credential`
|
||||
* (the entry carrying the short-lived credentials).
|
||||
*/
|
||||
export function turnIceServersToServeSimArgs(iceServers: TurnIceServers): string[] {
|
||||
const stunUrls = iceServers
|
||||
.filter(server => !server.username && !server.credential)
|
||||
.flatMap(server => server.urls);
|
||||
const turnServer = iceServers.find(server => server.username && server.credential);
|
||||
|
||||
const args: string[] = [];
|
||||
if (stunUrls.length > 0) {
|
||||
args.push('--stun-url', stunUrls.join(','));
|
||||
}
|
||||
if (turnServer?.username && turnServer.credential && turnServer.urls.length > 0) {
|
||||
args.push(
|
||||
'--turn-url',
|
||||
turnServer.urls.join(','),
|
||||
'--turn-username',
|
||||
turnServer.username,
|
||||
'--turn-credential',
|
||||
turnServer.credential
|
||||
);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch short-lived Cloudflare TURN ICE servers for this job run from www
|
||||
* (minted on demand, mirroring how the worker fetches project clone URLs) and
|
||||
* translate them into serve-sim CLI flags.
|
||||
*
|
||||
* Best-effort: on any failure we log and return [] so serve-sim falls back to
|
||||
* its built-in P2P/STUN behavior. The credential is passed to serve-sim as a
|
||||
* process arg and deliberately not logged (turtle-spawn never logs argv and the
|
||||
* worker is single-tenant).
|
||||
*/
|
||||
export async function fetchServeSimTurnArgsAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{ env, logger }: { env: BuildStepEnv; logger: bunyan }
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
|
||||
const expoApiServerUrl = nullthrows(ctx.env.__API_SERVER_URL, '__API_SERVER_URL is not set');
|
||||
const robotAccessToken = nullthrows(
|
||||
ctx.job.secrets?.robotAccessToken,
|
||||
'robot access token is not set'
|
||||
);
|
||||
|
||||
const response = await turtleFetch(
|
||||
new URL(
|
||||
`/v2/device-run-sessions/${deviceRunSessionId}/turn-ice-servers`,
|
||||
expoApiServerUrl
|
||||
).toString(),
|
||||
'POST',
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${robotAccessToken}`,
|
||||
},
|
||||
timeout: 5000,
|
||||
retries: 1,
|
||||
logger,
|
||||
}
|
||||
);
|
||||
|
||||
const { data } = TurnIceServersResponseSchema.parse(await response.json());
|
||||
const args = turnIceServersToServeSimArgs(data.iceServers);
|
||||
if (args.length > 0) {
|
||||
logger.info('Configured serve-sim with Cloudflare TURN ICE servers.');
|
||||
}
|
||||
return args;
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Could not fetch Cloudflare TURN ICE servers', error, { level: 'warning' });
|
||||
logger.warn(
|
||||
{ err: error },
|
||||
'Could not fetch Cloudflare TURN ICE servers; serve-sim will fall back to P2P/STUN.'
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadRemoteSessionConfigAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
@@ -120,18 +223,22 @@ export function spawnDetached({
|
||||
return { getOutput: () => output };
|
||||
}
|
||||
|
||||
export async function startServeSimWithTunnelAsync({
|
||||
baseDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs,
|
||||
}: {
|
||||
baseDomain: string;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
timeoutMs: number;
|
||||
}): Promise<{ previewUrl: string; streamUrl: string }> {
|
||||
export async function startServeSimWithTunnelAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
baseDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs,
|
||||
}: {
|
||||
baseDomain: string;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
timeoutMs: number;
|
||||
}
|
||||
): Promise<{ previewUrl: string; streamUrl: string }> {
|
||||
logger.info('Launching serve-sim with tunnel.');
|
||||
const turnArgs = await fetchServeSimTurnArgsAsync(ctx, { env, logger });
|
||||
const serveSim = spawnDetached({
|
||||
command: 'npx',
|
||||
args: [
|
||||
@@ -147,6 +254,7 @@ export async function startServeSimWithTunnelAsync({
|
||||
'0.55',
|
||||
'--codec',
|
||||
'webrtc',
|
||||
...turnArgs,
|
||||
],
|
||||
env,
|
||||
});
|
||||
|
||||
+130
-119
@@ -158,7 +158,7 @@ eas --help COMMAND
|
||||
* [`eas update:republish`](#eas-updaterepublish)
|
||||
* [`eas update:revert-update-rollout`](#eas-updaterevert-update-rollout)
|
||||
* [`eas update:roll-back-to-embedded`](#eas-updateroll-back-to-embedded)
|
||||
* [`eas update:rollback`](#eas-updaterollback)
|
||||
* [`eas update:rollback [GROUPID]`](#eas-updaterollback-groupid)
|
||||
* [`eas update:view GROUPID`](#eas-updateview-groupid)
|
||||
* [`eas upload`](#eas-upload)
|
||||
* [`eas webhook:create`](#eas-webhookcreate)
|
||||
@@ -199,7 +199,7 @@ ALIASES
|
||||
$ eas login
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/account/login.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/account/login.ts)_
|
||||
|
||||
## `eas account:logout`
|
||||
|
||||
@@ -216,7 +216,7 @@ ALIASES
|
||||
$ eas logout
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/account/logout.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/account/logout.ts)_
|
||||
|
||||
## `eas account:usage [ACCOUNT_NAME]`
|
||||
|
||||
@@ -238,7 +238,7 @@ DESCRIPTION
|
||||
view account usage and billing for the current cycle
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/usage.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/account/usage.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/usage.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/account/usage.ts)_
|
||||
|
||||
## `eas account:view`
|
||||
|
||||
@@ -255,7 +255,7 @@ ALIASES
|
||||
$ eas whoami
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/account/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/account/view.ts)_
|
||||
|
||||
## `eas analytics [STATUS]`
|
||||
|
||||
@@ -269,7 +269,7 @@ DESCRIPTION
|
||||
display or change analytics settings
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/analytics.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/analytics.ts)_
|
||||
|
||||
## `eas autocomplete [SHELL]`
|
||||
|
||||
@@ -321,7 +321,7 @@ DESCRIPTION
|
||||
create a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/branch/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/branch/create.ts)_
|
||||
|
||||
## `eas branch:delete [NAME]`
|
||||
|
||||
@@ -342,7 +342,7 @@ DESCRIPTION
|
||||
delete a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/branch/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/branch/delete.ts)_
|
||||
|
||||
## `eas branch:list`
|
||||
|
||||
@@ -362,7 +362,7 @@ DESCRIPTION
|
||||
list all branches
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/branch/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/branch/list.ts)_
|
||||
|
||||
## `eas branch:rename`
|
||||
|
||||
@@ -382,7 +382,7 @@ DESCRIPTION
|
||||
rename a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/branch/rename.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/branch/rename.ts)_
|
||||
|
||||
## `eas branch:view [NAME]`
|
||||
|
||||
@@ -405,7 +405,7 @@ DESCRIPTION
|
||||
view a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/branch/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/branch/view.ts)_
|
||||
|
||||
## `eas build`
|
||||
|
||||
@@ -446,7 +446,7 @@ DESCRIPTION
|
||||
start a build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/build/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/build/index.ts)_
|
||||
|
||||
## `eas build:cancel [BUILD_ID]`
|
||||
|
||||
@@ -466,7 +466,7 @@ DESCRIPTION
|
||||
cancel a build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/build/cancel.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/build/cancel.ts)_
|
||||
|
||||
## `eas build:configure`
|
||||
|
||||
@@ -484,7 +484,7 @@ DESCRIPTION
|
||||
configure the project to support EAS Build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/build/configure.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/build/configure.ts)_
|
||||
|
||||
## `eas build:delete [BUILD_ID]`
|
||||
|
||||
@@ -504,7 +504,7 @@ DESCRIPTION
|
||||
delete a build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/build/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/build/delete.ts)_
|
||||
|
||||
## `eas build:dev`
|
||||
|
||||
@@ -529,7 +529,7 @@ DESCRIPTION
|
||||
run dev client simulator/emulator build with matching fingerprint or create a new one
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/dev.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/build/dev.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/dev.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/build/dev.ts)_
|
||||
|
||||
## `eas build:download`
|
||||
|
||||
@@ -556,7 +556,7 @@ DESCRIPTION
|
||||
download a simulator/emulator build by build ID or fingerprint hash
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/build/download.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/build/download.ts)_
|
||||
|
||||
## `eas build:inspect`
|
||||
|
||||
@@ -594,7 +594,7 @@ DESCRIPTION
|
||||
inspect the state of the project at specific build stages, useful for troubleshooting
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/build/inspect.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/build/inspect.ts)_
|
||||
|
||||
## `eas build:list`
|
||||
|
||||
@@ -634,7 +634,7 @@ DESCRIPTION
|
||||
list all builds for your project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/build/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/build/list.ts)_
|
||||
|
||||
## `eas build:resign`
|
||||
|
||||
@@ -664,7 +664,7 @@ DESCRIPTION
|
||||
re-sign a build archive
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/build/resign.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/build/resign.ts)_
|
||||
|
||||
## `eas build:run`
|
||||
|
||||
@@ -692,7 +692,7 @@ DESCRIPTION
|
||||
run simulator/emulator builds from eas-cli
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/build/run.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/build/run.ts)_
|
||||
|
||||
## `eas build:submit`
|
||||
|
||||
@@ -744,7 +744,7 @@ DESCRIPTION
|
||||
get the latest version from EAS servers
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/build/version/get.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/build/version/get.ts)_
|
||||
|
||||
## `eas build:version:set`
|
||||
|
||||
@@ -762,7 +762,7 @@ DESCRIPTION
|
||||
update version of an app
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/build/version/set.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/build/version/set.ts)_
|
||||
|
||||
## `eas build:version:sync`
|
||||
|
||||
@@ -780,7 +780,7 @@ DESCRIPTION
|
||||
update a version in native code with a value stored on EAS servers
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/build/version/sync.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/build/version/sync.ts)_
|
||||
|
||||
## `eas build:view [BUILD_ID]`
|
||||
|
||||
@@ -797,7 +797,7 @@ DESCRIPTION
|
||||
view a build for your project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/build/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/build/view.ts)_
|
||||
|
||||
## `eas channel:create [NAME]`
|
||||
|
||||
@@ -818,7 +818,7 @@ DESCRIPTION
|
||||
create a channel
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/channel/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/channel/create.ts)_
|
||||
|
||||
## `eas channel:delete [NAME]`
|
||||
|
||||
@@ -839,7 +839,7 @@ DESCRIPTION
|
||||
Delete a channel
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/channel/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/channel/delete.ts)_
|
||||
|
||||
## `eas channel:edit [NAME]`
|
||||
|
||||
@@ -861,7 +861,7 @@ DESCRIPTION
|
||||
point a channel at a new branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/channel/edit.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/channel/edit.ts)_
|
||||
|
||||
## `eas channel:insights`
|
||||
|
||||
@@ -885,7 +885,7 @@ DESCRIPTION
|
||||
display adoption, crash, and unique-user insights for a channel + runtime version
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/insights.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/channel/insights.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/insights.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/channel/insights.ts)_
|
||||
|
||||
## `eas channel:list`
|
||||
|
||||
@@ -905,7 +905,7 @@ DESCRIPTION
|
||||
list all channels
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/channel/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/channel/list.ts)_
|
||||
|
||||
## `eas channel:pause [NAME]`
|
||||
|
||||
@@ -927,7 +927,7 @@ DESCRIPTION
|
||||
pause a channel to stop it from sending updates
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/pause.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/channel/pause.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/pause.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/channel/pause.ts)_
|
||||
|
||||
## `eas channel:resume [NAME]`
|
||||
|
||||
@@ -949,7 +949,7 @@ DESCRIPTION
|
||||
resume a channel to start sending updates
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/resume.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/channel/resume.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/resume.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/channel/resume.ts)_
|
||||
|
||||
## `eas channel:rollout [CHANNEL]`
|
||||
|
||||
@@ -984,7 +984,7 @@ DESCRIPTION
|
||||
Roll a new branch out on a channel incrementally.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/channel/rollout.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/channel/rollout.ts)_
|
||||
|
||||
## `eas channel:view [NAME]`
|
||||
|
||||
@@ -1007,7 +1007,7 @@ DESCRIPTION
|
||||
view a channel
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/channel/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/channel/view.ts)_
|
||||
|
||||
## `eas config`
|
||||
|
||||
@@ -1028,7 +1028,7 @@ DESCRIPTION
|
||||
display project configuration (app.json + eas.json)
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/config.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/config.ts)_
|
||||
|
||||
## `eas credentials`
|
||||
|
||||
@@ -1045,7 +1045,7 @@ DESCRIPTION
|
||||
manage credentials
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/credentials/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/credentials/index.ts)_
|
||||
|
||||
## `eas credentials:configure-build`
|
||||
|
||||
@@ -1063,7 +1063,7 @@ DESCRIPTION
|
||||
Set up credentials for building your project.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/credentials/configure-build.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/credentials/configure-build.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/credentials/configure-build.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/credentials/configure-build.ts)_
|
||||
|
||||
## `eas deploy [options]`
|
||||
|
||||
@@ -1092,7 +1092,7 @@ ALIASES
|
||||
$ eas worker:deploy
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/deploy/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/deploy/index.ts)_
|
||||
|
||||
## `eas deploy:alias`
|
||||
|
||||
@@ -1117,7 +1117,7 @@ ALIASES
|
||||
$ eas deploy:promote
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/deploy/alias/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/deploy/alias/index.ts)_
|
||||
|
||||
## `eas deploy:alias:delete [ALIAS_NAME]`
|
||||
|
||||
@@ -1138,7 +1138,7 @@ ALIASES
|
||||
$ eas worker:alias:delete
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
|
||||
|
||||
## `eas deploy:delete [DEPLOYMENT_ID]`
|
||||
|
||||
@@ -1159,7 +1159,7 @@ ALIASES
|
||||
$ eas worker:delete
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/deploy/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/deploy/delete.ts)_
|
||||
|
||||
## `eas deploy:promote`
|
||||
|
||||
@@ -1196,7 +1196,7 @@ DESCRIPTION
|
||||
register new Apple Devices to use for internal distribution
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/device/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/device/create.ts)_
|
||||
|
||||
## `eas device:delete`
|
||||
|
||||
@@ -1216,7 +1216,7 @@ DESCRIPTION
|
||||
remove a registered device from your account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/device/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/device/delete.ts)_
|
||||
|
||||
## `eas device:list`
|
||||
|
||||
@@ -1237,7 +1237,7 @@ DESCRIPTION
|
||||
list all registered devices for your account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/device/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/device/list.ts)_
|
||||
|
||||
## `eas device:rename`
|
||||
|
||||
@@ -1258,7 +1258,7 @@ DESCRIPTION
|
||||
rename a registered device
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/device/rename.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/device/rename.ts)_
|
||||
|
||||
## `eas device:view [UDID]`
|
||||
|
||||
@@ -1272,7 +1272,7 @@ DESCRIPTION
|
||||
view a device for your project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/device/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/device/view.ts)_
|
||||
|
||||
## `eas diagnostics`
|
||||
|
||||
@@ -1286,7 +1286,7 @@ DESCRIPTION
|
||||
display environment info
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/diagnostics.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/diagnostics.ts)_
|
||||
|
||||
## `eas env:create [ENVIRONMENT]`
|
||||
|
||||
@@ -1318,7 +1318,7 @@ DESCRIPTION
|
||||
create an environment variable for the current project or account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/create.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/env/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/create.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/env/create.ts)_
|
||||
|
||||
## `eas env:delete [ENVIRONMENT]`
|
||||
|
||||
@@ -1344,7 +1344,7 @@ DESCRIPTION
|
||||
delete an environment variable for the current project or account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/delete.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/env/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/delete.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/env/delete.ts)_
|
||||
|
||||
## `eas env:exec ENVIRONMENT BASH_COMMAND`
|
||||
|
||||
@@ -1366,7 +1366,7 @@ DESCRIPTION
|
||||
execute a command with environment variables from the selected environment
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/exec.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/env/exec.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/exec.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/env/exec.ts)_
|
||||
|
||||
## `eas env:get [ENVIRONMENT]`
|
||||
|
||||
@@ -1394,7 +1394,7 @@ DESCRIPTION
|
||||
view an environment variable for the current project or account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/get.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/env/get.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/get.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/env/get.ts)_
|
||||
|
||||
## `eas env:list [ENVIRONMENT]`
|
||||
|
||||
@@ -1422,7 +1422,7 @@ DESCRIPTION
|
||||
list environment variables for the current project or account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/list.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/env/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/list.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/env/list.ts)_
|
||||
|
||||
## `eas env:pull [ENVIRONMENT]`
|
||||
|
||||
@@ -1445,7 +1445,7 @@ DESCRIPTION
|
||||
pull environment variables for the selected environment to .env file
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/pull.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/env/pull.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/pull.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/env/pull.ts)_
|
||||
|
||||
## `eas env:push [ENVIRONMENT]`
|
||||
|
||||
@@ -1467,7 +1467,7 @@ DESCRIPTION
|
||||
push environment variables from .env file to the selected environment
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/push.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/env/push.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/push.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/env/push.ts)_
|
||||
|
||||
## `eas env:update [ENVIRONMENT]`
|
||||
|
||||
@@ -1501,7 +1501,7 @@ DESCRIPTION
|
||||
update an environment variable on the current project or account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/update.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/env/update.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/update.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/env/update.ts)_
|
||||
|
||||
## `eas fingerprint:compare [HASH1] [HASH2]`
|
||||
|
||||
@@ -1545,7 +1545,7 @@ EXAMPLES
|
||||
$ eas fingerprint:compare <FINGERPRINT-HASH> --update-id <UPDATE-ID> # Compare fingerprint from update against provided fingerprint
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/fingerprint/compare.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/fingerprint/compare.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/fingerprint/compare.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/fingerprint/compare.ts)_
|
||||
|
||||
## `eas fingerprint:generate`
|
||||
|
||||
@@ -1576,7 +1576,7 @@ EXAMPLES
|
||||
$ eas fingerprint:generate --json --non-interactive --platform android # Output fingerprint json to stdout
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/fingerprint/generate.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/fingerprint/generate.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/fingerprint/generate.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/fingerprint/generate.ts)_
|
||||
|
||||
## `eas help [COMMAND]`
|
||||
|
||||
@@ -1655,7 +1655,7 @@ DESCRIPTION
|
||||
connect a project to an App Store Connect app
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/integrations/asc/connect.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/integrations/asc/connect.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/integrations/asc/connect.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/integrations/asc/connect.ts)_
|
||||
|
||||
## `eas integrations:asc:disconnect`
|
||||
|
||||
@@ -1674,7 +1674,7 @@ DESCRIPTION
|
||||
disconnect the current project from its App Store Connect app
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/integrations/asc/disconnect.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/integrations/asc/disconnect.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/integrations/asc/disconnect.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/integrations/asc/disconnect.ts)_
|
||||
|
||||
## `eas integrations:asc:status`
|
||||
|
||||
@@ -1692,7 +1692,7 @@ DESCRIPTION
|
||||
show the App Store Connect app link status for the current project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/integrations/asc/status.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/integrations/asc/status.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/integrations/asc/status.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/integrations/asc/status.ts)_
|
||||
|
||||
## `eas integrations:convex:connect`
|
||||
|
||||
@@ -1714,7 +1714,7 @@ DESCRIPTION
|
||||
connect Convex to your Expo project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/integrations/convex/connect.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/integrations/convex/connect.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/integrations/convex/connect.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/integrations/convex/connect.ts)_
|
||||
|
||||
## `eas integrations:convex:dashboard`
|
||||
|
||||
@@ -1728,7 +1728,7 @@ DESCRIPTION
|
||||
open the Convex dashboard for the linked Convex project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/integrations/convex/dashboard.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/integrations/convex/dashboard.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/integrations/convex/dashboard.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/integrations/convex/dashboard.ts)_
|
||||
|
||||
## `eas integrations:convex:project`
|
||||
|
||||
@@ -1742,7 +1742,7 @@ DESCRIPTION
|
||||
display the Convex project linked to the current Expo app
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/integrations/convex/project.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/integrations/convex/project.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/integrations/convex/project.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/integrations/convex/project.ts)_
|
||||
|
||||
## `eas integrations:convex:project:delete`
|
||||
|
||||
@@ -1760,7 +1760,7 @@ DESCRIPTION
|
||||
remove the Convex project link for the current Expo app from EAS servers
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/integrations/convex/project/delete.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/integrations/convex/project/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/integrations/convex/project/delete.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/integrations/convex/project/delete.ts)_
|
||||
|
||||
## `eas integrations:convex:team`
|
||||
|
||||
@@ -1774,7 +1774,7 @@ DESCRIPTION
|
||||
display Convex teams linked to the current Expo app's owner account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/integrations/convex/team.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/integrations/convex/team.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/integrations/convex/team.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/integrations/convex/team.ts)_
|
||||
|
||||
## `eas integrations:convex:team:delete [CONVEX_TEAM]`
|
||||
|
||||
@@ -1795,7 +1795,7 @@ DESCRIPTION
|
||||
remove a Convex team link from the current Expo app owner account's EAS servers
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/integrations/convex/team/delete.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/integrations/convex/team/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/integrations/convex/team/delete.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/integrations/convex/team/delete.ts)_
|
||||
|
||||
## `eas integrations:convex:team:invite [CONVEX_TEAM]`
|
||||
|
||||
@@ -1815,7 +1815,7 @@ DESCRIPTION
|
||||
send a Convex team invitation to your verified email address
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/integrations/convex/team/invite.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/integrations/convex/team/invite.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/integrations/convex/team/invite.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/integrations/convex/team/invite.ts)_
|
||||
|
||||
## `eas login`
|
||||
|
||||
@@ -1867,7 +1867,7 @@ DESCRIPTION
|
||||
validate the local store configuration
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/metadata/lint.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/metadata/lint.ts)_
|
||||
|
||||
## `eas metadata:pull`
|
||||
|
||||
@@ -1885,7 +1885,7 @@ DESCRIPTION
|
||||
generate the local store configuration from the app stores
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/metadata/pull.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/metadata/pull.ts)_
|
||||
|
||||
## `eas metadata:push`
|
||||
|
||||
@@ -1903,7 +1903,7 @@ DESCRIPTION
|
||||
sync the local store configuration to the app stores
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/metadata/push.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/metadata/push.ts)_
|
||||
|
||||
## `eas new [PATH]`
|
||||
|
||||
@@ -1962,7 +1962,7 @@ DESCRIPTION
|
||||
arguments, a list of the available event names and associated event counts is returned.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/observe/events.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/observe/events.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/observe/events.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/observe/events.ts)_
|
||||
|
||||
## `eas observe:metrics [METRIC]`
|
||||
|
||||
@@ -1997,7 +1997,7 @@ DESCRIPTION
|
||||
display individual performance metric samples ordered by value
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/observe/metrics.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/observe/metrics.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/observe/metrics.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/observe/metrics.ts)_
|
||||
|
||||
## `eas observe:metrics-summary`
|
||||
|
||||
@@ -2028,7 +2028,7 @@ DESCRIPTION
|
||||
display aggregated performance metric statistics grouped by app version
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/observe/metrics-summary.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/observe/metrics-summary.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/observe/metrics-summary.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/observe/metrics-summary.ts)_
|
||||
|
||||
## `eas observe:routes`
|
||||
|
||||
@@ -2065,7 +2065,7 @@ DESCRIPTION
|
||||
display app navigation route metrics (Cold TTR, Warm TTR, TTI) grouped by route name
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/observe/routes.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/observe/routes.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/observe/routes.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/observe/routes.ts)_
|
||||
|
||||
## `eas observe:versions`
|
||||
|
||||
@@ -2090,7 +2090,7 @@ DESCRIPTION
|
||||
display app versions with build and update details
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/observe/versions.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/observe/versions.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/observe/versions.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/observe/versions.ts)_
|
||||
|
||||
## `eas onboarding [TARGET_PROJECT_DIRECTORY]`
|
||||
|
||||
@@ -2120,7 +2120,7 @@ DESCRIPTION
|
||||
open the project page in a web browser
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/open.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/open.ts)_
|
||||
|
||||
## `eas project:info`
|
||||
|
||||
@@ -2134,7 +2134,7 @@ DESCRIPTION
|
||||
information about the current project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/project/info.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/project/info.ts)_
|
||||
|
||||
## `eas project:init`
|
||||
|
||||
@@ -2157,7 +2157,7 @@ ALIASES
|
||||
$ eas init
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/project/init.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/project/init.ts)_
|
||||
|
||||
## `eas project:new [PATH]`
|
||||
|
||||
@@ -2181,7 +2181,7 @@ ALIASES
|
||||
$ eas new
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/project/new.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/project/new.ts)_
|
||||
|
||||
## `eas project:onboarding [TARGET_PROJECT_DIRECTORY]`
|
||||
|
||||
@@ -2199,7 +2199,7 @@ ALIASES
|
||||
$ eas onboarding
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/project/onboarding.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/project/onboarding.ts)_
|
||||
|
||||
## `eas submit`
|
||||
|
||||
@@ -2232,7 +2232,7 @@ ALIASES
|
||||
$ eas build:submit
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/submit.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/submit.ts)_
|
||||
|
||||
## `eas update`
|
||||
|
||||
@@ -2275,7 +2275,7 @@ DESCRIPTION
|
||||
publish an update group
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/update/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/update/index.ts)_
|
||||
|
||||
## `eas update:configure`
|
||||
|
||||
@@ -2296,7 +2296,7 @@ DESCRIPTION
|
||||
configure the project to support EAS Update
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/update/configure.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/update/configure.ts)_
|
||||
|
||||
## `eas update:delete GROUPID`
|
||||
|
||||
@@ -2317,7 +2317,7 @@ DESCRIPTION
|
||||
delete all the updates in an update group
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/update/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/update/delete.ts)_
|
||||
|
||||
## `eas update:edit [GROUPID]`
|
||||
|
||||
@@ -2342,7 +2342,7 @@ DESCRIPTION
|
||||
edit all the updates in an update group
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/edit.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/update/edit.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/edit.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/update/edit.ts)_
|
||||
|
||||
## `eas update:embedded:delete ID`
|
||||
|
||||
@@ -2363,7 +2363,7 @@ DESCRIPTION
|
||||
delete an embedded update registered with EAS Update
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/embedded/delete.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/update/embedded/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/embedded/delete.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/update/embedded/delete.ts)_
|
||||
|
||||
## `eas update:embedded:list`
|
||||
|
||||
@@ -2389,7 +2389,7 @@ DESCRIPTION
|
||||
list embedded updates registered with EAS Update for this project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/embedded/list.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/update/embedded/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/embedded/list.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/update/embedded/list.ts)_
|
||||
|
||||
## `eas update:embedded:upload`
|
||||
|
||||
@@ -2419,7 +2419,7 @@ EXAMPLES
|
||||
$ eas update:embedded:upload --platform android --bundle android/app/src/main/assets/index.android.bundle --manifest android/app/src/main/assets/app.manifest --channel production --build-id <BUILD-ID>
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/embedded/upload.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/update/embedded/upload.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/embedded/upload.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/update/embedded/upload.ts)_
|
||||
|
||||
## `eas update:embedded:view ID`
|
||||
|
||||
@@ -2439,7 +2439,7 @@ DESCRIPTION
|
||||
view details of an embedded update registered with EAS Update
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/embedded/view.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/update/embedded/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/embedded/view.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/update/embedded/view.ts)_
|
||||
|
||||
## `eas update:insights GROUPID`
|
||||
|
||||
@@ -2466,7 +2466,7 @@ DESCRIPTION
|
||||
display launch, crash, unique-user, and size insights for an update group
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/insights.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/update/insights.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/insights.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/update/insights.ts)_
|
||||
|
||||
## `eas update:list`
|
||||
|
||||
@@ -2493,7 +2493,7 @@ DESCRIPTION
|
||||
view the recent updates
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/update/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/update/list.ts)_
|
||||
|
||||
## `eas update:republish`
|
||||
|
||||
@@ -2531,7 +2531,7 @@ DESCRIPTION
|
||||
roll back to an existing update
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/update/republish.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/update/republish.ts)_
|
||||
|
||||
## `eas update:revert-update-rollout`
|
||||
|
||||
@@ -2559,7 +2559,7 @@ DESCRIPTION
|
||||
revert a rollout update for a project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/revert-update-rollout.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/revert-update-rollout.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
|
||||
|
||||
## `eas update:roll-back-to-embedded`
|
||||
|
||||
@@ -2589,28 +2589,39 @@ DESCRIPTION
|
||||
roll back to the embedded update
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/roll-back-to-embedded.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/roll-back-to-embedded.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
|
||||
|
||||
## `eas update:rollback`
|
||||
## `eas update:rollback [GROUPID]`
|
||||
|
||||
Roll back to an embedded update or an existing update. Users wishing to run this command non-interactively should instead execute "eas update:republish" or "eas update:roll-back-to-embedded".
|
||||
roll back to an embedded update or an existing update
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas update:rollback [--private-key-path <value>]
|
||||
$ eas update:rollback [GROUPID] [-m <value>] [-p android|ios|all] [--private-key-path <value>] [--json]
|
||||
[--non-interactive]
|
||||
|
||||
ARGUMENTS
|
||||
[GROUPID] The ID of the update group to roll back. Must be the latest update for its branch and runtime version. The
|
||||
update group published before it is republished; if there is none, a roll back to the embedded update is
|
||||
published. Required in non-interactive mode.
|
||||
|
||||
FLAGS
|
||||
--private-key-path=<value> File containing the PEM-encoded private key corresponding to the certificate in
|
||||
expo-updates' configuration. Defaults to a file named "private-key.pem" in the
|
||||
certificate's directory. Only relevant if you are using code signing:
|
||||
https://docs.expo.dev/eas-update/code-signing/
|
||||
-m, --message=<value> Short message describing the rollback update
|
||||
-p, --platform=<option> [default: all]
|
||||
<options: android|ios|all>
|
||||
--json Enable JSON output, non-JSON messages will be printed to stderr. Implies
|
||||
--non-interactive.
|
||||
--non-interactive Run the command in non-interactive mode.
|
||||
--private-key-path=<value> File containing the PEM-encoded private key corresponding to the certificate in
|
||||
expo-updates' configuration. Defaults to a file named "private-key.pem" in the
|
||||
certificate's directory. Only relevant if you are using code signing:
|
||||
https://docs.expo.dev/eas-update/code-signing/
|
||||
|
||||
DESCRIPTION
|
||||
Roll back to an embedded update or an existing update. Users wishing to run this command non-interactively should
|
||||
instead execute "eas update:republish" or "eas update:roll-back-to-embedded".
|
||||
roll back to an embedded update or an existing update
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/rollback.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/update/rollback.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/rollback.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/update/rollback.ts)_
|
||||
|
||||
## `eas update:view GROUPID`
|
||||
|
||||
@@ -2634,7 +2645,7 @@ DESCRIPTION
|
||||
update group details
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/update/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/update/view.ts)_
|
||||
|
||||
## `eas upload`
|
||||
|
||||
@@ -2655,7 +2666,7 @@ DESCRIPTION
|
||||
upload a local build and generate a sharable link
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/upload.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/upload.ts)_
|
||||
|
||||
## `eas webhook:create`
|
||||
|
||||
@@ -2677,7 +2688,7 @@ DESCRIPTION
|
||||
create a webhook
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/webhook/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/webhook/create.ts)_
|
||||
|
||||
## `eas webhook:delete [ID]`
|
||||
|
||||
@@ -2697,7 +2708,7 @@ DESCRIPTION
|
||||
delete a webhook
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/webhook/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/webhook/delete.ts)_
|
||||
|
||||
## `eas webhook:list`
|
||||
|
||||
@@ -2716,7 +2727,7 @@ DESCRIPTION
|
||||
list webhooks
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/webhook/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/webhook/list.ts)_
|
||||
|
||||
## `eas webhook:update`
|
||||
|
||||
@@ -2739,7 +2750,7 @@ DESCRIPTION
|
||||
update a webhook
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/webhook/update.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/webhook/update.ts)_
|
||||
|
||||
## `eas webhook:view ID`
|
||||
|
||||
@@ -2756,7 +2767,7 @@ DESCRIPTION
|
||||
view a webhook
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/webhook/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/webhook/view.ts)_
|
||||
|
||||
## `eas whoami`
|
||||
|
||||
@@ -2877,7 +2888,7 @@ DESCRIPTION
|
||||
to cancel.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/workflow/cancel.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/workflow/cancel.ts)_
|
||||
|
||||
## `eas workflow:create [NAME]`
|
||||
|
||||
@@ -2897,7 +2908,7 @@ DESCRIPTION
|
||||
create a new workflow configuration YAML file
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/create.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/workflow/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/create.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/workflow/create.ts)_
|
||||
|
||||
## `eas workflow:logs [ID]`
|
||||
|
||||
@@ -2921,7 +2932,7 @@ DESCRIPTION
|
||||
If no ID is passed in, you will be prompted to select from recent workflow runs for the current project.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/logs.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/workflow/logs.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/logs.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/workflow/logs.ts)_
|
||||
|
||||
## `eas workflow:run FILE`
|
||||
|
||||
@@ -2961,7 +2972,7 @@ FLAG DESCRIPTIONS
|
||||
Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/run.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/workflow/run.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/run.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/workflow/run.ts)_
|
||||
|
||||
## `eas workflow:runs`
|
||||
|
||||
@@ -2983,7 +2994,7 @@ DESCRIPTION
|
||||
list recent workflow runs for this project, with their IDs, statuses, and timestamps
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/runs.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/workflow/runs.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/runs.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/workflow/runs.ts)_
|
||||
|
||||
## `eas workflow:status [WORKFLOW_RUN_ID]`
|
||||
|
||||
@@ -3011,7 +3022,7 @@ FLAG DESCRIPTIONS
|
||||
Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/status.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/workflow/status.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/status.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/workflow/status.ts)_
|
||||
|
||||
## `eas workflow:validate PATH`
|
||||
|
||||
@@ -3031,7 +3042,7 @@ DESCRIPTION
|
||||
validate a workflow configuration yaml file
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/validate.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/workflow/validate.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/validate.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/workflow/validate.ts)_
|
||||
|
||||
## `eas workflow:view [ID]`
|
||||
|
||||
@@ -3053,5 +3064,5 @@ DESCRIPTION
|
||||
workflow runs for the current project.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/view.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/workflow/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/view.ts](https://github.com/expo/eas-cli/blob/v20.2.0/packages/eas-cli/src/commands/workflow/view.ts)_
|
||||
<!-- commandsstop -->
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "20.1.0",
|
||||
"version": "20.2.0",
|
||||
"description": "EAS command line tool",
|
||||
"keywords": [
|
||||
"cli",
|
||||
|
||||
@@ -62,6 +62,11 @@ export default class SimulatorStart extends EasCommand {
|
||||
description:
|
||||
'Version of the package backing the device run session (e.g. "0.1.3-alpha.3"). Defaults to "latest" when omitted.',
|
||||
}),
|
||||
'max-duration-minutes': Flags.integer({
|
||||
description:
|
||||
'Maximum duration of the device run session in minutes before it is automatically stopped. Only customizable on paid plans. Defaults to a value derived from the job run priority when omitted.',
|
||||
min: 0,
|
||||
}),
|
||||
force: Flags.boolean({
|
||||
description:
|
||||
'[default: true] Create a new device session even when an existing simulator session is present in the environment.',
|
||||
@@ -125,6 +130,7 @@ export default class SimulatorStart extends EasCommand {
|
||||
platform,
|
||||
type: DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE[flags.type],
|
||||
packageVersion: flags['package-version'],
|
||||
maxRunTimeMinutes: flags['max-duration-minutes'],
|
||||
});
|
||||
deviceRunSessionId = session.id;
|
||||
const jobRunId = nullthrows(session.turtleJobRun?.id, 'Expected device run session to start');
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
import { AppJSONConfig, ExpoConfig, PackageJSONConfig, getConfig } from '@expo/config';
|
||||
import { DirectoryJSON, vol } from 'memfs';
|
||||
import { instance, mock } from 'ts-mockito';
|
||||
|
||||
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
|
||||
import LoggedInContextField from '../../../commandUtils/context/LoggedInContextField';
|
||||
import { PrivateProjectConfigContextField } from '../../../commandUtils/context/PrivateProjectConfigContextField';
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import FeatureGateEnvOverrides from '../../../commandUtils/gating/FeatureGateEnvOverrides';
|
||||
import FeatureGating from '../../../commandUtils/gating/FeatureGating';
|
||||
import { jester } from '../../../credentials/__tests__/fixtures-constants';
|
||||
import { UpdateFragment } from '../../../graphql/generated';
|
||||
import { AppQuery } from '../../../graphql/queries/AppQuery';
|
||||
import { UpdateQuery } from '../../../graphql/queries/UpdateQuery';
|
||||
import UpdateRepublish from '../republish';
|
||||
import UpdateRollBackToEmbedded from '../roll-back-to-embedded';
|
||||
import UpdateRollback from '../rollback';
|
||||
|
||||
const projectRoot = '/test-project';
|
||||
const commandOptions = getMockOclifConfig({ root: projectRoot });
|
||||
|
||||
const updateStub: UpdateFragment = {
|
||||
id: 'update-1234',
|
||||
group: 'group-source',
|
||||
branch: { id: 'branch-1234', name: 'main' },
|
||||
message: 'source message',
|
||||
runtimeVersion: 'exposdk:47.0.0',
|
||||
platform: 'ios',
|
||||
gitCommitHash: 'commit',
|
||||
isGitWorkingTreeDirty: false,
|
||||
manifestFragment: JSON.stringify({ fake: 'manifest' }),
|
||||
isRollBackToEmbedded: false,
|
||||
manifestPermalink: 'https://expo.dev/fake/manifest/link',
|
||||
codeSigningInfo: null,
|
||||
createdAt: '2022-01-01T12:00:00Z',
|
||||
};
|
||||
|
||||
jest.mock('fs');
|
||||
jest.mock('@expo/config');
|
||||
jest.mock('../../../commandUtils/context/contextUtils/getProjectIdAsync');
|
||||
jest.mock('../../../graphql/queries/AppQuery');
|
||||
jest.mock('../../../graphql/queries/UpdateQuery');
|
||||
|
||||
describe(UpdateRollback.name, () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(UpdateRepublish, 'run').mockResolvedValue(undefined as any);
|
||||
jest.spyOn(UpdateRollBackToEmbedded, 'run').mockResolvedValue(undefined as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vol.reset();
|
||||
jest.restoreAllMocks();
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('errors in non-interactive mode when no group ID is provided', async () => {
|
||||
const flags = ['--non-interactive'];
|
||||
mockTestProject();
|
||||
|
||||
await expect(new UpdateRollback(flags, commandOptions).run()).rejects.toThrow(
|
||||
'The update group ID argument is required in non-interactive mode.'
|
||||
);
|
||||
|
||||
expect(UpdateRepublish.run).not.toHaveBeenCalled();
|
||||
expect(UpdateRollBackToEmbedded.run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('republishes the previous update group when the source group is the latest', async () => {
|
||||
const flags = ['group-source', '--non-interactive'];
|
||||
mockTestProject();
|
||||
|
||||
jest
|
||||
.mocked(UpdateQuery.viewUpdateGroupAsync)
|
||||
.mockResolvedValue([{ ...updateStub, group: 'group-source' }]);
|
||||
|
||||
// Most-recent-first: the source group is the latest, the previous group follows.
|
||||
jest
|
||||
.mocked(UpdateQuery.viewUpdateGroupsPaginatedOnBranchAsync)
|
||||
.mockResolvedValue([
|
||||
[{ ...updateStub, group: 'group-source', message: 'source message' }],
|
||||
[{ ...updateStub, group: 'group-previous', message: 'previous message' }],
|
||||
]);
|
||||
|
||||
await new UpdateRollback(flags, commandOptions).run();
|
||||
|
||||
expect(UpdateRollBackToEmbedded.run).not.toHaveBeenCalled();
|
||||
expect(UpdateRepublish.run).toHaveBeenCalledWith([
|
||||
'--group',
|
||||
'group-previous',
|
||||
'--message',
|
||||
'Roll back to "previous message" (group: group-previous)',
|
||||
'--non-interactive',
|
||||
'--platform',
|
||||
'all',
|
||||
]);
|
||||
});
|
||||
|
||||
it('rolls back to embedded when the source group is the only update for its runtime version', async () => {
|
||||
const flags = ['group-source', '--non-interactive'];
|
||||
mockTestProject();
|
||||
|
||||
jest
|
||||
.mocked(UpdateQuery.viewUpdateGroupAsync)
|
||||
.mockResolvedValue([{ ...updateStub, group: 'group-source' }]);
|
||||
|
||||
// Only the source group exists for this runtime version -> no previous group.
|
||||
jest
|
||||
.mocked(UpdateQuery.viewUpdateGroupsPaginatedOnBranchAsync)
|
||||
.mockResolvedValue([[{ ...updateStub, group: 'group-source', message: 'source message' }]]);
|
||||
|
||||
await new UpdateRollback(flags, commandOptions).run();
|
||||
|
||||
expect(UpdateRepublish.run).not.toHaveBeenCalled();
|
||||
expect(UpdateRollBackToEmbedded.run).toHaveBeenCalledWith([
|
||||
'--branch',
|
||||
'main',
|
||||
'--runtime-version',
|
||||
'exposdk:47.0.0',
|
||||
'--message',
|
||||
'Roll back to embedded',
|
||||
'--non-interactive',
|
||||
'--platform',
|
||||
'all',
|
||||
]);
|
||||
});
|
||||
|
||||
it('forwards --message, --platform, and --private-key-path to the republish path', async () => {
|
||||
const flags = [
|
||||
'group-source',
|
||||
'--non-interactive',
|
||||
'--message',
|
||||
'custom rollback message',
|
||||
'--platform',
|
||||
'ios',
|
||||
'--private-key-path',
|
||||
'./keys/private-key.pem',
|
||||
];
|
||||
mockTestProject();
|
||||
|
||||
jest
|
||||
.mocked(UpdateQuery.viewUpdateGroupAsync)
|
||||
.mockResolvedValue([{ ...updateStub, group: 'group-source' }]);
|
||||
|
||||
jest
|
||||
.mocked(UpdateQuery.viewUpdateGroupsPaginatedOnBranchAsync)
|
||||
.mockResolvedValue([
|
||||
[{ ...updateStub, group: 'group-source', message: 'source message' }],
|
||||
[{ ...updateStub, group: 'group-previous', message: 'previous message' }],
|
||||
]);
|
||||
|
||||
await new UpdateRollback(flags, commandOptions).run();
|
||||
|
||||
expect(UpdateRepublish.run).toHaveBeenCalledWith([
|
||||
'--group',
|
||||
'group-previous',
|
||||
'--message',
|
||||
'custom rollback message',
|
||||
'--non-interactive',
|
||||
'--platform',
|
||||
'ios',
|
||||
'--private-key-path',
|
||||
'./keys/private-key.pem',
|
||||
]);
|
||||
});
|
||||
|
||||
it('forwards --json to the republish path', async () => {
|
||||
const flags = ['group-source', '--json', '--non-interactive'];
|
||||
mockTestProject();
|
||||
|
||||
jest
|
||||
.mocked(UpdateQuery.viewUpdateGroupAsync)
|
||||
.mockResolvedValue([{ ...updateStub, group: 'group-source' }]);
|
||||
|
||||
jest
|
||||
.mocked(UpdateQuery.viewUpdateGroupsPaginatedOnBranchAsync)
|
||||
.mockResolvedValue([
|
||||
[{ ...updateStub, group: 'group-source', message: 'source message' }],
|
||||
[{ ...updateStub, group: 'group-previous', message: 'previous message' }],
|
||||
]);
|
||||
|
||||
await new UpdateRollback(flags, commandOptions).run();
|
||||
|
||||
expect(UpdateRepublish.run).toHaveBeenCalledWith(expect.arrayContaining(['--json']));
|
||||
});
|
||||
|
||||
it('forwards --platform and --private-key-path to the embedded rollback path', async () => {
|
||||
const flags = [
|
||||
'group-source',
|
||||
'--non-interactive',
|
||||
'--platform',
|
||||
'ios',
|
||||
'--private-key-path',
|
||||
'./keys/private-key.pem',
|
||||
];
|
||||
mockTestProject();
|
||||
|
||||
jest
|
||||
.mocked(UpdateQuery.viewUpdateGroupAsync)
|
||||
.mockResolvedValue([{ ...updateStub, group: 'group-source' }]);
|
||||
|
||||
// No previous group -> roll back to embedded.
|
||||
jest
|
||||
.mocked(UpdateQuery.viewUpdateGroupsPaginatedOnBranchAsync)
|
||||
.mockResolvedValue([[{ ...updateStub, group: 'group-source', message: 'source message' }]]);
|
||||
|
||||
await new UpdateRollback(flags, commandOptions).run();
|
||||
|
||||
expect(UpdateRepublish.run).not.toHaveBeenCalled();
|
||||
expect(UpdateRollBackToEmbedded.run).toHaveBeenCalledWith([
|
||||
'--branch',
|
||||
'main',
|
||||
'--runtime-version',
|
||||
'exposdk:47.0.0',
|
||||
'--message',
|
||||
'Roll back to embedded',
|
||||
'--non-interactive',
|
||||
'--platform',
|
||||
'ios',
|
||||
'--private-key-path',
|
||||
'./keys/private-key.pem',
|
||||
]);
|
||||
});
|
||||
|
||||
it('errors when the source group is not the latest update for its runtime version', async () => {
|
||||
const flags = ['group-source', '--non-interactive'];
|
||||
mockTestProject();
|
||||
|
||||
jest
|
||||
.mocked(UpdateQuery.viewUpdateGroupAsync)
|
||||
.mockResolvedValue([{ ...updateStub, group: 'group-source' }]);
|
||||
|
||||
// A newer group is the latest for this runtime version, so the source group is stale.
|
||||
jest
|
||||
.mocked(UpdateQuery.viewUpdateGroupsPaginatedOnBranchAsync)
|
||||
.mockResolvedValue([
|
||||
[{ ...updateStub, group: 'group-newer', message: 'newer message' }],
|
||||
[{ ...updateStub, group: 'group-source', message: 'source message' }],
|
||||
]);
|
||||
|
||||
await expect(new UpdateRollback(flags, commandOptions).run()).rejects.toThrow(
|
||||
'Update group "group-source" is not the latest update on branch "main" for runtime version "exposdk:47.0.0" (the latest is "group-newer"). Only the latest update can be rolled back.'
|
||||
);
|
||||
|
||||
expect(UpdateRepublish.run).not.toHaveBeenCalled();
|
||||
expect(UpdateRollBackToEmbedded.run).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
function mockTestProject({
|
||||
configuredProjectId = '1234',
|
||||
extraManifest,
|
||||
extraVol,
|
||||
}: {
|
||||
configuredProjectId?: string;
|
||||
extraManifest?: Partial<ExpoConfig>;
|
||||
extraVol?: DirectoryJSON;
|
||||
} = {}): void {
|
||||
const packageJSON: PackageJSONConfig = {
|
||||
name: 'testing123',
|
||||
version: '0.1.0',
|
||||
description: 'fake description',
|
||||
main: 'index.js',
|
||||
};
|
||||
|
||||
const appJSON: AppJSONConfig = {
|
||||
expo: {
|
||||
name: 'testing 123',
|
||||
version: '0.1.0',
|
||||
slug: 'testing-123',
|
||||
sdkVersion: '33.0.0',
|
||||
owner: jester.accounts[0].name,
|
||||
extra: {
|
||||
eas: {
|
||||
projectId: configuredProjectId,
|
||||
},
|
||||
},
|
||||
...extraManifest,
|
||||
},
|
||||
};
|
||||
|
||||
vol.fromJSON(
|
||||
{
|
||||
'package.json': JSON.stringify(packageJSON),
|
||||
'app.json': JSON.stringify(appJSON),
|
||||
...extraVol,
|
||||
},
|
||||
projectRoot
|
||||
);
|
||||
|
||||
const mockManifest = { exp: appJSON.expo };
|
||||
const graphqlClient = instance(mock<ExpoGraphqlClient>({}));
|
||||
|
||||
jest.mocked(getConfig).mockReturnValue(mockManifest as any);
|
||||
jest.spyOn(PrivateProjectConfigContextField.prototype, 'getValueAsync').mockResolvedValue({
|
||||
exp: mockManifest.exp,
|
||||
projectDir: projectRoot,
|
||||
projectId: configuredProjectId,
|
||||
});
|
||||
|
||||
jest.spyOn(LoggedInContextField.prototype, 'getValueAsync').mockResolvedValue({
|
||||
actor: jester,
|
||||
featureGating: new FeatureGating({}, new FeatureGateEnvOverrides()),
|
||||
graphqlClient,
|
||||
authenticationInfo: { accessToken: null, sessionSecret: '1234' },
|
||||
});
|
||||
|
||||
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
|
||||
id: '1234',
|
||||
slug: 'testing-123',
|
||||
name: 'testing-123',
|
||||
fullName: '@jester/testing-123',
|
||||
ownerAccount: jester.accounts[0],
|
||||
});
|
||||
}
|
||||
@@ -1,41 +1,188 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
import { Args, Flags } from '@oclif/core';
|
||||
|
||||
import UpdateRepublish from './republish';
|
||||
import UpdateRollBackToEmbedded from './roll-back-to-embedded';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import { UpdateQuery } from '../../graphql/queries/UpdateQuery';
|
||||
import { promptAsync } from '../../prompts';
|
||||
|
||||
export default class UpdateRollback extends EasCommand {
|
||||
static override description =
|
||||
'Roll back to an embedded update or an existing update. Users wishing to run this command non-interactively should instead execute "eas update:republish" or "eas update:roll-back-to-embedded".';
|
||||
const defaultRollbackPlatforms = ['android', 'ios'] as const;
|
||||
|
||||
static override flags = {
|
||||
'private-key-path': Flags.string({
|
||||
description: `File containing the PEM-encoded private key corresponding to the certificate in expo-updates' configuration. Defaults to a file named "private-key.pem" in the certificate's directory. Only relevant if you are using code signing: https://docs.expo.dev/eas-update/code-signing/`,
|
||||
type SourceUpdateGroup = {
|
||||
groupId: string;
|
||||
branchName: string;
|
||||
runtimeVersion: string;
|
||||
};
|
||||
|
||||
type PreviousUpdateGroup = {
|
||||
groupId: string;
|
||||
message: string | null;
|
||||
};
|
||||
|
||||
export default class UpdateRollback extends EasCommand {
|
||||
static override description = 'roll back to an embedded update or an existing update';
|
||||
|
||||
static override args = {
|
||||
groupId: Args.string({
|
||||
description:
|
||||
'The ID of the update group to roll back. Must be the latest update for its branch and runtime version. The update group published before it is republished; if there is none, a roll back to the embedded update is published. Required in non-interactive mode.',
|
||||
required: false,
|
||||
}),
|
||||
};
|
||||
|
||||
static override flags = {
|
||||
message: Flags.string({
|
||||
char: 'm',
|
||||
description: 'Short message describing the rollback update',
|
||||
required: false,
|
||||
}),
|
||||
platform: Flags.option({
|
||||
char: 'p',
|
||||
options: [...defaultRollbackPlatforms, 'all'] as const,
|
||||
default: 'all',
|
||||
required: false,
|
||||
})(),
|
||||
'private-key-path': Flags.string({
|
||||
description: `File containing the PEM-encoded private key corresponding to the certificate in expo-updates' configuration. Defaults to a file named "private-key.pem" in the certificate's directory. Only relevant if you are using code signing: https://docs.expo.dev/eas-update/code-signing/`,
|
||||
required: false,
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectConfig,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(UpdateRollback);
|
||||
|
||||
const { choice } = await promptAsync({
|
||||
type: 'select',
|
||||
message: 'Which type of update would you like to roll back to?',
|
||||
name: 'choice',
|
||||
choices: [
|
||||
{ title: 'Published Update', value: 'published' },
|
||||
{ title: 'Embedded Update', value: 'embedded' },
|
||||
],
|
||||
});
|
||||
|
||||
const { args, flags } = await this.parse(UpdateRollback);
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
const groupId = args.groupId;
|
||||
const platform = flags.platform;
|
||||
const messageArg = flags.message;
|
||||
const privateKeyPathArg = flags['private-key-path']
|
||||
? ['--private-key-path', flags['private-key-path']]
|
||||
: [];
|
||||
if (choice === 'published') {
|
||||
await UpdateRepublish.run(privateKeyPathArg);
|
||||
|
||||
if (!groupId) {
|
||||
if (nonInteractive) {
|
||||
throw new Error('The update group ID argument is required in non-interactive mode.');
|
||||
}
|
||||
|
||||
const { choice } = await promptAsync({
|
||||
type: 'select',
|
||||
message: 'Which type of update would you like to roll back to?',
|
||||
name: 'choice',
|
||||
choices: [
|
||||
{ title: 'Published Update', value: 'published' },
|
||||
{ title: 'Embedded Update', value: 'embedded' },
|
||||
],
|
||||
});
|
||||
|
||||
if (choice === 'published') {
|
||||
await UpdateRepublish.run(privateKeyPathArg);
|
||||
} else {
|
||||
await UpdateRollBackToEmbedded.run(privateKeyPathArg);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
loggedIn: { graphqlClient },
|
||||
privateProjectConfig: { projectId },
|
||||
} = await this.getContextAsync(UpdateRollback, {
|
||||
nonInteractive,
|
||||
withServerSideEnvironment: null,
|
||||
});
|
||||
|
||||
const sourceGroup = await getSourceUpdateGroupAsync(graphqlClient, groupId);
|
||||
const previousGroup = await getPreviousUpdateGroupAsync(graphqlClient, projectId, sourceGroup);
|
||||
|
||||
const commonArgs = [
|
||||
'--non-interactive',
|
||||
'--platform',
|
||||
platform,
|
||||
...privateKeyPathArg,
|
||||
...(json ? ['--json'] : []),
|
||||
];
|
||||
|
||||
if (previousGroup) {
|
||||
const message =
|
||||
messageArg ??
|
||||
`Roll back to "${previousGroup.message ?? ''}" (group: ${previousGroup.groupId})`;
|
||||
await UpdateRepublish.run([
|
||||
'--group',
|
||||
previousGroup.groupId,
|
||||
'--message',
|
||||
message,
|
||||
...commonArgs,
|
||||
]);
|
||||
} else {
|
||||
await UpdateRollBackToEmbedded.run(privateKeyPathArg);
|
||||
const message = messageArg ?? 'Roll back to embedded';
|
||||
await UpdateRollBackToEmbedded.run([
|
||||
'--branch',
|
||||
sourceGroup.branchName,
|
||||
'--runtime-version',
|
||||
sourceGroup.runtimeVersion,
|
||||
'--message',
|
||||
message,
|
||||
...commonArgs,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function getSourceUpdateGroupAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
groupId: string
|
||||
): Promise<SourceUpdateGroup> {
|
||||
// viewUpdateGroupAsync throws if no updates are found for the group ID.
|
||||
const updateGroup = await UpdateQuery.viewUpdateGroupAsync(graphqlClient, { groupId });
|
||||
const arbitraryUpdate = updateGroup[0];
|
||||
return {
|
||||
groupId,
|
||||
branchName: arbitraryUpdate.branch.name,
|
||||
runtimeVersion: arbitraryUpdate.runtimeVersion,
|
||||
};
|
||||
}
|
||||
|
||||
async function getPreviousUpdateGroupAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
projectId: string,
|
||||
sourceGroup: SourceUpdateGroup
|
||||
): Promise<PreviousUpdateGroup | null> {
|
||||
// Clients on a given runtime version are served the latest update group on the branch,
|
||||
// so a rollback is only meaningful when the source group is that latest group. Fetch the
|
||||
// two most recent groups for the runtime version (returned most-recent-first): the first
|
||||
// must be the source group, and the second (if any) is the update to roll back to.
|
||||
const latestGroups = await UpdateQuery.viewUpdateGroupsPaginatedOnBranchAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
branchName: sourceGroup.branchName,
|
||||
first: 2,
|
||||
filter: { runtimeVersions: [sourceGroup.runtimeVersion] },
|
||||
});
|
||||
|
||||
const latestGroup = latestGroups[0];
|
||||
if (!latestGroup?.length || latestGroup[0].group !== sourceGroup.groupId) {
|
||||
throw new Error(
|
||||
`Update group "${sourceGroup.groupId}" is not the latest update on branch "${
|
||||
sourceGroup.branchName
|
||||
}" for runtime version "${sourceGroup.runtimeVersion}"${
|
||||
latestGroup?.length ? ` (the latest is "${latestGroup[0].group}")` : ''
|
||||
}. Only the latest update can be rolled back.`
|
||||
);
|
||||
}
|
||||
|
||||
// Source group is the only update for this runtime version: roll back to embedded.
|
||||
const previousGroup = latestGroups[1];
|
||||
if (!previousGroup?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { groupId: previousGroup[0].group, message: previousGroup[0].message ?? null };
|
||||
}
|
||||
@@ -5,7 +5,9 @@ import path from 'path';
|
||||
import { UserRole } from '@expo/apple-utils';
|
||||
|
||||
import { formatAppleTeam } from './AppleTeamFormatting';
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { AccountFragment, AppStoreConnectApiKeyFragment } from '../../../graphql/generated';
|
||||
import { AppStoreConnectApiKeyQuery } from '../../../graphql/queries/AppStoreConnectApiKeyQuery';
|
||||
import Log, { learnMore } from '../../../log';
|
||||
import { confirmAsync, promptAsync, selectAsync } from '../../../prompts';
|
||||
import { fromNow } from '../../../utils/date';
|
||||
@@ -14,7 +16,11 @@ import {
|
||||
getCredentialsFromUserAsync,
|
||||
shouldAutoGenerateCredentialsAsync,
|
||||
} from '../../utils/promptForCredentials';
|
||||
import { getAscApiKeyForAppSubmissionsAsync } from '../api/GraphqlClient';
|
||||
import { AppLookupParams } from '../api/graphql/types/AppLookupParams';
|
||||
import { AscApiKey } from '../appstore/Credentials.types';
|
||||
import { AppleTeamType, AuthenticationMode } from '../appstore/authenticateTypes';
|
||||
import { hasAscEnvVars } from '../appstore/resolveCredentials';
|
||||
import {
|
||||
AscApiKeyPath,
|
||||
MinimalAscApiKey,
|
||||
@@ -277,3 +283,78 @@ export function formatAscApiKey(ascApiKey: AppStoreConnectApiKeyFragment): strin
|
||||
line += chalk.gray(`\n Updated: ${fromNow(new Date(updatedAt))} ago`);
|
||||
return line;
|
||||
}
|
||||
|
||||
export async function resolveAscApiKeyForAppCredentialsAsync({
|
||||
graphqlClient,
|
||||
app,
|
||||
}: {
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
app: AppLookupParams;
|
||||
}): Promise<{
|
||||
ascApiKey: MinimalAscApiKey;
|
||||
teamId?: string;
|
||||
teamName?: string;
|
||||
} | null> {
|
||||
const ascKeyFragment = await getAscApiKeyForAppSubmissionsAsync(graphqlClient, app);
|
||||
if (!ascKeyFragment) {
|
||||
return null;
|
||||
}
|
||||
const fullKey = await AppStoreConnectApiKeyQuery.getByIdAsync(graphqlClient, ascKeyFragment.id);
|
||||
return {
|
||||
ascApiKey: {
|
||||
keyP8: fullKey.keyP8,
|
||||
keyId: fullKey.keyIdentifier,
|
||||
issuerId: fullKey.issuerIdentifier,
|
||||
},
|
||||
teamId: ascKeyFragment.appleTeam?.appleTeamIdentifier,
|
||||
teamName: ascKeyFragment.appleTeam?.appleTeamName ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort helper that populates `ctx.appStore.authCtx` in non-interactive mode
|
||||
* by loading an App Store Connect API key (from env vars, or by fetching the app's
|
||||
* stored key from the www GraphQL API).
|
||||
*
|
||||
* Returns true if `ctx.appStore.authCtx` is set after the call, false otherwise.
|
||||
* Never throws.
|
||||
*/
|
||||
export async function tryAuthenticateAppStoreWithEasAscApiKeyAsync(
|
||||
ctx: CredentialsContext,
|
||||
app: AppLookupParams,
|
||||
teamType: AppleTeamType
|
||||
): Promise<boolean> {
|
||||
if (ctx.appStore.authCtx) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
if (hasAscEnvVars()) {
|
||||
await ctx.appStore.ensureAuthenticatedAsync({
|
||||
mode: AuthenticationMode.API_KEY,
|
||||
teamType,
|
||||
});
|
||||
return !!ctx.appStore.authCtx;
|
||||
}
|
||||
const resolvedKey = await resolveAscApiKeyForAppCredentialsAsync({
|
||||
graphqlClient: ctx.graphqlClient,
|
||||
app,
|
||||
});
|
||||
if (!resolvedKey) {
|
||||
return false;
|
||||
}
|
||||
Log.log('Using App Store Connect API Key from EAS credentials service.');
|
||||
await ctx.appStore.ensureAuthenticatedAsync({
|
||||
mode: AuthenticationMode.API_KEY,
|
||||
ascApiKey: resolvedKey.ascApiKey,
|
||||
teamId: resolvedKey.teamId,
|
||||
teamName: resolvedKey.teamName,
|
||||
teamType,
|
||||
});
|
||||
return !!ctx.appStore.authCtx;
|
||||
} catch (err: any) {
|
||||
Log.warn(
|
||||
`Failed to authenticate with the App Store Connect API key from EAS credentials service: ${err.message ?? err}`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
import { AppleProvisioningProfileMutationResult } from '../api/graphql/mutations/AppleProvisioningProfileMutation';
|
||||
import { AppLookupParams } from '../api/graphql/types/AppLookupParams';
|
||||
import { ProvisioningProfileStoreInfo } from '../appstore/Credentials.types';
|
||||
import { AuthCtx, AuthenticationMode } from '../appstore/authenticateTypes';
|
||||
import { AuthCtx } from '../appstore/authenticateTypes';
|
||||
import { Target } from '../types';
|
||||
|
||||
export class ConfigureProvisioningProfile {
|
||||
@@ -34,12 +34,9 @@ export class ConfigureProvisioningProfile {
|
||||
throw new ForbidCredentialModificationError(
|
||||
'Remove the --freeze-credentials flag to configure a Provisioning Profile.'
|
||||
);
|
||||
} else if (
|
||||
ctx.nonInteractive &&
|
||||
ctx.appStore.defaultAuthenticationMode !== AuthenticationMode.API_KEY
|
||||
) {
|
||||
} else if (ctx.nonInteractive && !ctx.appStore.authCtx) {
|
||||
throw new InsufficientAuthenticationNonInteractiveError(
|
||||
`In order to configure your Provisioning Profile, authentication with an ASC API key is required in non-interactive mode. ${learnMore(
|
||||
`In order to configure your Provisioning Profile, authentication with an ASC API key is required in non-interactive mode. Either set the EXPO_ASC_API_KEY_PATH/EXPO_ASC_KEY_ID/EXPO_ASC_ISSUER_ID environment variables, or configure an App Store Connect API Key for submissions for bundle identifier ${this.app.bundleIdentifier} on EAS. ${learnMore(
|
||||
'https://docs.expo.dev/build/building-on-ci/#optional-provide-an-asc-api-token-for-your-apple-team'
|
||||
)}`
|
||||
);
|
||||
|
||||
@@ -14,7 +14,7 @@ import { askForUserProvidedAsync } from '../../utils/promptForCredentials';
|
||||
import { AppleProvisioningProfileMutationResult } from '../api/graphql/mutations/AppleProvisioningProfileMutation';
|
||||
import { AppLookupParams } from '../api/graphql/types/AppLookupParams';
|
||||
import { ProvisioningProfile } from '../appstore/Credentials.types';
|
||||
import { AuthCtx, AuthenticationMode } from '../appstore/authenticateTypes';
|
||||
import { AuthCtx } from '../appstore/authenticateTypes';
|
||||
import { provisioningProfileSchema } from '../credentials';
|
||||
import { Target } from '../types';
|
||||
|
||||
@@ -30,12 +30,9 @@ export class CreateProvisioningProfile {
|
||||
throw new ForbidCredentialModificationError(
|
||||
'Run this command again without the --freeze-credentials flag in order to generate a new Provisioning Profile.'
|
||||
);
|
||||
} else if (
|
||||
ctx.nonInteractive &&
|
||||
ctx.appStore.defaultAuthenticationMode !== AuthenticationMode.API_KEY
|
||||
) {
|
||||
} else if (ctx.nonInteractive && !ctx.appStore.authCtx) {
|
||||
throw new InsufficientAuthenticationNonInteractiveError(
|
||||
`In order to generate a new Provisioning Profile, authentication with an ASC API key is required in non-interactive mode. ${learnMore(
|
||||
`In order to generate a new Provisioning Profile, authentication with an ASC API key is required in non-interactive mode. Either set the EXPO_ASC_API_KEY_PATH/EXPO_ASC_KEY_ID/EXPO_ASC_ISSUER_ID environment variables, or configure an App Store Connect API Key for submissions for bundle identifier ${this.app.bundleIdentifier} on EAS. ${learnMore(
|
||||
'https://docs.expo.dev/build/building-on-ci/#optional-provide-an-asc-api-token-for-your-apple-team'
|
||||
)}`
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
filterDevicesForApplePlatform,
|
||||
formatDeviceLabel,
|
||||
} from './DeviceUtils';
|
||||
import { resolveAscApiKeyForAppCredentialsAsync } from './AscApiKeyUtils';
|
||||
import { SetUpDistributionCertificate } from './SetUpDistributionCertificate';
|
||||
import DeviceCreateAction, { RegistrationMethod } from '../../../devices/actions/create/action';
|
||||
import {
|
||||
@@ -22,8 +23,6 @@ import {
|
||||
IosAppBuildCredentialsFragment,
|
||||
IosDistributionType,
|
||||
} from '../../../graphql/generated';
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { AppStoreConnectApiKeyQuery } from '../../../graphql/queries/AppStoreConnectApiKeyQuery';
|
||||
import Log from '../../../log';
|
||||
import { getApplePlatformFromTarget } from '../../../project/ios/target';
|
||||
import {
|
||||
@@ -35,13 +34,11 @@ import {
|
||||
import differenceBy from '../../../utils/expodash/differenceBy';
|
||||
import { CredentialsContext } from '../../context';
|
||||
import { MissingCredentialsNonInteractiveError } from '../../errors';
|
||||
import { getAscApiKeyForAppSubmissionsAsync } from '../api/GraphqlClient';
|
||||
import { AppLookupParams } from '../api/graphql/types/AppLookupParams';
|
||||
import { AppleTeamType, AuthenticationMode } from '../appstore/authenticateTypes';
|
||||
import { ProvisioningProfile } from '../appstore/Credentials.types';
|
||||
import { ApplePlatform } from '../appstore/constants';
|
||||
import { hasAscEnvVars } from '../appstore/resolveCredentials';
|
||||
import { MinimalAscApiKey } from '../credentials';
|
||||
import { Target } from '../types';
|
||||
import { validateProvisioningProfileAsync } from '../validators/validateProvisioningProfile';
|
||||
|
||||
@@ -424,6 +421,7 @@ export class SetUpAdhocProvisioningProfile {
|
||||
);
|
||||
}
|
||||
|
||||
Log.log('Using App Store Connect API Key from EAS credentials service.');
|
||||
await ctx.appStore.ensureAuthenticatedAsync({
|
||||
mode: AuthenticationMode.API_KEY,
|
||||
ascApiKey: resolvedKey.ascApiKey,
|
||||
@@ -436,35 +434,6 @@ export class SetUpAdhocProvisioningProfile {
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveAscApiKeyForAppCredentialsAsync({
|
||||
graphqlClient,
|
||||
app,
|
||||
}: {
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
app: AppLookupParams;
|
||||
}): Promise<{
|
||||
ascApiKey: MinimalAscApiKey;
|
||||
teamId?: string;
|
||||
teamName?: string;
|
||||
} | null> {
|
||||
const ascKeyFragment = await getAscApiKeyForAppSubmissionsAsync(graphqlClient, app);
|
||||
if (!ascKeyFragment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Log.log('Using App Store Connect API Key from EAS credentials service.');
|
||||
const fullKey = await AppStoreConnectApiKeyQuery.getByIdAsync(graphqlClient, ascKeyFragment.id);
|
||||
return {
|
||||
ascApiKey: {
|
||||
keyP8: fullKey.keyP8,
|
||||
keyId: fullKey.keyIdentifier,
|
||||
issuerId: fullKey.issuerIdentifier,
|
||||
},
|
||||
teamId: ascKeyFragment.appleTeam?.appleTeamIdentifier,
|
||||
teamName: ascKeyFragment.appleTeam?.appleTeamName ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function doUDIDsMatch(udidsA: string[], udidsB: string[]): boolean {
|
||||
const setA = new Set(udidsA);
|
||||
const setB = new Set(udidsB);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
import { tryAuthenticateAppStoreWithEasAscApiKeyAsync } from './AscApiKeyUtils';
|
||||
import {
|
||||
assignBuildCredentialsAsync,
|
||||
getBuildCredentialsAsync,
|
||||
@@ -15,7 +16,7 @@ import {
|
||||
IosAppBuildCredentialsFragment,
|
||||
IosDistributionType,
|
||||
} from '../../../graphql/generated';
|
||||
import { learnMore } from '../../../log';
|
||||
import Log, { learnMore } from '../../../log';
|
||||
import { getApplePlatformFromTarget } from '../../../project/ios/target';
|
||||
import { confirmAsync } from '../../../prompts';
|
||||
import { CredentialsContext } from '../../context';
|
||||
@@ -25,7 +26,8 @@ import {
|
||||
} from '../../errors';
|
||||
import { AppLookupParams } from '../api/graphql/types/AppLookupParams';
|
||||
import { ProvisioningProfileStoreInfo } from '../appstore/Credentials.types';
|
||||
import { AuthenticationMode } from '../appstore/authenticateTypes';
|
||||
import { resolveAppleTeamTypeFromEnvironment } from '../appstore/resolveCredentials';
|
||||
import { AppleTeamType } from '../appstore/authenticateTypes';
|
||||
import { Target } from '../types';
|
||||
import { validateProvisioningProfileAsync } from '../validators/validateProvisioningProfile';
|
||||
|
||||
@@ -102,8 +104,28 @@ export class SetUpProvisioningProfile {
|
||||
this.app,
|
||||
this.distributionType
|
||||
).runAsync(ctx);
|
||||
if (ctx.nonInteractive && !ctx.appStore.authCtx) {
|
||||
await tryAuthenticateAppStoreWithEasAscApiKeyAsync(
|
||||
ctx,
|
||||
this.app,
|
||||
this.resolveTeamTypeForAuthentication()
|
||||
);
|
||||
}
|
||||
|
||||
const areBuildCredentialsSetup = await this.areBuildCredentialsSetupAsync(ctx);
|
||||
let areBuildCredentialsSetup: boolean;
|
||||
try {
|
||||
areBuildCredentialsSetup = await this.areBuildCredentialsSetupAsync(ctx);
|
||||
} catch (error: any) {
|
||||
if (ctx.nonInteractive) {
|
||||
Log.warn(
|
||||
'Skipping Provisioning Profile validation on Apple servers due to an unexpected validation error. Continuing with local validation result.'
|
||||
);
|
||||
Log.debug('Provisioning profile validation on Apple servers failed:', error);
|
||||
areBuildCredentialsSetup = true;
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (areBuildCredentialsSetup) {
|
||||
return nullthrows(await getBuildCredentialsAsync(ctx, this.app, this.distributionType));
|
||||
}
|
||||
@@ -111,12 +133,11 @@ export class SetUpProvisioningProfile {
|
||||
throw new ForbidCredentialModificationError(
|
||||
'Provisioning profile is not configured correctly. Remove the --freeze-credentials flag to configure it.'
|
||||
);
|
||||
} else if (
|
||||
ctx.nonInteractive &&
|
||||
ctx.appStore.defaultAuthenticationMode !== AuthenticationMode.API_KEY
|
||||
) {
|
||||
}
|
||||
|
||||
if (ctx.nonInteractive && !ctx.appStore.authCtx) {
|
||||
throw new InsufficientAuthenticationNonInteractiveError(
|
||||
`In order to configure your Provisioning Profile, authentication with an ASC API key is required in non-interactive mode. ${learnMore(
|
||||
`In order to configure your Provisioning Profile, authentication with an ASC API key is required in non-interactive mode. Either set the EXPO_ASC_API_KEY_PATH/EXPO_ASC_KEY_ID/EXPO_ASC_ISSUER_ID environment variables, or configure an App Store Connect API Key for submissions for bundle identifier ${this.app.bundleIdentifier} on EAS. ${learnMore(
|
||||
'https://docs.expo.dev/build/building-on-ci/#optional-provide-an-asc-api-token-for-your-apple-team'
|
||||
)}`
|
||||
);
|
||||
@@ -167,6 +188,24 @@ export class SetUpProvisioningProfile {
|
||||
return updatedProfile;
|
||||
}
|
||||
|
||||
/**
|
||||
* The team type determines `team.inHouse`, which in turn selects the Apple profile
|
||||
* type used for every subsequent profile lookup and creation (IOS_APP_INHOUSE for
|
||||
* enterprise vs IOS_APP_STORE otherwise). We derive it from the distribution
|
||||
* type, which is exactly what the requested operation needs: enterprise
|
||||
* builds require an in-house team, other distribution types don't.
|
||||
* A genuine team/distribution mismatch is rejected by Apple regardless of this value.
|
||||
*/
|
||||
private getDerivedTeamTypeForAuthentication(): AppleTeamType {
|
||||
return this.distributionType === IosDistributionType.Enterprise
|
||||
? AppleTeamType.IN_HOUSE
|
||||
: AppleTeamType.COMPANY_OR_ORGANIZATION;
|
||||
}
|
||||
|
||||
private resolveTeamTypeForAuthentication(): AppleTeamType {
|
||||
return resolveAppleTeamTypeFromEnvironment() ?? this.getDerivedTeamTypeForAuthentication();
|
||||
}
|
||||
|
||||
private getCurrentProfileStoreInfo(
|
||||
profiles: ProvisioningProfileStoreInfo[],
|
||||
currentProfile: AppleProvisioningProfileFragment
|
||||
|
||||
+272
-1
@@ -19,8 +19,9 @@ import {
|
||||
ForbidCredentialModificationError,
|
||||
InsufficientAuthenticationNonInteractiveError,
|
||||
} from '../../../errors';
|
||||
import { AuthenticationMode } from '../../appstore/authenticateTypes';
|
||||
import { AppleTeamType, AuthenticationMode } from '../../appstore/authenticateTypes';
|
||||
import { validateProvisioningProfileAsync } from '../../validators/validateProvisioningProfile';
|
||||
import { tryAuthenticateAppStoreWithEasAscApiKeyAsync } from '../AscApiKeyUtils';
|
||||
import { getAppLookupParamsFromContextAsync } from '../BuildCredentialsUtils';
|
||||
import { SetUpProvisioningProfile } from '../SetUpProvisioningProfile';
|
||||
|
||||
@@ -30,11 +31,15 @@ jest.mock('../SetUpDistributionCertificate');
|
||||
jest.mock('../ConfigureProvisioningProfile');
|
||||
jest.mock('../CreateProvisioningProfile');
|
||||
jest.mock('../../validators/validateProvisioningProfile');
|
||||
jest.mock('../AscApiKeyUtils');
|
||||
jest.mock('../../../../graphql/queries/AppQuery');
|
||||
|
||||
describe('SetUpProvisioningProfile', () => {
|
||||
beforeEach(() => {
|
||||
delete process.env.EXPO_APPLE_TEAM_TYPE;
|
||||
jest.mocked(AppQuery.byIdAsync).mockResolvedValue(testAppQueryByIdResponse);
|
||||
jest.mocked(tryAuthenticateAppStoreWithEasAscApiKeyAsync).mockReset();
|
||||
jest.mocked(tryAuthenticateAppStoreWithEasAscApiKeyAsync).mockResolvedValue(false);
|
||||
});
|
||||
|
||||
const testCases = ['NON_INTERACTIVE', 'INTERACTIVE'];
|
||||
@@ -262,4 +267,270 @@ describe('SetUpProvisioningProfile', () => {
|
||||
// expect provisioning profile not to be deleted on expo servers
|
||||
expect(jest.mocked(ctx.ios.deleteProvisioningProfilesAsync).mock.calls.length).toBe(0);
|
||||
});
|
||||
|
||||
it('auto-authenticates before validation in nonInteractive mode when credentials are already valid', async () => {
|
||||
let authAttemptedBeforeValidation = false;
|
||||
jest
|
||||
.mocked(tryAuthenticateAppStoreWithEasAscApiKeyAsync)
|
||||
.mockImplementation(async authedCtx => {
|
||||
authAttemptedBeforeValidation = true;
|
||||
(authedCtx.appStore as any).authCtx = testAuthCtx;
|
||||
return true;
|
||||
});
|
||||
jest.mocked(validateProvisioningProfileAsync).mockImplementation(async () => {
|
||||
expect(authAttemptedBeforeValidation).toBe(true);
|
||||
return true;
|
||||
});
|
||||
const ctx = createCtxMock({
|
||||
nonInteractive: true,
|
||||
appStore: {
|
||||
...getAppstoreMock(),
|
||||
authCtx: undefined,
|
||||
},
|
||||
ios: {
|
||||
...getNewIosApiMock(),
|
||||
getIosAppCredentialsWithBuildCredentialsAsync: jest.fn(
|
||||
() => testCommonIosAppCredentialsFragment
|
||||
),
|
||||
},
|
||||
});
|
||||
const appLookupParams = await getAppLookupParamsFromContextAsync(
|
||||
ctx,
|
||||
findApplicationTarget(testTargets)
|
||||
);
|
||||
const setupProvisioningProfileAction = new SetUpProvisioningProfile(
|
||||
appLookupParams,
|
||||
testTarget,
|
||||
IosDistributionType.AppStore
|
||||
);
|
||||
await setupProvisioningProfileAction.runAsync(ctx);
|
||||
|
||||
expect(jest.mocked(tryAuthenticateAppStoreWithEasAscApiKeyAsync).mock.calls).toEqual([
|
||||
[ctx, appLookupParams, AppleTeamType.COMPANY_OR_ORGANIZATION],
|
||||
]);
|
||||
expect(jest.mocked(ctx.ios.createOrUpdateIosAppBuildCredentialsAsync).mock.calls.length).toBe(
|
||||
0
|
||||
);
|
||||
expect(jest.mocked(ctx.ios.deleteProvisioningProfilesAsync).mock.calls.length).toBe(0);
|
||||
});
|
||||
|
||||
it('uses best-effort validation when Apple validation fails in nonInteractive mode', async () => {
|
||||
jest.mocked(validateProvisioningProfileAsync).mockImplementation(async () => {
|
||||
throw new Error('Apple API request failed');
|
||||
});
|
||||
const ctx = createCtxMock({
|
||||
nonInteractive: true,
|
||||
appStore: {
|
||||
...getAppstoreMock(),
|
||||
authCtx: testAuthCtx,
|
||||
},
|
||||
ios: {
|
||||
...getNewIosApiMock(),
|
||||
getIosAppCredentialsWithBuildCredentialsAsync: jest.fn(
|
||||
() => testCommonIosAppCredentialsFragment
|
||||
),
|
||||
},
|
||||
});
|
||||
const appLookupParams = await getAppLookupParamsFromContextAsync(
|
||||
ctx,
|
||||
findApplicationTarget(testTargets)
|
||||
);
|
||||
const setupProvisioningProfileAction = new SetUpProvisioningProfile(
|
||||
appLookupParams,
|
||||
testTarget,
|
||||
IosDistributionType.AppStore
|
||||
);
|
||||
|
||||
await expect(setupProvisioningProfileAction.runAsync(ctx)).resolves.toEqual(
|
||||
testCommonIosAppCredentialsFragment.iosAppBuildCredentialsList[0]
|
||||
);
|
||||
|
||||
expect(jest.mocked(tryAuthenticateAppStoreWithEasAscApiKeyAsync).mock.calls.length).toBe(0);
|
||||
expect(jest.mocked(ctx.ios.createOrUpdateIosAppBuildCredentialsAsync).mock.calls.length).toBe(
|
||||
0
|
||||
);
|
||||
expect(jest.mocked(ctx.ios.deleteProvisioningProfilesAsync).mock.calls.length).toBe(0);
|
||||
});
|
||||
|
||||
it('rethrows Apple validation failures in interactive mode', async () => {
|
||||
jest.mocked(validateProvisioningProfileAsync).mockImplementation(async () => {
|
||||
throw new Error('Apple API request failed');
|
||||
});
|
||||
const ctx = createCtxMock({
|
||||
nonInteractive: false,
|
||||
appStore: {
|
||||
...getAppstoreMock(),
|
||||
authCtx: testAuthCtx,
|
||||
},
|
||||
ios: {
|
||||
...getNewIosApiMock(),
|
||||
getIosAppCredentialsWithBuildCredentialsAsync: jest.fn(
|
||||
() => testCommonIosAppCredentialsFragment
|
||||
),
|
||||
},
|
||||
});
|
||||
const appLookupParams = await getAppLookupParamsFromContextAsync(
|
||||
ctx,
|
||||
findApplicationTarget(testTargets)
|
||||
);
|
||||
const setupProvisioningProfileAction = new SetUpProvisioningProfile(
|
||||
appLookupParams,
|
||||
testTarget,
|
||||
IosDistributionType.AppStore
|
||||
);
|
||||
|
||||
await expect(setupProvisioningProfileAction.runAsync(ctx)).rejects.toThrow(
|
||||
'Apple API request failed'
|
||||
);
|
||||
expect(jest.mocked(ctx.ios.createOrUpdateIosAppBuildCredentialsAsync).mock.calls.length).toBe(
|
||||
0
|
||||
);
|
||||
expect(jest.mocked(ctx.ios.deleteProvisioningProfilesAsync).mock.calls.length).toBe(0);
|
||||
});
|
||||
|
||||
it('repairs the Provisioning Profile in nonInteractive mode after auto-authenticating with the EAS-stored ASC API key', async () => {
|
||||
jest.mocked(validateProvisioningProfileAsync).mockImplementation(async () => false);
|
||||
const appstoreMock = getAppstoreMock();
|
||||
const ctx = createCtxMock({
|
||||
nonInteractive: true,
|
||||
appStore: {
|
||||
...appstoreMock,
|
||||
defaultAuthenticationMode: AuthenticationMode.USER,
|
||||
authCtx: undefined,
|
||||
ensureAuthenticatedAsync: jest.fn(() => testAuthCtx),
|
||||
listProvisioningProfilesAsync: jest.fn(() => [
|
||||
{
|
||||
provisioningProfileId: nullthrows(
|
||||
testCommonIosAppCredentialsFragment.iosAppBuildCredentialsList[0].provisioningProfile
|
||||
).developerPortalIdentifier,
|
||||
},
|
||||
]),
|
||||
},
|
||||
ios: {
|
||||
...getNewIosApiMock(),
|
||||
getIosAppCredentialsWithBuildCredentialsAsync: jest.fn(
|
||||
() => testCommonIosAppCredentialsFragment
|
||||
),
|
||||
createOrGetExistingAppleAppIdentifierAsync: jest.fn(() => testAppleAppIdentifierFragment),
|
||||
createOrUpdateIosAppBuildCredentialsAsync: jest.fn(
|
||||
() => testIosAppBuildCredentialsFragment
|
||||
),
|
||||
},
|
||||
});
|
||||
jest
|
||||
.mocked(tryAuthenticateAppStoreWithEasAscApiKeyAsync)
|
||||
.mockImplementation(async authedCtx => {
|
||||
(authedCtx.appStore as any).authCtx = testAuthCtx;
|
||||
return true;
|
||||
});
|
||||
|
||||
const appLookupParams = await getAppLookupParamsFromContextAsync(
|
||||
ctx,
|
||||
findApplicationTarget(testTargets)
|
||||
);
|
||||
const setupProvisioningProfileAction = new SetUpProvisioningProfile(
|
||||
appLookupParams,
|
||||
testTarget,
|
||||
IosDistributionType.AppStore
|
||||
);
|
||||
await setupProvisioningProfileAction.runAsync(ctx);
|
||||
|
||||
// expect we attempted to auto-authenticate with the EAS-stored ASC API key
|
||||
expect(jest.mocked(tryAuthenticateAppStoreWithEasAscApiKeyAsync).mock.calls).toEqual([
|
||||
[ctx, appLookupParams, AppleTeamType.COMPANY_OR_ORGANIZATION],
|
||||
]);
|
||||
// expect build credentials to be created or updated on expo servers
|
||||
expect(jest.mocked(ctx.ios.createOrUpdateIosAppBuildCredentialsAsync).mock.calls.length).toBe(
|
||||
1
|
||||
);
|
||||
// expect provisioning profile not to be deleted on expo servers (it still exists on Apple)
|
||||
expect(jest.mocked(ctx.ios.deleteProvisioningProfilesAsync).mock.calls.length).toBe(0);
|
||||
});
|
||||
|
||||
it('passes IN_HOUSE team type for enterprise profiles when auto-authenticating in nonInteractive mode', async () => {
|
||||
jest.mocked(validateProvisioningProfileAsync).mockImplementation(async () => false);
|
||||
const appstoreMock = getAppstoreMock();
|
||||
const ctx = createCtxMock({
|
||||
nonInteractive: true,
|
||||
appStore: {
|
||||
...appstoreMock,
|
||||
defaultAuthenticationMode: AuthenticationMode.USER,
|
||||
authCtx: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const appLookupParams = await getAppLookupParamsFromContextAsync(
|
||||
ctx,
|
||||
findApplicationTarget(testTargets)
|
||||
);
|
||||
const setupProvisioningProfileAction = new SetUpProvisioningProfile(
|
||||
appLookupParams,
|
||||
testTarget,
|
||||
IosDistributionType.Enterprise
|
||||
);
|
||||
await expect(setupProvisioningProfileAction.runAsync(ctx)).rejects.toThrowError(
|
||||
InsufficientAuthenticationNonInteractiveError
|
||||
);
|
||||
|
||||
expect(jest.mocked(tryAuthenticateAppStoreWithEasAscApiKeyAsync).mock.calls).toEqual([
|
||||
[ctx, appLookupParams, AppleTeamType.IN_HOUSE],
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses EXPO_APPLE_TEAM_TYPE over derived team type when auto-authenticating in nonInteractive mode', async () => {
|
||||
process.env.EXPO_APPLE_TEAM_TYPE = AppleTeamType.IN_HOUSE;
|
||||
jest.mocked(validateProvisioningProfileAsync).mockImplementation(async () => false);
|
||||
const appstoreMock = getAppstoreMock();
|
||||
const ctx = createCtxMock({
|
||||
nonInteractive: true,
|
||||
appStore: {
|
||||
...appstoreMock,
|
||||
defaultAuthenticationMode: AuthenticationMode.USER,
|
||||
authCtx: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const appLookupParams = await getAppLookupParamsFromContextAsync(
|
||||
ctx,
|
||||
findApplicationTarget(testTargets)
|
||||
);
|
||||
const setupProvisioningProfileAction = new SetUpProvisioningProfile(
|
||||
appLookupParams,
|
||||
testTarget,
|
||||
IosDistributionType.AppStore
|
||||
);
|
||||
await expect(setupProvisioningProfileAction.runAsync(ctx)).rejects.toThrowError(
|
||||
InsufficientAuthenticationNonInteractiveError
|
||||
);
|
||||
|
||||
expect(jest.mocked(tryAuthenticateAppStoreWithEasAscApiKeyAsync).mock.calls).toEqual([
|
||||
[ctx, appLookupParams, AppleTeamType.IN_HOUSE],
|
||||
]);
|
||||
});
|
||||
|
||||
it('errors in nonInteractive mode when the profile needs regeneration but no Apple authentication can be established', async () => {
|
||||
jest.mocked(validateProvisioningProfileAsync).mockImplementation(async () => false);
|
||||
// tryAuthenticate fails to establish auth (default mock returns false and leaves authCtx unset).
|
||||
const ctx = createCtxMock({
|
||||
nonInteractive: true,
|
||||
});
|
||||
const appLookupParams = await getAppLookupParamsFromContextAsync(
|
||||
ctx,
|
||||
findApplicationTarget(testTargets)
|
||||
);
|
||||
const setupProvisioningProfileAction = new SetUpProvisioningProfile(
|
||||
appLookupParams,
|
||||
testTarget,
|
||||
IosDistributionType.AppStore
|
||||
);
|
||||
await expect(setupProvisioningProfileAction.runAsync(ctx)).rejects.toThrowError(
|
||||
InsufficientAuthenticationNonInteractiveError
|
||||
);
|
||||
|
||||
expect(jest.mocked(tryAuthenticateAppStoreWithEasAscApiKeyAsync).mock.calls.length).toBe(1);
|
||||
expect(jest.mocked(ctx.ios.createOrUpdateIosAppBuildCredentialsAsync).mock.calls.length).toBe(
|
||||
0
|
||||
);
|
||||
expect(jest.mocked(ctx.ios.deleteProvisioningProfilesAsync).mock.calls.length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { promptAsync } from '../../../../prompts';
|
||||
import { AppleTeamType } from '../authenticateTypes';
|
||||
import * as Keychain from '../keychain';
|
||||
import {
|
||||
resolveAppleTeamTypeFromEnvironment,
|
||||
resolveAppleTeamAsync,
|
||||
resolveAscApiKeyAsync,
|
||||
resolveUserCredentialsAsync,
|
||||
@@ -73,6 +74,17 @@ const testTeam = {
|
||||
teamName: 'test-name',
|
||||
teamType: AppleTeamType.IN_HOUSE,
|
||||
};
|
||||
describe(resolveAppleTeamTypeFromEnvironment, () => {
|
||||
it('returns undefined when EXPO_APPLE_TEAM_TYPE is not set', () => {
|
||||
expect(resolveAppleTeamTypeFromEnvironment()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns parsed team type when EXPO_APPLE_TEAM_TYPE is set', () => {
|
||||
process.env.EXPO_APPLE_TEAM_TYPE = AppleTeamType.COMPANY_OR_ORGANIZATION;
|
||||
expect(resolveAppleTeamTypeFromEnvironment()).toBe(AppleTeamType.COMPANY_OR_ORGANIZATION);
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolveAppleTeamAsync, () => {
|
||||
it(`uses option overrides over environment variables`, async () => {
|
||||
process.env.EXPO_APPLE_TEAM_ID = 'not supposed to be here';
|
||||
|
||||
@@ -118,7 +118,7 @@ function assertAppleTeamType(maybeTeamType: any): AppleTeamType {
|
||||
return maybeTeamType;
|
||||
}
|
||||
|
||||
function resolveAppleTeamTypeFromEnvironment(): AppleTeamType | undefined {
|
||||
export function resolveAppleTeamTypeFromEnvironment(): AppleTeamType | undefined {
|
||||
if (!process.env.EXPO_APPLE_TEAM_TYPE) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
+13
@@ -13589,6 +13589,19 @@ export type ViewUpdateGroupsOnBranchQueryVariables = Exact<{
|
||||
|
||||
export type ViewUpdateGroupsOnBranchQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, updateBranchByName?: { __typename?: 'UpdateBranch', id: string, updateGroups: Array<Array<{ __typename?: 'Update', id: string, group: string, message?: string | null, createdAt: any, runtimeVersion: string, platform: string, manifestFragment: string, isRollBackToEmbedded: boolean, manifestPermalink: string, gitCommitHash?: string | null, isGitWorkingTreeDirty: boolean, environment?: any | null, rolloutPercentage?: number | null, manifestHostOverride?: string | null, assetHostOverride?: string | null, actor?: { __typename: 'PartnerActor', username: string, id: string } | { __typename: 'Robot', firstName?: string | null, id: string } | { __typename: 'SSOUser', username: string, id: string } | { __typename: 'User', username: string, id: string } | null, branch: { __typename?: 'UpdateBranch', id: string, name: string }, codeSigningInfo?: { __typename?: 'CodeSigningInfo', keyid: string, sig: string, alg: string } | null, rolloutControlUpdate?: { __typename?: 'Update', id: string, group: string } | null, fingerprint?: { __typename?: 'Fingerprint', id: string, hash: string, debugInfoUrl?: string | null, source?: { __typename?: 'FingerprintSource', type: FingerprintSourceType, bucketKey: string, isDebugFingerprint?: boolean | null } | null } | null }>> } | null } } };
|
||||
|
||||
export type ViewUpdateGroupsPaginatedOnBranchQueryVariables = Exact<{
|
||||
appId: Scalars['String']['input'];
|
||||
branchName: Scalars['String']['input'];
|
||||
first?: InputMaybe<Scalars['Int']['input']>;
|
||||
last?: InputMaybe<Scalars['Int']['input']>;
|
||||
after?: InputMaybe<Scalars['String']['input']>;
|
||||
before?: InputMaybe<Scalars['String']['input']>;
|
||||
filter?: InputMaybe<UpdatesFilterV2>;
|
||||
}>;
|
||||
|
||||
|
||||
export type ViewUpdateGroupsPaginatedOnBranchQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, updateBranchByName?: { __typename?: 'UpdateBranch', id: string, updateGroupsPaginated: { __typename?: 'UpdateGroupsConnection', edges: Array<{ __typename?: 'UpdateGroupEdge', node: Array<{ __typename?: 'Update', id: string, group: string, message?: string | null, createdAt: any, runtimeVersion: string, platform: string, manifestFragment: string, isRollBackToEmbedded: boolean, manifestPermalink: string, gitCommitHash?: string | null, isGitWorkingTreeDirty: boolean, environment?: any | null, rolloutPercentage?: number | null, manifestHostOverride?: string | null, assetHostOverride?: string | null, actor?: { __typename: 'PartnerActor', username: string, id: string } | { __typename: 'Robot', firstName?: string | null, id: string } | { __typename: 'SSOUser', username: string, id: string } | { __typename: 'User', username: string, id: string } | null, branch: { __typename?: 'UpdateBranch', id: string, name: string }, codeSigningInfo?: { __typename?: 'CodeSigningInfo', keyid: string, sig: string, alg: string } | null, rolloutControlUpdate?: { __typename?: 'Update', id: string, group: string } | null, fingerprint?: { __typename?: 'Fingerprint', id: string, hash: string, debugInfoUrl?: string | null, source?: { __typename?: 'FingerprintSource', type: FingerprintSourceType, bucketKey: string, isDebugFingerprint?: boolean | null } | null } | null }> }> } } | null } } };
|
||||
|
||||
export type ViewUpdateGroupsOnAppQueryVariables = Exact<{
|
||||
appId: Scalars['String']['input'];
|
||||
limit: Scalars['Int']['input'];
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
ViewUpdateGroupsOnAppQueryVariables,
|
||||
ViewUpdateGroupsOnBranchQuery,
|
||||
ViewUpdateGroupsOnBranchQueryVariables,
|
||||
ViewUpdateGroupsPaginatedOnBranchQuery,
|
||||
ViewUpdateGroupsPaginatedOnBranchQueryVariables,
|
||||
ViewUpdatesByGroupQuery,
|
||||
ViewUpdatesByGroupQueryVariables,
|
||||
} from '../generated';
|
||||
@@ -96,6 +98,80 @@ export const UpdateQuery = {
|
||||
|
||||
return branch.updateGroups;
|
||||
},
|
||||
async viewUpdateGroupsPaginatedOnBranchAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
appId,
|
||||
branchName,
|
||||
first,
|
||||
last,
|
||||
after,
|
||||
before,
|
||||
filter,
|
||||
}: ViewUpdateGroupsPaginatedOnBranchQueryVariables
|
||||
): Promise<UpdateFragment[][]> {
|
||||
const response = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<
|
||||
ViewUpdateGroupsPaginatedOnBranchQuery,
|
||||
ViewUpdateGroupsPaginatedOnBranchQueryVariables
|
||||
>(
|
||||
gql`
|
||||
query ViewUpdateGroupsPaginatedOnBranch(
|
||||
$appId: String!
|
||||
$branchName: String!
|
||||
$first: Int
|
||||
$last: Int
|
||||
$after: String
|
||||
$before: String
|
||||
$filter: UpdatesFilterV2
|
||||
) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
updateBranchByName(name: $branchName) {
|
||||
id
|
||||
updateGroupsPaginated(
|
||||
first: $first
|
||||
last: $last
|
||||
after: $after
|
||||
before: $before
|
||||
filter: $filter
|
||||
) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
...UpdateFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${print(UpdateFragmentNode)}
|
||||
`,
|
||||
{
|
||||
appId,
|
||||
branchName,
|
||||
first,
|
||||
last,
|
||||
after,
|
||||
before,
|
||||
filter,
|
||||
},
|
||||
{ additionalTypenames: ['Update'] }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
const branch = response.app.byId.updateBranchByName;
|
||||
|
||||
if (!branch) {
|
||||
throw new Error(`Could not find branch "${branchName}"`);
|
||||
}
|
||||
|
||||
return branch.updateGroupsPaginated.edges.map(edge => edge.node);
|
||||
},
|
||||
async viewUpdateGroupsOnAppAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{ limit, offset, appId, filter }: ViewUpdateGroupsOnAppQueryVariables
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli-local-build-plugin",
|
||||
"version": "20.1.0",
|
||||
"version": "20.2.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,7 +26,7 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/build-tools": "20.1.0",
|
||||
"@expo/build-tools": "20.2.0",
|
||||
"@expo/eas-build-job": "20.1.0",
|
||||
"@expo/logger": "20.0.0",
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/worker",
|
||||
"version": "20.1.0",
|
||||
"version": "20.2.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "yarn watch",
|
||||
@@ -19,7 +19,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/core": "7.24.7",
|
||||
"@expo/build-tools": "20.1.0",
|
||||
"@expo/build-tools": "20.2.0",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/config-types": "55.0.5",
|
||||
"@expo/downloader": "20.0.0",
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
describe('config', () => {
|
||||
const originalEnvironment = process.env.ENVIRONMENT;
|
||||
const originalWorkerRuntimeConfigBase64 = process.env.WORKER_RUNTIME_CONFIG_BASE64;
|
||||
|
||||
afterEach(() => {
|
||||
restoreEnv('ENVIRONMENT', originalEnvironment);
|
||||
restoreEnv('WORKER_RUNTIME_CONFIG_BASE64', originalWorkerRuntimeConfigBase64);
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
it('parses cache URL fallbacks from worker runtime config', () => {
|
||||
process.env.ENVIRONMENT = 'staging';
|
||||
process.env.WORKER_RUNTIME_CONFIG_BASE64 = Buffer.from(
|
||||
JSON.stringify({
|
||||
buildId: 'build-id',
|
||||
npmCacheUrl: 'https://npm-cache.example',
|
||||
nodeJsCacheUrl: 'https://node-cache.example',
|
||||
mavenCacheUrl: 'https://maven-cache.example',
|
||||
cocoapodsCacheUrl: 'https://cocoapods-cache.example',
|
||||
})
|
||||
).toString('base64');
|
||||
|
||||
jest.isolateModules(() => {
|
||||
const config = require('../config').default;
|
||||
|
||||
expect(config.npmCacheUrl).toBe('https://npm-cache.example');
|
||||
expect(config.nodeJsCacheUrl).toBe('https://node-cache.example');
|
||||
expect(config.mavenCacheUrl).toBe('https://maven-cache.example');
|
||||
expect(config.cocoapodsCacheUrl).toBe('https://cocoapods-cache.example');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function restoreEnv(key: string, value: string | undefined): void {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { RuntimeSettings } from '@expo/build-tools';
|
||||
import { Platform, Workflow } from '@expo/eas-build-job';
|
||||
|
||||
import config from '../config';
|
||||
import { createBuildContext } from '../context';
|
||||
import { getBuildEnv } from '../env';
|
||||
import { prepareRuntimeEnvironmentConfigFiles } from '../runtimeEnvironment';
|
||||
|
||||
jest.mock('../env', () => ({
|
||||
getBuildEnv: jest.fn(() => ({
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
PATH: '/usr/bin',
|
||||
})),
|
||||
}));
|
||||
jest.mock('../runtimeEnvironment', () => ({
|
||||
prepareRuntimeEnvironmentConfigFiles: jest.fn(async () => {}),
|
||||
}));
|
||||
|
||||
describe(createBuildContext.name, () => {
|
||||
const childLogger = {
|
||||
error: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
} as any;
|
||||
const buildLogger = {
|
||||
child: jest.fn(() => childLogger),
|
||||
} as any;
|
||||
const baseOptions = {
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
analytics: {} as any,
|
||||
metadata: {},
|
||||
projectId: 'project-id',
|
||||
buildId: 'build-id',
|
||||
buildLogger,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.spyOn(RuntimeSettings, 'loadAsync').mockResolvedValue();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('loads runtime settings with the job environment before creating build env', async () => {
|
||||
const env = { EAS_USE_NPM_CACHE: '1' };
|
||||
|
||||
await createBuildContext({
|
||||
...baseOptions,
|
||||
job: {
|
||||
platform: Platform.IOS,
|
||||
type: Workflow.GENERIC,
|
||||
secrets: { environmentSecrets: [] },
|
||||
builderEnvironment: { env },
|
||||
environment: 'preview',
|
||||
} as any,
|
||||
});
|
||||
|
||||
expect(RuntimeSettings.loadAsync).toHaveBeenCalledWith({
|
||||
environment: config.env,
|
||||
env,
|
||||
});
|
||||
expect(prepareRuntimeEnvironmentConfigFiles).toHaveBeenCalled();
|
||||
expect(jest.mocked(RuntimeSettings.loadAsync).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
jest.mocked(getBuildEnv).mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
|
||||
it('loads runtime settings without job env when the job has no builder environment', async () => {
|
||||
await createBuildContext({
|
||||
...baseOptions,
|
||||
job: {
|
||||
platform: Platform.ANDROID,
|
||||
type: Workflow.GENERIC,
|
||||
secrets: { environmentSecrets: [] },
|
||||
} as any,
|
||||
});
|
||||
|
||||
expect(RuntimeSettings.loadAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
environment: config.env,
|
||||
env: undefined,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ describe(getBuildEnv.name, () => {
|
||||
NVM_NODEJS_ORG_MIRROR: process.env.NVM_NODEJS_ORG_MIRROR,
|
||||
EAS_BUILD_MAVEN_CACHE_URL: process.env.EAS_BUILD_MAVEN_CACHE_URL,
|
||||
EAS_BUILD_COCOAPODS_CACHE_URL: process.env.EAS_BUILD_COCOAPODS_CACHE_URL,
|
||||
NPM_CONFIG_REGISTRY: process.env.NPM_CONFIG_REGISTRY,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -26,6 +27,7 @@ describe(getBuildEnv.name, () => {
|
||||
delete process.env.NVM_NODEJS_ORG_MIRROR;
|
||||
delete process.env.EAS_BUILD_MAVEN_CACHE_URL;
|
||||
delete process.env.EAS_BUILD_COCOAPODS_CACHE_URL;
|
||||
delete process.env.NPM_CONFIG_REGISTRY;
|
||||
jest.spyOn(RuntimeSettings, 'getNpmCacheUrl').mockReturnValue(null);
|
||||
jest.spyOn(RuntimeSettings, 'getNodeJsCacheUrl').mockReturnValue(null);
|
||||
jest.spyOn(RuntimeSettings, 'getMavenCacheUrl').mockReturnValue(null);
|
||||
@@ -44,6 +46,7 @@ describe(getBuildEnv.name, () => {
|
||||
restoreEnv('NVM_NODEJS_ORG_MIRROR', originalCacheUrls.NVM_NODEJS_ORG_MIRROR);
|
||||
restoreEnv('EAS_BUILD_MAVEN_CACHE_URL', originalCacheUrls.EAS_BUILD_MAVEN_CACHE_URL);
|
||||
restoreEnv('EAS_BUILD_COCOAPODS_CACHE_URL', originalCacheUrls.EAS_BUILD_COCOAPODS_CACHE_URL);
|
||||
restoreEnv('NPM_CONFIG_REGISTRY', originalCacheUrls.NPM_CONFIG_REGISTRY);
|
||||
mockProcessPlatform(originalPlatform);
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
@@ -196,6 +199,7 @@ describe(getBuildEnv.name, () => {
|
||||
});
|
||||
|
||||
expect(env.NPM_CACHE_URL).toBeUndefined();
|
||||
expect(env.NPM_CONFIG_REGISTRY).toBeUndefined();
|
||||
expect(env.NVM_NODEJS_ORG_MIRROR).toBeUndefined();
|
||||
expect(env.EAS_BUILD_NPM_CACHE_URL).toBeUndefined();
|
||||
expect(env.EAS_BUILD_MAVEN_CACHE_URL).toBeUndefined();
|
||||
@@ -228,6 +232,7 @@ describe(getBuildEnv.name, () => {
|
||||
});
|
||||
|
||||
expect(env.NPM_CACHE_URL).toBeUndefined();
|
||||
expect(env.NPM_CONFIG_REGISTRY).toBeUndefined();
|
||||
expect(env.NVM_NODEJS_ORG_MIRROR).toBeUndefined();
|
||||
expect(env.EAS_BUILD_NPM_CACHE_URL).toBeUndefined();
|
||||
expect(env.EAS_BUILD_COCOAPODS_CACHE_URL).toBeUndefined();
|
||||
@@ -264,6 +269,7 @@ describe(getBuildEnv.name, () => {
|
||||
});
|
||||
|
||||
expect(env.NPM_CACHE_URL).toBe('https://npm.example');
|
||||
expect(env.NPM_CONFIG_REGISTRY).toBe('https://npm.example');
|
||||
expect(env.EAS_BUILD_NPM_CACHE_URL).toBe('https://npm.example');
|
||||
expect(env.NVM_NODEJS_ORG_MIRROR).toBe('https://node.example');
|
||||
expect(env.EAS_BUILD_MAVEN_CACHE_URL).toBe('https://maven.example');
|
||||
|
||||
@@ -89,7 +89,7 @@ describe('prepareRuntimeEnvironment', () => {
|
||||
|
||||
await prepareRuntimeEnvironmentConfigFiles();
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith('npm', [
|
||||
expect(spawn).not.toHaveBeenCalledWith('npm', [
|
||||
'config',
|
||||
'set',
|
||||
'registry',
|
||||
|
||||
@@ -26,7 +26,7 @@ jest.mock('../build', () => ({
|
||||
build: jest.fn(),
|
||||
}));
|
||||
jest.mock('../context', () => ({
|
||||
createBuildContext: jest.fn(() => ({ job: {} })),
|
||||
createBuildContext: jest.fn(async () => ({ job: {} })),
|
||||
}));
|
||||
jest.mock('../config', () => {
|
||||
const actual = jest.requireActual('../config').default;
|
||||
@@ -111,7 +111,7 @@ describe('BuildService Datadog setup', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
buildMock.mockResolvedValue({});
|
||||
createBuildContextMock.mockReturnValue({ job: {} } as any);
|
||||
createBuildContextMock.mockResolvedValue({ job: {} } as any);
|
||||
createBuildLoggerWithSecretsFilterMock.mockResolvedValue({
|
||||
logger: buildLogger,
|
||||
cleanUp: jest.fn(async () => {}),
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { BuildContext, GCS } from '@expo/build-tools';
|
||||
import { Job } from '@expo/eas-build-job';
|
||||
import { Job, errors } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { randomBytes, randomUUID } from 'crypto';
|
||||
import { vol } from 'memfs';
|
||||
import { Response } from 'node-fetch';
|
||||
|
||||
import config from '../config';
|
||||
import {
|
||||
uploadApplicationArchiveAsync,
|
||||
uploadBuildArtifactsAsync,
|
||||
@@ -59,7 +58,14 @@ describe(uploadApplicationArchiveAsync.name, () => {
|
||||
buildId: 'buildId',
|
||||
logger: ctx.logger,
|
||||
})
|
||||
).rejects.toThrow('env variables are not set');
|
||||
).rejects.toMatchObject({
|
||||
errorCode: errors.ErrorCode.SERVER_ERROR,
|
||||
trackingCode: 'EAS_BUILD_UPLOAD_APPLICATION_ARCHIVE_FAILED',
|
||||
message: 'Failed to upload application archive.',
|
||||
cause: expect.objectContaining({
|
||||
message: expect.stringContaining('env variables are not set'),
|
||||
}),
|
||||
});
|
||||
|
||||
ctx.env.__WORKFLOW_JOB_ID = randomUUID();
|
||||
|
||||
@@ -69,7 +75,14 @@ describe(uploadApplicationArchiveAsync.name, () => {
|
||||
buildId: 'buildId',
|
||||
logger: ctx.logger,
|
||||
})
|
||||
).rejects.toThrow('robot access token is not set');
|
||||
).rejects.toMatchObject({
|
||||
errorCode: errors.ErrorCode.SERVER_ERROR,
|
||||
trackingCode: 'EAS_BUILD_UPLOAD_APPLICATION_ARCHIVE_FAILED',
|
||||
message: 'Failed to upload application archive.',
|
||||
cause: expect.objectContaining({
|
||||
message: expect.stringContaining('robot access token is not set'),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('should upload the application archive', async () => {
|
||||
@@ -151,6 +164,9 @@ describe(uploadApplicationArchiveAsync.name, () => {
|
||||
headers: {
|
||||
Authorization: `Bearer ${ctx.job.secrets!.robotAccessToken}`,
|
||||
},
|
||||
retries: 2,
|
||||
retryIntervalMs: 1000,
|
||||
logger: ctx.logger,
|
||||
})
|
||||
);
|
||||
expect(turtleFetchMock).toHaveBeenNthCalledWith(
|
||||
@@ -162,84 +178,81 @@ describe(uploadApplicationArchiveAsync.name, () => {
|
||||
headers: {
|
||||
Authorization: `Bearer ${ctx.job.secrets!.robotAccessToken}`,
|
||||
},
|
||||
retries: 2,
|
||||
retryIntervalMs: 1000,
|
||||
logger: ctx.logger,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
describe('with signed upload url provided via config', () => {
|
||||
beforeAll(() => {
|
||||
config.gcsSignedUploadUrlForApplicationArchive = {
|
||||
url: 'https://upload.url/from-config',
|
||||
headers: {
|
||||
authorization: 'test-signed-upload-authorization',
|
||||
},
|
||||
};
|
||||
});
|
||||
afterAll(() => {
|
||||
config.gcsSignedUploadUrlForApplicationArchive = null;
|
||||
it('should throw a system error if the application archive upload fails', async () => {
|
||||
vol.fromJSON({
|
||||
'./artifact.ipa': JSON.stringify(randomBytes(20)),
|
||||
});
|
||||
const workflowJobId = randomUUID();
|
||||
|
||||
it('should fall back to signed upload URL if upload session fails', async () => {
|
||||
vol.fromJSON({
|
||||
'./artifact.ipa': JSON.stringify(randomBytes(20)),
|
||||
});
|
||||
const workflowJobId = randomUUID();
|
||||
|
||||
// @ts-expect-error
|
||||
const ctx: BuildContext<Job> = {
|
||||
env: {
|
||||
__WORKFLOW_JOB_ID: workflowJobId,
|
||||
// @ts-expect-error
|
||||
const ctx: BuildContext<Job> = {
|
||||
env: {
|
||||
__WORKFLOW_JOB_ID: workflowJobId,
|
||||
},
|
||||
job: {
|
||||
secrets: {
|
||||
robotAccessToken: 'fake-token',
|
||||
},
|
||||
job: {
|
||||
secrets: {
|
||||
robotAccessToken: 'fake-token',
|
||||
},
|
||||
} as Job,
|
||||
logger: mockLogger,
|
||||
};
|
||||
} as Job,
|
||||
logger: mockLogger,
|
||||
};
|
||||
|
||||
turtleFetchMock.mockImplementation(async () => {
|
||||
return {
|
||||
json: async () => ({
|
||||
data: {
|
||||
bucketKey: 'test/bucketKey',
|
||||
url: 'https://upload.url/from-www',
|
||||
headers: {
|
||||
authorization: 'test-signed-upload-authorization',
|
||||
},
|
||||
storageType: 'GCS',
|
||||
const bucketKey = `test/${randomUUID()}/artifact.ipa`;
|
||||
const uploadUrl = `https://upload.url/${randomUUID()}`;
|
||||
const testSignedUploadAuthorization = randomUUID();
|
||||
const uploadError = new Error('upload failed');
|
||||
|
||||
turtleFetchMock.mockImplementation(async () => {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
data: {
|
||||
bucketKey,
|
||||
url: uploadUrl,
|
||||
headers: {
|
||||
authorization: testSignedUploadAuthorization,
|
||||
},
|
||||
}),
|
||||
ok: true,
|
||||
} as unknown as Response;
|
||||
});
|
||||
|
||||
// GCS fails to upload to upload session from www.
|
||||
jest.mocked(GCS.uploadWithSignedUrl).mockImplementation(async ({ signedUrl }) => {
|
||||
if (signedUrl.url === 'https://upload.url/from-www') {
|
||||
throw new Error('upload failed');
|
||||
}
|
||||
return signedUrl.url;
|
||||
});
|
||||
storageType: 'GCS',
|
||||
},
|
||||
}),
|
||||
} as Response;
|
||||
});
|
||||
jest.mocked(GCS.uploadWithSignedUrl).mockRejectedValueOnce(uploadError);
|
||||
|
||||
let thrownError: unknown;
|
||||
try {
|
||||
await uploadApplicationArchiveAsync(ctx, {
|
||||
artifactPaths: ['./artifact.ipa'],
|
||||
buildId: randomUUID(),
|
||||
logger: ctx.logger,
|
||||
});
|
||||
} catch (err) {
|
||||
thrownError = err;
|
||||
}
|
||||
|
||||
expect(GCS.uploadWithSignedUrl).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
signedUrl: {
|
||||
url: 'https://upload.url/from-config',
|
||||
headers: {
|
||||
authorization: 'test-signed-upload-authorization',
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(turtleFetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(thrownError).toBeInstanceOf(errors.SystemError);
|
||||
expect(thrownError).toMatchObject({
|
||||
errorCode: errors.ErrorCode.SERVER_ERROR,
|
||||
trackingCode: 'EAS_BUILD_UPLOAD_APPLICATION_ARCHIVE_FAILED',
|
||||
message: 'Failed to upload application archive.',
|
||||
buildPhase: undefined,
|
||||
metadata: expect.objectContaining({
|
||||
filename: expect.stringMatching(/^application-.*\.ipa$/),
|
||||
size: expect.any(Number),
|
||||
url: uploadUrl,
|
||||
headers: {
|
||||
authorization: testSignedUploadAuthorization,
|
||||
},
|
||||
}),
|
||||
cause: uploadError,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -263,7 +276,14 @@ describe(uploadBuildArtifactsAsync.name, () => {
|
||||
buildId: 'buildId',
|
||||
logger: ctx.logger,
|
||||
})
|
||||
).rejects.toThrow('env variables are not set');
|
||||
).rejects.toMatchObject({
|
||||
errorCode: errors.ErrorCode.SERVER_ERROR,
|
||||
trackingCode: 'EAS_BUILD_UPLOAD_BUILD_ARTIFACTS_FAILED',
|
||||
message: 'Failed to upload build artifacts.',
|
||||
cause: expect.objectContaining({
|
||||
message: expect.stringContaining('env variables are not set'),
|
||||
}),
|
||||
});
|
||||
ctx.env.__WORKFLOW_JOB_ID = randomUUID();
|
||||
await expect(
|
||||
uploadBuildArtifactsAsync(ctx, {
|
||||
@@ -271,7 +291,14 @@ describe(uploadBuildArtifactsAsync.name, () => {
|
||||
buildId: 'buildId',
|
||||
logger: ctx.logger,
|
||||
})
|
||||
).rejects.toThrow('robot access token is not set');
|
||||
).rejects.toMatchObject({
|
||||
errorCode: errors.ErrorCode.SERVER_ERROR,
|
||||
trackingCode: 'EAS_BUILD_UPLOAD_BUILD_ARTIFACTS_FAILED',
|
||||
message: 'Failed to upload build artifacts.',
|
||||
cause: expect.objectContaining({
|
||||
message: expect.stringContaining('robot access token is not set'),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('should upload the build artifacts', async () => {
|
||||
@@ -348,6 +375,9 @@ describe(uploadBuildArtifactsAsync.name, () => {
|
||||
headers: {
|
||||
Authorization: `Bearer ${ctx.job.secrets!.robotAccessToken}`,
|
||||
},
|
||||
retries: 2,
|
||||
retryIntervalMs: 1000,
|
||||
logger: ctx.logger,
|
||||
})
|
||||
);
|
||||
expect(turtleFetchMock).toHaveBeenNthCalledWith(
|
||||
@@ -359,96 +389,83 @@ describe(uploadBuildArtifactsAsync.name, () => {
|
||||
headers: {
|
||||
Authorization: `Bearer ${ctx.job.secrets!.robotAccessToken}`,
|
||||
},
|
||||
retries: 2,
|
||||
retryIntervalMs: 1000,
|
||||
logger: ctx.logger,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
describe('with signed upload url provided via config', () => {
|
||||
beforeAll(() => {
|
||||
config.gcsSignedUploadUrlForBuildArtifacts = {
|
||||
url: 'https://upload.url/from-config',
|
||||
headers: {
|
||||
authorization: 'test-signed-upload-authorization',
|
||||
},
|
||||
};
|
||||
it('should throw a system error if the build artifacts upload fails', async () => {
|
||||
vol.fromJSON({
|
||||
'./video.mp4': JSON.stringify(randomBytes(20)),
|
||||
});
|
||||
afterAll(() => {
|
||||
config.gcsSignedUploadUrlForBuildArtifacts = null;
|
||||
});
|
||||
|
||||
it('should fall back to signed upload URL if upload session fails', async () => {
|
||||
vol.fromJSON({
|
||||
'./video.mp4': JSON.stringify(randomBytes(20)),
|
||||
});
|
||||
const workflowJobId = randomUUID();
|
||||
|
||||
// @ts-expect-error
|
||||
const ctx: BuildContext<Job> = {
|
||||
env: {
|
||||
__WORKFLOW_JOB_ID: workflowJobId,
|
||||
const workflowJobId = randomUUID();
|
||||
// @ts-expect-error
|
||||
const ctx: BuildContext<Job> = {
|
||||
env: {
|
||||
__WORKFLOW_JOB_ID: workflowJobId,
|
||||
},
|
||||
job: {
|
||||
secrets: {
|
||||
robotAccessToken: 'fake-token',
|
||||
},
|
||||
job: {
|
||||
secrets: {
|
||||
robotAccessToken: 'fake-token',
|
||||
},
|
||||
} as Job,
|
||||
logger: mockLogger,
|
||||
};
|
||||
|
||||
turtleFetchMock.mockImplementation(async () => {
|
||||
return {
|
||||
json: async () => ({
|
||||
data: {
|
||||
bucketKey: 'test/bucketKey',
|
||||
url: 'https://upload.url/from-www',
|
||||
headers: {
|
||||
authorization: 'test-signed-upload-authorization',
|
||||
},
|
||||
storageType: 'GCS',
|
||||
} as Job,
|
||||
logger: mockLogger,
|
||||
};
|
||||
const bucketKey = `test/${randomUUID()}/artifact.ipa`;
|
||||
const uploadUrl = `https://upload.url/${randomUUID()}`;
|
||||
const testSignedUploadAuthorization = randomUUID();
|
||||
const uploadError = new Error('upload failed');
|
||||
turtleFetchMock.mockImplementation(async () => {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
data: {
|
||||
bucketKey,
|
||||
url: uploadUrl,
|
||||
headers: {
|
||||
authorization: testSignedUploadAuthorization,
|
||||
},
|
||||
}),
|
||||
ok: true,
|
||||
} as unknown as Response;
|
||||
});
|
||||
|
||||
// GCS fails to upload to upload session from www.
|
||||
jest.mocked(GCS.uploadWithSignedUrl).mockImplementation(async ({ signedUrl }) => {
|
||||
if (signedUrl.url === 'https://upload.url/from-www') {
|
||||
throw new Error('upload failed');
|
||||
}
|
||||
return signedUrl.url;
|
||||
});
|
||||
storageType: 'GCS',
|
||||
},
|
||||
}),
|
||||
} as Response;
|
||||
});
|
||||
jest.mocked(GCS.uploadWithSignedUrl).mockRejectedValueOnce(uploadError);
|
||||
|
||||
let thrownError: unknown;
|
||||
try {
|
||||
await uploadBuildArtifactsAsync(ctx, {
|
||||
artifactPaths: ['./video.mp4'],
|
||||
buildId: randomUUID(),
|
||||
logger: ctx.logger,
|
||||
});
|
||||
} catch (err) {
|
||||
thrownError = err;
|
||||
}
|
||||
|
||||
expect(GCS.uploadWithSignedUrl).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
signedUrl: {
|
||||
url: 'https://upload.url/from-config',
|
||||
headers: {
|
||||
authorization: 'test-signed-upload-authorization',
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(turtleFetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(thrownError).toBeInstanceOf(errors.SystemError);
|
||||
expect(thrownError).toMatchObject({
|
||||
errorCode: errors.ErrorCode.SERVER_ERROR,
|
||||
trackingCode: 'EAS_BUILD_UPLOAD_BUILD_ARTIFACTS_FAILED',
|
||||
message: 'Failed to upload build artifacts.',
|
||||
buildPhase: undefined,
|
||||
metadata: expect.objectContaining({
|
||||
filename: expect.stringMatching(/^artifacts-.*\.mp4$/),
|
||||
size: expect.any(Number),
|
||||
url: uploadUrl,
|
||||
headers: {
|
||||
authorization: testSignedUploadAuthorization,
|
||||
},
|
||||
}),
|
||||
cause: uploadError,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with signed upload url provided via www', () => {
|
||||
beforeAll(() => {
|
||||
config.gcsSignedUploadUrlForBuildArtifacts = null;
|
||||
});
|
||||
afterAll(() => {
|
||||
config.gcsSignedUploadUrlForBuildArtifacts = null;
|
||||
});
|
||||
|
||||
it('should use www signed upload url', async () => {
|
||||
vol.fromJSON({
|
||||
'./video.mp4': JSON.stringify(randomBytes(20)),
|
||||
@@ -533,6 +550,9 @@ describe('with signed upload url provided via www', () => {
|
||||
headers: {
|
||||
Authorization: `Bearer ${ctx.job.secrets!.robotAccessToken}`,
|
||||
},
|
||||
retries: 2,
|
||||
retryIntervalMs: 1000,
|
||||
logger: ctx.logger,
|
||||
})
|
||||
);
|
||||
expect(turtleFetchMock).toHaveBeenNthCalledWith(
|
||||
@@ -544,6 +564,9 @@ describe('with signed upload url provided via www', () => {
|
||||
headers: {
|
||||
Authorization: `Bearer ${ctx.job.secrets!.robotAccessToken}`,
|
||||
},
|
||||
retries: 2,
|
||||
retryIntervalMs: 1000,
|
||||
logger: ctx.logger,
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -622,6 +645,9 @@ describe(uploadWorkflowArtifactAsync.name, () => {
|
||||
headers: {
|
||||
Authorization: `Bearer ${ctx.job.secrets!.robotAccessToken}`,
|
||||
},
|
||||
retries: 2,
|
||||
retryIntervalMs: 1000,
|
||||
logger: ctx.logger,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -63,17 +63,6 @@ export default {
|
||||
defaultValue: null,
|
||||
}),
|
||||
},
|
||||
gcsSignedUploadUrlForApplicationArchive: env<GCS.SignedUrl | null>(
|
||||
'WORKER_RUNTIME_CONFIG_BASE64',
|
||||
{
|
||||
transform: createBase64EnvTransformer('gcsSignedUploadUrlForApplicationArchive'),
|
||||
defaultValue: null,
|
||||
}
|
||||
),
|
||||
gcsSignedUploadUrlForBuildArtifacts: env<GCS.SignedUrl | null>('WORKER_RUNTIME_CONFIG_BASE64', {
|
||||
transform: createBase64EnvTransformer('gcsSignedUploadUrlForBuildArtifacts'),
|
||||
defaultValue: null,
|
||||
}),
|
||||
workingdir: env('WORKINGDIR', { defaultValue: path.join(__dirname, '../workingdir') }),
|
||||
sentry: {
|
||||
dsn: env('SENTRY_DSN', { defaultValue: '' }),
|
||||
@@ -82,22 +71,6 @@ export default {
|
||||
dataPlaneURL: env<string | null>('RUDDERSTACK_DATA_PLANE_URL', { defaultValue: null }),
|
||||
writeKey: env<string | null>('RUDDERSTACK_WRITE_KEY', { defaultValue: null }),
|
||||
},
|
||||
npmCacheUrl: env<string | null>('WORKER_RUNTIME_CONFIG_BASE64', {
|
||||
transform: createBase64EnvTransformer('npmCacheUrl'),
|
||||
defaultValue: null,
|
||||
}),
|
||||
nodeJsCacheUrl: env<string | null>('WORKER_RUNTIME_CONFIG_BASE64', {
|
||||
transform: createBase64EnvTransformer('nodeJsCacheUrl'),
|
||||
defaultValue: null,
|
||||
}),
|
||||
mavenCacheUrl: env<string | null>('WORKER_RUNTIME_CONFIG_BASE64', {
|
||||
transform: createBase64EnvTransformer('mavenCacheUrl'),
|
||||
defaultValue: null,
|
||||
}),
|
||||
cocoapodsCacheUrl: env<string | null>('WORKER_RUNTIME_CONFIG_BASE64', {
|
||||
transform: createBase64EnvTransformer('cocoapodsCacheUrl'),
|
||||
defaultValue: null,
|
||||
}),
|
||||
runMetricsServer: env<boolean | null>('WORKER_RUNTIME_CONFIG_BASE64', {
|
||||
transform: createBase64EnvTransformer('runMetricsServer'),
|
||||
defaultValue: null,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BuildContext, BuildContextOptions, LogBuffer } from '@expo/build-tools';
|
||||
import { BuildContext, BuildContextOptions, LogBuffer, RuntimeSettings } from '@expo/build-tools';
|
||||
import { Job, ManagedArtifactType, Metadata, Platform } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import assert from 'assert';
|
||||
@@ -8,6 +8,7 @@ import config from './config';
|
||||
import { getBuildEnv } from './env';
|
||||
import { Analytics } from './external/analytics';
|
||||
import { uploadXcodeBuildLogs } from './ios/xcodeLogs';
|
||||
import { prepareRuntimeEnvironmentConfigFiles } from './runtimeEnvironment';
|
||||
import sentry from './sentry';
|
||||
import {
|
||||
uploadApplicationArchiveAsync,
|
||||
@@ -16,7 +17,7 @@ import {
|
||||
uploadWorkflowArtifactAsync,
|
||||
} from './upload';
|
||||
|
||||
export function createBuildContext<TJob extends Job>({
|
||||
export async function createBuildContext<TJob extends Job>({
|
||||
job,
|
||||
logBuffer,
|
||||
analytics,
|
||||
@@ -32,9 +33,14 @@ export function createBuildContext<TJob extends Job>({
|
||||
projectId: string;
|
||||
buildId: string;
|
||||
buildLogger: bunyan;
|
||||
}): BuildContext<TJob> {
|
||||
const env = getBuildEnv({ job, projectId, metadata, buildId });
|
||||
}): Promise<BuildContext<TJob>> {
|
||||
const childLogger = buildLogger.child({ buildId });
|
||||
await RuntimeSettings.loadAsync({
|
||||
environment: config.env,
|
||||
env: job.builderEnvironment?.env,
|
||||
});
|
||||
await prepareRuntimeEnvironmentConfigFiles();
|
||||
const env = getBuildEnv({ job, projectId, metadata, buildId });
|
||||
|
||||
const uploadArtifact: BuildContextOptions['uploadArtifact'] = async ({ artifact, logger }) => {
|
||||
const { paths, type } = artifact;
|
||||
|
||||
@@ -42,6 +42,7 @@ export function getBuildEnv({
|
||||
const cocoapodsCacheUrl = RuntimeSettings.getCocoapodsCacheUrl();
|
||||
|
||||
setEnv(env, 'NPM_CACHE_URL', npmCacheUrl);
|
||||
setEnv(env, 'NPM_CONFIG_REGISTRY', npmCacheUrl);
|
||||
setEnv(env, 'NVM_NODEJS_ORG_MIRROR', nodeJsCacheUrl);
|
||||
setEnv(env, 'EAS_BUILD_NPM_CACHE_URL', npmCacheUrl);
|
||||
setEnv(env, 'EAS_BUILD_PROFILE', metadata.buildProfile);
|
||||
|
||||
-10
@@ -24,10 +24,6 @@ export namespace Worker {
|
||||
type JobRunWorkerRuntimeConfig = {
|
||||
gcsSignedUploadUrlForLogs: GCS.SignedUrl;
|
||||
|
||||
nodeJsCacheUrl: string | undefined;
|
||||
npmCacheUrl: string | undefined;
|
||||
mavenCacheUrl: string | undefined;
|
||||
cocoapodsCacheUrl: string | undefined;
|
||||
runMetricsServer: boolean;
|
||||
|
||||
type: 'jobRun';
|
||||
@@ -35,17 +31,11 @@ export namespace Worker {
|
||||
};
|
||||
|
||||
type BuildWorkerRuntimeConfig = {
|
||||
gcsSignedUploadUrlForApplicationArchive: GCS.SignedUrl | null;
|
||||
gcsSignedUploadUrlForBuildArtifacts: GCS.SignedUrl | null;
|
||||
gcsSignedUploadUrlForLogs: GCS.SignedUrl;
|
||||
gcsSignedUploadUrlForXcodeBuildLogs?: GCS.SignedUrl;
|
||||
gcsSignedUploadUrlForBuildCache?: GCS.SignedUrl;
|
||||
gcsSignedBuildCacheDownloadUrl?: string;
|
||||
|
||||
nodeJsCacheUrl: string | undefined;
|
||||
npmCacheUrl: string | undefined;
|
||||
mavenCacheUrl: string | undefined;
|
||||
cocoapodsCacheUrl: string | undefined;
|
||||
runMetricsServer: boolean;
|
||||
|
||||
type?: never;
|
||||
|
||||
@@ -1,25 +1,11 @@
|
||||
import { RuntimeSettings } from '@expo/build-tools';
|
||||
|
||||
import config from './config';
|
||||
import logger from './logger';
|
||||
import { startServer } from './metricsServer';
|
||||
import { prepareRuntimeEnvironmentConfigFiles } from './runtimeEnvironment';
|
||||
import sentry from './sentry';
|
||||
import { prepareWorkingdir } from './workingdir';
|
||||
import startWsServer from './ws';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
await RuntimeSettings.loadAsync({
|
||||
environment: config.env,
|
||||
logger,
|
||||
cacheUrlFallbacks: {
|
||||
npm: config.npmCacheUrl,
|
||||
nodejs: config.nodeJsCacheUrl,
|
||||
maven: config.mavenCacheUrl,
|
||||
cocoapods: config.cocoapodsCacheUrl,
|
||||
},
|
||||
});
|
||||
await prepareRuntimeEnvironmentConfigFiles();
|
||||
await prepareWorkingdir();
|
||||
startWsServer();
|
||||
if (config.runMetricsServer) {
|
||||
|
||||
@@ -27,8 +27,6 @@ export async function prepareRuntimeEnvironmentConfigFiles(): Promise<void> {
|
||||
const mavenCacheUrl = RuntimeSettings.getMavenCacheUrl();
|
||||
|
||||
if (npmCacheUrl) {
|
||||
// create ~/.npmrc
|
||||
await spawn('npm', ['config', 'set', 'registry', npmCacheUrl]);
|
||||
// create ~/.yarnrc.yml
|
||||
await templateFile(
|
||||
path.join(__dirname, '../src/templates/yarnrc.yml'),
|
||||
|
||||
@@ -292,7 +292,7 @@ export default class BuildService {
|
||||
});
|
||||
}
|
||||
|
||||
const ctx = createBuildContext({
|
||||
const ctx = await createBuildContext({
|
||||
job,
|
||||
logBuffer,
|
||||
analytics,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BuildContext, GCS } from '@expo/build-tools';
|
||||
import { ArchiveSourceType } from '@expo/eas-build-job';
|
||||
import { ArchiveSourceType, errors } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import fs from 'fs-extra';
|
||||
@@ -13,6 +13,9 @@ import { Analytics, Event } from './external/analytics';
|
||||
import sentry from './sentry';
|
||||
import { TurtleFetchError, turtleFetch } from './utils/turtleFetch';
|
||||
|
||||
const UPLOAD_API_REQUEST_RETRIES = 2;
|
||||
const UPLOAD_API_REQUEST_RETRY_INTERVAL_MS = 1000;
|
||||
|
||||
class ErrorWithMetadata extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
@@ -65,39 +68,19 @@ export async function uploadApplicationArchiveAsync(
|
||||
return { filename: null };
|
||||
} catch (err: any) {
|
||||
// Otherwise, we log the error and proceed to upload to Launcher's upload URL.
|
||||
const msg = 'Upload to upload session failed';
|
||||
logger.error({ err, filename, size }, msg);
|
||||
sentry.capture(msg, err, {
|
||||
extras: {
|
||||
logger.error({ err, filename, size }, 'Upload to upload session failed');
|
||||
|
||||
throw new errors.SystemError('Failed to upload application archive.', {
|
||||
trackingCode: 'EAS_BUILD_UPLOAD_APPLICATION_ARCHIVE_FAILED',
|
||||
metadata: {
|
||||
filename,
|
||||
size,
|
||||
...uploadSession,
|
||||
...(err instanceof ErrorWithMetadata ? err.metadata : {}),
|
||||
},
|
||||
});
|
||||
|
||||
// Lack of `gcsSignedUploadUrlForApplicationArchive` means we're being run by workflow-orchestration.
|
||||
if (!config.gcsSignedUploadUrlForApplicationArchive) {
|
||||
throw new Error(`Failed to upload application archive: ${err?.message}\n${err?.stack}`, {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await GCS.uploadWithSignedUrl({
|
||||
signedUrl: config.gcsSignedUploadUrlForApplicationArchive,
|
||||
srcGeneratorAsync: async () => {
|
||||
return fs.createReadStream(localPath);
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
throw new Error(`Failed to upload application archive: ${err?.message}\n${err?.stack}`, {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
|
||||
return { filename };
|
||||
}
|
||||
|
||||
export async function uploadBuildArtifactsAsync(
|
||||
@@ -140,39 +123,18 @@ export async function uploadBuildArtifactsAsync(
|
||||
// The saved artifact has the right filename, we don't need Launcher to rename or store it.
|
||||
return { filename: null };
|
||||
} catch (err: any) {
|
||||
// Lack of `gcsSignedUploadUrlForBuildArtifacts` means we're being run by workflow-orchestration.
|
||||
if (!config.gcsSignedUploadUrlForBuildArtifacts) {
|
||||
throw new Error(`Failed to upload build artifact: ${err?.message}\n${err?.stack}`, {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
logger.error({ err, filename, size }, 'Upload to upload session failed');
|
||||
|
||||
// Otherwise, we log the error and proceed to upload to Launcher's upload URL.
|
||||
const msg = 'Upload to upload session failed';
|
||||
logger.error({ err, filename, size }, msg);
|
||||
sentry.capture(msg, err, {
|
||||
extras: {
|
||||
throw new errors.SystemError('Failed to upload build artifacts.', {
|
||||
trackingCode: 'EAS_BUILD_UPLOAD_BUILD_ARTIFACTS_FAILED',
|
||||
metadata: {
|
||||
filename,
|
||||
size,
|
||||
...uploadSession,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await GCS.uploadWithSignedUrl({
|
||||
signedUrl: config.gcsSignedUploadUrlForBuildArtifacts,
|
||||
srcGeneratorAsync: async () => {
|
||||
return fs.createReadStream(localPath);
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
throw new Error(`Failed to upload build artifact: ${err?.message}\n${err?.stack}`, {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
|
||||
return { filename };
|
||||
}
|
||||
|
||||
export async function uploadWorkflowArtifactAsync(
|
||||
@@ -308,6 +270,9 @@ async function createUploadSessionAsync(
|
||||
Authorization: `Bearer ${robotAccessToken}`,
|
||||
},
|
||||
shouldThrowOnNotOk: false,
|
||||
retries: UPLOAD_API_REQUEST_RETRIES,
|
||||
retryIntervalMs: UPLOAD_API_REQUEST_RETRY_INTERVAL_MS,
|
||||
logger: ctx.logger,
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -322,6 +287,9 @@ async function createUploadSessionAsync(
|
||||
Authorization: `Bearer ${robotAccessToken}`,
|
||||
},
|
||||
shouldThrowOnNotOk: false,
|
||||
retries: UPLOAD_API_REQUEST_RETRIES,
|
||||
retryIntervalMs: UPLOAD_API_REQUEST_RETRY_INTERVAL_MS,
|
||||
logger: ctx.logger,
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -429,6 +397,9 @@ async function saveArtifactAsync(
|
||||
headers: {
|
||||
Authorization: `Bearer ${robotAccessToken}`,
|
||||
},
|
||||
retries: UPLOAD_API_REQUEST_RETRIES,
|
||||
retryIntervalMs: UPLOAD_API_REQUEST_RETRY_INTERVAL_MS,
|
||||
logger: ctx.logger,
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -442,6 +413,9 @@ async function saveArtifactAsync(
|
||||
headers: {
|
||||
Authorization: `Bearer ${robotAccessToken}`,
|
||||
},
|
||||
retries: UPLOAD_API_REQUEST_RETRIES,
|
||||
retryIntervalMs: UPLOAD_API_REQUEST_RETRY_INTERVAL_MS,
|
||||
logger: ctx.logger,
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -2856,7 +2856,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/build-tools@npm:20.1.0, @expo/build-tools@workspace:packages/build-tools":
|
||||
"@expo/build-tools@npm:20.2.0, @expo/build-tools@workspace:packages/build-tools":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/build-tools@workspace:packages/build-tools"
|
||||
dependencies:
|
||||
@@ -3558,7 +3558,7 @@ __metadata:
|
||||
resolution: "@expo/worker@workspace:packages/worker"
|
||||
dependencies:
|
||||
"@babel/core": "npm:7.24.7"
|
||||
"@expo/build-tools": "npm:20.1.0"
|
||||
"@expo/build-tools": "npm:20.2.0"
|
||||
"@expo/config-plugins": "npm:55.0.7"
|
||||
"@expo/config-types": "npm:55.0.5"
|
||||
"@expo/downloader": "npm:20.0.0"
|
||||
@@ -11100,7 +11100,7 @@ __metadata:
|
||||
version: 0.0.0-use.local
|
||||
resolution: "eas-cli-local-build-plugin@workspace:packages/local-build-plugin"
|
||||
dependencies:
|
||||
"@expo/build-tools": "npm:20.1.0"
|
||||
"@expo/build-tools": "npm:20.2.0"
|
||||
"@expo/eas-build-job": "npm:20.1.0"
|
||||
"@expo/logger": "npm:20.0.0"
|
||||
"@expo/spawn-async": "npm:^1.7.2"
|
||||
|
||||
Reference in new issue
Block a user