Compare commits

...
8 Commits
Author SHA1 Message Date
Expo CI 88e23aaa09 v27.1.0 2026-10-28 16:52:14 +00:00
Will Schurman 🦦 0a274a6fef [eas-cli] Throw when environment variable pull fails (#4774)
<!-- Add a changeset with `yarn changeset`, or skip the check by labeling the PR with "no changelog". -->

# Why

With protected environment variables (and even regular usage without protected environment variables), we need to prevent the build running when they can't be pulled. Otherwise the build can end up in a unexpected state where env variables are empty or unset.

# How

Re-throw the error when environment variable pulling fails. The original PR that added this (#2495) didn't specify why it was caught and allowed to proceed.

# Test Plan

Before:

```
$ neas build
✔ Select platform › Android

⚠️ Detected that your app uses Expo Go for development, this is not recommended when building production apps.
Learn more
To suppress this warning, set EAS_BUILD_NO_EXPO_GO_WARNING=true.

Resolved "production" environment for the build. Learn more
Failed to pull env variables for environment production from EAS servers
[GraphQL] Entity Not Authorized: Reading this environment variable requires the Release Manager role or higher since it targets exclusively the production environment and thus is considered protected.: Entity not authorized: EnvironmentVariableEntity[d3f35233-c3b7-4ef0-98fd-bc08d576e1db] (viewer = ScopedAccountActorViewerContext[RegularUserViewerContext[b99c08be-7584-4358-b8e4-aef050972708], scopes=expo_account:c344bcd9-c79d-4c0b-96fb-940d947cac42(RUN,VIEW);expo_account:102f030b-3f17-44ad-9472-cf051344224b(RUN,VIEW);expo_account:df3ec58d-d9b7-4f83-afdd-dbdb8258fdbe(VIEW)], action = READ, ruleIndex = -1)
This can possibly be a bug in EAS/EAS CLI. Report it here: https://github.com/expo/eas-cli/issues
✔ Incremented versionCode from 11 to 12.


✔ Using remote Android credentials (Expo server)
```

After:
```
$ neas build
✔ Select platform › Android

⚠️ Detected that your app uses Expo Go for development, this is not recommended when building production apps.
Learn more
To suppress this warning, set EAS_BUILD_NO_EXPO_GO_WARNING=true.

Resolved "production" environment for the build. Learn more
Failed to pull env variables for environment production from EAS servers
You don't have the required permissions to perform this operation.

This can sometimes happen if you are logged in as incorrect user or if the permissions granted during authentication are insufficient to perform the operation.
Run eas whoami to check the user you are logged in as and your permission grant.
Run eas login to change the user or permission grant.

Original error message: Entity Not Authorized: Reading this environment variable requires the Release Manager role or higher since it targets exclusively the production environment and thus is considered protected.: Entity not authorized: EnvironmentVariableEntity[d3f35233-c3b7-4ef0-98fd-bc08d576e1db] (viewer = ScopedAccountActorViewerContext[RegularUserViewerContext[b99c08be-7584-4358-b8e4-aef050972708], scopes=expo_account:c344bcd9-c79d-4c0b-96fb-940d947cac42(RUN,VIEW);expo_account:102f030b-3f17-44ad-9472-cf051344224b(RUN,VIEW);expo_account:df3ec58d-d9b7-4f83-afdd-dbdb8258fdbe(VIEW)], action = READ, ruleIndex = -1)
Request ID: 11ade772-f079-4515-84bc-3c5239bf35b7
```

Note that this ^^ is just the env evaluation for config, but the same function is used in `eas build:dev`, `build:resign`, `build:version:get/set/sync`, `eas config`, and `eas fingerprint:generate`.
2026-10-28 09:03:43 -07:00
Stanisław Chmiela 78f6782970 [build-tools] Handle ASC 90189 as version duplicate (#3461)
# Why

ASC error 90189 arrives after upload creation. The existing duplicate-version handler covers the creation request, so it does not handle this processing failure.

# How

Map error 90189 to the existing duplicate-build UserError. Include Apple’s original descriptions and codes in the public error message and retain the full failed-upload state as `cause`, including errors, warnings, and information messages. Keep the current SDK-version handler. Remove unrelated message edits copied into the old PR.

# Examples
- https://expo.dev/uuid/019cbf7e-e0f8-7212-9b72-37e97d43b7a2
- https://expo.dev/uuid/019cbf7d-28fa-747b-bd36-81f81b8d8d1b
- https://expo.dev/uuid/019cbf7a-b73a-7310-b845-4e2f8c58224c

# Datadog evidence

Checked on 23 October 2026. Count window: 16 October 2026 12:00 UTC to 23 October 2026 12:00 UTC (14:00 Warsaw time). Counts refer to distinct runs or builds with failed phases, not raw log lines.

187 distinct failed runs contain error 90189. In 173 runs, the validation error list has no other code, so it matches this PR’s condition. Mixed validation errors remain outside that condition.

- [Datadog: matching failed-phase logs](https://app.datadoghq.com/logs?query=service%3Aeas-build+build_phase_result%3Afailed+submission_platform%3Aios+%2290189%22&from_ts=1792152000000&to_ts=1792756800000&live=false&stream_sort=desc)
- [Datadog: example failed run or build](https://app.datadoghq.com/logs?query=service%3Aeas-build+job_run_id%3A01a16866-c973-7288-b710-eb556df52e1c&from_ts=1792152000000&to_ts=1792756800000&live=false&stream_sort=desc)

# Test Plan

- `yarn --cwd packages/build-tools jest-unit --runInBand src/steps/functions/__tests__/uploadToAsc.test.ts`
- `yarn --cwd packages/build-tools typecheck`
- Root type checks, lint, and formatting checks passed.
- All 19 focused tests passed. The new test for public error details and cause preservation fails on the previous code and passes with this change.
2026-10-28 14:21:02 +01:00
Szymon Świerk 5757091b33 [eas-cli] support nested workflow calls in local file workflow run (#4769)
* [eas-cli] Collect workflow files referenced by uses jobs

* [eas-cli] Upload referenced workflow files in workflow:run

* [eas-cli] Regenerate GraphQL types
2026-10-28 11:40:21 +01:00
Ash Wu dd6ad17179 [eas-cli] recognize queued workflow runs and report finishedAt only for final runs (#4258)
Signed-off-by: Ash Wu <[email protected]>
2026-10-28 18:01:28 +08:00
Ash Wu 695f567760 [build-tools][worker] keep NPM_TOKEN working on newer pnpm and explain pnpm Node.js mismatches (#4729)
pnpm 10.34.2+ and 11.5.3+ ignore ${NPM_TOKEN} placeholders in a project-level .npmrc (GHSA-3qhv-2rgh-x77r) but still expand them in the npm user config. On EAS Build the NPM_TOKEN credential now goes into the file `npm config get userconfig` reports for the build env (~/.npmrc unless NPM_CONFIG_USERCONFIG points elsewhere) whenever the job has NPM_TOKEN, and the project .npmrc is left to the user. Local builds keep creating the project .npmrc, because the home directory there belongs to the developer. The legacy PREPARE_PROJECT path and the eas/use_npm_token step share one helper.

Projects that pin pnpm 11 through packageManager on a Node 20 image crash on node:sqlite right after pnpm's "requires at least Node.js v22.13" banner. A new user error names the eas.json node value to set or the pnpm version to change. The worker raises the same error when it reinstalls pnpm under a node version pinned in eas.json, instead of the generic "Failed to install pnpm" message: the typed error it threw before never reached the log handlers, and the probe's piped output never reached the log.
2026-10-28 17:19:16 +08:00
Ash Wu 15e2302575 [build-tools] install the repack package with npm in an isolated sandbox (#4728) 2026-10-28 17:10:27 +08:00
Gabe Debes ee4f03d44e [ci] Format the generated changelog before release (#4773) 2026-10-27 19:18:51 -07:00
75 changed files with 4199 additions and 431 deletions

No files matched your search

+18
View File
@@ -2,6 +2,23 @@
This is the log of notable changes to EAS CLI and related packages.
## [27.1.0](https://github.com/expo/eas-cli/releases/tag/v27.1.0) - 2026-10-28
### 🎉 New features
- [eas-cli] Display queued workflow runs: `workflow:run --wait` and `workflow:status` show a run waiting for its concurrency group and the run blocking it, and `workflow:cancel` includes queued runs in its selection list. ([#4258](https://github.com/expo/eas-cli/pull/4258) by [@hSATAC](https://github.com/hSATAC))
- [eas-cli] Upload the workflow files that `uses` jobs reference, including nested ones, when running a local workflow file with `eas workflow:run`. ([#4769](https://github.com/expo/eas-cli/pull/4769) by [@sswrk](https://github.com/sswrk))
### 🐛 Bug fixes
- [build-tools] Explain App Store Connect error 90189 as a duplicate build number. ([#3461](https://github.com/expo/eas-cli/pull/3461) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-cli] Fail the command when EAS CLI cannot pull environment variables from EAS servers, instead of continuing without them. ([#4774](https://github.com/expo/eas-cli/pull/4774) by [@wschurman](https://github.com/wschurman))
- [eas-cli] Format the generated root changelog before committing a release. ([#4773](https://github.com/expo/eas-cli/pull/4773) by [@gwdp](https://github.com/gwdp))
- [build-tools][worker] Put the `NPM_TOKEN` registry credential in the builder's npm user config on EAS Build (`~/.npmrc`, or the file `NPM_CONFIG_USERCONFIG` names), where pnpm 10.34.2+ and 11.5.3+ still expand `${NPM_TOKEN}` (they ignore it in a project-level `.npmrc`). Explain what to change when the project's pnpm version needs a newer Node.js than the build runs, including when eas.json sets `node` or `pnpm`. ([#4729](https://github.com/expo/eas-cli/pull/4729) by [@hSATAC](https://github.com/hSATAC))
- [build-tools] Install `@expo/repack-app` for the `eas/repack` step with npm in a sandbox that sees only `PATH` and its own `HOME`, so the build machine's npm and Yarn configuration cannot break the install, fetch it through the EAS npm cache when the job uses one, and show the installer output in the build log. The step no longer looks the version up separately before installing. When the npm cache fails, the install retries against the public registry like the project install does; the `eas/run_agent` agent CLI install shares the same helper and gains the same retry. ([#4728](https://github.com/expo/eas-cli/pull/4728) by [@hSATAC](https://github.com/hSATAC))
- [eas-cli] Honor the `--non-interactive` flag in `workflow:cancel`; it was parsed but never applied, so automation could hang on the interactive picker. ([#4258](https://github.com/expo/eas-cli/pull/4258) by [@hSATAC](https://github.com/hSATAC))
- [eas-cli] Stop reporting a finish time for workflow runs that have not finished: `workflow:runs` reports `finishedAt` only for runs in a final status. ([#4258](https://github.com/expo/eas-cli/pull/4258) by [@hSATAC](https://github.com/hSATAC))
## [27.0.0](https://github.com/expo/eas-cli/releases/tag/v27.0.0) - 2026-10-27
### 🛠 Breaking changes
@@ -26,6 +43,7 @@ This is the log of notable changes to EAS CLI and related packages.
- [eas-cli] Require explicit `host:` simulator-host upload paths and report missing local files. ([#4683](https://github.com/expo/eas-cli/pull/4683) by [@gwdp](https://github.com/gwdp))
Missing local absolute paths now require the `host:` prefix. Actions that require upload IDs accept local files and reject host paths with an explanation.
- [build-tools] Handle a device session stopped during startup as normal cleanup instead of reporting a startup error. ([#4716](https://github.com/expo/eas-cli/pull/4716) by [@gwdp](https://github.com/gwdp))
- [build-tools] Support idle timeouts for iOS web-preview-only sessions, resetting the timer when preview input arrives. ([#4731](https://github.com/expo/eas-cli/pull/4731) by [@gwdp](https://github.com/gwdp))
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "27.0.0",
"version": "27.1.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+8
View File
@@ -1,5 +1,13 @@
# @expo/build-tools
## 27.1.0
### Patch Changes
- Explain App Store Connect error 90189 as a duplicate build number. ([#3461](https://github.com/expo/eas-cli/pull/3461) by [@sjchmiela](https://github.com/sjchmiela))
- Put the `NPM_TOKEN` registry credential in the builder's npm user config on EAS Build (`~/.npmrc`, or the file `NPM_CONFIG_USERCONFIG` names), where pnpm 10.34.2+ and 11.5.3+ still expand `${NPM_TOKEN}` (they ignore it in a project-level `.npmrc`). Explain what to change when the project's pnpm version needs a newer Node.js than the build runs, including when eas.json sets `node` or `pnpm`. ([#4729](https://github.com/expo/eas-cli/pull/4729) by [@hSATAC](https://github.com/hSATAC))
- Install `@expo/repack-app` for the `eas/repack` step with npm in a sandbox that sees only `PATH` and its own `HOME`, so the build machine's npm and Yarn configuration cannot break the install, fetch it through the EAS npm cache when the job uses one, and show the installer output in the build log. The step no longer looks the version up separately before installing. When the npm cache fails, the install retries against the public registry like the project install does; the `eas/run_agent` agent CLI install shares the same helper and gains the same retry. ([#4728](https://github.com/expo/eas-cli/pull/4728) by [@hSATAC](https://github.com/hSATAC))
## 27.0.0
### Patch Changes
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "27.0.0",
"version": "27.1.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -42,17 +42,17 @@
"dependencies": {
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/downloader": "27.0.0",
"@expo/eas-build-job": "27.0.0",
"@expo/downloader": "27.1.0",
"@expo/eas-build-job": "27.1.0",
"@expo/env": "^0.4.0",
"@expo/logger": "27.0.0",
"@expo/logger": "27.1.0",
"@expo/package-manager": "1.9.10",
"@expo/plist": "^0.3.5",
"@expo/results": "^1.0.0",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "27.0.0",
"@expo/template-file": "27.0.0",
"@expo/turtle-spawn": "27.0.0",
"@expo/steps": "27.1.0",
"@expo/template-file": "27.1.0",
"@expo/turtle-spawn": "27.1.0",
"@expo/xcpretty": "^4.3.1",
"@ngrok/ngrok": "1.7.0",
"@sentry/node": "7.77.0",
@@ -125,6 +125,31 @@ describe(resolveBuildPhaseErrorAsync, () => {
expect(err.trackingCode).toBeUndefined();
});
it('explains that the pnpm version needs a newer Node.js than the build runs', async () => {
const fakeError = new Error();
const err = await resolveBuildPhaseErrorAsync(
fakeError,
[
'Running "pnpm install --frozen-lockfile" in /home/expo/workingdir/build directory',
'warn: This version of pnpm requires at least Node.js v22.13',
'warn: The current version of Node.js is v20.19.4',
'warn: Visit https://r.pnpm.io/comp to see the list of past pnpm versions with respective Node.js version support.',
'Error [ERR_UNKNOWN_BUILTIN_MODULE]: No such built-in module: node:sqlite',
],
{
job: { platform: Platform.ANDROID } as Job,
phase: BuildPhase.INSTALL_DEPENDENCIES,
env: {},
},
'/fake/path'
);
expect(err.errorCode).toBe('EAS_BUILD_PNPM_UNSUPPORTED_NODE_VERSION_ERROR');
expect(err.message).toBe(
'This build uses a pnpm version that requires Node.js 22.13.0 or newer, but runs Node.js 20.19.4. You can fix it by either:\n- setting "node" to "22.13.0" or a newer version in the build profile in eas.json\n- using a pnpm version that supports Node.js 20.19.4: change "packageManager" in package.json if your project sets it, and set "pnpm" in the build profile in eas.json'
);
expect(err.docsUrl).toBe('https://docs.expo.dev/eas/json/');
});
it('detects xcode line error', async () => {
vol.fromJSON({
'/path/to/xcodelogs.log': originalFs.readFileSync(
@@ -0,0 +1,33 @@
import { errors } from '@expo/eas-build-job';
import nullthrows from 'nullthrows';
import semver from 'semver';
import { ErrorHandler } from './errors.types';
// example log: "warn: This version of pnpm requires at least Node.js v22.13" followed by "warn: The current version of Node.js is v20.19.4"
const PNPM_UNSUPPORTED_NODE_VERSION_REGEXP =
/This version of pnpm requires at least Node\.js v([\d.]+)\n.*The current version of Node\.js is v([\d.]+)/;
function createPnpmUnsupportedNodeVersionError(matchResult: RegExpMatchArray): errors.UserError {
const [, requiredNodeVersion, currentNodeVersion] = matchResult;
const minimumNodeVersion = nullthrows(semver.coerce(requiredNodeVersion)).version;
return new errors.UserError(
'EAS_BUILD_PNPM_UNSUPPORTED_NODE_VERSION_ERROR',
`This build uses a pnpm version that requires Node.js ${minimumNodeVersion} or newer, but runs Node.js ${currentNodeVersion}. You can fix it by either:
- setting "node" to "${minimumNodeVersion}" or a newer version in the build profile in eas.json
- using a pnpm version that supports Node.js ${currentNodeVersion}: change "packageManager" in package.json if your project sets it, and set "pnpm" in the build profile in eas.json`,
{ docsUrl: 'https://docs.expo.dev/eas/json/' }
);
}
export const pnpmUnsupportedNodeVersionErrorHandler: ErrorHandler<errors.UserError> = {
regexp: PNPM_UNSUPPORTED_NODE_VERSION_REGEXP,
createError: createPnpmUnsupportedNodeVersionError,
};
export function detectPnpmUnsupportedNodeVersionError(
output: string
): errors.UserError | undefined {
const match = output.match(PNPM_UNSUPPORTED_NODE_VERSION_REGEXP);
return match ? createPnpmUnsupportedNodeVersionError(match) : undefined;
}
@@ -1,6 +1,7 @@
import { BuildMode, BuildPhase, Platform, Workflow, errors } from '@expo/eas-build-job';
import { ErrorHandler, XCODE_BUILD_PHASE } from './errors.types';
import { pnpmUnsupportedNodeVersionErrorHandler } from './pnpmUnsupportedNodeVersionError';
import UserError = errors.UserError;
@@ -211,6 +212,7 @@ You are seeing this error because either:
return undefined;
},
},
pnpmUnsupportedNodeVersionErrorHandler,
{
platform: Platform.IOS,
phase: XCODE_BUILD_PHASE,
@@ -4,6 +4,7 @@ import path from 'path';
import { BuildContext } from '../context';
import { Sentry } from '../sentry';
import { NpmCacheRegistryInstallError, isNpmCacheInstallFailure } from '../utils/npmCacheRegistry';
import { PackageManager, findPackagerRootDir } from '../utils/packageManager';
import { isUsingModernYarnVersion } from '../utils/project';
@@ -196,10 +197,6 @@ export function resolvePackagerDir(ctx: BuildContext<Job>): string {
return packagerRunDir;
}
function isNpmCacheInstallFailure(err: unknown, npmCacheUrl: string): boolean {
return getErrorOutput(err).includes(npmCacheUrl);
}
function isNpmCacheRegistryErrorLine(line: string, npmCacheUrl: string): boolean {
return (
line.includes(npmCacheUrl) &&
@@ -207,24 +204,9 @@ function isNpmCacheRegistryErrorLine(line: string, npmCacheUrl: string): boolean
);
}
function getErrorOutput(err: unknown): string {
if (!(err instanceof Error)) {
return '';
}
const { stdout, stderr } = err as Error & { stdout?: string; stderr?: string };
return [stdout, stderr].filter(Boolean).join('\n');
}
class NpmCacheRegistryNonFatalError extends SystemError {
override name = 'NpmCacheRegistryNonFatalError';
constructor() {
super('Non-fatal npm cache registry error during dependency install');
}
}
class NpmCacheRegistryInstallError extends SystemError {
override name = 'NpmCacheRegistryInstallError';
constructor(cause: unknown) {
super('Failed to install dependencies using npm cache registry', { cause });
}
}
+6 -1
View File
@@ -71,7 +71,12 @@ export async function setupAsync<TJob extends BuildJob>(
}
);
await setUpNpmrcAsync(ctx, ctx.logger);
await setUpNpmrcAsync({
projectTargetDirectory: ctx.buildDirectory,
workingDirectory: ctx.getReactNativeProjectDirectory(),
env: ctx.env,
logger: ctx.logger,
});
if (ctx.job.platform === Platform.IOS && ctx.env.EAS_BUILD_RUNNER === 'eas-build') {
await deleteXcodeEnvLocalIfExistsAsync(ctx as BuildContext<Ios.Job>);
}
+1
View File
@@ -34,6 +34,7 @@ export { formatSecondsForLog } from './utils/formatDuration';
export { findAndUploadXcodeBuildLogsAsync } from './ios/xcodeBuildLogs';
export { Hook, runHookIfPresent } from './utils/hooks';
export { detectPnpmUnsupportedNodeVersionError } from './buildErrors/pnpmUnsupportedNodeVersionError';
export { parseGradleProfile, formatGradleProfileReport } from './android/gradleProfile';
export type { GradleProfileTask } from './android/gradleProfile';
@@ -1,8 +1,10 @@
import { type bunyan } from '@expo/logger';
import turtleSpawnAsync from '@expo/turtle-spawn';
import assert from 'node:assert';
import fg from 'fast-glob';
import { vol } from 'memfs';
import path from 'node:path';
import resolveFrom from 'resolve-from';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createTestAndroidJob, createTestIosJob } from '../../../__tests__/utils/job';
@@ -17,17 +19,100 @@ import {
} from '../repack';
jest.mock('fs');
jest.mock('@expo/spawn-async');
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
jest.mock('@expo/repack-app');
jest.mock('resolve-from', () => jest.fn((_, moduleName) => moduleName));
jest.mock('resolve-from', () => jest.fn(() => '@expo/repack-app'));
jest.mock('../../utils/ios/credentials/manager');
describe(createRepackBuildFunction, () => {
beforeEach(() => {
jest.mocked(turtleSpawnAsync).mockImplementation((async (_command: string, args: string[]) => {
const sandbox = args[args.indexOf('--prefix') + 1];
const packageName = args[args.length - 1].replace(/@[^@/]*$/, '');
vol.fromJSON({
[path.join(sandbox, 'node_modules', packageName, 'package.json')]: JSON.stringify({
version: '2.0.0-next.1',
}),
});
}) as unknown as typeof turtleSpawnAsync);
});
afterEach(() => {
jest.restoreAllMocks();
vol.reset();
});
it('installs the repack package with npm in a sandbox whose env is only PATH and HOME, through the npm cache registry', async () => {
const globalCtx = createGlobalContextMock({
staticContextContent: {
job: {},
},
});
globalCtx.updateEnv({
PATH: '/usr/local/bin:/usr/bin',
EAS_BUILD_NPM_CACHE_URL: 'https://npm-cache.expo.test',
NPM_TOKEN: 'secret',
});
const repack = createRepackBuildFunction();
const repackStep = repack.createBuildStepFromFunctionCall(globalCtx, {
callInputs: {
platform: 'ios',
source_app_path: '/path/to/source_app.ipa',
repack_package: '@scope/fork',
repack_version: 'next',
},
});
const logInfo = jest.spyOn(repackStep.ctx.logger, 'info');
await repackStep.executeAsync();
const [installCall] = jest.mocked(turtleSpawnAsync).mock.calls;
const npmEnv = installCall[2]?.env;
assert(npmEnv);
expect(Object.keys(npmEnv).sort()).toEqual(['HOME', 'PATH']);
expect(npmEnv.PATH).toMatch(/\/usr\/local\/bin:\/usr\/bin$/);
expect(installCall).toEqual([
'npm',
[
'install',
'--prefix',
npmEnv.HOME,
'--no-save',
'--package-lock=false',
'--no-audit',
'--no-fund',
'--no-update-notifier',
'--registry',
'https://npm-cache.expo.test',
'@scope/fork@next',
],
{ env: npmEnv, logger: repackStep.ctx.logger },
]);
expect(logInfo).toHaveBeenCalledWith('Using repack from: @scope/fork@next (2.0.0-next.1)');
expect(resolveFrom).toHaveBeenCalledWith(npmEnv.HOME, '@scope/fork');
});
it('installs the repack package from the default registry when the job has no npm cache', async () => {
const globalCtx = createGlobalContextMock({
staticContextContent: {
job: {},
},
});
globalCtx.updateEnv({ PATH: '/usr/local/bin:/usr/bin' });
const repack = createRepackBuildFunction();
const repackStep = repack.createBuildStepFromFunctionCall(globalCtx, {
callInputs: {
platform: 'ios',
source_app_path: '/path/to/source_app.ipa',
},
});
await repackStep.executeAsync();
const [installCall] = jest.mocked(turtleSpawnAsync).mock.calls;
expect(installCall[1]).not.toContain('--registry');
});
it('should set generated output path for successful repack', async () => {
const repack = createRepackBuildFunction();
const repackStep = repack.createBuildStepFromFunctionCall(
@@ -103,7 +103,7 @@ describe(createRunAgentBuildFunction, () => {
);
const npmEnv = installMock.mock.calls[0][2]!.env!;
const agentEnv = spawnMock.mock.calls[0][2].env!;
expect(Object.keys(npmEnv).sort()).toEqual(['HOME', 'LANG', 'PATH', 'TMPDIR']);
expect(Object.keys(npmEnv).sort()).toEqual(['HOME', 'PATH']);
expect(Object.keys(agentEnv).sort()).toEqual([
'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC',
'CLAUDE_CODE_OAUTH_TOKEN',
@@ -1,7 +1,15 @@
import { UserError } from '@expo/eas-build-job';
import fs from 'fs-extra';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { AscApiClient } from '../../utils/ios/AscApiClient';
import { AscApiUtils } from '../../utils/ios/AscApiUtils';
import {
createUploadToAscBuildFunction,
isClosedVersionTrainError,
isInvalidBundleIdentifierError,
isMissingPurposeStringError,
isRedundantBinaryUploadError,
isSdkVersionIssueError,
parseMissingUsageDescriptionKeys,
} from '../uploadToAsc';
@@ -45,6 +53,20 @@ describe(isInvalidBundleIdentifierError, () => {
});
});
describe(isRedundantBinaryUploadError, () => {
it('returns true when all errors are redundant-binary-upload codes', () => {
expect(isRedundantBinaryUploadError([{ code: '90189' }, { code: '90189' }])).toBe(true);
});
it('returns false when any other error code is present', () => {
expect(isRedundantBinaryUploadError([{ code: '90189' }, { code: '90062' }])).toBe(false);
});
it('returns false when there are no errors', () => {
expect(isRedundantBinaryUploadError([])).toBe(false);
});
});
describe(isMissingPurposeStringError, () => {
it('returns true when all errors are missing-purpose-string codes', () => {
expect(isMissingPurposeStringError([{ code: '90683' }, { code: '90683' }])).toBe(true);
@@ -110,3 +132,62 @@ describe(parseMissingUsageDescriptionKeys, () => {
).toEqual([]);
});
});
describe(createUploadToAscBuildFunction, () => {
it('keeps Apple duplicate-build details in the public error and the full state as its cause', async () => {
const state = {
state: 'FAILED',
errors: [
{
code: '90189',
description: 'Redundant Binary Upload. Build number 1 was already uploaded.',
},
],
warnings: [{ code: 'WARNING', description: 'Apple warning.' }],
infos: [{ code: 'INFO', description: 'Apple information.' }],
};
await fs.writeFile('/app.ipa', 'ipa');
await fs.writeFile('/key.json', '{}');
jest.spyOn(AscApiUtils, 'loadApiKeyAsync').mockResolvedValue({} as never);
jest.spyOn(AscApiUtils, 'getAppInfoAsync').mockResolvedValue({
data: { attributes: { name: 'App', bundleId: 'com.example.app' } },
} as never);
jest.spyOn(AscApiUtils, 'createBuildUploadAsync').mockResolvedValue({
data: { id: 'upload-id' },
} as never);
jest.spyOn(AscApiUtils, 'commitBuildUploadFileAsync').mockResolvedValue(undefined as never);
jest.spyOn(AscApiClient.prototype, 'postAsync').mockResolvedValue({
data: { id: 'file-id', attributes: { uploadOperations: [] } },
} as never);
jest
.spyOn(AscApiClient.prototype, 'getAsync')
.mockResolvedValueOnce({
data: { attributes: { assetDeliveryState: { state: 'COMPLETE' } } },
} as never)
.mockResolvedValueOnce({ data: { attributes: { state } } } as never);
try {
const step = createUploadToAscBuildFunction().createBuildStepFromFunctionCall(
createGlobalContextMock({ staticContextContent: { job: {} } }),
{
callInputs: {
ipa_path: '/app.ipa',
asc_api_key_path: '/key.json',
apple_app_identifier: 'app-id',
bundle_version: '1',
bundle_short_version: '1.0',
wait_for_processing: true,
},
}
);
const error = await step.executeAsync().catch(error => error);
expect(error).toBeInstanceOf(UserError);
expect(error.errorCode).toBe('EAS_UPLOAD_TO_ASC_VERSION_DUPLICATE');
expect(error.cause).toEqual(state);
expect(error.toExternalExpoError().message).toContain(
'Redundant Binary Upload. Build number 1 was already uploaded. (90189)'
);
} finally {
jest.restoreAllMocks();
}
});
});
@@ -0,0 +1,39 @@
import spawn from '@expo/turtle-spawn';
import { vol } from 'memfs';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { NpmrcAuthTokenLine } from '../../../templates/npmrc';
import { createSetUpNpmrcBuildFunction } from '../useNpmToken';
jest.mock('fs');
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
const projectTargetDirectory = '/workingdir/project';
const homeDirectory = '/home/expo';
describe(createSetUpNpmrcBuildFunction, () => {
beforeEach(async () => {
vol.reset();
await vol.promises.mkdir(projectTargetDirectory, { recursive: true });
await vol.promises.mkdir(homeDirectory, { recursive: true });
jest.mocked(spawn).mockResolvedValue({ stdout: `${homeDirectory}/.npmrc\n` } as never);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('adds the NPM_TOKEN placeholder from the step env to ~/.npmrc on EAS Build', async () => {
const globalCtx = createGlobalContextMock({ projectTargetDirectory });
globalCtx.updateEnv({ NPM_TOKEN: 'npm_secret_token', EAS_BUILD_RUNNER: 'eas-build' });
const step = createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
callInputs: {},
});
await step.executeAsync();
expect(await vol.promises.readFile(`${homeDirectory}/.npmrc`, 'utf8')).toContain(
NpmrcAuthTokenLine
);
});
});
@@ -23,7 +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';
import { installIsolatedNpmPackageAsync } from '../../utils/installIsolatedNpmPackageAsync';
export function createRepackBuildFunction(): BuildFunction {
return new BuildFunction({
@@ -121,18 +121,12 @@ export function createRepackBuildFunction(): BuildFunction {
: 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} (${resolvedRepackVersion})`
);
const repackApp = await installAndImportRepackAsync({
packageName: inputs.repack_package.value as string,
version: resolvedRepackVersion,
versionSpec: inputs.repack_version.value as string,
pathEnv: env.PATH,
npmCacheUrl: env.EAS_BUILD_NPM_CACHE_URL,
logger: stepsCtx.logger,
});
const { repackAppIosAsync, repackAppAndroidAsync } = repackApp;
@@ -226,16 +220,28 @@ function createOutputPath({
*/
async function installAndImportRepackAsync({
packageName,
version,
versionSpec,
pathEnv,
npmCacheUrl,
logger,
}: {
packageName: string;
version: string;
versionSpec: string;
pathEnv: string | undefined;
npmCacheUrl: string | undefined;
logger: bunyan;
}): Promise<typeof import('@expo/repack-app')> {
const sandbox = await fs.promises.mkdtemp(path.join(os.tmpdir(), `repack-package-root-`));
await spawnAsync('yarn', ['add', `${packageName}@${version}`], {
stdio: 'inherit',
cwd: sandbox,
await installIsolatedNpmPackageAsync({
packageSpec: `${packageName}@${versionSpec}`,
directory: sandbox,
npmCacheUrl,
pathEnv,
logger,
});
const packageJsonPath = path.join(sandbox, 'node_modules', packageName, 'package.json');
const { version } = JSON.parse(await fs.promises.readFile(packageJsonPath, 'utf8'));
logger.info(`Using repack from: ${packageName}@${versionSpec} (${version})`);
return require(resolveFrom(sandbox, packageName));
}
@@ -262,6 +262,19 @@ export function createUploadToAscBuildFunction(): BuildFunction {
}
);
}
if (isRedundantBinaryUploadError(errors)) {
throw new UserError(
'EAS_UPLOAD_TO_ASC_VERSION_DUPLICATE',
`Increment Build Number: Build number ${bundleVersion} for app version ${bundleShortVersion} has already been used. ` +
'App Store Connect requires unique build numbers within each app version (version train). ' +
'Increment it by setting ios.buildNumber in app.json, or set "autoIncrement": true in eas.json (recommended). Then rebuild and resubmit.' +
`\n\nApp Store Connect errors:\n${itemizeMessages(errors)}`,
{
docsUrl: 'https://docs.expo.dev/build-reference/app-versions/',
cause: state,
}
);
}
if (isInvalidBundleIdentifierError(errors)) {
const ipaInfoResult = await asyncResult(readIpaInfoAsync(ipaPath));
const ipaBundleIdentifier = ipaInfoResult.ok
@@ -344,6 +357,10 @@ export function isSdkVersionIssueError(messages: { code: string }[]): boolean {
return messages.length > 0 && messages.every(message => message.code === '90725');
}
export function isRedundantBinaryUploadError(messages: { code: string }[]): boolean {
return messages.length > 0 && messages.every(message => message.code === '90189');
}
export function isInvalidBundleIdentifierError(messages: { code: string }[]): boolean {
return (
messages.length > 0 && messages.every(message => ['90054', '90055'].includes(message.code))
@@ -1,9 +1,6 @@
import { BuildFunction } from '@expo/steps';
import fs from 'fs-extra';
import path from 'path';
import { NpmrcTemplate } from '../../templates/npmrc';
import { findPackagerRootDir } from '../../utils/packageManager';
import { setUpNpmrcAsync } from '../../utils/npmrc';
export function createSetUpNpmrcBuildFunction(): BuildFunction {
return new BuildFunction({
@@ -12,28 +9,12 @@ export function createSetUpNpmrcBuildFunction(): BuildFunction {
name: 'Use NPM_TOKEN',
__metricsId: 'eas/use_npm_token',
fn: async (stepCtx, { env }) => {
const { logger } = stepCtx;
if (env.NPM_TOKEN) {
logger.info('We detected that you set the NPM_TOKEN environment variable');
const projectNpmrcPath = path.join(stepCtx.global.projectTargetDirectory, '.npmrc');
if (await fs.pathExists(projectNpmrcPath)) {
logger.info('.npmrc already exists in your project directory, skipping generation');
} else {
logger.info('Creating .npmrc in your project directory with the following contents:');
logger.info(NpmrcTemplate);
await fs.writeFile(projectNpmrcPath, NpmrcTemplate);
}
} else {
const projectNpmrcPath = path.join(findPackagerRootDir(stepCtx.workingDirectory), '.npmrc');
if (await fs.pathExists(projectNpmrcPath)) {
logger.info(
`.npmrc found at ${path.relative(
stepCtx.global.projectTargetDirectory,
projectNpmrcPath
)}`
);
}
}
await setUpNpmrcAsync({
projectTargetDirectory: stepCtx.global.projectTargetDirectory,
workingDirectory: stepCtx.workingDirectory,
env,
logger: stepCtx.logger,
});
},
});
}
@@ -186,6 +186,7 @@ describe(runAgentAsync, () => {
'--package-lock=false',
'--no-audit',
'--no-fund',
'--no-update-notifier',
'--registry',
'https://npm-cache.expo.test',
'@anthropic-ai/[email protected]',
@@ -1,6 +1,5 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import turtleSpawn from '@expo/turtle-spawn';
import { spawn } from 'node:child_process';
import { once } from 'node:events';
import fs from 'node:fs/promises';
@@ -13,6 +12,7 @@ import { type AgentCli, type AgentInvocation, type AgentLogMessage } from './age
import { CLAUDE_CODE_CLI, prepareClaudeCodeAsync } from './agents/claude';
import { CODEX_CLI, prepareCodexAsync } from './agents/codex';
import { Sentry } from '../../sentry';
import { installIsolatedNpmPackageAsync } from '../../utils/installIsolatedNpmPackageAsync';
import { killProcessGroup } from '../../utils/processes';
export type AgentKind = 'claude-code' | 'codex';
@@ -76,17 +76,13 @@ export async function runAgentAsync({
const { packageSpec, bin, formatOutputLine } = AGENT_CLIS[agentKind];
logger.info(`Installing ${packageSpec}.`);
try {
await turtleSpawn(
'npm',
[
'install',
...['--prefix', cliDirectory],
...['--no-save', '--package-lock=false', '--no-audit', '--no-fund'],
...(npmCacheUrl ? ['--registry', npmCacheUrl] : []),
packageSpec,
],
{ env: minimalEnv, signal }
);
await installIsolatedNpmPackageAsync({
packageSpec,
directory: cliDirectory,
npmCacheUrl,
pathEnv,
signal,
});
} catch (error) {
signal.throwIfAborted();
throw new SystemError(
+3 -1
View File
@@ -1,3 +1,5 @@
export const NpmrcTemplate = `//registry.npmjs.org/:_authToken=\${NPM_TOKEN}
export const NpmrcAuthTokenLine = '//registry.npmjs.org/:_authToken=${NPM_TOKEN}';
export const NpmrcTemplate = `${NpmrcAuthTokenLine}
registry=https://registry.npmjs.org/
`;
@@ -0,0 +1,89 @@
import spawn from '@expo/turtle-spawn';
import { createMockLogger } from '../../__tests__/utils/logger';
import { Sentry } from '../../sentry';
import { installIsolatedNpmPackageAsync } from '../installIsolatedNpmPackageAsync';
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
jest.mock('../../sentry', () => ({ Sentry: { capture: jest.fn() } }));
const spawnMock = jest.mocked(spawn);
const npmCacheUrl = 'https://npm-cache.expo.test';
describe(installIsolatedNpmPackageAsync, () => {
it('retries without the npm cache registry when the install fails against it', async () => {
const logger = createMockLogger();
const cacheFailure = Object.assign(new Error('npm install exited with non-zero code: 1'), {
stderr: `npm error request to ${npmCacheUrl}/@scope%2ffork failed, reason: ECONNREFUSED`,
});
spawnMock.mockRejectedValueOnce(cacheFailure).mockResolvedValueOnce({} as never);
await installIsolatedNpmPackageAsync({
packageSpec: '@scope/fork@next',
directory: '/tmp/sandbox',
npmCacheUrl,
pathEnv: '/usr/bin',
logger,
});
expect(spawnMock).toHaveBeenCalledTimes(2);
expect(spawnMock.mock.calls[0][1]).toEqual(expect.arrayContaining(['--registry', npmCacheUrl]));
expect(spawnMock.mock.calls[1][1]).not.toContain('--registry');
expect(spawnMock.mock.calls[1][2]).toEqual({
env: { PATH: '/usr/bin', HOME: '/tmp/sandbox' },
logger,
});
expect(logger.warn).toHaveBeenCalledWith(
`Failed to install @scope/fork@next using the npm cache registry (${npmCacheUrl}). Retrying without the npm cache registry.`
);
expect(Sentry.capture).toHaveBeenCalledWith(
expect.objectContaining({ name: 'NpmCacheRegistryInstallError', cause: cacheFailure }),
{ level: 'warning', extras: { packageSpec: '@scope/fork@next', npmCacheUrl } }
);
});
it('rethrows an install failure that does not mention the npm cache registry', async () => {
const error = Object.assign(new Error('npm install exited with non-zero code: 1'), {
stderr: 'npm error notarget No matching version found for @scope/fork@next.',
});
spawnMock.mockRejectedValueOnce(error);
await expect(
installIsolatedNpmPackageAsync({
packageSpec: '@scope/fork@next',
directory: '/tmp/sandbox',
npmCacheUrl,
pathEnv: '/usr/bin',
})
).rejects.toBe(error);
expect(spawnMock).toHaveBeenCalledTimes(1);
expect(Sentry.capture).not.toHaveBeenCalled();
});
it('rethrows the cancellation of an install without retrying', async () => {
const controller = new AbortController();
const cancellation = new Error('cancelled');
spawnMock.mockImplementationOnce((() => {
controller.abort(cancellation);
return Promise.reject(
Object.assign(new Error('npm install was killed with SIGTERM'), {
stderr: `npm error request to ${npmCacheUrl}/@scope%2ffork failed`,
})
);
}) as unknown as typeof spawn);
await expect(
installIsolatedNpmPackageAsync({
packageSpec: '@scope/fork@next',
directory: '/tmp/sandbox',
npmCacheUrl,
pathEnv: '/usr/bin',
signal: controller.signal,
})
).rejects.toBe(cancellation);
expect(spawnMock).toHaveBeenCalledTimes(1);
expect(Sentry.capture).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,120 @@
import spawn from '@expo/turtle-spawn';
import { vol } from 'memfs';
import { createMockLogger } from '../../__tests__/utils/logger';
import { NpmrcAuthTokenLine, NpmrcTemplate } from '../../templates/npmrc';
import { setUpNpmrcAsync } from '../npmrc';
jest.mock('fs');
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
const projectTargetDirectory = '/workingdir/build';
const homeDirectory = '/home/expo';
describe(setUpNpmrcAsync, () => {
beforeEach(async () => {
vol.reset();
await vol.promises.mkdir(projectTargetDirectory, { recursive: true });
await vol.promises.mkdir(homeDirectory, { recursive: true });
jest.mocked(spawn).mockResolvedValue({ stdout: `${homeDirectory}/.npmrc\n` } as never);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('adds the NPM_TOKEN placeholder to ~/.npmrc on EAS Build and leaves the project .npmrc alone', async () => {
const projectContents = '//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}\n';
await vol.promises.writeFile(`${projectTargetDirectory}/.npmrc`, projectContents);
await vol.promises.writeFile(`${homeDirectory}/.npmrc`, 'fund=false');
await setUpNpmrcAsync({
projectTargetDirectory,
workingDirectory: projectTargetDirectory,
env: { NPM_TOKEN: 'npm_secret_token', EAS_BUILD_RUNNER: 'eas-build' },
logger: createMockLogger(),
});
expect(await vol.promises.readFile(`${homeDirectory}/.npmrc`, 'utf8')).toBe(
`fund=false\n${NpmrcAuthTokenLine}\n`
);
expect(await vol.promises.readFile(`${projectTargetDirectory}/.npmrc`, 'utf8')).toBe(
projectContents
);
});
it('adds the NPM_TOKEN placeholder to the user config file npm resolves from the build env', async () => {
await vol.promises.mkdir(`${homeDirectory}/npm`, { recursive: true });
jest.mocked(spawn).mockResolvedValueOnce({ stdout: `${homeDirectory}/npm/npmrc\n` } as never);
const env = {
NPM_TOKEN: 'npm_secret_token',
EAS_BUILD_RUNNER: 'eas-build',
NPM_CONFIG_USERCONFIG: '~/npm/npmrc',
};
await setUpNpmrcAsync({
projectTargetDirectory,
workingDirectory: projectTargetDirectory,
env,
logger: createMockLogger(),
});
expect(spawn).toHaveBeenCalledWith(
'npm',
['config', 'get', 'userconfig'],
expect.objectContaining({ cwd: projectTargetDirectory, env })
);
expect(await vol.promises.readFile(`${homeDirectory}/npm/npmrc`, 'utf8')).toBe(
`\n${NpmrcAuthTokenLine}\n`
);
expect(vol.existsSync(`${homeDirectory}/.npmrc`)).toBe(false);
});
it('creates the project .npmrc and leaves the user npmrc alone on local builds', async () => {
await setUpNpmrcAsync({
projectTargetDirectory,
workingDirectory: projectTargetDirectory,
env: { NPM_TOKEN: 'npm_secret_token', EAS_BUILD_RUNNER: 'local-build-plugin' },
logger: createMockLogger(),
});
expect(await vol.promises.readFile(`${projectTargetDirectory}/.npmrc`, 'utf8')).toBe(
NpmrcTemplate
);
expect(vol.existsSync(`${homeDirectory}/.npmrc`)).toBe(false);
});
it('leaves an existing project .npmrc untouched on local builds', async () => {
const existingContents = 'registry=https://registry.example.com/\n';
await vol.promises.writeFile(`${projectTargetDirectory}/.npmrc`, existingContents);
await setUpNpmrcAsync({
projectTargetDirectory,
workingDirectory: projectTargetDirectory,
env: { NPM_TOKEN: 'npm_secret_token', EAS_BUILD_RUNNER: 'local-build-plugin' },
logger: createMockLogger(),
});
expect(await vol.promises.readFile(`${projectTargetDirectory}/.npmrc`, 'utf8')).toBe(
existingContents
);
});
it('reports an existing .npmrc when NPM_TOKEN is not set', async () => {
const logger = createMockLogger();
await vol.promises.writeFile(
`${projectTargetDirectory}/.npmrc`,
'registry=https://registry.example.com/\n'
);
await setUpNpmrcAsync({
projectTargetDirectory,
workingDirectory: projectTargetDirectory,
env: { EAS_BUILD_RUNNER: 'eas-build' },
logger,
});
expect(logger.info).toHaveBeenCalledWith('.npmrc found at .npmrc');
expect(vol.existsSync(`${homeDirectory}/.npmrc`)).toBe(false);
});
});
@@ -1,9 +1,7 @@
import { errors } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import fs from 'fs-extra';
import { vol } from 'memfs';
import path from 'path';
import semver from 'semver';
import {
PackageManager,
@@ -16,7 +14,6 @@ import {
resolvePackageExec,
resolvePackageInstall,
resolvePackageManager,
resolvePackageVersionAsync,
shouldUseFrozenLockfile,
} from '../packageManager';
@@ -293,33 +290,6 @@ describe(resolvePackageInstall, () => {
});
});
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,62 @@
import { type bunyan } from '@expo/logger';
import spawn from '@expo/turtle-spawn';
import { NpmCacheRegistryInstallError, isNpmCacheInstallFailure } from './npmCacheRegistry';
import { Sentry } from '../sentry';
export async function installIsolatedNpmPackageAsync({
packageSpec,
directory,
npmCacheUrl,
pathEnv,
logger,
signal,
}: {
packageSpec: string;
directory: string;
npmCacheUrl: string | undefined;
pathEnv: string | undefined;
logger?: bunyan;
signal?: AbortSignal;
}): Promise<void> {
const installAsync = async (registryUrl: string | undefined): Promise<void> => {
await spawn(
'npm',
[
'install',
'--prefix',
directory,
'--no-save',
'--package-lock=false',
'--no-audit',
'--no-fund',
'--no-update-notifier',
...(registryUrl ? ['--registry', registryUrl] : []),
packageSpec,
],
// npm reads the user config in $HOME from any cwd, so the install directory doubles as HOME.
{ env: { PATH: pathEnv, HOME: directory }, logger, signal }
);
};
if (!npmCacheUrl) {
await installAsync(undefined);
return;
}
try {
await installAsync(npmCacheUrl);
} catch (error) {
signal?.throwIfAborted();
if (!isNpmCacheInstallFailure(error, npmCacheUrl)) {
throw error;
}
logger?.warn(
`Failed to install ${packageSpec} using the npm cache registry (${npmCacheUrl}). Retrying without the npm cache registry.`
);
Sentry.capture(new NpmCacheRegistryInstallError(error), {
level: 'warning',
extras: { packageSpec, npmCacheUrl },
});
await installAsync(undefined);
}
}
@@ -0,0 +1,20 @@
import { SystemError } from '@expo/eas-build-job';
export function isNpmCacheInstallFailure(err: unknown, npmCacheUrl: string): boolean {
return getErrorOutput(err).includes(npmCacheUrl);
}
function getErrorOutput(err: unknown): string {
if (!(err instanceof Error)) {
return '';
}
const { stdout, stderr } = err as Error & { stdout?: string; stderr?: string };
return [stdout, stderr].filter(Boolean).join('\n');
}
export class NpmCacheRegistryInstallError extends SystemError {
override name = 'NpmCacheRegistryInstallError';
constructor(cause: unknown) {
super('Failed to install dependencies using npm cache registry', { cause });
}
}
+50 -16
View File
@@ -1,23 +1,56 @@
import { Job } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import spawn from '@expo/turtle-spawn';
import fs from 'fs-extra';
import path from 'path';
import { findPackagerRootDir } from './packageManager';
import { BuildContext } from '../context';
import { NpmrcTemplate } from '../templates/npmrc';
import { NpmrcAuthTokenLine, NpmrcTemplate } from '../templates/npmrc';
export async function setUpNpmrcAsync(ctx: BuildContext<Job>, logger: bunyan): Promise<void> {
if (ctx.env.NPM_TOKEN) {
await createNpmrcIfNotExistsAsync(ctx, logger);
export async function setUpNpmrcAsync({
projectTargetDirectory,
workingDirectory,
env,
logger,
}: {
projectTargetDirectory: string;
workingDirectory: string;
env: Record<string, string | undefined>;
logger: bunyan;
}): Promise<void> {
if (!env.NPM_TOKEN) {
await logIfNpmrcExistsAsync(projectTargetDirectory, workingDirectory, logger);
return;
}
logger.info('We detected that you set the NPM_TOKEN environment variable');
// pnpm 10.34.2+ and 11.5.3+ ignore ${NPM_TOKEN} placeholders in a project-level .npmrc but expand them in ~/.npmrc
if (env.EAS_BUILD_RUNNER === 'eas-build') {
await addNpmTokenToUserNpmrcAsync(workingDirectory, env, logger);
} else {
await logIfNpmrcExistsAsync(ctx, logger);
await createProjectNpmrcIfNotExistsAsync(projectTargetDirectory, logger);
}
}
async function createNpmrcIfNotExistsAsync(ctx: BuildContext<Job>, logger: bunyan): Promise<void> {
logger.info('We detected that you set the NPM_TOKEN environment variable');
const projectNpmrcPath = path.join(ctx.buildDirectory, '.npmrc');
async function addNpmTokenToUserNpmrcAsync(
workingDirectory: string,
env: Record<string, string | undefined>,
logger: bunyan
): Promise<void> {
const userNpmrcPath = (
await spawn('npm', ['config', 'get', 'userconfig'], {
cwd: findPackagerRootDir(workingDirectory),
env,
stdio: 'pipe',
})
).stdout.trim();
logger.info(`Adding "${NpmrcAuthTokenLine}" to ${userNpmrcPath}`);
await fs.appendFile(userNpmrcPath, `\n${NpmrcAuthTokenLine}\n`);
}
async function createProjectNpmrcIfNotExistsAsync(
projectTargetDirectory: string,
logger: bunyan
): Promise<void> {
const projectNpmrcPath = path.join(projectTargetDirectory, '.npmrc');
if (await fs.pathExists(projectNpmrcPath)) {
logger.info('.npmrc already exists in your project directory, skipping generation');
} else {
@@ -27,12 +60,13 @@ async function createNpmrcIfNotExistsAsync(ctx: BuildContext<Job>, logger: bunya
}
}
async function logIfNpmrcExistsAsync(ctx: BuildContext<Job>, logger: bunyan): Promise<void> {
const projectNpmrcPath = path.join(
findPackagerRootDir(ctx.getReactNativeProjectDirectory()),
'.npmrc'
);
async function logIfNpmrcExistsAsync(
projectTargetDirectory: string,
workingDirectory: string,
logger: bunyan
): Promise<void> {
const projectNpmrcPath = path.join(findPackagerRootDir(workingDirectory), '.npmrc');
if (await fs.pathExists(projectNpmrcPath)) {
logger.info(`.npmrc found at ${path.relative(ctx.buildDirectory, projectNpmrcPath)}`);
logger.info(`.npmrc found at ${path.relative(projectTargetDirectory, projectNpmrcPath)}`);
}
}
@@ -1,5 +1,4 @@
import { errors } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import * as PackageManagerUtils from '@expo/package-manager';
import { type BuildStepEnv } from '@expo/steps';
import spawnAsync from '@expo/turtle-spawn';
@@ -126,34 +125,6 @@ export function resolvePackageInstall(
}
}
/**
* 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;
}
@@ -1,5 +1,9 @@
# create-eas-build-function
## 27.1.0
No changes in this release.
## 27.0.0
No changes in this release.
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "27.0.0",
"version": "27.1.0",
"description": "Create functions for use in EAS Build custom builds.",
"keywords": [
"expo",
@@ -35,7 +35,7 @@
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/steps": "27.0.0"
"@expo/steps": "27.1.0"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
+4
View File
@@ -1,5 +1,9 @@
# @expo/downloader
## 27.1.0
No changes in this release.
## 27.0.0
No changes in this release.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/downloader",
"version": "27.0.0",
"version": "27.1.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -1,5 +1,9 @@
# eas-build-cache-provider
## 27.1.0
No changes in this release.
## 27.0.0
No changes in this release.
@@ -1,6 +1,6 @@
{
"name": "eas-build-cache-provider",
"version": "27.0.0",
"version": "27.1.0",
"description": "A build cache provider plugin for the Expo CLI",
"homepage": "https://github.com/expo/eas-cli",
"bugs": "https://github.com/expo/eas-cli/issues",
+4
View File
@@ -1,5 +1,9 @@
# @expo/eas-build-job
## 27.1.0
No changes in this release.
## 27.0.0
No changes in this release.
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-build-job",
"version": "27.0.0",
"version": "27.1.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "MIT",
"author": "Expo <[email protected]>",
@@ -25,9 +25,9 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/logger": "27.0.0",
"@expo/logger": "27.1.0",
"@expo/results": "1.0.0",
"@expo/turtle-spawn": "27.0.0",
"@expo/turtle-spawn": "27.1.0",
"joi": "^17.13.1",
"semver": "^7.6.2",
"zod": "^4.3.5"
+14
View File
@@ -1,5 +1,19 @@
# eas-cli
## 27.1.0
### Minor Changes
- Display queued workflow runs: `workflow:run --wait` and `workflow:status` show a run waiting for its concurrency group and the run blocking it, and `workflow:cancel` includes queued runs in its selection list. ([#4258](https://github.com/expo/eas-cli/pull/4258) by [@hSATAC](https://github.com/hSATAC))
- Upload the workflow files that `uses` jobs reference, including nested ones, when running a local workflow file with `eas workflow:run`. ([#4769](https://github.com/expo/eas-cli/pull/4769) by [@sswrk](https://github.com/sswrk))
### Patch Changes
- Fail the command when EAS CLI cannot pull environment variables from EAS servers, instead of continuing without them. ([#4774](https://github.com/expo/eas-cli/pull/4774) by [@wschurman](https://github.com/wschurman))
- Format the generated root changelog before committing a release. ([#4773](https://github.com/expo/eas-cli/pull/4773) by [@gwdp](https://github.com/gwdp))
- Honor the `--non-interactive` flag in `workflow:cancel`; it was parsed but never applied, so automation could hang on the interactive picker. ([#4258](https://github.com/expo/eas-cli/pull/4258) by [@hSATAC](https://github.com/hSATAC))
- Stop reporting a finish time for workflow runs that have not finished: `workflow:runs` reports `finishedAt` only for runs in a final status. ([#4258](https://github.com/expo/eas-cli/pull/4258) by [@hSATAC](https://github.com/hSATAC))
## 27.0.0
### Major Changes
+147 -147
View File
File diff suppressed because it is too large. Load diff
+2217 -3
View File
File diff suppressed because it is too large. Load diff
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "27.0.0",
"version": "27.1.0",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -48,12 +48,12 @@
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/eas-build-job": "27.0.0",
"@expo/eas-json": "27.0.0",
"@expo/eas-build-job": "27.1.0",
"@expo/eas-json": "27.1.0",
"@expo/env": "^1.0.0",
"@expo/experimentation": "0.0.5",
"@expo/json-file": "8.3.3",
"@expo/logger": "27.0.0",
"@expo/logger": "27.1.0",
"@expo/multipart-body-parser": "2.0.0",
"@expo/osascript": "2.1.4",
"@expo/package-manager": "1.9.10",
@@ -65,7 +65,7 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "27.0.0",
"@expo/steps": "27.1.0",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
@@ -198,6 +198,28 @@ export function getMockEmptyWorkflowRunsFragment(): WorkflowRunFragment[] {
return getMockWorkflowRunsFragment();
}
export function getMockWorkflowRunFragment(
status: WorkflowRunStatus,
overrides?: Partial<WorkflowRunFragment>
): WorkflowRunFragment {
return {
id: `run-${status}`,
status,
triggerEventType: WorkflowRunTriggerEventType.Manual,
errors: [],
createdAt: '2022-01-01T00:00:00.000Z',
updatedAt: '2022-01-01T00:00:00.000Z',
gitCommitHash: '1234567890',
gitCommitMessage: 'commit message',
workflow: {
name: 'build',
id: 'build',
fileName: 'build.yml',
},
...overrides,
};
}
export function getMockWorkflowRunsFragment(
params?:
| undefined
@@ -240,6 +262,7 @@ export function getMockWorkflowRunsFragment(
status: WorkflowRunStatus.Failure,
createdAt: '2022-01-01T00:00:00.000Z',
updatedAt: '2022-01-01T00:00:00.000Z',
finalizedAt: '2022-01-01T00:00:00.000Z',
gitCommitHash: '1234567890',
gitCommitMessage: 'commit message',
workflow: {
@@ -332,6 +355,7 @@ export function getMockWorkflowRunWithJobsFragment(
id: mockProjectId,
__typename: 'App',
name: 'App',
slug: 'app',
ownerAccount: {
id: 'account-id',
name: 'account-name',
@@ -364,6 +388,7 @@ export function getMockWorkflowRunWithBuildJobsFragment(
id: mockProjectId,
__typename: 'App',
name: 'App',
slug: 'app',
ownerAccount: {
id: 'account-id',
name: 'account-name',
@@ -0,0 +1,88 @@
import {
getMockWorkflowRunFragment,
mockCommandContext,
mockProjectId,
mockTestCommand,
} from './utils';
import WorkflowRunCancel from '../../commands/workflow/cancel';
import { WorkflowRunStatus } from '../../graphql/generated';
import { WorkflowRunMutation } from '../../graphql/mutations/WorkflowRunMutation';
import { AppQuery } from '../../graphql/queries/AppQuery';
import { promptAsync } from '../../prompts';
jest.mock('../../graphql/queries/AppQuery');
jest.mock('../../graphql/mutations/WorkflowRunMutation');
jest.mock('../../log');
jest.mock('../../prompts');
describe(WorkflowRunCancel, () => {
afterEach(() => {
jest.clearAllMocks();
});
test('prompts with in-progress and queued runs and cancels the selected ones', async () => {
const ctx = mockCommandContext(WorkflowRunCancel, {
projectId: mockProjectId,
});
const inProgressRun = getMockWorkflowRunFragment(WorkflowRunStatus.InProgress);
const waitingRun = getMockWorkflowRunFragment(WorkflowRunStatus.Waiting);
jest
.mocked(AppQuery.byIdWorkflowRunsFilteredByStatusAsync)
.mockImplementation(async (_graphqlClient, _appId, status) =>
status === WorkflowRunStatus.InProgress ? [inProgressRun] : [waitingRun]
);
jest.mocked(promptAsync).mockResolvedValueOnce({ selectedRuns: [waitingRun.id] });
const cmd = mockTestCommand(WorkflowRunCancel, [], ctx);
await cmd.run();
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
mockProjectId,
WorkflowRunStatus.InProgress,
50
);
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
mockProjectId,
WorkflowRunStatus.Waiting,
50
);
const promptCall = jest.mocked(promptAsync).mock.calls[0][0] as any;
expect(promptCall.choices.map((choice: any) => choice.value)).toEqual([
inProgressRun.id,
waitingRun.id,
]);
expect(WorkflowRunMutation.cancelWorkflowRunAsync).toHaveBeenCalledTimes(1);
expect(WorkflowRunMutation.cancelWorkflowRunAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
{ workflowRunId: waitingRun.id }
);
});
test('throws in non-interactive mode when no run IDs are given', async () => {
const ctx = mockCommandContext(WorkflowRunCancel, {
projectId: mockProjectId,
});
const cmd = mockTestCommand(WorkflowRunCancel, ['--non-interactive'], ctx);
await expect(cmd.run()).rejects.toThrow(
'Must supply workflow run IDs as arguments when in non-interactive mode'
);
expect(promptAsync).not.toHaveBeenCalled();
});
test('cancels the given run IDs without prompting in non-interactive mode', async () => {
const ctx = mockCommandContext(WorkflowRunCancel, {
projectId: mockProjectId,
});
const cmd = mockTestCommand(WorkflowRunCancel, ['--non-interactive', 'run-id-1'], ctx);
await cmd.run();
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).not.toHaveBeenCalled();
expect(promptAsync).not.toHaveBeenCalled();
expect(WorkflowRunMutation.cancelWorkflowRunAsync).toHaveBeenCalledTimes(1);
expect(WorkflowRunMutation.cancelWorkflowRunAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
{ workflowRunId: 'run-id-1' }
);
});
});
@@ -142,6 +142,7 @@ describe(WorkflowRunList, () => {
triggerEventType: WorkflowRunTriggerEventType.Manual,
createdAt: '2022-01-01T00:00:00.000Z',
updatedAt: '2022-01-01T00:00:00.000Z',
finalizedAt: '2022-01-01T00:00:00.000Z',
gitCommitHash: '1234567890',
gitCommitMessage: 'commit message',
errors: [],
@@ -107,11 +107,7 @@ async function resolveEnvVarsAsync({
return { ...serverEnvVars, ...buildProfile.env };
} catch (e) {
Log.error(`Failed to pull env variables for environment ${environment} from EAS servers`);
Log.error(e);
Log.error(
'This can possibly be a bug in EAS/EAS CLI. Report it here: https://github.com/expo/eas-cli/issues'
);
return { ...buildProfile.env };
throw e;
}
}
@@ -0,0 +1,137 @@
import { vol } from 'memfs';
import { collectReferencedWorkflowFilesAsync } from '../referencedFiles';
jest.mock('fs');
const projectDir = '/app';
const workflowsDir = `${projectDir}/.eas/workflows`;
function callerYaml(calls: Record<string, string>): string {
return [
'jobs:',
...Object.entries(calls).flatMap(([jobKey, fileName]) => [
` ${jobKey}:`,
` uses: ./.eas/workflows/${fileName}`,
]),
'',
].join('\n');
}
const calleeYaml = [
'on:',
' workflow_call: {}',
'jobs:',
' job:',
' steps:',
' - run: echo hi',
'',
].join('\n');
async function collectAsync(
rootYamlConfig: string
): Promise<{ fileName: string; yamlConfig: string }[]> {
return await collectReferencedWorkflowFilesAsync({
projectDir,
rootFileName: 'root.yml',
rootYamlConfig,
});
}
describe(collectReferencedWorkflowFilesAsync, () => {
beforeEach(() => {
vol.reset();
});
it('returns no files and reads nothing when the root has no uses jobs', async () => {
vol.fromJSON({ [`${workflowsDir}/unused.yml`]: 'jobs: [not: valid' });
await expect(collectAsync(calleeYaml)).resolves.toEqual([]);
});
it('returns no files when the root is not valid YAML', async () => {
await expect(collectAsync('jobs: [not: valid')).resolves.toEqual([]);
});
it('collects a file called from one job', async () => {
vol.fromJSON({ [`${workflowsDir}/build.yml`]: calleeYaml });
await expect(collectAsync(callerYaml({ ship: 'build.yml' }))).resolves.toEqual([
{ fileName: 'build.yml', yamlConfig: calleeYaml },
]);
});
it('collects nested calls in discovery order', async () => {
const releaseYaml = `on:\n workflow_call: {}\n${callerYaml({ build: 'build.yml' })}`;
vol.fromJSON({
[`${workflowsDir}/release.yml`]: releaseYaml,
[`${workflowsDir}/build.yaml`]: calleeYaml,
[`${workflowsDir}/build.yml`]: calleeYaml,
});
await expect(collectAsync(callerYaml({ release: 'release.yml' }))).resolves.toEqual([
{ fileName: 'release.yml', yamlConfig: releaseYaml },
{ fileName: 'build.yml', yamlConfig: calleeYaml },
]);
});
it('collects each file once and stops at cycles', async () => {
const aYaml = callerYaml({ b: 'b.yml' });
const bYaml = callerYaml({ a: 'a.yml', root: 'root.yml', c: 'c.yml' });
vol.fromJSON({
[`${workflowsDir}/a.yml`]: aYaml,
[`${workflowsDir}/b.yml`]: bYaml,
[`${workflowsDir}/c.yml`]: calleeYaml,
});
await expect(
collectAsync(callerYaml({ first: 'a.yml', second: 'c.yml', third: 'a.yml' }))
).resolves.toEqual([
{ fileName: 'a.yml', yamlConfig: aYaml },
{ fileName: 'c.yml', yamlConfig: calleeYaml },
{ fileName: 'b.yml', yamlConfig: bYaml },
]);
});
it('fails when a called file does not exist', async () => {
await expect(collectAsync(callerYaml({ ship: 'missing.yml' }))).rejects.toThrow(
'Job "ship" in root.yml uses ./.eas/workflows/missing.yml, but /app/.eas/workflows/missing.yml does not exist.'
);
});
it('fails when a nested called file does not exist', async () => {
vol.fromJSON({ [`${workflowsDir}/release.yml`]: callerYaml({ build: 'missing.yml' }) });
await expect(collectAsync(callerYaml({ release: 'release.yml' }))).rejects.toThrow(
'Job "build" in release.yml uses ./.eas/workflows/missing.yml, but /app/.eas/workflows/missing.yml does not exist.'
);
});
it('fails when a called file is not valid YAML', async () => {
vol.fromJSON({ [`${workflowsDir}/build.yml`]: 'jobs: [not: valid' });
await expect(collectAsync(callerYaml({ ship: 'build.yml' }))).rejects.toThrow(
/^Job "ship" in root\.yml uses \.\/\.eas\/workflows\/build\.yml, but \/app\/\.eas\/workflows\/build\.yml is not valid YAML: /
);
});
it('skips uses values the server does not accept', async () => {
const rootYaml = [
'jobs:',
' nested:',
' uses: ./.eas/workflows/nested/build.yml',
' interpolated:',
' uses: ./.eas/workflows/${{ inputs.name }}.yml',
' notString:',
' uses: { path: ./.eas/workflows/build.yml }',
' valid:',
' uses: ./.eas/workflows/build.yml',
'',
].join('\n');
vol.fromJSON({ [`${workflowsDir}/build.yml`]: calleeYaml });
await expect(collectAsync(rootYaml)).resolves.toEqual([
{ fileName: 'build.yml', yamlConfig: calleeYaml },
]);
});
});
@@ -1,8 +1,11 @@
import { getMockWorkflowRunWithJobsFragment } from '../../../__tests__/commands/utils';
import { WorkflowJobStatus } from '../../../graphql/generated';
import {
getMockWorkflowRunFragment,
getMockWorkflowRunWithJobsFragment,
} from '../../../__tests__/commands/utils';
import { WorkflowJobStatus, WorkflowRunStatus } from '../../../graphql/generated';
import { groupLogLinesIntoSteps, parseLogLines } from '../../logs/parseLogs';
import { JobLogs, RawLogLine } from '../../logs/types';
import { formatActiveWorkflowRun, formatFailedWorkflowRun } from '../utils';
import { formatActiveWorkflowRun, formatFailedWorkflowRun, processWorkflowRuns } from '../utils';
function jobWithLogs(
logLines: RawLogLine[],
@@ -135,3 +138,32 @@ describe(formatFailedWorkflowRun, () => {
expect(output).toContain('line7');
});
});
describe(processWorkflowRuns, () => {
test.each([WorkflowRunStatus.Success, WorkflowRunStatus.Failure, WorkflowRunStatus.Canceled])(
'reports finalizedAt as finishedAt for final status %s',
status => {
const [run] = processWorkflowRuns([
getMockWorkflowRunFragment(status, { finalizedAt: '2022-01-01T12:00:00.000Z' }),
]);
expect(run.finishedAt).toBe('2022-01-01T12:00:00.000Z');
}
);
test('reports no finishedAt for a final run predating finalizedAt', () => {
const [run] = processWorkflowRuns([getMockWorkflowRunFragment(WorkflowRunStatus.Success)]);
expect(run.finishedAt).toBeNull();
});
test.each([
WorkflowRunStatus.New,
WorkflowRunStatus.Waiting,
WorkflowRunStatus.InProgress,
WorkflowRunStatus.ActionRequired,
])('reports no finishedAt for non-final status %s even with a lingering finalizedAt', status => {
const [run] = processWorkflowRuns([
getMockWorkflowRunFragment(status, { finalizedAt: '2022-01-01T12:00:00.000Z' }),
]);
expect(run.finishedAt).toBeNull();
});
});
@@ -0,0 +1,83 @@
import fs from 'fs';
import path from 'path';
import { parsedYamlFromWorkflowContents } from './parse';
import { WorkflowReferencedFileInput } from '../../graphql/generated';
const WORKFLOW_CALL_USES_REGEX = /^\.\/\.eas\/workflows\/(?:(?!\$\{\{)[^/\\])+\.ya?ml$/;
type WorkflowCall = { callerFileName: string; callerJobKey: string; uses: string };
export async function collectReferencedWorkflowFilesAsync({
projectDir,
rootFileName,
rootYamlConfig,
}: {
projectDir: string;
rootFileName: string;
rootYamlConfig: string;
}): Promise<WorkflowReferencedFileInput[]> {
let rootYaml: unknown;
try {
rootYaml = parsedYamlFromWorkflowContents({ yamlConfig: rootYamlConfig });
} catch {
return [];
}
const collectedFileNames = new Set<string>([rootFileName]);
const referencedFiles: WorkflowReferencedFileInput[] = [];
const pendingCalls = getWorkflowCalls(rootFileName, rootYaml);
for (const call of pendingCalls) {
const fileName = path.posix.basename(call.uses);
if (collectedFileNames.has(fileName)) {
continue;
}
collectedFileNames.add(fileName);
const { yamlConfig, parsedYaml } = await readReferencedWorkflowFileAsync(
projectDir,
fileName,
call
);
referencedFiles.push({ fileName, yamlConfig });
pendingCalls.push(...getWorkflowCalls(fileName, parsedYaml));
}
return referencedFiles;
}
function getWorkflowCalls(callerFileName: string, parsedYaml: unknown): WorkflowCall[] {
const jobs = (parsedYaml as { jobs?: unknown } | null)?.jobs;
if (!jobs || typeof jobs !== 'object') {
return [];
}
return Object.entries(jobs).flatMap(([callerJobKey, job]) => {
const uses = (job as { uses?: unknown } | null)?.uses;
return typeof uses === 'string' && WORKFLOW_CALL_USES_REGEX.test(uses)
? [{ callerFileName, callerJobKey, uses }]
: [];
});
}
async function readReferencedWorkflowFileAsync(
projectDir: string,
fileName: string,
{ callerFileName, callerJobKey, uses }: WorkflowCall
): Promise<{ yamlConfig: string; parsedYaml: unknown }> {
const filePath = path.join(projectDir, '.eas', 'workflows', fileName);
const describeCall = `Job "${callerJobKey}" in ${callerFileName} uses ${uses}, but ${filePath}`;
let yamlConfig: string;
try {
yamlConfig = await fs.promises.readFile(filePath, 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
throw new Error(`${describeCall} does not exist.`);
}
throw new Error(`${describeCall} could not be read: ${(error as Error).message}`);
}
try {
return { yamlConfig, parsedYaml: parsedYamlFromWorkflowContents({ yamlConfig }) };
} catch (error) {
throw new Error(`${describeCall} is not valid YAML: ${(error as Error).message}`);
}
}
@@ -19,7 +19,7 @@ export type WorkflowRunResult = {
triggerType: WorkflowTriggerType;
trigger: string | null;
startedAt: string;
finishedAt: string;
finishedAt: string | null;
workflowId: string;
workflowName: string | null;
workflowFileName: string;
@@ -3,6 +3,7 @@ import * as fs from 'node:fs';
import { fetchAndProcessLogsFromJobAsync, logSourceForWorkflowJob } from './logs';
import { WorkflowJobResult, WorkflowRunResult, WorkflowTriggerType } from './types';
import { getWorkflowRunUrl } from '../../build/utils/url';
import {
WorkflowJobStatus,
WorkflowRunByIdQuery,
@@ -15,7 +16,7 @@ import { choicesFromJobLogs, stepLogTail } from '../logs/format';
import { JobLogs } from '../logs/types';
import { LogsWatcher } from '../logs/watcher';
import { WorkflowRunQuery } from '../../graphql/queries/WorkflowRunQuery';
import Log from '../../log';
import Log, { link } from '../../log';
import { ora, updateSpinnerText } from '../../ora';
import { Choice } from '../../prompts';
import formatFields from '../../utils/formatFields';
@@ -81,7 +82,7 @@ export function choiceFromWorkflowJob(job: WorkflowJobResult, index: number): Ch
export function processWorkflowRuns(runs: WorkflowRunFragment[]): WorkflowRunResult[] {
return runs.map(run => {
const finishedAt = run.status === WorkflowRunStatus.InProgress ? null : run.updatedAt;
const finishedAt = isWorkflowRunFinal(run.status) ? (run.finalizedAt ?? null) : null;
const { triggerType, trigger } = computeTriggerInfoForWorkflowRun(run);
return {
id: run.id,
@@ -135,6 +136,20 @@ function isJobCompleted(status: WorkflowJobStatus): boolean {
}
}
function isWorkflowRunFinal(status: WorkflowRunStatus): boolean {
switch (status) {
case WorkflowRunStatus.Success:
case WorkflowRunStatus.Failure:
case WorkflowRunStatus.Canceled:
return true;
case WorkflowRunStatus.New:
case WorkflowRunStatus.Waiting:
case WorkflowRunStatus.InProgress:
case WorkflowRunStatus.ActionRequired:
return false;
}
}
type WorkflowRunWithJobs = WorkflowRunByIdWithJobsQuery['workflowRuns']['byId'];
type JobWithLogs = {
@@ -255,13 +270,12 @@ export async function showWorkflowStatusAsync(
): Promise<WorkflowRunByIdQuery['workflowRuns']['byId']> {
Log.log('Waiting for workflow run to complete. You can press Ctrl+C to exit.');
const waitingToStartPrefixText = chalk`{bold.yellow Workflow run is waiting to start:}`;
const spinner = ora({
stream: spinnerUsesStdErr ? process.stderr : process.stdout,
text: '',
}).start();
updateSpinnerText(spinner, {
prefixText: chalk`{bold.yellow Workflow run is waiting to start:}`,
});
updateSpinnerText(spinner, { prefixText: waitingToStartPrefixText });
const watcher = new LogsWatcher(
() => createRealtimeLogsClient(graphqlClient),
@@ -283,7 +297,27 @@ export async function showWorkflowStatusAsync(
switch (workflowRun.status) {
case WorkflowRunStatus.New:
// A queued run returns to NEW once its concurrency group frees up.
updateSpinnerText(spinner, { prefixText: waitingToStartPrefixText });
break;
case WorkflowRunStatus.Waiting: {
const {
blockingWorkflowRun,
workflow: { app },
} = workflowRun;
// The blocking run is in the same app: concurrency groups are app-scoped.
const actionRequiredSuffix =
blockingWorkflowRun?.status === WorkflowRunStatus.ActionRequired
? ` — it needs your action first: ${link(getWorkflowRunUrl(app.ownerAccount.name, app.slug, blockingWorkflowRun.id))}`
: '';
const reason = blockingWorkflowRun
? `Waiting for ${blockingWorkflowRun.name}${actionRequiredSuffix}`
: 'Waiting for its concurrency group';
updateSpinnerText(spinner, {
prefixText: chalk`{bold.yellow Workflow run is queued:} ${reason}`,
});
break;
}
case WorkflowRunStatus.InProgress: {
updateSpinnerText(spinner, {
prefixText: chalk`{bold.green Workflow run is in progress:}`,
@@ -0,0 +1,168 @@
import spawnAsync from '@expo/spawn-async';
import { Config } from '@oclif/core';
import { collectReferencedWorkflowFilesAsync } from '../../../commandUtils/workflow/referencedFiles';
import { fileExistsAsync, maybeReadStdinAsync } from '../../../commandUtils/workflow/utils';
import { WorkflowRevisionMutation } from '../../../graphql/mutations/WorkflowRevisionMutation';
import { WorkflowRunMutation } from '../../../graphql/mutations/WorkflowRunMutation';
import { getOwnerAccountForProjectIdAsync } from '../../../project/projectUtils';
import { uploadAccountScopedFileAsync } from '../../../project/uploadAccountScopedFileAsync';
import { uploadAccountScopedProjectSourceAsync } from '../../../project/uploadAccountScopedProjectSourceAsync';
import { WorkflowFile } from '../../../utils/workflowFile';
import WorkflowRun from '../run';
jest.mock('@expo/spawn-async', () => ({ __esModule: true, default: jest.fn() }));
jest.mock('../../../commandUtils/workflow/referencedFiles');
jest.mock('../../../graphql/mutations/WorkflowRevisionMutation');
jest.mock('../../../graphql/mutations/WorkflowRunMutation');
jest.mock('../../../project/projectUtils');
jest.mock('../../../project/uploadAccountScopedFileAsync');
jest.mock('../../../project/uploadAccountScopedProjectSourceAsync');
jest.mock('../../../utils/workflowFile');
jest.mock('../../../commandUtils/workflow/utils', () => ({
...jest.requireActual('../../../commandUtils/workflow/utils'),
fileExistsAsync: jest.fn(),
maybeReadStdinAsync: jest.fn(),
}));
jest.mock('../../../log', () => ({
__esModule: true,
default: {
log: jest.fn(),
newLine: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
},
link: jest.fn((url: string) => url),
}));
const mockSpawn = jest.mocked(spawnAsync);
const mockCollectReferencedFiles = jest.mocked(collectReferencedWorkflowFilesAsync);
const mockValidate = jest.mocked(WorkflowRevisionMutation.validateWorkflowYamlConfigAsync);
const mockGetOrCreateRevision = jest.mocked(
WorkflowRevisionMutation.getOrCreateWorkflowRevisionFromGitRefAsync
);
const mockCreateFromGitRef = jest.mocked(WorkflowRunMutation.createWorkflowRunFromGitRefAsync);
const mockCreateRun = jest.mocked(WorkflowRunMutation.createWorkflowRunAsync);
const mockGetOwner = jest.mocked(getOwnerAccountForProjectIdAsync);
const mockUploadProject = jest.mocked(uploadAccountScopedProjectSourceAsync);
const mockUploadFile = jest.mocked(uploadAccountScopedFileAsync);
const mockReadWorkflow = jest.mocked(WorkflowFile.readWorkflowFileContentsAsync);
const mockFileExists = jest.mocked(fileExistsAsync);
const mockReadStdin = jest.mocked(maybeReadStdinAsync);
const rootYamlConfig = 'jobs:\n ship:\n uses: ./.eas/workflows/build.yml\n';
const referencedFiles = [{ fileName: 'build.yml', yamlConfig: 'on:\n workflow_call: {}\n' }];
describe('WorkflowRun referenced workflow files', () => {
let mockConfig: Config;
let exitSpy: jest.SpyInstance;
beforeAll(async () => {
mockConfig = new Config({ root: __dirname });
mockConfig.runHook = async () => ({ failures: [], successes: [] });
});
beforeEach(() => {
jest.clearAllMocks();
exitSpy = jest.spyOn(process, 'exit').mockImplementation(((code?: number) => {
throw new Error(`process.exit:${code ?? 0}`);
}) as never);
mockReadStdin.mockResolvedValue(null);
mockGetOwner.mockResolvedValue({ id: 'acct-1', name: 'testuser' } as never);
mockValidate.mockResolvedValue(undefined as never);
mockReadWorkflow.mockResolvedValue({
filePath: '/test/project/.eas/workflows/test.yml',
yamlConfig: rootYamlConfig,
} as never);
mockCollectReferencedFiles.mockResolvedValue(referencedFiles);
mockFileExists.mockResolvedValue(false);
mockUploadProject.mockResolvedValue({ projectArchiveBucketKey: 'archive-key' } as never);
mockUploadFile.mockResolvedValue({ fileBucketKey: 'file-key' } as never);
mockCreateRun.mockResolvedValue({ id: 'run-1' } as never);
mockCreateFromGitRef.mockResolvedValue({ id: 'run-git-1' } as never);
mockSpawn.mockResolvedValue({ output: ['abc123\n'] } as never);
mockGetOrCreateRevision.mockResolvedValue({ id: 'rev-1', yamlConfig: rootYamlConfig } as never);
});
afterEach(() => {
exitSpy.mockRestore();
});
function createCommand(flags: { ref?: string }): WorkflowRun {
const command = new WorkflowRun(['.eas/workflows/test.yml'], mockConfig);
// @ts-expect-error getContextAsync/parse are protected
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
getDynamicPrivateProjectConfigAsync: async () => ({
projectId: 'app-1',
exp: { slug: 'testapp' },
}),
loggedIn: { graphqlClient: {} },
vcsClient: { getRootPathAsync: async () => '/test/project' },
projectDir: '/test/project',
});
// @ts-expect-error parse is protected on the oclif base
jest.spyOn(command, 'parse').mockResolvedValue({
flags: {
json: false,
wait: false,
'non-interactive': true,
input: undefined,
ref: flags.ref,
ssh: false,
'ssh-idle-timeout': undefined,
},
args: { file: '.eas/workflows/test.yml' },
});
return command;
}
it('sends the collected files to validation and to the run creation', async () => {
await expect(createCommand({}).runAsync()).rejects.toThrow('process.exit:0');
expect(mockCollectReferencedFiles).toHaveBeenCalledWith({
projectDir: '/test/project',
rootFileName: 'test.yml',
rootYamlConfig,
});
expect(mockValidate).toHaveBeenCalledWith(
{},
{ appId: 'app-1', yamlConfig: rootYamlConfig, referencedFiles }
);
expect(mockCreateRun).toHaveBeenCalledWith(
{},
expect.objectContaining({
workflowRevisionInput: {
fileName: 'test.yml',
yamlConfig: rootYamlConfig,
referencedFiles,
},
})
);
});
it('fails before validation and upload when the collector throws', async () => {
mockCollectReferencedFiles.mockRejectedValue(new Error('build.yml does not exist.'));
await expect(createCommand({}).runAsync()).rejects.toThrow('build.yml does not exist.');
expect(mockValidate).not.toHaveBeenCalled();
expect(mockUploadProject).not.toHaveBeenCalled();
expect(mockCreateRun).not.toHaveBeenCalled();
});
it('does not collect local files when --ref is set', async () => {
await expect(createCommand({ ref: 'main' }).runAsync()).rejects.toThrow('process.exit:0');
expect(mockCollectReferencedFiles).not.toHaveBeenCalled();
expect(mockValidate).toHaveBeenCalledWith(
{},
{ appId: 'app-1', yamlConfig: rootYamlConfig, referencedFiles: undefined }
);
expect(mockCreateFromGitRef).toHaveBeenCalledWith(
{},
expect.objectContaining({ workflowRevisionId: 'rev-1', gitRef: 'abc123' })
);
expect(mockCreateRun).not.toHaveBeenCalled();
});
});
@@ -9,7 +9,7 @@ import { promptAsync } from '../../prompts';
export default class WorkflowRunCancel extends EasCommand {
static override description =
'cancel one or more workflow runs. If no workflow run IDs are provided, you will be prompted to select IN_PROGRESS runs to cancel';
'cancel one or more workflow runs. If no workflow run IDs are provided, you will be prompted to select in-progress or queued runs to cancel';
static override strict = false;
@@ -22,21 +22,10 @@ export default class WorkflowRunCancel extends EasCommand {
};
async runAsync(): Promise<void> {
const { argv } = await this.parse(WorkflowRunCancel);
let nonInteractive = false;
const workflowRunIds: Set<string> = new Set();
// Custom parsing of argv
const tokens = [...argv] as string[];
while (tokens.length > 0) {
const token = tokens.shift();
if (token === '--non-interactive') {
nonInteractive = true;
continue;
} else if (token) {
workflowRunIds.add(token);
}
}
const { argv, flags } = await this.parse(WorkflowRunCancel);
// oclif removes declared flags such as --non-interactive from argv.
const nonInteractive = flags['non-interactive'];
const workflowRunIds = new Set(argv as string[]);
const {
projectId,
@@ -49,14 +38,13 @@ export default class WorkflowRunCancel extends EasCommand {
if (nonInteractive) {
throw new Error('Must supply workflow run IDs as arguments when in non-interactive mode');
}
// Run the workflow run list query and select runs to cancel
const queryResult = await AppQuery.byIdWorkflowRunsFilteredByStatusAsync(
graphqlClient,
projectId,
WorkflowRunStatus.InProgress,
50
const pickerStatuses = [WorkflowRunStatus.InProgress, WorkflowRunStatus.Waiting];
const runsByStatus = await Promise.all(
pickerStatuses.map(status =>
AppQuery.byIdWorkflowRunsFilteredByStatusAsync(graphqlClient, projectId, status, 50)
)
);
const runs = processWorkflowRuns(queryResult);
const runs = processWorkflowRuns(runsByStatus.flat());
if (runs.length === 0) {
Log.warn('No workflow runs to cancel');
return;
@@ -64,7 +52,7 @@ export default class WorkflowRunCancel extends EasCommand {
const answers = await promptAsync({
type: 'multiselect',
name: 'selectedRuns',
message: 'Select IN_PROGRESS workflow runs to cancel',
message: 'Select workflow runs to cancel',
choices: runs.map(run => choiceFromWorkflowRun(run)),
});
answers.selectedRuns.forEach((id: string) => {
@@ -57,6 +57,7 @@ import {
parseJsonInputs,
parseWorkflowInputsFromYaml,
} from '../../commandUtils/workflow/inputs';
import { collectReferencedWorkflowFilesAsync } from '../../commandUtils/workflow/referencedFiles';
import {
fileExistsAsync,
maybeReadStdinAsync,
@@ -65,6 +66,7 @@ import {
} from '../../commandUtils/workflow/utils';
import {
WorkflowProjectSourceType,
WorkflowReferencedFileInput,
WorkflowRevision,
WorkflowRunStatus,
} from '../../graphql/generated';
@@ -179,6 +181,7 @@ export default class WorkflowRun extends EasCommand {
let workflowRunId: string;
let workflowRevisionId: string | undefined;
let gitRef: string | undefined;
let referencedFiles: WorkflowReferencedFileInput[] | undefined;
if (flags.ref) {
// Run from git ref
@@ -230,6 +233,11 @@ export default class WorkflowRun extends EasCommand {
throw err;
}
referencedFiles = await collectReferencedWorkflowFilesAsync({
projectDir,
rootFileName: path.basename(args.file),
rootYamlConfig: yamlConfig,
});
}
// Validate workflow YAML
@@ -237,6 +245,7 @@ export default class WorkflowRun extends EasCommand {
await WorkflowRevisionMutation.validateWorkflowYamlConfigAsync(graphqlClient, {
appId: projectId,
yamlConfig,
referencedFiles,
});
} catch (error) {
if (error instanceof CombinedError) {
@@ -362,6 +371,7 @@ export default class WorkflowRun extends EasCommand {
workflowRevisionInput: {
fileName: path.basename(args.file),
yamlConfig,
referencedFiles,
},
workflowRunInput: {
inputs,
@@ -84,7 +84,7 @@ export default class WorkflowRunList extends EasCommand {
{ label: 'Workflow', value: run.workflowFileName },
{ label: 'Status', value: run.status },
{ label: 'Started At', value: run.startedAt },
{ label: 'Finished At', value: run.finishedAt },
{ label: 'Finished At', value: run.finishedAt ?? 'null' },
{ label: 'Trigger Type', value: run.triggerType },
{ label: 'Trigger', value: run.trigger ?? 'null' },
{ label: 'Git Commit Message', value: run.gitCommitMessage ?? 'null' },
+317 -9
View File
@@ -199,8 +199,7 @@ export type Account = {
* Whether the account turned on environment variable protection. When protection is on,
* reading, using, creating, changing, or deleting an environment variable whose environments are
* exclusively production needs the PUBLISH_PROTECTED permission, whatever its visibility, in
* every project of the account. Expo can also turn protection on for an account; this field
* does not show that.
* every project of the account.
*/
environmentVariableProtectionEnabled: Scalars['Boolean']['output'];
/** Environment variables for an account */
@@ -1100,6 +1099,7 @@ export type ActivityTimelineProjectActivity = {
export enum ActivityTimelineProjectActivityType {
Build = 'BUILD',
DeviceRunSession = 'DEVICE_RUN_SESSION',
Submission = 'SUBMISSION',
Update = 'UPDATE',
Worker = 'WORKER',
@@ -2487,11 +2487,13 @@ export type AppObserve = {
* @deprecated Use overview.stability instead.
*/
overviewStability: AppObserveOverviewStability;
/** One session's metrics and logs for the timeline (ENG-26160). Unknown session ids yield empty lists. */
/** One session's metrics, logs and spans for the timeline (ENG-26160). Unknown session ids yield empty lists. */
session: AppObserveSession;
/** @deprecated Use metrics.timeSeries instead. */
timeSeries: AppObserveTimeSeries;
totalEventCount: Scalars['Int']['output'];
/** Trace spans: today the HTTP request spans recorded with the SDK's networkTraces option (ENG-28448). */
traces: AppObserveTraces;
/**
* Approximate count of unique users (`eas_client_id`) with at least one
* supported-metric event in `[startTime, endTime)`. Uses a ClickHouse
@@ -3923,6 +3925,8 @@ export type AppObserveSession = {
logs: AppObserveLogConnection;
/** Metric samples in the session. */
metrics: AppObserveMetricConnection;
/** Trace spans in the session. Defaults to oldest first. */
spans: AppObserveSpanConnection;
};
@@ -3943,6 +3947,194 @@ export type AppObserveSession_MetricsArgs = {
orderBy?: InputMaybe<AppObserveMetricsListOrderBy>;
};
export type AppObserveSession_SpansArgs = {
after?: InputMaybe<Scalars['String']['input']>;
filter?: InputMaybe<AppObserveSessionSpanFilter>;
first?: InputMaybe<Scalars['Int']['input']>;
orderBy?: InputMaybe<AppObserveSpanListOrderBy>;
};
/** Filters for the spans of one session. The session already bounds the query. */
export type AppObserveSessionSpanFilter = {
hosts?: InputMaybe<Array<Scalars['String']['input']>>;
httpStatusCodes?: InputMaybe<Array<Scalars['Int']['input']>>;
kinds?: InputMaybe<Array<AppObserveSpanKind>>;
minDurationMs?: InputMaybe<Scalars['Float']['input']>;
names?: InputMaybe<Array<Scalars['String']['input']>>;
rootOnly?: InputMaybe<Scalars['Boolean']['input']>;
statusCodes?: InputMaybe<Array<AppObserveSpanStatusCode>>;
};
export type AppObserveSpan = {
__typename?: 'AppObserveSpan';
appBuildNumber: Scalars['String']['output'];
appEasBuildId?: Maybe<Scalars['String']['output']>;
appIdentifier: Scalars['String']['output'];
appUpdateId?: Maybe<Scalars['String']['output']>;
appVersion: Scalars['String']['output'];
/** Span attributes other than those extracted to dedicated fields. */
attributes: Array<AppObserveEventProperty>;
clientVersion?: Maybe<Scalars['String']['output']>;
countryCode?: Maybe<Scalars['String']['output']>;
deviceLanguageTag?: Maybe<Scalars['String']['output']>;
deviceModel: Scalars['String']['output'];
deviceOs: Scalars['String']['output'];
deviceOsVersion: Scalars['String']['output'];
droppedAttributesCount: Scalars['Int']['output'];
droppedEventsCount: Scalars['Int']['output'];
/** Span links are not stored; this counts the links received. */
droppedLinksCount: Scalars['Int']['output'];
/** Keeps sub-millisecond precision. */
durationMs: Scalars['Float']['output'];
easClientId: Scalars['String']['output'];
endTimestamp: Scalars['DateTime']['output'];
environment?: Maybe<Scalars['String']['output']>;
events: Array<AppObserveSpanEvent>;
expoSdkVersion?: Maybe<Scalars['String']['output']>;
/** Null when the span is not an HTTP request span. */
http?: Maybe<AppObserveSpanHttp>;
id: Scalars['ID']['output'];
ingestedAt?: Maybe<Scalars['DateTime']['output']>;
kind: AppObserveSpanKind;
/** Per the producer's semantic conventions: the HTTP method for request spans. */
name: Scalars['String']['output'];
/** Null for a root span. */
parentSpanId?: Maybe<Scalars['String']['output']>;
reactNativeVersion?: Maybe<Scalars['String']['output']>;
sessionId?: Maybe<Scalars['String']['output']>;
spanId: Scalars['String']['output'];
startTimestamp: Scalars['DateTime']['output'];
statusCode: AppObserveSpanStatusCode;
statusMessage?: Maybe<Scalars['String']['output']>;
traceId: Scalars['String']['output'];
};
export type AppObserveSpanAttributeFilter = {
key: Scalars['String']['input'];
value: Scalars['String']['input'];
};
export type AppObserveSpanConnection = {
__typename?: 'AppObserveSpanConnection';
edges: Array<AppObserveSpanEdge>;
pageInfo: PageInfo;
};
export type AppObserveSpanEdge = {
__typename?: 'AppObserveSpanEdge';
cursor: Scalars['String']['output'];
node: AppObserveSpan;
};
export type AppObserveSpanEvent = {
__typename?: 'AppObserveSpanEvent';
/** The event's attributes as a JSON object. */
attributes: Scalars['JSON']['output'];
name: Scalars['String']['output'];
timestamp: Scalars['DateTime']['output'];
};
/** HTTP request fields of a span, from the OpenTelemetry HTTP client semantic conventions. */
export type AppObserveSpanHttp = {
__typename?: 'AppObserveSpanHttp';
/** Set when the request failed before a response: an NSURLError code on iOS, an exception class on Android. */
errorType?: Maybe<Scalars['String']['output']>;
method?: Maybe<Scalars['String']['output']>;
protocolVersion?: Maybe<Scalars['String']['output']>;
/** On-the-wire bytes including headers. */
requestSize?: Maybe<Scalars['Int']['output']>;
responseSize?: Maybe<Scalars['Int']['output']>;
/** Null when the request got no response. */
statusCode?: Maybe<Scalars['Int']['output']>;
/** Credentials and signing query parameters are redacted at ingestion. */
urlFull?: Maybe<Scalars['String']['output']>;
urlHost?: Maybe<Scalars['String']['output']>;
};
export enum AppObserveSpanKind {
Client = 'CLIENT',
Consumer = 'CONSUMER',
Internal = 'INTERNAL',
Producer = 'PRODUCER',
Server = 'SERVER',
Unspecified = 'UNSPECIFIED'
}
export type AppObserveSpanListFilter = {
appBuildNumber?: InputMaybe<Scalars['String']['input']>;
appEasBuildId?: InputMaybe<Scalars['String']['input']>;
appUpdateId?: InputMaybe<Scalars['String']['input']>;
appVersion?: InputMaybe<Scalars['String']['input']>;
/** At most 5. Matches spans whose attribute has exactly this value. */
attributeFilters?: InputMaybe<Array<AppObserveSpanAttributeFilter>>;
easClientId?: InputMaybe<Scalars['String']['input']>;
endTime: Scalars['DateTime']['input'];
environment?: InputMaybe<Scalars['String']['input']>;
/** HTTP request spans to any of these hosts. */
hosts?: InputMaybe<Array<Scalars['String']['input']>>;
/** HTTP request spans with any of these response status codes. */
httpStatusCodes?: InputMaybe<Array<Scalars['Int']['input']>>;
isEmbeddedUpdate?: InputMaybe<Scalars['Boolean']['input']>;
kinds?: InputMaybe<Array<AppObserveSpanKind>>;
minDurationMs?: InputMaybe<Scalars['Float']['input']>;
/** Span names, such as GET or POST. The cheapest filter: span name is in the table's sort key. */
names?: InputMaybe<Array<Scalars['String']['input']>>;
/** Filter to these platforms. */
platforms?: InputMaybe<Array<AppObservePlatform>>;
/** Only spans with no parent. */
rootOnly?: InputMaybe<Scalars['Boolean']['input']>;
sessionId?: InputMaybe<Scalars['String']['input']>;
startTime: Scalars['DateTime']['input'];
statusCodes?: InputMaybe<Array<AppObserveSpanStatusCode>>;
/** 32-character hex trace id. */
traceId?: InputMaybe<Scalars['String']['input']>;
};
export type AppObserveSpanListOrderBy = {
direction: AppObserveOrderDirection;
field: AppObserveSpanListOrderByField;
};
export enum AppObserveSpanListOrderByField {
StartTimestamp = 'START_TIMESTAMP'
}
export type AppObserveSpanName = {
__typename?: 'AppObserveSpanName';
count: Scalars['Int']['output'];
kind: AppObserveSpanKind;
name: Scalars['String']['output'];
};
export type AppObserveSpanNames = {
__typename?: 'AppObserveSpanNames';
/** True when more names exist than were returned (top-N truncation). */
isTruncated: Scalars['Boolean']['output'];
names: Array<AppObserveSpanName>;
};
export type AppObserveSpanNamesInput = {
appBuildNumber?: InputMaybe<Scalars['String']['input']>;
appEasBuildId?: InputMaybe<Scalars['String']['input']>;
appUpdateId?: InputMaybe<Scalars['String']['input']>;
appVersion?: InputMaybe<Scalars['String']['input']>;
endTime: Scalars['DateTime']['input'];
environment?: InputMaybe<Scalars['String']['input']>;
isEmbeddedUpdate?: InputMaybe<Scalars['Boolean']['input']>;
/** Maximum names to return, 1 to 500. Defaults to 100. */
limit?: InputMaybe<Scalars['Int']['input']>;
/** Filter to these platforms. */
platforms?: InputMaybe<Array<AppObservePlatform>>;
startTime: Scalars['DateTime']['input'];
};
export enum AppObserveSpanStatusCode {
Error = 'ERROR',
Ok = 'OK',
Unset = 'UNSET'
}
/** Stability-tile filters. No release filters: the tiles always span all versions. */
export type AppObserveStabilityInput = {
/** Series bucket size. Defaults to one day. */
@@ -4006,6 +4198,70 @@ export type AppObserveTimeSeriesStatistics = {
p99?: Maybe<Scalars['Float']['output']>;
};
export type AppObserveTrace = {
__typename?: 'AppObserveTrace';
id: Scalars['ID']['output'];
/** True when the trace has more spans than were returned. */
isTruncated: Scalars['Boolean']['output'];
/** Oldest first. The root span, when present, is the one with no parent. */
spans: Array<AppObserveSpan>;
};
/**
* Trace spans (app_traces rows, one per span). Today the SDK records only HTTP request spans; the
* API is generic so other span producers can be added without a schema change.
*/
export type AppObserveTraces = {
__typename?: 'AppObserveTraces';
/** Distinct span names and kinds with counts over the time range, most frequent first. */
names: AppObserveSpanNames;
/** A single span by id. Null when missing or aged out of retention. */
span?: Maybe<AppObserveSpan>;
/** Individual spans. Defaults to newest first. */
spans: AppObserveSpanConnection;
/** All spans of one trace by its 32-character hex trace id. Null when unknown or aged out of retention. */
trace?: Maybe<AppObserveTrace>;
};
/**
* Trace spans (app_traces rows, one per span). Today the SDK records only HTTP request spans; the
* API is generic so other span producers can be added without a schema change.
*/
export type AppObserveTraces_NamesArgs = {
input: AppObserveSpanNamesInput;
};
/**
* Trace spans (app_traces rows, one per span). Today the SDK records only HTTP request spans; the
* API is generic so other span producers can be added without a schema change.
*/
export type AppObserveTraces_SpanArgs = {
id: Scalars['ID']['input'];
};
/**
* Trace spans (app_traces rows, one per span). Today the SDK records only HTTP request spans; the
* API is generic so other span producers can be added without a schema change.
*/
export type AppObserveTraces_SpansArgs = {
after?: InputMaybe<Scalars['String']['input']>;
filter: AppObserveSpanListFilter;
first?: InputMaybe<Scalars['Int']['input']>;
orderBy?: InputMaybe<AppObserveSpanListOrderBy>;
};
/**
* Trace spans (app_traces rows, one per span). Today the SDK records only HTTP request spans; the
* API is generic so other span producers can be added without a schema change.
*/
export type AppObserveTraces_TraceArgs = {
id: Scalars['ID']['input'];
};
export type AppObserveUpdate = {
__typename?: 'AppObserveUpdate';
appUpdateId: Scalars['String']['output'];
@@ -7104,8 +7360,10 @@ export type DeploymentsMutation_DeleteWorkerDeploymentByIdentifierArgs = {
deploymentIdentifier: Scalars['ID']['input'];
};
export type DeviceRunSession = {
export type DeviceRunSession = ActivityTimelineProjectActivity & {
__typename?: 'DeviceRunSession';
activityTimestamp: Scalars['DateTime']['output'];
actor?: Maybe<Actor>;
app: App;
/**
* Direct application archive URL installed and launched for this session. Null when
@@ -13923,6 +14181,13 @@ export type WorkflowJob = {
__typename?: 'WorkflowJob';
allDeviceTestCaseResults: Array<WorkflowDeviceTestCaseResult>;
approvals: Array<WorkflowJobApproval>;
/**
* The workflow run started by a WORKFLOW_CALL job. Null for other job types, while the
* called run is not created yet, and for a failed call that never started a run. A call
* job copied by "re-run from failed jobs" resolves to the called run of the attempt it
* was copied from.
*/
childWorkflowRun?: Maybe<WorkflowRun>;
createdAt: Scalars['DateTime']['output'];
credentialsAppleDeviceRegistrationRequest?: Maybe<AppleDeviceRegistrationRequest>;
/**
@@ -14058,6 +14323,7 @@ export enum WorkflowJobType {
Custom = 'CUSTOM',
Deploy = 'DEPLOY',
Doc = 'DOC',
Email = 'EMAIL',
Fingerprint = 'FINGERPRINT',
GetBuild = 'GET_BUILD',
GithubComment = 'GITHUB_COMMENT',
@@ -14104,6 +14370,11 @@ export type WorkflowQuery_ByIdArgs = {
workflowId: Scalars['ID']['input'];
};
export type WorkflowReferencedFileInput = {
fileName: Scalars['String']['input'];
yamlConfig: Scalars['String']['input'];
};
export type WorkflowRevision = {
__typename?: 'WorkflowRevision';
blobSha: Scalars['String']['output'];
@@ -14123,6 +14394,12 @@ export type WorkflowRevisionEdge = {
export type WorkflowRevisionInput = {
fileName: Scalars['String']['input'];
/**
* Other workflow files of the project that `uses` jobs reference, by bare file name.
* Required when the workflow has `uses` jobs. Every uploaded file is stored as a
* revision and pinned on the run, whether or not a job calls it.
*/
referencedFiles?: InputMaybe<Array<WorkflowReferencedFileInput>>;
yamlConfig: Scalars['String']['input'];
};
@@ -14149,6 +14426,7 @@ export type WorkflowRevisionMutation_GetWorkflowRevisionsFromGitRefArgs = {
export type WorkflowRevisionMutation_ValidateWorkflowYamlConfigArgs = {
appId: Scalars['ID']['input'];
referencedFiles?: InputMaybe<Array<WorkflowReferencedFileInput>>;
yamlConfig: Scalars['String']['input'];
};
@@ -14173,6 +14451,12 @@ export type WorkflowRun = ActivityTimelineProjectActivity & {
activityTimestamp: Scalars['DateTime']['output'];
actor?: Maybe<Actor>;
app: App;
/**
* The run currently holding the concurrency group this run is waiting for.
* Null for runs that are not waiting, and briefly null while the group's
* next run is being promoted.
*/
blockingWorkflowRun?: Maybe<WorkflowRun>;
/**
* Why the server canceled this run. Null for manually canceled runs and for
* runs canceled before the reason was recorded.
@@ -14192,6 +14476,11 @@ export type WorkflowRun = ActivityTimelineProjectActivity & {
jobs: Array<WorkflowJob>;
/** Snapshot of the workflow's name at run creation. */
name: Scalars['String']['output'];
/**
* The WORKFLOW_CALL job of another run that started this run. Null for runs started by
* a trigger, a manual dispatch or a retry. Called runs are omitted from run lists.
*/
parentWorkflowJob?: Maybe<WorkflowJob>;
pullRequestNumber?: Maybe<Scalars['Int']['output']>;
requestedGitRef?: Maybe<Scalars['String']['output']>;
retriedWorkflowRun?: Maybe<WorkflowRun>;
@@ -14207,7 +14496,7 @@ export type WorkflowRun = ActivityTimelineProjectActivity & {
workflowRevision?: Maybe<WorkflowRevision>;
};
export type WorkflowRunCancelReason = WorkflowRunCancelReasonConcurrencyGroup | WorkflowRunCancelReasonWorkflowCall;
export type WorkflowRunCancelReason = WorkflowRunCancelReasonConcurrencyGroup | WorkflowRunCancelReasonConcurrencyGroupQueueFull | WorkflowRunCancelReasonConcurrencyGroupReplaced | WorkflowRunCancelReasonWorkflowCall;
export type WorkflowRunCancelReasonConcurrencyGroup = {
__typename?: 'WorkflowRunCancelReasonConcurrencyGroup';
@@ -14218,6 +14507,24 @@ export type WorkflowRunCancelReasonConcurrencyGroup = {
cancelingWorkflowRun?: Maybe<WorkflowRun>;
};
export type WorkflowRunCancelReasonConcurrencyGroupQueueFull = {
__typename?: 'WorkflowRunCancelReasonConcurrencyGroupQueueFull';
/**
* The waiting-run capacity the concurrency group's queue was at when this
* run arrived.
*/
maximumWaitingRuns: Scalars['Int']['output'];
};
export type WorkflowRunCancelReasonConcurrencyGroupReplaced = {
__typename?: 'WorkflowRunCancelReasonConcurrencyGroupReplaced';
/**
* The newer run that took this run's place in the concurrency group's waiting
* slot. Null if that run no longer exists.
*/
replacingWorkflowRun?: Maybe<WorkflowRun>;
};
export type WorkflowRunCancelReasonWorkflowCall = {
__typename?: 'WorkflowRunCancelReasonWorkflowCall';
/**
@@ -15624,6 +15931,7 @@ export type GetOrCreateWorkflowRevisionFromGitRefMutation = { __typename?: 'Root
export type ValidateWorkflowYamlConfigMutationVariables = Exact<{
appId: Scalars['ID']['input'];
yamlConfig: Scalars['String']['input'];
referencedFiles?: InputMaybe<Array<WorkflowReferencedFileInput> | WorkflowReferencedFileInput>;
}>;
@@ -15752,7 +16060,7 @@ export type AppByIdWorkflowRunsFilteredByStatusQueryVariables = Exact<{
}>;
export type AppByIdWorkflowRunsFilteredByStatusQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, runs: { __typename?: 'AppWorkflowRunsConnection', edges: Array<{ __typename?: 'AppWorkflowRunEdge', node: { __typename?: 'WorkflowRun', id: string, status: WorkflowRunStatus, gitCommitMessage?: string | null, gitCommitHash?: string | null, requestedGitRef?: string | null, triggeringLabelName?: string | null, triggerEventType: WorkflowRunTriggerEventType, triggeringSchedule?: string | null, createdAt: any, updatedAt: any, actor?:
export type AppByIdWorkflowRunsFilteredByStatusQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, runs: { __typename?: 'AppWorkflowRunsConnection', edges: Array<{ __typename?: 'AppWorkflowRunEdge', node: { __typename?: 'WorkflowRun', id: string, status: WorkflowRunStatus, gitCommitMessage?: string | null, gitCommitHash?: string | null, requestedGitRef?: string | null, triggeringLabelName?: string | null, triggerEventType: WorkflowRunTriggerEventType, triggeringSchedule?: string | null, createdAt: any, updatedAt: any, finalizedAt?: any | null, actor?:
| { __typename: 'PartnerActor', id: string }
| { __typename: 'Robot', firstName?: string | null, id: string }
| { __typename: 'SSOUser', username: string, id: string }
@@ -16598,7 +16906,7 @@ export type WorkflowRunByIdWithJobsQueryVariables = Exact<{
}>;
export type WorkflowRunByIdWithJobsQuery = { __typename?: 'RootQuery', workflowRuns: { __typename?: 'WorkflowRunQuery', byId: { __typename?: 'WorkflowRun', id: string, status: WorkflowRunStatus, gitCommitMessage?: string | null, gitCommitHash?: string | null, requestedGitRef?: string | null, triggeringLabelName?: string | null, triggerEventType: WorkflowRunTriggerEventType, triggeringSchedule?: string | null, createdAt: any, updatedAt: any, workflow: { __typename?: 'Workflow', id: string, name?: string | null, fileName: string, app: { __typename?: 'App', id: string, name: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } }, jobs: Array<{ __typename?: 'WorkflowJob', id: string, key: string, name: string, status: WorkflowJobStatus, type: WorkflowJobType, outputs: any, createdAt: any, updatedAt: any, workflowRun: { __typename?: 'WorkflowRun', id: string }, turtleJobRun?: { __typename?: 'JobRun', id: string, logFileUrls: Array<string>, artifacts: Array<{ __typename?: 'WorkflowArtifact', id: string, name: string, contentType?: string | null, fileSizeBytes?: number | null, filename: string, downloadUrl?: string | null }>, errors: Array<{ __typename?: 'JobRunError', errorCode: string, message: string }> } | null, turtleBuild?: { __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, logFiles: Array<string>, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, appIdentifier?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, gitCommitHash?: string | null, gitCommitMessage?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt: any, updatedAt: any, message?: string | null, completedAt?: any | null, expirationDate?: any | null, isForIosSimulator: boolean, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null, applicationArchiveUrl?: string | null, buildArtifactsUrl?: string | null } | null, fingerprint?: { __typename?: 'Fingerprint', id: string, hash: string } | null, initiatingActor?:
export type WorkflowRunByIdWithJobsQuery = { __typename?: 'RootQuery', workflowRuns: { __typename?: 'WorkflowRunQuery', byId: { __typename?: 'WorkflowRun', id: string, status: WorkflowRunStatus, gitCommitMessage?: string | null, gitCommitHash?: string | null, requestedGitRef?: string | null, triggeringLabelName?: string | null, triggerEventType: WorkflowRunTriggerEventType, triggeringSchedule?: string | null, createdAt: any, updatedAt: any, finalizedAt?: any | null, blockingWorkflowRun?: { __typename?: 'WorkflowRun', id: string, name: string, status: WorkflowRunStatus } | null, workflow: { __typename?: 'Workflow', id: string, name?: string | null, fileName: string, app: { __typename?: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } }, jobs: Array<{ __typename?: 'WorkflowJob', id: string, key: string, name: string, status: WorkflowJobStatus, type: WorkflowJobType, outputs: any, createdAt: any, updatedAt: any, workflowRun: { __typename?: 'WorkflowRun', id: string }, turtleJobRun?: { __typename?: 'JobRun', id: string, logFileUrls: Array<string>, artifacts: Array<{ __typename?: 'WorkflowArtifact', id: string, name: string, contentType?: string | null, fileSizeBytes?: number | null, filename: string, downloadUrl?: string | null }>, errors: Array<{ __typename?: 'JobRunError', errorCode: string, message: string }> } | null, turtleBuild?: { __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, logFiles: Array<string>, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, appIdentifier?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, gitCommitHash?: string | null, gitCommitMessage?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt: any, updatedAt: any, message?: string | null, completedAt?: any | null, expirationDate?: any | null, isForIosSimulator: boolean, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null, applicationArchiveUrl?: string | null, buildArtifactsUrl?: string | null } | null, fingerprint?: { __typename?: 'Fingerprint', id: string, hash: string } | null, initiatingActor?:
| { __typename: 'PartnerActor', id: string, displayName: string }
| { __typename: 'Robot', id: string, displayName: string }
| { __typename: 'SSOUser', id: string, displayName: string }
@@ -16618,7 +16926,7 @@ export type WorkflowRunsForAppIdFileNameAndStatusQueryVariables = Exact<{
}>;
export type WorkflowRunsForAppIdFileNameAndStatusQuery = { __typename?: 'RootQuery', workflows: { __typename?: 'WorkflowQuery', byAppIdAndFileName: { __typename?: 'Workflow', id: string, runs: { __typename?: 'WorkflowRunsConnection', edges: Array<{ __typename?: 'WorkflowRunEdge', node: { __typename?: 'WorkflowRun', id: string, status: WorkflowRunStatus, gitCommitMessage?: string | null, gitCommitHash?: string | null, requestedGitRef?: string | null, triggeringLabelName?: string | null, triggerEventType: WorkflowRunTriggerEventType, triggeringSchedule?: string | null, createdAt: any, updatedAt: any, actor?:
export type WorkflowRunsForAppIdFileNameAndStatusQuery = { __typename?: 'RootQuery', workflows: { __typename?: 'WorkflowQuery', byAppIdAndFileName: { __typename?: 'Workflow', id: string, runs: { __typename?: 'WorkflowRunsConnection', edges: Array<{ __typename?: 'WorkflowRunEdge', node: { __typename?: 'WorkflowRun', id: string, status: WorkflowRunStatus, gitCommitMessage?: string | null, gitCommitHash?: string | null, requestedGitRef?: string | null, triggeringLabelName?: string | null, triggerEventType: WorkflowRunTriggerEventType, triggeringSchedule?: string | null, createdAt: any, updatedAt: any, finalizedAt?: any | null, actor?:
| { __typename: 'PartnerActor', id: string }
| { __typename: 'Robot', firstName?: string | null, id: string }
| { __typename: 'SSOUser', username: string, id: string }
@@ -16810,7 +17118,7 @@ export type WorkflowJobFragment = { __typename?: 'WorkflowJob', id: string, key:
| { __typename: 'User', id: string, displayName: string }
| null, app: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, updateChannel?: { __typename?: 'UpdateChannel', id: string, name: string } | null, runtime?: { __typename?: 'Runtime', id: string, version: string } | null, metrics?: { __typename?: 'BuildMetrics', buildWaitTime?: number | null, buildQueueTime?: number | null, buildDuration?: number | null } | null } | null, errors: Array<{ __typename?: 'WorkflowJobError', title: string, message: string }> };
export type WorkflowRunFragment = { __typename?: 'WorkflowRun', id: string, status: WorkflowRunStatus, gitCommitMessage?: string | null, gitCommitHash?: string | null, requestedGitRef?: string | null, triggeringLabelName?: string | null, triggerEventType: WorkflowRunTriggerEventType, triggeringSchedule?: string | null, createdAt: any, updatedAt: any, actor?:
export type WorkflowRunFragment = { __typename?: 'WorkflowRun', id: string, status: WorkflowRunStatus, gitCommitMessage?: string | null, gitCommitHash?: string | null, requestedGitRef?: string | null, triggeringLabelName?: string | null, triggerEventType: WorkflowRunTriggerEventType, triggeringSchedule?: string | null, createdAt: any, updatedAt: any, finalizedAt?: any | null, actor?:
| { __typename: 'PartnerActor', id: string }
| { __typename: 'Robot', firstName?: string | null, id: string }
| { __typename: 'SSOUser', username: string, id: string }
@@ -8,6 +8,7 @@ import {
GetOrCreateWorkflowRevisionFromGitRefMutationVariables,
ValidateWorkflowYamlConfigMutation,
ValidateWorkflowYamlConfigMutationVariables,
WorkflowReferencedFileInput,
WorkflowRevision,
} from '../generated';
@@ -72,24 +73,35 @@ export namespace WorkflowRevisionMutation {
{
appId,
yamlConfig,
referencedFiles,
}: {
appId: string;
yamlConfig: string;
referencedFiles?: WorkflowReferencedFileInput[];
}
): Promise<void> {
await withErrorHandlingAsync(
graphqlClient
.mutation<ValidateWorkflowYamlConfigMutation, ValidateWorkflowYamlConfigMutationVariables>(
gql`
mutation ValidateWorkflowYamlConfig($appId: ID!, $yamlConfig: String!) {
mutation ValidateWorkflowYamlConfig(
$appId: ID!
$yamlConfig: String!
$referencedFiles: [WorkflowReferencedFileInput!]
) {
workflowRevision {
validateWorkflowYamlConfig(appId: $appId, yamlConfig: $yamlConfig)
validateWorkflowYamlConfig(
appId: $appId
yamlConfig: $yamlConfig
referencedFiles: $referencedFiles
)
}
}
`,
{
appId,
yamlConfig,
referencedFiles,
}
)
.toPromise()
@@ -118,11 +118,17 @@ export const WorkflowRunQuery = {
workflowRuns {
byId(workflowRunId: $workflowRunId) {
id
blockingWorkflowRun {
id
name
status
}
workflow {
id
app {
id
name
slug
ownerAccount {
id
name
@@ -22,6 +22,7 @@ export const WorkflowRunFragmentNode = gql`
triggeringSchedule
createdAt
updatedAt
finalizedAt
errors {
title
message
@@ -466,6 +466,7 @@ function colorWorkflowStatus(status: string): string {
case WorkflowRunStatus.ActionRequired:
return chalk.yellow('action required');
case WorkflowRunStatus.New:
case WorkflowRunStatus.Waiting:
case WorkflowRunStatus.InProgress:
return chalk.blue(status.toLowerCase().replace(/_/g, ' '));
default:
+4
View File
@@ -1,5 +1,9 @@
# @expo/eas-json
## 27.1.0
No changes in this release.
## 27.0.0
No changes in this release.
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-json",
"version": "27.0.0",
"version": "27.1.0",
"description": "A library for interacting with eas.json",
"homepage": "https://github.com/expo/eas-cli",
"bugs": "https://github.com/expo/eas-cli/issues",
@@ -32,7 +32,7 @@
},
"dependencies": {
"@babel/code-frame": "7.23.5",
"@expo/eas-build-job": "27.0.0",
"@expo/eas-build-job": "27.1.0",
"chalk": "4.1.2",
"env-string": "1.0.1",
"fs-extra": "11.2.0",
+4
View File
@@ -1,5 +1,9 @@
# eas-cli-local-build-plugin
## 27.1.0
No changes in this release.
## 27.0.0
No changes in this release.
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli-local-build-plugin",
"version": "27.0.0",
"version": "27.1.0",
"description": "Tool for running EAS compatible builds on a local machine.",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
@@ -26,12 +26,12 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/build-tools": "27.0.0",
"@expo/build-tools": "27.1.0",
"@expo/bunyan": "^4.0.1",
"@expo/eas-build-job": "27.0.0",
"@expo/logger": "27.0.0",
"@expo/eas-build-job": "27.1.0",
"@expo/logger": "27.1.0",
"@expo/spawn-async": "^1.7.2",
"@expo/turtle-spawn": "27.0.0",
"@expo/turtle-spawn": "27.1.0",
"chalk": "4.1.2",
"env-paths": "^2.2.1",
"fs-extra": "^11.2.0",
+4
View File
@@ -1,5 +1,9 @@
# @expo/logger
## 27.1.0
No changes in this release.
## 27.0.0
No changes in this release.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/logger",
"version": "27.0.0",
"version": "27.1.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
+4
View File
@@ -1,5 +1,9 @@
# @expo/steps
## 27.1.0
No changes in this release.
## 27.0.0
No changes in this release.
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/steps",
"version": "27.0.0",
"version": "27.1.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -27,8 +27,8 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/eas-build-job": "27.0.0",
"@expo/logger": "27.0.0",
"@expo/eas-build-job": "27.1.0",
"@expo/logger": "27.1.0",
"@expo/spawn-async": "^1.7.2",
"arg": "^5.0.2",
"fast-glob": "^3.3.2",
+4
View File
@@ -1,5 +1,9 @@
# @expo/template-file
## 27.1.0
No changes in this release.
## 27.0.0
No changes in this release.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/template-file",
"version": "27.0.0",
"version": "27.1.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
+4
View File
@@ -1,5 +1,9 @@
# @expo/turtle-spawn
## 27.1.0
No changes in this release.
## 27.0.0
No changes in this release.
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/turtle-spawn",
"version": "27.0.0",
"version": "27.1.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -25,7 +25,7 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/logger": "27.0.0",
"@expo/logger": "27.1.0",
"@expo/spawn-async": "^1.7.2"
},
"devDependencies": {
+6
View File
@@ -1,5 +1,11 @@
# @expo/worker
## 27.1.0
### Patch Changes
- Put the `NPM_TOKEN` registry credential in the builder's npm user config on EAS Build (`~/.npmrc`, or the file `NPM_CONFIG_USERCONFIG` names), where pnpm 10.34.2+ and 11.5.3+ still expand `${NPM_TOKEN}` (they ignore it in a project-level `.npmrc`). Explain what to change when the project's pnpm version needs a newer Node.js than the build runs, including when eas.json sets `node` or `pnpm`. ([#4729](https://github.com/expo/eas-cli/pull/4729) by [@hSATAC](https://github.com/hSATAC))
## 27.0.0
No changes in this release.
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/worker",
"version": "27.0.0",
"version": "27.1.0",
"private": true,
"scripts": {
"start": "yarn watch",
@@ -19,17 +19,17 @@
},
"dependencies": {
"@babel/core": "7.24.7",
"@expo/build-tools": "27.0.0",
"@expo/build-tools": "27.1.0",
"@expo/config-plugins": "55.0.7",
"@expo/config-types": "55.0.5",
"@expo/eas-build-job": "27.0.0",
"@expo/logger": "27.0.0",
"@expo/eas-build-job": "27.1.0",
"@expo/logger": "27.1.0",
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "27.0.0",
"@expo/template-file": "27.0.0",
"@expo/turtle-spawn": "27.0.0",
"@expo/steps": "27.1.0",
"@expo/template-file": "27.1.0",
"@expo/turtle-spawn": "27.1.0",
"@hapi/boom": "10.0.1",
"check-disk-space": "3.4.0",
"fast-glob": "3.3.2",
@@ -359,6 +359,56 @@ describe('prepareRuntimeEnvironment', () => {
);
});
});
describe('installPnpm', () => {
const pnpmCtx = { ...ctx, metadata: { requiredPackageManager: 'pnpm' } };
function mockPnpmReinstallFailure(stderr: string): void {
let hasProbedPreinstalledPnpm = false;
jest.mocked(spawn).mockImplementation((cmd, args) => {
if (cmd === 'pnpm' && args[0] === '--version') {
if (!hasProbedPreinstalledPnpm) {
hasProbedPreinstalledPnpm = true;
return Promise.resolve(spawnResult);
}
return Promise.reject(
Object.assign(new Error('pnpm --version exited with non-zero code: 1'), {
stdout: '',
stderr,
})
);
}
return Promise.resolve(spawnResult);
});
}
it('throws the pnpm Node.js version error instead of the generic install error when the reinstalled pnpm cannot run on the build Node.js', async () => {
mockPnpmReinstallFailure(
[
'warn: This version of pnpm requires at least Node.js v22.13',
'warn: The current version of Node.js is v20.18.0',
'Error [ERR_UNKNOWN_BUILTIN_MODULE]: No such built-in module: node:sqlite',
].join('\n')
);
await expect(
prepareRuntimeEnvironment(pnpmCtx, { pnpm: '11.27.0' }, false)
).rejects.toMatchObject({
errorCode: 'EAS_BUILD_PNPM_UNSUPPORTED_NODE_VERSION_ERROR',
message: expect.stringContaining(
'requires Node.js 22.13.0 or newer, but runs Node.js 20.18.0'
),
});
});
it('reports a pnpm install failure without the Node.js banner as a generic install error', async () => {
mockPnpmReinstallFailure('pnpm: command not found');
await expect(
prepareRuntimeEnvironment(pnpmCtx, { pnpm: '11.27.0' }, false)
).rejects.toMatchObject({ errorCode: 'EAS_BUILD_SYSTEM_DEPS_INSTALL_ERROR' });
});
});
});
function mockProcessPlatform(platform: NodeJS.Platform): void {
+7 -2
View File
@@ -1,4 +1,8 @@
import { BuildContext, RuntimeSettings } from '@expo/build-tools';
import {
BuildContext,
RuntimeSettings,
detectPnpmUnsupportedNodeVersionError,
} from '@expo/build-tools';
import { Job, errors } from '@expo/eas-build-job';
import templateFile from '@expo/template-file';
import spawn from '@expo/turtle-spawn';
@@ -276,7 +280,8 @@ async function installPnpm({
} catch (err: any) {
ctx.logger.error({ err }, `Failed to install pnpm ${versionToInstall}\n`);
if (ctx.metadata?.requiredPackageManager === 'pnpm') {
throw new SystemDepsInstallError('pnpm');
const output = [err.stdout, err.stderr].filter(Boolean).join('\n');
throw detectPnpmUnsupportedNodeVersionError(output) ?? new SystemDepsInstallError('pnpm');
} else {
ctx.markBuildPhaseHasWarnings();
ctx.logger.warn(
+1
View File
@@ -85,6 +85,7 @@ async function mainAsync(): Promise<void> {
runWithOutput('yarn', ['install', '--mode=update-lockfile']);
runWithOutput('yarn', ['workspace', 'eas-cli', 'run', 'version']);
await updateRootChangelogAsync(section);
runWithOutput('yarn', ['exec', 'oxfmt', 'CHANGELOG.md']);
const tag = `v${version}`;
if (!isPublishingRun()) {
Generated
+35 -35
View File
@@ -2515,24 +2515,24 @@ __metadata:
languageName: node
linkType: hard
"@expo/build-tools@npm:27.0.0, @expo/build-tools@workspace:packages/build-tools":
"@expo/build-tools@npm:27.1.0, @expo/build-tools@workspace:packages/build-tools":
version: 0.0.0-use.local
resolution: "@expo/build-tools@workspace:packages/build-tools"
dependencies:
"@expo/config": "npm:55.0.10"
"@expo/config-plugins": "npm:55.0.7"
"@expo/downloader": "npm:27.0.0"
"@expo/eas-build-job": "npm:27.0.0"
"@expo/downloader": "npm:27.1.0"
"@expo/eas-build-job": "npm:27.1.0"
"@expo/env": "npm:^0.4.0"
"@expo/logger": "npm:27.0.0"
"@expo/logger": "npm:27.1.0"
"@expo/package-manager": "npm:1.9.10"
"@expo/plist": "npm:^0.3.5"
"@expo/repack-app": "npm:~0.9.0"
"@expo/results": "npm:^1.0.0"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:27.0.0"
"@expo/template-file": "npm:27.0.0"
"@expo/turtle-spawn": "npm:27.0.0"
"@expo/steps": "npm:27.1.0"
"@expo/template-file": "npm:27.1.0"
"@expo/turtle-spawn": "npm:27.1.0"
"@expo/xcpretty": "npm:^4.3.1"
"@ngrok/ngrok": "npm:1.7.0"
"@sentry/node": "npm:7.77.0"
@@ -2768,7 +2768,7 @@ __metadata:
languageName: node
linkType: hard
"@expo/downloader@npm:27.0.0, @expo/downloader@workspace:packages/downloader":
"@expo/downloader@npm:27.1.0, @expo/downloader@workspace:packages/downloader":
version: 0.0.0-use.local
resolution: "@expo/downloader@workspace:packages/downloader"
dependencies:
@@ -2784,13 +2784,13 @@ __metadata:
languageName: unknown
linkType: soft
"@expo/eas-build-job@npm:27.0.0, @expo/eas-build-job@workspace:packages/eas-build-job":
"@expo/eas-build-job@npm:27.1.0, @expo/eas-build-job@workspace:packages/eas-build-job":
version: 0.0.0-use.local
resolution: "@expo/eas-build-job@workspace:packages/eas-build-job"
dependencies:
"@expo/logger": "npm:27.0.0"
"@expo/logger": "npm:27.1.0"
"@expo/results": "npm:1.0.0"
"@expo/turtle-spawn": "npm:27.0.0"
"@expo/turtle-spawn": "npm:27.1.0"
"@types/jest": "npm:^29.5.12"
"@types/node": "npm:22.20.5"
jest: "npm:^29.7.0"
@@ -2803,12 +2803,12 @@ __metadata:
languageName: unknown
linkType: soft
"@expo/eas-json@npm:27.0.0, @expo/eas-json@workspace:packages/eas-json":
"@expo/eas-json@npm:27.1.0, @expo/eas-json@workspace:packages/eas-json":
version: 0.0.0-use.local
resolution: "@expo/eas-json@workspace:packages/eas-json"
dependencies:
"@babel/code-frame": "npm:7.23.5"
"@expo/eas-build-job": "npm:27.0.0"
"@expo/eas-build-job": "npm:27.1.0"
"@tsconfig/node22": "npm:22.0.6"
"@types/babel__code-frame": "npm:7.0.3"
"@types/fs-extra": "npm:11.0.4"
@@ -2931,7 +2931,7 @@ __metadata:
languageName: node
linkType: hard
"@expo/logger@npm:27.0.0, @expo/logger@workspace:packages/logger":
"@expo/logger@npm:27.1.0, @expo/logger@workspace:packages/logger":
version: 0.0.0-use.local
resolution: "@expo/logger@workspace:packages/logger"
dependencies:
@@ -3131,12 +3131,12 @@ __metadata:
languageName: node
linkType: hard
"@expo/steps@npm:27.0.0, @expo/steps@workspace:packages/steps":
"@expo/steps@npm:27.1.0, @expo/steps@workspace:packages/steps":
version: 0.0.0-use.local
resolution: "@expo/steps@workspace:packages/steps"
dependencies:
"@expo/eas-build-job": "npm:27.0.0"
"@expo/logger": "npm:27.0.0"
"@expo/eas-build-job": "npm:27.1.0"
"@expo/logger": "npm:27.1.0"
"@expo/spawn-async": "npm:^1.7.2"
"@jest/globals": "npm:^29.7.0"
"@types/jest": "npm:^29.5.12"
@@ -3162,7 +3162,7 @@ __metadata:
languageName: unknown
linkType: soft
"@expo/template-file@npm:27.0.0, @expo/template-file@workspace:packages/template-file":
"@expo/template-file@npm:27.1.0, @expo/template-file@workspace:packages/template-file":
version: 0.0.0-use.local
resolution: "@expo/template-file@workspace:packages/template-file"
dependencies:
@@ -3185,11 +3185,11 @@ __metadata:
languageName: node
linkType: hard
"@expo/turtle-spawn@npm:27.0.0, @expo/turtle-spawn@workspace:packages/turtle-spawn":
"@expo/turtle-spawn@npm:27.1.0, @expo/turtle-spawn@workspace:packages/turtle-spawn":
version: 0.0.0-use.local
resolution: "@expo/turtle-spawn@workspace:packages/turtle-spawn"
dependencies:
"@expo/logger": "npm:27.0.0"
"@expo/logger": "npm:27.1.0"
"@expo/spawn-async": "npm:^1.7.2"
"@types/jest": "npm:^29.5.12"
"@types/node": "npm:22.20.5"
@@ -3205,17 +3205,17 @@ __metadata:
resolution: "@expo/worker@workspace:packages/worker"
dependencies:
"@babel/core": "npm:7.24.7"
"@expo/build-tools": "npm:27.0.0"
"@expo/build-tools": "npm:27.1.0"
"@expo/config-plugins": "npm:55.0.7"
"@expo/config-types": "npm:55.0.5"
"@expo/eas-build-job": "npm:27.0.0"
"@expo/logger": "npm:27.0.0"
"@expo/eas-build-job": "npm:27.1.0"
"@expo/logger": "npm:27.1.0"
"@expo/results": "npm:1.0.0"
"@expo/rudder-sdk-node": "npm:1.1.1"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:27.0.0"
"@expo/template-file": "npm:27.0.0"
"@expo/turtle-spawn": "npm:27.0.0"
"@expo/steps": "npm:27.1.0"
"@expo/template-file": "npm:27.1.0"
"@expo/turtle-spawn": "npm:27.1.0"
"@hapi/boom": "npm:10.0.1"
"@types/babel__core": "npm:7.20.5"
"@types/fs-extra": "npm:11.0.4"
@@ -10099,7 +10099,7 @@ __metadata:
resolution: "create-eas-build-function@workspace:packages/create-eas-build-function"
dependencies:
"@expo/package-manager": "npm:1.9.10"
"@expo/steps": "npm:27.0.0"
"@expo/steps": "npm:27.1.0"
"@jest/globals": "npm:^29.7.0"
"@types/jest": "npm:^29.5.12"
"@types/node": "npm:22.20.5"
@@ -10683,12 +10683,12 @@ __metadata:
version: 0.0.0-use.local
resolution: "eas-cli-local-build-plugin@workspace:packages/local-build-plugin"
dependencies:
"@expo/build-tools": "npm:27.0.0"
"@expo/build-tools": "npm:27.1.0"
"@expo/bunyan": "npm:^4.0.1"
"@expo/eas-build-job": "npm:27.0.0"
"@expo/logger": "npm:27.0.0"
"@expo/eas-build-job": "npm:27.1.0"
"@expo/logger": "npm:27.1.0"
"@expo/spawn-async": "npm:^1.7.2"
"@expo/turtle-spawn": "npm:27.0.0"
"@expo/turtle-spawn": "npm:27.1.0"
"@types/fs-extra": "npm:^11.0.4"
"@types/hapi__joi": "npm:^17.1.14"
"@types/lodash": "npm:^4.17.4"
@@ -10742,12 +10742,12 @@ __metadata:
"@expo/code-signing-certificates": "npm:0.0.5"
"@expo/config": "npm:55.0.10"
"@expo/config-plugins": "npm:55.0.7"
"@expo/eas-build-job": "npm:27.0.0"
"@expo/eas-json": "npm:27.0.0"
"@expo/eas-build-job": "npm:27.1.0"
"@expo/eas-json": "npm:27.1.0"
"@expo/env": "npm:^1.0.0"
"@expo/experimentation": "npm:0.0.5"
"@expo/json-file": "npm:8.3.3"
"@expo/logger": "npm:27.0.0"
"@expo/logger": "npm:27.1.0"
"@expo/multipart-body-parser": "npm:2.0.0"
"@expo/osascript": "npm:2.1.4"
"@expo/package-manager": "npm:1.9.10"
@@ -10759,7 +10759,7 @@ __metadata:
"@expo/results": "npm:1.0.0"
"@expo/rudder-sdk-node": "npm:1.1.1"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:27.0.0"
"@expo/steps": "npm:27.1.0"
"@expo/timeago.js": "npm:1.0.0"
"@graphql-codegen/cli": "npm:6.3.1"
"@graphql-codegen/introspection": "npm:5.0.2"