Compare commits

...
Author SHA1 Message Date
Tomasz Czajęcki 822b703cbe [eas-cli] Remove output suppression from credential setup in go command
SetUpBuildCredentials can trigger interactive prompts (e.g. certificate
revocation selection when Apple's cert limit is hit). Wrapping it in
withSuppressedOutputAsync hid these prompts, causing an invisible prompt
that led to an infinite retry loop.
2026-03-17 23:06:24 +02:00
Tomasz Czajęcki ae6e9d86c6 [eas-cli] Remove local project generation from go command
The `go` command previously created a full local project in /tmp
(app.json, eas.json, package.json, npm install, git init) to satisfy
credentials setup and workflow upload. This was unnecessary because:

- Credential setup (SetUpBuildCredentials, SetUpAscApiKey, SetUpPushKey)
  operates entirely through GraphQL/Apple APIs and never reads from disk
- CredentialsContext only needs an in-memory ExpoConfig and projectId
- The workflow can generate its own project independently

Now the command:
- Constructs ExpoConfig in-memory instead of writing/reading app.json
- Passes a no-op VcsClient since credential actions don't use it
- No longer runs npm install or git init
- Creates minimal temp files only for the workflow upload tarball
- No longer changes process.cwd() or preserves/cleans up project dirs
2026-03-17 18:48:35 +02:00
Douglas Lowder d5a3fc06f9 Enable manual testing of changelog GitHub workflow (#3500)
# Why

There have been failures recently with the GitHub CHANGELOG check. Adjusting the workflow so that it can be manually tested.

# How

Add `workflow_dispatch:`

# Test Plan

CI should pass
2026-03-12 10:57:58 -07:00
Matheus MichelsandDouglas Lowder c51ac27e25 [eas-cli] add skip-build-if-not-found option to eas build:dev (#3495)
* add skip-build-if-not-found to build:dev

* add changelog entry for build:dev skip flag

* Remove duplicate line in CHANGELOG

* Adjust CHANGELOG

* Bump add-pr-comment workflow dep

---------

Co-authored-by: Douglas Lowder <[email protected]>
2026-03-11 17:57:44 -07:00
4 changed files with 233 additions and 211 deletions

No files matched your search

+2 -1
View File
@@ -1,6 +1,7 @@
name: CHANGELOG.md PR check
on:
workflow_dispatch:
pull_request:
types:
- opened
@@ -29,7 +30,7 @@ jobs:
with:
script: |
core.setFailed('Please add a changelog entry!')
- uses: mshick/add-pr-comment@v2.1.0
- uses: mshick/add-pr-comment@v2.8.2
if: always()
with:
message-id: changelog-entry-check
+2 -1
View File
@@ -8,6 +8,8 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- Add `skip-build-if-not-found` flag to `eas build:dev`. ([#3495](https://github.com/expo/eas-cli/pull/3495) by [@mmichels-brex](https://github.com/mmichels-brex))
### 🐛 Bug fixes
### 🧹 Chores
@@ -1673,7 +1675,6 @@ This is the log of notable changes to EAS CLI and related packages.
- Add rollback disambiguation command. ([#2004](https://github.com/expo/eas-cli/pull/2004) by [@wschurman](https://github.com/wschurman))
- Detect devices that fail to be provisioned, list them to the user and show the explanation message with the link to the devices page to check actual status. ([#2011](https://github.com/expo/eas-cli/pull/2011) by [@radoslawkrzemien](https://github.com/radoslawkrzemien))
- Add info to EAS Update asset upload process about asset counts and limits. ([#2013](https://github.com/expo/eas-cli/pull/2013) by [@wschurman](https://github.com/wschurman))
- .nvmrc support for setting node version. ([#1954](https://github.com/expo/eas-cli/pull/1954) by [@khamilowicz](https://github.com/khamilowicz))
### 🐛 Bug fixes
+10 -1
View File
@@ -43,6 +43,10 @@ export default class BuildDev extends EasCommand {
description: `Name of the build profile from eas.json. It must be a profile allowing to create emulator/simulator internal distribution dev client builds. The "${DEFAULT_EAS_BUILD_RUN_PROFILE_NAME}" build profile will be selected by default.`,
helpValue: 'PROFILE_NAME',
}),
'skip-build-if-not-found': Flags.boolean({
description: 'Skip build if no successful build with matching fingerprint is found.',
default: false,
}),
};
static override contextDefinition = {
@@ -128,10 +132,15 @@ export default class BuildDev extends EasCommand {
await this.startDevServerAsync({ projectDir, platform });
return;
} else {
Log.warn('Artifacts for this build expired. New build will be started.');
Log.warn('Artifacts for this build expired.');
}
}
if (flags['skip-build-if-not-found']) {
Log.log('No successful build with matching fingerprint found. Skipping build.');
return;
}
Log.log('🚀 No successful build with matching fingerprint found. Starting a new build...');
const previousBuildsForSelectedProfile = await this.getBuildsAsync({
+219 -208
View File
@@ -1,14 +1,14 @@
import { ExpoConfig } from '@expo/config';
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 * as tar from 'tar';
import { v4 as uuidv4 } from 'uuid';
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';
@@ -29,14 +29,14 @@ 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 { getTmpDirectory } from '../utils/paths';
import { sleepAsync } from '../utils/promise';
import { resolveVcsClient } from '../vcs';
import { Client as VcsClient } from '../vcs/vcs';
import { uploadAccountScopedFileAtPathToGCSAsync } from '../uploads';
import { AccountUploadSessionType } from '../graphql/generated';
import NoVcsClient from '../vcs/clients/noVcs';
// Expo Go release info - update when releasing a new version
const EXPO_GO_SDK_VERSION = '55';
@@ -191,89 +191,71 @@ export default class Go extends EasCommand {
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 setupSpinner = ora('Setting up project...').start();
const originalCwd = process.cwd();
process.chdir(projectDir);
// Step 1: Create/link EAS project
const projectId = await withSuppressedOutputAsync(() =>
this.ensureEasProjectAsync(graphqlClient, actor, bundleId)
);
const setupSpinner = ora('Creating project...').start();
// Step 2: Build in-memory Expo config for credentials
const exp = this.buildExpoConfig(slug, bundleId, appName, projectId);
// 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 3: Set up iOS credentials and create App Store Connect app
const ascApp = await this.setupCredentialsAsync(
exp,
projectId,
bundleId,
appName,
graphqlClient,
actor,
analytics,
flags.credentials,
() => {
setupSpinner.stop();
Log.markFreshLine();
}
);
// Step 5: Set up TestFlight group (silently)
try {
await setupTestFlightAsync(ascApp);
} catch {
// Non-fatal: TestFlight group setup failure shouldn't block the user
}
// Step 4: Start workflow and monitor progress
const { workflowUrl, workflowRunId } = await this.runWorkflowAsync(
graphqlClient,
projectId,
actor,
slug,
bundleId,
appName,
ascApp.id
);
Log.withTick(`Workflow started: ${chalk.cyan(workflowUrl)}`);
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);
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' }
)}`
);
}
private generateBundleId(actor: Actor): string {
@@ -287,87 +269,57 @@ export default class Go extends EasCommand {
return `com.${sanitizedUsername || 'app'}.expogo${EXPO_GO_SDK_VERSION}`;
}
private async createProjectFilesAsync(
projectDir: string,
private buildExpoConfig(
slug: string,
bundleId: string,
appName: string
): Promise<void> {
const slug = bundleId.split('.').pop() || 'custom-expo-go';
appName: string,
projectId: string
): ExpoConfig {
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,
},
return {
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,
},
],
},
},
extra: {
eas: {
projectId,
build: {
experimental: {
ios: {
appExtensions: [
{
targetName: 'ExpoNotificationServiceExtension',
bundleIdentifier: extensionBundleId,
},
],
},
},
},
},
},
_internal: {
isDebug: false,
projectRoot: '.',
dynamicConfigPath: null as any,
staticConfigPath: null as any,
packageJsonPath: null as any,
},
sdkVersion: `${EXPO_GO_SDK_VERSION}.0.0`,
platforms: ['ios'],
};
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';
@@ -380,42 +332,37 @@ export default class Go extends EasCommand {
);
if (existingProjectId) {
await saveProjectIdToAppConfigAsync(projectDir, existingProjectId);
return existingProjectId;
}
const projectId = await AppMutation.createAppAsync(graphqlClient, {
return await AppMutation.createAppAsync(graphqlClient, {
accountId: account.id,
projectName: slug,
});
await saveProjectIdToAppConfigAsync(projectDir, projectId);
return projectId;
}
private async setupCredentialsAsync(
projectDir: string,
exp: ExpoConfig,
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,
projectDir: process.cwd(),
user: actor,
graphqlClient,
analytics,
vcsClient,
vcsClient: new NoVcsClient(),
});
onBeforeAppleAuth?.();
@@ -437,38 +384,23 @@ export default class Go extends EasCommand {
},
];
const ascApp = await withSuppressedOutputAsync(async () => {
await new SetUpBuildCredentials({
app,
targets,
distribution: 'store',
}).runAsync(credentialsCtx);
await new SetUpBuildCredentials({
app,
targets,
distribution: 'store',
}).runAsync(credentialsCtx);
const appLookupParams = {
...app,
bundleIdentifier: bundleId,
};
await new SetUpAscApiKey(appLookupParams, AppStoreApiKeyPurpose.SUBMISSION_SERVICE).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;
const ascApp = await ensureAppExistsAsync(userAuthCtx, {
name: appName,
bundleIdentifier: bundleId,
});
// Set up push notifications (outside suppressed block so prompts are visible)
@@ -494,36 +426,115 @@ export default class Go extends EasCommand {
private async runWorkflowAsync(
graphqlClient: ExpoGraphqlClient,
projectDir: string,
projectId: string,
actor: Actor,
vcsClient: VcsClient
slug: string,
bundleId: string,
appName: string,
ascAppId: string
): Promise<{ workflowUrl: string; workflowRunId: string }> {
const account = actor.accounts[0];
const extensionBundleId = `${bundleId}.ExpoNotificationServiceExtension`;
const { projectArchiveBucketKey, easJsonBucketKey, packageJsonBucketKey } =
await withSuppressedOutputAsync(async () => {
const { projectArchiveBucketKey } = await uploadAccountScopedProjectSourceAsync({
graphqlClient,
vcsClient,
accountId: account.id,
});
const tmpDir = path.join(getTmpDirectory(), `eas-go-upload-${uuidv4()}`);
await fs.mkdirp(tmpDir);
const { fileBucketKey: easJsonBucketKey } = await uploadAccountScopedFileAsync({
graphqlClient,
accountId: account.id,
filePath: path.join(projectDir, 'eas.json'),
maxSizeBytes: 1024 * 1024,
});
try {
// Write minimal project files for upload
const easJson = {
cli: { version: '>= 5.0.0' },
build: {
production: {
distribution: 'store',
credentialsSource: 'remote',
},
},
submit: {
production: {
ios: { ascAppId },
},
},
};
const { fileBucketKey: packageJsonBucketKey } = await uploadAccountScopedFileAsync({
graphqlClient,
accountId: account.id,
filePath: path.join(projectDir, 'package.json'),
maxSizeBytes: 1024 * 1024,
});
const packageJson = {
name: slug,
version: '1.0.0',
dependencies: { expo: '~54.0.0' },
};
return { projectArchiveBucketKey, easJsonBucketKey, packageJsonBucketKey };
const appJson = {
expo: {
name: appName,
slug,
version: EXPO_GO_APP_VERSION,
ios: {
bundleIdentifier: bundleId,
buildNumber: EXPO_GO_BUILD_NUMBER,
config: { usesNonExemptEncryption: false },
},
extra: {
eas: {
projectId,
build: {
experimental: {
ios: {
appExtensions: [
{
targetName: 'ExpoNotificationServiceExtension',
bundleIdentifier: extensionBundleId,
},
],
},
},
},
},
},
},
};
const easJsonPath = path.join(tmpDir, 'eas.json');
const packageJsonPath = path.join(tmpDir, 'package.json');
const appJsonPath = path.join(tmpDir, 'app.json');
await fs.writeJson(easJsonPath, easJson, { spaces: 2 });
await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 });
await fs.writeJson(appJsonPath, appJson, { spaces: 2 });
// Create tarball directly (no git required)
const tarPath = path.join(getTmpDirectory(), `${uuidv4()}.tar.gz`);
await tar.create({ cwd: tmpDir, file: tarPath, prefix: 'project', gzip: true }, ['.']);
const projectArchiveBucketKey = await uploadAccountScopedFileAtPathToGCSAsync(
graphqlClient,
{
accountId: account.id,
type: AccountUploadSessionType.WorkflowsProjectSources,
path: tarPath,
handleProgressEvent: () => {},
}
);
await fs.remove(tarPath);
const { fileBucketKey: easJsonBucketKey } = await uploadAccountScopedFileAsync({
graphqlClient,
accountId: account.id,
filePath: easJsonPath,
maxSizeBytes: 1024 * 1024,
});
const { fileBucketKey: packageJsonBucketKey } = await uploadAccountScopedFileAsync({
graphqlClient,
accountId: account.id,
filePath: packageJsonPath,
maxSizeBytes: 1024 * 1024,
});
return { projectArchiveBucketKey, easJsonBucketKey, packageJsonBucketKey };
} finally {
await fs.remove(tmpDir);
}
});
const { id: workflowRunId } = await WorkflowRunMutation.createExpoGoRepackWorkflowRunAsync(