Compare commits

..
Author SHA1 Message Date
evanbacon 329ddccd61 Merge branch 'main' into @evanbacon/cli/auto-bundle-id 2026-03-30 11:11:21 -07:00
Evan BaconandClaude Opus 4.6 4e95a29797 Support non-interactive metadata commands with ASC API key auth (#3548)
* Support non-interactive metadata commands

Add a --non-interactive flag to metadata pull/push and thread nonInteractive through context and helper calls. Propagate the flag to ensureProjectConfiguredAsync, getProfilesAsync, getAppStoreAuthAsync, downloadMetadataAsync and uploadMetadataAsync so prompts are skipped or fail fast when non-interactive.

Improve App Store Connect auth resolution: try ASC API key from submit profile, then EAS credentials service (via GraphQL lookup), then environment variables; fall back to interactive cookie auth only when allowed. In non-interactive mode, throw a clear error if no ASC API key is available. Also update upload/download to auto-overwrite in non-interactive mode and make config validation in upload fail instead of prompting.

Minor: import/fs usage and GraphQL client types added, small logging improvements and error messages to aid non-interactive workflows.

* Add metadata tests and enhance App Store auth

Add unit tests for metadata auth, download, and upload flows and update App Store auth resolution to include team metadata.

- New tests: src/metadata/__tests__/auth.test.ts, download.test.ts, upload.test.ts covering API key resolution, interactive vs non-interactive behavior, telemetry, and validation prompts.
- auth.ts: refactor tryResolveAscApiKeyAsync to return ascApiKey plus optional teamId/teamName, read profile-provided keyP8 and prefer profile keys over stored keys, and include teamId/teamName/teamType (defaulting to COMPANY_OR_ORGANIZATION) when calling ensureAuthenticatedAsync.

These changes improve handling of ASC API keys and team info during authentication and add coverage for metadata CLI flows and edge cases.

* Use fs.promises.readFile instead of fs-extra

Replace import of fs-extra with Node's built-in fs and call fs.promises.readFile when loading the ASC API key. Update tests to mock fs.promises.readFile accordingly. This removes the dependency on fs-extra for this code path and aligns the readFile usage with the fs.promises API.

* [eas-cli] Add CHANGELOG entry for metadata non-interactive support

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-30 11:09:41 -07:00
Stanisław Chmiela 66d8e3d6b8 Add test precompiled iOS dependency preparation support (#3556)
## Summary
- export precompiled modules helpers from `@expo/build-tools` and start preparation when flagged builds enable precompiled modules
- wait for precompiled module preparation before running `pod install` in both build-tools pod flows
- set worker env and config for precompiled module downloads, including proxy-aware download handling and coverage for the new behavior

## Testing
<img width="812" height="325" alt="Zrzut ekranu 2026-03-28 o 22 26 35" src="https://github.com/user-attachments/assets/9e4deb10-d233-4bb6-ac5d-48cef31c17ed" />

<img width="1032" height="548" alt="Zrzut ekranu 2026-03-28 o 22 27 27" src="https://github.com/user-attachments/assets/a6bbcb75-4845-4dce-9f31-5097ece800ae" />
2026-03-30 19:37:41 +02:00
Stanisław Chmiela 5e4a88094f [build-tools] Disable Android emulator window transitions (#3557)
## Summary
- disable Android emulator window and transition animation scales once the emulator is booted and ready
- apply the setting for the initial emulator and any cloned emulator instances used by the start step
- add unit coverage for the new adb settings commands and the startup orchestration

## Why
Maestro test runs already avoid the emulator boot animation, but Android window and transition animations still run after boot. Those system animations add avoidable UI transition latency and can introduce test flake without affecting in-app animator timing.

See https://bsky.app/profile/rdperottoni.bsky.social/post/3mfnhnt77mk2f.

## Validation
- `yarn fmt packages/build-tools/src/utils/AndroidEmulatorUtils.ts packages/build-tools/src/steps/functions/startAndroidEmulator.ts packages/build-tools/src/steps/functions/__tests__/startAndroidEmulator.test.ts packages/build-tools/src/utils/__tests__/AndroidEmulatorUtils.test.ts`
- `yarn --cwd packages/build-tools jest-unit -- runInBand src/steps/functions/__tests__/startAndroidEmulator.test.ts src/utils/__tests__/AndroidEmulatorUtils.test.ts`
2026-03-30 19:04:46 +02:00
Kudo Chien 7a4c81f4e3 [build-tools] resolve repack version (#3549) 2026-03-28 01:48:50 +08:00
Mustafa Abban c9cdfd1eb5 [build-tools] support Gradle build cache for Android builds (#3510)
* gradle cache

* cache modules-2

* cache modules-2

* proper path

* set properties, rename

* rem modules-2

* set org.gradle.cache.cleanup to ALWAYS

* set org.gradle.cache.cleanup to ALWAYS

* consolidate caches in restore/save step

* restore gradle

* cleanup nullthrows

* always set properties if env set, cleanup

* consistent path, clean log

* modify gradle props in trycatch

* eas-cache-cleanup script

* shorter prune
2026-03-26 12:34:18 -05:00
Quinlan Jung 🦥 8115dc8766 [eas-cli] bump apple-utils (#3545)
* [eas-cli] bump apple-utils

* Temporary Commit at 3/25/2026, 6:42:37 PM

* Temporary Commit at 3/25/2026, 7:18:01 PM
2026-03-25 19:27:14 -07:00
Byron Karlen 113467c91d Reapply "Use auth code flow (#3398)" (#3422) (#3542)
This reverts commit 0fdf2dd44a.
2026-03-25 12:32:58 -07:00
Phil Pluckthun be967f4b9a [build-tools] Read app config with expo config CLI invocation before falling back to @expo/config (#3536)
* Add `expo config` app config reading to build-tools

* Align with other CLI invocations

* Switch to turtle-spawn for consistency

* Update `appConfig` reading for async calls

* Add CHANGELOG entry

* Update worker

* Fix typo

* Add unit tests
2026-03-25 15:13:48 +00:00
Stanisław Chmiela 660303b0c3 [steps] Use counter-based generated step ids (#3541)
## Summary
- replace UUID-based autogenerated step ids with a counter in `BuildStep.getNewId()`
- keep explicit user-defined ids unchanged
- rename the step-id test helper so tests no longer talk about UUIDs

## Testing
- yarn --cwd packages/steps test --runInBand BuildStep-test.ts BuildFunction-test.ts BuildConfigParser-test.ts StepsConfigParser-test.ts AbstractConfigParser-test.ts
- yarn --cwd packages/steps typecheck
2026-03-25 15:02:30 +01:00
Stanisław Chmiela 5514289648 [steps][local-build-plugin] Remove buildStepInternalId (#3540)
## Summary
- remove `buildStepInternalId` emission from `BuildStep`
- stop generating the extra UUID entirely
- drop the now-unused field from local build log sanitization

Just want to make logs smaller.

## Testing
- yarn --cwd packages/steps test --runInBand BuildStep-test.ts
- yarn --cwd packages/steps typecheck
2026-03-25 13:36:12 +00:00
Stanisław Chmiela 7924e33e96 [eas-cli] Key workflow logs by buildStepId (#3539)
## Summary
- key workflow log groups by `buildStepId` instead of display text (this might cause two steps named the same to appear as one)
- treat `buildStepInternalId` as nonexistent in the CLI parser (want to remove it)
- keep display names for prompts and status output while preserving stable selection keys

## Testing
- yarn --cwd packages/eas-cli test src/__tests__/commands/workflow-logs-test.ts src/commandUtils/workflow/__tests__/utils-test.ts
- yarn --cwd packages/eas-cli typecheck
2026-03-25 14:31:11 +01:00
Stanisław Chmiela e699a06c07 [steps] Simplify name/displayName handling (#3538)
## Summary
- remove `BuildStep.getDisplayName` and inline display-name selection at the shell/function callsites
- simplify shell step naming to `name ?? first non-empty command line ?? command`
- update step tests to use explicit display names instead of the removed helper

## Verification
- yarn --cwd packages/steps test --runInBand BuildStep-test.ts BuildFunction-test.ts BuildWorkflowValidator-test.ts BuildStepOutput-test.ts BuildStepInput-test.ts
- yarn --cwd packages/steps typecheck
2026-03-25 13:42:41 +01:00
Stanisław Chmiela ce819c1a14 [eas-cli] Add optional Sentry error reporting (#3537)
## Summary
- add a dedicated `packages/eas-cli/src/sentry.ts` module that initializes `@sentry/node` for eas-cli
- capture command failures from `EasCommand`, including `EasCommandError`, and flush Sentry on exit
- propagate `EAS_CLI_SENTRY_DSN` into the worker child-process environment so worker-run eas-cli commands can report errors too

https://exponent-internal.slack.com/archives/C06EFBQK3B7/p1773432161848589?thread_ts=1773417343.042269&cid=C06EFBQK3B7

## Validation
- `yarn test src/commandUtils/__tests__/EasCommand-test.ts --runInBand`
- `yarn test:unit --runInBand src/__unit__/env.test.ts`
- `yarn build` in `packages/eas-cli`
- `node ./bin/run --version`
- `node ./bin/run --help`
- `node ./bin/run analytics`
- `EAS_CLI_SENTRY_DSN=https://[email protected]/1 node ./bin/run analytics`

## Manual Sentry Verification
- ran the compiled CLI against the test Sentry project `expo-stanley-test/test-local` with a real DSN
- forced an `EasCommandError` from `eas analytics` and confirmed the CLI failed locally as expected
- verified that Sentry ingested the event and created issue `TEST-LOCAL-1`
- confirmed the event in Sentry at `https://expo-stanley-test.sentry.io/issues/TEST-LOCAL-1`
2026-03-25 13:37:09 +01:00
Quinlan Jung 🦥 92ad6b5ba3 Revert "[eas-cli][build-tools][worker] bump @expo/config and @expo/config-plugins (#3527)" (#3534)
This reverts commit d63d51f548.
2026-03-24 17:44:58 -07:00
Quinlan Jung 🦥 d63d51f548 [eas-cli][build-tools][worker] bump @expo/config and @expo/config-plugins (#3527)
* [eas-cli][build-tools][worker] bump @expo/config and @expo/config-plugins

* Temporary Commit at 3/23/2026, 5:55:24 PM

* Temporary Commit at 3/23/2026, 6:10:13 PM
2026-03-24 11:56:19 -07:00
Stanisław Chmiela bfb12f0317 [repo] Standardize clean scripts on rimraf (#3520)
## Summary
- standardize workspace clean/rebuild/prepack deletion steps on `rimraf`
- add `rimraf` where package-local scripts invoke it directly
- keep `worker` and root clean flows working from a clean install

Also adds Codex config for new workspace.

And the problem it solves is that sometimes `yarn clean` would fail if there were no `*.tsbuildinfo` files. Something weird about zsh in package.json

## Validation
- mise exec -- yarn clean
- mise exec -- yarn install --immutable
- mise exec -- yarn build
- mise exec -- yarn typecheck
- mise exec -- yarn clean (in packages/worker)
- mise exec -- yarn clean (in packages/logger)
2026-03-23 17:33:02 +01:00
Douglas LowderandClaude Opus 4.6 b162162007 [eas-cli] Fix workflow:logs for builds using built-in EAS build steps (#3523)
# Why

Logs from EAS build jobs using the built-in flow are in a different form than those from build jobs using custom YAML. Because of this, `eas workflow:logs` and `eas workflow:status` were not working correctly for workflow runs with these jobs.

# How

Analyze the differences in format, and modify the utils that parse logs to handle both formats.

# Test Plan

- Tested with recent workflow runs from expo/expo
- Unit tests updated

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-20 08:11:33 +01:00
Stanisław Chmiela 6ed7e14a69 [eas-cli] Update code for the new PartnerActor (#3521)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

After new union type addition GQL fails TSC in `eas-cli`.

# How

Added fetching of `username` for `PartnerActor` and used it where we would previously use `username` of a `User`.

Removed `getUsernameForBuildMetadataAndBuildJob` in favor of `getActorUsername`, I don't think these will every be different?

Removed 

# Test Plan

CI should pass.
2026-03-19 15:31:43 +01:00
Kudo Chien 37910b17e1 [build-tools] add js_bundle_only to repack function (#3517)
# Why

close ENG-17737

# How

adding `js_bundle_only` support to repack
2026-03-19 20:01:06 +08:00
Stanisław Chmiela 0d3c56f4d6 [build-tools] Improve ASC app ID error guidance (#3512)
## Summary
- surface example visible App Store Connect apps for invalid bundle-id upload failures
- reuse shared ASC app summary formatting for not-found and invalid-bundle-id errors
- add unit coverage for visible app summaries and message contents

Should help debug https://discord.com/channels/695411232856997968/1483454279204343910/1483492265312780340

## Testing
- ./node_modules/.bin/jest --config packages/build-tools/jest/unit-config.ts packages/build-tools/src/steps/utils/ios/__tests__/AscApiUtils.test.ts packages/build-tools/src/steps/functions/__tests__/uploadToAsc.test.ts
- ./node_modules/.bin/tsc -p packages/build-tools/tsconfig.json --noEmit
2026-03-17 16:59:49 +01:00
Stanisław Chmiela bc8e60d385 [build-tools] Handle ASC SDK version issue (90725) (#3464)
## Why
UNKNOWN submit failures include App Store Connect SDK compatibility error `90725`.

This should be surfaced as actionable EAS guidance (rebuild with newer iOS SDK/Xcode image) instead of generic unknown.

## How
- add `90725` detection in `upload_to_asc` failed-state handling
- throw `EAS_UPLOAD_TO_ASC_SDK_VERSION_ISSUE` with EAS-specific remediation
- include docs URL to iOS build image/Xcode configuration docs
- add unit tests for `isSdkVersionIssueError`

## Examples
- https://expo.dev/uuid/019cbbe7-9d71-7c90-80b5-a98870478265
- https://expo.dev/uuid/019cbf52-3cbd-7491-b35e-c3fc2f24afc9
- https://expo.dev/uuid/019cbf4f-41d9-7ab7-8e85-e53596526a82

## Test Plan
- `yarn --cwd packages/build-tools jest-unit src/steps/functions/__tests__/uploadToAsc.test.ts`
- `yarn --cwd packages/build-tools typecheck`
- `yarn fmt`
2026-03-17 16:35:00 +01:00
Expo CI 99b8d67d3f update CHANGELOG.md 2026-03-16 20:24:39 +00:00
evanbacon 1db7281e8b Merge branch 'main' into @evanbacon/cli/auto-bundle-id 2026-02-14 14:12:30 -08:00
evanbacon 1e8a0acabe Update CHANGELOG.md 2026-02-14 14:12:18 -08:00
evanbaconandClaude Opus 4.5 6bfb8af57f Fix tests and add changelog entry
- Wrap suggestion lookup in try-catch for non-interactive mode
- Add changelog entry for the new feature

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-02-12 15:16:00 -08:00
evanbacon 231f29ecf1 Non-interactive auto-generate bundle & package
Allow iOS bundle identifier and Android application id to be auto-generated in non-interactive mode instead of immediately throwing. If a suggested identifier is available (from AppQuery or the counterpart platform field) it will be used and logged; if not, an error is still thrown. Tests updated to cover auto-generation, cross-platform fallbacks, and the error case when no valid suggestion can be produced.
2026-02-12 15:15:23 -08:00
110 changed files with 7919 additions and 9152 deletions

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
version = 1
name = "eas-cli"
[setup]
script = '''
mise trust
mise install
yarn
yarn typecheck
'''
+1
View File
@@ -16,6 +16,7 @@
"packages/eas-cli/README.md",
"packages/eas-cli/schema/**",
"packages/eas-cli/graphql.schema.json",
"packages/eas-cli/src/graphql/generated.ts",
"packages/build-tools/schema.graphql"
]
}
+1
View File
@@ -3,3 +3,4 @@
When working with this repository follow instructions from CLAUDE.md.
- [./CLAUDE.md](./CLAUDE.md)
- Before committing, run the formatter on modified files.
+15 -2
View File
@@ -8,13 +8,26 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- Auto-detect non-interactive mode in CI and non-TTY environments. ([#3486](https://github.com/expo/eas-cli/pull/3486) by [@EvanBacon](https://github.com/EvanBacon))
- 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))
- Auto-generate `ios.bundleIdentifier` and `android.package` in non-interactive mode when not set in app config. ([#3399](https://github.com/expo/eas-cli/pull/3399) by [@evanbacon](https://github.com/evanbacon))
- 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))
- [eas-cli] Add `--non-interactive` flag to `metadata:push` and `metadata:pull` commands with ASC API Key auth support. ([#3548](https://github.com/expo/eas-cli/pull/3548) by [@EvanBacon](https://github.com/EvanBacon))
### 🐛 Bug fixes
- [eas-cli] Fix workflow:logs for builds using built-in EAS build steps. ([#3523](https://github.com/expo/eas-cli/pull/3523) by [@douglowder](https://github.com/douglowder))
- [build-tools][worker] Read Expo app config with `expo config` CLI invocation before falling back to `@expo/config` ([#3536](https://github.com/expo/eas-cli/pull/3536) by [@kitten](https://github.com/kitten))
### 🧹 Chores
- Bump apple-utils. ([#3545](https://github.com/expo/eas-cli/pull/3545) by [@quinlanj](https://github.com/quinlanj))
## [18.4.0](https://github.com/expo/eas-cli/releases/tag/v18.4.0) - 2026-03-16
### 🎉 New features
- Auto-detect non-interactive mode in CI and non-TTY environments. ([#3486](https://github.com/expo/eas-cli/pull/3486) by [@EvanBacon](https://github.com/EvanBacon))
- 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))
## [18.3.0](https://github.com/expo/eas-cli/releases/tag/v18.3.0) - 2026-03-10
### 🎉 New features
+2 -1
View File
@@ -17,7 +17,7 @@
"lint-changelog": "./scripts/bin/run lint-changelog",
"release": "lerna run rebuild --concurrency 1 && ./scripts/bin/run release",
"test": "lerna run test",
"clean": "lerna run clean && rm -rf node_modules coverage"
"clean": "lerna run clean && rimraf node_modules coverage"
},
"devDependencies": {
"@types/jest": "29.5.11",
@@ -30,6 +30,7 @@
"oxfmt": "0.28.0",
"oxlint": "1.43.0",
"oxlint-tsgolint": "0.11.1",
"rimraf": "3.0.2",
"ts-jest": "29.1.1",
"typescript": "5.5.4"
},
+4 -3
View File
@@ -24,12 +24,12 @@
"prebuild": "yarn gql",
"build": "tsc",
"typecheck": "tsc",
"prepack": "rm -rf dist *.tsbuildinfo && yarn gql && tsc -p tsconfig.build.json",
"prepack": "rimraf dist \"*.tsbuildinfo\" && yarn gql && tsc -p tsconfig.build.json",
"jest-unit": "jest --config jest/unit-config.ts",
"jest-integration": "jest --config jest/integration-config.ts",
"jest-unit-watch": "jest --config jest/unit-config.ts --watch",
"jest-integration-watch": "jest --config jest/integration-config.ts --watch",
"clean": "rm -rf node_modules dist coverage *.tsbuildinfo",
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\"",
"gql": "gql.tada generate-schema ${API_SERVER_URL:-https://api.expo.dev}/graphql --output ./schema.graphql && gql.tada generate-output",
"gql:local": "API_SERVER_URL=http://api.expo.test yarn gql",
"test": "yarn jest-unit"
@@ -75,7 +75,7 @@
"zod": "^4.3.5"
},
"devDependencies": {
"@expo/repack-app": "~0.2.5",
"@expo/repack-app": "~0.4.1",
"@types/fs-extra": "^11.0.4",
"@types/jest": "^29.5.12",
"@types/lodash": "^4.17.4",
@@ -91,6 +91,7 @@
"jest": "^29.7.0",
"memfs": "^4.17.1",
"nock": "13.4.0",
"rimraf": "3.0.2",
"ts-jest": "^29.1.4",
"ts-mockito": "^2.6.1",
"tslib": "^2.6.3",
+19 -3
View File
@@ -14,8 +14,12 @@ import { eagerBundleAsync, shouldUseEagerBundle } from '../common/eagerBundle';
import { prebuildAsync } from '../common/prebuild';
import { setupAsync } from '../common/setup';
import { Artifacts, BuildContext, SkipNativeBuildError } from '../context';
import { cacheStatsAsync, restoreCcacheAsync } from '../steps/functions/restoreBuildCache';
import { saveCcacheAsync } from '../steps/functions/saveBuildCache';
import {
cacheStatsAsync,
restoreCcacheAsync,
restoreGradleCacheAsync,
} from '../steps/functions/restoreBuildCache';
import { saveCcacheAsync, saveGradleCacheAsync } from '../steps/functions/saveBuildCache';
import {
injectConfigureVersionGradleConfig,
injectCredentialsGradleConfig,
@@ -81,6 +85,12 @@ async function buildAsync(ctx: BuildContext<Android.Job>): Promise<void> {
env: ctx.env,
secrets: ctx.job.secrets,
});
await restoreGradleCacheAsync({
logger: ctx.logger,
workingDirectory,
env: ctx.env,
secrets: ctx.job.secrets,
});
});
await ctx.runBuildPhase(BuildPhase.POST_INSTALL_HOOK, async () => {
@@ -93,7 +103,7 @@ async function buildAsync(ctx: BuildContext<Android.Job>): Promise<void> {
return await resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync({
cwd: ctx.getReactNativeProjectDirectory(),
logger: ctx.logger,
appConfig: ctx.appConfig,
appConfig: await ctx.appConfig,
platform: ctx.job.platform,
workflow: ctx.job.type,
env: ctx.env,
@@ -196,6 +206,12 @@ async function buildAsync(ctx: BuildContext<Android.Job>): Promise<void> {
env: ctx.env,
secrets: ctx.job.secrets,
});
await saveGradleCacheAsync({
logger: ctx.logger,
workingDirectory,
env: ctx.env,
secrets: ctx.job.secrets,
});
});
await ctx.runBuildPhase(BuildPhase.CACHE_STATS, async () => {
+1 -1
View File
@@ -103,7 +103,7 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
return await resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync({
cwd: ctx.getReactNativeProjectDirectory(),
logger: ctx.logger,
appConfig: ctx.appConfig,
appConfig: await ctx.appConfig,
platform: ctx.job.platform,
workflow: ctx.job.type,
env: ctx.env,
+4 -3
View File
@@ -113,7 +113,7 @@ export async function setupAsync<TJob extends BuildJob>(ctx: BuildContext<TJob>)
});
await ctx.runBuildPhase(BuildPhase.READ_APP_CONFIG, async () => {
const appConfig = ctx.appConfig;
const appConfig = await ctx.appConfig;
ctx.logger.info('Using app configuration:');
ctx.logger.info(JSON.stringify(appConfig, null, 2));
await validateAppConfigAsync(ctx, appConfig);
@@ -121,12 +121,13 @@ export async function setupAsync<TJob extends BuildJob>(ctx: BuildContext<TJob>)
if (ctx.job.triggeredBy === BuildTrigger.GIT_BASED_INTEGRATION) {
await ctx.runBuildPhase(BuildPhase.EAS_BUILD_INTERNAL, async () => {
if (!ctx.appConfig.ios?.bundleIdentifier && ctx.job.platform === Platform.IOS) {
const appConfig = await ctx.appConfig;
if (!appConfig.ios?.bundleIdentifier && ctx.job.platform === Platform.IOS) {
throw new Error(
'The "ios.bundleIdentifier" is required to be set in app config for builds triggered by GitHub integration. Learn more: https://docs.expo.dev/versions/latest/config/app/#bundleidentifier.'
);
}
if (!ctx.appConfig.android?.package && ctx.job.platform === Platform.ANDROID) {
if (!appConfig.android?.package && ctx.job.platform === Platform.ANDROID) {
throw new Error(
'The "android.package" is required to be set in app config for builds triggered by GitHub integration. Learn more: https://docs.expo.dev/versions/latest/config/app/#package.'
);
+3 -3
View File
@@ -96,7 +96,7 @@ export class BuildContext<TJob extends Job = Job> {
private buildPhase?: BuildPhase;
private buildPhaseSkipped = false;
private buildPhaseHasWarnings = false;
private _appConfig?: ExpoConfig;
private _appConfig?: Promise<ExpoConfig>;
private readonly reportBuildPhaseStats?: (stats: BuildPhaseStats) => void;
public readonly graphqlClient: Client;
@@ -175,14 +175,14 @@ export class BuildContext<TJob extends Job = Job> {
public get packageManager(): PackageManager {
return resolvePackageManager(this.getReactNativeProjectDirectory());
}
public get appConfig(): ExpoConfig {
public get appConfig(): Promise<ExpoConfig> {
if (!this._appConfig) {
this._appConfig = readAppConfig({
projectDir: this.getReactNativeProjectDirectory(),
env: this.env,
logger: this.logger,
sdkVersion: this.metadata?.sdkVersion,
}).exp;
}).then(config => config.exp);
}
return this._appConfig;
}
+7
View File
@@ -19,5 +19,12 @@ export { PackageManager } from './utils/packageManager';
export { findAndUploadXcodeBuildLogsAsync } from './ios/xcodeBuildLogs';
export { Hook, runHookIfPresent } from './utils/hooks';
export {
maybeStartPreparingPrecompiledModules,
PRECOMPILED_MODULES_PATH,
shouldUsePrecompiledDependencies,
startPreparingPrecompiledDependencies,
waitForPrecompiledModulesPreparationAsync,
} from './utils/precompiledModules';
export * from './generic';
@@ -0,0 +1,127 @@
import downloadFile from '@expo/downloader';
import spawn from '@expo/turtle-spawn';
import { mkdirp, mkdtemp, move, remove } from 'fs-extra';
import StreamZip from 'node-stream-zip';
import { createTestIosJob } from '../../__tests__/utils/job';
import { createMockLogger } from '../../__tests__/utils/logger';
import { BuildContext } from '../../context';
import {
startPreparingPrecompiledDependencies,
PRECOMPILED_MODULES_PATH,
} from '../../utils/precompiledModules';
import { installPods } from '../pod';
jest.mock('@expo/downloader');
jest.mock('fs-extra');
jest.mock('@expo/turtle-spawn');
jest.mock('node-stream-zip', () => ({
__esModule: true,
default: {
async: jest.fn(),
},
}));
describe(installPods.name, () => {
const extract = jest.fn();
const close = jest.fn();
beforeEach(() => {
jest.mocked(remove).mockImplementation(async () => undefined as any);
jest.mocked(mkdirp).mockImplementation(async () => undefined as any);
jest.mocked(move).mockImplementation(async () => undefined as any);
let mkdtempCallCount = 0;
jest.mocked(mkdtemp).mockImplementation(async () => {
mkdtempCallCount += 1;
return mkdtempCallCount === 1
? ('/tmp/precompiled-modules-archive' as any)
: ('/tmp/precompiled-modules-staging' as any);
});
extract.mockReset().mockResolvedValue(undefined);
close.mockReset().mockResolvedValue(undefined);
jest.mocked(StreamZip.async).mockImplementation(
() =>
({
extract,
close,
}) as any
);
});
it('waits for precompiled dependencies before running pod install', async () => {
let resolvePreparation: (() => void) | undefined;
const preparationPromise = new Promise<void>(resolve => {
resolvePreparation = resolve;
});
jest.mocked(downloadFile).mockReturnValue(preparationPromise);
const ctx = new BuildContext(
createTestIosJob({
buildCredentials: undefined,
}),
{
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
},
uploadArtifact: jest.fn(),
}
);
startPreparingPrecompiledDependencies(ctx, [
'https://example.com/precompiled-modules-0.zip',
'https://example.com/precompiled-modules-1.zip',
]);
jest.mocked(spawn).mockResolvedValue({} as any);
const installPodsPromise = installPods(ctx, {});
await Promise.resolve();
expect(spawn).not.toHaveBeenCalled();
resolvePreparation?.();
await installPodsPromise;
expect(spawn).toHaveBeenCalledWith(
'pod',
['install'],
expect.objectContaining({
cwd: '/workingdir/build/ios',
})
);
expect(mkdirp).toHaveBeenCalledWith(PRECOMPILED_MODULES_PATH);
});
it('continues with pod install when precompiled dependencies preparation fails', async () => {
jest.mocked(downloadFile).mockRejectedValue(new Error('download failed'));
const ctx = new BuildContext(
createTestIosJob({
buildCredentials: undefined,
}),
{
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
},
uploadArtifact: jest.fn(),
}
);
startPreparingPrecompiledDependencies(ctx, ['https://example.com/precompiled-modules-0.zip']);
jest.mocked(spawn).mockResolvedValue({} as any);
await expect(installPods(ctx, {})).resolves.toEqual({
spawnPromise: expect.any(Promise),
});
expect(spawn).toHaveBeenCalledWith(
'pod',
['install'],
expect.objectContaining({
cwd: '/workingdir/build/ios',
})
);
});
});
+10
View File
@@ -3,11 +3,21 @@ import spawn, { SpawnOptions, SpawnPromise, SpawnResult } from '@expo/turtle-spa
import path from 'path';
import { BuildContext } from '../context';
import { waitForPrecompiledModulesPreparationAsync } from '../utils/precompiledModules';
export async function installPods<TJob extends Ios.Job>(
ctx: BuildContext<TJob>,
{ infoCallbackFn }: SpawnOptions
): Promise<{ spawnPromise: SpawnPromise<SpawnResult> }> {
try {
await waitForPrecompiledModulesPreparationAsync();
} catch (err) {
ctx.logger.warn(
{ err },
'Precompiled dependencies were not prepared successfully, continuing with pod install'
);
}
const iosDir = path.join(ctx.getReactNativeProjectDirectory(), 'ios');
const verboseFlag = ctx.env['EAS_VERBOSE'] === '1' ? ['--verbose'] : [];
@@ -51,7 +51,6 @@ export function createEasMaestroTestFunctionGroup(
steps.push(
new BuildStep(globalCtx, {
id: BuildStep.getNewId(),
name: 'install_app',
displayName: `Install app to Simulator`,
command: `
# shopt -s nullglob is necessary not to try to install
@@ -97,7 +96,6 @@ export function createEasMaestroTestFunctionGroup(
steps.push(
new BuildStep(globalCtx, {
id: BuildStep.getNewId(),
name: 'install_app',
displayName: `Install app to Emulator`,
command: `
# shopt -s globstar is necessary to add /**/ support
@@ -134,7 +132,6 @@ export function createEasMaestroTestFunctionGroup(
steps.push(
new BuildStep(globalCtx, {
id: BuildStep.getNewId(),
name: 'maestro_test',
ifCondition: '${ always() }',
displayName: `maestro test ${flowPath}`,
command: `maestro test ${flowPath}`,
@@ -0,0 +1,59 @@
import spawn from '@expo/turtle-spawn';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { waitForPrecompiledModulesPreparationAsync } from '../../../utils/precompiledModules';
import { createInstallPodsBuildFunction } from '../installPods';
jest.mock('@expo/turtle-spawn');
jest.mock('../../../utils/precompiledModules', () => ({
...jest.requireActual('../../../utils/precompiledModules'),
waitForPrecompiledModulesPreparationAsync: jest.fn(),
}));
describe(createInstallPodsBuildFunction, () => {
it('waits for precompiled dependencies before running pod install', async () => {
jest.mocked(spawn).mockResolvedValue({} as any);
jest.mocked(waitForPrecompiledModulesPreparationAsync).mockResolvedValue(undefined);
const installPods = createInstallPodsBuildFunction();
const globalContext = createGlobalContextMock({});
const buildStep = installPods.createBuildStepFromFunctionCall(globalContext, {
callInputs: {},
});
await buildStep.executeAsync();
expect(waitForPrecompiledModulesPreparationAsync).toHaveBeenCalled();
expect(spawn).toHaveBeenCalledWith(
'pod',
['install'],
expect.objectContaining({
cwd: globalContext.defaultWorkingDirectory,
})
);
});
it('continues with pod install when precompiled dependencies preparation fails', async () => {
jest.mocked(spawn).mockResolvedValue({} as any);
jest
.mocked(waitForPrecompiledModulesPreparationAsync)
.mockRejectedValue(new Error('precompiled dependencies failed'));
const installPods = createInstallPodsBuildFunction();
const globalContext = createGlobalContextMock({});
const buildStep = installPods.createBuildStepFromFunctionCall(globalContext, {
callInputs: {},
});
await expect(buildStep.executeAsync()).resolves.toBeUndefined();
expect(waitForPrecompiledModulesPreparationAsync).toHaveBeenCalled();
expect(spawn).toHaveBeenCalledWith(
'pod',
['install'],
expect.objectContaining({
cwd: globalContext.defaultWorkingDirectory,
})
);
});
});
@@ -26,6 +26,7 @@ jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
cloneAsync: jest.fn(),
startAsync: jest.fn(),
waitForReadyAsync: jest.fn(),
disableWindowAndTransitionAnimationsAsync: jest.fn(),
deleteAsync: jest.fn(),
},
}));
@@ -34,14 +35,15 @@ const mockedSpawn = jest.mocked(spawn);
const mockedRetryAsync = jest.mocked(retryAsync);
const mockedAndroidUtils = jest.mocked(AndroidEmulatorUtils);
function createStep(callInputs?: Record<string, unknown>) {
function createStep(callInputs?: Record<string, unknown>, envOverrides?: NodeJS.ProcessEnv) {
const logger = createMockLogger();
const fn = createStartAndroidEmulatorBuildFunction();
const globalCtx = createGlobalContextMock({ logger });
globalCtx.updateEnv({ HOME: '/home/expo', ANDROID_HOME: '/android/home' });
return fn.createBuildStepFromFunctionCall(globalCtx, {
globalCtx.updateEnv({ HOME: '/home/expo', ANDROID_HOME: '/android/home', ...envOverrides });
const step = fn.createBuildStepFromFunctionCall(globalCtx, {
callInputs,
});
return Object.assign(step, { logger });
}
function createStartResult(serialId: string) {
@@ -57,7 +59,9 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
mockedAndroidUtils.getAvailableDevicesAsync.mockResolvedValue([]);
mockedAndroidUtils.createAsync.mockResolvedValue(undefined);
mockedAndroidUtils.cloneAsync.mockResolvedValue(undefined);
mockedAndroidUtils.startAsync.mockResolvedValue(createStartResult('emulator-default'));
mockedAndroidUtils.waitForReadyAsync.mockResolvedValue(undefined);
mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync.mockResolvedValue(undefined);
mockedAndroidUtils.deleteAsync.mockResolvedValue(undefined);
mockedRetryAsync.mockImplementation(async (fn, { retryOptions }) => {
@@ -97,6 +101,12 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
timeoutMs: 120_000,
})
);
expect(mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
serialId: 'emulator-2222',
})
);
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith(
expect.objectContaining({
serialId: 'emulator-1111',
@@ -147,6 +157,24 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
timeoutMs: 60_000,
})
);
expect(mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
serialId: 'emulator-base',
})
);
expect(mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
serialId: 'emulator-clone-1-attempt-2',
})
);
expect(mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
serialId: 'emulator-clone-2-attempt-1',
})
);
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith(
expect.objectContaining({
@@ -174,4 +202,14 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
);
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledTimes(3);
});
it('skips animation scale adjustments when opt out env var is disabled', async () => {
const step = createStep(undefined, {
ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE: 'false',
});
await step.executeAsync();
expect(mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync).not.toHaveBeenCalled();
});
});
@@ -2,6 +2,7 @@ import {
isClosedVersionTrainError,
isInvalidBundleIdentifierError,
isMissingPurposeStringError,
isSdkVersionIssueError,
parseMissingUsageDescriptionKeys,
} from '../uploadToAsc';
@@ -58,6 +59,20 @@ describe(isMissingPurposeStringError, () => {
});
});
describe(isSdkVersionIssueError, () => {
it('returns true when all errors are SDK-version-issue codes', () => {
expect(isSdkVersionIssueError([{ code: '90725' }, { code: '90725' }])).toBe(true);
});
it('returns false when any other error code is present', () => {
expect(isSdkVersionIssueError([{ code: '90725' }, { code: '90062' }])).toBe(false);
});
it('returns false when there are no errors', () => {
expect(isSdkVersionIssueError([])).toBe(false);
});
});
describe(parseMissingUsageDescriptionKeys, () => {
it('extracts missing UsageDescription keys from ASC messages', () => {
expect(
@@ -34,18 +34,20 @@ export function calculateEASUpdateRuntimeVersionFunction(): BuildFunction {
}),
],
fn: async (stepCtx, { env, inputs, outputs }) => {
const appConfig = readAppConfig({
projectDir: stepCtx.workingDirectory,
env: Object.keys(env).reduce(
(acc, key) => {
acc[key] = env[key] ?? '';
return acc;
},
{} as Record<string, string>
),
logger: stepCtx.logger,
sdkVersion: stepCtx.global.staticContext.metadata?.sdkVersion,
}).exp;
const appConfig = (
await readAppConfig({
projectDir: stepCtx.workingDirectory,
env: Object.keys(env).reduce(
(acc, key) => {
acc[key] = env[key] ?? '';
return acc;
},
{} as Record<string, string>
),
logger: stepCtx.logger,
sdkVersion: stepCtx.global.staticContext.metadata?.sdkVersion,
})
).exp;
const platform =
(inputs.platform.value as Platform) ?? stepCtx.global.staticContext.job.platform;
@@ -42,18 +42,20 @@ export function configureEASUpdateIfInstalledFunction(): BuildFunction {
assert(job.platform, 'Configuring EAS Update in generic jobs is not supported.');
const metadata = stepCtx.global.staticContext.metadata as Metadata | undefined;
const appConfig = readAppConfig({
projectDir: stepCtx.workingDirectory,
env: Object.keys(env).reduce(
(acc, key) => {
acc[key] = env[key] ?? '';
return acc;
},
{} as Record<string, string>
),
logger: stepCtx.logger,
sdkVersion: metadata?.sdkVersion,
}).exp;
const appConfig = (
await readAppConfig({
projectDir: stepCtx.workingDirectory,
env: Object.keys(env).reduce(
(acc, key) => {
acc[key] = env[key] ?? '';
return acc;
},
{} as Record<string, string>
),
logger: stepCtx.logger,
sdkVersion: metadata?.sdkVersion,
})
).exp;
const channelInput = inputs.channel.value as string | undefined;
const runtimeVersionInput = inputs.runtime_version.value as string | undefined;
@@ -1,6 +1,8 @@
import { BuildFunction } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import { waitForPrecompiledModulesPreparationAsync } from '../../utils/precompiledModules';
export function createInstallPodsBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
@@ -8,6 +10,14 @@ export function createInstallPodsBuildFunction(): BuildFunction {
name: 'Install Pods',
__metricsId: 'eas/install_pods',
fn: async (stepsCtx, { env }) => {
try {
await waitForPrecompiledModulesPreparationAsync();
} catch (err) {
stepsCtx.logger.warn(
{ err },
'Precompiled dependencies were not prepared successfully, continuing with pod install'
);
}
stepsCtx.logger.info('Installing pods');
const verboseFlag = stepsCtx.global.env['EAS_VERBOSE'] === '1' ? ['--verbose'] : [];
const cocoapodsDeploymentFlag =
@@ -23,6 +23,7 @@ import resolveFrom from 'resolve-from';
import { COMMON_FASTLANE_ENV } from '../../common/fastlane';
import IosCredentialsManager from '../utils/ios/credentials/manager';
import { resolvePackageVersionAsync } from '../../utils/packageManager';
export function createRepackBuildFunction(): BuildFunction {
return new BuildFunction({
@@ -51,6 +52,11 @@ export function createRepackBuildFunction(): BuildFunction {
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: false,
}),
BuildStepInput.createProvider({
id: 'js_bundle_only',
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: false,
}),
BuildStepInput.createProvider({
id: 'repack_version',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
@@ -82,7 +88,10 @@ export function createRepackBuildFunction(): BuildFunction {
);
}
const repackSpawnAsync = createSpawnAsyncStepAdapter({ verbose, logger: stepsCtx.logger });
const repackSpawnAsync = createSpawnAsyncStepAdapter({
verbose,
logger: stepsCtx.logger,
});
const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), `repack-`));
const workingDirectory = path.join(tmpDir, 'working-directory');
@@ -98,13 +107,20 @@ export function createRepackBuildFunction(): BuildFunction {
sourcemapOutput: undefined,
}
: undefined;
const jsBundleOnly = (inputs.js_bundle_only.value as boolean | undefined) ?? false;
const resolvedRepackVersion =
(await resolvePackageVersionAsync({
logger: stepsCtx.logger,
packageName: inputs.repack_package.value as string,
distTag: inputs.repack_version.value as string,
})) ?? (inputs.repack_version.value as string);
stepsCtx.logger.info(
`Using repack from: ${inputs.repack_package.value}@${inputs.repack_version.value}`
`Using repack from: ${inputs.repack_package.value}@${inputs.repack_version.value} (${resolvedRepackVersion})`
);
const repackApp = await installAndImportRepackAsync({
packageName: inputs.repack_package.value as string,
version: inputs.repack_version.value as string,
version: resolvedRepackVersion,
});
const { repackAppIosAsync, repackAppAndroidAsync } = repackApp;
@@ -118,6 +134,7 @@ export function createRepackBuildFunction(): BuildFunction {
outputPath,
workingDirectory,
exportEmbedOptions,
jsBundleOnly,
iosSigningOptions: await resolveIosSigningOptionsAsync({
job: stepsCtx.global.staticContext.job,
logger: stepsCtx.logger,
@@ -146,6 +163,7 @@ export function createRepackBuildFunction(): BuildFunction {
outputPath,
workingDirectory,
exportEmbedOptions,
jsBundleOnly,
androidSigningOptions,
logger: stepsCtx.logger,
spawnAsync: repackSpawnAsync,
@@ -7,7 +7,10 @@ import {
BuildStepInputValueTypeName,
spawnAsync,
} from '@expo/steps';
import fs from 'fs';
import nullthrows from 'nullthrows';
import os from 'os';
import path from 'path';
import { sendCcacheStatsAsync } from './ccacheStats';
import { decompressCacheAsync, downloadCacheAsync, downloadPublicCacheAsync } from './restoreCache';
@@ -16,6 +19,7 @@ import {
generateDefaultBuildCacheKeyAsync,
getCcachePath,
} from '../../utils/cacheKey';
import { GRADLE_CACHE_KEY_PREFIX, generateGradleCacheKeyAsync } from '../../utils/gradleCacheKey';
import { TurtleFetchError } from '../../utils/turtleFetch';
export function createRestoreBuildCacheFunction(): BuildFunction {
@@ -50,6 +54,15 @@ export function createRestoreBuildCacheFunction(): BuildFunction {
env,
secrets: stepCtx.global.staticContext.job.secrets,
});
if (platform === Platform.ANDROID) {
await restoreGradleCacheAsync({
logger,
workingDirectory,
env,
secrets: stepCtx.global.staticContext.job.secrets,
});
}
},
});
}
@@ -176,6 +189,106 @@ export async function restoreCcacheAsync({
}
}
export async function restoreGradleCacheAsync({
logger,
workingDirectory,
env,
secrets,
}: {
logger: bunyan;
workingDirectory: string;
env: Record<string, string | undefined>;
secrets?: { robotAccessToken?: string };
}): Promise<void> {
if (env.EXPERIMENTAL_EAS_GRADLE_CACHE !== '1') {
return;
}
try {
const gradlePropertiesPath = path.join(workingDirectory, 'android', 'gradle.properties');
const gradlePropertiesContent = await fs.promises.readFile(gradlePropertiesPath, 'utf-8');
await fs.promises.writeFile(
gradlePropertiesPath,
`${gradlePropertiesContent}\n\norg.gradle.caching=true\n`
);
// Configure cache cleanup via init script (works with both Gradle 8 and 9,
// org.gradle.cache.cleanup property was removed in Gradle 9)
const initScriptDir = path.join(os.homedir(), '.gradle', 'init.d');
await fs.promises.mkdir(initScriptDir, { recursive: true });
await fs.promises.writeFile(
path.join(initScriptDir, 'eas-cache-cleanup.gradle'),
[
'def cacheDir = new File(System.getProperty("user.home"), ".gradle/caches/build-cache-1")',
'def countBefore = cacheDir.exists() ? cacheDir.listFiles()?.length ?: 0 : 0',
'println "[EAS] Gradle build cache entries before cleanup: ${countBefore}"',
'',
'beforeSettings { settings ->',
' try {',
' settings.caches {',
' cleanup = Cleanup.ALWAYS',
' buildCache {',
' setRemoveUnusedEntriesAfterDays(3)',
' }',
' }',
' println "[EAS] Configured Gradle cache cleanup via init script"',
' } catch (Exception e) {',
' println "[EAS] Failed to configure cache cleanup: ${e.message}"',
' }',
'}',
'',
'gradle.buildFinished {',
' def countAfter = cacheDir.exists() ? cacheDir.listFiles()?.length ?: 0 : 0',
' println "[EAS] Gradle build cache entries after build: ${countAfter} (was ${countBefore})"',
'}',
'',
].join('\n')
);
const robotAccessToken = nullthrows(
secrets?.robotAccessToken,
'Robot access token is required for cache operations'
);
const expoApiServerURL = nullthrows(env.__API_SERVER_URL, '__API_SERVER_URL is not set');
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
const cacheKey = await generateGradleCacheKeyAsync(workingDirectory);
logger.info(`Restoring Gradle cache key: ${cacheKey}`);
const gradleCachesPath = path.join(os.homedir(), '.gradle', 'caches');
const buildCachePath = path.join(gradleCachesPath, 'build-cache-1');
const { archivePath, matchedKey } = await downloadCacheAsync({
logger,
jobId,
expoApiServerURL,
robotAccessToken,
paths: [buildCachePath],
key: cacheKey,
keyPrefixes: [GRADLE_CACHE_KEY_PREFIX],
platform: Platform.ANDROID,
});
await fs.promises.mkdir(gradleCachesPath, { recursive: true });
await decompressCacheAsync({
archivePath,
workingDirectory: gradleCachesPath,
verbose: env.EXPO_DEBUG === '1',
logger,
});
logger.info(
`Gradle cache restored to ${gradleCachesPath} ${matchedKey === cacheKey ? '(direct hit)' : '(prefix match)'}`
);
} catch (err: unknown) {
if (err instanceof TurtleFetchError && err.response?.status === 404) {
logger.info('No Gradle cache found for this key');
} else {
logger.warn('Failed to restore Gradle cache: ', err);
}
}
}
export async function cacheStatsAsync({
logger,
env,
@@ -10,8 +10,13 @@ import {
import fs from 'fs';
import nullthrows from 'nullthrows';
import os from 'os';
import path from 'path';
import { compressCacheAsync, uploadCacheAsync } from './saveCache';
import { formatBytes } from '../../utils/artifacts';
import { generateDefaultBuildCacheKeyAsync, getCcachePath } from '../../utils/cacheKey';
import { generateGradleCacheKeyAsync } from '../../utils/gradleCacheKey';
export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFunction {
return new BuildFunction({
@@ -46,6 +51,15 @@ export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFuncti
env,
secrets: stepCtx.global.staticContext.job.secrets,
});
if (platform === Platform.ANDROID) {
await saveGradleCacheAsync({
logger,
workingDirectory,
env,
secrets: stepCtx.global.staticContext.job.secrets,
});
}
},
});
}
@@ -135,3 +149,66 @@ export async function saveCcacheAsync({
logger.error({ err }, 'Failed to save cache');
}
}
export async function saveGradleCacheAsync({
logger,
workingDirectory,
env,
secrets,
}: {
logger: bunyan;
workingDirectory: string;
env: Record<string, string | undefined>;
secrets?: { robotAccessToken?: string };
}): Promise<void> {
if (env.EXPERIMENTAL_EAS_GRADLE_CACHE !== '1') {
return;
}
const gradleCachesPath = path.join(os.homedir(), '.gradle', 'caches');
const buildCachePath = path.join(gradleCachesPath, 'build-cache-1');
try {
await fs.promises.access(buildCachePath);
} catch {
logger.warn('No Gradle build cache found, skipping save');
return;
}
try {
const cacheKey = await generateGradleCacheKeyAsync(workingDirectory);
logger.info(`Saving Gradle cache key: ${cacheKey}`);
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
const robotAccessToken = nullthrows(
secrets?.robotAccessToken,
'Robot access token is required for cache operations'
);
const expoApiServerURL = nullthrows(env.__API_SERVER_URL, '__API_SERVER_URL is not set');
logger.info('Compressing Gradle build cache...');
const { archivePath } = await compressCacheAsync({
paths: [buildCachePath],
workingDirectory: gradleCachesPath,
verbose: env.EXPO_DEBUG === '1',
logger,
});
const { size } = await fs.promises.stat(archivePath);
logger.info(`Gradle cache archive size: ${formatBytes(size)}`);
await uploadCacheAsync({
logger,
jobId,
expoApiServerURL,
robotAccessToken,
archivePath,
key: cacheKey,
paths: [buildCachePath],
size,
platform: Platform.ANDROID,
});
} catch (err) {
logger.error({ err }, 'Failed to save Gradle cache');
}
}
@@ -57,6 +57,15 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
const systemImagePackage = `${inputs.system_image_package.value}`;
// We can cast because allowedValueTypeName validated this is a string.
const deviceIdentifier = inputs.device_identifier.value as AndroidDeviceName | undefined;
const shouldAdjustAnimationScale =
env.ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE !== 'false' &&
env.ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE !== '0';
if (!shouldAdjustAnimationScale) {
logger.info(
'Skipping Android emulator animation scale adjustments because $ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE is disabled.'
);
}
logger.info('Making sure system image is installed');
await retryAsync(
@@ -107,6 +116,13 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
timeoutMs,
logger,
});
if (shouldAdjustAnimationScale) {
await AndroidEmulatorUtils.disableWindowAndTransitionAnimationsAsync({
env,
logger,
serialId: attemptSerialId,
});
}
logger.info(`${deviceName} is ready.`);
serialId = attemptSerialId;
@@ -191,6 +207,13 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
timeoutMs,
logger,
});
if (shouldAdjustAnimationScale) {
await AndroidEmulatorUtils.disableWindowAndTransitionAnimationsAsync({
env,
logger,
serialId: cloneSerialId,
});
}
logger.info(`${cloneIdentifier} is ready.`);
} catch (err) {
@@ -266,12 +266,31 @@ export function createUploadToAscBuildFunction(): BuildFunction {
}
if (state.state === 'FAILED') {
if (isSdkVersionIssueError(errors)) {
throw new UserError(
'EAS_UPLOAD_TO_ASC_SDK_VERSION_ISSUE',
'Build upload was rejected by App Store Connect because the IPA was built with an iOS SDK that is too old for current App Store Connect requirements. ' +
'In an Expo project, this usually means the build used an outdated EAS iOS image or the project still depends on an Expo SDK / native setup that does not support the required Xcode toolchain. ' +
'Upgrade to a supported Expo SDK if needed and/or select a newer iOS image in `eas.json`, rebuild, and submit again.',
{
docsUrl: 'https://docs.expo.dev/workflow/upgrading-expo-sdk-walkthrough/',
}
);
}
if (isInvalidBundleIdentifierError(errors)) {
const ipaInfoResult = await asyncResult(readIpaInfoAsync(ipaPath));
const ipaBundleIdentifier = ipaInfoResult.ok
? ipaInfoResult.value.bundleIdentifier
: null;
let visibleAppsSummary: string | null = null;
try {
const apps = await AscApiUtils.getAppsAsync({ client, limit: 5 });
visibleAppsSummary = AscApiUtils.formatAppsList(apps);
} catch {
// Ok to fail, this is just trying to be helpful.
}
throw new UserError(
'EAS_UPLOAD_TO_ASC_INVALID_BUNDLE_ID',
`Build upload was rejected by App Store Connect because the app bundle identifier in the IPA does not match the selected App Store Connect app.\n\n` +
@@ -279,7 +298,12 @@ export function createUploadToAscBuildFunction(): BuildFunction {
`App Store Connect app bundle identifier: ${ascAppBundleIdentifier}\n\n` +
'Bundle identifier cannot be changed for an existing App Store Connect app. ' +
'If you selected the wrong app, change the Apple app identifier in the submit profile. ' +
'If you selected the right app, you may want to select a different build to upload (or rebuild with a different profile).'
'If you selected the right app, you may want to select a different build to upload (or rebuild with a different profile).' +
'\n\nOther App Store Connect apps visible to this API key:\n' +
(visibleAppsSummary ?? ' (unable to retrieve)'),
{
docsUrl: 'https://expo.fyi/asc-app-id',
}
);
}
if (isMissingPurposeStringError(errors)) {
@@ -331,6 +355,10 @@ export function isClosedVersionTrainError(messages: { code: string }[]): boolean
);
}
export function isSdkVersionIssueError(messages: { code: string }[]): boolean {
return messages.length > 0 && messages.every(message => message.code === '90725');
}
export function isInvalidBundleIdentifierError(messages: { code: string }[]): boolean {
return (
messages.length > 0 && messages.every(message => ['90054', '90055'].includes(message.code))
@@ -34,11 +34,13 @@ export namespace AscApiUtils {
let visibleAppsSummary: string | null = null;
try {
visibleAppsSummary = await getVisibleAppsSummaryAsync(client);
const apps = await AscApiUtils.getAppsAsync({ client, limit: 10 });
visibleAppsSummary = AscApiUtils.formatAppsList(apps);
} catch {
// Don't hide the original NOT_FOUND error with a secondary lookup failure.
throw error;
}
throw new UserError(
'EAS_UPLOAD_TO_ASC_APP_NOT_FOUND',
`App Store Connect app for application identifier ${appleAppIdentifier} was not found. ` +
@@ -108,19 +110,26 @@ export namespace AscApiUtils {
throw error;
}
}
}
async function getVisibleAppsSummaryAsync(
client: Pick<AscApiClient, 'getAsync'>
): Promise<string | null> {
const appsResponse = await client.getAsync('/v1/apps', {
'fields[apps]': ['bundleId', 'name'],
limit: 10,
});
if (appsResponse.data.length === 0) {
return ' (none)';
export async function getAppsAsync({
client,
limit,
}: {
client: Pick<AscApiClient, 'getAsync'>;
limit?: number;
}): Promise<AscApiClientGetApi['/v1/apps']['response']['data']> {
const appsResponse = await client.getAsync('/v1/apps', {
'fields[apps]': ['bundleId', 'name'],
limit: limit ?? 10,
});
return appsResponse.data;
}
export function formatAppsList(apps: AscApiClientGetApi['/v1/apps']['response']['data']): string {
return (
apps
.map(app => `- ${app.attributes.name} (${app.attributes.bundleId}) (ID: ${app.id})`)
.join('\n') || ' (none)'
);
}
return appsResponse.data
.map(app => `- ${app.attributes.name} (${app.attributes.bundleId}) (ID: ${app.id})`)
.join('\n');
}
@@ -60,8 +60,8 @@ describe('AscApiUtils', () => {
expect.objectContaining({
errorCode: 'EAS_UPLOAD_TO_ASC_APP_NOT_FOUND',
docsUrl: 'https://expo.fyi/asc-app-id',
message: expect.stringContaining(
'App Store Connect app for application identifier 1234567890 was not found'
message: expect.stringMatching(
/App Store Connect app for application identifier 1234567890 was not found[\s\S]*- Visible App \(com\.visible\.app\) \(ID: 1111111111\)/
),
})
);
@@ -96,6 +96,31 @@ describe('AscApiUtils', () => {
});
});
describe('formatAppsList', () => {
it('formats visible apps', () => {
expect(
AscApiUtils.formatAppsList([
{
type: 'apps',
id: '1111111111',
attributes: { name: 'Visible App', bundleId: 'com.visible.app' },
},
{
type: 'apps',
id: '2222222222',
attributes: { name: 'Second App', bundleId: 'com.second.app' },
},
])
).toBe(
'- Visible App (com.visible.app) (ID: 1111111111)\n- Second App (com.second.app) (ID: 2222222222)'
);
});
it('returns a placeholder when no visible apps are found', () => {
expect(AscApiUtils.formatAppsList([])).toBe(' (none)');
});
});
describe('createBuildUploadAsync', () => {
it('throws UserError when ASC duplicate version error is returned', async () => {
const payload = {
@@ -391,6 +391,38 @@ export namespace AndroidEmulatorUtils {
);
}
export async function disableWindowAndTransitionAnimationsAsync({
serialId,
env,
logger,
}: {
serialId: AndroidDeviceSerialId;
env: NodeJS.ProcessEnv;
logger: bunyan;
}): Promise<void> {
logger.info('Disabling Android emulator window animations.');
try {
await spawn(
'adb',
['-s', serialId, 'shell', 'settings', 'put', 'global', 'window_animation_scale', '0'],
{ env }
);
} catch (err) {
logger.warn({ err }, 'Failed to disable Android emulator window animations.');
}
logger.info('Disabling Android emulator transition animations.');
try {
await spawn(
'adb',
['-s', serialId, 'shell', 'settings', 'put', 'global', 'transition_animation_scale', '0'],
{ env }
);
} catch (err) {
logger.warn({ err }, 'Failed to disable Android emulator transition animations.');
}
}
async function hasNetworkConnectionAsync({
serialId,
env,
@@ -0,0 +1,120 @@
import fs from 'fs-extra';
import nock from 'nock';
import os from 'os';
import path from 'path';
import { createMockLogger } from '../../__tests__/utils/logger';
jest.unmock('fs');
jest.unmock('node:fs');
jest.unmock('fs/promises');
jest.unmock('node:fs/promises');
describe('precompiledModules integration', () => {
beforeAll(() => {
nock.disableNetConnect();
});
afterAll(() => {
nock.enableNetConnect();
});
afterEach(() => {
nock.cleanAll();
});
it('merges the extracted debug and release xcframework trees', async () => {
const homeDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'precompiled-modules-home-'));
const previousHome = process.env.HOME;
process.env.HOME = homeDirectory;
try {
const fixturesDirectory = path.join(__dirname, 'fixtures', 'precompiledModules');
const debugArchive = await fs.readFile(
path.join(fixturesDirectory, 'xcframeworks-Debug.zip')
);
const releaseArchive = await fs.readFile(
path.join(fixturesDirectory, 'xcframeworks-Release.zip')
);
nock('https://fixtures.expo.test')
.get('/xcframeworks-Debug.zip')
.reply(200, debugArchive, { 'Content-Type': 'application/zip' })
.get('/xcframeworks-Release.zip')
.reply(200, releaseArchive, { 'Content-Type': 'application/zip' });
jest.resetModules();
jest.doMock('os', () => ({
...jest.requireActual('os'),
homedir: () => homeDirectory,
}));
const {
PRECOMPILED_MODULES_PATH,
startPreparingPrecompiledDependencies,
waitForPrecompiledModulesPreparationAsync,
} = await import('../precompiledModules');
startPreparingPrecompiledDependencies(
{
logger: createMockLogger(),
env: {},
} as any,
[
'https://fixtures.expo.test/xcframeworks-Debug.zip',
'https://fixtures.expo.test/xcframeworks-Release.zip',
]
);
await waitForPrecompiledModulesPreparationAsync();
expect(PRECOMPILED_MODULES_PATH).toBe(
path.join(homeDirectory, '.expo', 'precompiled-modules')
);
await expect(
fs.readFile(
path.join(
PRECOMPILED_MODULES_PATH,
'expo-modules-core/output/debug/xcframeworks/ExpoModulesCore.tar.gz'
),
'utf8'
)
).resolves.toBe('debug expo-modules-core fixture\n');
await expect(
fs.readFile(
path.join(
PRECOMPILED_MODULES_PATH,
'expo-modules-core/output/release/xcframeworks/ExpoModulesCore.tar.gz'
),
'utf8'
)
).resolves.toBe('release expo-modules-core fixture\n');
await expect(
fs.readFile(
path.join(
PRECOMPILED_MODULES_PATH,
'expo-camera/output/debug/xcframeworks/ExpoCamera.tar.gz'
),
'utf8'
)
).resolves.toBe('debug expo-camera fixture\n');
await expect(
fs.readFile(
path.join(
PRECOMPILED_MODULES_PATH,
'expo-application/output/release/xcframeworks/EXApplication.tar.gz'
),
'utf8'
)
).resolves.toBe('release expo-application fixture\n');
} finally {
jest.dontMock('os');
jest.resetModules();
if (previousHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = previousHome;
}
await fs.remove(homeDirectory);
}
});
});
@@ -1,5 +1,6 @@
import spawn from '@expo/turtle-spawn';
import { createMockLogger } from '../../__tests__/utils/logger';
import { AndroidEmulatorUtils } from '../AndroidEmulatorUtils';
import { retryAsync } from '../retry';
@@ -110,6 +111,87 @@ describe('AndroidEmulatorUtils', () => {
});
});
describe(AndroidEmulatorUtils.disableWindowAndTransitionAnimationsAsync, () => {
it('sets window and transition animation scales to zero', async () => {
const logger = createMockLogger();
await AndroidEmulatorUtils.disableWindowAndTransitionAnimationsAsync({
serialId: 'emulator-5554' as any,
env: process.env,
logger,
});
expect(logger.info).toHaveBeenNthCalledWith(
1,
'Disabling Android emulator window animations.'
);
expect(mockedSpawn).toHaveBeenNthCalledWith(
1,
'adb',
[
'-s',
'emulator-5554',
'shell',
'settings',
'put',
'global',
'window_animation_scale',
'0',
],
{ env: process.env }
);
expect(logger.info).toHaveBeenNthCalledWith(
2,
'Disabling Android emulator transition animations.'
);
expect(mockedSpawn).toHaveBeenNthCalledWith(
2,
'adb',
[
'-s',
'emulator-5554',
'shell',
'settings',
'put',
'global',
'transition_animation_scale',
'0',
],
{ env: process.env }
);
});
it('logs and swallows failures when disabling animations', async () => {
const logger = createMockLogger();
mockedSpawn
.mockRejectedValueOnce(new Error('window failed'))
.mockRejectedValueOnce(new Error('transition failed'));
await expect(
AndroidEmulatorUtils.disableWindowAndTransitionAnimationsAsync({
serialId: 'emulator-5554' as any,
env: process.env,
logger,
})
).resolves.toBeUndefined();
expect(logger.warn).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
err: expect.objectContaining({ message: 'window failed' }),
}),
'Failed to disable Android emulator window animations.'
);
expect(logger.warn).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
err: expect.objectContaining({ message: 'transition failed' }),
}),
'Failed to disable Android emulator transition animations.'
);
});
});
describe(AndroidEmulatorUtils.stopAsync, () => {
it('kills emulator and waits for it to detach', async () => {
mockedSpawn.mockImplementation((async (_command: string, args: string[]) => {
@@ -0,0 +1,108 @@
import { bunyan } from '@expo/logger';
import { readAppConfig } from '../appConfig';
jest.mock('@expo/env', () => ({
load: jest.fn(() => ({ FROM_DOTENV: 'true' })),
}));
jest.mock('@expo/config', () => ({
getConfig: jest.fn(() => ({
exp: { name: 'fallback-app', slug: 'fallback-app' },
})),
}));
jest.mock('../expoCli');
const { expoCommandAsync } = jest.requireMock('../expoCli') as {
expoCommandAsync: jest.Mock;
};
const { getConfig } = jest.requireMock('@expo/config') as {
getConfig: jest.Mock;
};
const { load: loadEnv } = jest.requireMock('@expo/env') as {
load: jest.Mock;
};
const logger = { warn: jest.fn(), info: jest.fn(), error: jest.fn() } as unknown as bunyan;
const baseParams = {
projectDir: '/project',
env: { NODE_ENV: 'production' },
logger,
};
describe(readAppConfig, () => {
it('returns config from expo CLI when it succeeds', async () => {
const config = { exp: { name: 'test-app', slug: 'test-app' } };
expoCommandAsync.mockResolvedValue({ stdout: JSON.stringify(config) });
const result = await readAppConfig(baseParams);
expect(result).toEqual(config);
expect(expoCommandAsync).toHaveBeenCalledWith(
'/project',
['config', '--json', '--full', '--type', 'public'],
{ env: baseParams.env }
);
expect(getConfig).not.toHaveBeenCalled();
});
it('falls back to @expo/config when expo CLI fails', async () => {
expoCommandAsync.mockRejectedValue(new Error('expo not found'));
const result = await readAppConfig(baseParams);
expect(result).toEqual({ exp: { name: 'fallback-app', slug: 'fallback-app' } });
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('expo not found'));
expect(getConfig).toHaveBeenCalledWith('/project', {
skipSDKVersionRequirement: true,
isPublicConfig: true,
});
});
it('throws when expo CLI returns invalid JSON and @expo/config also fails', async () => {
expoCommandAsync.mockResolvedValue({ stdout: 'not json' });
getConfig.mockImplementation(() => {
throw new Error('@expo/config failed');
});
await expect(readAppConfig(baseParams)).rejects.toThrow('@expo/config failed');
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining("Failed to parse JSON output from 'expo config'")
);
});
it('throws when expo CLI output is missing exp field and @expo/config also fails', async () => {
expoCommandAsync.mockResolvedValue({ stdout: JSON.stringify({ other: 'data' }) });
getConfig.mockImplementation(() => {
throw new Error('@expo/config failed');
});
await expect(readAppConfig(baseParams)).rejects.toThrow('@expo/config failed');
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("missing 'exp' field"));
});
it('loads env vars from dotenv for SDK >= 49', async () => {
const config = { exp: { name: 'test-app', slug: 'test-app' } };
expoCommandAsync.mockResolvedValue({ stdout: JSON.stringify(config) });
await readAppConfig({ ...baseParams, sdkVersion: '49.0.0' });
expect(loadEnv).toHaveBeenCalledWith('/project');
expect(expoCommandAsync).toHaveBeenCalledWith('/project', expect.any(Array), {
env: { NODE_ENV: 'production', FROM_DOTENV: 'true' },
});
});
it('does not load env vars from dotenv for SDK < 49', async () => {
const config = { exp: { name: 'test-app', slug: 'test-app' } };
expoCommandAsync.mockResolvedValue({ stdout: JSON.stringify(config) });
await readAppConfig({ ...baseParams, sdkVersion: '48.0.0' });
expect(loadEnv).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,78 @@
import resolveFrom from 'resolve-from';
import spawnAsync from '@expo/turtle-spawn';
import { expoCommandAsync, ExpoCLIModuleNotFoundError } from '../expoCli';
jest.mock('resolve-from', () => ({
__esModule: true,
default: Object.assign(jest.fn(), {
silent: jest.fn(),
}),
}));
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn().mockResolvedValue({ stdout: '', stderr: '' }),
}));
const mockedResolveFrom = resolveFrom as jest.MockedFunction<typeof resolveFrom> & {
silent: jest.MockedFunction<typeof resolveFrom.silent>;
};
describe(expoCommandAsync, () => {
it('resolves expo CLI and spawns with correct args', async () => {
mockedResolveFrom.silent.mockReturnValue('/project/node_modules/expo/bin/cli');
await expoCommandAsync('/project', ['config', '--json'], { env: { FOO: 'bar' } });
expect(spawnAsync).toHaveBeenCalledWith(
'/project/node_modules/expo/bin/cli',
['config', '--json'],
expect.objectContaining({
cwd: '/project',
stdio: 'pipe',
env: { FOO: 'bar', EXPO_DEBUG: '0' },
})
);
});
it('falls back to expo/bin/cli.js when silent resolve returns null', async () => {
mockedResolveFrom.silent.mockReturnValue(undefined);
mockedResolveFrom.mockReturnValue('/project/node_modules/expo/bin/cli.js');
await expoCommandAsync('/project', ['config'], { env: {} });
expect(spawnAsync).toHaveBeenCalledWith(
'/project/node_modules/expo/bin/cli.js',
['config'],
expect.any(Object)
);
});
it('throws ExpoCLIModuleNotFoundError when expo is not installed', async () => {
mockedResolveFrom.silent.mockReturnValue(undefined);
const moduleError = new Error('Cannot find module');
(moduleError as any).code = 'MODULE_NOT_FOUND';
mockedResolveFrom.mockImplementation(() => {
throw moduleError;
});
await expect(expoCommandAsync('/project', ['config'], { env: {} })).rejects.toThrow(
ExpoCLIModuleNotFoundError
);
});
it('overrides EXPO_DEBUG to 0', async () => {
mockedResolveFrom.silent.mockReturnValue('/project/node_modules/expo/bin/cli');
await expoCommandAsync('/project', ['config'], { env: { EXPO_DEBUG: '1' } });
expect(spawnAsync).toHaveBeenCalledWith(
expect.any(String),
expect.any(Array),
expect.objectContaining({
env: expect.objectContaining({ EXPO_DEBUG: '0' }),
})
);
});
});
@@ -1,11 +1,14 @@
import { type bunyan } from '@expo/logger';
import fs from 'fs-extra';
import { vol } from 'memfs';
import path from 'path';
import semver from 'semver';
import {
findPackagerRootDir,
getPackageVersionFromPackageJson,
resolvePackageManager,
resolvePackageVersionAsync,
shouldUseFrozenLockfile,
} from '../packageManager';
@@ -68,6 +71,33 @@ describe(resolvePackageManager, () => {
});
});
describe(resolvePackageVersionAsync, () => {
const logger = {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
} as unknown as bunyan;
it('should resolve version from package name and dist tag', async () => {
const version = await resolvePackageVersionAsync({
logger,
packageName: '@expo/repack-app',
distTag: 'latest',
});
expect(semver.valid(version)).toBeTruthy();
});
it('should return null if version cannot be resolved', async () => {
const version = await resolvePackageVersionAsync({
logger,
packageName: '@expo/repack-app',
distTag: 'nonexistent-dist-tag',
});
expect(version).toBeNull();
});
});
describe(findPackagerRootDir, () => {
beforeEach(() => {
vol.reset();
@@ -0,0 +1,168 @@
import downloadFile from '@expo/downloader';
import { mkdirp, mkdtemp, move, remove } from 'fs-extra';
import StreamZip from 'node-stream-zip';
import {
PRECOMPILED_MODULES_PATH,
startPreparingPrecompiledDependencies,
waitForPrecompiledModulesPreparationAsync,
} from '../precompiledModules';
import { createMockLogger } from '../../__tests__/utils/logger';
import { BuildContext } from '../../context';
jest.mock('@expo/downloader');
jest.mock('fs-extra');
jest.mock('node-stream-zip', () => ({
__esModule: true,
default: {
async: jest.fn(),
},
}));
describe('precompiledModules', () => {
const extract = jest.fn();
const close = jest.fn();
const getMkdtempPath = jest.fn();
const createCtx = (env: Record<string, string | undefined> = {}): BuildContext =>
({
logger: createMockLogger(),
env,
}) as unknown as BuildContext;
beforeEach(() => {
jest.mocked(remove).mockImplementation(async () => undefined as any);
jest.mocked(mkdirp).mockImplementation(async () => undefined as any);
jest.mocked(move).mockImplementation(async () => undefined as any);
getMkdtempPath.mockReset();
getMkdtempPath
.mockReturnValueOnce('/tmp/precompiled-modules-archive')
.mockReturnValueOnce('/tmp/precompiled-modules-staging');
jest.mocked(mkdtemp).mockImplementation(async () => getMkdtempPath() as any);
jest.mocked(downloadFile).mockReset();
extract.mockReset().mockResolvedValue(undefined);
close.mockReset().mockResolvedValue(undefined);
jest.mocked(StreamZip.async).mockImplementation(
() =>
({
extract,
close,
}) as any
);
});
afterEach(() => {
jest.useRealTimers();
});
it('downloads through CocoaPods proxy when enabled', async () => {
jest.mocked(downloadFile).mockResolvedValue(undefined);
startPreparingPrecompiledDependencies(
createCtx({ EAS_BUILD_COCOAPODS_CACHE_URL: 'http://localhost:9001' }),
[
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
]
);
await waitForPrecompiledModulesPreparationAsync();
expect(downloadFile).toHaveBeenCalledWith(
'http://localhost:9001/storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
'/tmp/precompiled-modules-archive/precompiled-modules-0.zip',
{ retry: 3 }
);
});
it('falls back to the direct url when CocoaPods proxy download fails', async () => {
jest
.mocked(downloadFile)
.mockRejectedValueOnce(new Error('proxy failed'))
.mockResolvedValueOnce(undefined);
startPreparingPrecompiledDependencies(
createCtx({ EAS_BUILD_COCOAPODS_CACHE_URL: 'http://localhost:9001' }),
[
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
]
);
await waitForPrecompiledModulesPreparationAsync();
expect(downloadFile).toHaveBeenNthCalledWith(
1,
'http://localhost:9001/storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
'/tmp/precompiled-modules-archive/precompiled-modules-0.zip',
{ retry: 3 }
);
expect(downloadFile).toHaveBeenNthCalledWith(
2,
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
'/tmp/precompiled-modules-archive/precompiled-modules-0.zip',
{ retry: 3 }
);
});
it('extracts all configured archives into the well-known destination path', async () => {
jest.mocked(downloadFile).mockResolvedValue(undefined);
startPreparingPrecompiledDependencies(createCtx(), [
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-1.zip',
]);
await waitForPrecompiledModulesPreparationAsync();
expect(mkdirp).toHaveBeenCalledWith(PRECOMPILED_MODULES_PATH);
expect(downloadFile).toHaveBeenNthCalledWith(
1,
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
'/tmp/precompiled-modules-archive/precompiled-modules-0.zip',
{ retry: 3 }
);
expect(downloadFile).toHaveBeenNthCalledWith(
2,
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-1.zip',
'/tmp/precompiled-modules-archive/precompiled-modules-1.zip',
{ retry: 3 }
);
expect(extract).toHaveBeenNthCalledWith(1, null, '/tmp/precompiled-modules-staging');
expect(extract).toHaveBeenNthCalledWith(2, null, '/tmp/precompiled-modules-staging');
expect(move).toHaveBeenCalledWith('/tmp/precompiled-modules-staging', PRECOMPILED_MODULES_PATH);
});
it('leaves the destination empty when preparation fails', async () => {
jest.mocked(downloadFile).mockResolvedValue(undefined);
extract.mockRejectedValueOnce(new Error('extract failed'));
startPreparingPrecompiledDependencies(createCtx(), [
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
]);
await expect(waitForPrecompiledModulesPreparationAsync()).rejects.toThrow('extract failed');
expect(move).not.toHaveBeenCalled();
expect(remove).toHaveBeenCalledWith('/tmp/precompiled-modules-staging');
expect(remove).toHaveBeenCalledWith(PRECOMPILED_MODULES_PATH);
expect(mkdirp).toHaveBeenCalledWith(PRECOMPILED_MODULES_PATH);
});
it('throws when precompiled dependencies are still not ready after 30 seconds', async () => {
jest.useFakeTimers();
jest.mocked(downloadFile).mockImplementation(() => new Promise<void>(() => {}) as any);
startPreparingPrecompiledDependencies(createCtx(), [
'https://example.com/xcframeworks-Debug.zip',
]);
const waitPromise = waitForPrecompiledModulesPreparationAsync();
const waitExpectation = expect(waitPromise).rejects.toThrow(
'Timed out waiting for precompiled dependencies after 30 seconds'
);
await Promise.resolve();
await jest.advanceTimersByTimeAsync(30_000);
await waitExpectation;
});
});
@@ -24,7 +24,7 @@ describe(runExpoCliCommand, () => {
const mockCtx = mock<BuildContext<Android.Job>>();
when(mockCtx.packageManager).thenReturn(PackageManager.NPM);
when(mockCtx.appConfig).thenReturn(expoConfig);
when(mockCtx.appConfig).thenReturn(Promise.resolve(expoConfig));
const ctx = instance(mockCtx);
void runExpoCliCommand({ args: ['doctor'], options: {}, packageManager: ctx.packageManager });
@@ -38,7 +38,7 @@ describe(runExpoCliCommand, () => {
const mockCtx = mock<BuildContext<Android.Job>>();
when(mockCtx.packageManager).thenReturn(PackageManager.NPM);
when(mockCtx.appConfig).thenReturn(expoConfig);
when(mockCtx.appConfig).thenReturn(Promise.resolve(expoConfig));
const ctx = instance(mockCtx);
void runExpoCliCommand({ args: ['doctor'], options: {}, packageManager: ctx.packageManager });
@@ -52,7 +52,7 @@ describe(runExpoCliCommand, () => {
const mockCtx = mock<BuildContext<Android.Job>>();
when(mockCtx.packageManager).thenReturn(PackageManager.PNPM);
when(mockCtx.appConfig).thenReturn(expoConfig);
when(mockCtx.appConfig).thenReturn(Promise.resolve(expoConfig));
const ctx = instance(mockCtx);
void runExpoCliCommand({ args: ['doctor'], options: {}, packageManager: ctx.packageManager });
@@ -66,7 +66,7 @@ describe(runExpoCliCommand, () => {
const mockCtx = mock<BuildContext<Android.Job>>();
when(mockCtx.packageManager).thenReturn(PackageManager.BUN);
when(mockCtx.appConfig).thenReturn(expoConfig);
when(mockCtx.appConfig).thenReturn(Promise.resolve(expoConfig));
const ctx = instance(mockCtx);
void runExpoCliCommand({ args: ['doctor'], options: {}, packageManager: ctx.packageManager });
+60 -11
View File
@@ -3,18 +3,70 @@ import { Env } from '@expo/eas-build-job';
import { load } from '@expo/env';
import { LoggerLevel, bunyan } from '@expo/logger';
import semver from 'semver';
import { expoCommandAsync } from './expoCli';
export function readAppConfig({
projectDir,
env,
logger,
sdkVersion,
}: {
interface ReadAppConfigParams {
projectDir: string;
env: Env;
logger: bunyan;
sdkVersion?: string;
}): ProjectConfig {
}
export async function readAppConfig(params: ReadAppConfigParams): Promise<ProjectConfig> {
const shouldLoadEnvVarsFromDotenvFile =
params.sdkVersion && semver.satisfies(params.sdkVersion, '>=49');
if (shouldLoadEnvVarsFromDotenvFile) {
const envVarsFromDotenvFile = load(params.projectDir) as Env;
const env = { ...params.env, ...envVarsFromDotenvFile };
params = { ...params, env };
}
// Reading the app config is done in two steps/attempts. We first attempt to run `expo config` as a CLI,
try {
return await getAppConfigFromExpo(params);
} catch (error: any) {
params.logger.warn(
'Failed to read the app config file with `expo config` command:\n' +
`${error?.message || error}`
);
}
// If this fails, we fall back to directly using `@expo/config`
// This can fail, since it's tied to a specific SDK version, so reading for older SDKs isn't guaranteed to work
return getAppConfigFromExpoConfig(params);
}
async function getAppConfigFromExpo({
projectDir,
env,
}: ReadAppConfigParams): Promise<ProjectConfig> {
const result = await expoCommandAsync(
projectDir,
['config', '--json', '--full', '--type', 'public'],
{ env }
);
let parsed: any;
try {
parsed = JSON.parse(result.stdout);
} catch {
throw new Error(
`Failed to parse JSON output from 'expo config'.\nOutput: ${result.stdout.slice(0, 500)}`
);
}
if (!('exp' in parsed)) {
throw new Error(`Unexpected output from 'expo config': missing 'exp' field.`);
}
return parsed;
}
function getAppConfigFromExpoConfig({
projectDir,
env,
logger,
}: ReadAppConfigParams): ProjectConfig {
const originalProcessExit = process.exit;
const originalProcessCwd = process.cwd;
const originalStdoutWrite = process.stdout.write;
@@ -22,11 +74,8 @@ export function readAppConfig({
const originalProcessEnv = process.env;
const stdoutStore: { text: string; level: LoggerLevel }[] = [];
const shouldLoadEnvVarsFromDotenvFile = sdkVersion && semver.satisfies(sdkVersion, '>=49');
const envVarsFromDotenvFile = shouldLoadEnvVarsFromDotenvFile ? load(projectDir) : {};
const newEnvsToUse = { ...env, ...envVarsFromDotenvFile };
try {
process.env = newEnvsToUse;
process.env = env;
process.exit = () => {
throw new Error('Failed to evaluate app config file');
};
+36
View File
@@ -0,0 +1,36 @@
import resolveFrom from 'resolve-from';
import spawnAsync, { type SpawnOptions } from '@expo/turtle-spawn';
export class ExpoCLIModuleNotFoundError extends Error {}
function resolveExpoCLI(projectRoot: string): string {
try {
return (
resolveFrom.silent(projectRoot, 'expo/bin/cli') ?? resolveFrom(projectRoot, 'expo/bin/cli.js')
);
} catch (e: any) {
if (e.code === 'MODULE_NOT_FOUND') {
throw new ExpoCLIModuleNotFoundError(`The \`expo\` package was not found.`);
}
throw e;
}
}
export async function expoCommandAsync(
projectDir: string,
args: string[],
options: Omit<SpawnOptions, 'cwd'>
) {
const expoCliPath = resolveExpoCLI(projectDir);
return spawnAsync(expoCliPath, args, {
cwd: projectDir,
stdio: 'pipe',
...options,
env: {
...options.env,
// NOTE: If we're reading user configs, if a user has set this, it might cause excessive output
// that can stop the command from being readable
EXPO_DEBUG: '0',
},
});
}
@@ -120,7 +120,7 @@ This would cause any updates published on the local machine to not be compatible
);
}
if (isEASUpdateConfigured(ctx)) {
if (await isEASUpdateConfigured(ctx)) {
if (ctx.job.updates?.channel !== undefined) {
await configureEASExpoUpdatesAsync(ctx);
} else {
@@ -134,7 +134,7 @@ This would cause any updates published on the local machine to not be compatible
} else if (isDevelopmentClient) {
// NO-OP: Development clients don't need to have a channel set
} else {
const easUpdateUrl = ctx.appConfig.updates?.url ?? null;
const easUpdateUrl = (await ctx.appConfig).updates?.url ?? null;
const jobProfile = ctx.job.buildProfile ?? null;
ctx.logger.warn(
`This build has an invalid EAS Update configuration: update.url is set to "${easUpdateUrl}" in app config, but a channel is not specified${
@@ -220,8 +220,8 @@ export async function getRuntimeVersionAsync(ctx: BuildContext<Job>): Promise<st
}
}
export function isEASUpdateConfigured(ctx: BuildContext<Job>): boolean {
const rawUrl = ctx.appConfig.updates?.url;
export async function isEASUpdateConfigured(ctx: BuildContext<Job>): Promise<boolean> {
const rawUrl = (await ctx.appConfig).updates?.url;
if (!rawUrl) {
return false;
}
@@ -0,0 +1,21 @@
import * as PackageManagerUtils from '@expo/package-manager';
import { hashFiles } from '@expo/steps';
import path from 'path';
import { findPackagerRootDir } from './packageManager';
export const GRADLE_CACHE_KEY_PREFIX = 'android-gradle-cache-';
export async function generateGradleCacheKeyAsync(workingDirectory: string): Promise<string> {
const packagerRunDir = findPackagerRootDir(workingDirectory);
const manager = PackageManagerUtils.createForProject(packagerRunDir);
const lockPath = path.join(packagerRunDir, manager.lockFile);
try {
return `${GRADLE_CACHE_KEY_PREFIX}${hashFiles([lockPath])}`;
} catch (err: unknown) {
throw new Error(
`Failed to read lockfile for Gradle cache key generation: ${err instanceof Error ? err.message : err}`
);
}
}
@@ -1,3 +1,4 @@
import { type bunyan } from '@expo/logger';
import * as PackageManagerUtils from '@expo/package-manager';
import spawnAsync from '@expo/turtle-spawn';
import semver from 'semver';
@@ -27,6 +28,34 @@ export function resolvePackageManager(directory: string): PackageManager {
}
}
/**
* Get the version of a package from the dist-tags.
* Returns null if the version cannot be resolved.
*/
export async function resolvePackageVersionAsync({
logger,
packageName,
distTag,
}: {
logger: bunyan;
packageName: string;
distTag: string;
}): Promise<string | null> {
try {
const { stdout } = await spawnAsync('npm', ['view', packageName, 'dist-tags', '--json'], {
stdio: 'pipe',
});
const distTags = JSON.parse(stdout);
if (distTag in distTags) {
return distTags[distTag];
}
} catch (e: unknown) {
const message = e instanceof Error ? e.message : String(e);
logger.warn(`Unable to resolve version for ${packageName}@${distTag}: ${message}`);
}
return null;
}
export function findPackagerRootDir(currentDir: string): string {
return PackageManagerUtils.resolveWorkspaceRoot(currentDir) ?? currentDir;
}
@@ -0,0 +1,174 @@
import downloadFile from '@expo/downloader';
import { bunyan } from '@expo/logger';
import fs from 'fs-extra';
import os from 'os';
import path from 'path';
import StreamZip from 'node-stream-zip';
import { BuildContext } from '../context';
let precompiledModulesPreparationPromise: Promise<void> | null = null;
const PRECOMPILED_MODULES_WAIT_TIMEOUT_MS = 30_000;
export const PRECOMPILED_MODULES_PATH = path.join(os.homedir(), '.expo', 'precompiled-modules');
export function shouldUsePrecompiledDependencies(env: Record<string, string | undefined>): boolean {
return env.EAS_USE_PRECOMPILED_MODULES === '1';
}
export function maybeStartPreparingPrecompiledModules(
ctx: BuildContext,
config: { precompiledModulesUrls: string[] }
): void {
if (!shouldUsePrecompiledDependencies(ctx.env)) {
return;
}
startPreparingPrecompiledDependencies(ctx, config.precompiledModulesUrls);
}
export function startPreparingPrecompiledDependencies(ctx: BuildContext, urls: string[]): void {
precompiledModulesPreparationPromise = preparePrecompiledDependenciesAsync({
logger: ctx.logger,
urls,
destinationDirectory: PRECOMPILED_MODULES_PATH,
cocoapodsProxyUrl: ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL,
});
}
export async function waitForPrecompiledModulesPreparationAsync(): Promise<void> {
if (!precompiledModulesPreparationPromise) {
return;
}
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
try {
await Promise.race([
precompiledModulesPreparationPromise,
new Promise<void>((_, reject) => {
timeoutHandle = setTimeout(() => {
reject(
new Error(
`Timed out waiting for precompiled dependencies after ${PRECOMPILED_MODULES_WAIT_TIMEOUT_MS / 1000} seconds`
)
);
}, PRECOMPILED_MODULES_WAIT_TIMEOUT_MS);
timeoutHandle.unref?.();
}),
]);
} finally {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
}
}
async function preparePrecompiledDependenciesAsync({
logger,
urls,
destinationDirectory,
cocoapodsProxyUrl,
}: {
logger: bunyan;
urls: string[];
destinationDirectory: string;
cocoapodsProxyUrl?: string;
}): Promise<void> {
const archiveDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'precompiled-modules-'));
const destinationParentDirectory = path.dirname(destinationDirectory);
await fs.mkdirp(destinationParentDirectory);
const stagingDirectory = await fs.mkdtemp(
path.join(destinationParentDirectory, `${path.basename(destinationDirectory)}-staging-`)
);
logger.info('');
logger.info(
{
destinationDirectory,
urls,
},
'Starting precompiled dependencies download'
);
await fs.remove(destinationDirectory);
await fs.mkdirp(destinationDirectory);
try {
const archives = urls.map((url, index) => ({
archiveName: path.basename(new URL(url).pathname),
url,
archivePath: path.join(archiveDirectory, `precompiled-modules-${index}.zip`),
}));
await Promise.all(
archives.map(async ({ url, archivePath }) => {
await downloadPrecompiledModulesAsync({
url,
archivePath,
cocoapodsProxyUrl,
logger,
});
})
);
for (const { archiveName, url, archivePath } of archives) {
logger.info({ archivePath, url }, `Downloaded ${archiveName}, extracting archive`);
await extractZipAsync(archivePath, stagingDirectory);
logger.info(
{ destinationDirectory: stagingDirectory },
`Extracted ${archiveName} into staging precompiled dependencies directory`
);
}
await fs.remove(destinationDirectory);
await fs.move(stagingDirectory, destinationDirectory);
logger.info({ destinationDirectory }, `Precompiled modules ready in ${destinationDirectory}`);
} catch (error) {
await fs.remove(stagingDirectory);
await fs.remove(destinationDirectory);
await fs.mkdirp(destinationDirectory);
throw error;
} finally {
await fs.remove(archiveDirectory);
}
}
async function downloadPrecompiledModulesAsync({
url,
archivePath,
cocoapodsProxyUrl,
logger,
}: {
url: string;
archivePath: string;
cocoapodsProxyUrl?: string;
logger: bunyan;
}): Promise<void> {
const proxiedUrl = cocoapodsProxyUrl
? (() => {
const parsedUrl = new URL(url);
return `${cocoapodsProxyUrl}/${parsedUrl.hostname}${parsedUrl.pathname}${parsedUrl.search}${parsedUrl.hash}`;
})()
: null;
if (proxiedUrl) {
try {
await downloadFile(proxiedUrl, archivePath, { retry: 3 });
return;
} catch (err) {
logger.warn(
{ err, proxiedUrl },
'Failed to download through CocoaPods proxy, retrying directly'
);
}
}
await downloadFile(url, archivePath, { retry: 3 });
}
async function extractZipAsync(archivePath: string, destinationDirectory: string): Promise<void> {
const zip = new StreamZip.async({ file: archivePath });
try {
await zip.extract(null, destinationDirectory);
} finally {
await zip.close();
}
}
+3 -2
View File
@@ -19,10 +19,10 @@
"watch": "tsc --watch --preserveWatchOutput",
"build": "tsc",
"typecheck": "tsc",
"prepack": "rm -rf dist *.tsbuildinfo && tsc -p tsconfig.build.json",
"prepack": "rimraf dist \"*.tsbuildinfo\" && tsc -p tsconfig.build.json",
"test": "jest --config jest.config.ts",
"test:watch": "jest --config jest.config.ts --watch",
"clean": "rm -rf node_modules dist coverage *.tsbuildinfo"
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"fs-extra": "^11.2.0",
@@ -33,6 +33,7 @@
"@types/jest": "^29.5.12",
"@types/node": "20.14.2",
"jest": "^29.7.0",
"rimraf": "3.0.2",
"ts-jest": "^29.1.4",
"typescript": "^5.5.4"
}
@@ -26,9 +26,9 @@
"watch-allow-unused": "yarn build-allow-unused --watch --preserveWatchOutput",
"typecheck": "tsc",
"prepack": "yarn rebuild",
"rebuild": "rimraf build *.tsbuildinfo && yarn build",
"rebuild": "rimraf build \"*.tsbuildinfo\" && yarn build",
"test": "jest --passWithNoTests",
"clean": "rimraf build node_modules yarn-error.log *.tsbuildinfo"
"clean": "rimraf build node_modules yarn-error.log \"*.tsbuildinfo\""
},
"dependencies": {
"@babel/code-frame": "7.23.5",
+3 -2
View File
@@ -19,10 +19,10 @@
"watch": "tsc --watch --preserveWatchOutput",
"build": "tsc",
"typecheck": "tsc",
"prepack": "rm -rf dist *.tsbuildinfo && tsc -p tsconfig.build.json",
"prepack": "rimraf dist \"*.tsbuildinfo\" && tsc -p tsconfig.build.json",
"test": "jest --config jest.config.ts",
"test:watch": "jest --config jest.config.ts --watch",
"clean": "rm -rf node_modules dist coverage *.tsbuildinfo"
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/logger": "18.0.1",
@@ -34,6 +34,7 @@
"@types/jest": "^29.5.12",
"@types/node": "20.14.2",
"jest": "^29.7.0",
"rimraf": "3.0.2",
"ts-jest": "^29.1.4",
"typescript": "^5.5.4"
}
+3067 -359
View File
File diff suppressed because it is too large. Load diff
+4 -3
View File
@@ -28,7 +28,7 @@
"scripts": {
"postpack": "rimraf oclif.manifest.json",
"prepack": "yarn rebuild && node ./scripts/prepack.js",
"rebuild": "rimraf build *.tsbuildinfo && yarn build",
"rebuild": "rimraf build \"*.tsbuildinfo\" && yarn build",
"build": "yarn typecheck-for-build && yarn copy-new-templates",
"build-allow-unused": "tsc --project tsconfig.allowUnused.json",
"watch": "yarn typecheck-for-build --watch --preserveWatchOutput",
@@ -39,11 +39,11 @@
"version": "yarn run -T oclif readme && node scripts/patch-readme && git add README.md",
"generate-graphql-code": "graphql-codegen --config graphql-codegen.yml",
"verify-graphql-code": "./scripts/verify-graphql.sh",
"clean": "rimraf dist build tmp node_modules yarn-error.log *.tsbuildinfo",
"clean": "rimraf dist build tmp node_modules yarn-error.log \"*.tsbuildinfo\"",
"copy-new-templates": "rimraf build/commandUtils/new/templates && mkdir -p build/commandUtils/new && cp -r src/commandUtils/new/templates build/commandUtils/new"
},
"dependencies": {
"@expo/apple-utils": "2.1.13",
"@expo/apple-utils": "2.1.15",
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "10.0.6",
"@expo/config-plugins": "9.0.12",
@@ -68,6 +68,7 @@
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
"@segment/ajv-human-errors": "^2.1.2",
"@sentry/node": "7.77.0",
"@urql/core": "4.0.11",
"@urql/exchange-retry": "1.2.0",
"ajv": "8.11.0",
@@ -1,6 +1,7 @@
import {
getMockEmptyWorkflowRunsFragment,
getMockWorkflowCustomJobFragment,
getMockWorkflowRunWithBuildJobsFragment,
getMockWorkflowRunWithJobsFragment,
getMockWorkflowRunsFragment,
mockCommandContext,
@@ -124,7 +125,8 @@ describe(WorkflowLogView, () => {
.mocked(AppQuery.byIdWorkflowRunsFilteredByStatusAsync)
.mockResolvedValue(getMockWorkflowRunsFragment({ withJobs: 1 }));
let promptCalls = 0;
jest.mocked(promptAsync).mockImplementation(async () => {
let stepPrompt: any;
jest.mocked(promptAsync).mockImplementation(async prompt => {
if (promptCalls === 0) {
promptCalls++;
return { selectedRun: 'build1' };
@@ -132,13 +134,14 @@ describe(WorkflowLogView, () => {
promptCalls++;
return { selectedJob: 0 };
} else {
return { selectedStep: 'step1' };
stepPrompt = prompt;
return { selectedStep: 'step-id-1' };
}
});
jest
.mocked(fetchRawLogsForCustomJobAsync)
.mockResolvedValue(
'{"result":"test","marker":"test","buildStepDisplayName":"step1","buildStepInternalId":"step1","time":"2022-01-01T00:00:00.000Z","msg":"test"}'
'{"result":"success","marker":"end-step","buildStepDisplayName":"Install dependencies","buildStepId":"step-id-1","time":"2022-01-01T00:00:00.000Z","msg":"test"}'
);
await cmd.run();
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).toHaveBeenCalledWith(
@@ -148,6 +151,14 @@ describe(WorkflowLogView, () => {
20
);
expect(promptAsync).toHaveBeenCalledTimes(3);
expect(stepPrompt.choices).toEqual(
expect.arrayContaining([
expect.objectContaining({
title: 'Install dependencies - success',
value: 'step-id-1',
}),
])
);
expect(Log.error).not.toHaveBeenCalled();
expect(Log.log).toHaveBeenCalledWith(' 2022-01-01T00:00:00.000Z test');
});
@@ -159,6 +170,9 @@ describe(WorkflowLogView, () => {
jest
.mocked(AppQuery.byIdWorkflowRunsFilteredByStatusAsync)
.mockResolvedValue(getMockWorkflowRunsFragment({ withBuildJobs: 1 }));
jest
.mocked(WorkflowRunQuery.withJobsByIdAsync)
.mockResolvedValue(getMockWorkflowRunWithBuildJobsFragment());
jest.mocked(BuildQuery.byIdAsync).mockResolvedValue({
id: 'build1',
status: BuildStatus.Finished,
@@ -181,7 +195,8 @@ describe(WorkflowLogView, () => {
logFiles: ['https://example.com/log1'],
});
let promptCalls = 0;
jest.mocked(promptAsync).mockImplementation(async () => {
let stepPrompt: any;
jest.mocked(promptAsync).mockImplementation(async prompt => {
if (promptCalls === 0) {
promptCalls++;
return { selectedRun: 'build1' };
@@ -189,13 +204,14 @@ describe(WorkflowLogView, () => {
promptCalls++;
return { selectedJob: 0 };
} else {
return { selectedStep: 'step1' };
stepPrompt = prompt;
return { selectedStep: 'step-id-1' };
}
});
jest
.mocked(fetchRawLogsForBuildJobAsync)
.mockResolvedValue(
'{"result":"test","marker":"test","buildStepDisplayName":"step1","buildStepInternalId":"step1","time":"2022-01-01T00:00:00.000Z","msg":"test"}'
'{"result":"success","marker":"end-step","buildStepDisplayName":"step1","buildStepId":"step-id-1","time":"2022-01-01T00:00:00.000Z","msg":"test"}'
);
await cmd.run();
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).toHaveBeenCalledWith(
@@ -205,9 +221,65 @@ describe(WorkflowLogView, () => {
20
);
expect(promptAsync).toHaveBeenCalledTimes(3);
expect(stepPrompt.choices).toEqual(
expect.arrayContaining([
expect.objectContaining({
title: 'step1 - success',
value: 'step-id-1',
}),
])
);
expect(Log.error).not.toHaveBeenCalled();
expect(Log.log).toHaveBeenCalledWith(' 2022-01-01T00:00:00.000Z test');
});
test('view logs keeps duplicate display names separated by step id', async () => {
const ctx = mockCommandContext(WorkflowLogView, {
projectId: mockProjectId,
});
const cmd = mockTestCommand(WorkflowLogView, [], ctx);
jest
.mocked(AppQuery.byIdWorkflowRunsFilteredByStatusAsync)
.mockResolvedValue(getMockWorkflowRunsFragment({ withJobs: 1 }));
let promptCalls = 0;
let stepPrompt: any;
jest.mocked(promptAsync).mockImplementation(async prompt => {
if (promptCalls === 0) {
promptCalls++;
return { selectedRun: 'build1' };
} else if (promptCalls === 1) {
promptCalls++;
return { selectedJob: 0 };
} else {
stepPrompt = prompt;
return { selectedStep: 'step-id-2' };
}
});
jest
.mocked(fetchRawLogsForCustomJobAsync)
.mockResolvedValue(
[
'{"result":"success","marker":"end-step","buildStepDisplayName":"Install","buildStepId":"step-id-1","time":"2022-01-01T00:00:00.000Z","msg":"first"}',
'{"result":"fail","marker":"end-step","buildStepDisplayName":"Install","buildStepId":"step-id-2","time":"2022-01-01T00:00:01.000Z","msg":"second"}',
].join('\n')
);
await cmd.run();
expect(promptAsync).toHaveBeenCalledTimes(3);
expect(stepPrompt.choices).toEqual(
expect.arrayContaining([
expect.objectContaining({
title: 'Install - success',
value: 'step-id-1',
}),
expect.objectContaining({
title: 'Install - fail',
value: 'step-id-2',
}),
])
);
expect(Log.log).toHaveBeenCalledWith(' 2022-01-01T00:00:01.000Z second');
});
test('view logs for a workflow job, passing in a job ID, all steps', async () => {
const ctx = mockCommandContext(WorkflowLogView, {
projectId: mockProjectId,
@@ -12,8 +12,8 @@ import slash from 'slash';
import { AndroidCredentials } from '../../credentials/android/AndroidCredentialsProvider';
import { getCustomBuildConfigPathForJob } from '../../project/customBuildConfig';
import { getUsernameForBuildMetadataAndBuildJob } from '../../project/projectUtils';
import { BuildContext } from '../context';
import { getActorUsername } from '../../user/User';
interface JobData {
projectArchive: ArchiveSource;
@@ -29,7 +29,7 @@ export async function prepareJobAsync(
ctx: BuildContext<Platform.ANDROID>,
jobData: JobData
): Promise<Android.Job> {
const username = getUsernameForBuildMetadataAndBuildJob(ctx.user);
const username = getActorUsername(ctx.user) ?? undefined;
const buildProfile: BuildProfile<Platform.ANDROID> = ctx.buildProfile;
const projectRootDirectory =
slash(path.relative(await ctx.vcsClient.getRootPathAsync(), ctx.projectDir)) || '.';
+2 -2
View File
@@ -14,8 +14,8 @@ import slash from 'slash';
import { IosCredentials, TargetCredentials } from '../../credentials/ios/types';
import { IosJobSecretsInput } from '../../graphql/generated';
import { getCustomBuildConfigPathForJob } from '../../project/customBuildConfig';
import { getUsernameForBuildMetadataAndBuildJob } from '../../project/projectUtils';
import { BuildContext } from '../context';
import { getActorUsername } from '../../user/User';
interface JobData {
projectArchive: ArchiveSource;
@@ -32,7 +32,7 @@ export async function prepareJobAsync(
ctx: BuildContext<Platform.IOS>,
jobData: JobData
): Promise<Ios.Job> {
const username = getUsernameForBuildMetadataAndBuildJob(ctx.user);
const username = getActorUsername(ctx.user) ?? undefined;
const buildProfile: BuildProfile<Platform.IOS> = ctx.buildProfile;
const projectRootDirectory =
slash(path.relative(await ctx.vcsClient.getRootPathAsync(), ctx.projectDir)) || '.';
+2 -2
View File
@@ -10,12 +10,12 @@ import { LocalBuildMode } from './local';
import { BuildDistributionType } from './types';
import Log from '../log';
import {
getUsernameForBuildMetadataAndBuildJob,
isExpoUpdatesInstalled,
} from '../project/projectUtils';
import { readChannelSafelyAsync as readAndroidChannelSafelyAsync } from '../update/android/UpdatesModule';
import { readChannelSafelyAsync as readIosChannelSafelyAsync } from '../update/ios/UpdatesModule';
import { easCliVersion } from '../utils/easCli';
import { getActorUsername } from '../user/User';
export async function collectMetadataAsync<T extends Platform>(
ctx: BuildContext<T>,
@@ -49,7 +49,7 @@ export async function collectMetadataAsync<T extends Platform>(
ctx.localBuildOptions.localBuildMode === LocalBuildMode.INTERNAL
? false
: await ctx.vcsClient.hasUncommittedChangesAsync(),
username: getUsernameForBuildMetadataAndBuildJob(ctx.user),
username: getActorUsername(ctx.user) ?? undefined,
message: ctx.message,
...(ctx.platform === Platform.IOS && {
iosEnterpriseProvisioning: resolveIosEnterpriseProvisioning(
@@ -27,7 +27,9 @@ import {
createAnalyticsAsync,
} from '../analytics/AnalyticsManager';
import Log, { link } from '../log';
import Sentry from '../sentry';
import SessionManager from '../user/SessionManager';
import { getActorDisplayName } from '../user/User';
import { Client } from '../vcs/vcs';
export type ContextInput<
@@ -169,7 +171,6 @@ export default abstract class EasCommand extends Command {
* It is set up here to ensure a consistent setup.
*/
private analyticsInternal?: AnalyticsWithOrchestration;
/**
* Execute the context in the contextDefinition to satisfy command prerequisites.
*/
@@ -230,7 +231,13 @@ export default abstract class EasCommand extends Command {
// this is needed for logEvent call below as it identifies the user in the analytics system
// if possible
await this.sessionManager.getUserAsync();
const actor = await this.sessionManager.getUserAsync();
if (actor) {
Sentry.setUser({
id: actor.id,
username: getActorDisplayName(actor),
});
}
this.analytics.logEvent(CommandEvent.ACTION, {
// id is assigned by oclif in constructor based on the filepath:
@@ -244,11 +251,13 @@ export default abstract class EasCommand extends Command {
// eslint-disable-next-line async-protect/async-suffix
override async finally(err: Error): Promise<any> {
await this.analytics.flushAsync();
await Sentry.flush(2000);
return await super.finally(err);
}
protected override catch(err: Error): Promise<any> {
let baseMessage = `${this.id} command failed.`;
const commandId = this.id ?? 'unknown';
let baseMessage = `${commandId} command failed.`;
if (err instanceof EasCommandError) {
Log.error(err.message);
} else if (err instanceof CombinedError && err?.graphQLErrors) {
@@ -293,6 +302,17 @@ export default abstract class EasCommand extends Command {
Log.error(err.message);
}
Log.debug(err);
Sentry.withScope(scope => {
scope.setTag('command', commandId);
scope.setTag('error_name', err.name);
scope.setExtra('commandId', commandId);
scope.setExtra('commandMessage', baseMessage);
scope.setExtra('originalMessage', err.message);
if (err instanceof CombinedError) {
scope.setExtra('graphQLErrorCount', err.graphQLErrors.length);
}
Sentry.captureException(err);
});
const sanitizedError = new Error(baseMessage);
sanitizedError.stack = err.stack;
throw sanitizedError;
@@ -1,14 +1,20 @@
import { CombinedError } from '@urql/core';
import { GraphQLError } from 'graphql/error';
import { v4 as uuidv4 } from 'uuid';
import { AnalyticsWithOrchestration, createAnalyticsAsync } from '../../analytics/AnalyticsManager';
import Log from '../../log';
import SessionManager from '../../user/SessionManager';
import EasCommand from '../EasCommand';
import { AnalyticsWithOrchestration } from '../../analytics/AnalyticsManager';
jest.mock('../../user/User');
jest.mock('../../user/SessionManager');
jest.mock('../../sentry', () => ({
__esModule: true,
default: {
init: jest.fn(),
setTag: jest.fn(),
setUser: jest.fn(),
withScope: jest.fn(),
captureException: jest.fn(),
flush: jest.fn(async (): Promise<void> => {}),
},
}));
jest.mock('../../analytics/AnalyticsManager', () => {
const { CommandEvent } = jest.requireActual('../../analytics/AnalyticsManager');
return {
@@ -38,20 +44,22 @@ const analytics: AnalyticsWithOrchestration = {
beforeEach(() => {
jest.resetAllMocks();
jest.mocked(createAnalyticsAsync).mockResolvedValue(analytics);
});
const createTestEasCommand = (): any => {
const { createAnalyticsAsync } = jest.requireMock('../../analytics/AnalyticsManager');
createAnalyticsAsync.mockResolvedValue(analytics);
const EasCommand = require('../EasCommand').default;
class TestEasCommand extends EasCommand {
override async runAsync(): Promise<void> {}
async runAsync(): Promise<void> {}
}
TestEasCommand.id = 'testEasCommand'; // normally oclif will assign ids, but b/c this is located outside the commands folder it will not
return TestEasCommand;
};
describe(EasCommand.name, () => {
describe('EasCommand', () => {
describe('without exceptions', () => {
// The first test in this suite should have an increased timeout
// because of the implementation of Command from @oclif/command.
@@ -64,6 +72,7 @@ describe(EasCommand.name, () => {
const TestEasCommand = createTestEasCommand();
await TestEasCommand.run();
const SessionManager = jest.requireMock('../../user/SessionManager').default;
const sessionManagerSpy = jest.spyOn(SessionManager.prototype, 'getUserAsync');
expect(sessionManagerSpy).toBeCalledTimes(1);
}, 60_000);
@@ -72,6 +81,7 @@ describe(EasCommand.name, () => {
const TestEasCommand = createTestEasCommand();
await TestEasCommand.run();
const { createAnalyticsAsync } = jest.requireMock('../../analytics/AnalyticsManager');
expect(createAnalyticsAsync).toHaveBeenCalled();
});
@@ -82,6 +92,14 @@ describe(EasCommand.name, () => {
expect(analytics.flushAsync).toHaveBeenCalled();
});
it('flushes Sentry', async () => {
const TestEasCommand = createTestEasCommand();
await TestEasCommand.run();
const Sentry = jest.requireMock('../../sentry').default;
expect(Sentry.flush).toHaveBeenCalled();
});
it('logs events', async () => {
const TestEasCommand = createTestEasCommand();
await TestEasCommand.run();
@@ -105,8 +123,59 @@ describe(EasCommand.name, () => {
});
describe('catch', () => {
it('captures unexpected errors', async () => {
const TestEasCommand = createTestEasCommand();
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
const error = new Error('Unexpected, internal error message');
const sentryScope = {
setTag: jest.fn(),
setExtra: jest.fn(),
};
const Sentry = jest.requireMock('../../sentry').default;
Sentry.withScope.mockImplementation((cb: (scope: typeof sentryScope) => void) => {
cb(sentryScope);
});
runAsyncMock.mockImplementation(() => {
throw error;
});
try {
await TestEasCommand.run();
} catch {}
expect(sentryScope.setTag).toHaveBeenCalledWith('command', TestEasCommand.id);
expect(sentryScope.setTag).toHaveBeenCalledWith('error_name', error.name);
expect(sentryScope.setExtra).toHaveBeenCalledWith('commandId', TestEasCommand.id);
expect(sentryScope.setExtra).toHaveBeenCalledWith(
'commandMessage',
`${TestEasCommand.id} command failed.`
);
expect(sentryScope.setExtra).toHaveBeenCalledWith('originalMessage', error.message);
expect(Sentry.captureException).toHaveBeenCalledWith(error);
});
it('captures expected command errors', async () => {
const TestEasCommand = createTestEasCommand();
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
const { EasCommandError } = require('../errors');
const Sentry = jest.requireMock('../../sentry').default;
Sentry.withScope.mockImplementation((cb: (scope: any) => void) => {
cb({ setTag: jest.fn(), setExtra: jest.fn() });
});
runAsyncMock.mockImplementation(() => {
throw new EasCommandError('Expected user-facing error');
});
try {
await TestEasCommand.run();
} catch {}
expect(Sentry.captureException).toHaveBeenCalledTimes(1);
});
it('logs the message', async () => {
const TestEasCommand = createTestEasCommand();
const Log = jest.requireMock('../../log').default;
const logErrorSpy = jest.spyOn(Log, 'error');
const logDebugSpy = jest.spyOn(Log, 'debug');
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
@@ -124,9 +193,11 @@ describe(EasCommand.name, () => {
it('logs the cleaned message if needed', async () => {
const TestEasCommand = createTestEasCommand();
const Log = jest.requireMock('../../log').default;
const logErrorSpy = jest.spyOn(Log, 'error');
const logDebugSpy = jest.spyOn(Log, 'debug');
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
const { CombinedError } = require('@urql/core');
const graphQLErrors = ['Unexpected GraphQL error message'];
const error = new CombinedError({ graphQLErrors });
runAsyncMock.mockImplementation(() => {
@@ -142,9 +213,12 @@ describe(EasCommand.name, () => {
it('logs the cleaned message with request ID if present', async () => {
const TestEasCommand = createTestEasCommand();
const Log = jest.requireMock('../../log').default;
const logErrorSpy = jest.spyOn(Log, 'error');
const logDebugSpy = jest.spyOn(Log, 'debug');
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
const { CombinedError } = require('@urql/core');
const { GraphQLError } = require('graphql/error');
const graphQLError = new GraphQLError(
'Unexpected GraphQL error message',
null,
@@ -174,9 +248,12 @@ describe(EasCommand.name, () => {
it('logs the cleaned messages with request IDs if multiple GraphQL errors present', async () => {
const TestEasCommand = createTestEasCommand();
const Log = jest.requireMock('../../log').default;
const logErrorSpy = jest.spyOn(Log, 'error');
const logDebugSpy = jest.spyOn(Log, 'debug');
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
const { CombinedError } = require('@urql/core');
const { GraphQLError } = require('graphql/error');
const graphQLErrors = [
new GraphQLError('Unexpected GraphQL error message', null, null, null, null, null, {
errorCode: 'UNKNOWN_GRAPHQL_ERROR',
@@ -227,6 +304,7 @@ describe(EasCommand.name, () => {
it('re-throws the error with a different default base message in case of CombinedError', async () => {
const TestEasCommand = createTestEasCommand();
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
const { CombinedError } = require('@urql/core');
runAsyncMock.mockImplementation(() => {
const graphQLErrors = ['Unexpected GraphQL error message'];
throw new CombinedError({ graphQLErrors });
@@ -242,6 +320,8 @@ describe(EasCommand.name, () => {
it('re-throws the error with a different default base message in case of CombinedError with multiple GraphQLErrors', async () => {
const TestEasCommand = createTestEasCommand();
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
const { CombinedError } = require('@urql/core');
const { GraphQLError } = require('graphql/error');
runAsyncMock.mockImplementation(() => {
const graphQLErrors = [
new GraphQLError('Unexpected GraphQL error message', null, null, null, null, null, {
@@ -189,6 +189,8 @@ export async function validateOrSetProjectIdAsync({
return user.username;
case 'SSOUser':
return user.username;
case 'PartnerActor':
return user.username;
case 'Robot':
throw new Error(
'Must configure EAS project by running "eas init" before using a robot user to manage the project.'
@@ -0,0 +1,35 @@
import { getMockWorkflowRunWithJobsFragment } from '../../../__tests__/commands/utils';
import { fetchRawLogsForCustomJobAsync } from '../fetchLogs';
import { infoForActiveWorkflowRunAsync } from '../utils';
import { WorkflowJobStatus } from '../../../graphql/generated';
jest.mock('../fetchLogs');
describe('workflow utils', () => {
afterEach(() => {
jest.clearAllMocks();
});
test('shows the display name for the current step while keying logs by step id', async () => {
const workflowRun = getMockWorkflowRunWithJobsFragment();
workflowRun.jobs = workflowRun.jobs.map(job => ({
...job,
status: WorkflowJobStatus.InProgress,
}));
jest
.mocked(fetchRawLogsForCustomJobAsync)
.mockResolvedValue(
[
'{"buildStepId":"step-id-1","buildStepDisplayName":"Install dependencies","time":"2022-01-01T00:00:00.000Z","msg":"npm ci"}',
'{"buildStepId":"step-id-1","buildStepDisplayName":"Install dependencies","marker":"end-step","result":"success","time":"2022-01-01T00:00:01.000Z","msg":"done"}',
].join('\n')
);
const output = await infoForActiveWorkflowRunAsync({} as any, workflowRun);
expect(output).toContain('Current step');
expect(output).toContain('Install dependencies');
expect(output).not.toContain('step-id-1');
});
});
@@ -22,6 +22,17 @@ export async function fetchRawLogsForBuildJobAsync(
state: { graphqlClient: ExpoGraphqlClient },
job: WorkflowJobResult
): Promise<string | null> {
// Prefer turtleJobRun logs, which contain JSONL-formatted step logs
// for both built-in and custom build steps
const turtleLogFileUrl = job.turtleJobRun?.logFileUrls?.[0];
if (turtleLogFileUrl) {
const response = await fetch(turtleLogFileUrl, {
method: 'GET',
});
return await response.text();
}
// Fall back to build logFiles
const buildId = job.outputs?.build_id;
if (!buildId) {
return null;
@@ -36,6 +47,5 @@ export async function fetchRawLogsForBuildJobAsync(
const response = await fetch(firstLogFileUrl, {
method: 'GET',
});
const rawLogs = await response.text();
return rawLogs;
return await response.text();
}
@@ -50,4 +50,11 @@ export type WorkflowLogLine = {
err?: any;
};
export type WorkflowLogs = Map<string, WorkflowLogLine[]>;
export type WorkflowLogs = Map<
string,
{
key: string;
label: string;
logLines: WorkflowLogLine[];
}
>;
@@ -84,16 +84,17 @@ export function choiceFromWorkflowJob(job: WorkflowJobResult, index: number): Ch
export function choicesFromWorkflowLogs(
logs: WorkflowLogs
): (Choice & { name: string; status: string; logLines: WorkflowLogLine[] | undefined })[] {
return Array.from(logs.keys())
.map(step => {
const logLines = logs.get(step);
return Array.from(logs.values())
.map(({ key, label, logLines }) => {
const stepStatus =
logLines?.filter((line: WorkflowLogLine) => line.marker === 'end-step')[0]?.result ?? '';
logLines?.filter(
(line: WorkflowLogLine) => line.marker === 'end-step' || line.marker === 'END_PHASE'
)[0]?.result ?? '';
return {
title: `${step} - ${stepStatus}`,
name: step,
title: `${label} - ${stepStatus}`,
name: label,
status: stepStatus,
value: step,
value: key,
logLines,
};
})
@@ -139,20 +140,27 @@ export async function fetchAndProcessLogsFromJobAsync(
}
Log.debug(`rawLogs = ${JSON.stringify(rawLogs, null, 2)}`);
const logs: WorkflowLogs = new Map();
const logKeys = new Set<string>();
rawLogs.split('\n').forEach((line, index) => {
Log.debug(`line ${index} = ${JSON.stringify(line, null, 2)}`);
try {
const parsedLine = JSON.parse(line);
const { buildStepDisplayName, buildStepInternalId, time, msg, result, marker, err } =
const { buildStepDisplayName, buildStepId, phase, time, msg, result, marker, err } =
parsedLine;
const stepId = buildStepDisplayName ?? buildStepInternalId;
if (stepId) {
if (!logKeys.has(stepId)) {
logKeys.add(stepId);
logs.set(stepId, []);
const stepKey = buildStepId ?? buildStepDisplayName ?? phase;
const stepLabel = buildStepDisplayName ?? buildStepId ?? phase;
if (stepKey && stepLabel) {
if (!logs.has(stepKey)) {
logs.set(stepKey, {
key: stepKey,
label: stepLabel,
logLines: [],
});
}
logs.get(stepId)?.push({ time, msg, result, marker, err });
const logGroup = logs.get(stepKey)!;
if (buildStepDisplayName) {
logGroup.label = buildStepDisplayName;
}
logGroup.logLines.push({ time, msg, result, marker, err });
}
} catch {}
});
@@ -238,7 +246,7 @@ export async function infoForFailedWorkflowRunAsync(
statusValues.push({ label: '', value: '' });
statusValues.push({ label: ' Failed job', value: job.name });
if (steps.length > 0) {
const failedStep = steps.find(step => step.status === 'fail');
const failedStep = steps.find(step => step.status === 'fail' || step.status === 'failed');
if (failedStep) {
const logs = failedStep.logLines?.map(line => line.msg).slice(-logLinesToKeep) ?? [];
statusValues.push({ label: ' Failed step', value: failedStep.name });
+11 -4
View File
@@ -21,6 +21,10 @@ export default class MetadataPull extends EasCommand {
description:
'Name of the submit profile from eas.json. Defaults to "production" if defined in eas.json.',
}),
'non-interactive': Flags.boolean({
default: false,
description: 'Run the command in non-interactive mode.',
}),
};
static override contextDefinition = {
@@ -34,18 +38,18 @@ export default class MetadataPull extends EasCommand {
Log.warn('EAS Metadata is in beta and subject to breaking changes.');
const { flags } = await this.parse(MetadataPull);
const nonInteractive = flags['non-interactive'];
const {
loggedIn: { actor, graphqlClient },
privateProjectConfig: { exp, projectId, projectDir },
analytics,
vcsClient,
} = await this.getContextAsync(MetadataPull, {
nonInteractive: false,
nonInteractive,
withServerSideEnvironment: null,
});
// this command is interactive (all nonInteractive flags passed to utility functions are false)
await ensureProjectConfiguredAsync({ projectDir, nonInteractive: false, vcsClient });
await ensureProjectConfiguredAsync({ projectDir, nonInteractive, vcsClient });
const submitProfiles = await getProfilesAsync({
type: 'submit',
@@ -66,7 +70,7 @@ export default class MetadataPull extends EasCommand {
user: actor,
graphqlClient,
analytics,
nonInteractive: false,
nonInteractive,
vcsClient,
});
@@ -77,6 +81,9 @@ export default class MetadataPull extends EasCommand {
credentialsCtx,
projectDir,
profile: submitProfile,
nonInteractive,
graphqlClient,
projectId,
});
const relativePath = path.relative(process.cwd(), filePath);
+11 -4
View File
@@ -19,6 +19,10 @@ export default class MetadataPush extends EasCommand {
description:
'Name of the submit profile from eas.json. Defaults to "production" if defined in eas.json.',
}),
'non-interactive': Flags.boolean({
default: false,
description: 'Run the command in non-interactive mode.',
}),
};
static override contextDefinition = {
@@ -32,18 +36,18 @@ export default class MetadataPush extends EasCommand {
Log.warn('EAS Metadata is in beta and subject to breaking changes.');
const { flags } = await this.parse(MetadataPush);
const nonInteractive = flags['non-interactive'];
const {
loggedIn: { actor, graphqlClient },
privateProjectConfig: { exp, projectId, projectDir },
analytics,
vcsClient,
} = await this.getContextAsync(MetadataPush, {
nonInteractive: false,
nonInteractive,
withServerSideEnvironment: null,
});
// this command is interactive (all nonInteractive flags passed to utility functions are false)
await ensureProjectConfiguredAsync({ projectDir, nonInteractive: false, vcsClient });
await ensureProjectConfiguredAsync({ projectDir, nonInteractive, vcsClient });
const submitProfiles = await getProfilesAsync({
type: 'submit',
@@ -64,7 +68,7 @@ export default class MetadataPush extends EasCommand {
user: actor,
graphqlClient,
analytics,
nonInteractive: false,
nonInteractive,
vcsClient,
});
@@ -75,6 +79,9 @@ export default class MetadataPush extends EasCommand {
credentialsCtx,
projectDir,
profile: submitProfile,
nonInteractive,
graphqlClient,
projectId,
});
Log.addNewLineIfNone();
@@ -73,6 +73,12 @@ export default class Onboarding extends EasCommand {
);
}
if (actor.__typename === 'PartnerActor') {
throw new Error(
'This command is not available for partner actors. Make sure you are using a user token and try again.'
);
}
if (!actor.preferences.onboarding) {
throw new Error(
'This command can only be run as part of the onboarding process started on the Expo website. Visit https://expo.new to start a new project.'
+16 -12
View File
@@ -12,14 +12,14 @@ import Log from '../../log';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
function printLogsForAllSteps(logs: WorkflowLogs): void {
[...logs.keys()].forEach(step => {
const logLines = logs.get(step);
if (logLines) {
Log.log(`Step: ${step}`);
logLines.forEach(line => {
Log.log(` ${line.time} ${line.msg}`);
});
[...logs.values()].forEach(({ label, logLines }) => {
if (logLines.length === 0) {
return;
}
Log.log(`Step: ${label}`);
logLines.forEach(line => {
Log.log(` ${line.time} ${line.msg}`);
});
Log.addNewLineIfNone();
});
}
@@ -89,21 +89,25 @@ export default class WorkflowLogView extends EasCommand {
if (allSteps) {
if (logs) {
if (flags.json) {
printJsonOnlyOutput(Object.fromEntries(logs));
printJsonOnlyOutput(
Object.fromEntries(
Array.from(logs.entries()).map(([key, value]) => [key, value.logLines])
)
);
} else {
printLogsForAllSteps(logs);
}
}
} else {
const selectedStep = finalSelectionState?.step as unknown as string;
const logLines = logs?.get(selectedStep);
if (logLines) {
const logGroup = logs?.get(selectedStep);
if (logGroup) {
if (flags.json) {
const output: { [key: string]: WorkflowLogLine[] | null } = {};
output[selectedStep] = logLines ?? null;
output[selectedStep] = logGroup.logLines;
printJsonOnlyOutput(output);
} else {
logLines.forEach(line => {
logGroup.logLines.forEach(line => {
Log.log(` ${line.time} ${line.msg}`);
});
}
+1605 -8236
View File
File diff suppressed because it is too large. Load diff
@@ -43,6 +43,9 @@ export const UserQuery = {
id
...AccountFragment
}
... on PartnerActor {
username
}
featureGates
isExpoAdmin
}
@@ -23,6 +23,9 @@ export const UpdateFragmentNode = gql`
... on Robot {
firstName
}
... on PartnerActor {
username
}
}
branch {
id
@@ -0,0 +1,232 @@
import { App } from '@expo/apple-utils';
import { SubmitProfile } from '@expo/eas-json';
import { getAppStoreAuthAsync } from '../auth';
import { AuthenticationMode } from '../../credentials/ios/appstore/authenticateTypes';
// Mock all external dependencies
jest.mock('@expo/apple-utils', () => ({
App: { findAsync: jest.fn() },
}));
jest.mock('../../credentials/ios/appstore/authenticate', () => ({
getRequestContext: jest.fn(() => ({ token: 'mock-token' })),
}));
jest.mock('../../credentials/ios/appstore/resolveCredentials', () => ({
hasAscEnvVars: jest.fn(() => false),
}));
jest.mock('../../project/ios/bundleIdentifier', () => ({
getBundleIdentifierAsync: jest.fn(() => 'com.example.app'),
}));
jest.mock('../../project/projectUtils', () => ({
getOwnerAccountForProjectIdAsync: jest.fn(() => ({
id: 'account-id',
name: 'testaccount',
})),
}));
jest.mock('../../credentials/ios/api/GraphqlClient', () => ({
getAscApiKeyForAppSubmissionsAsync: jest.fn(() => null),
}));
jest.mock('../../graphql/queries/AppStoreConnectApiKeyQuery', () => ({
AppStoreConnectApiKeyQuery: {
getByIdAsync: jest.fn(),
},
}));
jest.mock('fs', () => ({
promises: {
readFile: jest.fn(() => 'mock-key-p8-content'),
},
}));
jest.mock('../../log');
const { hasAscEnvVars } =
require('../../credentials/ios/appstore/resolveCredentials') as jest.Mocked<
typeof import('../../credentials/ios/appstore/resolveCredentials')
>;
const { getAscApiKeyForAppSubmissionsAsync } =
require('../../credentials/ios/api/GraphqlClient') as jest.Mocked<
typeof import('../../credentials/ios/api/GraphqlClient')
>;
const { AppStoreConnectApiKeyQuery } =
require('../../graphql/queries/AppStoreConnectApiKeyQuery') as jest.Mocked<
typeof import('../../graphql/queries/AppStoreConnectApiKeyQuery')
>;
const mockApp = { id: '123', bundleId: 'com.example.app' };
function createMockCredentialsCtx(ensureAuthResult?: any) {
return {
appStore: {
ensureAuthenticatedAsync: jest.fn(() => ({
authState: { context: { token: 'mock-token' } },
...ensureAuthResult,
})),
},
vcsClient: {},
} as any;
}
function createBaseArgs(overrides: Record<string, any> = {}) {
return {
projectDir: '/app',
profile: { bundleIdentifier: 'com.example.app' } as SubmitProfile,
exp: { slug: 'my-app', name: 'My App' } as any,
credentialsCtx: createMockCredentialsCtx(),
nonInteractive: false,
graphqlClient: {} as any,
projectId: 'test-project-id',
...overrides,
};
}
describe(getAppStoreAuthAsync, () => {
beforeEach(() => {
jest.clearAllMocks();
(App.findAsync as jest.Mock).mockResolvedValue(mockApp);
hasAscEnvVars.mockReturnValue(false);
(getAscApiKeyForAppSubmissionsAsync as jest.Mock).mockResolvedValue(null);
});
it('uses API key auth when ASC env vars are set', async () => {
hasAscEnvVars.mockReturnValue(true);
const args = createBaseArgs();
const result = await getAppStoreAuthAsync(args);
expect(args.credentialsCtx.appStore.ensureAuthenticatedAsync).toHaveBeenCalledWith(
expect.objectContaining({ mode: AuthenticationMode.API_KEY })
);
expect(result.app).toBe(mockApp);
});
it('uses API key from submit profile when ascApiKeyPath/Id/IssuerId are set', async () => {
const profile = {
bundleIdentifier: 'com.example.app',
ascApiKeyPath: '/path/to/key.p8',
ascApiKeyId: 'KEY123',
ascApiKeyIssuerId: 'ISSUER456',
} as any;
const args = createBaseArgs({ profile });
const result = await getAppStoreAuthAsync(args);
expect(args.credentialsCtx.appStore.ensureAuthenticatedAsync).toHaveBeenCalledWith(
expect.objectContaining({
mode: AuthenticationMode.API_KEY,
ascApiKey: {
keyP8: 'mock-key-p8-content',
keyId: 'KEY123',
issuerId: 'ISSUER456',
},
})
);
expect(result.app).toBe(mockApp);
});
it('uses API key from EAS credentials service when available', async () => {
(getAscApiKeyForAppSubmissionsAsync as jest.Mock).mockResolvedValue({
id: 'asc-key-id',
keyIdentifier: 'KEY789',
issuerIdentifier: 'ISSUER012',
appleTeam: {
appleTeamIdentifier: 'TEAM123',
appleTeamName: 'Test Team',
},
});
(AppStoreConnectApiKeyQuery.getByIdAsync as jest.Mock).mockResolvedValue({
keyP8: 'stored-key-p8',
keyIdentifier: 'KEY789',
issuerIdentifier: 'ISSUER012',
});
const args = createBaseArgs();
const result = await getAppStoreAuthAsync(args);
expect(AppStoreConnectApiKeyQuery.getByIdAsync).toHaveBeenCalledWith(
args.graphqlClient,
'asc-key-id'
);
expect(args.credentialsCtx.appStore.ensureAuthenticatedAsync).toHaveBeenCalledWith(
expect.objectContaining({
mode: AuthenticationMode.API_KEY,
ascApiKey: {
keyP8: 'stored-key-p8',
keyId: 'KEY789',
issuerId: 'ISSUER012',
},
teamId: 'TEAM123',
teamName: 'Test Team',
})
);
expect(result.app).toBe(mockApp);
});
it('falls back to interactive cookie auth when no API key and interactive mode', async () => {
const args = createBaseArgs({ nonInteractive: false });
const result = await getAppStoreAuthAsync(args);
// Should call ensureAuthenticatedAsync without API key options
expect(args.credentialsCtx.appStore.ensureAuthenticatedAsync).toHaveBeenCalledWith();
expect(result.app).toBe(mockApp);
});
it('throws in non-interactive mode when no API key is available', async () => {
const args = createBaseArgs({ nonInteractive: true });
await expect(getAppStoreAuthAsync(args)).rejects.toThrow('No App Store Connect API Key found');
});
it('falls back gracefully when credentials service lookup fails', async () => {
(getAscApiKeyForAppSubmissionsAsync as jest.Mock).mockRejectedValue(new Error('Network error'));
// Interactive mode: should fall back to cookie auth
const args = createBaseArgs({ nonInteractive: false });
const result = await getAppStoreAuthAsync(args);
expect(args.credentialsCtx.appStore.ensureAuthenticatedAsync).toHaveBeenCalledWith();
expect(result.app).toBe(mockApp);
});
it('falls back gracefully when credentials service fails in non-interactive mode', async () => {
(getAscApiKeyForAppSubmissionsAsync as jest.Mock).mockRejectedValue(new Error('Network error'));
const args = createBaseArgs({ nonInteractive: true });
await expect(getAppStoreAuthAsync(args)).rejects.toThrow('No App Store Connect API Key found');
});
it('prefers submit profile key over credentials service', async () => {
const profile = {
bundleIdentifier: 'com.example.app',
ascApiKeyPath: '/path/to/key.p8',
ascApiKeyId: 'PROFILE_KEY',
ascApiKeyIssuerId: 'PROFILE_ISSUER',
} as any;
// Also set up a stored key that should NOT be used
(getAscApiKeyForAppSubmissionsAsync as jest.Mock).mockResolvedValue({
id: 'stored-key-id',
keyIdentifier: 'STORED_KEY',
issuerIdentifier: 'STORED_ISSUER',
});
const args = createBaseArgs({ profile });
await getAppStoreAuthAsync(args);
// Should use profile key, not the stored one
expect(args.credentialsCtx.appStore.ensureAuthenticatedAsync).toHaveBeenCalledWith(
expect.objectContaining({
ascApiKey: expect.objectContaining({ keyId: 'PROFILE_KEY' }),
})
);
// Should NOT have called getByIdAsync since profile key was found first
expect(AppStoreConnectApiKeyQuery.getByIdAsync).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,100 @@
import fs from 'fs-extra';
import { downloadMetadataAsync } from '../download';
import { MetadataValidationError } from '../errors';
jest.mock('../auth', () => ({
getAppStoreAuthAsync: jest.fn(() => ({
app: { id: '123' },
auth: { context: { token: 'mock' } },
})),
}));
jest.mock('../apple/tasks', () => ({
createAppleTasks: jest.fn(() => []),
}));
jest.mock('../config/resolve', () => ({
createAppleWriter: jest.fn(() => ({
toSchema: jest.fn(() => ({ configVersion: 0 })),
})),
getStaticConfigFilePath: jest.fn(() => '/app/store.config.json'),
}));
jest.mock('../utils/telemetry', () => ({
subscribeTelemetryAsync: jest.fn(() => ({
unsubscribeTelemetry: jest.fn(),
executionId: 'exec-123',
})),
}));
jest.mock('fs-extra', () => ({
pathExists: jest.fn(() => false),
writeJSON: jest.fn(),
}));
jest.mock('../../log');
jest.mock('../../prompts', () => ({
confirmAsync: jest.fn(),
}));
const { confirmAsync } = require('../../prompts') as jest.Mocked<typeof import('../../prompts')>;
function createArgs(overrides: Record<string, any> = {}) {
return {
projectDir: '/app',
profile: { bundleIdentifier: 'com.example.app' } as any,
exp: { slug: 'my-app', name: 'My App' } as any,
analytics: {} as any,
credentialsCtx: { appStore: { ensureAuthenticatedAsync: jest.fn() }, vcsClient: {} } as any,
nonInteractive: false,
graphqlClient: {} as any,
projectId: 'test-project-id',
...overrides,
};
}
describe(downloadMetadataAsync, () => {
beforeEach(() => {
jest.clearAllMocks();
(fs.pathExists as jest.Mock).mockResolvedValue(false);
});
it('skips overwrite prompt and auto-overwrites in non-interactive mode', async () => {
(fs.pathExists as jest.Mock).mockResolvedValue(true);
await downloadMetadataAsync(createArgs({ nonInteractive: true }));
expect(confirmAsync).not.toHaveBeenCalled();
expect(fs.writeJSON).toHaveBeenCalled();
});
it('prompts for overwrite in interactive mode when file exists', async () => {
(fs.pathExists as jest.Mock).mockResolvedValue(true);
(confirmAsync as jest.Mock).mockResolvedValue(true);
await downloadMetadataAsync(createArgs({ nonInteractive: false }));
expect(confirmAsync).toHaveBeenCalledWith(
expect.objectContaining({ message: expect.stringContaining('overwrite') })
);
});
it('throws MetadataValidationError when user declines overwrite in interactive mode', async () => {
(fs.pathExists as jest.Mock).mockResolvedValue(true);
(confirmAsync as jest.Mock).mockResolvedValue(false);
await expect(downloadMetadataAsync(createArgs({ nonInteractive: false }))).rejects.toThrow(
MetadataValidationError
);
});
it('does not prompt when file does not exist', async () => {
(fs.pathExists as jest.Mock).mockResolvedValue(false);
await downloadMetadataAsync(createArgs({ nonInteractive: false }));
expect(confirmAsync).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,103 @@
import { uploadMetadataAsync } from '../upload';
import { MetadataValidationError } from '../errors';
jest.mock('../auth', () => ({
getAppStoreAuthAsync: jest.fn(() => ({
app: { id: '123' },
auth: { context: { token: 'mock' } },
})),
}));
jest.mock('../apple/tasks', () => ({
createAppleTasks: jest.fn(() => []),
}));
jest.mock('../config/resolve', () => ({
createAppleReader: jest.fn(() => ({
getVersion: jest.fn(() => ({ versionString: '1.0.0' })),
})),
loadConfigAsync: jest.fn(() => ({ configVersion: 0 })),
}));
jest.mock('../utils/telemetry', () => ({
subscribeTelemetryAsync: jest.fn(() => ({
unsubscribeTelemetry: jest.fn(),
executionId: 'exec-123',
})),
}));
jest.mock('../../log');
jest.mock('../../prompts', () => ({
confirmAsync: jest.fn(),
}));
const { loadConfigAsync } = require('../config/resolve') as jest.Mocked<
typeof import('../config/resolve')
>;
const { confirmAsync } = require('../../prompts') as jest.Mocked<typeof import('../../prompts')>;
function createArgs(overrides: Record<string, any> = {}) {
return {
projectDir: '/app',
profile: { metadataPath: 'store.config.json', bundleIdentifier: 'com.example.app' } as any,
exp: { slug: 'my-app', name: 'My App' } as any,
analytics: {} as any,
credentialsCtx: { appStore: { ensureAuthenticatedAsync: jest.fn() }, vcsClient: {} } as any,
nonInteractive: false,
graphqlClient: {} as any,
projectId: 'test-project-id',
...overrides,
};
}
describe(uploadMetadataAsync, () => {
beforeEach(() => {
jest.clearAllMocks();
(loadConfigAsync as jest.Mock).mockResolvedValue({ configVersion: 0 });
});
it('throws MetadataValidationError directly in non-interactive mode without prompting', async () => {
(loadConfigAsync as jest.Mock).mockRejectedValue(
new MetadataValidationError('validation errors found')
);
await expect(uploadMetadataAsync(createArgs({ nonInteractive: true }))).rejects.toThrow(
MetadataValidationError
);
expect(confirmAsync).not.toHaveBeenCalled();
});
it('prompts user about validation errors in interactive mode', async () => {
(loadConfigAsync as jest.Mock).mockRejectedValueOnce(
new MetadataValidationError('validation errors found')
);
(confirmAsync as jest.Mock).mockResolvedValue(true);
// Second call (with skipValidation) succeeds
(loadConfigAsync as jest.Mock).mockResolvedValueOnce({ configVersion: 0 });
await uploadMetadataAsync(createArgs({ nonInteractive: false }));
expect(confirmAsync).toHaveBeenCalledWith(
expect.objectContaining({ message: expect.stringContaining('still want to push') })
);
});
it('re-throws validation error when user declines to continue in interactive mode', async () => {
(loadConfigAsync as jest.Mock).mockRejectedValue(
new MetadataValidationError('validation errors found')
);
(confirmAsync as jest.Mock).mockResolvedValue(false);
await expect(uploadMetadataAsync(createArgs({ nonInteractive: false }))).rejects.toThrow(
MetadataValidationError
);
});
it('uploads successfully when config is valid', async () => {
const result = await uploadMetadataAsync(createArgs());
expect(result).toHaveProperty('appleLink');
expect(result.appleLink).toContain('appstoreconnect.apple.com');
});
});
+140 -2
View File
@@ -2,10 +2,22 @@ import { App, Session } from '@expo/apple-utils';
import { ExpoConfig } from '@expo/config';
import { SubmitProfile } from '@expo/eas-json';
import assert from 'assert';
import fs from 'fs';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { CredentialsContext } from '../credentials/context';
import { getRequestContext } from '../credentials/ios/appstore/authenticate';
import { AppLookupParams } from '../credentials/ios/api/graphql/types/AppLookupParams';
import {
Options as AuthOptions,
getRequestContext,
} from '../credentials/ios/appstore/authenticate';
import { AppleTeamType, AuthenticationMode } from '../credentials/ios/appstore/authenticateTypes';
import { hasAscEnvVars } from '../credentials/ios/appstore/resolveCredentials';
import { MinimalAscApiKey } from '../credentials/ios/credentials';
import { AppStoreConnectApiKeyQuery } from '../graphql/queries/AppStoreConnectApiKeyQuery';
import Log from '../log';
import { getBundleIdentifierAsync } from '../project/ios/bundleIdentifier';
import { getOwnerAccountForProjectIdAsync } from '../project/projectUtils';
import { Client } from '../vcs/vcs';
export type MetadataAppStoreAuthentication = {
@@ -32,20 +44,107 @@ async function resolveAppStoreBundleIdentifierAsync(
return await getBundleIdentifierAsync(projectDir, exp, vcsClient);
}
type ResolvedAscApiKey = {
ascApiKey: MinimalAscApiKey;
teamId?: string;
teamName?: string;
};
/**
* Try to resolve an ASC API key from the submit profile or EAS credentials service.
* Returns null if no key is available from these sources.
*/
async function tryResolveAscApiKeyAsync({
profile,
graphqlClient,
projectId,
exp,
bundleId,
}: {
profile: SubmitProfile;
graphqlClient: ExpoGraphqlClient;
projectId: string;
exp: ExpoConfig;
bundleId: string;
}): Promise<ResolvedAscApiKey | null> {
// 1. Check submit profile for ASC API key fields
if ('ascApiKeyPath' in profile && 'ascApiKeyIssuerId' in profile && 'ascApiKeyId' in profile) {
const { ascApiKeyPath, ascApiKeyIssuerId, ascApiKeyId } = profile;
if (ascApiKeyPath && ascApiKeyIssuerId && ascApiKeyId) {
const keyP8 = await fs.promises.readFile(ascApiKeyPath, 'utf-8');
// Also try to get teamId from the profile if available
const teamId = 'appleTeamId' in profile ? (profile as any).appleTeamId : undefined;
return {
ascApiKey: { keyP8, keyId: ascApiKeyId, issuerId: ascApiKeyIssuerId },
teamId,
};
}
}
// 2. Look up stored credentials via EAS credentials service
try {
const account = await getOwnerAccountForProjectIdAsync(graphqlClient, projectId);
const appLookupParams: AppLookupParams = {
account,
projectName: exp.slug,
bundleIdentifier: bundleId,
};
// Import dynamically to avoid circular dependency issues
const { getAscApiKeyForAppSubmissionsAsync } =
await import('../credentials/ios/api/GraphqlClient');
const ascKeyFragment = await getAscApiKeyForAppSubmissionsAsync(graphqlClient, appLookupParams);
if (ascKeyFragment) {
Log.log('Using App Store Connect API Key from EAS credentials service.');
const fullKey = await AppStoreConnectApiKeyQuery.getByIdAsync(
graphqlClient,
ascKeyFragment.id
);
return {
ascApiKey: {
keyP8: fullKey.keyP8,
keyId: fullKey.keyIdentifier,
issuerId: fullKey.issuerIdentifier,
},
teamId: ascKeyFragment.appleTeam?.appleTeamIdentifier,
teamName: ascKeyFragment.appleTeam?.appleTeamName ?? undefined,
};
}
} catch (error: any) {
// If we can't look up credentials, that's fine — we'll fall back
Log.warn(`Could not look up stored ASC API key: ${error.message}`);
}
return null;
}
/**
* To start syncing ASC entities, we need access to the apple utils App instance.
* This resolves both the authentication and that App instance.
*
* Resolution order for authentication:
* 1. ASC API key from environment variables (EXPO_ASC_API_KEY_PATH, etc.)
* 2. ASC API key from submit profile (ascApiKeyPath, etc. in eas.json)
* 3. ASC API key from EAS credentials service
* 4. Interactive cookie auth (only when not in non-interactive mode)
*/
export async function getAppStoreAuthAsync({
projectDir,
profile,
exp,
credentialsCtx,
nonInteractive,
graphqlClient,
projectId,
}: {
projectDir: string;
profile: SubmitProfile;
exp: ExpoConfig;
credentialsCtx: CredentialsContext;
nonInteractive: boolean;
graphqlClient: ExpoGraphqlClient;
projectId: string;
}): Promise<MetadataAppStoreAuthentication> {
const bundleId = await resolveAppStoreBundleIdentifierAsync(
projectDir,
@@ -54,10 +153,49 @@ export async function getAppStoreAuthAsync({
credentialsCtx.vcsClient
);
// Try to resolve an ASC API key from profile or credentials service
const resolvedKey = await tryResolveAscApiKeyAsync({
profile,
graphqlClient,
projectId,
exp,
bundleId,
});
if (resolvedKey || hasAscEnvVars()) {
const authOptions: AuthOptions = {
mode: AuthenticationMode.API_KEY,
...(resolvedKey
? {
ascApiKey: resolvedKey.ascApiKey,
teamId: resolvedKey.teamId,
teamName: resolvedKey.teamName,
// Default to COMPANY_OR_ORGANIZATION to avoid prompting for team type
teamType: AppleTeamType.COMPANY_OR_ORGANIZATION,
}
: {}),
};
const authCtx = await credentialsCtx.appStore.ensureAuthenticatedAsync(authOptions);
assert(authCtx.authState, 'Failed to authenticate with App Store Connect');
const app = await App.findAsync(getRequestContext(authCtx), { bundleId });
assert(app, `Failed to load app "${bundleId}" from App Store Connect`);
return { app, auth: authCtx.authState };
}
if (nonInteractive) {
throw new Error(
'No App Store Connect API Key found. In non-interactive mode, provide one via:\n' +
' - Environment variables: EXPO_ASC_API_KEY_PATH, EXPO_ASC_KEY_ID, EXPO_ASC_ISSUER_ID\n' +
' - eas.json submit profile: ascApiKeyPath, ascApiKeyId, ascApiKeyIssuerId\n' +
' - EAS credentials service: run `eas credentials` to set up an API key'
);
}
// Fall back to interactive cookie auth
const authCtx = await credentialsCtx.appStore.ensureAuthenticatedAsync();
assert(authCtx.authState, 'Failed to authenticate with App Store Connect');
// TODO: improve error handling by mentioning possible configuration errors
const app = await App.findAsync(getRequestContext(authCtx), { bundleId });
assert(app, `Failed to load app "${bundleId}" from App Store Connect`);
+20 -6
View File
@@ -10,6 +10,7 @@ import { createAppleWriter, getStaticConfigFilePath } from './config/resolve';
import { MetadataDownloadError, MetadataValidationError } from './errors';
import { subscribeTelemetryAsync } from './utils/telemetry';
import { Analytics, MetadataEvent } from '../analytics/AnalyticsManager';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { CredentialsContext } from '../credentials/context';
import Log from '../log';
import { confirmAsync } from '../prompts';
@@ -24,23 +25,33 @@ export async function downloadMetadataAsync({
exp,
analytics,
credentialsCtx,
nonInteractive,
graphqlClient,
projectId,
}: {
projectDir: string;
profile: SubmitProfile;
exp: ExpoConfig;
analytics: Analytics;
credentialsCtx: CredentialsContext;
nonInteractive: boolean;
graphqlClient: ExpoGraphqlClient;
projectId: string;
}): Promise<string> {
const filePath = getStaticConfigFilePath({ projectDir, profile });
const fileExists = await fs.pathExists(filePath);
if (fileExists) {
const filePathRelative = path.relative(projectDir, filePath);
const overwrite = await confirmAsync({
message: `Do you want to overwrite the existing "${filePathRelative}"?`,
});
if (!overwrite) {
throw new MetadataValidationError(`Store config already exists at "${filePath}"`);
if (nonInteractive) {
Log.log(`Overwriting existing store config at "${path.relative(projectDir, filePath)}".`);
} else {
const filePathRelative = path.relative(projectDir, filePath);
const overwrite = await confirmAsync({
message: `Do you want to overwrite the existing "${filePathRelative}"?`,
});
if (!overwrite) {
throw new MetadataValidationError(`Store config already exists at "${filePath}"`);
}
}
}
@@ -49,6 +60,9 @@ export async function downloadMetadataAsync({
credentialsCtx,
projectDir,
profile,
nonInteractive,
graphqlClient,
projectId,
});
const { unsubscribeTelemetry, executionId } = await subscribeTelemetryAsync(
+22 -2
View File
@@ -9,6 +9,7 @@ import { MetadataConfig } from './config/schema';
import { MetadataUploadError, MetadataValidationError, logMetadataValidationError } from './errors';
import { subscribeTelemetryAsync } from './utils/telemetry';
import { Analytics, MetadataEvent } from '../analytics/AnalyticsManager';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { CredentialsContext } from '../credentials/context';
import Log from '../log';
import { confirmAsync } from '../prompts';
@@ -23,19 +24,32 @@ export async function uploadMetadataAsync({
exp,
analytics,
credentialsCtx,
nonInteractive,
graphqlClient,
projectId,
}: {
projectDir: string;
profile: SubmitProfile;
exp: ExpoConfig;
analytics: Analytics;
credentialsCtx: CredentialsContext;
nonInteractive: boolean;
graphqlClient: ExpoGraphqlClient;
projectId: string;
}): Promise<{ appleLink: string }> {
const storeConfig = await loadConfigWithValidationPromptAsync(projectDir, profile);
const storeConfig = await loadConfigWithValidationPromptAsync(
projectDir,
profile,
nonInteractive
);
const { app, auth } = await getAppStoreAuthAsync({
exp,
credentialsCtx,
projectDir,
profile,
nonInteractive,
graphqlClient,
projectId,
});
const { unsubscribeTelemetry, executionId } = await subscribeTelemetryAsync(
@@ -84,12 +98,18 @@ export async function uploadMetadataAsync({
async function loadConfigWithValidationPromptAsync(
projectDir: string,
profile: SubmitProfile
profile: SubmitProfile,
nonInteractive: boolean
): Promise<MetadataConfig> {
try {
return await loadConfigAsync({ projectDir, profile });
} catch (error) {
if (error instanceof MetadataValidationError) {
if (nonInteractive) {
logMetadataValidationError(error);
throw error;
}
logMetadataValidationError(error);
Log.newLine();
Log.warn(
@@ -137,21 +137,81 @@ describe(ensureApplicationIdIsDefinedForManagedProjectAsync, () => {
})
).rejects.toThrowError(/we can't update this file programmatically/);
});
it('throws an error in non-interactive mode', async () => {
it('auto-generates application id in non-interactive mode when suggestion is available', async () => {
const graphqlClient = instance(mock<ExpoGraphqlClient>());
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
id: '1234',
slug: 'testing-123',
name: 'testing-123',
fullName: '@jester/testing-123',
ownerAccount: jester.accounts[0],
});
vol.fromJSON(
{
'app.json': '{ "expo": {} }',
},
'/app'
);
const result = await ensureApplicationIdIsDefinedForManagedProjectAsync({
graphqlClient,
projectDir: '/app',
projectId: '1234',
exp: { slug: 'myapp' } as any,
vcsClient,
nonInteractive: true,
});
expect(result).toBe('com.jester.myapp');
expect(promptAsync).not.toHaveBeenCalled();
const appJson = JSON.parse(await fs.readFile('/app/app.json', 'utf-8'));
expect(appJson).toMatchObject({
expo: { android: { package: 'com.jester.myapp' } },
});
});
it('uses ios.bundleIdentifier as application id in non-interactive mode when available', async () => {
const graphqlClient = instance(mock<ExpoGraphqlClient>());
vol.fromJSON(
{
'app.json': '{ "blah": {} }',
'app.json': '{ "expo": {} }',
},
'/app'
);
const result = await ensureApplicationIdIsDefinedForManagedProjectAsync({
graphqlClient,
projectDir: '/app',
projectId: '1234',
exp: { slug: 'myapp', ios: { bundleIdentifier: 'com.example.myapp' } } as any,
vcsClient,
nonInteractive: true,
});
expect(result).toBe('com.example.myapp');
expect(promptAsync).not.toHaveBeenCalled();
});
it('throws an error in non-interactive mode when no valid suggestion can be generated', async () => {
const graphqlClient = instance(mock<ExpoGraphqlClient>());
jest.mocked(AppQuery.byIdAsync).mockRejectedValue(new Error('Not found'));
vol.fromJSON(
{
'app.json': '{ "expo": {} }',
},
'/app'
);
await expect(
ensureApplicationIdIsDefinedForManagedProjectAsync({
graphqlClient,
projectDir: '/app',
projectId: '',
exp: {} as any,
projectId: '1234',
exp: { slug: '' } as any,
vcsClient,
nonInteractive: true,
})
@@ -148,15 +148,8 @@ async function configureApplicationIdAsync({
exp: ExpoConfig;
nonInteractive: boolean;
}): Promise<string> {
if (nonInteractive) {
throw new Error(
`The "android.package" is required to be set in app config when running in non-interactive mode. ${learnMore(
'https://docs.expo.dev/versions/latest/config/app/#package'
)}`
);
}
const paths = getConfigFilePaths(projectDir);
// we can't automatically update app.config.js
if (paths.dynamicConfigPath) {
throw new Error(
@@ -166,25 +159,50 @@ async function configureApplicationIdAsync({
assert(paths.staticConfigPath, 'app.json must exist');
Log.addNewLineIfNone();
Log.log(
`${chalk.bold(`📝 Android application id`)} ${chalk.dim(
learnMore('https://expo.fyi/android-package')
)}`
);
let packageName: string;
const suggestedAndroidApplicationId = await getSuggestedApplicationIdAsync(
graphqlClient,
exp,
projectId
);
const { packageName } = await promptAsync({
name: 'packageName',
type: 'text',
message: `What would you like your Android application id to be?`,
initial: suggestedAndroidApplicationId,
validate: value => (isApplicationIdValid(value) ? true : INVALID_APPLICATION_ID_MESSAGE),
});
if (nonInteractive) {
let suggestedAndroidApplicationId: string | undefined;
try {
suggestedAndroidApplicationId = await getSuggestedApplicationIdAsync(
graphqlClient,
exp,
projectId
);
} catch {
// If we can't get a suggestion, we'll throw the non-interactive error below
}
if (!suggestedAndroidApplicationId) {
throw new Error(
`The "android.package" is required to be set in app config when running in non-interactive mode. ${learnMore(
'https://docs.expo.dev/versions/latest/config/app/#package'
)}`
);
}
packageName = suggestedAndroidApplicationId;
Log.log(`Using automatically generated Android application id: ${chalk.bold(packageName)}`);
} else {
const suggestedAndroidApplicationId = await getSuggestedApplicationIdAsync(
graphqlClient,
exp,
projectId
);
Log.addNewLineIfNone();
Log.log(
`${chalk.bold(`📝 Android application id`)} ${chalk.dim(
learnMore('https://expo.fyi/android-package')
)}`
);
const result = await promptAsync({
name: 'packageName',
type: 'text',
message: `What would you like your Android application id to be?`,
initial: suggestedAndroidApplicationId,
validate: value => (isApplicationIdValid(value) ? true : INVALID_APPLICATION_ID_MESSAGE),
});
packageName = result.packageName;
}
const rawStaticConfig = readAppJson(paths.staticConfigPath);
rawStaticConfig.expo = {
@@ -115,20 +115,81 @@ describe(ensureBundleIdentifierIsDefinedForManagedProjectAsync, () => {
})
).rejects.toThrowError(/we can't update this file programmatically/);
});
it('throws an error in non-interactive mode', async () => {
it('auto-generates bundle identifier in non-interactive mode when suggestion is available', async () => {
const graphqlClient = instance(mock<ExpoGraphqlClient>());
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
id: '1234',
slug: 'testing-123',
name: 'testing-123',
fullName: '@jester/testing-123',
ownerAccount: jester.accounts[0],
});
vol.fromJSON(
{
'app.json': '{ "blah": {} }',
'app.json': '{ "expo": {} }',
},
'/app'
);
const result = await ensureBundleIdentifierIsDefinedForManagedProjectAsync({
graphqlClient,
projectDir: '/app',
projectId: '1234',
exp: { slug: 'myapp' } as any,
vcsClient,
nonInteractive: true,
});
expect(result).toBe('com.jester.myapp');
expect(promptAsync).not.toHaveBeenCalled();
const appJson = JSON.parse(await fs.readFile('/app/app.json', 'utf-8'));
expect(appJson).toMatchObject({
expo: { ios: { bundleIdentifier: 'com.jester.myapp' } },
});
});
it('uses android.package as bundle identifier in non-interactive mode when available', async () => {
const graphqlClient = instance(mock<ExpoGraphqlClient>());
vol.fromJSON(
{
'app.json': '{ "expo": {} }',
},
'/app'
);
const result = await ensureBundleIdentifierIsDefinedForManagedProjectAsync({
graphqlClient,
projectDir: '/app',
projectId: '1234',
exp: { slug: 'myapp', android: { package: 'com.example.myapp' } } as any,
vcsClient,
nonInteractive: true,
});
expect(result).toBe('com.example.myapp');
expect(promptAsync).not.toHaveBeenCalled();
});
it('throws an error in non-interactive mode when no valid suggestion can be generated', async () => {
const graphqlClient = instance(mock<ExpoGraphqlClient>());
jest.mocked(AppQuery.byIdAsync).mockRejectedValue(new Error('Not found'));
vol.fromJSON(
{
'app.json': '{ "expo": {} }',
},
'/app'
);
await expect(
ensureBundleIdentifierIsDefinedForManagedProjectAsync({
graphqlClient,
projectDir: '/app',
projectId: '1234',
exp: {} as any,
exp: { slug: '' } as any,
vcsClient,
nonInteractive: true,
})
@@ -137,15 +137,8 @@ async function configureBundleIdentifierAsync({
exp: ExpoConfig;
nonInteractive: boolean;
}): Promise<string> {
if (nonInteractive) {
throw new Error(
`The "ios.bundleIdentifier" is required to be set in app config when running in non-interactive mode. ${learnMore(
'https://docs.expo.dev/versions/latest/config/app/#bundleidentifier'
)}`
);
}
const paths = getConfigFilePaths(projectDir);
// we can't automatically update app.config.js
if (paths.dynamicConfigPath) {
throw new Error(
@@ -155,26 +148,51 @@ async function configureBundleIdentifierAsync({
assert(paths.staticConfigPath, 'app.json must exist');
Log.addNewLineIfNone();
Log.log(
`${chalk.bold(`📝 iOS Bundle Identifier`)} ${chalk.dim(
learnMore('https://expo.fyi/bundle-identifier')
)}`
);
let bundleIdentifier: string;
const suggestedBundleIdentifier = await getSuggestedBundleIdentifierAsync(
graphqlClient,
exp,
projectId
);
if (nonInteractive) {
let suggestedBundleIdentifier: string | undefined;
try {
suggestedBundleIdentifier = await getSuggestedBundleIdentifierAsync(
graphqlClient,
exp,
projectId
);
} catch {
// If we can't get a suggestion, we'll throw the non-interactive error below
}
if (!suggestedBundleIdentifier) {
throw new Error(
`The "ios.bundleIdentifier" is required to be set in app config when running in non-interactive mode. ${learnMore(
'https://docs.expo.dev/versions/latest/config/app/#bundleidentifier'
)}`
);
}
bundleIdentifier = suggestedBundleIdentifier;
Log.log(`Using automatically generated iOS bundle identifier: ${chalk.bold(bundleIdentifier)}`);
} else {
const suggestedBundleIdentifier = await getSuggestedBundleIdentifierAsync(
graphqlClient,
exp,
projectId
);
Log.addNewLineIfNone();
Log.log(
`${chalk.bold(`📝 iOS Bundle Identifier`)} ${chalk.dim(
learnMore('https://expo.fyi/bundle-identifier')
)}`
);
const { bundleIdentifier } = await promptAsync({
name: 'bundleIdentifier',
type: 'text',
message: `What would you like your iOS bundle identifier to be?`,
initial: suggestedBundleIdentifier,
validate: value => (isBundleIdentifierValid(value) ? true : INVALID_BUNDLE_IDENTIFIER_MESSAGE),
});
const result = await promptAsync({
name: 'bundleIdentifier',
type: 'text',
message: `What would you like your iOS bundle identifier to be?`,
initial: suggestedBundleIdentifier,
validate: value =>
isBundleIdentifierValid(value) ? true : INVALID_BUNDLE_IDENTIFIER_MESSAGE,
});
bundleIdentifier = result.bundleIdentifier;
}
const rawStaticConfig = readAppJson(paths.staticConfigPath);
rawStaticConfig.expo = {
@@ -10,21 +10,8 @@ import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGr
import { AccountFragment } from '../graphql/generated';
import { AppQuery } from '../graphql/queries/AppQuery';
import Log, { learnMore } from '../log';
import { Actor } from '../user/User';
import { expoCommandAsync } from '../utils/expoCli';
export function getUsernameForBuildMetadataAndBuildJob(user: Actor): string | undefined {
switch (user.__typename) {
case 'User':
return user.username;
case 'SSOUser':
return user.username;
case 'Robot':
// robot users don't have usernames
return undefined;
}
}
/**
* Return a useful name describing the project config.
* - dynamic: app.config.js
+22
View File
@@ -0,0 +1,22 @@
import * as Sentry from '@sentry/node';
import { easCliVersion } from './utils/easCli';
Sentry.init({
dsn: process.env.EAS_CLI_SENTRY_DSN,
enabled: !!process.env.EAS_CLI_SENTRY_DSN,
environment: getSentryEnvironment(),
release: easCliVersion ? `eas-cli@${easCliVersion}` : undefined,
});
Sentry.setTag('source', 'eas-cli');
function getSentryEnvironment(): string {
if (process.env.EXPO_LOCAL) {
return 'local';
} else if (process.env.EXPO_STAGING) {
return 'staging';
}
return 'production';
}
export default Sentry;
+3
View File
@@ -7,6 +7,7 @@ import semver from 'semver';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import {
AppPlatform,
PartnerActor,
Robot,
SsoUser,
Update,
@@ -29,6 +30,7 @@ export type FormatUpdateParameter = Pick<Update, 'id' | 'createdAt' | 'message'>
| Pick<Robot, '__typename' | 'firstName'>
| Pick<User, '__typename' | 'username'>
| Pick<SsoUser, '__typename' | 'username'>
| Pick<PartnerActor, '__typename' | 'username'>
| null;
};
@@ -194,6 +196,7 @@ export function formatUpdateTitle(update: UpdateFragment): string {
let actorName: string;
switch (actor?.__typename) {
case 'User':
case 'PartnerActor':
case 'SSOUser': {
actorName = actor.username;
break;
+5 -1
View File
@@ -1,4 +1,4 @@
import { CurrentUserQuery, Robot, SsoUser, User } from '../graphql/generated';
import { CurrentUserQuery, PartnerActor, Robot, SsoUser, User } from '../graphql/generated';
export type Actor = NonNullable<CurrentUserQuery['meActor']>;
@@ -12,6 +12,7 @@ export function getActorDisplayName(
| Pick<Robot, '__typename' | 'firstName'>
| Pick<User, '__typename' | 'username'>
| Pick<SsoUser, '__typename' | 'username'>
| Pick<PartnerActor, '__typename' | 'username'>
| null
): string {
switch (actor?.__typename) {
@@ -21,6 +22,8 @@ export function getActorDisplayName(
return actor.firstName ? `${actor.firstName} (robot)` : 'robot';
case 'SSOUser':
return actor.username;
case 'PartnerActor':
return actor.username;
case undefined:
return 'unknown';
}
@@ -30,6 +33,7 @@ export function getActorUsername(actor?: Actor): string | null {
switch (actor?.__typename) {
case 'User':
case 'SSOUser':
case 'PartnerActor':
return actor.username;
case 'Robot':
case undefined:
@@ -1,24 +1,86 @@
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 { getExpoWebsiteBaseUrl } from '../api';
import { getExpoApiBaseUrl, getExpoWebsiteBaseUrl } from '../api';
import fetch from '../fetch';
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 path = '/auth/callback';
const callbackPath = '/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 => {
const params = querystring.stringify({
confirm_account: true,
app_redirect_uri: `${scheme}://${hostname}:${port}${path}`,
});
// 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('&');
return `${expoWebsiteUrl}${sso ? '/sso-login' : '/login'}?${params}`;
};
@@ -39,22 +101,39 @@ export async function getSessionUsingBrowserAuthFlowAsync({ sso = false }): Prom
}
};
try {
const handleRequestAsync = async (): Promise<void> => {
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 sessionSecret = url.searchParams.get('session_secret');
const code = url.searchParams.get('code');
const returnedState = url.searchParams.get('state');
if (!sessionSecret) {
throw new Error('Request missing session_secret search parameter.');
if (!code) {
throw new Error('Request missing code 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');
} catch (error) {
};
handleRequestAsync().catch(error => {
redirectAndCleanup('error');
reject(error);
}
});
}
);
+2 -2
View File
@@ -26,9 +26,9 @@
"watch-allow-unused": "yarn build-allow-unused --watch --preserveWatchOutput",
"typecheck": "tsc",
"prepack": "yarn rebuild",
"rebuild": "rimraf build *.tsbuildinfo && yarn build",
"rebuild": "rimraf build \"*.tsbuildinfo\" && yarn build",
"test": "jest",
"clean": "rimraf build node_modules yarn-error.log *.tsbuildinfo"
"clean": "rimraf build node_modules yarn-error.log \"*.tsbuildinfo\""
},
"dependencies": {
"@babel/code-frame": "7.23.5",
+3 -2
View File
@@ -21,9 +21,9 @@
"watch": "tsc --watch --preserveWatchOutput",
"build": "tsc",
"typecheck": "tsc",
"prepack": "rm -rf dist *.tsbuildinfo && tsc -p tsconfig.build.json",
"prepack": "rimraf dist \"*.tsbuildinfo\" && tsc -p tsconfig.build.json",
"test": "jest --passWithNoTests",
"clean": "rm -rf node_modules dist coverage *.tsbuildinfo"
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/build-tools": "18.4.0",
@@ -50,6 +50,7 @@
"@types/semver": "^7.5.8",
"@types/uuid": "^9.0.7",
"jest": "^29.7.0",
"rimraf": "3.0.2",
"ts-jest": "^29.1.4",
"typescript": "^5.5.4"
},
@@ -86,7 +86,6 @@ class PrettyStream extends Writable {
'pid',
'hostname',
'name',
'buildStepInternalId',
'buildStepId',
'buildStepDisplayName',
]);
+3 -2
View File
@@ -19,10 +19,10 @@
"watch": "tsc --watch --preserveWatchOutput",
"build": "tsc",
"typecheck": "tsc",
"prepack": "rm -rf dist *.tsbuildinfo && tsc -p tsconfig.build.json",
"prepack": "rimraf dist \"*.tsbuildinfo\" && tsc -p tsconfig.build.json",
"test": "jest --config jest.config.ts --passWithNoTests",
"test:watch": "jest --config jest.config.ts --watch",
"clean": "rm -rf node_modules dist coverage *.tsbuildinfo"
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@types/bunyan": "^1.8.11",
@@ -32,6 +32,7 @@
"@types/jest": "^29.5.12",
"@types/node": "20.14.2",
"jest": "^29.7.0",
"rimraf": "3.0.2",
"ts-jest": "^29.1.4",
"typescript": "^5.5.4"
}
+3 -2
View File
@@ -19,12 +19,12 @@
"start": "yarn watch",
"build": "tsc --project tsconfig.build.json",
"typecheck": "tsc",
"prepack": "rm -rf dist *.tsbuildinfo && tsc --project tsconfig.build.json",
"prepack": "rimraf dist \"*.tsbuildinfo\" && tsc --project tsconfig.build.json",
"watch": "tsc --watch --preserveWatchOutput",
"test": "jest --config jest.config.ts",
"test:coverage": "jest --config jest.config.ts --coverage",
"test:watch": "yarn test --watch",
"clean": "rm -rf node_modules dist coverage *.tsbuildinfo"
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/eas-build-job": "18.4.0",
@@ -47,6 +47,7 @@
"@types/node": "20.14.2",
"eslint-plugin-async-protect": "^3.1.0",
"jest": "^29.7.0",
"rimraf": "3.0.2",
"ts-jest": "^29.1.4",
"ts-mockito": "^2.6.1",
"ts-node": "^10.9.2",
+14 -4
View File
@@ -127,7 +127,14 @@ export class BuildConfigParser extends AbstractConfigParser {
__metrics_id,
} = run;
const id = BuildStep.getNewId(maybeId);
const displayName = BuildStep.getDisplayName({ id, name, command });
const displayName =
name ??
maybeId ??
command
.split('\n')
.find(line => line.trim())
?.trim() ??
command;
const inputs =
inputsConfig && this.createBuildStepInputsFromDefinition(inputsConfig, displayName);
const outputs =
@@ -135,10 +142,9 @@ export class BuildConfigParser extends AbstractConfigParser {
const timeoutMs = timeout_minutes !== undefined ? timeout_minutes * 60 * 1000 : undefined;
return new BuildStep(this.ctx, {
id,
displayName,
inputs,
outputs,
name,
displayName,
workingDirectory,
shell,
command,
@@ -153,7 +159,11 @@ export class BuildConfigParser extends AbstractConfigParser {
run: command,
}: BuildStepBareCommandRun): BuildStep {
const id = BuildStep.getNewId();
const displayName = BuildStep.getDisplayName({ id, command });
const displayName =
command
.split('\n')
.find(line => line.trim())
?.trim() ?? command;
return new BuildStep(this.ctx, {
id,
displayName,
+1 -6
View File
@@ -105,11 +105,7 @@ export class BuildFunction {
): BuildStep {
const buildStepId = BuildStep.getNewId(id);
const buildStepName = name ?? this.name;
const buildStepDisplayName = BuildStep.getDisplayName({
id: buildStepId,
command: this.command,
name: buildStepName,
});
const buildStepDisplayName = buildStepName ?? id ?? this.getFullId();
const inputs = this.inputProviders?.map(inputProvider => {
const input = inputProvider(ctx, buildStepId);
@@ -122,7 +118,6 @@ export class BuildFunction {
return new BuildStep(ctx, {
id: buildStepId,
name: buildStepName,
displayName: buildStepDisplayName,
command: this.command,
fn:
+2 -41
View File
@@ -3,7 +3,6 @@ import assert from 'assert';
import { Buffer } from 'buffer';
import fs from 'fs/promises';
import path from 'path';
import { v4 as uuidv4 } from 'uuid';
import { BuildRuntimePlatform } from './BuildRuntimePlatform';
import { BuildStepContext, BuildStepGlobalContext } from './BuildStepContext';
@@ -57,10 +56,6 @@ export type BuildStepFunction = (
}
) => unknown;
// TODO: move to a place common with tests
const UUID_REGEX =
/^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/;
export interface SerializedBuildStepOutputAccessor {
id: string;
executed: boolean;
@@ -126,8 +121,8 @@ export class BuildStepOutputAccessor {
}
export class BuildStep extends BuildStepOutputAccessor {
private static nextGeneratedId = 1;
public readonly id: string;
public readonly name?: string;
public readonly displayName: string;
public readonly supportedRuntimePlatforms?: BuildRuntimePlatform[];
public readonly inputs?: BuildStepInput[];
@@ -144,46 +139,17 @@ export class BuildStep extends BuildStepOutputAccessor {
private readonly outputsDir: string;
private readonly envsDir: string;
private readonly internalId: string;
private readonly inputById: BuildStepInputById;
protected executed = false;
public static getNewId(userDefinedId?: string): string {
return userDefinedId ?? uuidv4();
}
public static getDisplayName({
id,
name,
command,
}: {
id: string;
name?: string;
command?: string;
}): string {
if (name) {
return name;
}
if (!id.match(UUID_REGEX)) {
return id;
}
if (command) {
const splits = command.trim().split('\n');
for (const split of splits) {
const trimmed = split.trim();
if (trimmed && !trimmed.startsWith('#')) {
return trimmed;
}
}
}
return id;
return userDefinedId ?? `step-${String(BuildStep.nextGeneratedId++).padStart(3, '0')}`;
}
constructor(
ctx: BuildStepGlobalContext,
{
id,
name,
displayName,
inputs,
outputs,
@@ -198,7 +164,6 @@ export class BuildStep extends BuildStepOutputAccessor {
__metricsId,
}: {
id: string;
name?: string;
displayName: string;
inputs?: BuildStepInput[];
outputs?: BuildStepOutput[];
@@ -219,7 +184,6 @@ export class BuildStep extends BuildStepOutputAccessor {
super(id, displayName, false, outputById);
this.id = id;
this.name = name;
this.displayName = displayName;
this.supportedRuntimePlatforms = maybeSupportedRuntimePlatforms;
this.inputs = inputs;
@@ -233,10 +197,7 @@ export class BuildStep extends BuildStepOutputAccessor {
this.__metricsId = __metricsId;
this.status = BuildStepStatus.NEW;
this.internalId = uuidv4();
const logger = ctx.baseLogger.child({
buildStepInternalId: this.internalId,
buildStepId: this.id,
buildStepDisplayName: this.displayName,
});
+9 -3
View File
@@ -114,14 +114,20 @@ export class StepsConfigParser extends AbstractConfigParser {
private createBuildStepFromShellStepConfig(step: ShellStep): BuildStep {
const id = BuildStep.getNewId(step.id);
const displayName = BuildStep.getDisplayName({ id, name: step.name, command: step.run });
const displayName =
step.name ??
step.id ??
step.run
.split('\n')
.find(line => line.trim())
?.trim() ??
step.run;
const outputs =
step.outputs && this.createBuildStepOutputsFromDefinition(step.outputs, displayName);
return new BuildStep(this.ctx, {
id,
outputs,
name: step.name,
displayName,
outputs,
workingDirectory: step.working_directory,
shell: step.shell,
command: step.run,
@@ -1,7 +1,6 @@
import path from 'path';
import { createGlobalContextMock } from './utils/context';
import { UUID_REGEX } from './utils/uuid';
import { BuildConfigParser } from '../BuildConfigParser';
import { BuildFunction } from '../BuildFunction';
import { BuildStep } from '../BuildStep';
@@ -65,15 +64,7 @@ describe('Publish Update job', () => {
});
function assertStepsAreMatching(step1: BuildStep, step2: BuildStep): void {
expect(step1.id).toMatch(UUID_REGEX);
expect(step2.id).toMatch(UUID_REGEX);
expect(step1.name).toEqual(step2.name);
if (step1.command) {
expect(step1.displayName).toEqual(step2.displayName);
} else {
expect(step1.displayName).toMatch(UUID_REGEX);
expect(step2.displayName).toMatch(UUID_REGEX);
}
expect(step1.displayName).toEqual(step2.displayName);
expect(step1.inputs).toStrictEqual(step2.inputs);
expect(step1.outputById).toStrictEqual(step2.outputById);
expect(step1.command?.trim()).toEqual(step2.command?.trim());
@@ -2,7 +2,7 @@ import path from 'path';
import { createGlobalContextMock } from './utils/context';
import { getError, getErrorAsync } from './utils/error';
import { UUID_REGEX } from './utils/uuid';
import { GENERATED_STEP_ID_REGEX } from './utils/stepId';
import { BuildConfigParser } from '../BuildConfigParser';
import { BuildFunction } from '../BuildFunction';
import { BuildFunctionGroup } from '../BuildFunctionGroup';
@@ -112,8 +112,8 @@ describe(BuildConfigParser, () => {
// - run: echo "Hi!"
const step1 = buildSteps[0];
expect(step1.id).toMatch(UUID_REGEX);
expect(step1.name).toBeUndefined();
expect(step1.id).toMatch(GENERATED_STEP_ID_REGEX);
expect(step1.displayName).toBe('echo "Hi!"');
expect(step1.command).toBe('echo "Hi!"');
expect(step1.ctx.workingDirectory).toBe(ctx.defaultWorkingDirectory);
expect(step1.shell).toBe('/bin/bash -eo pipefail');
@@ -128,8 +128,7 @@ describe(BuildConfigParser, () => {
// echo "L"
// echo "O"
const step2 = buildSteps[1];
expect(step2.id).toMatch(UUID_REGEX);
expect(step2.name).toBe('Say HELLO');
expect(step2.displayName).toBe('Say HELLO');
expect(step2.command).toMatchSnapshot();
expect(step2.ctx.workingDirectory).toBe(ctx.defaultWorkingDirectory);
expect(step2.shell).toBe('/bin/bash -eo pipefail');
@@ -143,7 +142,7 @@ describe(BuildConfigParser, () => {
// BAR: baz
const step3 = buildSteps[2];
expect(step3.id).toBe('id_2137');
expect(step3.name).toBeUndefined();
expect(step3.displayName).toBe('id_2137');
expect(step3.command).toBe('echo "Step with an ID"');
expect(step3.ctx.workingDirectory).toBe(ctx.defaultWorkingDirectory);
expect(step3.shell).toBe('/bin/bash -eo pipefail');
@@ -157,8 +156,7 @@ describe(BuildConfigParser, () => {
// working_directory: relative/path/to/files
// command: ls -la
const step4 = buildSteps[3];
expect(step4.id).toMatch(UUID_REGEX);
expect(step4.name).toBe('List files');
expect(step4.displayName).toBe('List files');
expect(step4.command).toBe('ls -la');
expect(step4.ctx.workingDirectory).toBe(
path.join(ctx.defaultWorkingDirectory, 'relative/path/to/files')
@@ -171,8 +169,7 @@ describe(BuildConfigParser, () => {
// working_directory: /home/dsokal
// command: ls -la
const step5 = buildSteps[4];
expect(step5.id).toMatch(UUID_REGEX);
expect(step5.name).toBe('List files in another directory');
expect(step5.displayName).toBe('List files in another directory');
expect(step5.command).toBe('ls -la');
expect(step5.ctx.workingDirectory).toBe(
path.join(ctx.projectTargetDirectory, '/home/dsokal')
@@ -186,8 +183,7 @@ describe(BuildConfigParser, () => {
// shell: /nib/hsab
// command: echo 123
const step6 = buildSteps[5];
expect(step6.id).toMatch(UUID_REGEX);
expect(step6.name).toBe('Use non-default shell');
expect(step6.displayName).toBe('Use non-default shell');
expect(step6.command).toBe('echo 123');
expect(step6.ctx.workingDirectory).toBe(ctx.defaultWorkingDirectory);
expect(step6.shell).toBe('/nib/hsab');
@@ -219,8 +215,7 @@ describe(BuildConfigParser, () => {
// property3: value4
// command: echo "Hi, ${ inputs.name }, ${ inputs.boolean_value }!"
const step1 = buildSteps[0];
expect(step1.id).toMatch(UUID_REGEX);
expect(step1.name).toBe('Say HI');
expect(step1.displayName).toBe('Say HI');
expect(step1.command).toBe('echo "Hi, ${ inputs.name }, ${ inputs.boolean_value }!"');
expect(step1.ctx.workingDirectory).toBe(ctx.defaultWorkingDirectory);
expect(step1.shell).toBe('/bin/bash -eo pipefail');
@@ -270,8 +265,7 @@ describe(BuildConfigParser, () => {
// set-output first_name Brent
// set-output last_name Vatne
const step1 = buildSteps[0];
expect(step1.id).toMatch(UUID_REGEX);
expect(step1.name).toBeUndefined();
expect(step1.displayName).toBe('set-output first_name Brent');
expect(step1.command).toMatchSnapshot();
expect(step1.ctx.workingDirectory).toBe(ctx.defaultWorkingDirectory);
expect(step1.shell).toBe('/bin/bash -eo pipefail');
@@ -294,8 +288,7 @@ describe(BuildConfigParser, () => {
// set-output last_name Sokal
// set-output nickname dsokal
const step2 = buildSteps[1];
expect(step2.id).toMatch(UUID_REGEX);
expect(step2.name).toBeUndefined();
expect(step2.displayName).toBe('set-output first_name Dominik');
expect(step2.command).toMatchSnapshot();
expect(step2.ctx.workingDirectory).toBe(ctx.defaultWorkingDirectory);
expect(step2.shell).toBe('/bin/bash -eo pipefail');
@@ -333,8 +326,7 @@ describe(BuildConfigParser, () => {
// - aaa
// - bbb
const step1 = buildSteps[0];
expect(step1.id).toMatch(UUID_REGEX);
expect(step1.name).toBe('Hi!');
expect(step1.displayName).toBe('Hi!');
expect(step1.command).toBe('echo "Hi, ${ inputs.name }!"');
expect(step1.ctx.workingDirectory).toBe(ctx.defaultWorkingDirectory);
expect(step1.shell).toBe('/bin/bash -eo pipefail');
@@ -365,8 +357,7 @@ describe(BuildConfigParser, () => {
// name: Szymon
// build_number: 122
const step2 = buildSteps[1];
expect(step2.id).toMatch(UUID_REGEX);
expect(step2.name).toBe('Hi, Szymon!');
expect(step2.displayName).toBe('Hi, Szymon!');
expect(step2.command).toBe('echo "Hi, ${ inputs.name }!"');
expect(step2.ctx.workingDirectory).toBe(ctx.defaultWorkingDirectory);
expect(step2.shell).toBe('/bin/bash -eo pipefail');
@@ -392,8 +383,7 @@ describe(BuildConfigParser, () => {
// - say_hi_wojtek
const step3 = buildSteps[2];
expect(step3.id).toMatch(UUID_REGEX);
expect(step3.name).toBe('Hi, Wojtek!');
expect(step3.displayName).toBe('Hi, Wojtek!');
expect(step3.command).toBe('echo "Hi, Wojtek!"');
expect(step3.ctx.workingDirectory).toBe(ctx.defaultWorkingDirectory);
expect(step3.shell).toBe('/bin/bash -eo pipefail');
@@ -403,7 +393,7 @@ describe(BuildConfigParser, () => {
// id: random_number
const step4 = buildSteps[3];
expect(step4.id).toMatch('random_number');
expect(step4.name).toBe('Generate random number');
expect(step4.displayName).toBe('Generate random number');
expect(step4.command).toBe('set-output value 6');
expect(step4.ctx.workingDirectory).toBe(ctx.defaultWorkingDirectory);
expect(step4.shell).toBe('/bin/bash -eo pipefail');
@@ -416,8 +406,7 @@ describe(BuildConfigParser, () => {
// inputs:
// value: ${ steps.random_number.value }
const step5 = buildSteps[4];
expect(step5.id).toMatch(UUID_REGEX);
expect(step5.name).toBe(undefined);
expect(step5.displayName).toBe('print');
expect(step5.command).toBe('echo "${ inputs.value }"');
expect(step5.ctx.workingDirectory).toBe(ctx.defaultWorkingDirectory);
expect(step5.shell).toBe('/bin/bash -eo pipefail');
@@ -431,8 +420,7 @@ describe(BuildConfigParser, () => {
// greeting: Hello
// num: 123
const step6 = buildSteps[5];
expect(step6.id).toMatch(UUID_REGEX);
expect(step6.name).toBe('Hi!');
expect(step6.displayName).toBe('Hi!');
expect(step6.command).toBe('echo "${ inputs.greeting }, ${ inputs.name }!"');
expect(step6.ctx.workingDirectory).toBe(ctx.defaultWorkingDirectory);
expect(step6.shell).toBe('/bin/bash -eo pipefail');
@@ -644,12 +632,11 @@ describe(BuildConfigParser, () => {
// - eas/download_project
const step1 = workflow.buildSteps[0];
expect(step1.id).toMatch(UUID_REGEX);
expect(step1.id).toMatch(GENERATED_STEP_ID_REGEX);
expect(step1.fn).toBe(downloadProjectFn);
// - eas/build_project
const step2 = workflow.buildSteps[1];
expect(step2.id).toMatch(UUID_REGEX);
expect(step2.fn).toBe(buildProjectFn);
});
@@ -726,37 +713,32 @@ describe(BuildConfigParser, () => {
// - eas/download_project
const step1 = workflow.buildSteps[0];
expect(step1.id).toMatch(UUID_REGEX);
expect(step1.id).toMatch(GENERATED_STEP_ID_REGEX);
expect(step1.fn).toBe(downloadProjectFn);
// - eas/build_project
const step2 = workflow.buildSteps[1];
expect(step2.id).toMatch(UUID_REGEX);
expect(step2.id).toMatch(GENERATED_STEP_ID_REGEX);
expect(step2.fn).toBe(buildProjectFn);
// - eas/download_project3 originating from build group eas/build
const step3 = workflow.buildSteps[2];
expect(step3.id).toMatch(UUID_REGEX);
expect(step3.fn).toBe(downloadProjectFn);
// - eas/build_project4 originating from build group eas/build
const step4 = workflow.buildSteps[3];
expect(step4.id).toMatch(UUID_REGEX);
expect(step4.fn).toBe(buildProjectFn);
// - eas/download_project5 originating from submit group eas/submit
const step5 = workflow.buildSteps[4];
expect(step5.id).toMatch(UUID_REGEX);
expect(step5.fn).toBe(downloadProjectFn);
// - eas/build_project6 originating from submit group eas/submit
const step6 = workflow.buildSteps[5];
expect(step6.id).toMatch(UUID_REGEX);
expect(step6.fn).toBe(buildProjectFn);
// - eas/test7 originating from submit group eas/submit
const step7 = workflow.buildSteps[6];
expect(step7.id).toMatch(UUID_REGEX);
expect(step7.fn).toBeDefined();
});
});
@@ -1,5 +1,5 @@
import { createGlobalContextMock } from './utils/context';
import { UUID_REGEX } from './utils/uuid';
import { GENERATED_STEP_ID_REGEX } from './utils/stepId';
import { BuildFunction } from '../BuildFunction';
import { BuildStep, BuildStepFunction } from '../BuildStep';
import {
@@ -86,8 +86,8 @@ describe(BuildFunction, () => {
workingDirectory: ctx.defaultWorkingDirectory,
});
expect(step).toBeInstanceOf(BuildStep);
expect(step.id).toMatch(UUID_REGEX);
expect(step.name).toBe('Test function');
expect(step.id).toMatch(GENERATED_STEP_ID_REGEX);
expect(step.displayName).toBe('Test function');
expect(step.command).toBe('echo 123');
});
it('works with build step function', () => {
@@ -102,8 +102,7 @@ describe(BuildFunction, () => {
workingDirectory: ctx.defaultWorkingDirectory,
});
expect(step).toBeInstanceOf(BuildStep);
expect(step.id).toMatch(UUID_REGEX);
expect(step.name).toBe('Test function');
expect(step.displayName).toBe('Test function');
expect(step.fn).toBe(fn);
});
it('works with custom JS/TS function', () => {
@@ -117,10 +116,21 @@ describe(BuildFunction, () => {
workingDirectory: ctx.defaultWorkingDirectory,
});
expect(step).toBeInstanceOf(BuildStep);
expect(step.id).toMatch(UUID_REGEX);
expect(step.name).toBe('Test function');
expect(step.displayName).toBe('Test function');
expect(step.fn).toEqual(expect.any(Function));
});
it('uses the function id as the display name when the function name is not defined', () => {
const ctx = createGlobalContextMock();
const func = new BuildFunction({
namespace: 'eas',
id: 'test1',
command: 'echo 123',
});
const step = func.createBuildStepFromFunctionCall(ctx, {
workingDirectory: ctx.defaultWorkingDirectory,
});
expect(step.displayName).toBe('eas/test1');
});
it('can override id and shell from function definition', () => {
const ctx = createGlobalContextMock();
const func = new BuildFunction({
@@ -138,6 +148,7 @@ describe(BuildFunction, () => {
expect(func.shell).toBe('/bin/bash');
expect(step.id).toBe('test2');
expect(step.shell).toBe('/bin/zsh');
expect(step.displayName).toBe('Test function');
});
it('creates function inputs and outputs', () => {
const ctx = createGlobalContextMock();
+51 -70
View File
@@ -2,12 +2,11 @@ import { jest } from '@jest/globals';
import fs from 'fs/promises';
import path from 'path';
import { instance, mock, verify, when } from 'ts-mockito';
import { v4 as uuidv4 } from 'uuid';
import { createGlobalContextMock } from './utils/context';
import { getError, getErrorAsync } from './utils/error';
import { createMockLogger } from './utils/logger';
import { UUID_REGEX } from './utils/uuid';
import { GENERATED_STEP_ID_REGEX } from './utils/stepId';
import { BuildFunction } from '../BuildFunction';
import { BuildRuntimePlatform } from '../BuildRuntimePlatform';
import { BuildStep, BuildStepFunction, BuildStepStatus } from '../BuildStep';
@@ -21,37 +20,21 @@ import { spawnAsync } from '../utils/shell/spawn';
describe(BuildStep, () => {
describe(BuildStep.getNewId, () => {
it('returns a uuid if the user-defined id is undefined', () => {
expect(BuildStep.getNewId()).toMatch(UUID_REGEX);
it('returns a generated step id if the user-defined id is undefined', () => {
expect(BuildStep.getNewId()).toMatch(GENERATED_STEP_ID_REGEX);
});
it('returns unique generated step ids', () => {
const stepId1 = BuildStep.getNewId();
const stepId2 = BuildStep.getNewId();
expect(stepId1).not.toBe(stepId2);
expect(stepId1).toMatch(GENERATED_STEP_ID_REGEX);
expect(stepId2).toMatch(GENERATED_STEP_ID_REGEX);
});
it('returns the user-defined id if defined', () => {
expect(BuildStep.getNewId('test1')).toBe('test1');
});
});
describe(BuildStep.getDisplayName, () => {
it('returns the name if defined', () => {
expect(BuildStep.getDisplayName({ id: 'test1', name: 'Step 1' })).toBe('Step 1');
});
it("returns the id if it's not a uuid", () => {
expect(BuildStep.getDisplayName({ id: 'test1' })).toBe('test1');
});
it('returns the first line of the command if name is undefined and id is a uuid', () => {
expect(BuildStep.getDisplayName({ id: uuidv4(), command: 'echo 123\necho 456' })).toBe(
'echo 123'
);
});
it('returns the first non-comment line of the command', async () => {
expect(
BuildStep.getDisplayName({ id: uuidv4(), command: '# list files\nls -la\necho 123' })
).toBe('ls -la');
});
it('returns the uuid id if neither name nor command is defined', () => {
const id = uuidv4();
expect(BuildStep.getDisplayName({ id })).toBe(id);
});
});
describe('constructor', () => {
it('throws when neither command nor fn is set', () => {
const mockCtx = mock<BuildStepGlobalContext>();
@@ -62,7 +45,7 @@ describe(BuildStep, () => {
// eslint-disable-next-line no-new
new BuildStep(ctx, {
id,
displayName: BuildStep.getDisplayName({ id }),
displayName: id,
workingDirectory: '/tmp',
});
}).toThrowError(/Either command or fn must be defined/);
@@ -75,7 +58,7 @@ describe(BuildStep, () => {
expect(() => {
const id = 'test1';
const command = 'echo 123';
const displayName = BuildStep.getDisplayName({ id, command });
const displayName = command;
// eslint-disable-next-line no-new
new BuildStep(ctx, {
@@ -96,7 +79,7 @@ describe(BuildStep, () => {
const id = 'test1';
const command = 'ls -la';
const displayName = BuildStep.getDisplayName({ id, command });
const displayName = command;
const step = new BuildStep(ctx, {
id,
@@ -113,7 +96,7 @@ describe(BuildStep, () => {
const id = 'test1';
const command = 'ls -la';
const displayName = BuildStep.getDisplayName({ id, command });
const displayName = command;
const step = new BuildStep(ctx, {
id,
@@ -129,7 +112,7 @@ describe(BuildStep, () => {
const id = 'test1';
const command = 'ls -la';
const displayName = BuildStep.getDisplayName({ id, command });
const displayName = command;
const step = new BuildStep(ctx, {
id,
@@ -149,7 +132,7 @@ describe(BuildStep, () => {
const id = 'test1';
const command = 'ls -la';
const displayName = BuildStep.getDisplayName({ id, command });
const displayName = command;
const step = new BuildStep(ctx, {
id,
@@ -169,7 +152,7 @@ describe(BuildStep, () => {
const id = 'test1';
const command = 'ls -la';
const displayName = BuildStep.getDisplayName({ id, command });
const displayName = command;
const step = new BuildStep(ctx, {
id,
@@ -185,17 +168,15 @@ describe(BuildStep, () => {
const id = 'test1';
const name = 'Test step';
const command = 'ls -la';
const displayName = BuildStep.getDisplayName({ id, name, command });
const displayName = name;
const step = new BuildStep(ctx, {
id,
name,
displayName,
command,
});
expect(ctx.baseLogger.child).toHaveBeenCalledWith(
expect.objectContaining({
buildStepInternalId: expect.stringMatching(UUID_REGEX),
buildStepId: 'test1',
buildStepDisplayName: 'Test step',
})
@@ -208,7 +189,7 @@ describe(BuildStep, () => {
const id = 'test1';
const command = 'echo hello';
const displayName = BuildStep.getDisplayName({ id, command });
const displayName = command;
const step = new BuildStep(ctx, {
id,
@@ -224,7 +205,7 @@ describe(BuildStep, () => {
const id = 'test1';
const command = 'echo hello';
const displayName = BuildStep.getDisplayName({ id, command });
const displayName = command;
const step = new BuildStep(ctx, {
id,
@@ -252,7 +233,7 @@ describe(BuildStep, () => {
it('sets status to FAIL when step fails', async () => {
const id = 'test1';
const command = 'false';
const displayName = BuildStep.getDisplayName({ id, command });
const displayName = command;
const step = new BuildStep(baseStepCtx, {
id,
@@ -266,7 +247,7 @@ describe(BuildStep, () => {
it('sets status to SUCCESS when step succeeds', async () => {
const id = 'test1';
const command = 'true';
const displayName = BuildStep.getDisplayName({ id, command });
const displayName = command;
const step = new BuildStep(baseStepCtx, {
id,
@@ -281,7 +262,7 @@ describe(BuildStep, () => {
it('logs an error if the command is to be executed in non-existing working directory', async () => {
const id = 'test1';
const command = 'ls -la';
const displayName = BuildStep.getDisplayName({ id, command });
const displayName = command;
const step = new BuildStep(baseStepCtx, {
id,
@@ -312,7 +293,7 @@ describe(BuildStep, () => {
it('does not log an error if the command is to be executed in a directory that exists', async () => {
const id = 'test1';
const command = 'ls -la';
const displayName = BuildStep.getDisplayName({ id, command });
const displayName = command;
await fs.mkdir(path.join(baseStepCtx.defaultWorkingDirectory, 'existing-directory'), {
recursive: true,
@@ -368,7 +349,7 @@ describe(BuildStep, () => {
const id = 'test1';
const command = 'ls -la';
const displayName = BuildStep.getDisplayName({ id, command });
const displayName = command;
const step = new BuildStep(baseStepCtx, {
id,
@@ -385,7 +366,7 @@ describe(BuildStep, () => {
it('interpolates the inputs in command template', async () => {
const id = 'test1';
const command = "set-output foo2 '${inputs.foo1} ${inputs.foo2} ${inputs.foo3}'";
const displayName = BuildStep.getDisplayName({ id, command });
const displayName = command;
const step = new BuildStep(baseStepCtx, {
id,
@@ -466,7 +447,7 @@ describe(BuildStep, () => {
it('collects the outputs after calling the script', async () => {
const id = 'test1';
const command = 'set-output abc 123';
const displayName = BuildStep.getDisplayName({ id, command });
const displayName = command;
const step = new BuildStep(baseStepCtx, {
id,
@@ -493,7 +474,7 @@ describe(BuildStep, () => {
env,
});
});
const displayName = BuildStep.getDisplayName({ id });
const displayName = id;
const step = new BuildStep(baseStepCtx, {
id,
@@ -512,7 +493,7 @@ describe(BuildStep, () => {
env,
});
});
const displayName = BuildStep.getDisplayName({ id });
const displayName = id;
const step = new BuildStep(baseStepCtx, {
id,
@@ -536,7 +517,7 @@ describe(BuildStep, () => {
it('succeeds when step completes within timeout', async () => {
const id = 'test1';
const command = 'sleep 0.1';
const displayName = BuildStep.getDisplayName({ id, command });
const displayName = command;
const step = new BuildStep(baseStepCtx, {
id,
@@ -551,7 +532,7 @@ describe(BuildStep, () => {
it('fails when command exceeds timeout', async () => {
const id = 'test1';
const command = 'sleep 2';
const displayName = BuildStep.getDisplayName({ id, command });
const displayName = command;
const step = new BuildStep(baseStepCtx, {
id,
@@ -574,7 +555,7 @@ describe(BuildStep, () => {
setTimeout(resolve, 2000); // 2 second delay
})
);
const displayName = BuildStep.getDisplayName({ id });
const displayName = id;
const step = new BuildStep(baseStepCtx, {
id,
@@ -592,7 +573,7 @@ describe(BuildStep, () => {
it('works without timeout when timeoutMs is undefined', async () => {
const id = 'test1';
const command = 'sleep 0.1';
const displayName = BuildStep.getDisplayName({ id, command });
const displayName = command;
const step = new BuildStep(baseStepCtx, {
id,
@@ -609,7 +590,7 @@ describe(BuildStep, () => {
it('works with strings with whitespaces passed as a value for an output parameter', async () => {
const id = 'test1';
const command = 'set-output abc "d o m i n i k"';
const displayName = BuildStep.getDisplayName({ id, command });
const displayName = command;
const step = new BuildStep(baseStepCtx, {
id,
@@ -631,7 +612,7 @@ describe(BuildStep, () => {
it('throws an error if some required outputs have not been set with set-output in script', async () => {
const id = 'test1';
const command = 'echo 123';
const displayName = BuildStep.getDisplayName({ id, command });
const displayName = command;
const step = new BuildStep(baseStepCtx, {
id,
@@ -661,7 +642,7 @@ describe(BuildStep, () => {
baseStepCtx.updateEnv(globalEnv);
const id = 'test1';
const displayName = BuildStep.getDisplayName({ id });
const displayName = id;
const step = new BuildStep(baseStepCtx, {
id,
@@ -694,7 +675,7 @@ describe(BuildStep, () => {
baseStepCtx.updateEnv(globalEnv);
const id = 'test1';
const displayName = BuildStep.getDisplayName({ id });
const displayName = id;
const step = new BuildStep(baseStepCtx, {
id,
@@ -722,7 +703,7 @@ describe(BuildStep, () => {
baseStepCtx.updateEnv(env);
const id = 'test1';
const displayName = BuildStep.getDisplayName({ id });
const displayName = id;
const inputs: BuildStepInput[] = [
new BuildStepInput(baseStepCtx, {
@@ -807,7 +788,7 @@ describe(BuildStep, () => {
it('throws an error when the step has not been executed yet', async () => {
const id = 'test1';
const command = 'set-output abc 123';
const displayName = BuildStep.getDisplayName({ id, command });
const displayName = command;
const step = new BuildStep(baseStepCtx, {
id,
@@ -831,7 +812,7 @@ describe(BuildStep, () => {
it('throws an error when trying to access a non-existent output', async () => {
const id = 'test1';
const command = 'set-output abc 123';
const displayName = BuildStep.getDisplayName({ id, command });
const displayName = command;
const step = new BuildStep(baseStepCtx, {
id,
@@ -850,13 +831,13 @@ describe(BuildStep, () => {
step.getOutputValueByName('def');
});
expect(error).toBeInstanceOf(BuildStepRuntimeError);
expect(error.message).toMatch(/Step "test1" does not have output "def"/);
expect(error.message).toMatch(/Step "set-output abc 123" does not have output "def"/);
});
it('returns the output value', async () => {
const id = 'test1';
const command = 'set-output abc 123';
const displayName = BuildStep.getDisplayName({ id, command });
const displayName = command;
const step = new BuildStep(baseStepCtx, {
id,
@@ -889,7 +870,7 @@ describe(BuildStep, () => {
const id = 'test1';
const command = 'echo "$TEST_ABC $TEST_DEF"';
const displayName = BuildStep.getDisplayName({ id, command });
const displayName = command;
(baseStepCtx as any).baseLogger = logger;
@@ -920,7 +901,7 @@ describe(BuildStep, () => {
const id = 'test1';
const command = 'echo $TEST_ABC';
const displayName = BuildStep.getDisplayName({ id, command });
const displayName = command;
(baseStepCtx as any).baseLogger = logger;
@@ -952,7 +933,7 @@ describe(BuildStep, () => {
const id = 'test1';
const command =
'echo $__EXPO_STEPS_BUILD_ID\necho $__EXPO_STEPS_OUTPUTS_DIR\necho $__EXPO_STEPS_ENVS_DIR\necho $__EXPO_STEPS_WORKING_DIRECTORY';
const displayName = BuildStep.getDisplayName({ id, command });
const displayName = command;
(baseStepCtx as any).baseLogger = logger;
const step = new BuildStep(baseStepCtx, {
@@ -976,7 +957,7 @@ describe(BuildStep, () => {
it('can update global env object with set-env', async () => {
const id = 'test1';
const command = 'set-env EXAMPLE value';
const displayName = BuildStep.getDisplayName({ id, command });
const displayName = command;
const step = new BuildStep(baseStepCtx, {
id,
@@ -989,7 +970,7 @@ describe(BuildStep, () => {
it('can override existing envs in global env object with set-env', async () => {
const id = 'test1';
const command = 'set-env EXAMPLE value';
const displayName = BuildStep.getDisplayName({ id, command });
const displayName = command;
baseStepCtx.updateEnv({
EXAMPLE: 'test1',
@@ -1022,7 +1003,7 @@ describe(BuildStep.prototype.canBeRunOnRuntimePlatform, () => {
it('returns true when the step does not have a platform filter', async () => {
const id = 'test1';
const command = 'set-output abc 123';
const displayName = BuildStep.getDisplayName({ id, command });
const displayName = command;
const step = new BuildStep(baseStepCtx, {
id,
@@ -1035,7 +1016,7 @@ describe(BuildStep.prototype.canBeRunOnRuntimePlatform, () => {
it('returns true when the step has a platform filter and the platform matches', async () => {
const id = 'test1';
const command = 'set-output abc 123';
const displayName = BuildStep.getDisplayName({ id, command });
const displayName = command;
const step = new BuildStep(baseStepCtx, {
id,
@@ -1049,7 +1030,7 @@ describe(BuildStep.prototype.canBeRunOnRuntimePlatform, () => {
it('returns false when the step has a platform filter and the platform does not match', async () => {
const id = 'test1';
const command = 'set-output abc 123';
const displayName = BuildStep.getDisplayName({ id, command });
const displayName = command;
const step = new BuildStep(baseStepCtx, {
id,
@@ -1076,7 +1057,7 @@ describe(BuildStep.prototype.serialize, () => {
it('serializes correctly', async () => {
const id = 'test1';
const command = 'set-output abc 123';
const displayName = BuildStep.getDisplayName({ id, command });
const displayName = command;
const outputs = [
new BuildStepOutput(baseStepCtx, {
@@ -14,7 +14,7 @@ describe(BuildStepInput, () => {
const ctx = createGlobalContextMock();
const i = new BuildStepInput(ctx, {
id: 'foo',
stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }),
stepDisplayName: 'test1',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
});
@@ -26,7 +26,7 @@ describe(BuildStepInput, () => {
const ctx = createGlobalContextMock();
const i = new BuildStepInput(ctx, {
id: 'foo',
stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }),
stepDisplayName: 'test1',
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: true,
});
@@ -38,7 +38,7 @@ describe(BuildStepInput, () => {
const ctx = createGlobalContextMock();
const i = new BuildStepInput(ctx, {
id: 'foo',
stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }),
stepDisplayName: 'test1',
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
required: true,
});
@@ -50,7 +50,7 @@ describe(BuildStepInput, () => {
const ctx = createGlobalContextMock();
const i = new BuildStepInput(ctx, {
id: 'foo',
stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }),
stepDisplayName: 'test1',
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
required: true,
});
@@ -64,7 +64,7 @@ describe(BuildStepInput, () => {
const ctx = createGlobalContextMock();
const i = new BuildStepInput(ctx, {
id: 'foo',
stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }),
stepDisplayName: 'test1',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
});
@@ -76,7 +76,7 @@ describe(BuildStepInput, () => {
const ctx = createGlobalContextMock();
const i = new BuildStepInput(ctx, {
id: 'foo',
stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }),
stepDisplayName: 'test1',
defaultValue: 'baz',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: true,
@@ -88,7 +88,7 @@ describe(BuildStepInput, () => {
const ctx = createGlobalContextMock();
const i = new BuildStepInput(ctx, {
id: 'foo',
stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }),
stepDisplayName: 'test1',
defaultValue: true,
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: true,
@@ -100,7 +100,7 @@ describe(BuildStepInput, () => {
const ctx = createGlobalContextMock();
const i = new BuildStepInput(ctx, {
id: 'foo',
stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }),
stepDisplayName: 'test1',
defaultValue: { foo: 'bar' },
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
required: true,
@@ -114,7 +114,7 @@ describe(BuildStepInput, () => {
const ctx = createGlobalContextMock();
const i = new BuildStepInput(ctx, {
id: 'foo',
stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }),
stepDisplayName: 'test1',
defaultValue: '${ eas.runtimePlatform }',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
@@ -130,7 +130,7 @@ describe(BuildStepInput, () => {
});
const i = new BuildStepInput(ctx, {
id: 'foo',
stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }),
stepDisplayName: 'test1',
defaultValue: '${{ foo }}',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
@@ -146,7 +146,7 @@ describe(BuildStepInput, () => {
});
const i = new BuildStepInput(ctx, {
id: 'foo',
stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }),
stepDisplayName: 'test1',
defaultValue: 'test-${{ foo }}',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
@@ -161,7 +161,7 @@ describe(BuildStepInput, () => {
});
const i = new BuildStepInput(ctx, {
id: 'foo',
stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }),
stepDisplayName: 'test1',
defaultValue: '${ eas.env.HOME }',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
@@ -181,7 +181,7 @@ describe(BuildStepInput, () => {
});
const i = new BuildStepInput(ctx, {
id: 'foo',
stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }),
stepDisplayName: 'test1',
defaultValue: '${ eas.foo.bar }',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
@@ -201,7 +201,7 @@ describe(BuildStepInput, () => {
});
const i = new BuildStepInput(ctx, {
id: 'foo',
stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }),
stepDisplayName: 'test1',
defaultValue: '${{ foo.bar }}',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
@@ -221,7 +221,7 @@ describe(BuildStepInput, () => {
});
const i = new BuildStepInput(ctx, {
id: 'foo',
stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }),
stepDisplayName: 'test1',
defaultValue: '${ eas.foo.bar }',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
@@ -241,7 +241,7 @@ describe(BuildStepInput, () => {
});
const i = new BuildStepInput(ctx, {
id: 'foo',
stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }),
stepDisplayName: 'test1',
defaultValue: '${{ foo.bar }}',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
@@ -268,7 +268,7 @@ describe(BuildStepInput, () => {
});
const i = new BuildStepInput<BuildStepInputValueTypeName>(ctx, {
id: 'foo',
stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }),
stepDisplayName: 'test1',
defaultValue: '${ eas.foo.bar[3].baz }',
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
required: true,
@@ -293,7 +293,7 @@ describe(BuildStepInput, () => {
});
const i = new BuildStepInput<BuildStepInputValueTypeName>(ctx, {
id: 'foo',
stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }),
stepDisplayName: 'test1',
defaultValue: '${{ foo.bar[3].baz }}',
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
required: true,
@@ -318,7 +318,7 @@ describe(BuildStepInput, () => {
});
const i = new BuildStepInput<BuildStepInputValueTypeName>(ctx, {
id: 'foo',
stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }),
stepDisplayName: 'test1',
defaultValue: 'test-${{ foo.bar[3].baz }}',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: true,
@@ -345,7 +345,7 @@ describe(BuildStepInput, () => {
});
const i = new BuildStepInput<BuildStepInputValueTypeName>(ctx, {
id: 'foo',
stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }),
stepDisplayName: 'test1',
defaultValue: '${ eas.foo.bar[3].baz.qux }',
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: true,
@@ -372,7 +372,7 @@ describe(BuildStepInput, () => {
});
const i = new BuildStepInput<BuildStepInputValueTypeName>(ctx, {
id: 'foo',
stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }),
stepDisplayName: 'test1',
defaultValue: '${{ startsWith(foo.bar[3].baz.qux, "test") }}',
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: true,
@@ -399,7 +399,7 @@ describe(BuildStepInput, () => {
});
const i = new BuildStepInput<BuildStepInputValueTypeName>(ctx, {
id: 'foo',
stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }),
stepDisplayName: 'test1',
defaultValue: '${{ foo.bar[3].baz.qux }}',
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: true,
@@ -426,7 +426,7 @@ describe(BuildStepInput, () => {
});
const i = new BuildStepInput<BuildStepInputValueTypeName>(ctx, {
id: 'foo',
stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }),
stepDisplayName: 'test1',
defaultValue: '${ eas.foo }',
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
required: true,
@@ -455,7 +455,7 @@ describe(BuildStepInput, () => {
});
const i = new BuildStepInput<BuildStepInputValueTypeName>(ctx, {
id: 'foo',
stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }),
stepDisplayName: 'test1',
defaultValue: '${{ foo }}',
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
required: true,
@@ -484,7 +484,7 @@ describe(BuildStepInput, () => {
});
const i = new BuildStepInput<BuildStepInputValueTypeName>(ctx, {
id: 'foo',
stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }),
stepDisplayName: 'test1',
defaultValue: '${ eas.foo.bar[3].baz.qux }',
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
required: true,
@@ -513,7 +513,7 @@ describe(BuildStepInput, () => {
});
const i = new BuildStepInput<BuildStepInputValueTypeName>(ctx, {
id: 'foo',
stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }),
stepDisplayName: 'test1',
defaultValue: '${{ foo.bar[3].baz.qux }}',
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
required: true,
@@ -542,7 +542,7 @@ describe(BuildStepInput, () => {
});
const i = new BuildStepInput<BuildStepInputValueTypeName>(ctx, {
id: 'foo',
stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }),
stepDisplayName: 'test1',
defaultValue: '${ eas.foo.bar[3].baz.qux }',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
@@ -571,7 +571,7 @@ describe(BuildStepInput, () => {
});
const i = new BuildStepInput<BuildStepInputValueTypeName>(ctx, {
id: 'foo',
stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }),
stepDisplayName: 'test1',
defaultValue: '${{ foo.bar[3].baz.qux }}',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
@@ -600,7 +600,7 @@ describe(BuildStepInput, () => {
});
const i = new BuildStepInput<BuildStepInputValueTypeName>(ctx, {
id: 'foo',
stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }),
stepDisplayName: 'test1',
defaultValue: '${ eas.foo.bar[3].baz.qux }',
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
required: true,
@@ -629,7 +629,7 @@ describe(BuildStepInput, () => {
});
const i = new BuildStepInput<BuildStepInputValueTypeName>(ctx, {
id: 'foo',
stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }),
stepDisplayName: 'test1',
defaultValue: '${{ foo.bar[3].baz.qux }}',
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
required: true,
@@ -650,7 +650,7 @@ describe(BuildStepInput, () => {
});
const i = new BuildStepInput(ctx, {
id: 'foo',
stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }),
stepDisplayName: 'test1',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
});
@@ -685,7 +685,7 @@ describe(BuildStepInput, () => {
});
const i = new BuildStepInput(ctx, {
id: 'foo',
stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }),
stepDisplayName: 'test1',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
});
@@ -717,7 +717,7 @@ describe(BuildStepInput, () => {
});
const i = new BuildStepInput(ctx, {
id: 'foo',
stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }),
stepDisplayName: 'test1',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
});
@@ -747,7 +747,7 @@ describe(BuildStepInput, () => {
});
const i = new BuildStepInput(ctx, {
id: 'foo',
stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }),
stepDisplayName: 'test1',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
});
@@ -773,7 +773,7 @@ describe(BuildStepInput, () => {
const ctx = createGlobalContextMock();
const i = new BuildStepInput(ctx, {
id: 'foo',
stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }),
stepDisplayName: 'test1',
defaultValue: 42,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
required: true,
@@ -785,7 +785,7 @@ describe(BuildStepInput, () => {
const ctx = createGlobalContextMock();
const i = new BuildStepInput(ctx, {
id: 'foo',
stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }),
stepDisplayName: 'test1',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
});
@@ -803,7 +803,7 @@ describe(BuildStepInput, () => {
const ctx = createGlobalContextMock();
const i = new BuildStepInput<BuildStepInputValueTypeName>(ctx, {
id: 'foo',
stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }),
stepDisplayName: 'test1',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
});
@@ -821,7 +821,7 @@ describe(BuildStepInput, () => {
const i = new BuildStepInput<BuildStepInputValueTypeName>(ctx, {
id: 'foo',
required: true,
stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }),
stepDisplayName: 'test1',
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
});
i.set('${ eas.runtimePlatform }');
@@ -840,7 +840,7 @@ describe(BuildStepInput, () => {
const i = new BuildStepInput<BuildStepInputValueTypeName>(ctx, {
id: 'foo',
required: true,
stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }),
stepDisplayName: 'test1',
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
});
i.set('${{ foo }}');
@@ -855,7 +855,7 @@ describe(BuildStepInput, () => {
const i = new BuildStepInput<BuildStepInputValueTypeName>(ctx, {
id: 'foo',
required: true,
stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }),
stepDisplayName: 'test1',
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
});
i.set('${ eas.runtimePlatform }');
@@ -876,7 +876,7 @@ describe(BuildStepInput, () => {
const i = new BuildStepInput<BuildStepInputValueTypeName>(ctx, {
id: 'foo',
required: true,
stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }),
stepDisplayName: 'test1',
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
});
i.set('${{ foo }}');
@@ -893,7 +893,7 @@ describe(BuildStepInput, () => {
const i = new BuildStepInput<BuildStepInputValueTypeName>(ctx, {
id: 'foo',
required: true,
stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }),
stepDisplayName: 'test1',
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
});
i.set('${ eas.runtimePlatform }');
@@ -914,7 +914,7 @@ describe(BuildStepInput, () => {
const i = new BuildStepInput<BuildStepInputValueTypeName>(ctx, {
id: 'foo',
required: true,
stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }),
stepDisplayName: 'test1',
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
});
i.set('${{ foo }}');
@@ -930,7 +930,7 @@ describe(BuildStepInput, () => {
const ctx = createGlobalContextMock();
const i = new BuildStepInput<BuildStepInputValueTypeName>(ctx, {
id: 'foo',
stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }),
stepDisplayName: 'test1',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
});
@@ -952,21 +952,21 @@ describe(makeBuildStepInputByIdMap, () => {
const inputs: BuildStepInput[] = [
new BuildStepInput(ctx, {
id: 'foo1',
stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }),
stepDisplayName: 'test1',
defaultValue: 'bar1',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
new BuildStepInput(ctx, {
id: 'foo2',
stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }),
stepDisplayName: 'test1',
defaultValue: 'bar2',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
new BuildStepInput(ctx, {
id: 'foo3',
stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }),
stepDisplayName: 'test1',
defaultValue: true,
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: true,
@@ -8,7 +8,7 @@ describe(BuildStepOutput, () => {
const ctx = createGlobalContextMock();
const o = new BuildStepOutput(ctx, {
id: 'foo',
stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }),
stepDisplayName: 'test1',
required: true,
});
o.set('bar');
@@ -19,7 +19,7 @@ describe(BuildStepOutput, () => {
const ctx = createGlobalContextMock();
const o = new BuildStepOutput(ctx, {
id: 'foo',
stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }),
stepDisplayName: 'test1',
required: true,
});
expect(() => {
@@ -36,7 +36,7 @@ describe(BuildStepOutput, () => {
const ctx = createGlobalContextMock();
const i = new BuildStepOutput<boolean>(ctx, {
id: 'foo',
stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }),
stepDisplayName: 'test1',
required: true,
});
expect(() => {
@@ -50,13 +50,13 @@ describe(BuildStepOutput, () => {
const ctx = createGlobalContextMock();
const o = new BuildStepOutput(ctx, {
id: 'foo',
stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }),
stepDisplayName: 'test1',
required: true,
});
o.set('bar');
expect(o.serialize()).toEqual({
id: 'foo',
stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }),
stepDisplayName: 'test1',
required: true,
value: 'bar',
});
@@ -65,12 +65,12 @@ describe(BuildStepOutput, () => {
test('deserializes correctly', () => {
const o = BuildStepOutput.deserialize({
id: 'foo',
stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }),
stepDisplayName: 'test1',
required: true,
value: 'bar',
});
expect(o.id).toBe('foo');
expect(o.stepDisplayName).toBe(BuildStep.getDisplayName({ id: 'test1' }));
expect(o.stepDisplayName).toBe('test1');
expect(o.required).toBe(true);
expect(o.value).toBe('bar');
});
@@ -86,12 +86,12 @@ describe(makeBuildStepOutputByIdMap, () => {
const outputs: BuildStepOutput[] = [
new BuildStepOutput(ctx, {
id: 'abc1',
stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }),
stepDisplayName: 'test1',
required: true,
}),
new BuildStepOutput(ctx, {
id: 'abc2',
stepDisplayName: BuildStep.getDisplayName({ id: 'test1' }),
stepDisplayName: 'test1',
required: true,
}),
];
@@ -18,27 +18,27 @@ describe(BuildWorkflowValidator, () => {
buildSteps: [
new BuildStep(ctx, {
id: 'test1',
displayName: BuildStep.getDisplayName({ id: 'test1', command: 'echo 123' }),
displayName: 'echo 123',
command: 'echo 123',
}),
new BuildStep(ctx, {
id: 'test1',
displayName: BuildStep.getDisplayName({ id: 'test1', command: 'echo 456' }),
displayName: 'echo 456',
command: 'echo 456',
}),
new BuildStep(ctx, {
id: 'test1',
displayName: BuildStep.getDisplayName({ id: 'test1', command: 'echo 789' }),
displayName: 'echo 789',
command: 'echo 789',
}),
new BuildStep(ctx, {
id: 'test3',
displayName: BuildStep.getDisplayName({ id: 'test3', command: 'echo 123' }),
displayName: 'echo 123',
command: 'echo 123',
}),
new BuildStep(ctx, {
id: 'test3',
displayName: BuildStep.getDisplayName({ id: 'test3', command: 'echo 456' }),
displayName: 'echo 456',
command: 'echo 456',
}),
],
@@ -60,7 +60,7 @@ describe(BuildWorkflowValidator, () => {
const id1 = 'test1';
const command1 = 'set-output output1 123';
const displayName1 = BuildStep.getDisplayName({ id: id1, command: command1 });
const displayName1 = id1;
const workflow = new BuildWorkflow(ctx, {
buildSteps: [
@@ -227,11 +227,11 @@ describe(BuildWorkflowValidator, () => {
const id1 = 'test1';
const command1 = 'set-output output1 123';
const displayName1 = BuildStep.getDisplayName({ id: id1, command: command1 });
const displayName1 = id1;
const id2 = 'test2';
const command2 = 'set-output output1 123';
const displayName2 = BuildStep.getDisplayName({ id: id2, command: command2 });
const displayName2 = id2;
const workflow = new BuildWorkflow(ctx, {
buildSteps: [
@@ -280,7 +280,7 @@ describe(BuildWorkflowValidator, () => {
test('output from non-existent step', async () => {
const id = 'test2';
const command = 'echo ${ inputs.input1 }';
const displayName = BuildStep.getDisplayName({ id, command });
const displayName = id;
const ctx = createGlobalContextMock();
const workflow = new BuildWorkflow(ctx, {
@@ -318,15 +318,15 @@ describe(BuildWorkflowValidator, () => {
test('undefined output', async () => {
const id1 = 'test1';
const command1 = 'set-output output1 123';
const displayName1 = BuildStep.getDisplayName({ id: id1, command: command1 });
const displayName1 = id1;
const id2 = 'test2';
const command2 = 'echo ${ inputs.input1 }';
const displayName2 = BuildStep.getDisplayName({ id: id2, command: command2 });
const displayName2 = id2;
const id3 = 'test3';
const command3 = 'echo ${ inputs.input1 }';
const displayName3 = BuildStep.getDisplayName({ id: id3, command: command3 });
const displayName3 = id3;
const ctx = createGlobalContextMock();
const workflow = new BuildWorkflow(ctx, {
@@ -390,15 +390,15 @@ describe(BuildWorkflowValidator, () => {
test('multiple config errors', async () => {
const id1 = 'test1';
const command1 = 'set-output output1 123';
const displayName1 = BuildStep.getDisplayName({ id: id1, command: command1 });
const displayName1 = id1;
const id2 = 'test2';
const command2 = 'echo ${ inputs.input1 }';
const displayName2 = BuildStep.getDisplayName({ id: id2, command: command2 });
const displayName2 = id2;
const id3 = 'test3';
const command3 = 'echo ${ inputs.input1 }';
const displayName3 = BuildStep.getDisplayName({ id: id3, command: command3 });
const displayName3 = id3;
const ctx = createGlobalContextMock();
const workflow = new BuildWorkflow(ctx, {
@@ -465,7 +465,7 @@ describe(BuildWorkflowValidator, () => {
});
test('unallowed platform for build step', async () => {
const id = 'test';
const displayName = BuildStep.getDisplayName({ id });
const displayName = id;
const fn: BuildStepFunction = () => {};
const ctx = createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.LINUX });
@@ -3,7 +3,6 @@ import path from 'path';
import { createGlobalContextMock } from './utils/context';
import { getError } from './utils/error';
import { UUID_REGEX } from './utils/uuid';
import { BuildFunction } from '../BuildFunction';
import { BuildFunctionGroup } from '../BuildFunctionGroup';
import { BuildStepInput, BuildStepInputValueTypeName } from '../BuildStepInput';
@@ -272,8 +271,7 @@ describe(StepsConfigParser, () => {
expect(result.buildSteps).toHaveLength(4);
const step1 = result.buildSteps[0];
expect(step1.id).toMatch(UUID_REGEX);
expect(step1.name).toBeUndefined();
expect(step1.displayName).toBe('command1');
expect(step1.command).toBe('command1');
expect(step1.shell).toBe('/bin/bash -eo pipefail');
expect(step1.fn).toBeUndefined();
@@ -285,7 +283,7 @@ describe(StepsConfigParser, () => {
const step2 = result.buildSteps[1];
expect(step2.id).toEqual('step2');
expect(step2.name).toBeUndefined();
expect(step2.displayName).toBe('step2');
expect(step2.command).toBeUndefined();
expect(step2.fn).toBeDefined();
expect(step2.ctx.workingDirectory).toBe(path.join(ctx.defaultWorkingDirectory, 'test'));
@@ -298,7 +296,7 @@ describe(StepsConfigParser, () => {
const step3 = result.buildSteps[2];
expect(step3.id).toEqual('step3');
expect(step3.name).toEqual('Step 3');
expect(step3.displayName).toEqual('Step 3');
expect(step3.command).toBe('command2');
expect(step3.shell).toBe('sh');
expect(step3.fn).toBeUndefined();
@@ -325,7 +323,7 @@ describe(StepsConfigParser, () => {
const step4 = result.buildSteps[3];
expect(step4.id).toEqual('step4');
expect(step4.name).toEqual('Step 4');
expect(step4.displayName).toEqual('Step 4');
expect(step4.command).toBeUndefined();
expect(step4.fn).toBeDefined();
expect(step4.ctx.workingDirectory).toBe(path.join(ctx.defaultWorkingDirectory, 'dir'));
@@ -0,0 +1 @@
export const GENERATED_STEP_ID_REGEX = /^step-\d{3,}$/;
@@ -1,2 +0,0 @@
export const UUID_REGEX =
/^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/;
Loaded 100 of 110 files, more files were not shown because too many files have changed in this diff. Show more