Compare commits

..
17 Commits
Author SHA1 Message Date
Expo CI df8ebc8f84 v18.0.6 2026-02-27 22:34:25 +00:00
Tomek Czajecki 62ab329f39 [eas-cli] Fix invisible credential prompts in eas go command (#3429) 2026-02-25 19:22:47 +02:00
Ash Wu 198b796804 [build-tools] Wrap parseMaestroResults in try-catch to prevent step failure (#3433)
Signed-off-by: Ash Wu <[email protected]>
2026-02-25 21:32:18 +08:00
Tomek Czajecki 110055d943 Add time estimates to go command spinners (#3428)
* [eas-cli] Add time estimates to go command spinners

* Fix formatting
2026-02-25 12:27:36 +02:00
Tomek Czajecki 06e2ba5bdb Update eas go fyi link (#3430) 2026-02-25 12:27:11 +02:00
Ash Wu 15690e7f6b [build-tools] Add eas/report_maestro_test_results build function (#3388)
Signed-off-by: Ash Wu <[email protected]>
2026-02-25 17:31:18 +08:00
Expo CI 134e74f50b update CHANGELOG.md 2026-02-25 00:42:47 +00:00
Expo CI 4b3dcda99d v18.0.5 2026-02-25 00:40:34 +00:00
Douglas Lowder 0c32aa3a39 [release] Work around the removal of yarn --silent (#3424)
# Why

When yarn moved to version 4, they removed the `--silent` flag. Because of this change, any use of yarn to call scripts and get output will also get yarn debugging garbage, and there is no way to suppress it.

The release scripts are mostly ok with this, except for calling the `next-version` script. The output of this script has to be clean for it to be used as input for `lerna version`.

# How

Replaced the yarn call with a direct invocation of the node Typescript source, bypassing yarn.

# Test Plan

Since the `trigger-release.yml` workflow supports a dry run mode, it's safe to modify it to check out a test branch and run the workflow many times to debug the issue.

Once this change is merged, a dry run invocation of the workflow should succeed even if no version string is passed in.
2026-02-24 03:37:51 -08:00
Tomek Czajecki 59ac8ae77a [eas-cli] Add go command (#3376) 2026-02-23 15:23:22 -08:00
Douglas Lowder 3e13df470e [eas-cli] Require --environment flag for eas update when SDK >= 55 (#3418)
# Why

Problems can occur if an update intended for a development build is published with the environment defaulting to or set to production, and vice versa.

To prevent such problems in the future, this PR makes the `--environment` flag a required flag for projects using SDK 55 and greater.

# How

Add a new utility function to throw an error if sdkVersion does not meet the requirement, and use that function in `eas update:configure` and `eas update`.

Skip the check if EAS_BUILD env is set.

# Test Plan

Unit tests added.

Testing locally with different projects.
2026-02-23 13:48:35 -08:00
Expo CI 83877f6285 update CHANGELOG.md 2026-02-23 20:47:53 +00:00
Expo CI 56018338e7 v18.0.4 2026-02-23 20:45:25 +00:00
Byron Karlen 0fdf2dd44a Revert "Use auth code flow (#3398)" (#3422)
This reverts commit 1604b7bc20.
2026-02-23 12:30:47 -08:00
Expo CI 0dcd6d465a update CHANGELOG.md 2026-02-20 08:02:18 +00:00
Expo CI d5da4f4167 v18.0.3 2026-02-20 07:59:25 +00:00
Stanisław Chmiela d0b9281bb2 [chore] Fix Yarn v4 prepack commands (#3417)
## Why
Release publish failed in `Publish to npm` for `v18.0.2`.

- `packages/eas-cli/scripts/prepack.js` invoked `yarn oclif manifest`, which fails on Yarn v4 (`Couldn't find a script named \"oclif\"`).
- `packages/create-eas-build-function` `clean` script used an unquoted `*.tsbuildinfo` glob, which fails when no files match.

## How
- Update `packages/eas-cli/scripts/prepack.js` to run `yarn run -T oclif manifest`.
- Quote the tsbuildinfo glob in `packages/create-eas-build-function/package.json`:
  - `rimraf ./build/ "*.tsbuildinfo"`

## Test plan
- `cd packages/eas-cli && mise exec [email protected].4 -- node ./scripts/prepack.js`
- `cd packages/create-eas-build-function && mise exec [email protected].4 -- yarn run clean`
- CI
2026-02-20 08:48:21 +01:00
37 changed files with 2340 additions and 217 deletions

No files matched your search

+18 -2
View File
@@ -8,12 +8,28 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- Use authorization code flow with PKCE for browser-based login. ([#3398](https://github.com/expo/eas-cli/pull/3398) by [@byronkarlen](https://github.com/byronkarlen))
### 🐛 Bug fixes
### 🧹 Chores
## [18.0.5](https://github.com/expo/eas-cli/releases/tag/v18.0.5) - 2026-02-25
### 🛠 Breaking changes
- Require --environment flag for update commands when SDK >= 55. ([#3398](https://github.com/expo/eas-cli/pull/3398) by [@douglowder](https://github.com/douglowder))
## [18.0.4](https://github.com/expo/eas-cli/releases/tag/v18.0.4) - 2026-02-23
### 🐛 Bug fixes
- Revert authorization code flow with PKCE for browser-based login. ([#3422](https://github.com/expo/eas-cli/pull/3422) by [@byronkarlen](https://github.com/byronkarlen))
## [18.0.3](https://github.com/expo/eas-cli/releases/tag/v18.0.3) - 2026-02-20
### 🎉 New features
- Use authorization code flow with PKCE for browser-based login. ([#3398](https://github.com/expo/eas-cli/pull/3398) by [@byronkarlen](https://github.com/byronkarlen))
## [18.0.1](https://github.com/expo/eas-cli/releases/tag/v18.0.1) - 2026-02-12
### 🛠 Breaking changes
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "18.0.2",
"version": "18.0.6",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "18.0.2",
"version": "18.0.6",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -53,6 +53,7 @@
"@urql/core": "^6.0.1",
"bplist-parser": "0.3.2",
"fast-glob": "^3.3.2",
"fast-xml-parser": "^4.4.1",
"fs-extra": "^11.2.0",
"gql.tada": "^1.8.13",
"joi": "^17.13.1",
@@ -10,6 +10,7 @@ import {
} from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { BuildRuntimePlatform, ExternalBuildContextProvider } from '@expo/steps';
import { Client } from '@urql/core';
import assert from 'assert';
import path from 'path';
@@ -53,6 +54,7 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
public readonly startTime: Date;
public readonly logger: bunyan;
public readonly graphqlClient: Client;
public readonly runtimeApi: BuilderRuntimeApi;
public job: TJob;
public metadata?: Metadata;
@@ -65,6 +67,7 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
this.metadata = buildCtx.metadata;
this.logger = buildCtx.logger.child({ phase: BuildPhase.CUSTOM });
this.graphqlClient = buildCtx.graphqlClient;
this.projectSourceDirectory = path.join(buildCtx.workingdir, 'temporary-custom-build');
this.projectTargetDirectory = path.join(buildCtx.workingdir, 'build');
this.defaultWorkingDirectory = buildCtx.getReactNativeProjectDirectory();
@@ -21,6 +21,7 @@ import { createInternalEasMaestroTestFunction } from './functions/internalMaestr
import { createPrebuildBuildFunction } from './functions/prebuild';
import { createReadIpaInfoBuildFunction } from './functions/readIpaInfo';
import { createRepackBuildFunction } from './functions/repack';
import { createReportMaestroTestResultsFunction } from './functions/reportMaestroTestResults';
import { resolveAppleTeamIdFromCredentialsFunction } from './functions/resolveAppleTeamIdFromCredentials';
import { createResolveBuildConfigBuildFunction } from './functions/resolveBuildConfig';
import {
@@ -81,6 +82,8 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createUploadToAscBuildFunction(),
createInternalEasMaestroTestFunction(ctx),
createReportMaestroTestResultsFunction(ctx),
];
if (ctx.hasBuildJob()) {
@@ -0,0 +1,98 @@
import { spawnAsync } from '@expo/steps';
import { vol } from 'memfs';
import os from 'os';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
import { findMaestroPathsFlowsToExecuteAsync } from '../../../utils/findMaestroPathsFlowsToExecuteAsync';
import { createInternalEasMaestroTestFunction } from '../internalMaestroTest';
jest.mock('@expo/steps', () => ({
...jest.requireActual('@expo/steps'),
spawnAsync: jest.fn(),
}));
jest.mock('../../../utils/IosSimulatorUtils');
jest.mock('../../../utils/AndroidEmulatorUtils');
jest.mock('../../../utils/findMaestroPathsFlowsToExecuteAsync');
const mockedSpawnAsync = jest.mocked(spawnAsync);
const mockedIosUtils = jest.mocked(IosSimulatorUtils);
const mockedFindFlows = jest.mocked(findMaestroPathsFlowsToExecuteAsync);
describe(createInternalEasMaestroTestFunction, () => {
const mockUploadArtifact = jest.fn();
beforeEach(() => {
vol.mkdirSync(os.tmpdir(), { recursive: true });
mockedSpawnAsync.mockResolvedValue(undefined as any);
mockUploadArtifact.mockResolvedValue({ artifactId: null });
mockedIosUtils.getAvailableDevicesAsync.mockResolvedValue([
{ name: 'iPhone 15', udid: 'test-udid-123' } as any,
]);
mockedIosUtils.cloneAsync.mockResolvedValue(undefined as any);
mockedIosUtils.startAsync.mockResolvedValue({ udid: 'cloned-udid' } as any);
mockedIosUtils.waitForReadyAsync.mockResolvedValue(undefined as any);
mockedIosUtils.collectLogsAsync.mockResolvedValue({ outputPath: '/tmp/logs.txt' } as any);
mockedIosUtils.deleteAsync.mockResolvedValue(undefined as any);
mockedFindFlows.mockResolvedValue(['/project/.maestro/home.yml']);
});
function createStep(overrides?: { callInputs?: Record<string, unknown> }) {
const ctx = {
runtimeApi: { uploadArtifact: mockUploadArtifact },
};
const fn = createInternalEasMaestroTestFunction(ctx as any);
return fn.createBuildStepFromFunctionCall(
createGlobalContextMock({
logger: createMockLogger(),
}),
{
callInputs: {
platform: 'ios',
flow_paths: JSON.stringify(['.maestro']),
...overrides?.callInputs,
},
}
);
}
it('sets junit_report_directory output when output_format is junit', async () => {
const step = createStep({
callInputs: { output_format: 'junit' },
});
await step.executeAsync();
const junitDir = step.getOutputValueByName('junit_report_directory');
expect(junitDir).toMatch(/maestro-reports-/);
});
it('does not set junit_report_directory output when output_format is not junit', async () => {
const step = createStep();
await step.executeAsync();
const junitDir = step.getOutputValueByName('junit_report_directory');
expect(junitDir).toBeUndefined();
});
it('does not set junit_report_directory when function throws before reaching output code', async () => {
mockedIosUtils.getAvailableDevicesAsync.mockResolvedValue([]);
const step = createStep({
callInputs: { output_format: 'junit' },
});
try {
await step.executeAsync();
} catch {
// Expected - no booted device found
}
const junitDir = step.getOutputValueByName('junit_report_directory');
expect(junitDir).toBeUndefined();
});
});
@@ -0,0 +1,594 @@
import { vol } from 'memfs';
import {
parseFlowMetadata,
parseJUnitTestCases,
parseMaestroResults,
} from '../maestroResultParser';
describe(parseFlowMetadata, () => {
it('parses valid ai-*.json', async () => {
vol.fromJSON({
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/Users/expo/workingdir/build/.maestro/home.yml',
}),
});
const result = await parseFlowMetadata('/tests/2026-01-28_055409/ai-home.json');
expect(result).toEqual({
flow_name: 'home',
flow_file_path: '/Users/expo/workingdir/build/.maestro/home.yml',
});
});
it('returns null when flow_name is missing', async () => {
vol.fromJSON({
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_file_path: '/Users/expo/workingdir/build/.maestro/home.yml',
}),
});
const result = await parseFlowMetadata('/tests/2026-01-28_055409/ai-home.json');
expect(result).toBeNull();
});
it('returns null when flow_file_path is missing', async () => {
vol.fromJSON({
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
}),
});
const result = await parseFlowMetadata('/tests/2026-01-28_055409/ai-home.json');
expect(result).toBeNull();
});
it('returns null for invalid JSON', async () => {
vol.fromJSON({
'/tests/2026-01-28_055409/ai-home.json': 'not json',
});
const result = await parseFlowMetadata('/tests/2026-01-28_055409/ai-home.json');
expect(result).toBeNull();
});
});
describe(parseMaestroResults, () => {
it('parses JUnit results and enriches with ai-*.json metadata', async () => {
vol.fromJSON({
// JUnit XML (primary data)
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="2" failures="1">',
' <testcase id="home" name="home" classname="home" time="10.5" status="SUCCESS"/>',
' <testcase id="login" name="login" classname="login" time="5.0" status="ERROR">',
' <failure>Tap failed</failure>',
' </testcase>',
' </testsuite>',
'</testsuites>',
].join('\n'),
// Debug output (for flow_file_path)
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
'/tests/2026-01-28_055409/ai-login.json': JSON.stringify({
flow_name: 'login',
flow_file_path: '/root/project/.maestro/login.yml',
}),
});
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
expect(results).toHaveLength(2);
expect(results).toEqual(
expect.arrayContaining([
{
name: 'home',
path: '.maestro/home.yml',
status: 'passed',
errorMessage: null,
duration: 10500,
retryCount: 0,
tags: [],
properties: {},
},
{
name: 'login',
path: '.maestro/login.yml',
status: 'failed',
errorMessage: 'Tap failed',
duration: 5000,
retryCount: 0,
tags: [],
properties: {},
},
])
);
});
it('calculates retryCount from timestamp directory occurrences', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
// Two timestamp dirs = 1 retry
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
'/tests/2026-01-28_055420/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
});
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
expect(results[0].retryCount).toBe(1);
});
it('uses flow name as fallback path when ai-*.json not found', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
// No ai-*.json files
});
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
expect(results[0].path).toBe('home');
expect(results[0].retryCount).toBe(0);
});
it('returns empty array when no JUnit files found', async () => {
vol.fromJSON({
'/junit/.gitkeep': '',
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
});
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
expect(results).toEqual([]);
});
it('extracts tags from JUnit properties and separates from other properties', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS">',
' <properties>',
' <property name="env" value="staging"/>',
' <property name="tags" value="e2e, smoke"/>',
' </properties>',
' </testcase>',
' </testsuite>',
'</testsuites>',
].join('\n'),
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
});
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
expect(results[0].tags).toEqual(['e2e', 'smoke']);
expect(results[0].properties).toEqual({ env: 'staging' });
});
it('handles reuse_devices=true (junit_report_directory == tests_directory)', async () => {
vol.fromJSON({
// Same directory for both JUnit and debug output
'/maestro-tests/android-maestro-junit.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
'/maestro-tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
});
const results = await parseMaestroResults('/maestro-tests', '/maestro-tests', '/root/project');
expect(results).toEqual([
expect.objectContaining({
name: 'home',
path: '.maestro/home.yml',
status: 'passed',
}),
]);
});
it('handles reuse_devices=false (separate junit_report_directory)', async () => {
vol.fromJSON({
// JUnit in temp dir (per-flow files)
'/tmp/maestro-reports-abc123/junit-report-flow-1.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
'/tmp/maestro-reports-abc123/junit-report-flow-2.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="1">',
' <testcase id="login" name="login" classname="login" time="5.0" status="ERROR">',
' <failure>Failed</failure>',
' </testcase>',
' </testsuite>',
'</testsuites>',
].join('\n'),
// Debug output in default location
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
'/tests/2026-01-28_055409/ai-login.json': JSON.stringify({
flow_name: 'login',
flow_file_path: '/root/project/.maestro/login.yml',
}),
});
const results = await parseMaestroResults(
'/tmp/maestro-reports-abc123',
'/tests',
'/root/project'
);
expect(results).toHaveLength(2);
expect(results).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: 'home', path: '.maestro/home.yml', status: 'passed' }),
expect.objectContaining({ name: 'login', path: '.maestro/login.yml', status: 'failed' }),
])
);
});
it('uses raw path when flow_file_path is outside project_root', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/somewhere/else/.maestro/home.yml',
}),
});
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
expect(results[0].path).toBe('/somewhere/else/.maestro/home.yml');
});
it('filters out non-timestamp directories when scanning debug output', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
'/tests/not-a-timestamp/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
});
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
// Only 1 occurrence (not-a-timestamp dir should be ignored)
expect(results[0].retryCount).toBe(0);
});
});
describe(parseJUnitTestCases, () => {
it('parses a single testcase with SUCCESS status', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" device="emulator-5554" tests="1" failures="0" time="10.5">',
' <testcase id="home" name="home" classname="home" time="10.5" status="SUCCESS">',
' </testcase>',
' </testsuite>',
'</testsuites>',
].join('\n'),
});
const results = await parseJUnitTestCases('/junit');
expect(results).toEqual([
{
name: 'home',
status: 'passed',
duration: 10500,
errorMessage: null,
tags: [],
properties: {},
},
]);
});
it('parses a failed testcase with failure message', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="1">',
' <testcase id="login" name="login" classname="login" time="5.0" status="ERROR">',
' <failure>Element not visible</failure>',
' </testcase>',
' </testsuite>',
'</testsuites>',
].join('\n'),
});
const results = await parseJUnitTestCases('/junit');
expect(results).toEqual([
{
name: 'login',
status: 'failed',
duration: 5000,
errorMessage: 'Element not visible',
tags: [],
properties: {},
},
]);
});
it('extracts properties from testcase', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS">',
' <properties>',
' <property name="env" value="staging"/>',
' <property name="priority" value="high"/>',
' </properties>',
' </testcase>',
' </testsuite>',
'</testsuites>',
].join('\n'),
});
const results = await parseJUnitTestCases('/junit');
expect(results[0].properties).toEqual({ env: 'staging', priority: 'high' });
});
it('extracts tags from "tags" property as comma-separated values', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS">',
' <properties>',
' <property name="tags" value="smoke, critical, auth"/>',
' <property name="priority" value="high"/>',
' </properties>',
' </testcase>',
' </testsuite>',
'</testsuites>',
].join('\n'),
});
const results = await parseJUnitTestCases('/junit');
expect(results[0].tags).toEqual(['smoke', 'critical', 'auth']);
expect(results[0].properties).toEqual({ priority: 'high' });
});
it('returns empty tags when no "tags" property exists (older Maestro)', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS">',
' <properties>',
' <property name="priority" value="high"/>',
' </properties>',
' </testcase>',
' </testsuite>',
'</testsuites>',
].join('\n'),
});
const results = await parseJUnitTestCases('/junit');
expect(results[0].tags).toEqual([]);
expect(results[0].properties).toEqual({ priority: 'high' });
});
it('parses multiple testcases across multiple testsuites (shards)', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" device="emulator-5554" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
' </testsuite>',
' <testsuite name="Test Suite" device="emulator-5556" tests="1" failures="1">',
' <testcase id="login" name="login" classname="login" time="5.0" status="ERROR">',
' <failure>Tap failed</failure>',
' </testcase>',
' </testsuite>',
'</testsuites>',
].join('\n'),
});
const results = await parseJUnitTestCases('/junit');
expect(results).toHaveLength(2);
expect(results).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: 'home', status: 'passed' }),
expect.objectContaining({ name: 'login', status: 'failed', errorMessage: 'Tap failed' }),
])
);
});
it('parses multiple JUnit XML files in the same directory', async () => {
vol.fromJSON({
'/junit/junit-report-flow-1.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
'/junit/junit-report-flow-2.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="1">',
' <testcase id="login" name="login" classname="login" time="5.0" status="ERROR">',
' <failure>Tap failed</failure>',
' </testcase>',
' </testsuite>',
'</testsuites>',
].join('\n'),
});
const results = await parseJUnitTestCases('/junit');
expect(results).toHaveLength(2);
});
it('handles missing time attribute (defaults to 0)', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
});
const results = await parseJUnitTestCases('/junit');
expect(results[0].duration).toBe(0);
});
it('handles invalid time attribute (defaults to 0)', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="not_a_number" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
});
const results = await parseJUnitTestCases('/junit');
expect(results[0].duration).toBe(0);
});
it('uses @_status attribute for pass/fail (not <failure> presence)', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="1">',
' <testcase id="home" name="home" classname="home" time="5.0" status="ERROR"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
});
const results = await parseJUnitTestCases('/junit');
expect(results[0].status).toBe('failed');
expect(results[0].errorMessage).toBeNull();
});
it('extracts error message from <error> element', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="1">',
' <testcase id="home" name="home" classname="home" time="5.0" status="ERROR">',
' <error>Runtime exception occurred</error>',
' </testcase>',
' </testsuite>',
'</testsuites>',
].join('\n'),
});
const results = await parseJUnitTestCases('/junit');
expect(results[0].status).toBe('failed');
expect(results[0].errorMessage).toBe('Runtime exception occurred');
});
it('returns empty array when directory does not exist', async () => {
const results = await parseJUnitTestCases('/nonexistent');
expect(results).toEqual([]);
});
it('returns empty array when no XML files found', async () => {
vol.fromJSON({ '/junit/.gitkeep': '' });
const results = await parseJUnitTestCases('/junit');
expect(results).toEqual([]);
});
it('skips invalid XML files gracefully', async () => {
vol.fromJSON({
'/junit/bad.xml': 'not xml at all',
'/junit/good.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
});
const results = await parseJUnitTestCases('/junit');
expect(results).toEqual([expect.objectContaining({ name: 'home', status: 'passed' })]);
});
it('handles testcase with no properties element', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
});
const results = await parseJUnitTestCases('/junit');
expect(results[0].properties).toEqual({});
});
});
@@ -0,0 +1,273 @@
import { Client } from '@urql/core';
import { vol } from 'memfs';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { createReportMaestroTestResultsFunction } from '../reportMaestroTestResults';
const JUNIT_PASS = [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n');
const JUNIT_FAIL = [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="1">',
' <testcase id="home" name="home" classname="home" time="5.0" status="ERROR">',
' <failure>Tap failed</failure>',
' </testcase>',
' </testsuite>',
'</testsuites>',
].join('\n');
const FLOW_AI = JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
});
describe(createReportMaestroTestResultsFunction, () => {
let mockMutationFn: jest.Mock;
let mockGraphqlClient: Client;
beforeEach(() => {
mockMutationFn = jest.fn();
mockGraphqlClient = {
mutation: jest.fn().mockReturnValue({
toPromise: mockMutationFn,
}),
} as unknown as Client;
});
function createStep(overrides?: {
callInputs?: Record<string, unknown>;
staticContextContent?: Record<string, unknown>;
env?: Record<string, string>;
}) {
const ctx = { graphqlClient: mockGraphqlClient };
const fn = createReportMaestroTestResultsFunction(ctx as any);
const globalCtx = createGlobalContextMock({
logger: createMockLogger(),
projectTargetDirectory: '/root/project',
staticContextContent: {
expoApiServerURL: 'https://api.expo.test',
job: { secrets: { robotAccessToken: 'test-token' } },
...overrides?.staticContextContent,
},
});
globalCtx.updateEnv(overrides?.env ?? { __WORKFLOW_JOB_ID: 'job-uuid-123' });
return fn.createBuildStepFromFunctionCall(globalCtx, {
callInputs: {
junit_report_directory: '/junit',
tests_directory: '/tests',
...overrides?.callInputs,
},
});
}
it('parses JUnit results and calls GraphQL mutation', async () => {
vol.fromJSON({
'/junit/report.xml': JUNIT_PASS,
'/tests/2026-01-28_055409/ai-home.json': FLOW_AI,
});
mockMutationFn.mockResolvedValue({
data: {
workflowDeviceTestCaseResult: {
createWorkflowDeviceTestCaseResults: [{ id: 'id-1' }],
},
},
});
await createStep().executeAsync();
expect(mockGraphqlClient.mutation).toHaveBeenCalledTimes(1);
const [, variables] = (mockGraphqlClient.mutation as jest.Mock).mock.calls[0];
expect(variables.input.workflowJobId).toBe('job-uuid-123');
expect(variables.input.testCaseResults).toEqual([
expect.objectContaining({
name: 'home',
path: '.maestro/home.yml',
status: 'PASSED',
errorMessage: null,
duration: 10000,
retryCount: 0,
properties: {},
}),
]);
});
it('sends tags and properties from JUnit XML', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS">',
' <properties>',
' <property name="testCaseId" value="TC-001"/>',
' <property name="priority" value="high"/>',
' <property name="tags" value="e2e"/>',
' </properties>',
' </testcase>',
' </testsuite>',
'</testsuites>',
].join('\n'),
'/tests/2026-01-28_055409/ai-home.json': FLOW_AI,
});
mockMutationFn.mockResolvedValue({
data: {
workflowDeviceTestCaseResult: {
createWorkflowDeviceTestCaseResults: [{ id: 'id-1' }],
},
},
});
await createStep().executeAsync();
const [, variables] = (mockGraphqlClient.mutation as jest.Mock).mock.calls[0];
expect(variables.input.testCaseResults[0].tags).toEqual(['e2e']);
expect(variables.input.testCaseResults[0].properties).toEqual({
testCaseId: 'TC-001',
priority: 'high',
});
});
it('reports failed flow with correct status and errorMessage', async () => {
vol.fromJSON({
'/junit/report.xml': JUNIT_FAIL,
'/tests/2026-01-28_055409/ai-home.json': FLOW_AI,
});
mockMutationFn.mockResolvedValue({
data: {
workflowDeviceTestCaseResult: {
createWorkflowDeviceTestCaseResults: [{ id: 'id-1' }],
},
},
});
await createStep().executeAsync();
const [, variables] = (mockGraphqlClient.mutation as jest.Mock).mock.calls[0];
expect(variables.input.testCaseResults[0]).toEqual(
expect.objectContaining({
status: 'FAILED',
errorMessage: 'Tap failed',
duration: 5000,
})
);
});
// robotAccessToken guard removed — Generic.JobZ requires robotAccessToken (z.string(),
// not optional), so it's always present in workflow jobs. graphqlClient is already
// initialized with the token in BuildContext constructor.
it('skips report when junit_report_directory is empty string (upstream step failed)', async () => {
const step = createStep({
callInputs: { junit_report_directory: '' },
});
await step.executeAsync();
expect(mockGraphqlClient.mutation).not.toHaveBeenCalled();
});
it('skips report when __WORKFLOW_JOB_ID env is not set', async () => {
vol.fromJSON({
'/junit/report.xml': JUNIT_PASS,
'/tests/2026-01-28_055409/ai-home.json': FLOW_AI,
});
const step = createStep({ env: {} });
await step.executeAsync();
expect(mockGraphqlClient.mutation).not.toHaveBeenCalled();
});
it('skips report when no JUnit files found', async () => {
const step = createStep();
await step.executeAsync();
expect(mockGraphqlClient.mutation).not.toHaveBeenCalled();
});
it('does not throw when GraphQL returns error', async () => {
vol.fromJSON({
'/junit/report.xml': JUNIT_PASS,
'/tests/2026-01-28_055409/ai-home.json': FLOW_AI,
});
mockMutationFn.mockResolvedValue({
error: { message: 'Something went wrong' },
});
await createStep().executeAsync();
});
it('does not throw when mutation rejects', async () => {
vol.fromJSON({
'/junit/report.xml': JUNIT_PASS,
'/tests/2026-01-28_055409/ai-home.json': FLOW_AI,
});
mockMutationFn.mockRejectedValue(new Error('Network error'));
await createStep().executeAsync();
});
it('skips report when duplicate testcase names found', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="2" failures="0">',
' <testcase id="login" name="login" classname="login" time="10.0" status="SUCCESS"/>',
' <testcase id="login" name="login" classname="login" time="5.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
'/tests/2026-01-28_055409/ai-login.json': JSON.stringify({
flow_name: 'login',
flow_file_path: '/root/project/.maestro/login.yml',
}),
});
await createStep().executeAsync();
expect(mockGraphqlClient.mutation).not.toHaveBeenCalled();
});
it('uses default directories when inputs are not provided', async () => {
vol.fromJSON({
'/home/expo/.maestro/tests/report.xml': JUNIT_PASS,
'/home/expo/.maestro/tests/2026-01-28_055409/ai-home.json': FLOW_AI,
});
const ctx = { graphqlClient: mockGraphqlClient };
const fn = createReportMaestroTestResultsFunction(ctx as any);
const globalCtx = createGlobalContextMock({
logger: createMockLogger(),
projectTargetDirectory: '/root/project',
staticContextContent: {
expoApiServerURL: 'https://api.expo.test',
job: { secrets: { robotAccessToken: 'test-token' } },
},
});
globalCtx.updateEnv({ __WORKFLOW_JOB_ID: 'job-uuid-123', HOME: '/home/expo' });
mockMutationFn.mockResolvedValue({
data: {
workflowDeviceTestCaseResult: {
createWorkflowDeviceTestCaseResults: [{ id: 'id-1' }],
},
},
});
const step = fn.createBuildStepFromFunctionCall(globalCtx, {
callInputs: {},
});
await step.executeAsync();
expect(mockGraphqlClient.mutation).toHaveBeenCalledTimes(1);
});
});
@@ -81,6 +81,10 @@ export function createInternalEasMaestroTestFunction(ctx: CustomBuildContext): B
id: 'test_reports_artifact_id',
required: false,
}),
BuildStepOutput.createProvider({
id: 'junit_report_directory',
required: false,
}),
],
fn: async (stepCtx, { inputs: _inputs, env, outputs }) => {
// inputs come in form of { value: unknown }. Here we parse them into a typed and validated object.
@@ -348,6 +352,10 @@ export function createInternalEasMaestroTestFunction(ctx: CustomBuildContext): B
}
}
if (output_format === 'junit') {
outputs.junit_report_directory.set(maestroReportsDir);
}
const generatedDeviceLogs = await fs.promises.readdir(deviceLogsDir);
if (generatedDeviceLogs.length === 0) {
stepCtx.logger.warn('No device logs were successfully collected.');
@@ -0,0 +1,264 @@
import { XMLParser } from 'fast-xml-parser';
import fs from 'fs/promises';
import path from 'path';
import { z } from 'zod';
export interface MaestroFlowResult {
name: string;
path: string;
status: 'passed' | 'failed';
errorMessage: string | null;
duration: number; // milliseconds
retryCount: number;
tags: string[];
properties: Record<string, string>;
}
// Maestro's TestDebugReporter creates timestamped directories, e.g. "2024-06-15_143022"
const TIMESTAMP_DIR_PATTERN = /^\d{4}-\d{2}-\d{2}_\d{6}$/;
export function extractFlowKey(filename: string, prefix: string): string | null {
const match = filename.match(new RegExp(`^${prefix}-(.+)\\.json$`));
return match?.[1] ?? null;
}
export interface JUnitTestCaseResult {
name: string;
status: 'passed' | 'failed';
duration: number; // milliseconds
errorMessage: string | null;
tags: string[];
properties: Record<string, string>;
}
const xmlParser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
// Ensure single-element arrays are always arrays
isArray: name => ['testsuite', 'testcase', 'property'].includes(name),
});
export async function parseJUnitTestCases(junitDirectory: string): Promise<JUnitTestCaseResult[]> {
let entries: string[];
try {
entries = await fs.readdir(junitDirectory);
} catch {
return [];
}
const xmlFiles = entries.filter(f => f.endsWith('.xml'));
if (xmlFiles.length === 0) {
return [];
}
const results: JUnitTestCaseResult[] = [];
for (const xmlFile of xmlFiles) {
try {
const content = await fs.readFile(path.join(junitDirectory, xmlFile), 'utf-8');
const parsed = xmlParser.parse(content);
const testsuites = parsed?.testsuites?.testsuite;
if (!Array.isArray(testsuites)) {
continue;
}
for (const suite of testsuites) {
const testcases = suite?.testcase;
if (!Array.isArray(testcases)) {
continue;
}
for (const tc of testcases) {
const name = tc['@_name'];
if (!name) {
continue;
}
const timeStr = tc['@_time'];
const timeSeconds = timeStr ? parseFloat(timeStr) : 0;
const duration = Number.isFinite(timeSeconds) ? Math.round(timeSeconds * 1000) : 0;
// Use @_status as primary indicator (more robust than checking <failure> presence)
const status: 'passed' | 'failed' = tc['@_status'] === 'SUCCESS' ? 'passed' : 'failed';
// Extract error message from <failure> or <error> elements
const failureText =
tc.failure != null
? typeof tc.failure === 'string'
? tc.failure
: (tc.failure?.['#text'] ?? null)
: null;
const errorText =
tc.error != null
? typeof tc.error === 'string'
? tc.error
: (tc.error?.['#text'] ?? null)
: null;
const errorMessage: string | null = failureText ?? errorText ?? null;
// Extract properties
const rawProperties: { '@_name': string; '@_value': string }[] =
tc.properties?.property ?? [];
const properties: Record<string, string> = {};
for (const prop of rawProperties) {
const propName = prop['@_name'];
const value = prop['@_value'];
if (typeof propName !== 'string' || typeof value !== 'string') {
continue;
}
properties[propName] = value;
}
// Extract tags from "tags" property (Maestro 2.2.0+, comma-separated)
const tagsValue = properties['tags'];
const tags: string[] = tagsValue
? tagsValue
.split(',')
.map(t => t.trim())
.filter(Boolean)
: [];
delete properties['tags'];
results.push({ name, status, duration, errorMessage, tags, properties });
}
}
} catch {
// Skip malformed XML files
continue;
}
}
return results;
}
const FlowMetadataFileSchema = z.object({
flow_name: z.string(),
flow_file_path: z.string(),
});
type FlowMetadata = z.output<typeof FlowMetadataFileSchema>;
/**
* Parses an `ai-*.json` file produced by Maestro's TestDebugReporter.
*
* The file contains:
* - `flow_name`: derived from the YAML `config.name` field if present, otherwise
* the flow filename without extension.
* See: https://github.com/mobile-dev-inc/Maestro/blob/c0e95fd/maestro-cli/src/main/java/maestro/cli/runner/TestRunner.kt#L70
* - `flow_file_path`: absolute path to the original flow YAML file.
* - `outputs`: screenshot defect data (unused here).
*
* Filename format: `ai-(flowName).json` where `/` in flowName is replaced with `_`.
* See: https://github.com/mobile-dev-inc/Maestro/blob/c0e95fd/maestro-cli/src/main/java/maestro/cli/report/TestDebugReporter.kt#L67
*/
export async function parseFlowMetadata(filePath: string): Promise<FlowMetadata | null> {
try {
const content = await fs.readFile(filePath, 'utf-8');
const data = JSON.parse(content);
return FlowMetadataFileSchema.parse(data);
} catch {
return null;
}
}
export async function parseMaestroResults(
junitDirectory: string,
testsDirectory: string,
projectRoot: string
): Promise<MaestroFlowResult[]> {
// 1. Parse JUnit XML files (primary source)
const junitResults = await parseJUnitTestCases(junitDirectory);
if (junitResults.length === 0) {
return [];
}
// 2. Parse ai-*.json from debug output for flow_file_path + retryCount
const flowPathMap = new Map<string, string>(); // flowName → flowFilePath
const flowOccurrences = new Map<string, number>(); // flowName → count
let entries: string[];
try {
entries = await fs.readdir(testsDirectory);
} catch {
entries = [];
}
const timestampDirs = entries.filter(name => TIMESTAMP_DIR_PATTERN.test(name)).sort();
for (const dir of timestampDirs) {
const dirPath = path.join(testsDirectory, dir);
let files: string[];
try {
files = await fs.readdir(dirPath);
} catch {
continue;
}
for (const file of files) {
const flowKey = extractFlowKey(file, 'ai');
if (!flowKey) {
continue;
}
const metadata = await parseFlowMetadata(path.join(dirPath, file));
if (!metadata) {
continue;
}
// Track latest path (last timestamp dir wins)
flowPathMap.set(metadata.flow_name, metadata.flow_file_path);
// Count occurrences for retryCount
flowOccurrences.set(metadata.flow_name, (flowOccurrences.get(metadata.flow_name) ?? 0) + 1);
}
}
// 3. Merge: JUnit results + ai-*.json metadata
const results: MaestroFlowResult[] = [];
for (const junit of junitResults) {
const flowFilePath = flowPathMap.get(junit.name);
const relativePath = flowFilePath
? await relativizePathAsync(flowFilePath, projectRoot)
: junit.name; // fallback: use flow name if ai-*.json not found
const occurrences = flowOccurrences.get(junit.name) ?? 0;
const retryCount = Math.max(0, occurrences - 1);
results.push({
name: junit.name,
path: relativePath,
status: junit.status,
errorMessage: junit.errorMessage,
duration: junit.duration,
retryCount,
tags: junit.tags,
properties: junit.properties,
});
}
return results;
}
async function relativizePathAsync(flowFilePath: string, projectRoot: string): Promise<string> {
if (!path.isAbsolute(flowFilePath)) {
return flowFilePath;
}
// Resolve symlinks (e.g., /tmp -> /private/tmp on macOS) for consistent comparison
let resolvedRoot = projectRoot;
let resolvedFlow = flowFilePath;
try {
resolvedRoot = await fs.realpath(projectRoot);
} catch {}
try {
resolvedFlow = await fs.realpath(flowFilePath);
} catch {}
const relative = path.relative(resolvedRoot, resolvedFlow);
if (relative.startsWith('..')) {
return flowFilePath;
}
return relative;
}
@@ -0,0 +1,120 @@
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
import { graphql } from 'gql.tada';
import { MaestroFlowResult, parseMaestroResults } from './maestroResultParser';
import { CustomBuildContext } from '../../customBuildContext';
const CREATE_MUTATION = graphql(`
mutation CreateWorkflowDeviceTestCaseResults($input: CreateWorkflowDeviceTestCaseResultsInput!) {
workflowDeviceTestCaseResult {
createWorkflowDeviceTestCaseResults(input: $input) {
id
}
}
}
`);
const FLOW_STATUS_TO_TEST_CASE_RESULT_STATUS: Record<string, 'PASSED' | 'FAILED' | undefined> = {
passed: 'PASSED',
failed: 'FAILED',
} satisfies Record<MaestroFlowResult['status'], 'PASSED' | 'FAILED'>;
export function createReportMaestroTestResultsFunction(ctx: CustomBuildContext): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'report_maestro_test_results',
name: 'Report Maestro Test Results',
__metricsId: 'eas/report_maestro_test_results',
inputProviders: [
BuildStepInput.createProvider({
id: 'junit_report_directory',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
defaultValue: '${{ env.HOME }}/.maestro/tests',
}),
BuildStepInput.createProvider({
id: 'tests_directory',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
defaultValue: '${{ env.HOME }}/.maestro/tests',
}),
],
fn: async (stepsCtx, { inputs }) => {
const { logger } = stepsCtx;
const workflowJobId = stepsCtx.global.env.__WORKFLOW_JOB_ID;
if (!workflowJobId) {
logger.info('Not running in a workflow job, skipping test results report');
return;
}
const junitDirectory = (inputs.junit_report_directory.value as string | undefined) ?? '';
if (!junitDirectory) {
logger.info('No JUnit directory provided, skipping test results report');
return;
}
const testsDirectory = inputs.tests_directory.value as string;
try {
const flowResults = await parseMaestroResults(
junitDirectory,
testsDirectory,
stepsCtx.workingDirectory
);
if (flowResults.length === 0) {
logger.info('No maestro test results found, skipping report');
return;
}
// Maestro allows overriding flow names via config, so different flow files can share
// the same name. JUnit XML only contains names (not file paths), making it impossible
// to map duplicates back to their original flow files. Skip and let the user fix it.
const names = flowResults.map(r => r.name);
const duplicates = names.filter((n, i) => names.indexOf(n) !== i);
if (duplicates.length > 0) {
logger.error(
`Duplicate test case names found in JUnit output: ${[...new Set(duplicates)].join(
', '
)}. Skipping report. Ensure each Maestro flow has a unique name.`
);
return;
}
const testCaseResults = flowResults.flatMap(f => {
const status = FLOW_STATUS_TO_TEST_CASE_RESULT_STATUS[f.status];
if (!status) {
return [];
}
return [
{
name: f.name,
path: f.path,
status,
errorMessage: f.errorMessage,
duration: f.duration,
retryCount: f.retryCount,
tags: f.tags,
properties: f.properties,
},
];
});
const result = await ctx.graphqlClient
.mutation(CREATE_MUTATION, {
input: {
workflowJobId,
testCaseResults,
},
})
.toPromise();
if (result.error) {
logger.error({ error: result.error }, 'GraphQL error creating test case results');
return;
}
logger.info(`Reported ${testCaseResults.length} test case result(s).`);
} catch (error) {
logger.error({ err: error }, 'Failed to create test case results');
}
},
});
}
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "18.0.2",
"version": "18.0.3",
"description": "Create functions for use in EAS Build custom builds.",
"keywords": [
"expo",
@@ -32,7 +32,7 @@
"build:dev": "ncc build ./src/index.ts -o build/",
"build": "ncc build ./src/index.ts -o build/ --minify --no-cache --no-source-map-register",
"typecheck": "tsc",
"clean": "rimraf ./build/ *.tsbuildinfo"
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/steps": "18.0.2"
+92 -90
View File
@@ -178,7 +178,7 @@ ALIASES
$ eas login
```
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/account/login.ts)_
## `eas account:logout`
@@ -195,7 +195,7 @@ ALIASES
$ eas logout
```
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/account/logout.ts)_
## `eas account:usage [ACCOUNT_NAME]`
@@ -217,7 +217,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/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/account/usage.ts)_
## `eas account:view`
@@ -234,7 +234,7 @@ ALIASES
$ eas whoami
```
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/account/view.ts)_
## `eas analytics [STATUS]`
@@ -248,7 +248,7 @@ DESCRIPTION
display or change analytics settings
```
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v18.0.2/packages/eas-cli/src/commands/analytics.ts)_
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v18.0.6/packages/eas-cli/src/commands/analytics.ts)_
## `eas autocomplete [SHELL]`
@@ -300,7 +300,7 @@ DESCRIPTION
create a branch
```
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/branch/create.ts)_
## `eas branch:delete [NAME]`
@@ -321,7 +321,7 @@ DESCRIPTION
delete a branch
```
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/branch/delete.ts)_
## `eas branch:list`
@@ -341,7 +341,7 @@ DESCRIPTION
list all branches
```
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/branch/list.ts)_
## `eas branch:rename`
@@ -361,7 +361,7 @@ DESCRIPTION
rename a branch
```
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/branch/rename.ts)_
## `eas branch:view [NAME]`
@@ -384,7 +384,7 @@ DESCRIPTION
view a branch
```
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/branch/view.ts)_
## `eas build`
@@ -425,7 +425,7 @@ DESCRIPTION
start a build
```
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/build/index.ts)_
## `eas build:cancel [BUILD_ID]`
@@ -444,7 +444,7 @@ DESCRIPTION
cancel a build
```
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/build/cancel.ts)_
## `eas build:configure`
@@ -461,7 +461,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/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/build/configure.ts)_
## `eas build:delete [BUILD_ID]`
@@ -480,7 +480,7 @@ DESCRIPTION
delete a build
```
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/build/delete.ts)_
## `eas build:dev`
@@ -500,7 +500,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/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/build/dev.ts)_
## `eas build:download`
@@ -521,7 +521,7 @@ DESCRIPTION
download simulator/emulator builds for a given fingerprint hash
```
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/build/download.ts)_
## `eas build:inspect`
@@ -556,7 +556,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/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/build/inspect.ts)_
## `eas build:list`
@@ -608,7 +608,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/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/build/list.ts)_
## `eas build:resign`
@@ -637,7 +637,7 @@ DESCRIPTION
re-sign a build archive
```
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/build/resign.ts)_
## `eas build:run`
@@ -663,7 +663,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/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/build/run.ts)_
## `eas build:submit`
@@ -717,7 +717,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/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/build/version/get.ts)_
## `eas build:version:set`
@@ -736,7 +736,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/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/build/version/set.ts)_
## `eas build:version:sync`
@@ -755,7 +755,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/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/build/version/sync.ts)_
## `eas build:view [BUILD_ID]`
@@ -772,7 +772,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/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/build/view.ts)_
## `eas channel:create [NAME]`
@@ -793,7 +793,7 @@ DESCRIPTION
create a channel
```
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/channel/create.ts)_
## `eas channel:delete [NAME]`
@@ -814,7 +814,7 @@ DESCRIPTION
Delete a channel
```
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/channel/delete.ts)_
## `eas channel:edit [NAME]`
@@ -836,7 +836,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/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/channel/edit.ts)_
## `eas channel:list`
@@ -856,7 +856,7 @@ DESCRIPTION
list all channels
```
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/channel/list.ts)_
## `eas channel:pause [NAME]`
@@ -878,7 +878,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/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/channel/pause.ts)_
## `eas channel:resume [NAME]`
@@ -900,7 +900,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/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/channel/resume.ts)_
## `eas channel:rollout [CHANNEL]`
@@ -933,7 +933,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/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/channel/rollout.ts)_
## `eas channel:view [NAME]`
@@ -956,7 +956,7 @@ DESCRIPTION
view a channel
```
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/channel/view.ts)_
## `eas config`
@@ -977,7 +977,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/v18.0.2/packages/eas-cli/src/commands/config.ts)_
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v18.0.6/packages/eas-cli/src/commands/config.ts)_
## `eas credentials`
@@ -994,7 +994,7 @@ DESCRIPTION
manage credentials
```
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/credentials/index.ts)_
## `eas credentials:configure-build`
@@ -1012,7 +1012,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/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/credentials/configure-build.ts)_
## `eas deploy [options]`
@@ -1040,7 +1040,7 @@ ALIASES
$ eas worker:deploy
```
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/deploy/index.ts)_
## `eas deploy:alias`
@@ -1065,7 +1065,7 @@ ALIASES
$ eas deploy:promote
```
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/deploy/alias/index.ts)_
## `eas deploy:alias:delete [ALIAS_NAME]`
@@ -1086,7 +1086,7 @@ ALIASES
$ eas worker:alias:delete
```
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
## `eas deploy:delete [DEPLOYMENT_ID]`
@@ -1107,7 +1107,7 @@ ALIASES
$ eas worker:delete
```
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/deploy/delete.ts)_
## `eas deploy:promote`
@@ -1144,7 +1144,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/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/device/create.ts)_
## `eas device:delete`
@@ -1164,7 +1164,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/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/device/delete.ts)_
## `eas device:list`
@@ -1185,7 +1185,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/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/device/list.ts)_
## `eas device:rename`
@@ -1206,7 +1206,7 @@ DESCRIPTION
rename a registered device
```
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/device/rename.ts)_
## `eas device:view [UDID]`
@@ -1220,7 +1220,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/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/device/view.ts)_
## `eas diagnostics`
@@ -1234,7 +1234,7 @@ DESCRIPTION
display environment info
```
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v18.0.2/packages/eas-cli/src/commands/diagnostics.ts)_
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v18.0.6/packages/eas-cli/src/commands/diagnostics.ts)_
## `eas env:create [ENVIRONMENT]`
@@ -1264,7 +1264,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/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/env/create.ts)_
## `eas env:delete [ENVIRONMENT]`
@@ -1289,7 +1289,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/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/env/delete.ts)_
## `eas env:exec ENVIRONMENT BASH_COMMAND`
@@ -1311,7 +1311,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/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/env/exec.ts)_
## `eas env:get [ENVIRONMENT]`
@@ -1336,7 +1336,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/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/env/get.ts)_
## `eas env:list [ENVIRONMENT]`
@@ -1362,7 +1362,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/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/env/list.ts)_
## `eas env:pull [ENVIRONMENT]`
@@ -1384,7 +1384,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/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/env/pull.ts)_
## `eas env:push [ENVIRONMENT]`
@@ -1406,7 +1406,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/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/env/push.ts)_
## `eas env:update [ENVIRONMENT]`
@@ -1438,7 +1438,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/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/env/update.ts)_
## `eas fingerprint:compare [HASH1] [HASH2]`
@@ -1482,7 +1482,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/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/fingerprint/compare.ts)_
## `eas fingerprint:generate`
@@ -1512,7 +1512,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/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/fingerprint/generate.ts)_
## `eas help [COMMAND]`
@@ -1621,7 +1621,7 @@ DESCRIPTION
validate the local store configuration
```
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/metadata/lint.ts)_
## `eas metadata:pull`
@@ -1638,7 +1638,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/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/metadata/pull.ts)_
## `eas metadata:push`
@@ -1655,7 +1655,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/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/metadata/push.ts)_
## `eas new [PATH]`
@@ -1706,7 +1706,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/v18.0.2/packages/eas-cli/src/commands/open.ts)_
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v18.0.6/packages/eas-cli/src/commands/open.ts)_
## `eas project:info`
@@ -1720,7 +1720,7 @@ DESCRIPTION
information about the current project
```
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/project/info.ts)_
## `eas project:init`
@@ -1743,7 +1743,7 @@ ALIASES
$ eas init
```
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/project/init.ts)_
## `eas project:new [PATH]`
@@ -1766,7 +1766,7 @@ ALIASES
$ eas new
```
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/project/new.ts)_
## `eas project:onboarding [TARGET_PROJECT_DIRECTORY]`
@@ -1784,7 +1784,7 @@ ALIASES
$ eas onboarding
```
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/project/onboarding.ts)_
## `eas submit`
@@ -1819,7 +1819,7 @@ ALIASES
$ eas build:submit
```
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v18.0.2/packages/eas-cli/src/commands/submit.ts)_
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v18.0.6/packages/eas-cli/src/commands/submit.ts)_
## `eas update`
@@ -1841,7 +1841,8 @@ FLAGS
--emit-metadata Emit "eas-update-metadata.json" in the bundle folder with detailed information about
the generated updates
--environment=<value> Environment to use for the server-side defined EAS environment variables during
command execution, e.g. "production", "preview", "development"
command execution, e.g. "production", "preview", "development". Required for
projects using Expo SDK 55 or greater.
--input-dir=<value> [default: dist] Location of the bundle
--json Enable JSON output, non-JSON messages will be printed to stderr.
--non-interactive Run the command in non-interactive mode.
@@ -1859,7 +1860,7 @@ DESCRIPTION
publish an update group
```
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/update/index.ts)_
## `eas update:configure`
@@ -1872,14 +1873,15 @@ USAGE
FLAGS
-p, --platform=(android|ios|all) [default: all] Platform to configure
--environment=<value> Environment to use for the server-side defined EAS environment variables during
command execution, e.g. "production", "preview", "development"
command execution, e.g. "production", "preview", "development". Required for
projects using Expo SDK 55 or greater.
--non-interactive Run the command in non-interactive mode.
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/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/update/configure.ts)_
## `eas update:delete GROUPID`
@@ -1900,7 +1902,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/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/update/delete.ts)_
## `eas update:edit [GROUPID]`
@@ -1924,7 +1926,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/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/update/edit.ts)_
## `eas update:list`
@@ -1949,7 +1951,7 @@ DESCRIPTION
view the recent updates
```
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/update/list.ts)_
## `eas update:republish`
@@ -1985,7 +1987,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/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/update/republish.ts)_
## `eas update:revert-update-rollout`
@@ -2012,7 +2014,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/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
## `eas update:roll-back-to-embedded`
@@ -2040,7 +2042,7 @@ 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/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
## `eas update:rollback`
@@ -2061,7 +2063,7 @@ DESCRIPTION
instead execute "eas update:republish" or "eas update:roll-back-to-embedded".
```
_See code: [packages/eas-cli/src/commands/update/rollback.ts](https://github.com/expo/eas-cli/blob/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/update/rollback.ts)_
## `eas update:view GROUPID`
@@ -2081,7 +2083,7 @@ DESCRIPTION
update group details
```
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/update/view.ts)_
## `eas upload`
@@ -2102,7 +2104,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/v18.0.2/packages/eas-cli/src/commands/upload.ts)_
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v18.0.6/packages/eas-cli/src/commands/upload.ts)_
## `eas webhook:create`
@@ -2123,7 +2125,7 @@ DESCRIPTION
create a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/webhook/create.ts)_
## `eas webhook:delete [ID]`
@@ -2143,7 +2145,7 @@ DESCRIPTION
delete a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/webhook/delete.ts)_
## `eas webhook:list`
@@ -2161,7 +2163,7 @@ DESCRIPTION
list webhooks
```
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/webhook/list.ts)_
## `eas webhook:update`
@@ -2183,7 +2185,7 @@ DESCRIPTION
update a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/webhook/update.ts)_
## `eas webhook:view ID`
@@ -2200,7 +2202,7 @@ DESCRIPTION
view a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/webhook/view.ts)_
## `eas whoami`
@@ -2320,7 +2322,7 @@ DESCRIPTION
to cancel.
```
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/workflow/cancel.ts)_
## `eas workflow:create [NAME]`
@@ -2340,7 +2342,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/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/workflow/create.ts)_
## `eas workflow:logs [ID]`
@@ -2364,7 +2366,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/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/workflow/logs.ts)_
## `eas workflow:run [FILE]`
@@ -2404,7 +2406,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/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/workflow/run.ts)_
## `eas workflow:runs`
@@ -2429,7 +2431,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/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/workflow/runs.ts)_
## `eas workflow:status [WORKFLOW_RUN_ID]`
@@ -2457,7 +2459,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/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/workflow/status.ts)_
## `eas workflow:validate PATH`
@@ -2477,7 +2479,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/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/workflow/validate.ts)_
## `eas workflow:view [ID]`
@@ -2499,5 +2501,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/v18.0.2/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/v18.0.6/packages/eas-cli/src/commands/workflow/view.ts)_
<!-- commandsstop -->
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "18.0.2",
"version": "18.0.6",
"description": "EAS command line tool",
"keywords": [
"cli",
+1 -1
View File
@@ -2,6 +2,6 @@ const spawn = require('@expo/spawn-async');
(async () => {
if (!process.env.CLI_SIZE_CHECK) {
await spawn('yarn', ['oclif', 'manifest'], { stdio: 'inherit' });
await spawn('yarn', ['run', '-T', 'oclif', 'manifest'], { stdio: 'inherit' });
}
})();
+1 -1
View File
@@ -67,7 +67,7 @@ export const EasJsonOnlyFlag = {
export const EasUpdateEnvironmentFlag = {
environment: Flags.string({
description:
'Environment to use for the server-side defined EAS environment variables during command execution, e.g. "production", "preview", "development"',
'Environment to use for the server-side defined EAS environment variables during command execution, e.g. "production", "preview", "development". Required for projects using Expo SDK 55 or greater.',
required: false,
default: undefined,
}),
+656
View File
@@ -0,0 +1,656 @@
import { App, User, UserRole } from '@expo/apple-utils';
import spawnAsync from '@expo/spawn-async';
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import * as fs from 'fs-extra';
import * as os from 'os';
import * as path from 'path';
import { Analytics } from '../analytics/AnalyticsManager';
import EasCommand from '../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { saveProjectIdToAppConfigAsync } from '../commandUtils/context/contextUtils/getProjectIdAsync';
import { CredentialsContext } from '../credentials/context';
import { AppStoreApiKeyPurpose } from '../credentials/ios/actions/AscApiKeyUtils';
import { getAppFromContextAsync } from '../credentials/ios/actions/BuildCredentialsUtils';
import { SetUpAscApiKey } from '../credentials/ios/actions/SetUpAscApiKey';
import { SetUpBuildCredentials } from '../credentials/ios/actions/SetUpBuildCredentials';
import { SetUpPushKey } from '../credentials/ios/actions/SetUpPushKey';
import { ensureAppExistsAsync } from '../credentials/ios/appstore/ensureAppExists';
import { Target } from '../credentials/ios/types';
import {
WorkflowJobStatus,
WorkflowProjectSourceType,
WorkflowRunStatus,
} from '../graphql/generated';
import { AppMutation } from '../graphql/mutations/AppMutation';
import { WorkflowRunMutation } from '../graphql/mutations/WorkflowRunMutation';
import { AppQuery } from '../graphql/queries/AppQuery';
import { WorkflowRunQuery } from '../graphql/queries/WorkflowRunQuery';
import Log, { learnMore } from '../log';
import { confirmAsync } from '../prompts';
import { ora } from '../ora';
import { getPrivateExpoConfigAsync } from '../project/expoConfig';
import { findProjectIdByAccountNameAndSlugNullableAsync } from '../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
import { uploadAccountScopedFileAsync } from '../project/uploadAccountScopedFileAsync';
import { uploadAccountScopedProjectSourceAsync } from '../project/uploadAccountScopedProjectSourceAsync';
import { Actor, getActorDisplayName } from '../user/User';
import { sleepAsync } from '../utils/promise';
import { resolveVcsClient } from '../vcs';
import { Client as VcsClient } from '../vcs/vcs';
// Expo Go release info - update when releasing a new version
const EXPO_GO_SDK_VERSION = '55';
const EXPO_GO_APP_VERSION = '55.0.11';
const EXPO_GO_BUILD_NUMBER = '1017799';
const TESTFLIGHT_GROUP_NAME = 'Team (Expo)';
async function setupTestFlightAsync(ascApp: App): Promise<void> {
// Create or get TestFlight group
let group;
for (let attempt = 0; attempt < 10; attempt++) {
try {
const groups = await ascApp.getBetaGroupsAsync({
query: { includes: ['betaTesters'] },
});
group = groups.find(
g => g.attributes.isInternalGroup && g.attributes.name === TESTFLIGHT_GROUP_NAME
);
if (!group) {
group = await ascApp.createBetaGroupAsync({
name: TESTFLIGHT_GROUP_NAME,
isInternalGroup: true,
hasAccessToAllBuilds: true,
});
}
break;
} catch (error: any) {
// Apple returns this error when the app isn't ready yet
if (error?.data?.errors?.some((e: any) => e.code === 'ENTITY_ERROR.RELATIONSHIP.INVALID')) {
if (attempt < 9) {
await new Promise(resolve => setTimeout(resolve, 10000));
continue;
}
}
throw error;
}
}
if (!group) {
throw new Error('Failed to create TestFlight group');
}
const users = await User.getAsync(ascApp.context);
const admins = users.filter(u => u.attributes.roles?.includes(UserRole.ADMIN));
const existingEmails = new Set(
group.attributes.betaTesters?.map((t: any) => t.attributes.email?.toLowerCase()) ?? []
);
const newTesters = admins
.filter(u => u.attributes.email && !existingEmails.has(u.attributes.email.toLowerCase()))
.map(u => ({
email: u.attributes.email!,
firstName: u.attributes.firstName ?? '',
lastName: u.attributes.lastName ?? '',
}));
if (newTesters.length > 0) {
await group.createBulkBetaTesterAssignmentsAsync(newTesters);
}
}
/* eslint-disable no-console */
async function withSuppressedOutputAsync<T>(fn: () => Promise<T>): Promise<T> {
const originalStdoutWrite = process.stdout.write.bind(process.stdout);
const originalConsoleLog = console.log;
const originalConsoleError = console.error;
const originalConsoleWarn = console.warn;
let capturedOutput = '';
const capture = (chunk: any): boolean => {
if (typeof chunk === 'string') {
capturedOutput += chunk;
}
return true;
};
// Only suppress stdout, not stderr — ora writes spinner frames to stderr and
// patching it would freeze the spinner animation during suppressed async work.
process.stdout.write = capture as any;
console.log = () => {};
console.error = () => {};
console.warn = () => {};
try {
return await fn();
} catch (error) {
process.stdout.write = originalStdoutWrite;
console.log = originalConsoleLog;
console.error = originalConsoleError;
console.warn = originalConsoleWarn;
if (capturedOutput) {
originalConsoleLog(capturedOutput);
}
throw error;
} finally {
process.stdout.write = originalStdoutWrite;
console.log = originalConsoleLog;
console.error = originalConsoleError;
console.warn = originalConsoleWarn;
}
}
/* eslint-enable no-console */
export default class Go extends EasCommand {
static override description = 'Create a custom Expo Go and submit to TestFlight';
static override hidden = true;
static override flags = {
'bundle-id': Flags.string({
description: 'iOS bundle identifier (auto-generated if not provided)',
required: false,
}),
name: Flags.string({
description: 'App name',
default: 'My Expo Go',
}),
credentials: Flags.boolean({
description: 'Interactively select credentials (default: auto-select)',
default: false,
}),
};
static override contextDefinition = {
...this.ContextOptions.LoggedIn,
...this.ContextOptions.Analytics,
};
async runAsync(): Promise<void> {
Log.log(
chalk.bold(
`Creating your personal Expo Go and deploying to TestFlight. ${learnMore('https://expo.fyi/deploy-expo-go-testflight')}`
)
);
const { flags } = await this.parse(Go);
const spinner = ora('Logging in to Expo...').start();
const {
loggedIn: { actor, graphqlClient },
analytics,
} = await this.getContextAsync(Go, {
nonInteractive: false,
});
spinner.succeed(`Logged in as ${chalk.cyan(getActorDisplayName(actor))}`);
const bundleId = flags['bundle-id'] ?? this.generateBundleId(actor);
const appName = flags.name ?? 'My Expo Go';
const slug = bundleId.split('.').pop() || 'my-expo-go';
const projectDir = path.join(os.tmpdir(), `eas-go-${slug}`);
await fs.emptyDir(projectDir);
const originalCwd = process.cwd();
process.chdir(projectDir);
const setupSpinner = ora('Creating project...').start();
// Step 1: Create project files and initialize git (silently)
try {
await withSuppressedOutputAsync(async () => {
await this.createProjectFilesAsync(projectDir, bundleId, appName);
await this.initGitRepoAsync(projectDir);
});
const vcsClient = resolveVcsClient();
// Step 2: Create/link EAS project (silently)
const projectId = await withSuppressedOutputAsync(() =>
this.ensureEasProjectAsync(graphqlClient, actor, projectDir, bundleId)
);
// Step 3: Set up iOS credentials and create App Store Connect app
const ascApp = await this.setupCredentialsAsync(
projectDir,
projectId,
bundleId,
appName,
graphqlClient,
actor,
analytics,
vcsClient,
flags.credentials,
() => {
setupSpinner.stop();
Log.markFreshLine();
}
);
// Step 4: Start workflow and monitor progress
const { workflowUrl, workflowRunId } = await this.runWorkflowAsync(
graphqlClient,
projectDir,
projectId,
actor,
vcsClient
);
Log.withTick(`Workflow started: ${chalk.cyan(workflowUrl)}`);
const status = await this.monitorWorkflowJobsAsync(graphqlClient, workflowRunId);
if (status === WorkflowRunStatus.Failure) {
throw new Error('Workflow failed');
} else if (status === WorkflowRunStatus.Canceled) {
throw new Error('Workflow was canceled');
}
// Step 5: Set up TestFlight group (silently)
try {
await setupTestFlightAsync(ascApp);
} catch {
// Non-fatal: TestFlight group setup failure shouldn't block the user
}
Log.newLine();
Log.succeed(
`Done! Your custom Expo Go has been submitted to TestFlight. ${learnMore(
`https://appstoreconnect.apple.com/apps/${ascApp.id}/testflight`,
{ learnMoreMessage: 'Open it on App Store Connect' }
)}`
);
Log.log(
`App Store processing may take several minutes to complete. ${learnMore(
'https://expo.fyi/personal-expo-go',
{ learnMoreMessage: 'Learn more about Expo Go on TestFlight' }
)}`
);
await fs.remove(projectDir);
} catch (error) {
Log.gray(`Project files preserved for debugging: ${projectDir}`);
throw error;
} finally {
process.chdir(originalCwd);
}
}
private generateBundleId(actor: Actor): string {
const username = actor.accounts[0].name;
// Sanitize username for bundle ID: only alphanumeric and hyphens allowed
const sanitizedUsername = username
.toLowerCase()
.replace(/[^a-z0-9-]/g, '')
.replace(/^-+|-+$/g, ''); // trim leading/trailing hyphens
// Deterministic bundle ID per user + SDK version (reuses same ASC app)
return `com.${sanitizedUsername || 'app'}.expogo${EXPO_GO_SDK_VERSION}`;
}
private async createProjectFilesAsync(
projectDir: string,
bundleId: string,
appName: string
): Promise<void> {
const slug = bundleId.split('.').pop() || 'custom-expo-go';
const extensionBundleId = `${bundleId}.ExpoNotificationServiceExtension`;
const appJson = {
expo: {
name: appName,
slug,
version: EXPO_GO_APP_VERSION,
ios: {
bundleIdentifier: bundleId,
buildNumber: EXPO_GO_BUILD_NUMBER,
config: {
usesNonExemptEncryption: false,
},
},
extra: {
eas: {
build: {
experimental: {
ios: {
appExtensions: [
{
targetName: 'ExpoNotificationServiceExtension',
bundleIdentifier: extensionBundleId,
},
],
},
},
},
},
},
},
};
const easJson = {
cli: {
version: '>= 5.0.0',
},
build: {
production: {
distribution: 'store',
credentialsSource: 'remote',
},
},
submit: {
production: {
ios: {},
},
},
};
const packageJson = {
name: slug,
version: '1.0.0',
dependencies: {
expo: '~54.0.0',
},
};
await fs.writeJson(path.join(projectDir, 'app.json'), appJson, { spaces: 2 });
await fs.writeJson(path.join(projectDir, 'eas.json'), easJson, { spaces: 2 });
await fs.writeJson(path.join(projectDir, 'package.json'), packageJson, { spaces: 2 });
await spawnAsync('npm', ['install'], { cwd: projectDir });
}
private async initGitRepoAsync(projectDir: string): Promise<void> {
await spawnAsync('git', ['init'], { cwd: projectDir });
await spawnAsync('git', ['add', '.'], { cwd: projectDir });
await spawnAsync('git', ['commit', '-m', 'Initial commit'], { cwd: projectDir });
}
private async ensureEasProjectAsync(
graphqlClient: ExpoGraphqlClient,
actor: Actor,
projectDir: string,
bundleId: string
): Promise<string> {
const slug = bundleId.split('.').pop() || 'custom-expo-go';
const account = actor.accounts[0];
const existingProjectId = await findProjectIdByAccountNameAndSlugNullableAsync(
graphqlClient,
account.name,
slug
);
if (existingProjectId) {
await saveProjectIdToAppConfigAsync(projectDir, existingProjectId);
return existingProjectId;
}
const projectId = await AppMutation.createAppAsync(graphqlClient, {
accountId: account.id,
projectName: slug,
});
await saveProjectIdToAppConfigAsync(projectDir, projectId);
return projectId;
}
private async setupCredentialsAsync(
projectDir: string,
projectId: string,
bundleId: string,
appName: string,
graphqlClient: ExpoGraphqlClient,
actor: Actor,
analytics: Analytics,
vcsClient: VcsClient,
customizeCreds: boolean,
onBeforeAppleAuth?: () => void
): Promise<App> {
const exp = await getPrivateExpoConfigAsync(projectDir);
const extensionBundleId = `${bundleId}.ExpoNotificationServiceExtension`;
const credentialsCtx = new CredentialsContext({
projectInfo: { exp, projectId },
nonInteractive: false,
autoAcceptCredentialReuse: !customizeCreds,
projectDir,
user: actor,
graphqlClient,
analytics,
vcsClient,
});
onBeforeAppleAuth?.();
const userAuthCtx = await credentialsCtx.appStore.ensureUserAuthenticatedAsync();
const app = await getAppFromContextAsync(credentialsCtx);
const targets: Target[] = [
{
targetName: exp.slug,
bundleIdentifier: bundleId,
entitlements: {},
},
{
targetName: 'ExpoNotificationServiceExtension',
bundleIdentifier: extensionBundleId,
parentBundleIdentifier: bundleId,
entitlements: {},
},
];
const ascApp = await withSuppressedOutputAsync(async () => {
await new SetUpBuildCredentials({
app,
targets,
distribution: 'store',
}).runAsync(credentialsCtx);
const appLookupParams = {
...app,
bundleIdentifier: bundleId,
};
await new SetUpAscApiKey(appLookupParams, AppStoreApiKeyPurpose.SUBMISSION_SERVICE).runAsync(
credentialsCtx
);
const ascAppResult = await ensureAppExistsAsync(userAuthCtx, {
name: appName,
bundleIdentifier: bundleId,
});
const easJsonPath = path.join(projectDir, 'eas.json');
const easJson = await fs.readJson(easJsonPath);
easJson.submit = easJson.submit || {};
easJson.submit.production = easJson.submit.production || {};
easJson.submit.production.ios = easJson.submit.production.ios || {};
easJson.submit.production.ios.ascAppId = ascAppResult.id;
await fs.writeJson(easJsonPath, easJson, { spaces: 2 });
await spawnAsync('git', ['add', 'eas.json'], { cwd: projectDir });
await spawnAsync('git', ['commit', '-m', 'Add ascAppId to eas.json'], { cwd: projectDir });
return ascAppResult;
});
// Set up push notifications (outside suppressed block so prompts are visible)
const appLookupParamsForPushKey = { ...app, bundleIdentifier: bundleId };
const setupPushKeyAction = new SetUpPushKey(appLookupParamsForPushKey);
const isPushKeySetup = await setupPushKeyAction.isPushKeySetupAsync(credentialsCtx);
if (!isPushKeySetup) {
if (customizeCreds) {
const wantsPushNotifications = await confirmAsync({
message: 'Would you like to set up Push Notifications for your app?',
initial: true,
});
if (wantsPushNotifications) {
await setupPushKeyAction.runAsync(credentialsCtx);
}
} else {
await setupPushKeyAction.runAsync(credentialsCtx);
}
}
return ascApp;
}
private async runWorkflowAsync(
graphqlClient: ExpoGraphqlClient,
projectDir: string,
projectId: string,
actor: Actor,
vcsClient: VcsClient
): Promise<{ workflowUrl: string; workflowRunId: string }> {
const account = actor.accounts[0];
const { projectArchiveBucketKey, easJsonBucketKey, packageJsonBucketKey } =
await withSuppressedOutputAsync(async () => {
const { projectArchiveBucketKey } = await uploadAccountScopedProjectSourceAsync({
graphqlClient,
vcsClient,
accountId: account.id,
});
const { fileBucketKey: easJsonBucketKey } = await uploadAccountScopedFileAsync({
graphqlClient,
accountId: account.id,
filePath: path.join(projectDir, 'eas.json'),
maxSizeBytes: 1024 * 1024,
});
const { fileBucketKey: packageJsonBucketKey } = await uploadAccountScopedFileAsync({
graphqlClient,
accountId: account.id,
filePath: path.join(projectDir, 'package.json'),
maxSizeBytes: 1024 * 1024,
});
return { projectArchiveBucketKey, easJsonBucketKey, packageJsonBucketKey };
});
const { id: workflowRunId } = await WorkflowRunMutation.createExpoGoRepackWorkflowRunAsync(
graphqlClient,
{
appId: projectId,
projectSource: {
type: WorkflowProjectSourceType.Gcs,
projectArchiveBucketKey,
easJsonBucketKey,
packageJsonBucketKey,
projectRootDirectory: '.',
},
}
);
const app = await AppQuery.byIdAsync(graphqlClient, projectId);
const workflowUrl = `https://expo.dev/accounts/${account.name}/projects/${app.slug}/workflows/${workflowRunId}`;
return { workflowUrl, workflowRunId };
}
private async monitorWorkflowJobsAsync(
graphqlClient: ExpoGraphqlClient,
workflowRunId: string
): Promise<WorkflowRunStatus> {
const EXPECTED_BUILD_DURATION_SECONDS = 5 * 60;
const EXPECTED_SUBMIT_DURATION_SECONDS = 2 * 60;
const buildStartTime = Date.now();
let submitStartTime: number | null = null;
const buildSpinner = ora(
this.formatSpinnerText('Building Expo Go', EXPECTED_BUILD_DURATION_SECONDS, buildStartTime)
).start();
let submitSpinner: ReturnType<typeof ora> | null = null;
let buildCompleted = false;
let failedFetchesCount = 0;
while (true) {
if (!buildCompleted) {
buildSpinner.text = this.formatSpinnerText(
'Building Expo Go',
EXPECTED_BUILD_DURATION_SECONDS,
buildStartTime
);
}
if (submitSpinner && submitStartTime) {
submitSpinner.text = this.formatSpinnerText(
'Submitting to TestFlight',
EXPECTED_SUBMIT_DURATION_SECONDS,
submitStartTime
);
}
try {
const workflowRun = await WorkflowRunQuery.withJobsByIdAsync(graphqlClient, workflowRunId, {
useCache: false,
});
failedFetchesCount = 0;
const repackJob = workflowRun.jobs.find(j => j.name === 'Repack Expo Go');
const submitJob = workflowRun.jobs.find(j => j.name === 'Submit to TestFlight');
if (!buildCompleted) {
if (repackJob?.status === WorkflowJobStatus.Success) {
buildSpinner.succeed('Built Expo Go');
buildCompleted = true;
} else if (
repackJob?.status === WorkflowJobStatus.Failure ||
repackJob?.status === WorkflowJobStatus.Canceled
) {
buildSpinner.fail('Build failed');
return WorkflowRunStatus.Failure;
}
}
if (buildCompleted && submitSpinner === null && submitJob) {
submitStartTime = Date.now();
submitSpinner = ora(
this.formatSpinnerText(
'Submitting to TestFlight',
EXPECTED_SUBMIT_DURATION_SECONDS,
submitStartTime
)
).start();
}
if (workflowRun.status === WorkflowRunStatus.Success) {
submitSpinner?.stop();
return WorkflowRunStatus.Success;
} else if (workflowRun.status === WorkflowRunStatus.Failure) {
buildSpinner.stop();
submitSpinner?.fail('Submission failed');
return WorkflowRunStatus.Failure;
} else if (workflowRun.status === WorkflowRunStatus.Canceled) {
buildSpinner.stop();
submitSpinner?.stop();
return WorkflowRunStatus.Canceled;
}
} catch {
failedFetchesCount++;
if (failedFetchesCount > 6) {
buildSpinner.fail();
submitSpinner?.fail();
throw new Error('Failed to fetch the workflow run status 6 times in a row');
}
}
await sleepAsync(10 * 1000);
}
}
private formatSpinnerText(
label: string,
expectedDurationSeconds: number,
startTime: number
): string {
const elapsedSeconds = Math.floor((Date.now() - startTime) / 1000);
const remainingSeconds = Math.max(0, expectedDurationSeconds - elapsedSeconds);
if (remainingSeconds === 0) {
return `${label} (almost done...)`;
}
const minutes = Math.ceil(remainingSeconds / 60);
const unit = minutes === 1 ? 'minute' : 'minutes';
return `${label} (~${minutes} ${unit} remaining)`;
}
}
@@ -143,6 +143,67 @@ describe(UpdatePublish.name, () => {
expect(PublishMutation.publishUpdateGroupAsync).toHaveBeenCalled();
});
it('errors when SDK >= 55 and --environment is not provided', async () => {
const flags = ['--non-interactive', '--branch=branch123', '--message=abc'];
mockTestProject({ expoConfig: { sdkVersion: '55.0.0' } });
await expect(new UpdatePublish(flags, commandOptions).run()).rejects.toThrow(
'--environment flag is required for projects using Expo SDK 55 or greater'
);
});
it('does not error when SDK >= 55 and --environment is provided', async () => {
const flags = [
'--non-interactive',
'--branch=branch123',
'--message=abc',
'--environment=production',
];
mockTestProject({ expoConfig: { sdkVersion: '55.0.0' } });
const { platforms, runtimeVersion } = mockTestExport();
jest.mocked(ensureBranchExistsAsync).mockResolvedValue({
branch: {
id: 'branch123',
name: 'wat',
},
createdBranch: false,
});
jest
.mocked(PublishMutation.publishUpdateGroupAsync)
.mockResolvedValue(platforms.map(platform => ({ ...updateStub, platform, runtimeVersion })));
await new UpdatePublish(flags, commandOptions).run();
expect(PublishMutation.publishUpdateGroupAsync).toHaveBeenCalled();
});
it('does not error when SDK < 55 and --environment is not provided', async () => {
const flags = ['--non-interactive', '--branch=branch123', '--message=abc'];
mockTestProject({ expoConfig: { sdkVersion: '54.0.0' } });
const { platforms, runtimeVersion } = mockTestExport();
jest.mocked(ensureBranchExistsAsync).mockResolvedValue({
branch: {
id: 'branch123',
name: 'wat',
},
createdBranch: false,
});
jest
.mocked(PublishMutation.publishUpdateGroupAsync)
.mockResolvedValue(platforms.map(platform => ({ ...updateStub, platform, runtimeVersion })));
await new UpdatePublish(flags, commandOptions).run();
expect(PublishMutation.publishUpdateGroupAsync).toHaveBeenCalled();
});
it('creates a new update with the public expo config', async () => {
const flags = ['--non-interactive', '--branch=branch123', '--message=abc'];
@@ -11,6 +11,7 @@ import {
ensureEASUpdateIsConfiguredAsync,
ensureEASUpdateIsConfiguredInEasJsonAsync,
} from '../../update/configure';
import { assertEnvironmentFlagForSdk55OrGreater } from '../../update/utils';
export default class UpdateConfigure extends EasCommand {
static override description = 'configure the project to support EAS Update';
@@ -42,6 +43,11 @@ export default class UpdateConfigure extends EasCommand {
withServerSideEnvironment: flags['environment'] ?? null,
});
assertEnvironmentFlagForSdk55OrGreater({
sdkVersion: exp.sdkVersion,
environment: flags['environment'],
});
Log.log(
'💡 The following process will configure your project to use EAS Update. These changes only apply to your local project files and you can safely revert them at any time.'
);
@@ -9,6 +9,7 @@ import { ensureRepoIsCleanAsync } from '../../build/utils/repository';
import { getUpdateGroupUrl } from '../../build/utils/url';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags, EasUpdateEnvironmentFlag } from '../../commandUtils/flags';
import { assertEnvironmentFlagForSdk55OrGreater } from '../../update/utils';
import { getPaginatedQueryOptions } from '../../commandUtils/pagination';
import fetch from '../../fetch';
import {
@@ -249,6 +250,11 @@ export default class UpdatePublish extends EasCommand {
projectDir,
} = await getDynamicPublicProjectConfigAsync();
assertEnvironmentFlagForSdk55OrGreater({
sdkVersion: expPossiblyWithoutEasUpdateConfigured.sdkVersion,
environment,
});
await maybeWarnAboutEasOutagesAsync(graphqlClient, [StatuspageServiceName.EasUpdate]);
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
@@ -25,6 +25,7 @@ export class CredentialsContext {
public readonly appStore = new AppStoreApi();
public readonly ios = IosGraphqlClient;
public readonly nonInteractive: boolean;
public readonly autoAcceptCredentialReuse: boolean;
public readonly freezeCredentials: boolean = false;
public readonly projectDir: string;
public readonly user: Actor;
@@ -50,6 +51,7 @@ export class CredentialsContext {
analytics: Analytics;
vcsClient: Client;
freezeCredentials?: boolean;
autoAcceptCredentialReuse?: boolean;
env?: Env;
}
) {
@@ -60,6 +62,7 @@ export class CredentialsContext {
this.analytics = options.analytics;
this.vcsClient = options.vcsClient;
this.nonInteractive = options.nonInteractive ?? false;
this.autoAcceptCredentialReuse = options.autoAcceptCredentialReuse ?? false;
this.projectInfo = options.projectInfo;
this.freezeCredentials = options.freezeCredentials ?? false;
this.usesBroadcastPushNotifications =
@@ -66,6 +66,11 @@ export async function provideOrGenerateAscApiKeyAsync(
throw new Error(`A new App Store Connect API Key cannot be created in non-interactive mode.`);
}
// When auto-accepting credentials, always auto-generate without asking for user input
if (ctx.autoAcceptCredentialReuse) {
return await generateAscApiKeyAsync(ctx, purpose);
}
const userProvided = await promptForAscApiKeyAsync(ctx);
if (!userProvided) {
return await generateAscApiKeyAsync(ctx, purpose);
@@ -64,7 +64,7 @@ export class CreateProvisioningProfile {
private async maybeGetUserProvidedAsync(
ctx: CredentialsContext
): Promise<ProvisioningProfile | null> {
if (ctx.nonInteractive) {
if (ctx.nonInteractive || ctx.autoAcceptCredentialReuse) {
return null;
}
const userProvided = await askForUserProvidedAsync(provisioningProfileSchema);
@@ -162,7 +162,7 @@ Remember that Apple Distribution Certificates are not application specific!
export async function provideOrGenerateDistributionCertificateAsync(
ctx: CredentialsContext
): Promise<DistributionCertificate> {
if (!ctx.nonInteractive) {
if (!ctx.nonInteractive && !ctx.autoAcceptCredentialReuse) {
const userProvided = await promptForDistCertAsync(ctx);
if (userProvided) {
if (!ctx.appStore.authCtx) {
@@ -14,7 +14,7 @@ import { pushKeySchema } from '../credentials';
import { isPushKeyValidAndTrackedAsync } from '../validators/validatePushKey';
export async function provideOrGeneratePushKeyAsync(ctx: CredentialsContext): Promise<PushKey> {
if (!ctx.nonInteractive) {
if (!ctx.nonInteractive && !ctx.autoAcceptCredentialReuse) {
const userProvided = await promptForPushKeyAsync(ctx);
if (userProvided) {
if (!ctx.appStore.authCtx) {
@@ -65,6 +65,12 @@ export class SetUpAscApiKey {
return await new AssignAscApiKey(this.app).runAsync(ctx, maybeAutoselectedKey, this.purpose);
}
// When auto-accepting credentials and no valid key was found, generate a new one without prompting
if (ctx.autoAcceptCredentialReuse) {
const ascApiKey = await new CreateAscApiKey(this.app.account).runAsync(ctx, this.purpose);
return await new AssignAscApiKey(this.app).runAsync(ctx, ascApiKey, this.purpose);
}
const availableChoices =
keysForAccount.length === 0
? this.choices.filter(choice => choice.value !== SetupAscApiKeyChoice.USE_EXISTING)
@@ -90,9 +96,11 @@ export class SetUpAscApiKey {
return null;
}
const [autoselectedKey] = sortAscApiKeysByUpdatedAtDesc(validKeys);
const useAutoselected = await confirmAsync({
message: `Reuse this App Store Connect API Key?\n${formatAscApiKey(autoselectedKey)}`,
});
const useAutoselected =
ctx.autoAcceptCredentialReuse ||
(await confirmAsync({
message: `Reuse this App Store Connect API Key?\n${formatAscApiKey(autoselectedKey)}`,
}));
if (useAutoselected) {
Log.log(`Using App Store Connect API Key with ID ${autoselectedKey.keyIdentifier}`);
@@ -117,11 +117,13 @@ export class SetUpDistributionCertificate {
const validDistCerts = await this.getValidDistCertsAsync(ctx);
const autoselectedDistCert = validDistCerts[0];
const useAutoselected = await confirmAsync({
message: `Reuse this distribution certificate?\n${formatDistributionCertificate(
autoselectedDistCert
)}`,
});
const useAutoselected =
ctx.autoAcceptCredentialReuse ||
(await confirmAsync({
message: `Reuse this distribution certificate?\n${formatDistributionCertificate(
autoselectedDistCert
)}`,
}));
if (useAutoselected) {
Log.log(
@@ -142,7 +142,7 @@ export class SetUpProvisioningProfile {
}
const isNonInteractiveOrUserDidConfirmAsync = async (): Promise<boolean> => {
if (ctx.nonInteractive) {
if (ctx.nonInteractive || ctx.autoAcceptCredentialReuse) {
return true;
}
return await confirmAsync({
@@ -63,12 +63,14 @@ export class SetUpPushKey {
if (validPushKeys.length > 0) {
const autoselectedPushKey = validPushKeys[0];
const useAutoselected = await confirmAsync({
message: `Reuse this Push Key?\n${formatPushKey(
autoselectedPushKey,
validPushKeys.map(pushKey => pushKey.keyIdentifier)
)}`,
});
const useAutoselected =
ctx.autoAcceptCredentialReuse ||
(await confirmAsync({
message: `Reuse this Push Key?\n${formatPushKey(
autoselectedPushKey,
validPushKeys.map(pushKey => pushKey.keyIdentifier)
)}`,
}));
if (useAutoselected) {
Log.log(`Using push key with ID ${autoselectedPushKey.keyIdentifier}`);
@@ -77,6 +79,11 @@ export class SetUpPushKey {
}
}
// When auto-accepting and no valid push key was found, generate a new one without prompting
if (ctx.autoAcceptCredentialReuse) {
return await new CreatePushKey(this.app.account).runAsync(ctx);
}
const { action } = await promptAsync({
type: 'select',
name: 'action',
@@ -9,6 +9,7 @@ import {
CreateWorkflowRunFromGitRefMutationVariables,
CreateWorkflowRunMutation,
CreateWorkflowRunMutationVariables,
WorkflowProjectSourceInput,
WorkflowRevisionInput,
WorkflowRunInput,
} from '../generated';
@@ -103,6 +104,40 @@ export namespace WorkflowRunMutation {
return { id: data.workflowRun.createWorkflowRunFromGitRef.id };
}
export async function createExpoGoRepackWorkflowRunAsync(
graphqlClient: ExpoGraphqlClient,
{
appId,
projectSource,
}: {
appId: string;
projectSource: WorkflowProjectSourceInput;
}
): Promise<{ id: string }> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<
{ workflowRun: { createExpoGoRepackWorkflowRun: { id: string } } },
{ appId: string; projectSource: WorkflowProjectSourceInput }
>(
/* eslint-disable graphql/template-strings */
gql`
mutation CreateExpoGoRepackWorkflowRun($appId: ID!, $projectSource: WorkflowProjectSourceInput!) {
workflowRun {
createExpoGoRepackWorkflowRun(appId: $appId, projectSource: $projectSource) {
id
}
}
}
`,
/* eslint-enable graphql/template-strings */
{ appId, projectSource }
)
.toPromise()
);
return { id: data.workflowRun.createExpoGoRepackWorkflowRun.id };
}
export async function cancelWorkflowRunAsync(
graphqlClient: ExpoGraphqlClient,
{
+9
View File
@@ -26,6 +26,15 @@ export default class Log {
}
}
/**
* Signal that the cursor is already at the start of a fresh line (e.g. after
* a spinner clears itself), so that the next `addNewLineIfNone()` call knows
* it doesn't need to emit an extra blank line.
*/
public static markFreshLine(): void {
Log.isLastLineNewLine = true;
}
public static error(...args: any[]): void {
Log.consoleLog(...Log.withTextColor(args, chalk.red));
}
+19
View File
@@ -1,7 +1,9 @@
import { ExpoConfig } from '@expo/config';
import { Errors } from '@oclif/core';
import { format } from '@expo/timeago.js';
import chalk from 'chalk';
import dateFormat from 'dateformat';
import semver from 'semver';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import {
@@ -357,3 +359,20 @@ export const updatePublishPlatformToAppPlatform: Record<UpdatePublishPlatform, A
android: AppPlatform.Android,
ios: AppPlatform.Ios,
};
export function assertEnvironmentFlagForSdk55OrGreater({
sdkVersion,
environment,
}: {
sdkVersion: string | undefined;
environment: string | undefined;
}): void {
if (process.env.EAS_BUILD) {
return;
}
if (sdkVersion && semver.gte(sdkVersion, '55.0.0') && !environment) {
Errors.error('--environment flag is required for projects using Expo SDK 55 or greater', {
exit: 1,
});
}
}
@@ -1,86 +1,24 @@
import assert from 'assert';
import openBrowserAsync from 'better-opn';
import crypto from 'crypto';
import http from 'http';
import { Socket } from 'node:net';
import querystring from 'querystring';
import { getExpoApiBaseUrl, getExpoWebsiteBaseUrl } from '../api';
import fetch from '../fetch';
import { getExpoWebsiteBaseUrl } from '../api';
import Log from '../log';
const CLIENT_ID = 'eas-cli';
function generateCodeVerifier(): string {
return crypto.randomBytes(32).toString('base64url');
}
function generateCodeChallenge(codeVerifier: string): string {
return crypto.createHash('sha256').update(codeVerifier).digest('base64url');
}
function generateState(): string {
return crypto.randomBytes(32).toString('base64url');
}
async function exchangeCodeForSessionSecretAsync({
code,
codeVerifier,
redirectUri,
}: {
code: string;
codeVerifier: string;
redirectUri: string;
}): Promise<string> {
const tokenUrl = `${getExpoApiBaseUrl()}/v2/auth/token`;
const response = await fetch(tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri,
code_verifier: codeVerifier,
client_id: CLIENT_ID,
}),
});
const result: any = await response.json();
const sessionSecret = result?.data?.session_secret;
if (!sessionSecret) {
throw new Error('Failed to obtain session secret from token exchange.');
}
return sessionSecret;
}
export async function getSessionUsingBrowserAuthFlowAsync({ sso = false }): Promise<string> {
const scheme = 'http';
const hostname = 'localhost';
const callbackPath = '/auth/callback';
const path = '/auth/callback';
const expoWebsiteUrl = getExpoWebsiteBaseUrl();
const codeVerifier = generateCodeVerifier();
const codeChallenge = generateCodeChallenge(codeVerifier);
const state = generateState();
const buildRedirectUri = (port: number): string =>
`${scheme}://${hostname}:${port}${callbackPath}`;
const buildExpoLoginUrl = (port: number, sso: boolean): string => {
// Note: we avoid URLSearchParams here because better-opn calls encodeURI()
// on the URL before passing it to AppleScript, which would double-encode
// the percent-encoded values from URLSearchParams.toString().
const params = [
`client_id=${CLIENT_ID}`,
`redirect_uri=${buildRedirectUri(port)}`,
`response_type=code`,
`code_challenge=${codeChallenge}`,
`code_challenge_method=S256`,
`state=${state}`,
`confirm_account=true`,
].join('&');
const params = querystring.stringify({
confirm_account: true,
app_redirect_uri: `${scheme}://${hostname}:${port}${path}`,
});
return `${expoWebsiteUrl}${sso ? '/sso-login' : '/login'}?${params}`;
};
@@ -101,39 +39,22 @@ export async function getSessionUsingBrowserAuthFlowAsync({ sso = false }): Prom
}
};
const handleRequestAsync = async (): Promise<void> => {
try {
if (!(request.method === 'GET' && request.url?.includes('/auth/callback'))) {
throw new Error('Unexpected login response.');
}
const url = new URL(request.url, `http:${request.headers.host}`);
const code = url.searchParams.get('code');
const returnedState = url.searchParams.get('state');
const sessionSecret = url.searchParams.get('session_secret');
if (!code) {
throw new Error('Request missing code search parameter.');
if (!sessionSecret) {
throw new Error('Request missing session_secret search parameter.');
}
if (returnedState !== state) {
throw new Error('State mismatch. Possible CSRF attack.');
}
const address = server.address();
assert(address !== null && typeof address === 'object');
const redirectUri = buildRedirectUri(address.port);
const sessionSecret = await exchangeCodeForSessionSecretAsync({
code,
codeVerifier,
redirectUri,
});
resolve(sessionSecret);
redirectAndCleanup('success');
};
handleRequestAsync().catch(error => {
} catch (error) {
redirectAndCleanup('error');
reject(error);
});
}
}
);
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli-local-build-plugin",
"version": "18.0.2",
"version": "18.0.6",
"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": "rm -rf node_modules dist coverage *.tsbuildinfo"
},
"dependencies": {
"@expo/build-tools": "18.0.2",
"@expo/build-tools": "18.0.6",
"@expo/eas-build-job": "18.0.2",
"@expo/logger": "18.0.1",
"@expo/spawn-async": "^1.7.2",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/worker",
"version": "18.0.2",
"version": "18.0.6",
"private": true,
"scripts": {
"start": "yarn watch",
@@ -19,7 +19,7 @@
},
"dependencies": {
"@babel/core": "7.24.7",
"@expo/build-tools": "18.0.2",
"@expo/build-tools": "18.0.6",
"@expo/config-plugins": "8.0.8",
"@expo/config-types": "52.0.1",
"@expo/downloader": "18.0.1",
+3 -1
View File
@@ -12,9 +12,11 @@ ROOT_DIR="$( cd "$SCRIPTS_DIR"/.. && pwd )"
# to @expo/eas-build-job dependency, so the update-local-plugin step is no longer needed.
# $SCRIPTS_DIR/bin/run update-local-plugin
next_version_bump=$($SCRIPTS_DIR/bin/run next-version)
next_version_bump=`(cd $SCRIPTS_DIR; node --no-warnings=ExperimentalWarning --loader ts-node/esm src/nextVersion.ts)`
next_version=${1:-$next_version_bump}
echo "next_version = ${next_version}"
if [[ "$GITHUB_USER" == "Expo CI" && "$GITHUB_EMAIL" == "[email protected]" && "$INPUT_DRY_RUN" != "true" ]]; then
echo "Releasing with version $next_version"
lerna version --yes --exact "$next_version"
Generated
+4 -3
View File
@@ -2020,7 +2020,7 @@ __metadata:
languageName: node
linkType: hard
"@expo/build-tools@npm:18.0.2, @expo/build-tools@workspace:packages/build-tools":
"@expo/build-tools@npm:18.0.6, @expo/build-tools@workspace:packages/build-tools":
version: 0.0.0-use.local
resolution: "@expo/build-tools@workspace:packages/build-tools"
dependencies:
@@ -2055,6 +2055,7 @@ __metadata:
bplist-parser: "npm:0.3.2"
bunyan: "npm:^1.8.15"
fast-glob: "npm:^3.3.2"
fast-xml-parser: "npm:^4.4.1"
fs-extra: "npm:^11.2.0"
gql.tada: "npm:^1.8.13"
jest: "npm:^29.7.0"
@@ -2717,7 +2718,7 @@ __metadata:
resolution: "@expo/worker@workspace:packages/worker"
dependencies:
"@babel/core": "npm:7.24.7"
"@expo/build-tools": "npm:18.0.2"
"@expo/build-tools": "npm:18.0.6"
"@expo/config-plugins": "npm:8.0.8"
"@expo/config-types": "npm:52.0.1"
"@expo/downloader": "npm:18.0.1"
@@ -10120,7 +10121,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "eas-cli-local-build-plugin@workspace:packages/local-build-plugin"
dependencies:
"@expo/build-tools": "npm:18.0.2"
"@expo/build-tools": "npm:18.0.6"
"@expo/eas-build-job": "npm:18.0.2"
"@expo/logger": "npm:18.0.1"
"@expo/spawn-async": "npm:^1.7.2"