Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ This is the log of notable changes to EAS CLI and related packages.

### 🐛 Bug fixes

- [eas-cli] `eas go` now pre-selects the SDK version from the current project's `app.json` or `app.config.js` when available. ([#3776](https://github.com/expo/eas-cli/pull/3776) by [@gwdp](https://github.com/gwdp))

### 🧹 Chores

## [19.0.5](https://github.com/expo/eas-cli/releases/tag/v19.0.5) - 2026-05-20
Expand Down
149 changes: 149 additions & 0 deletions packages/eas-cli/src/__tests__/commands/go-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { getConfigFilePaths } from '@expo/config';

import { getWorkflowRunUrl } from '../../build/utils/url';
import Go from '../../commands/go';
import { WorkflowRunStatus } from '../../graphql/generated';
import { WorkflowRunMutation } from '../../graphql/mutations/WorkflowRunMutation';
import { WorkflowRunQuery } from '../../graphql/queries/WorkflowRunQuery';
import Log from '../../log';
import { getPrivateExpoConfigAsync } from '../../project/expoConfig';
import { uploadAccountScopedFileAsync } from '../../project/uploadAccountScopedFileAsync';
import { uploadAccountScopedProjectSourceAsync } from '../../project/uploadAccountScopedProjectSourceAsync';
import { ensureActorHasPrimaryAccount } from '../../user/actions';
import { detectProjectSdkVersionAsync } from '../../commands/go';
import { mockTestCommand } from './utils';

jest.mock('@expo/config', () => ({
...jest.requireActual('@expo/config'),
getConfigFilePaths: jest.fn(),
}));
jest.mock('../../project/expoConfig');
jest.mock('../../log', () => ({
__esModule: true,
default: {
log: jest.fn(),
withTick: jest.fn(),
newLine: jest.fn(),
succeed: jest.fn(),
debug: jest.fn(),
markFreshLine: jest.fn(),
error: jest.fn(),
},
learnMore: jest.fn().mockReturnValue(''),
}));
jest.mock('../../ora', () => ({
ora: jest.fn().mockReturnValue({
start: jest.fn().mockReturnThis(),
stop: jest.fn().mockReturnThis(),
fail: jest.fn().mockReturnThis(),
succeed: jest.fn().mockReturnThis(),
}),
}));
jest.mock('fs-extra', () => ({
ensureDir: jest.fn().mockResolvedValue(undefined),
writeFile: jest.fn().mockResolvedValue(undefined),
remove: jest.fn().mockResolvedValue(undefined),
}));
jest.mock('../../user/actions');
jest.mock('../../graphql/queries/WorkflowRunQuery');
jest.mock('../../graphql/mutations/WorkflowRunMutation');
jest.mock('../../project/uploadAccountScopedFileAsync');
jest.mock('../../project/uploadAccountScopedProjectSourceAsync');
jest.mock('../../build/utils/url');

const mockGetConfigFilePaths = jest.mocked(getConfigFilePaths);
const mockGetPrivateExpoConfigAsync = jest.mocked(getPrivateExpoConfigAsync);

describe('detectProjectSdkVersionAsync', () => {
it('returns undefined when no config file exists', async () => {
mockGetConfigFilePaths.mockReturnValue({ staticConfigPath: null, dynamicConfigPath: null });
await expect(detectProjectSdkVersionAsync('/project')).resolves.toBeUndefined();
});

it('returns the sdkVersion from the project config', async () => {
mockGetConfigFilePaths.mockReturnValue({
staticConfigPath: '/project/app.json',
dynamicConfigPath: null,
});
mockGetPrivateExpoConfigAsync.mockResolvedValue({ sdkVersion: '55.0.0' } as any);
await expect(detectProjectSdkVersionAsync('/project')).resolves.toBe('55.0.0');
});

it('returns undefined when reading the config throws', async () => {
mockGetConfigFilePaths.mockReturnValue({
staticConfigPath: '/project/app.json',
dynamicConfigPath: null,
});
mockGetPrivateExpoConfigAsync.mockRejectedValue(new Error('config error'));
await expect(detectProjectSdkVersionAsync('/project')).resolves.toBeUndefined();
});
});

const mockAccount = { id: 'account-id', name: 'testuser' };
const mockActor = {
__typename: 'User' as const,
id: 'user-id',
username: 'testuser',
primaryAccount: mockAccount,
};

describe('Go command', () => {
beforeEach(() => {
jest.mocked(ensureActorHasPrimaryAccount).mockReturnValue(mockAccount as any);
jest.mocked(WorkflowRunQuery.expoGoRepackConfigurationAsync).mockResolvedValue({
files: [],
sdkVersion: '55.0.0',
} as any);
jest.mocked(WorkflowRunMutation.createExpoGoRepackWorkflowRunAsync).mockResolvedValue({
id: 'run-id',
} as any);
jest.mocked(uploadAccountScopedProjectSourceAsync).mockResolvedValue({
projectArchiveBucketKey: 'archive-key',
});
jest
.mocked(uploadAccountScopedFileAsync)
.mockResolvedValue({ fileBucketKey: 'file-key' } as any);
jest.mocked(getWorkflowRunUrl).mockReturnValue('https://expo.dev/run/123');
});

afterEach(() => {
jest.clearAllMocks();
});

function makeCmd(argv: string[] = []) {
const ctx = {
loggedIn: { actor: mockActor as any, graphqlClient: {} as any },
analytics: {} as any,
};
const cmd = mockTestCommand(Go, ['--bundle-id', 'com.test.go', ...argv], ctx);
jest.spyOn(cmd as any, 'ensureEasProjectAsync').mockResolvedValue('project-id');
jest.spyOn(cmd as any, 'setupCredentialsAsync').mockResolvedValue({ id: 'asc-app-id' });
jest.spyOn(cmd as any, 'monitorWorkflowJobsAsync').mockResolvedValue(WorkflowRunStatus.Success);
return cmd;
}

it('logs auto-selected SDK message and reports resolved version after dispatch', async () => {
mockGetConfigFilePaths.mockReturnValue({
staticConfigPath: '/app.json',
dynamicConfigPath: null,
});
mockGetPrivateExpoConfigAsync.mockResolvedValue({ sdkVersion: '55.0.0' } as any);

await makeCmd().run();

expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('SDK 55'));
expect(Log.withTick).toHaveBeenCalledWith(expect.stringContaining('Using Expo Go SDK'));
});

it('skips auto-select log when --sdk-version flag is provided', async () => {
mockGetConfigFilePaths.mockReturnValue({
staticConfigPath: '/app.json',
dynamicConfigPath: null,
});
mockGetPrivateExpoConfigAsync.mockResolvedValue({ sdkVersion: '55.0.0' } as any);

await makeCmd(['--sdk-version', '55.0.0']).run();

expect(Log.log).not.toHaveBeenCalledWith(expect.stringContaining('Auto-selected'));
});
});
36 changes: 31 additions & 5 deletions packages/eas-cli/src/commands/go.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ExpoConfig } from '@expo/config';
import { ExpoConfig, getConfigFilePaths } from '@expo/config';
import { App, User, UserRole } from '@expo/apple-utils';
import { Flags } from '@oclif/core';
import chalk from 'chalk';
Expand Down Expand Up @@ -28,6 +28,7 @@ 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';
Expand All @@ -45,6 +46,20 @@ function deriveBundleIdSlug(bundleId: string): string {
return bundleId.split('.').filter(Boolean).pop()!;
}

export async function detectProjectSdkVersionAsync(
projectDir: string
): Promise<string | undefined> {
const paths = getConfigFilePaths(projectDir);
if (!paths.staticConfigPath && !paths.dynamicConfigPath) {
return;
}
try {
return (await getPrivateExpoConfigAsync(projectDir)).sdkVersion;
} catch {
return;
}
}

const TESTFLIGHT_GROUP_NAME = 'Team (Expo)';

async function setupTestFlightAsync(ascApp: App): Promise<void> {
Expand Down Expand Up @@ -189,7 +204,13 @@ export default class Go extends EasCommand {
});
Log.withTick(`Logged in as ${chalk.cyan(getActorDisplayName(actor))}`);

const sdkVersion = flags['sdk-version'];
const detectedSdkVersion = await detectProjectSdkVersionAsync(process.cwd());
if (detectedSdkVersion && !flags['sdk-version']) {
Log.log(
`Current project using SDK ${detectedSdkVersion.split('.')[0]}. Auto-selected same version. To use a different version, pass --sdk-version.`
);
}
const sdkVersion = flags['sdk-version'] ?? detectedSdkVersion;
const bundleId = flags['bundle-id'] ?? this.generateBundleId(actor);
if (!isBundleIdentifierValid(bundleId)) {
throw new Error(
Expand Down Expand Up @@ -231,7 +252,11 @@ export default class Go extends EasCommand {
}
);

const { workflowUrl, workflowRunId } = await this.dispatchWorkflowAsync(
const {
workflowUrl,
workflowRunId,
sdkVersion: resolvedSdkVersion,
} = await this.dispatchWorkflowAsync(
graphqlClient,
projectId,
actor,
Expand All @@ -242,6 +267,7 @@ export default class Go extends EasCommand {
tmpDir,
vcsClient
);
Log.withTick(`Using Expo Go SDK ${chalk.cyan(resolvedSdkVersion.split('.')[0])}`);
Log.withTick(`Build started: ${chalk.cyan(workflowUrl)}`);

const status = await this.monitorWorkflowJobsAsync(graphqlClient, workflowRunId);
Expand Down Expand Up @@ -401,7 +427,7 @@ export default class Go extends EasCommand {
sdkVersion: string | undefined,
tmpDir: string,
vcsClient: Client
): Promise<{ workflowUrl: string; workflowRunId: string }> {
): Promise<{ workflowUrl: string; workflowRunId: string; sdkVersion: string }> {
const account = ensureActorHasPrimaryAccount(actor);

const repackConfig = await WorkflowRunQuery.expoGoRepackConfigurationAsync(graphqlClient, {
Expand Down Expand Up @@ -451,7 +477,7 @@ export default class Go extends EasCommand {

const workflowUrl = getWorkflowRunUrl(account.name, deriveBundleIdSlug(bundleId), result.id);

return { workflowUrl, workflowRunId: result.id };
return { workflowUrl, workflowRunId: result.id, sdkVersion: repackConfig.sdkVersion };
}

private async monitorWorkflowJobsAsync(
Expand Down
Loading