mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9ebc41413 | ||
|
|
9313ef11ca | ||
|
|
fd80532a7e | ||
|
|
f20ccebcad | ||
|
|
e8602f7062 | ||
|
|
d14c0b196b | ||
|
|
c24b9a037a | ||
|
|
c3c3f087ac | ||
|
|
ac761edb5c | ||
|
|
78c30acbc9 | ||
|
|
a716925ed7 | ||
|
|
e579441d5b | ||
|
|
4f5831305b | ||
|
|
98db228cc8 | ||
|
|
5c762d300e | ||
|
|
2a6bee7f5a | ||
|
|
4d365e0355 | ||
|
|
48aa2ccde2 | ||
|
|
bdc04e3e6b | ||
|
|
935491f967 | ||
|
|
031198d1aa | ||
|
|
ac60d159f2 | ||
|
|
fc09fe4a34 | ||
|
|
a62289b988 | ||
|
|
78c0584331 | ||
|
|
c4a7b7c9f1 | ||
|
|
f448bc75b5 | ||
|
|
0dda7ba880 | ||
|
|
9679af9670 | ||
|
|
3e0e823e56 | ||
|
|
fbb8af6fca |
No files matched your search
@@ -2,7 +2,7 @@ commentConfiguration:
|
||||
preamble: 'Subscribed to pull request'
|
||||
epilogue: 'Generated by [CodeMention](https://github.com/expo/expo/actions/workflows/codemention.yaml)'
|
||||
rules:
|
||||
- patterns: ['**/*']
|
||||
- patterns: ['packages/eas-cli/**']
|
||||
mentions: ['douglowder']
|
||||
- patterns: ['packages/eas-cli/src/update/**']
|
||||
mentions: ['wschurman', 'quinlanj']
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
name: License
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 1 1-12/3 *' # Run every three months
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- .github/workflows/license.yml
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
update:
|
||||
runs-on: ubuntu-latest
|
||||
name: Update BSL change date
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
token: ${{ secrets.EXPO_BOT_PAT }}
|
||||
- name: Check last commit date
|
||||
run: |
|
||||
# The date in the license
|
||||
change_date=$(sed --quiet --regexp-extended 's/^Change Date:\s+([0-9]{4}-[0-9]{2}-[0-9]{2})/\1/p' < LICENSE-BUSL)
|
||||
[ -z "$change_date" ] && exit 1
|
||||
|
||||
new_change_date=$(date --date "+3 years" --iso-8601)
|
||||
|
||||
if [ "$(date --date "$new_change_date" +%s)" -gt "$(date --date "$change_date" +%s)" ]; then
|
||||
sed --in-place "s/$change_date/$new_change_date/" LICENSE-BUSL
|
||||
git config user.name 'Expo Bot'
|
||||
git config user.email [email protected]
|
||||
git add LICENSE-BUSL
|
||||
git commit -m "[license] Update BSL Change Date to $new_change_date"
|
||||
if [ "$(git branch --show-current)" = main ]; then
|
||||
git push
|
||||
fi
|
||||
fi
|
||||
@@ -6,6 +6,38 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Remove temporary working directories after `eas build:inspect` copies inspect output, avoiding leftover disk usage after failed builds. ([#3981](https://github.com/expo/eas-cli/pull/3981) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli] Bump `tmp` to `0.2.7` to resolve [Dependabot alert 423](https://github.com/expo/eas-cli/security/dependabot/423). ([#3965](https://github.com/expo/eas-cli/pull/3965) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [worker] Bump `ws` to patched releases to resolve [Dependabot alert 462](https://github.com/expo/eas-cli/security/dependabot/462). ([#3963](https://github.com/expo/eas-cli/pull/3963) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- Bump `lodash` and `lodash-es` to `4.18.1` across workspaces and upgrade GraphQL Code Generator to remove the remaining vulnerable transitive copy for Dependabot alert 354. ([#4009](https://github.com/expo/eas-cli/pull/4009) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [eas-cli] Bump `sigstore` to `4.1.1` to resolve [Dependabot alert 475](https://github.com/expo/eas-cli/security/dependabot/475). ([#3961](https://github.com/expo/eas-cli/pull/3961) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] Bump `axios` to patched releases to resolve [Dependabot alert 439](https://github.com/expo/eas-cli/security/dependabot/439). ([#3964](https://github.com/expo/eas-cli/pull/3964) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] Bump `handlebars` to `4.7.9` to resolve [Dependabot alert 334](https://github.com/expo/eas-cli/security/dependabot/334). ([#3958](https://github.com/expo/eas-cli/pull/3958) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
## [21.0.1](https://github.com/expo/eas-cli/releases/tag/v21.0.1) - 2026-07-15
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `eas project:delete` command to delete a project — the current directory's project by default, or any project passed as an argument (`@account/slug` or ID). Confirmation requires the project's full name, typed at the interactive prompt or passed via `--dangerously-confirm-deletion` (required in non-interactive mode). Deletion requires sudo mode: the command prompts for your password (and OTP) to upgrade the session when the server requires it. Deletion was previously only possible from the website. ([#4012](https://github.com/expo/eas-cli/pull/4012) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [eas-cli] Add `eas browse` to open the project page on expo.dev in a web browser, with an optional page argument (e.g. `eas browse build`, `eas browse hosting`) for project subpages and a `--no-browser` flag to print the URL. `eas open` is now an alias of `eas browse`. ([#4003](https://github.com/expo/eas-cli/pull/4003) by [@jonsamp](https://github.com/jonsamp))
|
||||
- [eas-cli] Add `--dev-domain` flag to `eas deploy` to choose the project's preview URL (`<name>.expo.app`) without prompting, allowing first-time deploys with `--non-interactive`. ([#4004](https://github.com/expo/eas-cli/pull/4004) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [eas-cli] Add `-p` as an alias for `--platform` in `eas simulator:start`. ([#3902](https://github.com/expo/eas-cli/pull/3902) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli][build-tools][local-build-plugin][worker] Bump `tar` to `7.5.19`. ([#3971](https://github.com/expo/eas-cli/pull/3971) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
## [21.0.0](https://github.com/expo/eas-cli/releases/tag/v21.0.0) - 2026-07-13
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
- [eas-cli] Remove `eas onboarding` command (aliases `init:onboarding`, `project:onboarding`). ([#3993](https://github.com/expo/eas-cli/pull/3993) by [@byronkarlen](https://github.com/byronkarlen))
|
||||
|
||||
### 🎉 New features
|
||||
@@ -28,6 +60,7 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Upgrade Expo plist dependencies to use `@xmldom/xmldom` `0.8.13` without a root resolution, addressing Dependabot alert 372. ([#4010](https://github.com/expo/eas-cli/pull/4010) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [eas-cli] add agent detection to analytics events. ([#3983](https://github.com/expo/eas-cli/pull/3983) by [@davidmokos](https://github.com/davidmokos))
|
||||
- Bump `glob` 10.x to a patched version in the root lockfile. ([#3979](https://github.com/expo/eas-cli/pull/3979) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [build-tools] Bump `jws` to a patched version in the root lockfile. ([#3978](https://github.com/expo/eas-cli/pull/3978) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
@@ -44,6 +77,7 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Fix Free plan users being warned they had reached their build limit while still well below it. Overage warnings are now shown only for paid plans with an actual billable overage cost. ([#3882](https://github.com/expo/eas-cli/pull/3882) by [@sarahlane8](https://github.com/sarahlane8))
|
||||
- [eas-cli] Count only genuine per-worker build overages in the pre-build usage warning, so the reported number of builds beyond included credits is accurate. ([#3932](https://github.com/expo/eas-cli/pull/3932) by [@sarahlane8](https://github.com/sarahlane8))
|
||||
- [eas-cli] Select correct tvOS build target for non-interactive Apple builds when EXPO_TV env variable is set. ([#3907](https://github.com/expo/eas-cli/pull/3907) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that you d
|
||||
to access the functionality of and directly benefit from the
|
||||
functionality of the Licensed Work.
|
||||
|
||||
Change Date: 2028-04-01
|
||||
Change Date: 2029-07-15
|
||||
|
||||
Change License: MIT
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "21.0.0",
|
||||
"version": "21.0.2",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "21.0.0",
|
||||
"version": "21.0.2",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -39,18 +39,17 @@
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "21.0.0",
|
||||
"@expo/eas-build-job": "21.0.0",
|
||||
"@expo/eas-build-job": "21.0.2",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "21.0.0",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
"@expo/plist": "^0.2.0",
|
||||
"@expo/plist": "^0.3.5",
|
||||
"@expo/results": "^1.0.0",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "21.0.0",
|
||||
"@expo/template-file": "21.0.0",
|
||||
"@expo/steps": "21.0.2",
|
||||
"@expo/template-file": "21.0.2",
|
||||
"@expo/turtle-spawn": "21.0.0",
|
||||
"@expo/xcpretty": "^4.3.1",
|
||||
"@google-cloud/storage": "^7.11.2",
|
||||
"@ngrok/ngrok": "1.7.0",
|
||||
"@sentry/node": "7.77.0",
|
||||
"@urql/core": "^6.0.1",
|
||||
@@ -61,7 +60,7 @@
|
||||
"gql.tada": "^1.8.13",
|
||||
"joi": "^17.13.1",
|
||||
"jose": "5.10.0",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash": "^4.18.1",
|
||||
"node-fetch": "^2.7.0",
|
||||
"node-forge": "^1.3.1",
|
||||
"node-stream-zip": "1.15.0",
|
||||
@@ -72,7 +71,7 @@
|
||||
"resolve-from": "^5.0.0",
|
||||
"retry": "^0.13.1",
|
||||
"semver": "^7.6.2",
|
||||
"tar": "7.5.7",
|
||||
"tar": "7.5.19",
|
||||
"uuid": "9.0.1",
|
||||
"yaml": "^2.8.1",
|
||||
"zod": "^4.3.5"
|
||||
|
||||
@@ -59,8 +59,15 @@ public struct FirstFrameWallClock: Codable, Sendable {
|
||||
public let iso8601: String
|
||||
}
|
||||
|
||||
public enum RecordingFinalizationStage: Sendable {
|
||||
case captureStopped
|
||||
case videoSaved
|
||||
}
|
||||
|
||||
public struct RecordingManifest: Codable, Sendable {
|
||||
public let firstFrameWallClock: FirstFrameWallClock
|
||||
public let width: Int
|
||||
public let height: Int
|
||||
public let hlsVersion: Int?
|
||||
public let hlsTargetDurationSeconds: Int?
|
||||
public let hlsMediaSequence: Int?
|
||||
|
||||
+5
-1
@@ -100,7 +100,9 @@ final class RecordingOutputWriter: NSObject, AVAssetWriterDelegate {
|
||||
@discardableResult
|
||||
func writeManifest(
|
||||
configuration: SimulatorRecordingConfiguration,
|
||||
firstFrameWallClock: Date
|
||||
firstFrameWallClock: Date,
|
||||
width: Int,
|
||||
height: Int
|
||||
) throws -> RecordingManifest {
|
||||
try stateQueue.sync {
|
||||
if let firstError {
|
||||
@@ -121,6 +123,8 @@ final class RecordingOutputWriter: NSObject, AVAssetWriterDelegate {
|
||||
unixMs: unixMs(firstFrameWallClock),
|
||||
iso8601: iso8601(firstFrameWallClock)
|
||||
),
|
||||
width: width,
|
||||
height: height,
|
||||
hlsVersion: segmented ? 7 : nil,
|
||||
hlsTargetDurationSeconds: targetDuration,
|
||||
hlsMediaSequence: segmented ? 0 : nil,
|
||||
|
||||
+13
-1
@@ -7,6 +7,7 @@ import UniformTypeIdentifiers
|
||||
public final class SimulatorRecorder {
|
||||
public var onSegment: ((SegmentOutput) -> Void)?
|
||||
public var onSimulatorStopped: ((String) -> Void)?
|
||||
public var onFinalizationStage: ((RecordingFinalizationStage) -> Void)?
|
||||
|
||||
private static let callbackStalenessTimeout: TimeInterval = 5
|
||||
private static let firstFrameRewireInterval: TimeInterval = 1
|
||||
@@ -26,6 +27,8 @@ public final class SimulatorRecorder {
|
||||
private var monotonicClock = MonotonicClock()
|
||||
private var firstAcceptedCaptureTime: CMTime?
|
||||
private var firstAcceptedWallClock: Date?
|
||||
private var recordingWidth: Int?
|
||||
private var recordingHeight: Int?
|
||||
private var lastPTS: CMTime?
|
||||
private var lastSeed: UInt32?
|
||||
private var lastFrameCallbackElapsed: TimeInterval?
|
||||
@@ -113,6 +116,7 @@ public final class SimulatorRecorder {
|
||||
displaySource?.stop()
|
||||
displaySource = nil
|
||||
}
|
||||
onFinalizationStage?(.captureStopped)
|
||||
|
||||
return try writerQueue.sync {
|
||||
if let firstError {
|
||||
@@ -139,12 +143,18 @@ public final class SimulatorRecorder {
|
||||
if let error = outputWriter.error {
|
||||
throw error
|
||||
}
|
||||
onFinalizationStage?(.videoSaved)
|
||||
guard let firstAcceptedWallClock else {
|
||||
throw RecorderError.make(25, "Missing first frame wall-clock timestamp")
|
||||
}
|
||||
guard let recordingWidth, let recordingHeight else {
|
||||
throw RecorderError.make(27, "Missing recording dimensions")
|
||||
}
|
||||
return try outputWriter.writeManifest(
|
||||
configuration: configuration,
|
||||
firstFrameWallClock: firstAcceptedWallClock
|
||||
firstFrameWallClock: firstAcceptedWallClock,
|
||||
width: recordingWidth,
|
||||
height: recordingHeight
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -590,6 +600,8 @@ public final class SimulatorRecorder {
|
||||
self.writer = writer
|
||||
self.input = input
|
||||
self.adaptor = adaptor
|
||||
recordingWidth = width
|
||||
recordingHeight = height
|
||||
}
|
||||
|
||||
private func assetWriterSettings(width: Int, height: Int) -> [String: Any] {
|
||||
|
||||
@@ -10,15 +10,15 @@ struct CLIOptions {
|
||||
var codec: RecorderCodec = .h264
|
||||
}
|
||||
|
||||
private var stopRequested: CInt = 0
|
||||
private var requestedStopSignal: CInt = 0
|
||||
private var simulatorStopped: CInt = 0
|
||||
|
||||
private func handleStopSignal(_: CInt) {
|
||||
stopRequested = 1
|
||||
private func handleStopSignal(_ signal: CInt) {
|
||||
requestedStopSignal = signal
|
||||
}
|
||||
|
||||
private func isStopRequested() -> Bool {
|
||||
stopRequested != 0 || simulatorStopped != 0
|
||||
requestedStopSignal != 0 || simulatorStopped != 0
|
||||
}
|
||||
|
||||
func usage() -> String {
|
||||
@@ -112,6 +112,14 @@ do {
|
||||
simulatorStopped = 1
|
||||
fputs("[record-sim] simulator stopped state=\(state); finalizing recording\n", stderr)
|
||||
}
|
||||
recorder.onFinalizationStage = { stage in
|
||||
switch stage {
|
||||
case .captureStopped:
|
||||
fputs("[record-sim] screen capture stopped; saving final video\n", stderr)
|
||||
case .videoSaved:
|
||||
fputs("[record-sim] final video saved; writing recording details\n", stderr)
|
||||
}
|
||||
}
|
||||
recorder.onSegment = { segment in
|
||||
switch segment.kind {
|
||||
case .initialization:
|
||||
@@ -130,6 +138,14 @@ do {
|
||||
Thread.sleep(forTimeInterval: 0.1)
|
||||
}
|
||||
|
||||
if requestedStopSignal != 0 {
|
||||
let signalName = switch requestedStopSignal {
|
||||
case SIGINT: "SIGINT"
|
||||
case SIGTERM: "SIGTERM"
|
||||
default: "signal \(requestedStopSignal)"
|
||||
}
|
||||
fputs("[record-sim] received \(signalName); finishing recording\n", stderr)
|
||||
}
|
||||
let manifest = try recorder.stop()
|
||||
if let recording = manifest.recording {
|
||||
fputs("[record-sim] done recording=\(recording)\n", stderr)
|
||||
|
||||
@@ -19,12 +19,10 @@ jest.mock('fs-extra');
|
||||
jest.mock('../datadog', () => ({
|
||||
Datadog: {
|
||||
distribution: jest.fn(),
|
||||
log: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const datadogDistributionMock = jest.mocked(Datadog.distribution);
|
||||
const datadogLogMock = jest.mocked(Datadog.log);
|
||||
|
||||
describe('BuildContext', () => {
|
||||
beforeEach(() => {
|
||||
@@ -171,45 +169,6 @@ describe('BuildContext', () => {
|
||||
expect(ctx.env.EXISTING_ENV).toBe('new-value');
|
||||
expect(ctx.env.REMOVED_ENV).toBeUndefined();
|
||||
expect(ctx.env.NEW_ENV).toBe('new-env-value');
|
||||
const logMessage = datadogLogMock.mock.calls[0][0];
|
||||
expect(logMessage).toContain('BuildContext.updateEnv merge order produced different env');
|
||||
expect(logMessage).toContain('different_env_names=');
|
||||
expect(logMessage).toContain('EXISTING_ENV');
|
||||
expect(logMessage).toContain('REMOVED_ENV');
|
||||
expect(JSON.stringify(datadogLogMock.mock.calls)).not.toContain('old-value');
|
||||
expect(JSON.stringify(datadogLogMock.mock.calls)).not.toContain('new-value');
|
||||
expect(JSON.stringify(datadogLogMock.mock.calls)).not.toContain('new-env-value');
|
||||
});
|
||||
|
||||
it('logs when updateEnv merge order produces the same env without values', async () => {
|
||||
await vol.promises.mkdir('/workingdir/eas-environment-secrets/', { recursive: true });
|
||||
|
||||
const ctx = new BuildContext(
|
||||
{
|
||||
triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION,
|
||||
secrets: {},
|
||||
} as Job,
|
||||
{
|
||||
env: {
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
EXISTING_ENV: 'same-value',
|
||||
},
|
||||
workingdir: '/workingdir',
|
||||
logger: createMockLogger(),
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
uploadArtifact: jest.fn(),
|
||||
}
|
||||
);
|
||||
|
||||
ctx.updateEnv({
|
||||
EXISTING_ENV: 'same-value',
|
||||
UNSET_ENV: undefined,
|
||||
});
|
||||
|
||||
expect(datadogLogMock).toHaveBeenCalledWith(
|
||||
'BuildContext.updateEnv merge order produced same env'
|
||||
);
|
||||
expect(JSON.stringify(datadogLogMock.mock.calls)).not.toContain('same-value');
|
||||
});
|
||||
|
||||
it('emits build phase duration metrics for successful build phases', async () => {
|
||||
|
||||
@@ -111,6 +111,37 @@ describe(runGenericJobAsync, () => {
|
||||
expect(rejected).toBe(true);
|
||||
});
|
||||
|
||||
it('passes the job hooks into the steps parser', async () => {
|
||||
// @expo/steps is fully mocked here — assert the plumbing only (real
|
||||
// insertion behavior is covered by the parser's own tests).
|
||||
const mockWorkflow = { executeAsync: jest.fn().mockResolvedValue(undefined) };
|
||||
(StepsConfigParser as unknown as jest.Mock).mockImplementation(() => ({
|
||||
parseAsync: jest.fn().mockResolvedValue(mockWorkflow),
|
||||
}));
|
||||
|
||||
mockCtx.job.hooks = { before_install_node_modules: [{ run: 'echo hi' }] };
|
||||
await runGenericJobAsync(mockCtx);
|
||||
|
||||
expect(StepsConfigParser).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ hooks: mockCtx.job.hooks })
|
||||
);
|
||||
});
|
||||
|
||||
it('passes hooks: undefined into the steps parser for a hook-less job (dormancy)', async () => {
|
||||
const mockWorkflow = { executeAsync: jest.fn().mockResolvedValue(undefined) };
|
||||
(StepsConfigParser as unknown as jest.Mock).mockImplementation(() => ({
|
||||
parseAsync: jest.fn().mockResolvedValue(mockWorkflow),
|
||||
}));
|
||||
|
||||
await runGenericJobAsync(mockCtx);
|
||||
|
||||
expect(StepsConfigParser).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ hooks: undefined })
|
||||
);
|
||||
});
|
||||
|
||||
it('throws before workflow execution when expoApiV2BaseUrl is missing', async () => {
|
||||
mockCtx.expoApiV2BaseUrl = undefined;
|
||||
|
||||
|
||||
@@ -10,7 +10,10 @@ import os from 'os';
|
||||
import path from 'path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { createTestIosJob } from './job';
|
||||
import { createMockLogger } from './logger';
|
||||
import { BuildContext } from '../../context';
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
|
||||
export class MockContextProvider implements ExternalBuildContextProvider {
|
||||
private _env: BuildStepEnv = {};
|
||||
@@ -72,6 +75,19 @@ export function createStepContextMock({
|
||||
});
|
||||
}
|
||||
|
||||
export function createCustomBuildContextMock(): CustomBuildContext {
|
||||
const ctx = new BuildContext(createTestIosJob({}), {
|
||||
env: {
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
},
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: createMockLogger(),
|
||||
uploadArtifact: jest.fn(),
|
||||
workingdir: '',
|
||||
});
|
||||
return new CustomBuildContext(ctx);
|
||||
}
|
||||
|
||||
export function createGlobalContextMock({
|
||||
logger,
|
||||
skipCleanup,
|
||||
|
||||
@@ -63,6 +63,7 @@ export async function runCustomBuildAsync(ctx: BuildContext<BuildJob>): Promise<
|
||||
externalFunctions: easFunctions,
|
||||
externalFunctionGroups: easFunctionGroups,
|
||||
steps: ctx.job.steps,
|
||||
hooks: ctx.job.hooks,
|
||||
})
|
||||
: new BuildConfigParser(globalContext, {
|
||||
externalFunctions: easFunctions,
|
||||
|
||||
@@ -72,41 +72,6 @@ export interface BuildContextOptions {
|
||||
|
||||
export class SkipNativeBuildError extends Error {}
|
||||
|
||||
function logEnvComparison({
|
||||
oldMergeOrderEnv,
|
||||
newMergeOrderEnv,
|
||||
}: {
|
||||
oldMergeOrderEnv: Env;
|
||||
newMergeOrderEnv: Env;
|
||||
}): void {
|
||||
const envNames = new Set([...Object.keys(oldMergeOrderEnv), ...Object.keys(newMergeOrderEnv)]);
|
||||
const sameEnvNames: string[] = [];
|
||||
const differentEnvNames: string[] = [];
|
||||
|
||||
for (const key of envNames) {
|
||||
const hasOldValue = key in oldMergeOrderEnv;
|
||||
const hasNewValue = key in newMergeOrderEnv;
|
||||
if (hasOldValue === hasNewValue && oldMergeOrderEnv[key] === newMergeOrderEnv[key]) {
|
||||
sameEnvNames.push(key);
|
||||
} else {
|
||||
differentEnvNames.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
if (differentEnvNames.length === 0) {
|
||||
Datadog.log('BuildContext.updateEnv merge order produced same env');
|
||||
return;
|
||||
}
|
||||
|
||||
Datadog.log(
|
||||
`BuildContext.updateEnv merge order produced different env: compared_env_count=${
|
||||
envNames.size
|
||||
} different_env_count=${differentEnvNames.length} different_env_names=${differentEnvNames.join(
|
||||
','
|
||||
)} same_env_count=${sameEnvNames.length} same_env_names=${sameEnvNames.join(',')}`
|
||||
);
|
||||
}
|
||||
|
||||
export class BuildContext<TJob extends Job = Job> {
|
||||
public readonly workingdir: string;
|
||||
public logger: bunyan;
|
||||
@@ -283,23 +248,11 @@ export class BuildContext<TJob extends Job = Job> {
|
||||
);
|
||||
}
|
||||
|
||||
const oldMergeOrderEnv: Env = {
|
||||
...env,
|
||||
...this._env,
|
||||
__EAS_BUILD_ENVS_DIR: this.buildEnvsDirectory,
|
||||
};
|
||||
const newMergeOrderEnv: Env = {
|
||||
this._env = {
|
||||
...this._env,
|
||||
...env,
|
||||
__EAS_BUILD_ENVS_DIR: this.buildEnvsDirectory,
|
||||
};
|
||||
|
||||
logEnvComparison({
|
||||
oldMergeOrderEnv,
|
||||
newMergeOrderEnv,
|
||||
});
|
||||
|
||||
this._env = newMergeOrderEnv;
|
||||
this._env.PATH = this._env.PATH
|
||||
? [this.buildExecutablesDirectory, this._env.PATH].join(':')
|
||||
: this.buildExecutablesDirectory;
|
||||
|
||||
@@ -9,12 +9,18 @@ import {
|
||||
StaticJobInterpolationContext,
|
||||
} from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { BuildRuntimePlatform, ExternalBuildContextProvider, StepMetric } from '@expo/steps';
|
||||
import {
|
||||
BuildRuntimePlatform,
|
||||
ExternalBuildContextProvider,
|
||||
StepMetric,
|
||||
WorkflowHookMetric,
|
||||
} from '@expo/steps';
|
||||
import { Client } from '@urql/core';
|
||||
import assert from 'assert';
|
||||
import path from 'path';
|
||||
|
||||
import { ArtifactToUpload, BuildContext } from './context';
|
||||
import { reportWorkflowHookMetricToDatadog } from './utils/hookMetrics';
|
||||
import { uploadStepMetricsToWwwAsync } from './utils/stepMetrics';
|
||||
|
||||
const platformToBuildRuntimePlatform: Record<Platform, BuildRuntimePlatform> = {
|
||||
@@ -165,6 +171,10 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
|
||||
this.pendingMetricUploads.push(p);
|
||||
}
|
||||
|
||||
public reportWorkflowHookMetric(metric: WorkflowHookMetric): void {
|
||||
reportWorkflowHookMetricToDatadog(metric);
|
||||
}
|
||||
|
||||
public async drainPendingMetricUploads(): Promise<void> {
|
||||
await Promise.allSettled(this.pendingMetricUploads);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Readable, Writable, pipeline } from 'stream';
|
||||
import { promisify } from 'util';
|
||||
import zlib from 'zlib';
|
||||
|
||||
import GCS from './client';
|
||||
import { GCS } from './client';
|
||||
|
||||
type PromiseResolveFn = (value?: void | PromiseLike<void> | undefined) => void;
|
||||
|
||||
@@ -41,7 +41,7 @@ class GCSLoggerStream extends Writable {
|
||||
}
|
||||
|
||||
private findNormalizedHeader(name: string): string | null {
|
||||
if (!this.uploadMethod || 'client' in this.uploadMethod) {
|
||||
if (!this.uploadMethod) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -165,25 +165,10 @@ class GCSLoggerStream extends Writable {
|
||||
);
|
||||
};
|
||||
|
||||
if ('signedUrl' in this.uploadMethod) {
|
||||
return await GCS.uploadWithSignedUrl({
|
||||
signedUrl: this.uploadMethod.signedUrl,
|
||||
srcGeneratorAsync,
|
||||
});
|
||||
}
|
||||
|
||||
const { Location } = await this.uploadMethod.client.uploadFile({
|
||||
key: this.uploadMethod.key,
|
||||
src: await srcGeneratorAsync(),
|
||||
streamOptions: {
|
||||
metadata: {
|
||||
contentType: 'text/plain;charset=utf-8',
|
||||
...(this.compress !== null ? { contentEncoding: this.compress } : {}),
|
||||
customTime: this.uploadMethod.customTime,
|
||||
},
|
||||
},
|
||||
return await GCS.uploadWithSignedUrl({
|
||||
signedUrl: this.uploadMethod.signedUrl,
|
||||
srcGeneratorAsync,
|
||||
});
|
||||
return Location;
|
||||
}
|
||||
|
||||
private async createCompressedStream(src: Readable): Promise<Readable> {
|
||||
@@ -223,9 +208,9 @@ namespace GCSLoggerStream {
|
||||
compress?: CompressionMethod;
|
||||
}
|
||||
|
||||
export type UploadMethod =
|
||||
| { client: GCS; key: string; customTime: Date | null }
|
||||
| { signedUrl: GCS.SignedUrl };
|
||||
export type UploadMethod = {
|
||||
signedUrl: GCS.SignedUrl;
|
||||
};
|
||||
|
||||
export interface Config {
|
||||
logger: bunyan;
|
||||
|
||||
+34
-92
@@ -1,10 +1,8 @@
|
||||
import { randomBytes } from 'crypto';
|
||||
import fs from 'fs-extra';
|
||||
import fetch, { RequestInit, Response } from 'node-fetch';
|
||||
import path from 'path';
|
||||
import { Readable } from 'stream';
|
||||
|
||||
import GCS from '../client';
|
||||
import { GCS } from '../client';
|
||||
|
||||
jest.mock('node-fetch');
|
||||
|
||||
@@ -29,18 +27,15 @@ class DNSError extends ErrorWithCode {
|
||||
|
||||
const TEST_BUCKET = 'turtle-v2-test';
|
||||
|
||||
let googleApplicationCredentials: string | undefined;
|
||||
beforeAll(() => {
|
||||
googleApplicationCredentials = process.env.GOOGLE_APPLICATION_CREDENTIALS;
|
||||
process.env.GOOGLE_APPLICATION_CREDENTIALS = path.join(__dirname, 'mock-credentials.json');
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env.GOOGLE_APPLICATION_CREDENTIALS = googleApplicationCredentials;
|
||||
});
|
||||
function createSignedUrl(key: string, contentType: string): GCS.SignedUrl {
|
||||
return {
|
||||
url: `https://storage.googleapis.com/${TEST_BUCKET}/${key}?signature=test`,
|
||||
headers: { 'content-type': contentType },
|
||||
};
|
||||
}
|
||||
|
||||
describe('GCS client', () => {
|
||||
describe('uploadWithPresignedURL function', () => {
|
||||
describe('uploadWithSignedUrl', () => {
|
||||
it('should throw an error if upload fails', async () => {
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
const res = {
|
||||
@@ -48,22 +43,16 @@ describe('GCS client', () => {
|
||||
status: 500,
|
||||
} as Response;
|
||||
|
||||
const localImagePath = path.join(__dirname, 'cat.jpg');
|
||||
const key = 'cat.jpg';
|
||||
|
||||
const gcs = new GCS(TEST_BUCKET);
|
||||
const signedUrl = await gcs.createSignedUploadUrl({
|
||||
key,
|
||||
expirationTime: 30000,
|
||||
contentType: 'image/jpeg',
|
||||
});
|
||||
const signedUrl = createSignedUrl(key, 'image/jpeg');
|
||||
|
||||
fetchMock.mockImplementation(async () => res);
|
||||
await expect(
|
||||
GCS.uploadWithSignedUrl({
|
||||
signedUrl,
|
||||
srcGeneratorAsync: async () => fs.createReadStream(localImagePath),
|
||||
retryIntervalMs: 500,
|
||||
srcGeneratorAsync: async () => Readable.from('image'),
|
||||
retryIntervalMs: 0,
|
||||
})
|
||||
).rejects.toThrow();
|
||||
});
|
||||
@@ -75,21 +64,15 @@ describe('GCS client', () => {
|
||||
status: 200,
|
||||
} as Response;
|
||||
|
||||
const localImagePath = path.join(__dirname, 'cat.jpg');
|
||||
const key = 'cat.jpg';
|
||||
|
||||
const gcs = new GCS(TEST_BUCKET);
|
||||
const signedUrl = await gcs.createSignedUploadUrl({
|
||||
key,
|
||||
expirationTime: 30000,
|
||||
contentType: 'image/jpeg',
|
||||
});
|
||||
const signedUrl = createSignedUrl(key, 'image/jpeg');
|
||||
|
||||
fetchMock.mockImplementation(async () => res);
|
||||
const result = await GCS.uploadWithSignedUrl({
|
||||
signedUrl,
|
||||
srcGeneratorAsync: async () => fs.createReadStream(localImagePath),
|
||||
retryIntervalMs: 500,
|
||||
srcGeneratorAsync: async () => Readable.from('image'),
|
||||
retryIntervalMs: 0,
|
||||
});
|
||||
expect(result).toEqual('https://storage.googleapis.com/turtle-v2-test/cat.jpg');
|
||||
});
|
||||
@@ -101,15 +84,9 @@ describe('GCS client', () => {
|
||||
status: 200,
|
||||
} as Response;
|
||||
|
||||
const localImagePath = path.join(__dirname, 'cat.jpg');
|
||||
const key = 'cat.jpg';
|
||||
|
||||
const gcs = new GCS(TEST_BUCKET);
|
||||
const signedUrl = await gcs.createSignedUploadUrl({
|
||||
key,
|
||||
expirationTime: 30000,
|
||||
contentType: 'image/jpeg',
|
||||
});
|
||||
const signedUrl = createSignedUrl(key, 'image/jpeg');
|
||||
|
||||
fetchMock
|
||||
.mockImplementationOnce(async () => {
|
||||
@@ -121,8 +98,8 @@ describe('GCS client', () => {
|
||||
.mockImplementation(async () => res);
|
||||
const result = await GCS.uploadWithSignedUrl({
|
||||
signedUrl,
|
||||
srcGeneratorAsync: async () => fs.createReadStream(localImagePath),
|
||||
retryIntervalMs: 500,
|
||||
srcGeneratorAsync: async () => Readable.from('image'),
|
||||
retryIntervalMs: 0,
|
||||
});
|
||||
expect(result).toEqual('https://storage.googleapis.com/turtle-v2-test/cat.jpg');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
@@ -135,15 +112,9 @@ describe('GCS client', () => {
|
||||
status: 200,
|
||||
} as Response;
|
||||
|
||||
const localImagePath = path.join(__dirname, 'cat.jpg');
|
||||
const key = 'cat.jpg';
|
||||
|
||||
const gcs = new GCS(TEST_BUCKET);
|
||||
const signedUrl = await gcs.createSignedUploadUrl({
|
||||
key,
|
||||
expirationTime: 30000,
|
||||
contentType: 'image/jpeg',
|
||||
});
|
||||
const signedUrl = createSignedUrl(key, 'image/jpeg');
|
||||
|
||||
fetchMock
|
||||
.mockImplementationOnce(async () => {
|
||||
@@ -161,8 +132,8 @@ describe('GCS client', () => {
|
||||
.mockImplementation(async () => res);
|
||||
const result = await GCS.uploadWithSignedUrl({
|
||||
signedUrl,
|
||||
srcGeneratorAsync: async () => fs.createReadStream(localImagePath),
|
||||
retryIntervalMs: 500,
|
||||
srcGeneratorAsync: async () => Readable.from('image'),
|
||||
retryIntervalMs: 0,
|
||||
});
|
||||
expect(result).toEqual('https://storage.googleapis.com/turtle-v2-test/cat.jpg');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
@@ -175,15 +146,9 @@ describe('GCS client', () => {
|
||||
status: 200,
|
||||
} as Response;
|
||||
|
||||
const localImagePath = path.join(__dirname, 'cat.jpg');
|
||||
const key = 'cat.jpg';
|
||||
|
||||
const gcs = new GCS(TEST_BUCKET);
|
||||
const signedUrl = await gcs.createSignedUploadUrl({
|
||||
key,
|
||||
expirationTime: 30000,
|
||||
contentType: 'image/jpeg',
|
||||
});
|
||||
const signedUrl = createSignedUrl(key, 'image/jpeg');
|
||||
|
||||
fetchMock
|
||||
.mockImplementationOnce(async () => {
|
||||
@@ -198,8 +163,8 @@ describe('GCS client', () => {
|
||||
.mockImplementation(async () => res);
|
||||
const result = await GCS.uploadWithSignedUrl({
|
||||
signedUrl,
|
||||
srcGeneratorAsync: async () => fs.createReadStream(localImagePath),
|
||||
retryIntervalMs: 500,
|
||||
srcGeneratorAsync: async () => Readable.from('image'),
|
||||
retryIntervalMs: 0,
|
||||
});
|
||||
expect(result).toEqual('https://storage.googleapis.com/turtle-v2-test/cat.jpg');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
@@ -212,15 +177,9 @@ describe('GCS client', () => {
|
||||
status: 200,
|
||||
} as Response;
|
||||
|
||||
const localImagePath = path.join(__dirname, 'cat.jpg');
|
||||
const key = 'cat.jpg';
|
||||
|
||||
const gcs = new GCS(TEST_BUCKET);
|
||||
const signedUrl = await gcs.createSignedUploadUrl({
|
||||
key,
|
||||
expirationTime: 30000,
|
||||
contentType: 'image/jpeg',
|
||||
});
|
||||
const signedUrl = createSignedUrl(key, 'image/jpeg');
|
||||
|
||||
const lastDNSError = new DNSError('failed thrice');
|
||||
fetchMock
|
||||
@@ -237,8 +196,8 @@ describe('GCS client', () => {
|
||||
await expect(
|
||||
GCS.uploadWithSignedUrl({
|
||||
signedUrl,
|
||||
srcGeneratorAsync: async () => fs.createReadStream(localImagePath),
|
||||
retryIntervalMs: 500,
|
||||
srcGeneratorAsync: async () => Readable.from('image'),
|
||||
retryIntervalMs: 0,
|
||||
})
|
||||
).rejects.toThrow(lastDNSError);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
@@ -251,15 +210,9 @@ describe('GCS client', () => {
|
||||
status: 200,
|
||||
} as Response;
|
||||
|
||||
const localImagePath = path.join(__dirname, 'cat.jpg');
|
||||
const key = 'cat.jpg';
|
||||
|
||||
const gcs = new GCS(TEST_BUCKET);
|
||||
const signedUrl = await gcs.createSignedUploadUrl({
|
||||
key,
|
||||
expirationTime: 30000,
|
||||
contentType: 'image/jpeg',
|
||||
});
|
||||
const signedUrl = createSignedUrl(key, 'image/jpeg');
|
||||
|
||||
fetchMock
|
||||
.mockImplementationOnce(async () => {
|
||||
@@ -284,8 +237,8 @@ describe('GCS client', () => {
|
||||
await expect(
|
||||
GCS.uploadWithSignedUrl({
|
||||
signedUrl,
|
||||
srcGeneratorAsync: async () => fs.createReadStream(localImagePath),
|
||||
retryIntervalMs: 500,
|
||||
srcGeneratorAsync: async () => Readable.from('image'),
|
||||
retryIntervalMs: 0,
|
||||
})
|
||||
).rejects.toThrow();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
@@ -298,15 +251,9 @@ describe('GCS client', () => {
|
||||
status: 200,
|
||||
} as Response;
|
||||
|
||||
const localImagePath = path.join(__dirname, 'cat.jpg');
|
||||
const key = 'cat.jpg';
|
||||
|
||||
const gcs = new GCS(TEST_BUCKET);
|
||||
const signedUrl = await gcs.createSignedUploadUrl({
|
||||
key,
|
||||
expirationTime: 30000,
|
||||
contentType: 'image/jpeg',
|
||||
});
|
||||
const signedUrl = createSignedUrl(key, 'image/jpeg');
|
||||
|
||||
const nonDNSError = new ErrorWithCode('failed once', 'A_DIFFERENT_CODE');
|
||||
fetchMock
|
||||
@@ -317,8 +264,8 @@ describe('GCS client', () => {
|
||||
await expect(
|
||||
GCS.uploadWithSignedUrl({
|
||||
signedUrl,
|
||||
srcGeneratorAsync: async () => fs.createReadStream(localImagePath),
|
||||
retryIntervalMs: 500,
|
||||
srcGeneratorAsync: async () => Readable.from('image'),
|
||||
retryIntervalMs: 0,
|
||||
})
|
||||
).rejects.toThrow(nonDNSError);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
@@ -354,19 +301,14 @@ describe('GCS client', () => {
|
||||
} as Response;
|
||||
});
|
||||
|
||||
const gcs = new GCS(TEST_BUCKET);
|
||||
const signedUrl = await gcs.createSignedUploadUrl({
|
||||
key: 'text.txt',
|
||||
expirationTime: 30000,
|
||||
contentType: 'text/plain',
|
||||
});
|
||||
const signedUrl = createSignedUrl('text.txt', 'text/plain');
|
||||
|
||||
const bufferToUpload = randomBytes(16);
|
||||
|
||||
const result = await GCS.uploadWithSignedUrl({
|
||||
signedUrl,
|
||||
srcGeneratorAsync: async () => Readable.from(bufferToUpload),
|
||||
retryIntervalMs: 500,
|
||||
retryIntervalMs: 0,
|
||||
});
|
||||
expect(result).toEqual('https://storage.googleapis.com/turtle-v2-test/text.txt');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.9 KiB |
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"type": "service_account",
|
||||
"project_id": "mock-project",
|
||||
"private_key_id": "mock-private-key-id",
|
||||
"private_key": "-----BEGIN RSA PRIVATE KEY-----\nMIICXQIBAAKBgQCMk5Qbd5E8RQeP+2vObJVJVnXWXfW/OStxkAYe7r+KVXzHyLYU\nD6Ejt94OiC3X2+tqrMIZgNAUR3VF5tSiP57Il0iWHSspmNIHz7cW6/FHiQsvN2oJ\n8Fzz03vK/FfaU50QWO+BwRnixJ0xG1kSX3MxNyRZ5omGJgcwx0l6Jt878QIDAQAB\nAoGAcGZbqO6ceNU068H6/A1D/GSeSa3NHX2np+ChlEAPdJtP7yojs7yfekC801+9\nT/gurpe9hsjBF0XflemwIJ6/5KucWHrcp2cCkdBHtkK6ScHeIVl3DcOYYhxqJrAA\nYiyGNh1+pf3FMPFBcPJ04pQT4PqOif61/W3p8eJFUke5x7UCQQDoyJ427wrUCZb+\nj2GD5uVyxQa4OJxDbRFriK5LTdwLeFeOmQhoo6kaGywILn6UtlPTUJ38ROA4Jv58\nEJf/jssDAkEAmpi/JkWlOzSM9mnb9blt/fH/DRKu9z0JPvl47V5YAzBhUtUMK15g\nSbEl+AJZyaklpJcgv6ShiGY0PLdoY7oQ+wJBAMF6VX4lKqPYIKcN9ygRlm3Q8ufV\nLZQhKBRvsyYl3Zmu+V8tNL78IEXxhaR7OHxUGtINNHKDsLUbO/NUO3GOdo8CQQCV\nV0sIIK0+JUSq4ZYvqKI9d6Fnso28noSpBfuwabvh0MGjb9Viq7eeWHeSPksYSMLp\nXWiwWMwGZJy/rnk0JVEzAkADiiARp70BZgRB8ogBx+DGTravfhPP64eFMHJnUgAE\nmDKlj3v0x3OZfdUz6Us5hfy3WqFyYbcCGLf7joVVZAEm\n-----END RSA PRIVATE KEY-----",
|
||||
"client_email": "mock-client-email",
|
||||
"client_id": "mock-client-id",
|
||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
||||
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/mock-client-email",
|
||||
"universe_domain": "googleapis.com"
|
||||
}
|
||||
@@ -1,37 +1,23 @@
|
||||
import { CreateWriteStreamOptions, File, Storage } from '@google-cloud/storage';
|
||||
import fs from 'fs-extra';
|
||||
import fetch, { FetchError } from 'node-fetch';
|
||||
import stream from 'node:stream';
|
||||
import { promisify } from 'node:util';
|
||||
import { Readable } from 'stream';
|
||||
import { URL } from 'url';
|
||||
|
||||
import { RetryOptions, retryOnGCSUploadFailure } from './retry';
|
||||
|
||||
const pipeline = promisify(stream.pipeline);
|
||||
|
||||
interface SignedUrlParams {
|
||||
key: string;
|
||||
expirationTime: number;
|
||||
contentType?: string;
|
||||
extensionHeaders?: { [key: string]: number | string | string[] };
|
||||
}
|
||||
import { retryOnGCSUploadFailure } from './retry';
|
||||
|
||||
interface UploadWithSignedUrlParams {
|
||||
signedUrl: GCS.SignedUrl;
|
||||
srcGeneratorAsync: () => Promise<Readable>;
|
||||
retryIntervalMs?: RetryOptions['retryIntervalMs'];
|
||||
retries?: RetryOptions['retries'];
|
||||
retryIntervalMs?: number;
|
||||
retries?: number;
|
||||
}
|
||||
|
||||
class GCS {
|
||||
private readonly client = new Storage();
|
||||
export namespace GCS {
|
||||
export type SignedUrl = {
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
};
|
||||
|
||||
constructor(private readonly bucket: string) {
|
||||
this.bucket = bucket;
|
||||
}
|
||||
|
||||
public static async uploadWithSignedUrl({
|
||||
export async function uploadWithSignedUrl({
|
||||
signedUrl,
|
||||
srcGeneratorAsync,
|
||||
retries = 2,
|
||||
@@ -48,18 +34,15 @@ class GCS {
|
||||
body: src,
|
||||
});
|
||||
},
|
||||
{
|
||||
retries,
|
||||
retryIntervalMs,
|
||||
}
|
||||
{ retries, retryIntervalMs }
|
||||
);
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof FetchError) {
|
||||
throw new Error(`Failed to upload the file, reason: ${err.code}`);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
let body: string | undefined;
|
||||
try {
|
||||
@@ -69,142 +52,8 @@ class GCS {
|
||||
`Failed to upload file: status: ${resp.status} status text: ${resp.statusText}, body: ${body}`
|
||||
);
|
||||
}
|
||||
|
||||
const url = new URL(signedUrl.url);
|
||||
return `${url.protocol}//${url.host}${url.pathname}`; // strip query string
|
||||
}
|
||||
|
||||
public formatHttpUrl(key: string): string {
|
||||
return this.client.bucket(this.bucket).file(key).publicUrl();
|
||||
}
|
||||
|
||||
public async uploadFile({
|
||||
key,
|
||||
src,
|
||||
streamOptions,
|
||||
}: {
|
||||
key: string;
|
||||
src: Readable;
|
||||
streamOptions?: CreateWriteStreamOptions;
|
||||
}): Promise<{ Location: string }> {
|
||||
const file = this.client.bucket(this.bucket).file(key);
|
||||
await new Promise<void>((res, rej) => {
|
||||
src.pipe(
|
||||
file
|
||||
.createWriteStream(streamOptions)
|
||||
.on('error', err => {
|
||||
rej(err);
|
||||
})
|
||||
.on('finish', () => {
|
||||
res();
|
||||
})
|
||||
);
|
||||
});
|
||||
return { Location: file.publicUrl() };
|
||||
}
|
||||
|
||||
public async deleteFile(key: string): Promise<void> {
|
||||
try {
|
||||
await this.client.bucket(this.bucket).file(key).delete();
|
||||
} catch (err: any) {
|
||||
if (err.response?.statusCode === 404) {
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
public async createSignedUploadUrl({
|
||||
key,
|
||||
expirationTime,
|
||||
contentType,
|
||||
extensionHeaders = {},
|
||||
}: SignedUrlParams): Promise<GCS.SignedUrl> {
|
||||
const config = {
|
||||
version: 'v4' as const,
|
||||
action: 'write' as const,
|
||||
expires: Date.now() + expirationTime,
|
||||
contentType,
|
||||
extensionHeaders,
|
||||
};
|
||||
|
||||
const [url] = await this.client.bucket(this.bucket).file(key).getSignedUrl(config);
|
||||
|
||||
return {
|
||||
url,
|
||||
headers: {
|
||||
...(contentType ? { 'content-type': contentType } : {}),
|
||||
...extensionHeaders,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public async createSignedDownloadUrl({
|
||||
key,
|
||||
expirationTime,
|
||||
}: {
|
||||
key: string;
|
||||
expirationTime: number;
|
||||
}): Promise<string> {
|
||||
const options = {
|
||||
version: 'v4' as const,
|
||||
action: 'read' as const,
|
||||
expires: Date.now() + expirationTime,
|
||||
};
|
||||
|
||||
const [url] = await this.client.bucket(this.bucket).file(key).getSignedUrl(options);
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
public async checkIfFileExists(key: string, fileHash?: string): Promise<boolean> {
|
||||
let metadata;
|
||||
try {
|
||||
[metadata] = await this.client.bucket(this.bucket).file(key).getMetadata();
|
||||
} catch (error: any) {
|
||||
if (error.code === 404) {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return fileHash ? metadata.etag === fileHash : true;
|
||||
}
|
||||
|
||||
public async listDirectory(prefix: string): Promise<string[]> {
|
||||
const [files] = await this.client.bucket(this.bucket).getFiles({ prefix });
|
||||
return files.map(x => this.formatHttpUrl(x.name));
|
||||
}
|
||||
|
||||
public async moveFile(src: string, dest: string): Promise<void> {
|
||||
await this.client.bucket(this.bucket).file(src).move(dest);
|
||||
}
|
||||
|
||||
public async deleteFiles(keys: string[]): Promise<void> {
|
||||
await Promise.all(keys.map(key => this.deleteFile(key)));
|
||||
}
|
||||
|
||||
public async downloadFile(key: string, destinationPath: string): Promise<void> {
|
||||
const stream = this.client.bucket(this.bucket).file(key).createReadStream();
|
||||
await pipeline(stream, fs.createWriteStream(destinationPath));
|
||||
}
|
||||
|
||||
public getFile(key: string): File {
|
||||
return this.client.bucket(this.bucket).file(key);
|
||||
return `${url.protocol}//${url.host}${url.pathname}`;
|
||||
}
|
||||
}
|
||||
|
||||
namespace GCS {
|
||||
export interface SignedUrl {
|
||||
url: string;
|
||||
headers: { [key: string]: string };
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
region: string;
|
||||
bucket: string;
|
||||
}
|
||||
}
|
||||
|
||||
export default GCS;
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Response } from 'node-fetch';
|
||||
|
||||
export interface RetryOptions {
|
||||
type RetryOptions = {
|
||||
retries: number;
|
||||
retryIntervalMs: number;
|
||||
shouldRetryOnError: (error: any) => boolean;
|
||||
shouldRetryOnError: (error: unknown) => boolean;
|
||||
shouldRetryOnResponse: (response: Response) => boolean;
|
||||
}
|
||||
};
|
||||
|
||||
// based on https://github.com/googleapis/nodejs-storage/blob/8ab50804fc7bae3bbd159bbb4adf65c02215b11b/src/storage.ts#L284-L320
|
||||
export async function retryOnGCSUploadFailure(
|
||||
@@ -18,13 +18,14 @@ export async function retryOnGCSUploadFailure(
|
||||
return await retry(fn, {
|
||||
retries,
|
||||
retryIntervalMs,
|
||||
shouldRetryOnError: e => {
|
||||
shouldRetryOnError: err => {
|
||||
return (
|
||||
e.code === 'ENOTFOUND' ||
|
||||
e.code === 'EAI_AGAIN' ||
|
||||
e.code === 'ECONNRESET' ||
|
||||
e.code === 'ETIMEDOUT' ||
|
||||
e.code === 'EPIPE'
|
||||
isErrorWithCode(err) &&
|
||||
(err.code === 'ENOTFOUND' ||
|
||||
err.code === 'EAI_AGAIN' ||
|
||||
err.code === 'ECONNRESET' ||
|
||||
err.code === 'ETIMEDOUT' ||
|
||||
err.code === 'EPIPE')
|
||||
);
|
||||
},
|
||||
shouldRetryOnResponse: resp => {
|
||||
@@ -33,8 +34,12 @@ export async function retryOnGCSUploadFailure(
|
||||
});
|
||||
}
|
||||
|
||||
function isErrorWithCode(error: unknown): error is { code: string } {
|
||||
return typeof error === 'object' && error !== null && 'code' in error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper used to execute an inner function and possibly retry it if it throws and error
|
||||
* Wrapper used to execute an inner function and possibly retry it if it throws an error
|
||||
* @param fn Function to be executed and retried in case of error
|
||||
* @param retries How many times at most should the function be retried
|
||||
* @param retryIntervalMs Time interval between the retries
|
||||
@@ -55,7 +60,7 @@ async function retry(
|
||||
} else {
|
||||
return resp;
|
||||
}
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
if (attemptCount === retries || !shouldRetryOnError(err)) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ export async function runGenericJobAsync(
|
||||
externalFunctions: getEasFunctions(customBuildCtx),
|
||||
externalFunctionGroups: getEasFunctionGroups(customBuildCtx),
|
||||
steps: ctx.job.steps,
|
||||
hooks: ctx.job.hooks,
|
||||
});
|
||||
|
||||
const workflow = await ctx.runBuildPhase(BuildPhase.PARSE_CUSTOM_WORKFLOW_CONFIG, async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as Builders from './builders';
|
||||
import GCSLoggerStream from './gcs/LoggerStream';
|
||||
import GCS from './gcs/client';
|
||||
import { GCS } from './gcs/client';
|
||||
|
||||
export { Builders, GCS, GCSLoggerStream };
|
||||
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { HookAnchorId } from '@expo/eas-build-job';
|
||||
import { StepsConfigParser } from '@expo/steps';
|
||||
|
||||
import {
|
||||
createCustomBuildContextMock,
|
||||
createGlobalContextMock,
|
||||
} from '../../__tests__/utils/context';
|
||||
import { getEasFunctionGroups } from '../easFunctionGroups';
|
||||
import { getEasFunctions } from '../easFunctions';
|
||||
|
||||
jest.mock('fs');
|
||||
|
||||
describe('hook anchor self-declarations', () => {
|
||||
it('exactly the four v0 functions declare anchors, with these bindings', () => {
|
||||
const declarations: Record<string, HookAnchorId> = {};
|
||||
for (const buildFunction of getEasFunctions(createCustomBuildContextMock())) {
|
||||
if (buildFunction.__hookId !== undefined) {
|
||||
declarations[buildFunction.getFullId()] = buildFunction.__hookId;
|
||||
}
|
||||
}
|
||||
// An empty declaration set must fail: without these, every user-authored
|
||||
// unstamped `uses:` step and every `eas/build` expansion loses hooks.
|
||||
expect(declarations).toEqual({
|
||||
'eas/checkout': 'checkout',
|
||||
'eas/install_node_modules': 'install_node_modules',
|
||||
'eas/upload_to_asc': 'submit',
|
||||
'eas/maestro_tests': 'maestro_tests',
|
||||
});
|
||||
});
|
||||
|
||||
it('no function group id collides with a declaring function id', () => {
|
||||
// Group lookup shadows function lookup in StepsConfigParser, so a
|
||||
// collision would silently unbind the anchor.
|
||||
const declaringFunctionIds = getEasFunctions(createCustomBuildContextMock())
|
||||
.filter(buildFunction => buildFunction.__hookId !== undefined)
|
||||
.map(buildFunction => buildFunction.getFullId());
|
||||
const functionGroupIds = getEasFunctionGroups(createCustomBuildContextMock()).map(
|
||||
functionGroup => functionGroup.getFullId()
|
||||
);
|
||||
for (const functionId of declaringFunctionIds) {
|
||||
expect(functionGroupIds).not.toContain(functionId);
|
||||
}
|
||||
});
|
||||
|
||||
it('attaches hooks inside a uses: eas/build expansion (declaration-driven anchor discovery)', async () => {
|
||||
// This is the guard against a semantically stale declaration — the
|
||||
// declaration existence test alone cannot catch a binding that no longer
|
||||
// corresponds to what the group actually expands to.
|
||||
const customBuildCtx = createCustomBuildContextMock();
|
||||
const globalContext = createGlobalContextMock({});
|
||||
const parser = new StepsConfigParser(globalContext, {
|
||||
steps: [{ uses: 'eas/build' }],
|
||||
hooks: {
|
||||
before_install_node_modules: [{ run: 'echo before', id: 'before-hook' }],
|
||||
after_install_node_modules: [{ run: 'echo after', id: 'after-hook' }],
|
||||
},
|
||||
externalFunctions: getEasFunctions(customBuildCtx),
|
||||
externalFunctionGroups: getEasFunctionGroups(customBuildCtx),
|
||||
});
|
||||
const workflow = await parser.parseAsync();
|
||||
|
||||
const installStep = workflow.buildSteps.find(step => step.__hookId === 'install_node_modules');
|
||||
expect(installStep).toBeDefined();
|
||||
const anchorHooks = workflow.hooksByAnchorStep.get(installStep!);
|
||||
expect(anchorHooks?.anchor).toBe('install_node_modules');
|
||||
expect(anchorHooks?.before[0].steps[0].id).toBe('before-hook');
|
||||
expect(anchorHooks?.after[0].steps[0].id).toBe('after-hook');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['eas/checkout', 'before_checkout', 'after_checkout', undefined],
|
||||
[
|
||||
'eas/install_node_modules',
|
||||
'before_install_node_modules',
|
||||
'after_install_node_modules',
|
||||
undefined,
|
||||
],
|
||||
[
|
||||
'eas/upload_to_asc',
|
||||
'before_submit',
|
||||
'after_submit',
|
||||
{
|
||||
ipa_path: 'app.ipa',
|
||||
asc_api_key_path: 'key.p8',
|
||||
apple_app_identifier: '1234567890',
|
||||
bundle_version: '1',
|
||||
bundle_short_version: '1.0.0',
|
||||
},
|
||||
],
|
||||
[
|
||||
'eas/maestro_tests',
|
||||
'before_maestro_tests',
|
||||
'after_maestro_tests',
|
||||
{ flow_path: ['flow.yaml'] },
|
||||
],
|
||||
] as const)(
|
||||
'an unstamped user-authored %s step fires its hook keys end-to-end',
|
||||
async (functionId, beforeKey, afterKey, callInputs) => {
|
||||
const customBuildCtx = createCustomBuildContextMock();
|
||||
const globalContext = createGlobalContextMock({});
|
||||
const parser = new StepsConfigParser(globalContext, {
|
||||
steps: [{ uses: functionId, ...(callInputs !== undefined ? { with: callInputs } : null) }],
|
||||
hooks: {
|
||||
[beforeKey]: [{ run: 'echo before', id: 'before-hook' }],
|
||||
[afterKey]: [{ run: 'echo after', id: 'after-hook' }],
|
||||
},
|
||||
externalFunctions: getEasFunctions(customBuildCtx),
|
||||
externalFunctionGroups: getEasFunctionGroups(customBuildCtx),
|
||||
});
|
||||
const workflow = await parser.parseAsync();
|
||||
expect(workflow.hooksByAnchorStep.size).toBe(1);
|
||||
const anchorHooks = [...workflow.hooksByAnchorStep.values()][0];
|
||||
expect(anchorHooks.before[0].steps[0].id).toBe('before-hook');
|
||||
expect(anchorHooks.after[0].steps[0].id).toBe('after-hook');
|
||||
}
|
||||
);
|
||||
});
|
||||
+82
-3
@@ -12,7 +12,23 @@ jest.mock('../../utils/deviceRunSessionArtifacts', () => ({
|
||||
uploadDeviceRunSessionArtifactAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const SIMULATOR_UDID = '01234567-89AB-CDEF-0123-456789ABCDEF';
|
||||
const SECOND_SIMULATOR_UDID = 'FEDCBA98-7654-3210-FEDC-BA9876543210';
|
||||
|
||||
describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockReset()
|
||||
.mockImplementation(async (_ctx, { stream }) => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
stream.once('end', resolve);
|
||||
stream.once('error', reject);
|
||||
stream.resume();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('uploads simulator device metadata', async () => {
|
||||
const recordingDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'ios-recording-test-'));
|
||||
const recordingPath = path.join(recordingDirectory, 'recording.mp4');
|
||||
@@ -22,6 +38,8 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
|
||||
JSON.stringify({
|
||||
recording: path.basename(recordingPath),
|
||||
firstFrameWallClock: { iso8601: '2026-07-10T10:00:00.000Z' },
|
||||
width: 1179,
|
||||
height: 2556,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -36,7 +54,7 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
|
||||
callInputs: {
|
||||
recordings_json: [
|
||||
{
|
||||
udid: 'simulator-udid',
|
||||
udid: SIMULATOR_UDID,
|
||||
deviceName: 'iPhone 16',
|
||||
runtimeDisplayName: 'iOS 18.6',
|
||||
directory: recordingDirectory,
|
||||
@@ -50,13 +68,16 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
|
||||
expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
name: 'iPhone 16 screen recording',
|
||||
name: 'iPhone 16 screen recording (01234567-…, started at Jul 10, 2026, 10:00:00.000 UTC)',
|
||||
metadata: {
|
||||
__eas_type: 'screen-recording',
|
||||
__eas_screen_recording: '1',
|
||||
udid: 'simulator-udid',
|
||||
udid: SIMULATOR_UDID,
|
||||
deviceName: 'iPhone 16',
|
||||
runtimeDisplayName: 'iOS 18.6',
|
||||
firstFrameAt: '2026-07-10T10:00:00.000Z',
|
||||
width: 1179,
|
||||
height: 2556,
|
||||
},
|
||||
})
|
||||
);
|
||||
@@ -64,4 +85,62 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
|
||||
await fs.rm(recordingDirectory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('uses unique names for recordings from rebooted and same-model simulators', async () => {
|
||||
const recordingDirectories = await Promise.all(
|
||||
['2026-07-10T10:00:00.000Z', '2026-07-10T10:05:00.000Z', '2026-07-10T10:00:00.000Z'].map(
|
||||
async firstFrameAt => {
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ios-recording-test-'));
|
||||
await fs.writeFile(path.join(directory, 'recording.mp4'), 'recording');
|
||||
await fs.writeFile(
|
||||
path.join(directory, 'session.json'),
|
||||
JSON.stringify({
|
||||
recording: 'recording.mp4',
|
||||
firstFrameWallClock: { iso8601: firstFrameAt },
|
||||
width: 1179,
|
||||
height: 2556,
|
||||
})
|
||||
);
|
||||
return directory;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
try {
|
||||
const globalContext = createGlobalContextMock({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
});
|
||||
const buildStep = createUploadDeviceRunSessionScreenRecordingsBuildFunction(
|
||||
{} as CustomBuildContext
|
||||
).createBuildStepFromFunctionCall(globalContext, {
|
||||
env: { DEVICE_RUN_SESSION_ID: 'device-run-session-id' },
|
||||
callInputs: {
|
||||
recordings_json: recordingDirectories.map((directory, index) => ({
|
||||
udid: index === 2 ? SECOND_SIMULATOR_UDID : SIMULATOR_UDID,
|
||||
deviceName: 'iPhone 16',
|
||||
runtimeDisplayName: 'iOS 18.6',
|
||||
directory,
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
const names = jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mock.calls.map(([, options]) => options.name);
|
||||
expect(names).toEqual(
|
||||
expect.arrayContaining([
|
||||
'iPhone 16 screen recording (01234567-…, started at Jul 10, 2026, 10:00:00.000 UTC)',
|
||||
'iPhone 16 screen recording (01234567-…, started at Jul 10, 2026, 10:05:00.000 UTC)',
|
||||
'iPhone 16 screen recording (FEDCBA98-…, started at Jul 10, 2026, 10:00:00.000 UTC)',
|
||||
])
|
||||
);
|
||||
expect(new Set(names).size).toBe(3);
|
||||
} finally {
|
||||
await Promise.all(
|
||||
recordingDirectories.map(directory => fs.rm(directory, { recursive: true, force: true }))
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ export function createCheckoutBuildFunction(): BuildFunction {
|
||||
id: 'checkout',
|
||||
name: 'Checkout',
|
||||
__metricsId: 'eas/checkout',
|
||||
__hookId: 'checkout',
|
||||
fn: async stepsCtx => {
|
||||
if (stepsCtx.global.wasCheckedOut()) {
|
||||
stepsCtx.logger.info('Project directory is already checked out');
|
||||
|
||||
@@ -16,6 +16,7 @@ export function createInstallNodeModulesBuildFunction(): BuildFunction {
|
||||
id: 'install_node_modules',
|
||||
name: 'Install node modules',
|
||||
__metricsId: 'eas/install_node_modules',
|
||||
__hookId: 'install_node_modules',
|
||||
fn: async (stepCtx, { env }) => {
|
||||
await installNodeModules(stepCtx, env);
|
||||
},
|
||||
|
||||
@@ -97,6 +97,7 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
id: 'maestro_tests',
|
||||
name: 'Run Maestro Tests',
|
||||
__metricsId: 'eas/maestro_tests',
|
||||
__hookId: 'maestro_tests',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'flow_path',
|
||||
|
||||
@@ -29,19 +29,18 @@ export function createStartServeSimRemoteSessionBuildFunction(
|
||||
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
|
||||
const { previewUrl, streamUrl } = await startServeSimWithTunnelAsync(ctx, {
|
||||
const { previewUrl } = await startServeSimWithTunnelAsync(ctx, {
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
});
|
||||
logger.info(`Preview URL: ${previewUrl}`);
|
||||
logger.info(`Stream URL: ${streamUrl}`);
|
||||
|
||||
await uploadRemoteSessionConfigAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: { previewUrl, streamUrl },
|
||||
remoteConfig: { previewUrl },
|
||||
logger,
|
||||
});
|
||||
|
||||
|
||||
@@ -30,9 +30,24 @@ const RecordingManifestSchema = z.object({
|
||||
firstFrameWallClock: z.object({
|
||||
iso8601: z.string(),
|
||||
}),
|
||||
width: z.number().int().positive(),
|
||||
height: z.number().int().positive(),
|
||||
recording: z.string(),
|
||||
});
|
||||
|
||||
const recordingStartTimeFormatter = new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
fractionalSecondDigits: 3,
|
||||
hourCycle: 'h23',
|
||||
timeZone: 'UTC',
|
||||
timeZoneName: 'short',
|
||||
});
|
||||
|
||||
export function createUploadDeviceRunSessionScreenRecordingsBuildFunction(
|
||||
ctx: CustomBuildContext
|
||||
): BuildFunction {
|
||||
@@ -68,11 +83,15 @@ export function createUploadDeviceRunSessionScreenRecordingsBuildFunction(
|
||||
await Promise.all(
|
||||
recordings.map(recording =>
|
||||
limit(async () => {
|
||||
const displayName = `${recording.deviceName} screen recording`;
|
||||
try {
|
||||
const metadata = RecordingManifestSchema.parse(
|
||||
JSON.parse(await readFile(path.join(recording.directory, 'session.json'), 'utf-8'))
|
||||
);
|
||||
const startedAt = recordingStartTimeFormatter.format(
|
||||
new Date(metadata.firstFrameWallClock.iso8601)
|
||||
);
|
||||
const shortUdid = `${recording.udid.slice(0, 8)}-…`;
|
||||
const displayName = `${recording.deviceName} screen recording (${shortUdid}, started at ${startedAt})`;
|
||||
const recordingPath = path.join(recording.directory, metadata.recording);
|
||||
const { size } = await stat(recordingPath);
|
||||
const recordingId = path.basename(recording.directory);
|
||||
@@ -86,11 +105,14 @@ export function createUploadDeviceRunSessionScreenRecordingsBuildFunction(
|
||||
filename: `${recordingId}.mp4`,
|
||||
kind: 'screen-recording',
|
||||
metadata: {
|
||||
__eas_type: 'screen-recording',
|
||||
__eas_screen_recording: '1',
|
||||
udid: recording.udid,
|
||||
deviceName: recording.deviceName,
|
||||
runtimeDisplayName: recording.runtimeDisplayName,
|
||||
firstFrameAt: metadata.firstFrameWallClock.iso8601,
|
||||
width: metadata.width,
|
||||
height: metadata.height,
|
||||
},
|
||||
size,
|
||||
stream: createReadStream(recordingPath),
|
||||
|
||||
@@ -23,6 +23,7 @@ export function createUploadToAscBuildFunction(): BuildFunction {
|
||||
id: 'upload_to_asc',
|
||||
name: 'Upload to App Store Connect',
|
||||
__metricsId: 'eas/upload_to_asc',
|
||||
__hookId: 'submit',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'ipa_path',
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Sentry } from '../../sentry';
|
||||
import { IosSimulatorUtils, type IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
|
||||
|
||||
const IOS_SIMULATOR_RECORDING_POLL_INTERVAL_MS = 2_000;
|
||||
const RECORD_SIM_FINISH_TIMEOUT_MS = 30_000;
|
||||
const RECORD_SIM_FINISH_TIMEOUT_MS = 70_000;
|
||||
const RECORD_SIM_FORCE_STOP_TIMEOUT_MS = 5_000;
|
||||
const RECORD_SIM_MAX_ATTEMPTS_PER_BOOT = 3;
|
||||
const RECORD_SIM_COMMAND = 'record-sim';
|
||||
@@ -114,7 +114,16 @@ export namespace IosSimulatorRecordingUtils {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.warn(`Forcing iOS Simulator recording process for ${recording.deviceName} to stop.`);
|
||||
const recordSimOutput = recording.getOutput().trim();
|
||||
const finishTimeoutSeconds = Math.round(RECORD_SIM_FINISH_TIMEOUT_MS / 1_000);
|
||||
logger.warn(
|
||||
{ recordSimOutput },
|
||||
`Screen recording for ${recording.deviceName} did not finish within ${finishTimeoutSeconds} seconds and will be stopped.${
|
||||
recordSimOutput
|
||||
? `\nRecent recorder messages:\n${recordSimOutput}`
|
||||
: '\nNo recorder messages were captured.'
|
||||
}`
|
||||
);
|
||||
recording.recordingProcess.kill('SIGKILL');
|
||||
const killed = await Promise.race([
|
||||
recording.completionPromise.then(() => true),
|
||||
|
||||
@@ -343,7 +343,7 @@ export async function startServeSimWithTunnelAsync(
|
||||
logger: bunyan;
|
||||
timeoutMs: number;
|
||||
}
|
||||
): Promise<{ previewUrl: string; streamUrl: string }> {
|
||||
): Promise<{ previewUrl: string }> {
|
||||
logger.info('Launching serve-sim with tunnel.');
|
||||
const turnArgs = await fetchServeSimTurnArgsAsync(ctx, { env, logger });
|
||||
const serveSim = spawnDetached({
|
||||
@@ -366,33 +366,30 @@ export async function startServeSimWithTunnelAsync(
|
||||
env,
|
||||
});
|
||||
|
||||
logger.info('Waiting for serve-sim to report tunnel and stream URLs.');
|
||||
logger.info('Waiting for serve-sim to report tunnel URL.');
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const output = serveSim.getOutput();
|
||||
const previewUrl = matchLabeledUrl({ output, label: 'Tunnel', baseDomain });
|
||||
const streamUrl = matchLabeledUrl({ output, label: 'Stream', baseDomain });
|
||||
if (previewUrl && streamUrl) {
|
||||
return { previewUrl, streamUrl };
|
||||
const previewUrl = matchTunnelUrl({ output, baseDomain });
|
||||
if (previewUrl) {
|
||||
return { previewUrl };
|
||||
}
|
||||
await sleepAsync(1_000);
|
||||
}
|
||||
throw new SystemError(
|
||||
`Timed out waiting for serve-sim to report Tunnel and Stream URLs. Last output:\n${serveSim.getOutput() || '<empty>'}`
|
||||
`Timed out waiting for serve-sim to report Tunnel URL. Last output:\n${serveSim.getOutput() || '<empty>'}`
|
||||
);
|
||||
}
|
||||
|
||||
function matchLabeledUrl({
|
||||
function matchTunnelUrl({
|
||||
output,
|
||||
label,
|
||||
baseDomain,
|
||||
}: {
|
||||
output: string;
|
||||
label: string;
|
||||
baseDomain: string;
|
||||
}): string | null {
|
||||
const labelPattern = new RegExp(
|
||||
`${label}:\\s*(https:\\/\\/[a-z0-9-]+\\.${escapeRegExp(baseDomain)})`
|
||||
`Tunnel:\\s*(https:\\/\\/[a-z0-9-]+\\.${escapeRegExp(baseDomain)})`
|
||||
);
|
||||
const match = labelPattern.exec(output);
|
||||
return match ? match[1] : null;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Datadog } from '../../datadog';
|
||||
import { reportWorkflowHookMetricToDatadog } from '../hookMetrics';
|
||||
|
||||
describe(reportWorkflowHookMetricToDatadog, () => {
|
||||
const distributionSpy = jest.spyOn(Datadog, 'distribution').mockImplementation(() => {});
|
||||
|
||||
beforeEach(() => {
|
||||
distributionSpy.mockClear();
|
||||
});
|
||||
|
||||
it('emits one eas.workflow.hook distribution tagged by anchor/timing/result', () => {
|
||||
reportWorkflowHookMetricToDatadog({
|
||||
anchor: 'install_node_modules',
|
||||
timing: 'before',
|
||||
result: 'success',
|
||||
});
|
||||
expect(distributionSpy).toHaveBeenCalledTimes(1);
|
||||
expect(distributionSpy).toHaveBeenCalledWith('eas.workflow.hook', 1, {
|
||||
anchor: 'install_node_modules',
|
||||
timing: 'before',
|
||||
result: 'success',
|
||||
});
|
||||
});
|
||||
|
||||
it('adds the anchor_result tag on after events', () => {
|
||||
reportWorkflowHookMetricToDatadog({
|
||||
anchor: 'submit',
|
||||
timing: 'after',
|
||||
result: 'failed',
|
||||
anchorResult: 'failed',
|
||||
});
|
||||
expect(distributionSpy).toHaveBeenCalledWith('eas.workflow.hook', 1, {
|
||||
anchor: 'submit',
|
||||
timing: 'after',
|
||||
result: 'failed',
|
||||
anchor_result: 'failed',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { WorkflowHookMetric } from '@expo/steps';
|
||||
|
||||
import { Datadog } from '../datadog';
|
||||
|
||||
/**
|
||||
* The engine's provider callback is the ONLY `eas.workflow.hook` emitter —
|
||||
* never emit directly on top. The turtle-job-runs proxy forwards these tags
|
||||
* as-is; the turtle-builds proxy (custom builds) also adds the standard build
|
||||
* tags (image, sdk_version, build_mode, …), the same as every build metric.
|
||||
*/
|
||||
export function reportWorkflowHookMetricToDatadog(metric: WorkflowHookMetric): void {
|
||||
Datadog.distribution('eas.workflow.hook', 1, {
|
||||
anchor: metric.anchor,
|
||||
timing: metric.timing,
|
||||
result: metric.result,
|
||||
...(metric.anchorResult !== undefined ? { anchor_result: metric.anchorResult } : null),
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-eas-build-function",
|
||||
"version": "21.0.0",
|
||||
"version": "21.0.2",
|
||||
"description": "Create functions for use in EAS Build custom builds.",
|
||||
"keywords": [
|
||||
"expo",
|
||||
@@ -35,7 +35,7 @@
|
||||
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/steps": "21.0.0"
|
||||
"@expo/steps": "21.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-build-job",
|
||||
"version": "21.0.0",
|
||||
"version": "21.0.2",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "MIT",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { HOOK_ANCHORS, parseHookKey } from '../hooks';
|
||||
|
||||
describe('HOOK_ANCHORS', () => {
|
||||
it('contains the v1 anchors', () => {
|
||||
expect([...HOOK_ANCHORS].sort()).toEqual([
|
||||
'checkout',
|
||||
'install_node_modules',
|
||||
'maestro_cloud',
|
||||
'maestro_tests',
|
||||
'submit',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseHookKey, () => {
|
||||
it('parses valid keys', () => {
|
||||
expect(parseHookKey('before_install_node_modules')).toEqual({
|
||||
side: 'before',
|
||||
anchorId: 'install_node_modules',
|
||||
});
|
||||
expect(parseHookKey('after_maestro_cloud')).toEqual({
|
||||
side: 'after',
|
||||
anchorId: 'maestro_cloud',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for unknown anchors and malformed keys', () => {
|
||||
expect(parseHookKey('before_nonexistent')).toBeNull();
|
||||
expect(parseHookKey('install_node_modules')).toBeNull();
|
||||
expect(parseHookKey('')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not treat Object prototype property names as registered anchors', () => {
|
||||
expect(parseHookKey('before___proto__')).toBeNull();
|
||||
expect(parseHookKey('after_toString')).toBeNull();
|
||||
expect(parseHookKey('before_constructor')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -71,6 +71,30 @@ describe('StepZ', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('hook anchor stamps', () => {
|
||||
it('accepts the internal __hook_id stamp field on shell steps', () => {
|
||||
const steps = [{ run: 'echo submit', __hook_id: 'submit' }];
|
||||
expect(validateSteps(steps)).toEqual(steps);
|
||||
});
|
||||
|
||||
it('accepts and retains the __hook_id stamp on uses: steps', () => {
|
||||
const steps = [{ uses: 'eas/upload_to_asc', __hook_id: 'submit' }];
|
||||
expect(validateSteps(steps)).toEqual(steps);
|
||||
});
|
||||
|
||||
it('strips the removed split-pair stamp fields (legacy __hook_before_id/__hook_after_id)', () => {
|
||||
expect(
|
||||
validateSteps([
|
||||
{ run: 'echo x', __hook_before_id: 'maestro_cloud', __hook_after_id: 'maestro_cloud' },
|
||||
])
|
||||
).toEqual([{ run: 'echo x' }]);
|
||||
});
|
||||
|
||||
it('treats stamp values as loose strings so old workers never hard-fail on new anchors', () => {
|
||||
expect(() => validateSteps([{ run: 'echo x', __hook_id: 'some_future_anchor' }])).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe(isStepShellStep, () => {
|
||||
it('returns true for shell step', () => {
|
||||
expect(isStepShellStep({ run: 'echo Hello, world!', shell: 'sh' })).toBe(true);
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Single source of hook anchor names.
|
||||
*
|
||||
* An anchor is a named moment in a job that `before_<anchor>` / `after_<anchor>`
|
||||
* hook keys attach to. Which job types expose which keys is declared on the EAS
|
||||
* servers (per-job key lists), not here: anchor existence (this registry)
|
||||
* ≠ binding (self-declared by the carrier) ≠ availability (per-job key lists).
|
||||
*
|
||||
* Binding is self-declared by the anchor's carrier: EAS-generated steps carry
|
||||
* an internal `__hook_id` stamp, and EAS build functions declare `__hookId` on
|
||||
* their definition (any step invoking a declaring function — directly, or
|
||||
* inside a function-group expansion — is an occurrence of the anchor). Several
|
||||
* steps and several functions may declare the same anchor.
|
||||
*/
|
||||
export const HOOK_ANCHORS = [
|
||||
'install_node_modules',
|
||||
'submit',
|
||||
'maestro_tests',
|
||||
'maestro_cloud',
|
||||
'checkout',
|
||||
] as const;
|
||||
|
||||
export type HookAnchorId = (typeof HOOK_ANCHORS)[number];
|
||||
export type HookKey = `before_${HookAnchorId}` | `after_${HookAnchorId}`;
|
||||
|
||||
export const HOOK_KEYS: readonly HookKey[] = HOOK_ANCHORS.flatMap((anchor): HookKey[] => [
|
||||
`before_${anchor}`,
|
||||
`after_${anchor}`,
|
||||
]);
|
||||
|
||||
export function isHookAnchorId(value: string): value is HookAnchorId {
|
||||
return HOOK_ANCHORS.some(anchor => anchor === value);
|
||||
}
|
||||
|
||||
export function parseHookKey(
|
||||
key: string
|
||||
): { side: 'before' | 'after'; anchorId: HookAnchorId } | null {
|
||||
const match = key.match(/^(before|after)_(.+)$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const [, side, anchorId] = match;
|
||||
if (!isHookAnchorId(anchorId)) {
|
||||
return null;
|
||||
}
|
||||
return { side: side as 'before' | 'after', anchorId };
|
||||
}
|
||||
@@ -24,6 +24,7 @@ export * as errors from './errors';
|
||||
export * from './artifacts';
|
||||
export * from './context';
|
||||
export * from './generic';
|
||||
export * from './hooks';
|
||||
export * from './step';
|
||||
export * from './submission-config';
|
||||
export * from './projectPackage';
|
||||
|
||||
@@ -54,6 +54,12 @@ const CommonStepZ = z.object({
|
||||
// Internal field for metrics collection. Not documented publicly.
|
||||
// YAML uses snake_case, but we transform to camelCase for internal use.
|
||||
__metrics_id: z.string().optional(),
|
||||
// Internal hook-anchor stamp set by EAS servers on generated anchor steps,
|
||||
// `uses:` steps included (unstamped function steps bind through the invoked
|
||||
// function's own anchor declaration instead). Loose strings on purpose
|
||||
// (never enums): an older worker must treat unknown anchor names as inert
|
||||
// metadata instead of failing validation.
|
||||
__hook_id: z.string().optional(),
|
||||
});
|
||||
|
||||
export const FunctionStepZ = CommonStepZ.extend({
|
||||
|
||||
+158
-118
@@ -67,6 +67,7 @@ eas --help COMMAND
|
||||
* [`eas branch:list`](#eas-branchlist)
|
||||
* [`eas branch:rename`](#eas-branchrename)
|
||||
* [`eas branch:view [NAME]`](#eas-branchview-name)
|
||||
* [`eas browse [PAGE]`](#eas-browse-page)
|
||||
* [`eas build`](#eas-build)
|
||||
* [`eas build:cancel [BUILD_ID]`](#eas-buildcancel-build_id)
|
||||
* [`eas build:configure`](#eas-buildconfigure)
|
||||
@@ -142,7 +143,7 @@ eas --help COMMAND
|
||||
* [`eas observe:routes`](#eas-observeroutes)
|
||||
* [`eas observe:session SESSIONID`](#eas-observesession-sessionid)
|
||||
* [`eas observe:versions`](#eas-observeversions)
|
||||
* [`eas open`](#eas-open)
|
||||
* [`eas project:delete [NAME]`](#eas-projectdelete-name)
|
||||
* [`eas project:info`](#eas-projectinfo)
|
||||
* [`eas project:init`](#eas-projectinit)
|
||||
* [`eas project:new [PATH]`](#eas-projectnew-path)
|
||||
@@ -204,7 +205,7 @@ DESCRIPTION
|
||||
view the audit logs for an account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/audit.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/account/audit.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/audit.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/account/audit.ts)_
|
||||
|
||||
## `eas account:login`
|
||||
|
||||
@@ -225,7 +226,7 @@ ALIASES
|
||||
$ eas login
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/account/login.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/account/login.ts)_
|
||||
|
||||
## `eas account:logout`
|
||||
|
||||
@@ -242,7 +243,7 @@ ALIASES
|
||||
$ eas logout
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/account/logout.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/account/logout.ts)_
|
||||
|
||||
## `eas account:usage [ACCOUNT_NAME]`
|
||||
|
||||
@@ -264,7 +265,7 @@ DESCRIPTION
|
||||
view account usage and billing for the current cycle
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/usage.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/account/usage.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/usage.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/account/usage.ts)_
|
||||
|
||||
## `eas account:view`
|
||||
|
||||
@@ -281,7 +282,7 @@ ALIASES
|
||||
$ eas whoami
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/account/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/account/view.ts)_
|
||||
|
||||
## `eas analytics [STATUS]`
|
||||
|
||||
@@ -295,7 +296,7 @@ DESCRIPTION
|
||||
display or change analytics settings
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/analytics.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/analytics.ts)_
|
||||
|
||||
## `eas autocomplete [SHELL]`
|
||||
|
||||
@@ -347,7 +348,7 @@ DESCRIPTION
|
||||
create a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/branch/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/branch/create.ts)_
|
||||
|
||||
## `eas branch:delete [NAME]`
|
||||
|
||||
@@ -368,7 +369,7 @@ DESCRIPTION
|
||||
delete a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/branch/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/branch/delete.ts)_
|
||||
|
||||
## `eas branch:list`
|
||||
|
||||
@@ -388,7 +389,7 @@ DESCRIPTION
|
||||
list all branches
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/branch/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/branch/list.ts)_
|
||||
|
||||
## `eas branch:rename`
|
||||
|
||||
@@ -408,7 +409,7 @@ DESCRIPTION
|
||||
rename a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/branch/rename.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/branch/rename.ts)_
|
||||
|
||||
## `eas branch:view [NAME]`
|
||||
|
||||
@@ -431,7 +432,30 @@ DESCRIPTION
|
||||
view a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/branch/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/branch/view.ts)_
|
||||
|
||||
## `eas browse [PAGE]`
|
||||
|
||||
Transition from the terminal to the web browser to view and interact with your project on https://expo.dev
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas browse [PAGE] [-n] [--json] [--non-interactive]
|
||||
|
||||
ARGUMENTS
|
||||
[PAGE] (build|builds|submit|submissions|update|updates|workflow|workflows|cicd|hosting|deployments|credentials|env|in
|
||||
sights|observe|settings) Project subpage to open. Defaults to the project dashboard.
|
||||
|
||||
FLAGS
|
||||
-n, --no-browser Print the URL instead of opening it in a web browser
|
||||
--json Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive.
|
||||
--non-interactive Run the command in non-interactive mode.
|
||||
|
||||
DESCRIPTION
|
||||
Transition from the terminal to the web browser to view and interact with your project on https://expo.dev
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/browse.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/browse.ts)_
|
||||
|
||||
## `eas build`
|
||||
|
||||
@@ -472,7 +496,7 @@ DESCRIPTION
|
||||
start a build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/build/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/build/index.ts)_
|
||||
|
||||
## `eas build:cancel [BUILD_ID]`
|
||||
|
||||
@@ -492,7 +516,7 @@ DESCRIPTION
|
||||
cancel a build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/build/cancel.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/build/cancel.ts)_
|
||||
|
||||
## `eas build:configure`
|
||||
|
||||
@@ -510,7 +534,7 @@ DESCRIPTION
|
||||
configure the project to support EAS Build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/build/configure.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/build/configure.ts)_
|
||||
|
||||
## `eas build:delete [BUILD_ID]`
|
||||
|
||||
@@ -530,7 +554,7 @@ DESCRIPTION
|
||||
delete a build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/build/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/build/delete.ts)_
|
||||
|
||||
## `eas build:dev`
|
||||
|
||||
@@ -555,7 +579,7 @@ DESCRIPTION
|
||||
run dev client simulator/emulator build with matching fingerprint or create a new one
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/dev.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/build/dev.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/dev.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/build/dev.ts)_
|
||||
|
||||
## `eas build:download`
|
||||
|
||||
@@ -582,7 +606,7 @@ DESCRIPTION
|
||||
download a simulator/emulator build by build ID or fingerprint hash
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/build/download.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/build/download.ts)_
|
||||
|
||||
## `eas build:inspect`
|
||||
|
||||
@@ -620,7 +644,7 @@ DESCRIPTION
|
||||
inspect the state of the project at specific build stages, useful for troubleshooting
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/build/inspect.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/build/inspect.ts)_
|
||||
|
||||
## `eas build:list`
|
||||
|
||||
@@ -660,7 +684,7 @@ DESCRIPTION
|
||||
list all builds for your project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/build/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/build/list.ts)_
|
||||
|
||||
## `eas build:resign`
|
||||
|
||||
@@ -690,7 +714,7 @@ DESCRIPTION
|
||||
re-sign a build archive
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/build/resign.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/build/resign.ts)_
|
||||
|
||||
## `eas build:run`
|
||||
|
||||
@@ -718,7 +742,7 @@ DESCRIPTION
|
||||
run simulator/emulator builds from eas-cli
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/build/run.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/build/run.ts)_
|
||||
|
||||
## `eas build:submit`
|
||||
|
||||
@@ -770,7 +794,7 @@ DESCRIPTION
|
||||
get the latest version from EAS servers
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/build/version/get.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/build/version/get.ts)_
|
||||
|
||||
## `eas build:version:set`
|
||||
|
||||
@@ -788,7 +812,7 @@ DESCRIPTION
|
||||
update version of an app
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/build/version/set.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/build/version/set.ts)_
|
||||
|
||||
## `eas build:version:sync`
|
||||
|
||||
@@ -806,7 +830,7 @@ DESCRIPTION
|
||||
update a version in native code with a value stored on EAS servers
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/build/version/sync.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/build/version/sync.ts)_
|
||||
|
||||
## `eas build:view [BUILD_ID]`
|
||||
|
||||
@@ -823,7 +847,7 @@ DESCRIPTION
|
||||
view a build for your project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/build/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/build/view.ts)_
|
||||
|
||||
## `eas channel:create [NAME]`
|
||||
|
||||
@@ -844,7 +868,7 @@ DESCRIPTION
|
||||
create a channel
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/channel/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/channel/create.ts)_
|
||||
|
||||
## `eas channel:delete [NAME]`
|
||||
|
||||
@@ -865,7 +889,7 @@ DESCRIPTION
|
||||
Delete a channel
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/channel/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/channel/delete.ts)_
|
||||
|
||||
## `eas channel:edit [NAME]`
|
||||
|
||||
@@ -887,7 +911,7 @@ DESCRIPTION
|
||||
point a channel at a new branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/channel/edit.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/channel/edit.ts)_
|
||||
|
||||
## `eas channel:insights`
|
||||
|
||||
@@ -911,7 +935,7 @@ DESCRIPTION
|
||||
display adoption, crash, and unique-user insights for a channel + runtime version
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/insights.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/channel/insights.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/insights.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/channel/insights.ts)_
|
||||
|
||||
## `eas channel:list`
|
||||
|
||||
@@ -931,7 +955,7 @@ DESCRIPTION
|
||||
list all channels
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/channel/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/channel/list.ts)_
|
||||
|
||||
## `eas channel:pause [NAME]`
|
||||
|
||||
@@ -953,7 +977,7 @@ DESCRIPTION
|
||||
pause a channel to stop it from sending updates
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/pause.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/channel/pause.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/pause.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/channel/pause.ts)_
|
||||
|
||||
## `eas channel:resume [NAME]`
|
||||
|
||||
@@ -975,7 +999,7 @@ DESCRIPTION
|
||||
resume a channel to start sending updates
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/resume.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/channel/resume.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/resume.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/channel/resume.ts)_
|
||||
|
||||
## `eas channel:rollout [CHANNEL]`
|
||||
|
||||
@@ -1010,7 +1034,7 @@ DESCRIPTION
|
||||
Roll a new branch out on a channel incrementally.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/channel/rollout.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/channel/rollout.ts)_
|
||||
|
||||
## `eas channel:view [NAME]`
|
||||
|
||||
@@ -1033,7 +1057,7 @@ DESCRIPTION
|
||||
view a channel
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/channel/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/channel/view.ts)_
|
||||
|
||||
## `eas config`
|
||||
|
||||
@@ -1054,7 +1078,7 @@ DESCRIPTION
|
||||
display project configuration (app.json + eas.json)
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/config.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/config.ts)_
|
||||
|
||||
## `eas credentials`
|
||||
|
||||
@@ -1071,7 +1095,7 @@ DESCRIPTION
|
||||
manage credentials
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/credentials/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/credentials/index.ts)_
|
||||
|
||||
## `eas credentials:configure-build`
|
||||
|
||||
@@ -1089,7 +1113,7 @@ DESCRIPTION
|
||||
Set up credentials for building your project.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/credentials/configure-build.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/credentials/configure-build.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/credentials/configure-build.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/credentials/configure-build.ts)_
|
||||
|
||||
## `eas deploy [options]`
|
||||
|
||||
@@ -1099,9 +1123,12 @@ deploy your Expo Router web build and API Routes
|
||||
USAGE
|
||||
$ eas deploy [options]
|
||||
$ eas deploy --prod
|
||||
$ eas deploy --non-interactive --dev-domain my-app
|
||||
|
||||
FLAGS
|
||||
--alias=name Custom alias to assign to the new deployment.
|
||||
--dev-domain=name Custom preview URL subdomain to assign to the project on its first deployment, e.g. "my-app"
|
||||
for my-app.expo.app. Required with --non-interactive if you want to customize the preview URL.
|
||||
--dry-run Outputs a tarball of the new deployment instead of uploading it.
|
||||
--environment=<value> Environment variable's environment, e.g. 'production', 'preview', 'development'
|
||||
--export-dir=dir [default: dist] Directory where the Expo project was exported.
|
||||
@@ -1118,7 +1145,7 @@ ALIASES
|
||||
$ eas worker:deploy
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/deploy/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/deploy/index.ts)_
|
||||
|
||||
## `eas deploy:alias`
|
||||
|
||||
@@ -1143,7 +1170,7 @@ ALIASES
|
||||
$ eas deploy:promote
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/deploy/alias/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/deploy/alias/index.ts)_
|
||||
|
||||
## `eas deploy:alias:delete [ALIAS_NAME]`
|
||||
|
||||
@@ -1164,7 +1191,7 @@ ALIASES
|
||||
$ eas worker:alias:delete
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
|
||||
|
||||
## `eas deploy:delete [DEPLOYMENT_ID]`
|
||||
|
||||
@@ -1185,7 +1212,7 @@ ALIASES
|
||||
$ eas worker:delete
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/deploy/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/deploy/delete.ts)_
|
||||
|
||||
## `eas deploy:promote`
|
||||
|
||||
@@ -1222,7 +1249,7 @@ DESCRIPTION
|
||||
register new Apple Devices to use for internal distribution
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/device/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/device/create.ts)_
|
||||
|
||||
## `eas device:delete`
|
||||
|
||||
@@ -1242,7 +1269,7 @@ DESCRIPTION
|
||||
remove a registered device from your account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/device/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/device/delete.ts)_
|
||||
|
||||
## `eas device:list`
|
||||
|
||||
@@ -1263,7 +1290,7 @@ DESCRIPTION
|
||||
list all registered devices for your account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/device/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/device/list.ts)_
|
||||
|
||||
## `eas device:rename`
|
||||
|
||||
@@ -1284,7 +1311,7 @@ DESCRIPTION
|
||||
rename a registered device
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/device/rename.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/device/rename.ts)_
|
||||
|
||||
## `eas device:view [UDID]`
|
||||
|
||||
@@ -1298,7 +1325,7 @@ DESCRIPTION
|
||||
view a device for your project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/device/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/device/view.ts)_
|
||||
|
||||
## `eas diagnostics`
|
||||
|
||||
@@ -1312,7 +1339,7 @@ DESCRIPTION
|
||||
display environment info
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/diagnostics.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/diagnostics.ts)_
|
||||
|
||||
## `eas env:create [ENVIRONMENT]`
|
||||
|
||||
@@ -1344,7 +1371,7 @@ DESCRIPTION
|
||||
create an environment variable for the current project or account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/create.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/env/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/create.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/env/create.ts)_
|
||||
|
||||
## `eas env:delete [ENVIRONMENT]`
|
||||
|
||||
@@ -1370,7 +1397,7 @@ DESCRIPTION
|
||||
delete an environment variable for the current project or account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/delete.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/env/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/delete.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/env/delete.ts)_
|
||||
|
||||
## `eas env:exec ENVIRONMENT BASH_COMMAND`
|
||||
|
||||
@@ -1392,7 +1419,7 @@ DESCRIPTION
|
||||
execute a command with environment variables from the selected environment
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/exec.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/env/exec.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/exec.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/env/exec.ts)_
|
||||
|
||||
## `eas env:get [ENVIRONMENT]`
|
||||
|
||||
@@ -1420,7 +1447,7 @@ DESCRIPTION
|
||||
view an environment variable for the current project or account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/get.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/env/get.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/get.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/env/get.ts)_
|
||||
|
||||
## `eas env:list [ENVIRONMENT]`
|
||||
|
||||
@@ -1448,7 +1475,7 @@ DESCRIPTION
|
||||
list environment variables for the current project or account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/list.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/env/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/list.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/env/list.ts)_
|
||||
|
||||
## `eas env:pull [ENVIRONMENT]`
|
||||
|
||||
@@ -1471,7 +1498,7 @@ DESCRIPTION
|
||||
pull environment variables for the selected environment to .env file
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/pull.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/env/pull.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/pull.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/env/pull.ts)_
|
||||
|
||||
## `eas env:push [ENVIRONMENT]`
|
||||
|
||||
@@ -1493,7 +1520,7 @@ DESCRIPTION
|
||||
push environment variables from .env file to the selected environment
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/push.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/env/push.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/push.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/env/push.ts)_
|
||||
|
||||
## `eas env:update [ENVIRONMENT]`
|
||||
|
||||
@@ -1527,7 +1554,7 @@ DESCRIPTION
|
||||
update an environment variable on the current project or account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/update.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/env/update.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/update.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/env/update.ts)_
|
||||
|
||||
## `eas fingerprint:compare [HASH1] [HASH2]`
|
||||
|
||||
@@ -1571,7 +1598,7 @@ EXAMPLES
|
||||
$ eas fingerprint:compare <FINGERPRINT-HASH> --update-id <UPDATE-ID> # Compare fingerprint from update against provided fingerprint
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/fingerprint/compare.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/fingerprint/compare.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/fingerprint/compare.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/fingerprint/compare.ts)_
|
||||
|
||||
## `eas fingerprint:generate`
|
||||
|
||||
@@ -1602,7 +1629,7 @@ EXAMPLES
|
||||
$ eas fingerprint:generate --json --non-interactive --platform android # Output fingerprint json to stdout
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/fingerprint/generate.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/fingerprint/generate.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/fingerprint/generate.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/fingerprint/generate.ts)_
|
||||
|
||||
## `eas help [COMMAND]`
|
||||
|
||||
@@ -1665,7 +1692,7 @@ DESCRIPTION
|
||||
connect a project to an App Store Connect app
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/integrations/asc/connect.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/integrations/asc/connect.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/integrations/asc/connect.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/integrations/asc/connect.ts)_
|
||||
|
||||
## `eas integrations:asc:disconnect`
|
||||
|
||||
@@ -1684,7 +1711,7 @@ DESCRIPTION
|
||||
disconnect the current project from its App Store Connect app
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/integrations/asc/disconnect.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/integrations/asc/disconnect.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/integrations/asc/disconnect.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/integrations/asc/disconnect.ts)_
|
||||
|
||||
## `eas integrations:asc:status`
|
||||
|
||||
@@ -1702,7 +1729,7 @@ DESCRIPTION
|
||||
show the App Store Connect app link status for the current project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/integrations/asc/status.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/integrations/asc/status.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/integrations/asc/status.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/integrations/asc/status.ts)_
|
||||
|
||||
## `eas integrations:convex:connect`
|
||||
|
||||
@@ -1724,7 +1751,7 @@ DESCRIPTION
|
||||
connect Convex to your Expo project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/integrations/convex/connect.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/integrations/convex/connect.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/integrations/convex/connect.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/integrations/convex/connect.ts)_
|
||||
|
||||
## `eas integrations:convex:dashboard`
|
||||
|
||||
@@ -1738,7 +1765,7 @@ DESCRIPTION
|
||||
open the Convex dashboard for the linked Convex project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/integrations/convex/dashboard.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/integrations/convex/dashboard.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/integrations/convex/dashboard.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/integrations/convex/dashboard.ts)_
|
||||
|
||||
## `eas integrations:convex:project`
|
||||
|
||||
@@ -1752,7 +1779,7 @@ DESCRIPTION
|
||||
display the Convex project linked to the current Expo app
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/integrations/convex/project.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/integrations/convex/project.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/integrations/convex/project.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/integrations/convex/project.ts)_
|
||||
|
||||
## `eas integrations:convex:project:delete`
|
||||
|
||||
@@ -1770,7 +1797,7 @@ DESCRIPTION
|
||||
remove the Convex project link for the current Expo app from EAS servers
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/integrations/convex/project/delete.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/integrations/convex/project/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/integrations/convex/project/delete.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/integrations/convex/project/delete.ts)_
|
||||
|
||||
## `eas integrations:convex:team`
|
||||
|
||||
@@ -1784,7 +1811,7 @@ DESCRIPTION
|
||||
display Convex teams linked to the current Expo app's owner account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/integrations/convex/team.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/integrations/convex/team.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/integrations/convex/team.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/integrations/convex/team.ts)_
|
||||
|
||||
## `eas integrations:convex:team:delete [CONVEX_TEAM]`
|
||||
|
||||
@@ -1805,7 +1832,7 @@ DESCRIPTION
|
||||
remove a Convex team link from the current Expo app owner account's EAS servers
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/integrations/convex/team/delete.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/integrations/convex/team/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/integrations/convex/team/delete.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/integrations/convex/team/delete.ts)_
|
||||
|
||||
## `eas integrations:convex:team:invite [CONVEX_TEAM]`
|
||||
|
||||
@@ -1825,7 +1852,7 @@ DESCRIPTION
|
||||
send a Convex team invitation to your verified email address
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/integrations/convex/team/invite.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/integrations/convex/team/invite.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/integrations/convex/team/invite.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/integrations/convex/team/invite.ts)_
|
||||
|
||||
## `eas integrations:posthog:connect`
|
||||
|
||||
@@ -1852,7 +1879,7 @@ DESCRIPTION
|
||||
connect PostHog to your Expo project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/integrations/posthog/connect.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/integrations/posthog/connect.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/integrations/posthog/connect.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/integrations/posthog/connect.ts)_
|
||||
|
||||
## `eas integrations:posthog:dashboard`
|
||||
|
||||
@@ -1872,7 +1899,7 @@ DESCRIPTION
|
||||
open the PostHog dashboard for the linked PostHog project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/integrations/posthog/dashboard.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/integrations/posthog/dashboard.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/integrations/posthog/dashboard.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/integrations/posthog/dashboard.ts)_
|
||||
|
||||
## `eas integrations:posthog:disconnect`
|
||||
|
||||
@@ -1891,7 +1918,7 @@ DESCRIPTION
|
||||
remove the PostHog project link for the current Expo app from EAS servers
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/integrations/posthog/disconnect.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/integrations/posthog/disconnect.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/integrations/posthog/disconnect.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/integrations/posthog/disconnect.ts)_
|
||||
|
||||
## `eas login`
|
||||
|
||||
@@ -1943,7 +1970,7 @@ DESCRIPTION
|
||||
validate the local store configuration
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/metadata/lint.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/metadata/lint.ts)_
|
||||
|
||||
## `eas metadata:pull`
|
||||
|
||||
@@ -1961,7 +1988,7 @@ DESCRIPTION
|
||||
generate the local store configuration from the app stores
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/metadata/pull.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/metadata/pull.ts)_
|
||||
|
||||
## `eas metadata:push`
|
||||
|
||||
@@ -1979,7 +2006,7 @@ DESCRIPTION
|
||||
sync the local store configuration to the app stores
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/metadata/push.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/metadata/push.ts)_
|
||||
|
||||
## `eas new [PATH]`
|
||||
|
||||
@@ -2038,7 +2065,7 @@ DESCRIPTION
|
||||
arguments, a list of the available event names and associated event counts is returned.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/observe/events.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/observe/events.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/observe/events.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/observe/events.ts)_
|
||||
|
||||
## `eas observe:metrics [METRIC]`
|
||||
|
||||
@@ -2074,7 +2101,7 @@ DESCRIPTION
|
||||
display individual performance metric samples ordered by value
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/observe/metrics.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/observe/metrics.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/observe/metrics.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/observe/metrics.ts)_
|
||||
|
||||
## `eas observe:metrics-summary`
|
||||
|
||||
@@ -2106,7 +2133,7 @@ DESCRIPTION
|
||||
display aggregated performance metric statistics grouped by app version
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/observe/metrics-summary.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/observe/metrics-summary.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/observe/metrics-summary.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/observe/metrics-summary.ts)_
|
||||
|
||||
## `eas observe:routes`
|
||||
|
||||
@@ -2143,7 +2170,7 @@ DESCRIPTION
|
||||
display app navigation route metrics (Cold TTR, Warm TTR, TTI) grouped by route name
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/observe/routes.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/observe/routes.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/observe/routes.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/observe/routes.ts)_
|
||||
|
||||
## `eas observe:session SESSIONID`
|
||||
|
||||
@@ -2165,7 +2192,7 @@ DESCRIPTION
|
||||
display the timeline of metric and log events for a specific session
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/observe/session.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/observe/session.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/observe/session.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/observe/session.ts)_
|
||||
|
||||
## `eas observe:versions`
|
||||
|
||||
@@ -2190,21 +2217,31 @@ DESCRIPTION
|
||||
display app versions with build and update details
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/observe/versions.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/observe/versions.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/observe/versions.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/observe/versions.ts)_
|
||||
|
||||
## `eas open`
|
||||
## `eas project:delete [NAME]`
|
||||
|
||||
open the project page in a web browser
|
||||
delete a project
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas open
|
||||
$ eas project:delete [NAME] [--dangerously-confirm-deletion <value>] [--json] [--non-interactive]
|
||||
|
||||
ARGUMENTS
|
||||
[NAME] Full name (@account/slug) or ID of the project to delete. Defaults to the project in the current directory.
|
||||
|
||||
FLAGS
|
||||
--dangerously-confirm-deletion=<value> The project's full name (@account/slug), to confirm deletion. Required in
|
||||
non-interactive mode.
|
||||
--json Enable JSON output, non-JSON messages will be printed to stderr. Implies
|
||||
--non-interactive.
|
||||
--non-interactive Run the command in non-interactive mode.
|
||||
|
||||
DESCRIPTION
|
||||
open the project page in a web browser
|
||||
delete a project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/open.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/project/delete.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/project/delete.ts)_
|
||||
|
||||
## `eas project:info`
|
||||
|
||||
@@ -2218,7 +2255,7 @@ DESCRIPTION
|
||||
information about the current project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/project/info.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/project/info.ts)_
|
||||
|
||||
## `eas project:init`
|
||||
|
||||
@@ -2241,7 +2278,7 @@ ALIASES
|
||||
$ eas init
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/project/init.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/project/init.ts)_
|
||||
|
||||
## `eas project:new [PATH]`
|
||||
|
||||
@@ -2265,7 +2302,7 @@ ALIASES
|
||||
$ eas new
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/project/new.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/project/new.ts)_
|
||||
|
||||
## `eas submit`
|
||||
|
||||
@@ -2298,7 +2335,7 @@ ALIASES
|
||||
$ eas build:submit
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/submit.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/submit.ts)_
|
||||
|
||||
## `eas update`
|
||||
|
||||
@@ -2341,7 +2378,7 @@ DESCRIPTION
|
||||
publish an update group
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/update/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/update/index.ts)_
|
||||
|
||||
## `eas update:configure`
|
||||
|
||||
@@ -2362,7 +2399,7 @@ DESCRIPTION
|
||||
configure the project to support EAS Update
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/update/configure.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/update/configure.ts)_
|
||||
|
||||
## `eas update:delete GROUPID`
|
||||
|
||||
@@ -2383,7 +2420,7 @@ DESCRIPTION
|
||||
delete all the updates in an update group
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/update/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/update/delete.ts)_
|
||||
|
||||
## `eas update:edit [GROUPID]`
|
||||
|
||||
@@ -2408,7 +2445,7 @@ DESCRIPTION
|
||||
edit all the updates in an update group
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/edit.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/update/edit.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/edit.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/update/edit.ts)_
|
||||
|
||||
## `eas update:embedded:delete ID`
|
||||
|
||||
@@ -2429,7 +2466,7 @@ DESCRIPTION
|
||||
delete an embedded update registered with EAS Update
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/embedded/delete.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/update/embedded/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/embedded/delete.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/update/embedded/delete.ts)_
|
||||
|
||||
## `eas update:embedded:list`
|
||||
|
||||
@@ -2455,7 +2492,7 @@ DESCRIPTION
|
||||
list embedded updates registered with EAS Update for this project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/embedded/list.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/update/embedded/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/embedded/list.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/update/embedded/list.ts)_
|
||||
|
||||
## `eas update:embedded:upload`
|
||||
|
||||
@@ -2485,7 +2522,7 @@ EXAMPLES
|
||||
$ eas update:embedded:upload --platform android --bundle android/app/src/main/assets/index.android.bundle --manifest android/app/src/main/assets/app.manifest --channel production --build-id <BUILD-ID>
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/embedded/upload.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/update/embedded/upload.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/embedded/upload.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/update/embedded/upload.ts)_
|
||||
|
||||
## `eas update:embedded:view ID`
|
||||
|
||||
@@ -2505,7 +2542,7 @@ DESCRIPTION
|
||||
view details of an embedded update registered with EAS Update
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/embedded/view.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/update/embedded/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/embedded/view.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/update/embedded/view.ts)_
|
||||
|
||||
## `eas update:insights GROUPID`
|
||||
|
||||
@@ -2532,7 +2569,7 @@ DESCRIPTION
|
||||
display launch, crash, unique-user, and size insights for an update group
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/insights.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/update/insights.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/insights.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/update/insights.ts)_
|
||||
|
||||
## `eas update:list`
|
||||
|
||||
@@ -2559,7 +2596,7 @@ DESCRIPTION
|
||||
view the recent updates
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/update/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/update/list.ts)_
|
||||
|
||||
## `eas update:republish`
|
||||
|
||||
@@ -2597,7 +2634,7 @@ DESCRIPTION
|
||||
roll back to an existing update
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/update/republish.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/update/republish.ts)_
|
||||
|
||||
## `eas update:revert-update-rollout`
|
||||
|
||||
@@ -2625,7 +2662,7 @@ DESCRIPTION
|
||||
revert a rollout update for a project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/revert-update-rollout.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/revert-update-rollout.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
|
||||
|
||||
## `eas update:roll-back-to-embedded`
|
||||
|
||||
@@ -2655,7 +2692,7 @@ DESCRIPTION
|
||||
roll back to the embedded update
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/roll-back-to-embedded.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/roll-back-to-embedded.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
|
||||
|
||||
## `eas update:rollback [GROUPID]`
|
||||
|
||||
@@ -2687,7 +2724,7 @@ DESCRIPTION
|
||||
roll back to an embedded update or an existing update
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/rollback.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/update/rollback.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/rollback.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/update/rollback.ts)_
|
||||
|
||||
## `eas update:view GROUPID`
|
||||
|
||||
@@ -2711,7 +2748,7 @@ DESCRIPTION
|
||||
update group details
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/update/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/update/view.ts)_
|
||||
|
||||
## `eas upload`
|
||||
|
||||
@@ -2732,7 +2769,7 @@ DESCRIPTION
|
||||
upload a local build and generate a sharable link
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/upload.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/upload.ts)_
|
||||
|
||||
## `eas webhook:create`
|
||||
|
||||
@@ -2754,7 +2791,7 @@ DESCRIPTION
|
||||
create a webhook
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/webhook/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/webhook/create.ts)_
|
||||
|
||||
## `eas webhook:delete [ID]`
|
||||
|
||||
@@ -2774,7 +2811,7 @@ DESCRIPTION
|
||||
delete a webhook
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/webhook/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/webhook/delete.ts)_
|
||||
|
||||
## `eas webhook:list`
|
||||
|
||||
@@ -2793,7 +2830,7 @@ DESCRIPTION
|
||||
list webhooks
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/webhook/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/webhook/list.ts)_
|
||||
|
||||
## `eas webhook:update`
|
||||
|
||||
@@ -2816,7 +2853,7 @@ DESCRIPTION
|
||||
update a webhook
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/webhook/update.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/webhook/update.ts)_
|
||||
|
||||
## `eas webhook:view ID`
|
||||
|
||||
@@ -2833,7 +2870,7 @@ DESCRIPTION
|
||||
view a webhook
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/webhook/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/webhook/view.ts)_
|
||||
|
||||
## `eas whoami`
|
||||
|
||||
@@ -2919,9 +2956,12 @@ deploy your Expo Router web build and API Routes
|
||||
USAGE
|
||||
$ eas worker:deploy deploy [options]
|
||||
$ eas worker:deploy deploy --prod
|
||||
$ eas worker:deploy deploy --non-interactive --dev-domain my-app
|
||||
|
||||
FLAGS
|
||||
--alias=name Custom alias to assign to the new deployment.
|
||||
--dev-domain=name Custom preview URL subdomain to assign to the project on its first deployment, e.g. "my-app"
|
||||
for my-app.expo.app. Required with --non-interactive if you want to customize the preview URL.
|
||||
--dry-run Outputs a tarball of the new deployment instead of uploading it.
|
||||
--environment=<value> Environment variable's environment, e.g. 'production', 'preview', 'development'
|
||||
--export-dir=dir [default: dist] Directory where the Expo project was exported.
|
||||
@@ -2954,7 +2994,7 @@ DESCRIPTION
|
||||
to cancel.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/workflow/cancel.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/workflow/cancel.ts)_
|
||||
|
||||
## `eas workflow:create [NAME]`
|
||||
|
||||
@@ -2976,7 +3016,7 @@ DESCRIPTION
|
||||
create a new workflow configuration YAML file
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/create.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/workflow/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/create.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/workflow/create.ts)_
|
||||
|
||||
## `eas workflow:logs [ID]`
|
||||
|
||||
@@ -3000,7 +3040,7 @@ DESCRIPTION
|
||||
If no ID is passed in, you will be prompted to select from recent workflow runs for the current project.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/logs.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/workflow/logs.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/logs.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/workflow/logs.ts)_
|
||||
|
||||
## `eas workflow:run FILE`
|
||||
|
||||
@@ -3040,7 +3080,7 @@ FLAG DESCRIPTIONS
|
||||
Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/run.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/workflow/run.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/run.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/workflow/run.ts)_
|
||||
|
||||
## `eas workflow:runs`
|
||||
|
||||
@@ -3062,7 +3102,7 @@ DESCRIPTION
|
||||
list recent workflow runs for this project, with their IDs, statuses, and timestamps
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/runs.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/workflow/runs.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/runs.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/workflow/runs.ts)_
|
||||
|
||||
## `eas workflow:status [WORKFLOW_RUN_ID]`
|
||||
|
||||
@@ -3090,7 +3130,7 @@ FLAG DESCRIPTIONS
|
||||
Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/status.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/workflow/status.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/status.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/workflow/status.ts)_
|
||||
|
||||
## `eas workflow:validate PATH`
|
||||
|
||||
@@ -3110,7 +3150,7 @@ DESCRIPTION
|
||||
validate a workflow configuration yaml file
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/validate.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/workflow/validate.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/validate.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/workflow/validate.ts)_
|
||||
|
||||
## `eas workflow:view [ID]`
|
||||
|
||||
@@ -3132,5 +3172,5 @@ DESCRIPTION
|
||||
workflow runs for the current project.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/view.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/workflow/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/view.ts](https://github.com/expo/eas-cli/blob/v21.0.2/packages/eas-cli/src/commands/workflow/view.ts)_
|
||||
<!-- commandsstop -->
|
||||
Generated
+35
-39
@@ -58471,7 +58471,7 @@
|
||||
},
|
||||
{
|
||||
"name": "setPreferences",
|
||||
"description": "Sets user preferences. This is a key-value store for user-specific settings. Provided values are\nkey-level merged with existing values.",
|
||||
"description": "Legacy user preferences are no longer stored; this mutation accepts and discards\nits input. Use userPreference.set instead.",
|
||||
"args": [
|
||||
{
|
||||
"name": "preferences",
|
||||
@@ -58499,8 +58499,8 @@
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
"isDeprecated": true,
|
||||
"deprecationReason": "No longer stored; this mutation has no effect. Use userPreference.set instead."
|
||||
},
|
||||
{
|
||||
"name": "setPrimarySecondFactorDevice",
|
||||
@@ -66775,8 +66775,8 @@
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
"isDeprecated": true,
|
||||
"deprecationReason": "No longer stored. Use userPreference instead."
|
||||
},
|
||||
{
|
||||
"name": "primaryAccount",
|
||||
@@ -67765,16 +67765,12 @@
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
}
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
"isDeprecated": true,
|
||||
"deprecationReason": "Use previewUrl instead."
|
||||
}
|
||||
],
|
||||
"inputFields": null,
|
||||
@@ -76602,8 +76598,8 @@
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
"isDeprecated": true,
|
||||
"deprecationReason": "No longer stored. Use userPreference instead."
|
||||
},
|
||||
{
|
||||
"name": "primaryAccount",
|
||||
@@ -77305,8 +77301,8 @@
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
"isDeprecated": true,
|
||||
"deprecationReason": "No longer stored. Use userPreference instead."
|
||||
},
|
||||
{
|
||||
"name": "primaryAccount",
|
||||
@@ -80045,20 +80041,8 @@
|
||||
"name": "UserPreferencesOnboarding",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "selectedAccountName",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": true,
|
||||
"deprecationReason": "Use userPreference.get(key: \"selected-account-id\") instead."
|
||||
"deprecationReason": "No longer stored; always null."
|
||||
}
|
||||
],
|
||||
"inputFields": null,
|
||||
@@ -80112,7 +80096,7 @@
|
||||
},
|
||||
{
|
||||
"name": "deviceType",
|
||||
"description": "Can be null if the user has not selected one yet.",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "ENUM",
|
||||
@@ -80124,7 +80108,7 @@
|
||||
},
|
||||
{
|
||||
"name": "environment",
|
||||
"description": "Can be null if the user has not selected one yet.",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "ENUM",
|
||||
@@ -80136,7 +80120,7 @@
|
||||
},
|
||||
{
|
||||
"name": "isCLIDone",
|
||||
"description": "Set by CLI when the user has completed that phase. Used by the website to determine when\nthe next step can be shown.",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
@@ -80148,7 +80132,7 @@
|
||||
},
|
||||
{
|
||||
"name": "lastUsed",
|
||||
"description": "The last time when this object was updated.",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
@@ -80164,7 +80148,7 @@
|
||||
},
|
||||
{
|
||||
"name": "platform",
|
||||
"description": "User selects a platform for which they want to build the app. CLI uses this information to start the build.",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "ENUM",
|
||||
@@ -88995,6 +88979,18 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "reviewingActor",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "INTERFACE",
|
||||
"name": "Actor",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "updatedAt",
|
||||
"description": null,
|
||||
@@ -89020,8 +89016,8 @@
|
||||
"name": "UserActor",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
"isDeprecated": true,
|
||||
"deprecationReason": "Use reviewingActor instead"
|
||||
},
|
||||
{
|
||||
"name": "workflowJob",
|
||||
@@ -93115,4 +93111,4 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "21.0.0",
|
||||
"version": "21.0.2",
|
||||
"description": "EAS command line tool",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -48,8 +48,8 @@
|
||||
"@expo/code-signing-certificates": "0.0.5",
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/eas-build-job": "21.0.0",
|
||||
"@expo/eas-json": "21.0.0",
|
||||
"@expo/eas-build-job": "21.0.2",
|
||||
"@expo/eas-json": "21.0.2",
|
||||
"@expo/env": "^1.0.0",
|
||||
"@expo/json-file": "8.3.3",
|
||||
"@expo/logger": "21.0.0",
|
||||
@@ -57,14 +57,14 @@
|
||||
"@expo/osascript": "2.1.4",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
"@expo/pkcs12": "0.1.3",
|
||||
"@expo/plist": "0.2.0",
|
||||
"@expo/plist": "0.3.5",
|
||||
"@expo/plugin-help": "5.1.23",
|
||||
"@expo/plugin-warn-if-update-available": "2.5.1",
|
||||
"@expo/prebuild-config": "8.0.17",
|
||||
"@expo/prebuild-config": "9.0.12",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "21.0.0",
|
||||
"@expo/steps": "21.0.2",
|
||||
"@expo/timeago.js": "1.0.0",
|
||||
"@oclif/core": "^4.8.3",
|
||||
"@oclif/plugin-autocomplete": "^3.2.40",
|
||||
@@ -121,7 +121,7 @@
|
||||
"semver": "7.5.4",
|
||||
"set-interval-async": "3.0.3",
|
||||
"slash": "3.0.0",
|
||||
"tar": "7.5.7",
|
||||
"tar": "7.5.19",
|
||||
"tar-stream": "3.1.7",
|
||||
"terminal-link": "2.1.1",
|
||||
"ts-deepmerge": "6.2.0",
|
||||
@@ -134,17 +134,16 @@
|
||||
"zod": "^4.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@graphql-codegen/cli": "5.0.0",
|
||||
"@graphql-codegen/introspection": "4.0.0",
|
||||
"@graphql-codegen/typescript": "4.0.1",
|
||||
"@graphql-codegen/typescript-operations": "4.0.1",
|
||||
"@graphql-codegen/cli": "6.3.1",
|
||||
"@graphql-codegen/introspection": "5.0.2",
|
||||
"@graphql-codegen/typescript": "5.0.10",
|
||||
"@graphql-codegen/typescript-operations": "5.1.0",
|
||||
"@tsconfig/node20": "20.1.8",
|
||||
"@types/cli-progress": "3.11.5",
|
||||
"@types/dateformat": "3.0.1",
|
||||
"@types/debug": "4.1.13",
|
||||
"@types/diff": "6.0.0",
|
||||
"@types/envinfo": "7.8.3",
|
||||
"@types/express": "4.17.15",
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"@types/getenv": "^1.0.0",
|
||||
"@types/invariant": "^2.2.37",
|
||||
@@ -160,9 +159,8 @@
|
||||
"@types/tough-cookie": "4.0.2",
|
||||
"@types/uuid": "9.0.7",
|
||||
"@types/wrap-ansi": "3.0.0",
|
||||
"axios": "1.11.0",
|
||||
"axios": "1.18.1",
|
||||
"eslint-plugin-graphql": "4.0.0",
|
||||
"express": "4.20.0",
|
||||
"jest": "29.7.0",
|
||||
"memfs": "3.4.13",
|
||||
"mockdate": "3.0.5",
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { getProjectDashboardUrl, getProjectPageUrl } from '../url';
|
||||
|
||||
describe(getProjectPageUrl, () => {
|
||||
it('builds the project dashboard URL when no page is provided', () => {
|
||||
expect(getProjectPageUrl('testuser', 'testapp')).toBe(
|
||||
'https://expo.dev/accounts/testuser/projects/testapp'
|
||||
);
|
||||
});
|
||||
|
||||
it('appends the page path when provided', () => {
|
||||
expect(getProjectPageUrl('testuser', 'testapp', 'builds')).toBe(
|
||||
'https://expo.dev/accounts/testuser/projects/testapp/builds'
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves nested page paths without encoding the separator', () => {
|
||||
expect(getProjectPageUrl('testuser', 'testapp', 'hosting/deployments')).toBe(
|
||||
'https://expo.dev/accounts/testuser/projects/testapp/hosting/deployments'
|
||||
);
|
||||
});
|
||||
|
||||
it('encodes account and project names', () => {
|
||||
expect(getProjectPageUrl('my org', 'my app', 'updates')).toBe(
|
||||
'https://expo.dev/accounts/my%20org/projects/my%20app/updates'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(getProjectDashboardUrl, () => {
|
||||
it('matches the page URL with no page', () => {
|
||||
expect(getProjectDashboardUrl('testuser', 'testapp')).toBe(
|
||||
getProjectPageUrl('testuser', 'testapp')
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -4,10 +4,18 @@ import { getExpoApiBaseUrl, getExpoWebsiteBaseUrl } from '../../api';
|
||||
import { AppPlatform, BuildFragment } from '../../graphql/generated';
|
||||
|
||||
export function getProjectDashboardUrl(accountName: string, projectName: string): string {
|
||||
return new URL(
|
||||
`/accounts/${accountName}/projects/${projectName}`,
|
||||
getExpoWebsiteBaseUrl()
|
||||
).toString();
|
||||
return getProjectPageUrl(accountName, projectName);
|
||||
}
|
||||
|
||||
export function getProjectPageUrl(
|
||||
accountName: string,
|
||||
projectName: string,
|
||||
page?: string | null
|
||||
): string {
|
||||
const projectPath = `/accounts/${encodeURIComponent(accountName)}/projects/${encodeURIComponent(
|
||||
projectName
|
||||
)}`;
|
||||
return new URL(page ? `${projectPath}/${page}` : projectPath, getExpoWebsiteBaseUrl()).toString();
|
||||
}
|
||||
|
||||
export function getBuildLogsUrl(build: BuildFragment, hash?: string): string {
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import openBrowserAsync from 'better-opn';
|
||||
|
||||
import { getMockOclifConfig } from '../../__tests__/commands/utils';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { testProjectId } from '../../credentials/__tests__/fixtures-constants';
|
||||
import { AccountFragment } from '../../graphql/generated';
|
||||
import Log from '../../log';
|
||||
import { ora } from '../../ora';
|
||||
import { getOwnerAccountForProjectIdAsync } from '../../project/projectUtils';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import Browse from '../browse';
|
||||
|
||||
jest.mock('better-opn');
|
||||
jest.mock('../../log');
|
||||
jest.mock('../../project/projectUtils');
|
||||
jest.mock('../../utils/json');
|
||||
jest.mock('../../ora', () => ({
|
||||
ora: jest.fn(() => ({
|
||||
start: jest.fn().mockReturnThis(),
|
||||
succeed: jest.fn().mockReturnThis(),
|
||||
fail: jest.fn().mockReturnThis(),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe(Browse, () => {
|
||||
const graphqlClient = {} as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
|
||||
function createCommand(argv: string[]): Browse {
|
||||
const command = new Browse(argv, mockConfig);
|
||||
jest.spyOn(command as any, 'getContextAsync').mockReturnValue({
|
||||
privateProjectConfig: {
|
||||
projectId: testProjectId,
|
||||
exp: { slug: 'testapp' },
|
||||
},
|
||||
loggedIn: { graphqlClient },
|
||||
} as never);
|
||||
return command;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest
|
||||
.mocked(getOwnerAccountForProjectIdAsync)
|
||||
.mockResolvedValue({ name: 'testuser' } as AccountFragment);
|
||||
jest.mocked(openBrowserAsync).mockResolvedValue({} as never);
|
||||
jest.mocked(ora).mockReturnValue({
|
||||
start: jest.fn().mockReturnThis(),
|
||||
succeed: jest.fn().mockReturnThis(),
|
||||
fail: jest.fn().mockReturnThis(),
|
||||
} as any);
|
||||
});
|
||||
|
||||
it('opens the project dashboard when no page is provided', async () => {
|
||||
await createCommand([]).runAsync();
|
||||
|
||||
expect(openBrowserAsync).toHaveBeenCalledWith(
|
||||
'https://expo.dev/accounts/testuser/projects/testapp'
|
||||
);
|
||||
});
|
||||
|
||||
it('opens a subpage by its EAS command name', async () => {
|
||||
await createCommand(['build']).runAsync();
|
||||
|
||||
expect(openBrowserAsync).toHaveBeenCalledWith(
|
||||
'https://expo.dev/accounts/testuser/projects/testapp/builds'
|
||||
);
|
||||
});
|
||||
|
||||
it('maps the website wording to the same subpage', async () => {
|
||||
await createCommand(['submissions']).runAsync();
|
||||
|
||||
expect(openBrowserAsync).toHaveBeenCalledWith(
|
||||
'https://expo.dev/accounts/testuser/projects/testapp/submissions'
|
||||
);
|
||||
});
|
||||
|
||||
it('opens the observe page', async () => {
|
||||
await createCommand(['observe']).runAsync();
|
||||
|
||||
expect(openBrowserAsync).toHaveBeenCalledWith(
|
||||
'https://expo.dev/accounts/testuser/projects/testapp/observe'
|
||||
);
|
||||
});
|
||||
|
||||
it('prints the URL without opening a browser when --no-browser is passed', async () => {
|
||||
await createCommand(['hosting', '--no-browser']).runAsync();
|
||||
|
||||
expect(openBrowserAsync).not.toHaveBeenCalled();
|
||||
expect(Log.log).toHaveBeenCalledWith(
|
||||
'https://expo.dev/accounts/testuser/projects/testapp/hosting'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an unknown page', async () => {
|
||||
await expect(createCommand(['nope']).runAsync()).rejects.toThrow();
|
||||
expect(openBrowserAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forwards non-interactive mode to the context so it can run without prompts', async () => {
|
||||
const command = createCommand(['--non-interactive']);
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect((command as any).getContextAsync).toHaveBeenCalledWith(
|
||||
Browse,
|
||||
expect.objectContaining({ nonInteractive: true })
|
||||
);
|
||||
});
|
||||
|
||||
it('prints the URL as JSON without opening a browser when --json is passed', async () => {
|
||||
const command = createCommand(['build', '--json']);
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(enableJsonOutput).toHaveBeenCalled();
|
||||
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
url: 'https://expo.dev/accounts/testuser/projects/testapp/builds',
|
||||
});
|
||||
expect(openBrowserAsync).not.toHaveBeenCalled();
|
||||
expect((command as any).getContextAsync).toHaveBeenCalledWith(
|
||||
Browse,
|
||||
expect.objectContaining({ nonInteractive: true })
|
||||
);
|
||||
});
|
||||
|
||||
it('fails the spinner when the browser cannot be opened', async () => {
|
||||
jest.mocked(openBrowserAsync).mockResolvedValue(false);
|
||||
const spinner = {
|
||||
start: jest.fn().mockReturnThis(),
|
||||
succeed: jest.fn().mockReturnThis(),
|
||||
fail: jest.fn().mockReturnThis(),
|
||||
};
|
||||
jest.mocked(ora).mockReturnValue(spinner as any);
|
||||
|
||||
await createCommand([]).runAsync();
|
||||
|
||||
expect(spinner.fail).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Unable to open a web browser')
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Args, Flags } from '@oclif/core';
|
||||
import openBrowserAsync from 'better-opn';
|
||||
|
||||
import { getProjectPageUrl } from '../build/utils/url';
|
||||
import EasCommand from '../commandUtils/EasCommand';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../commandUtils/flags';
|
||||
import Log from '../log';
|
||||
import { ora } from '../ora';
|
||||
import { getOwnerAccountForProjectIdAsync } from '../project/projectUtils';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../utils/json';
|
||||
|
||||
const PROJECT_PAGES: Record<string, string> = {
|
||||
build: 'builds',
|
||||
builds: 'builds',
|
||||
submit: 'submissions',
|
||||
submissions: 'submissions',
|
||||
update: 'updates',
|
||||
updates: 'updates',
|
||||
workflow: 'workflows',
|
||||
workflows: 'workflows',
|
||||
cicd: 'workflows',
|
||||
hosting: 'hosting',
|
||||
deployments: 'hosting/deployments',
|
||||
credentials: 'credentials',
|
||||
env: 'environment-variables',
|
||||
insights: 'insights',
|
||||
observe: 'observe',
|
||||
settings: 'settings',
|
||||
};
|
||||
|
||||
export default class Browse extends EasCommand {
|
||||
static override description =
|
||||
'Transition from the terminal to the web browser to view and interact with your project on https://expo.dev';
|
||||
|
||||
static override args = {
|
||||
page: Args.string({
|
||||
description: 'Project subpage to open. Defaults to the project dashboard.',
|
||||
required: false,
|
||||
options: Object.keys(PROJECT_PAGES),
|
||||
}),
|
||||
};
|
||||
|
||||
static override flags = {
|
||||
'no-browser': Flags.boolean({
|
||||
char: 'n',
|
||||
description: 'Print the URL instead of opening it in a web browser',
|
||||
default: false,
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectConfig,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { args, flags } = await this.parse(Browse);
|
||||
const page = args.page ? PROJECT_PAGES[args.page] : null;
|
||||
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
if (jsonFlag) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
const {
|
||||
privateProjectConfig: { projectId, exp },
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(Browse, {
|
||||
nonInteractive,
|
||||
withServerSideEnvironment: null,
|
||||
});
|
||||
|
||||
const account = await getOwnerAccountForProjectIdAsync(graphqlClient, projectId);
|
||||
const url = getProjectPageUrl(account.name, exp.slug, page);
|
||||
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput({ url });
|
||||
return;
|
||||
}
|
||||
|
||||
if (flags['no-browser']) {
|
||||
Log.log(url);
|
||||
return;
|
||||
}
|
||||
|
||||
const failedMessage = `Unable to open a web browser. Project page is available at: ${url}`;
|
||||
const spinner = ora(`Opening ${url}`).start();
|
||||
try {
|
||||
const opened = await openBrowserAsync(url);
|
||||
|
||||
if (opened) {
|
||||
spinner.succeed(`Opened ${url}`);
|
||||
} else {
|
||||
spinner.fail(failedMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
spinner.fail(failedMessage);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
|
||||
import { runBuildAndSubmitAsync } from '../../../build/runBuildAndSubmit';
|
||||
import BuildInspect from '../inspect';
|
||||
|
||||
jest.mock('fs-extra', () => ({
|
||||
pathExists: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
mkdirp: jest.fn(),
|
||||
copy: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../build/runBuildAndSubmit', () => ({
|
||||
runBuildAndSubmitAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../ora', () => ({
|
||||
ora: jest.fn(() => ({
|
||||
start: jest.fn(() => ({
|
||||
succeed: jest.fn(),
|
||||
fail: jest.fn(),
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
jest.mock('../../../utils/paths', () => ({
|
||||
...jest.requireActual('../../../utils/paths'),
|
||||
getTmpDirectory: jest.fn(() => '/tmp/eas-cli'),
|
||||
}));
|
||||
jest.mock('uuid', () => ({
|
||||
v4: jest.fn(() => 'tmp-id'),
|
||||
}));
|
||||
|
||||
describe(BuildInspect, () => {
|
||||
const mockConfig = getMockOclifConfig();
|
||||
const tmpWorkingdir = '/tmp/eas-cli/tmp-id';
|
||||
const tmpBuildDir = path.join(tmpWorkingdir, 'build');
|
||||
const outputDirectory = path.join(process.cwd(), 'inspect-output');
|
||||
const mockPathExists = fs.pathExists as jest.Mock;
|
||||
const mockMkdirp = fs.mkdirp as jest.Mock;
|
||||
const mockCopy = fs.copy as jest.Mock;
|
||||
const mockRemove = fs.remove as jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockPathExists.mockResolvedValue(false);
|
||||
mockMkdirp.mockResolvedValue(undefined);
|
||||
mockCopy.mockResolvedValue(undefined);
|
||||
mockRemove.mockResolvedValue(undefined);
|
||||
jest.mocked(runBuildAndSubmitAsync).mockResolvedValue({ buildIds: [] });
|
||||
});
|
||||
|
||||
function createCommand(): BuildInspect {
|
||||
const command = new BuildInspect(
|
||||
['--platform', 'ios', '--stage', 'pre-build', '--output', 'inspect-output'],
|
||||
mockConfig
|
||||
);
|
||||
jest.spyOn(command as any, 'getContextAsync').mockResolvedValue({
|
||||
loggedIn: {
|
||||
actor: {},
|
||||
graphqlClient: {},
|
||||
},
|
||||
getDynamicPrivateProjectConfigAsync: jest.fn(),
|
||||
projectDir: '/project',
|
||||
analytics: {},
|
||||
vcsClient: {},
|
||||
} as any);
|
||||
return command;
|
||||
}
|
||||
|
||||
it('removes the temporary working directory after copying output when the build fails', async () => {
|
||||
jest.mocked(runBuildAndSubmitAsync).mockRejectedValue(new Error('build failed'));
|
||||
mockPathExists.mockResolvedValueOnce(false).mockResolvedValueOnce(true);
|
||||
|
||||
await createCommand().runAsync();
|
||||
|
||||
expect(fs.copy).toHaveBeenCalledWith(tmpBuildDir, outputDirectory);
|
||||
expect(fs.remove).toHaveBeenCalledWith(tmpBuildDir);
|
||||
expect(fs.remove).toHaveBeenCalledWith(tmpWorkingdir);
|
||||
});
|
||||
|
||||
it('keeps the temporary working directory when copying output fails', async () => {
|
||||
jest.mocked(runBuildAndSubmitAsync).mockRejectedValue(new Error('build failed'));
|
||||
mockPathExists.mockResolvedValueOnce(false).mockResolvedValueOnce(true);
|
||||
mockCopy.mockRejectedValue(new Error('copy failed'));
|
||||
|
||||
await expect(createCommand().runAsync()).rejects.toThrow('copy failed');
|
||||
|
||||
expect(fs.remove).not.toHaveBeenCalledWith(tmpBuildDir);
|
||||
expect(fs.remove).not.toHaveBeenCalledWith(tmpWorkingdir);
|
||||
});
|
||||
});
|
||||
@@ -135,6 +135,7 @@ export default class BuildInspect extends EasCommand {
|
||||
}
|
||||
} finally {
|
||||
await this.copyToOutputDirAsync(path.join(tmpWorkingdir, 'build'), outputDirectory);
|
||||
await this.cleanUpTmpWorkingdirAsync(tmpWorkingdir);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -163,4 +164,13 @@ export default class BuildInspect extends EasCommand {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async cleanUpTmpWorkingdirAsync(tmpWorkingdir: string): Promise<void> {
|
||||
try {
|
||||
await fs.remove(tmpWorkingdir);
|
||||
} catch (err) {
|
||||
Log.warn(`Failed to remove temporary directory ${tmpWorkingdir}`);
|
||||
Log.debug(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { getOwnerAccountForProjectIdAsync } from '../../project/projectUtils';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import * as WorkerAssets from '../../worker/assets';
|
||||
import {
|
||||
assertValidDevDomainName,
|
||||
assignWorkerDeploymentAliasAsync,
|
||||
assignWorkerDeploymentProductionAsync,
|
||||
getSignedDeploymentUrlAsync,
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
uploadAsync,
|
||||
} from '../../worker/upload';
|
||||
import {
|
||||
EXPO_BASE_DOMAIN,
|
||||
formatWorkerDeploymentJson,
|
||||
formatWorkerDeploymentTable,
|
||||
getDeploymentUrlFromFullName,
|
||||
@@ -50,6 +52,7 @@ interface DeployFlags {
|
||||
aliasName?: string;
|
||||
environment?: string;
|
||||
deploymentIdentifier?: string;
|
||||
devDomainName?: string;
|
||||
exportDir: string;
|
||||
dryRun: boolean;
|
||||
sourceMaps: boolean;
|
||||
@@ -62,6 +65,7 @@ interface RawDeployFlags {
|
||||
prod: boolean;
|
||||
alias?: string;
|
||||
id?: string;
|
||||
'dev-domain'?: string;
|
||||
'export-dir': string;
|
||||
'dry-run': boolean;
|
||||
'source-maps': boolean;
|
||||
@@ -82,7 +86,11 @@ interface DeployInProgressParams {
|
||||
export default class WorkerDeploy extends EasCommand {
|
||||
static override description = 'deploy your Expo Router web build and API Routes';
|
||||
static override aliases = ['worker:deploy'];
|
||||
static override usage = [chalk`deploy {dim [options]}`, `deploy --prod`];
|
||||
static override usage = [
|
||||
chalk`deploy {dim [options]}`,
|
||||
`deploy --prod`,
|
||||
`deploy --non-interactive --dev-domain my-app`,
|
||||
];
|
||||
static override state = 'preview';
|
||||
|
||||
static override flags = {
|
||||
@@ -99,6 +107,10 @@ export default class WorkerDeploy extends EasCommand {
|
||||
description: 'Custom unique identifier for the new deployment.',
|
||||
helpValue: 'xyz123',
|
||||
}),
|
||||
'dev-domain': Flags.string({
|
||||
description: `Custom preview URL subdomain to assign to the project on its first deployment, e.g. "my-app" for my-app.${EXPO_BASE_DOMAIN}.app. Required with --non-interactive if you want to customize the preview URL.`,
|
||||
helpValue: 'name',
|
||||
}),
|
||||
'export-dir': Flags.string({
|
||||
description: 'Directory where the Expo project was exported.',
|
||||
helpValue: 'dir',
|
||||
@@ -322,6 +334,7 @@ export default class WorkerDeploy extends EasCommand {
|
||||
const uploadUrl = await getSignedDeploymentUrlAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
deploymentIdentifier: flags.deploymentIdentifier,
|
||||
devDomainName: flags.devDomainName,
|
||||
// NOTE(cedric): this function might ask the user for a dev-domain name,
|
||||
// when that happens, no ora spinner should be running.
|
||||
onSetupDevDomain: () => progress.stop(),
|
||||
@@ -467,12 +480,19 @@ export default class WorkerDeploy extends EasCommand {
|
||||
|
||||
private sanitizeFlags(flags: RawDeployFlags): DeployFlags {
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
|
||||
const devDomainName = flags['dev-domain']?.trim().toLowerCase();
|
||||
if (devDomainName !== undefined) {
|
||||
assertValidDevDomainName(devDomainName);
|
||||
}
|
||||
|
||||
return {
|
||||
nonInteractive,
|
||||
json,
|
||||
isProduction: !!flags.prod,
|
||||
aliasName: flags.alias?.trim().toLowerCase(),
|
||||
deploymentIdentifier: flags.id?.trim(),
|
||||
devDomainName,
|
||||
exportDir: flags['export-dir'],
|
||||
environment: flags['environment'],
|
||||
dryRun: flags['dry-run'],
|
||||
|
||||
@@ -1,46 +1,12 @@
|
||||
import openBrowserAsync from 'better-opn';
|
||||
|
||||
import { getProjectDashboardUrl } from '../build/utils/url';
|
||||
import EasCommand from '../commandUtils/EasCommand';
|
||||
import { ora } from '../ora';
|
||||
import { getOwnerAccountForProjectIdAsync } from '../project/projectUtils';
|
||||
import Browse from './browse';
|
||||
|
||||
export default class Open extends EasCommand {
|
||||
static override description = 'open the project page in a web browser';
|
||||
static override description = 'open the project page in a web browser (alias of `eas browse`)';
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectConfig,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
static override hidden = true;
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
// this command is interactive by nature (only really run by humans in a terminal)
|
||||
const {
|
||||
privateProjectConfig: { projectId, exp },
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(Open, {
|
||||
nonInteractive: false,
|
||||
withServerSideEnvironment: null,
|
||||
});
|
||||
|
||||
const account = await getOwnerAccountForProjectIdAsync(graphqlClient, projectId);
|
||||
|
||||
const projectName = exp.slug;
|
||||
|
||||
const projectDashboardUrl = getProjectDashboardUrl(account.name, projectName);
|
||||
const failedMessage = `Unable to open a web browser. Project page is available at: ${projectDashboardUrl}`;
|
||||
const spinner = ora(`Opening ${projectDashboardUrl}`).start();
|
||||
try {
|
||||
const opened = await openBrowserAsync(projectDashboardUrl);
|
||||
|
||||
if (opened) {
|
||||
spinner.succeed(`Opened ${projectDashboardUrl}`);
|
||||
} else {
|
||||
spinner.fail(failedMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
spinner.fail(failedMessage);
|
||||
throw error;
|
||||
}
|
||||
await Browse.run([]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import { getConfigFilePaths } from '@expo/config';
|
||||
|
||||
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { findProjectRootAsync } from '../../../commandUtils/context/contextUtils/findProjectDirAndVerifyProjectSetupAsync';
|
||||
import {
|
||||
testAppQueryByIdResponse,
|
||||
testExperienceName,
|
||||
testProjectId,
|
||||
} from '../../../credentials/__tests__/fixtures-constants';
|
||||
import {
|
||||
BackgroundJobReceiptDataFragment,
|
||||
BackgroundJobResultType,
|
||||
BackgroundJobState,
|
||||
} from '../../../graphql/generated';
|
||||
import { AppMutation } from '../../../graphql/mutations/AppMutation';
|
||||
import { AppQuery } from '../../../graphql/queries/AppQuery';
|
||||
import Log from '../../../log';
|
||||
import { getPrivateExpoConfigAsync } from '../../../project/expoConfig';
|
||||
import { promptAsync } from '../../../prompts';
|
||||
import { isSudoModeRequiredError, promptForSudoModeUpgradeAsync } from '../../../user/sudo';
|
||||
import { pollForBackgroundJobReceiptAsync } from '../../../utils/pollForBackgroundJobReceiptAsync';
|
||||
import ProjectDelete from '../delete';
|
||||
|
||||
jest.mock('../../../graphql/queries/AppQuery');
|
||||
jest.mock('../../../graphql/mutations/AppMutation');
|
||||
jest.mock('../../../utils/pollForBackgroundJobReceiptAsync');
|
||||
jest.mock('../../../prompts');
|
||||
jest.mock('../../../user/sudo');
|
||||
jest.mock('../../../commandUtils/context/contextUtils/findProjectDirAndVerifyProjectSetupAsync');
|
||||
jest.mock('../../../project/expoConfig');
|
||||
jest.mock('@expo/config');
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../ora', () => ({
|
||||
ora: () => ({
|
||||
start: jest.fn().mockReturnThis(),
|
||||
stop: jest.fn().mockReturnThis(),
|
||||
succeed: jest.fn().mockReturnThis(),
|
||||
fail: jest.fn().mockReturnThis(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe(ProjectDelete, () => {
|
||||
const graphqlClient = {} as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
|
||||
const mockReceipt: BackgroundJobReceiptDataFragment = {
|
||||
id: 'receipt-1',
|
||||
state: BackgroundJobState.Success,
|
||||
tries: 1,
|
||||
willRetry: false,
|
||||
resultId: null,
|
||||
resultType: BackgroundJobResultType.Void,
|
||||
resultData: null,
|
||||
errorCode: null,
|
||||
errorMessage: null,
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
function createCommand(argv: string[], { insideProjectDir = true } = {}): ProjectDelete {
|
||||
if (insideProjectDir) {
|
||||
jest.mocked(findProjectRootAsync).mockResolvedValue('/app');
|
||||
jest
|
||||
.mocked(getConfigFilePaths)
|
||||
.mockReturnValue({ staticConfigPath: '/app/app.json', dynamicConfigPath: null });
|
||||
jest
|
||||
.mocked(getPrivateExpoConfigAsync)
|
||||
.mockResolvedValue({ extra: { eas: { projectId: testProjectId } } } as never);
|
||||
} else {
|
||||
jest
|
||||
.mocked(findProjectRootAsync)
|
||||
.mockRejectedValue(new Error('Run this command inside a project directory.'));
|
||||
}
|
||||
const command = new ProjectDelete(argv, mockConfig);
|
||||
jest.spyOn(command as any, 'getContextAsync').mockReturnValue({
|
||||
loggedIn: {
|
||||
graphqlClient,
|
||||
authenticationInfo: { accessToken: null, sessionSecret: 'session-secret' },
|
||||
},
|
||||
} as never);
|
||||
return command;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.spyOn(Log, 'log').mockImplementation(() => {});
|
||||
jest.spyOn(Log, 'warn').mockImplementation(() => {});
|
||||
jest.spyOn(Log, 'error').mockImplementation(() => {});
|
||||
jest.spyOn(Log, 'addNewLineIfNone').mockImplementation(() => {});
|
||||
jest.spyOn(Log, 'newLine').mockImplementation(() => {});
|
||||
|
||||
jest.mocked(AppQuery.byIdAsync).mockResolvedValue(testAppQueryByIdResponse);
|
||||
jest.mocked(AppQuery.byFullNameAsync).mockResolvedValue(testAppQueryByIdResponse);
|
||||
jest.mocked(AppMutation.scheduleAppDeletionAsync).mockResolvedValue(mockReceipt);
|
||||
jest.mocked(pollForBackgroundJobReceiptAsync).mockResolvedValue(mockReceipt);
|
||||
jest.mocked(promptAsync).mockResolvedValue({ confirmedName: testExperienceName });
|
||||
jest.mocked(isSudoModeRequiredError).mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('deletes the project when the typed name matches', async () => {
|
||||
await createCommand([]).runAsync();
|
||||
|
||||
expect(promptAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining(testExperienceName),
|
||||
})
|
||||
);
|
||||
expect(AppMutation.scheduleAppDeletionAsync).toHaveBeenCalledWith(graphqlClient, testProjectId);
|
||||
expect(pollForBackgroundJobReceiptAsync).toHaveBeenCalledWith(graphqlClient, mockReceipt);
|
||||
});
|
||||
|
||||
it('cancels deletion when the typed name does not match', async () => {
|
||||
jest.mocked(promptAsync).mockResolvedValue({ confirmedName: 'wrong-name' });
|
||||
const exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('process.exit');
|
||||
});
|
||||
|
||||
await expect(createCommand([]).runAsync()).rejects.toThrow('process.exit');
|
||||
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
expect(AppMutation.scheduleAppDeletionAsync).not.toHaveBeenCalled();
|
||||
exitSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('deletes without prompting when --dangerously-confirm-deletion matches the full name', async () => {
|
||||
await createCommand(['--dangerously-confirm-deletion', testExperienceName]).runAsync();
|
||||
|
||||
expect(promptAsync).not.toHaveBeenCalled();
|
||||
expect(AppMutation.scheduleAppDeletionAsync).toHaveBeenCalledWith(graphqlClient, testProjectId);
|
||||
});
|
||||
|
||||
it('throws when --dangerously-confirm-deletion does not match the full name', async () => {
|
||||
await expect(
|
||||
createCommand(['--dangerously-confirm-deletion', 'wrong-name']).runAsync()
|
||||
).rejects.toThrow(/did not match the project's full name/);
|
||||
|
||||
expect(promptAsync).not.toHaveBeenCalled();
|
||||
expect(AppMutation.scheduleAppDeletionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes in non-interactive mode when --dangerously-confirm-deletion matches the full name', async () => {
|
||||
await createCommand([
|
||||
'--non-interactive',
|
||||
'--dangerously-confirm-deletion',
|
||||
testExperienceName,
|
||||
]).runAsync();
|
||||
|
||||
expect(promptAsync).not.toHaveBeenCalled();
|
||||
expect(AppMutation.scheduleAppDeletionAsync).toHaveBeenCalledWith(graphqlClient, testProjectId);
|
||||
});
|
||||
|
||||
it('throws in non-interactive mode without --dangerously-confirm-deletion', async () => {
|
||||
await expect(createCommand(['--non-interactive']).runAsync()).rejects.toThrow(
|
||||
/requires passing --dangerously-confirm-deletion/
|
||||
);
|
||||
|
||||
expect(AppMutation.scheduleAppDeletionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes a project by full name argument outside a project directory', async () => {
|
||||
await createCommand(
|
||||
[
|
||||
testExperienceName,
|
||||
'--non-interactive',
|
||||
'--dangerously-confirm-deletion',
|
||||
testExperienceName,
|
||||
],
|
||||
{ insideProjectDir: false }
|
||||
).runAsync();
|
||||
|
||||
expect(AppQuery.byFullNameAsync).toHaveBeenCalledWith(graphqlClient, testExperienceName);
|
||||
expect(AppMutation.scheduleAppDeletionAsync).toHaveBeenCalledWith(graphqlClient, testProjectId);
|
||||
});
|
||||
|
||||
it('deletes a project by ID argument', async () => {
|
||||
await createCommand([
|
||||
'00000000-0000-0000-0000-000000000001',
|
||||
'--non-interactive',
|
||||
'--dangerously-confirm-deletion',
|
||||
testExperienceName,
|
||||
]).runAsync();
|
||||
|
||||
expect(AppQuery.byIdAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
'00000000-0000-0000-0000-000000000001'
|
||||
);
|
||||
expect(AppMutation.scheduleAppDeletionAsync).toHaveBeenCalledWith(graphqlClient, testProjectId);
|
||||
});
|
||||
|
||||
it('throws outside a project directory when no argument is passed', async () => {
|
||||
await expect(createCommand([], { insideProjectDir: false }).runAsync()).rejects.toThrow(
|
||||
/No EAS project found in the current directory/
|
||||
);
|
||||
|
||||
expect(AppMutation.scheduleAppDeletionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when the current project has no EAS projectId configured, without offering to create one', async () => {
|
||||
const command = createCommand([]);
|
||||
jest.mocked(getPrivateExpoConfigAsync).mockResolvedValue({} as never);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
/No EAS project found in the current directory/
|
||||
);
|
||||
|
||||
expect(AppMutation.scheduleAppDeletionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not read or create an app config when the directory has none', async () => {
|
||||
const command = createCommand([]);
|
||||
jest
|
||||
.mocked(getConfigFilePaths)
|
||||
.mockReturnValue({ staticConfigPath: null, dynamicConfigPath: null });
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
/No EAS project found in the current directory/
|
||||
);
|
||||
|
||||
expect(getPrivateExpoConfigAsync).not.toHaveBeenCalled();
|
||||
expect(AppMutation.scheduleAppDeletionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('propagates app config errors instead of reporting no project found', async () => {
|
||||
const command = createCommand([]);
|
||||
jest
|
||||
.mocked(getPrivateExpoConfigAsync)
|
||||
.mockRejectedValue(new Error('Invalid app config.\n"name" is required'));
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(/Invalid app config/);
|
||||
|
||||
expect(AppMutation.scheduleAppDeletionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('upgrades the session to sudo mode and retries when the server requires it', async () => {
|
||||
const sudoError = new Error('sudo required');
|
||||
jest
|
||||
.mocked(AppMutation.scheduleAppDeletionAsync)
|
||||
.mockRejectedValueOnce(sudoError)
|
||||
.mockResolvedValueOnce(mockReceipt);
|
||||
jest.mocked(isSudoModeRequiredError).mockImplementation(e => e === sudoError);
|
||||
|
||||
await createCommand([]).runAsync();
|
||||
|
||||
expect(promptForSudoModeUpgradeAsync).toHaveBeenCalledWith({
|
||||
accessToken: null,
|
||||
sessionSecret: 'session-secret',
|
||||
});
|
||||
expect(AppMutation.scheduleAppDeletionAsync).toHaveBeenCalledTimes(2);
|
||||
expect(pollForBackgroundJobReceiptAsync).toHaveBeenCalledWith(graphqlClient, mockReceipt);
|
||||
});
|
||||
|
||||
it('throws in non-interactive mode when sudo mode is required', async () => {
|
||||
const sudoError = new Error('sudo required');
|
||||
jest.mocked(AppMutation.scheduleAppDeletionAsync).mockRejectedValue(sudoError);
|
||||
jest.mocked(isSudoModeRequiredError).mockImplementation(e => e === sudoError);
|
||||
|
||||
await expect(
|
||||
createCommand([
|
||||
'--non-interactive',
|
||||
'--dangerously-confirm-deletion',
|
||||
testExperienceName,
|
||||
]).runAsync()
|
||||
).rejects.toThrow(/requires a session in sudo mode/);
|
||||
|
||||
expect(promptForSudoModeUpgradeAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import { getConfigFilePaths } from '@expo/config';
|
||||
import { Args, Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { findProjectRootAsync } from '../../commandUtils/context/contextUtils/findProjectDirAndVerifyProjectSetupAsync';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import { AppFragment } from '../../graphql/generated';
|
||||
import { AppMutation } from '../../graphql/mutations/AppMutation';
|
||||
import { AppQuery } from '../../graphql/queries/AppQuery';
|
||||
import Log from '../../log';
|
||||
import { ora } from '../../ora';
|
||||
import { getPrivateExpoConfigAsync } from '../../project/expoConfig';
|
||||
import { promptAsync } from '../../prompts';
|
||||
import { isSudoModeRequiredError, promptForSudoModeUpgradeAsync } from '../../user/sudo';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import { pollForBackgroundJobReceiptAsync } from '../../utils/pollForBackgroundJobReceiptAsync';
|
||||
|
||||
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
export default class ProjectDelete extends EasCommand {
|
||||
static override description = 'delete a project';
|
||||
|
||||
static override args = {
|
||||
name: Args.string({
|
||||
required: false,
|
||||
description:
|
||||
'Full name (@account/slug) or ID of the project to delete. Defaults to the project in the current directory.',
|
||||
}),
|
||||
};
|
||||
|
||||
static override flags = {
|
||||
'dangerously-confirm-deletion': Flags.string({
|
||||
description:
|
||||
"The project's full name (@account/slug), to confirm deletion. Required in non-interactive mode.",
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const {
|
||||
args: { name },
|
||||
flags,
|
||||
} = await this.parse(ProjectDelete);
|
||||
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
|
||||
if (jsonFlag) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
const {
|
||||
loggedIn: { graphqlClient, authenticationInfo },
|
||||
} = await this.getContextAsync(ProjectDelete, { nonInteractive });
|
||||
|
||||
let app: AppFragment;
|
||||
if (name) {
|
||||
app = UUID_REGEX.test(name)
|
||||
? await AppQuery.byIdAsync(graphqlClient, name)
|
||||
: await AppQuery.byFullNameAsync(graphqlClient, name);
|
||||
} else {
|
||||
let projectDir: string | null = null;
|
||||
try {
|
||||
projectDir = await findProjectRootAsync();
|
||||
} catch {
|
||||
// Not inside a project directory — handled by the error below.
|
||||
}
|
||||
|
||||
let projectId: string | undefined;
|
||||
if (projectDir) {
|
||||
// Only read the config when one already exists: getPrivateExpoConfigAsync creates a
|
||||
// fresh app.json as a side effect when the directory has none. Config errors (broken
|
||||
// app.config.js, invalid app.json) propagate so they are not masked by the error below.
|
||||
const configPaths = getConfigFilePaths(projectDir);
|
||||
if (configPaths.staticConfigPath ?? configPaths.dynamicConfigPath) {
|
||||
const exp = await getPrivateExpoConfigAsync(projectDir);
|
||||
projectId = exp.extra?.eas?.projectId;
|
||||
}
|
||||
}
|
||||
if (!projectId) {
|
||||
throw new Error(
|
||||
"No EAS project found in the current directory. Pass the project's full name (@account/slug) or ID as an argument."
|
||||
);
|
||||
}
|
||||
app = await AppQuery.byIdAsync(graphqlClient, projectId);
|
||||
}
|
||||
|
||||
const confirmedFullName = flags['dangerously-confirm-deletion'];
|
||||
if (confirmedFullName) {
|
||||
if (confirmedFullName !== app.fullName) {
|
||||
throw new Error(
|
||||
`The value of --dangerously-confirm-deletion ("${confirmedFullName}") did not match the project's full name ("${app.fullName}"). Cancelled deletion.`
|
||||
);
|
||||
}
|
||||
} else if (nonInteractive) {
|
||||
throw new Error(
|
||||
`Deleting a project in non-interactive mode requires passing --dangerously-confirm-deletion with the project's full name ("${app.fullName}").`
|
||||
);
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.warn(
|
||||
`You are about to permanently delete project ${chalk.bold(app.fullName)}.` +
|
||||
`\nThis will delete everything associated with it, including builds, submissions, update branches, published updates, and environment variables.` +
|
||||
`\nThis action is irreversible.`
|
||||
);
|
||||
Log.newLine();
|
||||
const { confirmedName } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'confirmedName',
|
||||
message: `Type the project's full name (${app.fullName}) to confirm deletion:`,
|
||||
});
|
||||
if (confirmedName !== app.fullName) {
|
||||
Log.error(`The input did not match the project's full name. Cancelled deletion.`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const spinner = ora(`Deleting project ${app.fullName}`).start();
|
||||
try {
|
||||
let receipt;
|
||||
try {
|
||||
receipt = await AppMutation.scheduleAppDeletionAsync(graphqlClient, app.id);
|
||||
} catch (error) {
|
||||
if (!isSudoModeRequiredError(error)) {
|
||||
throw error;
|
||||
}
|
||||
if (nonInteractive) {
|
||||
throw new Error(
|
||||
'Deleting a project requires a session in sudo mode. Run this command interactively to confirm your password, then retry in non-interactive mode while sudo mode is active.'
|
||||
);
|
||||
}
|
||||
spinner.stop();
|
||||
await promptForSudoModeUpgradeAsync(authenticationInfo);
|
||||
spinner.start();
|
||||
receipt = await AppMutation.scheduleAppDeletionAsync(graphqlClient, app.id);
|
||||
}
|
||||
await pollForBackgroundJobReceiptAsync(graphqlClient, receipt);
|
||||
spinner.succeed(`Deleted project ${chalk.bold(app.fullName)}`);
|
||||
} catch (error) {
|
||||
spinner.fail(`Failed to delete project ${chalk.bold(app.fullName)}`);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput({ id: app.id, fullName: app.fullName });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,7 @@ export default class SimulatorStart extends EasCommand {
|
||||
|
||||
static override flags = {
|
||||
platform: Flags.option({
|
||||
char: 'p',
|
||||
description: 'Device platform',
|
||||
options: ['android', 'ios'] as const,
|
||||
required: true,
|
||||
|
||||
+741
-83
File diff suppressed because it is too large.
Load diff
@@ -3,7 +3,14 @@ import gql from 'graphql-tag';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { withErrorHandlingAsync } from '../client';
|
||||
import { CreateAppMutation, CreateAppMutationVariables } from '../generated';
|
||||
import {
|
||||
BackgroundJobReceiptDataFragment,
|
||||
CreateAppMutation,
|
||||
CreateAppMutationVariables,
|
||||
ScheduleAppDeletionMutation,
|
||||
ScheduleAppDeletionMutationVariables,
|
||||
} from '../generated';
|
||||
import { BackgroundJobReceiptNode } from '../types/BackgroundJobReceipt';
|
||||
|
||||
export const AppMutation = {
|
||||
async createAppAsync(
|
||||
@@ -35,4 +42,30 @@ export const AppMutation = {
|
||||
assert(appId, 'App ID must be defined');
|
||||
return appId;
|
||||
},
|
||||
async scheduleAppDeletionAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
appId: string
|
||||
): Promise<BackgroundJobReceiptDataFragment> {
|
||||
const result = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<ScheduleAppDeletionMutation, ScheduleAppDeletionMutationVariables>(
|
||||
gql`
|
||||
mutation ScheduleAppDeletion($appId: ID!) {
|
||||
app {
|
||||
scheduleAppDeletion(appId: $appId) {
|
||||
id
|
||||
...BackgroundJobReceiptData
|
||||
}
|
||||
}
|
||||
}
|
||||
${BackgroundJobReceiptNode}
|
||||
`,
|
||||
{ appId }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
const receipt = result.app?.scheduleAppDeletion;
|
||||
assert(receipt, 'Background job receipt must be defined');
|
||||
return receipt;
|
||||
},
|
||||
};
|
||||
@@ -201,6 +201,12 @@ export const AccountQuery = {
|
||||
overageMetrics {
|
||||
id
|
||||
value
|
||||
metadata {
|
||||
... on AccountUsageEASBuildMetadata {
|
||||
billingResourceClass
|
||||
platform
|
||||
}
|
||||
}
|
||||
}
|
||||
totalCost
|
||||
}
|
||||
|
||||
@@ -62,7 +62,6 @@ export const DeviceRunSessionQuery = {
|
||||
}
|
||||
... on ServeSimRunSessionRemoteConfig {
|
||||
previewUrl
|
||||
streamUrl
|
||||
}
|
||||
}
|
||||
turtleJobRun {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { promptForSudoModeUpgradeAsync } from '../sudo';
|
||||
import { ApiV2Error } from '../../ApiV2Error';
|
||||
import { ApiV2Client } from '../../api';
|
||||
import { promptAsync } from '../../prompts';
|
||||
|
||||
jest.mock('../../api');
|
||||
jest.mock('../../prompts');
|
||||
jest.mock('../../log');
|
||||
|
||||
const authenticationInfo = { accessToken: null, sessionSecret: 'session-secret' } as const;
|
||||
|
||||
describe(promptForSudoModeUpgradeAsync, () => {
|
||||
let postAsync: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
postAsync = jest.fn().mockResolvedValue({ data: { status: 'success' } });
|
||||
jest.mocked(ApiV2Client).mockImplementation(() => ({ postAsync }) as unknown as ApiV2Client);
|
||||
jest.mocked(promptAsync).mockResolvedValue({ password: 'hunter2', otp: '123456' });
|
||||
});
|
||||
|
||||
it('upgrades the session with the password', async () => {
|
||||
await promptForSudoModeUpgradeAsync(authenticationInfo);
|
||||
|
||||
expect(postAsync).toHaveBeenCalledWith('auth/upgradeSudo', {
|
||||
body: { password: 'hunter2' },
|
||||
});
|
||||
});
|
||||
|
||||
it('retries with an OTP when one is required', async () => {
|
||||
postAsync
|
||||
.mockRejectedValueOnce(
|
||||
new ApiV2Error({ message: 'otp required', code: 'ONE_TIME_PASSWORD_REQUIRED' })
|
||||
)
|
||||
.mockResolvedValueOnce({ data: { status: 'success' } });
|
||||
|
||||
await promptForSudoModeUpgradeAsync(authenticationInfo);
|
||||
|
||||
expect(postAsync).toHaveBeenCalledTimes(2);
|
||||
expect(postAsync).toHaveBeenLastCalledWith('auth/upgradeSudo', {
|
||||
body: { password: 'hunter2', otp: '123456' },
|
||||
});
|
||||
});
|
||||
|
||||
it('rethrows other API errors', async () => {
|
||||
postAsync.mockRejectedValue(
|
||||
new ApiV2Error({ message: 'The password you entered is invalid', code: 'VALIDATION_ERROR' })
|
||||
);
|
||||
|
||||
await expect(promptForSudoModeUpgradeAsync(authenticationInfo)).rejects.toThrow(
|
||||
/password you entered is invalid/
|
||||
);
|
||||
});
|
||||
|
||||
it('throws for access-token sessions', async () => {
|
||||
await expect(
|
||||
promptForSudoModeUpgradeAsync({ accessToken: 'token', sessionSecret: null })
|
||||
).rejects.toThrow(/Access tokens/);
|
||||
|
||||
expect(postAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { LoggedInAuthenticationInfo } from './SessionManager';
|
||||
import { ApiV2Error } from '../ApiV2Error';
|
||||
import { ApiV2Client } from '../api';
|
||||
import { GraphqlError } from '../graphql/client';
|
||||
import Log from '../log';
|
||||
import { promptAsync } from '../prompts';
|
||||
|
||||
export const SUDO_MODE_REQUIRED_ERROR_CODE = 'SUDO_MODE_REQUIRED';
|
||||
|
||||
/**
|
||||
* Whether the error is the server signaling that the current session must be upgraded
|
||||
* to sudo mode before performing this operation.
|
||||
*/
|
||||
export function isSudoModeRequiredError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof GraphqlError &&
|
||||
error.graphQLErrors.some(e => e?.extensions?.errorCode === SUDO_MODE_REQUIRED_ERROR_CODE)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upgrade the current session to sudo mode by re-confirming the user's password
|
||||
* (and OTP if two-factor authentication is enabled). Sudo mode is required for
|
||||
* destructive operations like project deletion, and expires server-side after a
|
||||
* few minutes.
|
||||
*/
|
||||
export async function promptForSudoModeUpgradeAsync(
|
||||
authenticationInfo: LoggedInAuthenticationInfo
|
||||
): Promise<void> {
|
||||
if (!authenticationInfo.sessionSecret) {
|
||||
throw new Error(
|
||||
'This action requires sudo mode, which is only available for user sessions. ' +
|
||||
'Access tokens (EXPO_TOKEN) cannot be upgraded to sudo mode; log in with `eas login` instead.'
|
||||
);
|
||||
}
|
||||
|
||||
Log.log('This action requires sudo mode. Confirm your password to continue.');
|
||||
const { password } = await promptAsync({
|
||||
type: 'password',
|
||||
name: 'password',
|
||||
message: 'Password:',
|
||||
});
|
||||
if (!password) {
|
||||
throw new Error('Password is required for sudo mode.');
|
||||
}
|
||||
|
||||
const apiV2Client = new ApiV2Client(authenticationInfo);
|
||||
try {
|
||||
await apiV2Client.postAsync('auth/upgradeSudo', { body: { password } });
|
||||
} catch (error) {
|
||||
if (error instanceof ApiV2Error && error.expoApiV2ErrorCode === 'ONE_TIME_PASSWORD_REQUIRED') {
|
||||
Log.log('One-time password from authenticator required.');
|
||||
const { otp } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'otp',
|
||||
message: 'One-time password or backup code:',
|
||||
});
|
||||
if (!otp) {
|
||||
throw new Error('Cancelled sudo mode upgrade.');
|
||||
}
|
||||
await apiV2Client.postAsync('auth/upgradeSudo', { body: { password, otp } });
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,13 @@
|
||||
import { randomInt } from 'crypto';
|
||||
import express from 'express';
|
||||
import fs from 'fs';
|
||||
import http from 'http';
|
||||
import path from 'path';
|
||||
|
||||
import { ImageNonPngError, ImageTransparencyError, ensurePNGIsNotTransparentAsync } from '../image';
|
||||
|
||||
const TEST_SERVER_PORT = randomInt(9000, 9000 + 1000);
|
||||
|
||||
const fixturesPath = path.join(__dirname, 'fixtures');
|
||||
const transparentPngPath = path.join(fixturesPath, 'icon-alpha.png');
|
||||
const nonTransparentPngPath = path.join(fixturesPath, 'icon-no-alpha.png');
|
||||
const jpgPath = path.join(fixturesPath, 'icon.jpg');
|
||||
const nonTransparentPngURL = `http://127.0.0.1:${TEST_SERVER_PORT}/icon-alpha.png`;
|
||||
|
||||
describe(ensurePNGIsNotTransparentAsync, () => {
|
||||
describe('local paths', () => {
|
||||
@@ -32,15 +28,27 @@ describe(ensurePNGIsNotTransparentAsync, () => {
|
||||
|
||||
describe('remote URLs', () => {
|
||||
let server: http.Server;
|
||||
let transparentPngURL: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await new Promise<void>(res => {
|
||||
const app = express();
|
||||
app.use(express.static(fixturesPath));
|
||||
server = app.listen(TEST_SERVER_PORT, () => {
|
||||
server = http.createServer((_request, response) => {
|
||||
response.setHeader('Content-Type', 'image/png');
|
||||
fs.createReadStream(transparentPngPath).pipe(response);
|
||||
});
|
||||
await new Promise<void>((res, rej) => {
|
||||
const onError = (error: Error): void => rej(error);
|
||||
server.once('error', onError);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
server.off('error', onError);
|
||||
res();
|
||||
});
|
||||
});
|
||||
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('Failed to determine test server address');
|
||||
}
|
||||
transparentPngURL = `http://127.0.0.1:${address.port}/icon-alpha.png`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -52,7 +60,7 @@ describe(ensurePNGIsNotTransparentAsync, () => {
|
||||
});
|
||||
|
||||
it('works with remote URLs', async () => {
|
||||
await expect(ensurePNGIsNotTransparentAsync(nonTransparentPngURL)).rejects.toThrowError(
|
||||
await expect(ensurePNGIsNotTransparentAsync(transparentPngURL)).rejects.toThrowError(
|
||||
ImageTransparencyError
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
AccountUsageForOverageWarningQuery,
|
||||
AppPlatform,
|
||||
EasBuildBillingResourceClass,
|
||||
EasService,
|
||||
EasServiceMetric,
|
||||
EstimatedUsage,
|
||||
@@ -40,6 +42,30 @@ function createMockPlanMetric({
|
||||
};
|
||||
}
|
||||
|
||||
type MockOverageMetric =
|
||||
AccountUsageForOverageWarningQuery['account']['byId']['usageMetrics']['EAS_BUILD']['overageMetrics'][number];
|
||||
|
||||
// A genuine per-worker build overage row, i.e. one carrying build metadata. Only rows
|
||||
// shaped like this should be counted toward the "N builds beyond your credits" total.
|
||||
function createMockBuildOverage({
|
||||
id = 'overage-id',
|
||||
value = 1,
|
||||
platform = AppPlatform.Ios,
|
||||
billingResourceClass = EasBuildBillingResourceClass.Medium,
|
||||
}: {
|
||||
id?: string;
|
||||
value?: number;
|
||||
platform?: AppPlatform;
|
||||
billingResourceClass?: EasBuildBillingResourceClass;
|
||||
} = {}): MockOverageMetric {
|
||||
return {
|
||||
__typename: 'EstimatedOverageAndCost',
|
||||
id,
|
||||
value,
|
||||
metadata: { __typename: 'AccountUsageEASBuildMetadata', billingResourceClass, platform },
|
||||
};
|
||||
}
|
||||
|
||||
function createMockAccountUsage({
|
||||
id = 'account-id',
|
||||
name = 'test-account',
|
||||
@@ -52,7 +78,7 @@ function createMockAccountUsage({
|
||||
name?: string;
|
||||
subscriptionName?: string;
|
||||
buildPlanMetrics?: EstimatedUsage[];
|
||||
overageMetrics?: { __typename: 'EstimatedOverageAndCost'; id: string; value: number }[];
|
||||
overageMetrics?: MockOverageMetric[];
|
||||
totalCost?: number;
|
||||
} = {}): AccountUsageForOverageWarningQuery['account']['byId'] {
|
||||
return {
|
||||
@@ -184,7 +210,7 @@ describe('maybeWarnAboutUsageOveragesAsync', () => {
|
||||
mockGetUsageForOverageWarningAsync.mockResolvedValue(
|
||||
createMockAccountUsage({
|
||||
buildPlanMetrics: [createMockPlanMetric({ value: 105 })],
|
||||
overageMetrics: [{ __typename: 'EstimatedOverageAndCost', id: 'o1', value: 5 }],
|
||||
overageMetrics: [createMockBuildOverage({ id: 'o1', value: 5 })],
|
||||
totalCost: 0,
|
||||
})
|
||||
);
|
||||
@@ -207,7 +233,7 @@ describe('maybeWarnAboutUsageOveragesAsync', () => {
|
||||
createMockAccountUsage({
|
||||
subscriptionName: 'Starter',
|
||||
buildPlanMetrics: [createMockPlanMetric({ value: 110 })],
|
||||
overageMetrics: [{ __typename: 'EstimatedOverageAndCost', id: 'o1', value: 10 }],
|
||||
overageMetrics: [createMockBuildOverage({ id: 'o1', value: 10 })],
|
||||
totalCost: 1500,
|
||||
})
|
||||
);
|
||||
@@ -242,6 +268,32 @@ describe('maybeWarnAboutUsageOveragesAsync', () => {
|
||||
expect(mockWarn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('counts only per-worker build overage rows, ignoring rows without build metadata', async () => {
|
||||
mockGetUsageForOverageWarningAsync.mockResolvedValue(
|
||||
createMockAccountUsage({
|
||||
subscriptionName: 'Starter',
|
||||
buildPlanMetrics: [createMockPlanMetric({ value: 103 })],
|
||||
overageMetrics: [
|
||||
createMockBuildOverage({ id: 'real', value: 3 }),
|
||||
// A rollup/summary row with no build metadata - must not be summed in.
|
||||
{ __typename: 'EstimatedOverageAndCost', id: 'rollup', value: 50 },
|
||||
],
|
||||
totalCost: 450,
|
||||
})
|
||||
);
|
||||
|
||||
await maybeWarnAboutUsageOveragesAsync({
|
||||
graphqlClient: mockGraphqlClient,
|
||||
accountId: 'account-id',
|
||||
});
|
||||
|
||||
expect(mockWarn).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
"You've used 3 builds beyond your included credits this billing period ($4.50 in overages so far)."
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('does not warn a Free plan below its limit even if the usage API reports an overage', async () => {
|
||||
// Regression: free users with only a handful of builds were told they'd reached
|
||||
// their limit because a nonzero overage row was treated as billable usage.
|
||||
@@ -249,7 +301,7 @@ describe('maybeWarnAboutUsageOveragesAsync', () => {
|
||||
createMockAccountUsage({
|
||||
subscriptionName: 'Free',
|
||||
buildPlanMetrics: [createMockPlanMetric({ value: 6, limit: 30 })],
|
||||
overageMetrics: [{ __typename: 'EstimatedOverageAndCost', id: 'o1', value: 5 }],
|
||||
overageMetrics: [createMockBuildOverage({ id: 'o1', value: 5 })],
|
||||
totalCost: 0,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -33,7 +33,12 @@ export async function maybeWarnAboutUsageOveragesAsync({
|
||||
return;
|
||||
}
|
||||
|
||||
const overageCount = buildMetrics.overageMetrics.reduce((sum, o) => sum + o.value, 0);
|
||||
// Count only genuine per-worker build overage rows. `overageMetrics` can also
|
||||
// contain rows without build metadata (e.g. rollups), which must not be summed
|
||||
// in — otherwise the displayed "N builds beyond your credits" count is inflated.
|
||||
const overageCount = buildMetrics.overageMetrics
|
||||
.filter(o => o.metadata?.billingResourceClass && o.metadata?.platform)
|
||||
.reduce((sum, o) => sum + o.value, 0);
|
||||
const overageCostCents = buildMetrics.totalCost;
|
||||
const hasFreePlan = subscription.name === 'Free';
|
||||
const tier = classifyUsageTier({
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { promptAsync } from '../../prompts';
|
||||
import {
|
||||
assertValidDevDomainName,
|
||||
assignDevDomainNameAsync,
|
||||
getSignedDeploymentUrlAsync,
|
||||
} from '../deployment';
|
||||
import { DeploymentsMutation } from '../mutations';
|
||||
import { DeploymentsQuery } from '../queries';
|
||||
|
||||
jest.mock('../mutations');
|
||||
jest.mock('../queries');
|
||||
jest.mock('../../prompts');
|
||||
jest.mock('../../log');
|
||||
|
||||
function createDevDomainNameTakenError(): Error {
|
||||
return Object.assign(new Error('Dev domain name is already taken'), {
|
||||
graphQLErrors: [{ extensions: { errorCode: 'DEV_DOMAIN_NAME_TAKEN' } }],
|
||||
});
|
||||
}
|
||||
|
||||
function createMissingDevDomainNameError(): Error {
|
||||
return Object.assign(new Error('App does not have a dev domain name'), {
|
||||
graphQLErrors: [{ extensions: { errorCode: 'APP_NO_DEV_DOMAIN_NAME' } }],
|
||||
});
|
||||
}
|
||||
|
||||
describe(assertValidDevDomainName, () => {
|
||||
it.each(['abc', 'my-app', 'app123', 'my-app-123'])('accepts %p', name => {
|
||||
expect(() => {
|
||||
assertValidDevDomainName(name);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects empty names', () => {
|
||||
expect(() => {
|
||||
assertValidDevDomainName('');
|
||||
}).toThrow(/choose a preview URL/);
|
||||
});
|
||||
|
||||
it('rejects names shorter than 3 characters', () => {
|
||||
expect(() => {
|
||||
assertValidDevDomainName('ab');
|
||||
}).toThrow(/at least 3 characters/);
|
||||
});
|
||||
|
||||
it('rejects names ending with a hyphen', () => {
|
||||
expect(() => {
|
||||
assertValidDevDomainName('my-app-');
|
||||
}).toThrow(/cannot end with a hyphen/);
|
||||
});
|
||||
|
||||
it.each(['-my-app', 'My-App', 'my_app', 'my--app', 'my.app'])(
|
||||
'rejects names with invalid characters, like %p',
|
||||
name => {
|
||||
expect(() => {
|
||||
assertValidDevDomainName(name);
|
||||
}).toThrow(/lowercase letters, numbers, and non-consecutive hyphens/);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe(assignDevDomainNameAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('assigns the requested dev domain name without prompting', async () => {
|
||||
const graphqlClient = {} as ExpoGraphqlClient;
|
||||
jest.mocked(DeploymentsMutation.assignDevDomainNameAsync).mockResolvedValueOnce(true);
|
||||
|
||||
await assignDevDomainNameAsync({
|
||||
graphqlClient,
|
||||
appId: 'test-app-id',
|
||||
devDomainName: 'my-app',
|
||||
nonInteractive: true,
|
||||
});
|
||||
|
||||
expect(DeploymentsMutation.assignDevDomainNameAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: 'test-app-id',
|
||||
name: 'my-app',
|
||||
});
|
||||
expect(DeploymentsQuery.getSuggestedDevDomainByAppIdAsync).not.toHaveBeenCalled();
|
||||
expect(promptAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when the requested dev domain name is taken', async () => {
|
||||
const graphqlClient = {} as ExpoGraphqlClient;
|
||||
jest
|
||||
.mocked(DeploymentsMutation.assignDevDomainNameAsync)
|
||||
.mockRejectedValueOnce(createDevDomainNameTakenError());
|
||||
|
||||
await expect(
|
||||
assignDevDomainNameAsync({
|
||||
graphqlClient,
|
||||
appId: 'test-app-id',
|
||||
devDomainName: 'my-app',
|
||||
nonInteractive: true,
|
||||
})
|
||||
).rejects.toThrow(/"my-app" is already taken.*--dev-domain/);
|
||||
|
||||
expect(DeploymentsMutation.assignDevDomainNameAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('assigns the suggested dev domain name in non-interactive mode', async () => {
|
||||
const graphqlClient = {} as ExpoGraphqlClient;
|
||||
jest
|
||||
.mocked(DeploymentsQuery.getSuggestedDevDomainByAppIdAsync)
|
||||
.mockResolvedValueOnce('suggested-name');
|
||||
jest.mocked(DeploymentsMutation.assignDevDomainNameAsync).mockResolvedValueOnce(true);
|
||||
|
||||
await assignDevDomainNameAsync({
|
||||
graphqlClient,
|
||||
appId: 'test-app-id',
|
||||
nonInteractive: true,
|
||||
});
|
||||
|
||||
expect(DeploymentsMutation.assignDevDomainNameAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: 'test-app-id',
|
||||
name: 'suggested-name',
|
||||
});
|
||||
expect(promptAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to the prompt when the requested dev domain name is taken in interactive mode', async () => {
|
||||
const graphqlClient = {} as ExpoGraphqlClient;
|
||||
jest
|
||||
.mocked(DeploymentsMutation.assignDevDomainNameAsync)
|
||||
.mockRejectedValueOnce(createDevDomainNameTakenError())
|
||||
.mockResolvedValueOnce(true);
|
||||
jest
|
||||
.mocked(DeploymentsQuery.getSuggestedDevDomainByAppIdAsync)
|
||||
.mockResolvedValueOnce('suggested-name');
|
||||
jest.mocked(promptAsync).mockResolvedValueOnce({ name: 'prompted-name' });
|
||||
|
||||
await assignDevDomainNameAsync({
|
||||
graphqlClient,
|
||||
appId: 'test-app-id',
|
||||
devDomainName: 'my-app',
|
||||
nonInteractive: false,
|
||||
});
|
||||
|
||||
expect(promptAsync).toHaveBeenCalledTimes(1);
|
||||
expect(DeploymentsMutation.assignDevDomainNameAsync).toHaveBeenLastCalledWith(graphqlClient, {
|
||||
appId: 'test-app-id',
|
||||
name: 'prompted-name',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when the suggested dev domain name is taken in non-interactive mode', async () => {
|
||||
const graphqlClient = {} as ExpoGraphqlClient;
|
||||
jest
|
||||
.mocked(DeploymentsQuery.getSuggestedDevDomainByAppIdAsync)
|
||||
.mockResolvedValueOnce('suggested-name');
|
||||
jest
|
||||
.mocked(DeploymentsMutation.assignDevDomainNameAsync)
|
||||
.mockRejectedValueOnce(createDevDomainNameTakenError());
|
||||
|
||||
await expect(
|
||||
assignDevDomainNameAsync({
|
||||
graphqlClient,
|
||||
appId: 'test-app-id',
|
||||
nonInteractive: true,
|
||||
})
|
||||
).rejects.toThrow(/"suggested-name" is already taken.*--dev-domain/);
|
||||
|
||||
expect(DeploymentsMutation.assignDevDomainNameAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe(getSignedDeploymentUrlAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('throws when the requested dev domain name differs from the existing one', async () => {
|
||||
const graphqlClient = {} as ExpoGraphqlClient;
|
||||
jest.mocked(DeploymentsQuery.getDevDomainNameByAppIdAsync).mockResolvedValueOnce('other-name');
|
||||
|
||||
await expect(
|
||||
getSignedDeploymentUrlAsync(graphqlClient, {
|
||||
appId: 'test-app-id',
|
||||
devDomainName: 'my-app',
|
||||
nonInteractive: true,
|
||||
})
|
||||
).rejects.toThrow(/already set to "other-name\..*" and cannot be changed/);
|
||||
|
||||
expect(DeploymentsMutation.createSignedDeploymentUrlAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns the deployment URL when the requested dev domain name matches the existing one', async () => {
|
||||
const graphqlClient = {} as ExpoGraphqlClient;
|
||||
jest.mocked(DeploymentsQuery.getDevDomainNameByAppIdAsync).mockResolvedValueOnce('my-app');
|
||||
jest
|
||||
.mocked(DeploymentsMutation.createSignedDeploymentUrlAsync)
|
||||
.mockResolvedValueOnce('https://upload-url.example');
|
||||
|
||||
await expect(
|
||||
getSignedDeploymentUrlAsync(graphqlClient, {
|
||||
appId: 'test-app-id',
|
||||
devDomainName: 'my-app',
|
||||
nonInteractive: true,
|
||||
})
|
||||
).resolves.toBe('https://upload-url.example');
|
||||
});
|
||||
|
||||
it('assigns the requested dev domain name when the project has none yet', async () => {
|
||||
const graphqlClient = {} as ExpoGraphqlClient;
|
||||
jest.mocked(DeploymentsQuery.getDevDomainNameByAppIdAsync).mockResolvedValueOnce(null);
|
||||
jest
|
||||
.mocked(DeploymentsMutation.createSignedDeploymentUrlAsync)
|
||||
.mockRejectedValueOnce(createMissingDevDomainNameError())
|
||||
.mockResolvedValueOnce('https://upload-url.example');
|
||||
jest.mocked(DeploymentsMutation.assignDevDomainNameAsync).mockResolvedValueOnce(true);
|
||||
|
||||
await expect(
|
||||
getSignedDeploymentUrlAsync(graphqlClient, {
|
||||
appId: 'test-app-id',
|
||||
devDomainName: 'my-app',
|
||||
nonInteractive: true,
|
||||
})
|
||||
).resolves.toBe('https://upload-url.example');
|
||||
|
||||
expect(DeploymentsMutation.assignDevDomainNameAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: 'test-app-id',
|
||||
name: 'my-app',
|
||||
});
|
||||
// The retry after assignment must not re-run the dev domain pre-check query
|
||||
expect(DeploymentsQuery.getDevDomainNameByAppIdAsync).toHaveBeenCalledTimes(1);
|
||||
expect(promptAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when the requested dev domain name was never assigned by the deployment', async () => {
|
||||
const graphqlClient = {} as ExpoGraphqlClient;
|
||||
jest.mocked(DeploymentsQuery.getDevDomainNameByAppIdAsync).mockResolvedValueOnce(null);
|
||||
jest
|
||||
.mocked(DeploymentsMutation.createSignedDeploymentUrlAsync)
|
||||
.mockResolvedValueOnce('https://upload-url.example');
|
||||
|
||||
await expect(
|
||||
getSignedDeploymentUrlAsync(graphqlClient, {
|
||||
appId: 'test-app-id',
|
||||
devDomainName: 'my-app',
|
||||
nonInteractive: true,
|
||||
})
|
||||
).rejects.toThrow(/preview URL was not assigned as part of this deployment/);
|
||||
|
||||
expect(DeploymentsMutation.assignDevDomainNameAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -16,14 +16,30 @@ export async function getSignedDeploymentUrlAsync(
|
||||
options: {
|
||||
appId: string;
|
||||
deploymentIdentifier?: string | null;
|
||||
/** Custom dev domain name (preview URL subdomain) requested through the `--dev-domain` flag */
|
||||
devDomainName?: string;
|
||||
/** Callback which is invoked when the project is going to setup the dev domain */
|
||||
onSetupDevDomain?: () => any;
|
||||
/** If the terminal is running in non interactive mode or not */
|
||||
nonInteractive?: boolean;
|
||||
}
|
||||
): Promise<string> {
|
||||
let currentDevDomainName: string | null = null;
|
||||
if (options.devDomainName) {
|
||||
currentDevDomainName = await DeploymentsQuery.getDevDomainNameByAppIdAsync(graphqlClient, {
|
||||
appId: options.appId,
|
||||
});
|
||||
|
||||
if (currentDevDomainName && currentDevDomainName !== options.devDomainName) {
|
||||
throw new Error(
|
||||
`The project's preview URL is already set to "${currentDevDomainName}.${EXPO_BASE_DOMAIN}.app" and cannot be changed to "${options.devDomainName}.${EXPO_BASE_DOMAIN}.app" through "eas deploy".\nRemove the --dev-domain flag to deploy to the existing preview URL.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let signedUrl: string;
|
||||
try {
|
||||
return await DeploymentsMutation.createSignedDeploymentUrlAsync(graphqlClient, {
|
||||
signedUrl = await DeploymentsMutation.createSignedDeploymentUrlAsync(graphqlClient, {
|
||||
appId: options.appId,
|
||||
deploymentIdentifier: options.deploymentIdentifier,
|
||||
});
|
||||
@@ -43,11 +59,27 @@ export async function getSignedDeploymentUrlAsync(
|
||||
await assignDevDomainNameAsync({
|
||||
graphqlClient,
|
||||
appId: options.appId,
|
||||
devDomainName: options.devDomainName,
|
||||
nonInteractive: options.nonInteractive,
|
||||
});
|
||||
// Retry creating the signed URL
|
||||
return await getSignedDeploymentUrlAsync(graphqlClient, options);
|
||||
// Retry creating the signed URL. The dev domain name was just assigned, so drop it from
|
||||
// the retry to skip the pre-checks (and their extra query) — it is known to be set now.
|
||||
return await getSignedDeploymentUrlAsync(graphqlClient, {
|
||||
...options,
|
||||
devDomainName: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// The --dev-domain flag is applied when the server requires a preview URL to be assigned
|
||||
// before the first deployment. If the deployment was accepted without that assignment,
|
||||
// fail instead of silently deploying without the requested preview URL.
|
||||
if (options.devDomainName && !currentDevDomainName) {
|
||||
throw new Error(
|
||||
`The --dev-domain flag was provided, but the project's preview URL was not assigned as part of this deployment.\nRemove the --dev-domain flag and set the preview URL from the project's hosting settings on the website instead.`
|
||||
);
|
||||
}
|
||||
|
||||
return signedUrl;
|
||||
}
|
||||
|
||||
type PromptInstance = {
|
||||
@@ -78,6 +110,40 @@ function formatDevDomainName(name = ''): string {
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a dev domain name (preview URL subdomain), matching the rules of the interactive prompt.
|
||||
* Returns `true` when valid, or an error message describing why the name is invalid.
|
||||
*/
|
||||
function validateDevDomainName(value: string): true | string {
|
||||
if (!value) {
|
||||
return 'You have to choose a preview URL for your project';
|
||||
}
|
||||
if (value.length < 3) {
|
||||
return 'Preview URLs must be at least 3 characters long';
|
||||
}
|
||||
if (value.endsWith('-')) {
|
||||
return 'Preview URLs cannot end with a hyphen (-)';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that a user-provided dev domain name (preview URL subdomain) is valid.
|
||||
* Unlike the interactive prompt, which reformats invalid input while typing,
|
||||
* this throws an error when the name contains invalid characters.
|
||||
*/
|
||||
export function assertValidDevDomainName(name: string): void {
|
||||
const validationResult = validateDevDomainName(name);
|
||||
if (validationResult !== true) {
|
||||
throw new Error(validationResult);
|
||||
}
|
||||
if (formatDevDomainName(name) !== name) {
|
||||
throw new Error(
|
||||
`Preview URLs can only contain lowercase letters, numbers, and non-consecutive hyphens (-), and cannot start or end with a hyphen: "${name}"`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function promptDevDomainNameAsync(initialDevDomain: string): Promise<string> {
|
||||
const rootDomain = `.${EXPO_BASE_DOMAIN}.app`;
|
||||
const memoizedFormatDevDomainName = memoize(formatDevDomainName);
|
||||
@@ -87,18 +153,7 @@ async function promptDevDomainNameAsync(initialDevDomain: string): Promise<strin
|
||||
name: 'name',
|
||||
message: 'Choose a preview URL for your project:',
|
||||
initial: initialDevDomain,
|
||||
validate: (value: string) => {
|
||||
if (!value) {
|
||||
return 'You have to choose a preview URL for your project';
|
||||
}
|
||||
if (value.length < 3) {
|
||||
return 'Preview URLs must be at least 3 characters long';
|
||||
}
|
||||
if (value.endsWith('-')) {
|
||||
return 'Preview URLs cannot end with a hyphen (-)';
|
||||
}
|
||||
return true;
|
||||
},
|
||||
validate: validateDevDomainName,
|
||||
onState(this: PromptInstance, state: { value?: string }) {
|
||||
const value = memoizedFormatDevDomainName(state.value);
|
||||
if (value !== state.value) {
|
||||
@@ -129,23 +184,28 @@ async function promptDevDomainNameAsync(initialDevDomain: string): Promise<strin
|
||||
|
||||
/**
|
||||
* Assign a dev domain name to a project.
|
||||
* - When a dev domain name is provided, it will assign that name without prompting.
|
||||
* - When running in interactive mode, it will prompt the user with a suggested domain name.
|
||||
* - When running in non interactive mode, it will auto-assign the suggested domain name.
|
||||
*/
|
||||
export async function assignDevDomainNameAsync({
|
||||
graphqlClient,
|
||||
appId,
|
||||
devDomainName: requestedDevDomainName,
|
||||
nonInteractive,
|
||||
}: {
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
appId: string;
|
||||
devDomainName?: string;
|
||||
nonInteractive?: boolean;
|
||||
}): ReturnType<typeof DeploymentsMutation.assignDevDomainNameAsync> {
|
||||
let devDomainName = await DeploymentsQuery.getSuggestedDevDomainByAppIdAsync(graphqlClient, {
|
||||
appId,
|
||||
});
|
||||
let devDomainName =
|
||||
requestedDevDomainName ??
|
||||
(await DeploymentsQuery.getSuggestedDevDomainByAppIdAsync(graphqlClient, {
|
||||
appId,
|
||||
}));
|
||||
|
||||
if (!nonInteractive) {
|
||||
if (!requestedDevDomainName && !nonInteractive) {
|
||||
devDomainName = await promptDevDomainNameAsync(devDomainName);
|
||||
}
|
||||
|
||||
@@ -164,10 +224,17 @@ export async function assignDevDomainNameAsync({
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!nonInteractive) {
|
||||
Log.error(`The preview URL "${devDomainName}" is already taken, choose a different URL.`);
|
||||
if (nonInteractive) {
|
||||
throw new Error(
|
||||
requestedDevDomainName
|
||||
? `The preview URL "${requestedDevDomainName}" is already taken, choose a different URL with the --dev-domain flag.`
|
||||
: `The suggested preview URL "${devDomainName}" is already taken, choose a different URL with the --dev-domain flag.`
|
||||
);
|
||||
}
|
||||
|
||||
// In interactive mode, fall back to the prompt so a taken name (requested or suggested)
|
||||
// does not abort the deployment.
|
||||
Log.error(`The preview URL "${devDomainName}" is already taken, choose a different URL.`);
|
||||
return await assignDevDomainNameAsync({ graphqlClient, appId, nonInteractive });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import { WorkerDeploymentAliasFragmentNode } from './fragments/WorkerDeploymentA
|
||||
import type { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { withErrorHandlingAsync } from '../graphql/client';
|
||||
import {
|
||||
DevDomainNameByAppIdQuery,
|
||||
DevDomainNameByAppIdQueryVariables,
|
||||
type PaginatedWorkerDeploymentsQuery,
|
||||
type PaginatedWorkerDeploymentsQueryVariables,
|
||||
SuggestedDevDomainNameQuery,
|
||||
@@ -89,6 +91,35 @@ export const DeploymentsQuery = {
|
||||
return data.app.byId.suggestedDevDomainName;
|
||||
},
|
||||
|
||||
async getDevDomainNameByAppIdAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{ appId }: DevDomainNameByAppIdQueryVariables
|
||||
): Promise<string | null> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<DevDomainNameByAppIdQuery, DevDomainNameByAppIdQueryVariables>(
|
||||
gql`
|
||||
query DevDomainNameByAppId($appId: String!) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
devDomainName {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ appId },
|
||||
{ additionalTypenames: ['AppDevDomainName'] }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
|
||||
return data.app.byId.devDomainName?.name ?? null;
|
||||
},
|
||||
|
||||
async getAllAliasesPaginatedAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-json",
|
||||
"version": "21.0.0",
|
||||
"version": "21.0.2",
|
||||
"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": "21.0.0",
|
||||
"@expo/eas-build-job": "21.0.2",
|
||||
"chalk": "4.1.2",
|
||||
"env-string": "1.0.1",
|
||||
"fs-extra": "11.2.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli-local-build-plugin",
|
||||
"version": "21.0.0",
|
||||
"version": "21.0.2",
|
||||
"description": "Tool for running EAS compatible builds on a local machine.",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
@@ -26,8 +26,8 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/build-tools": "21.0.0",
|
||||
"@expo/eas-build-job": "21.0.0",
|
||||
"@expo/build-tools": "21.0.2",
|
||||
"@expo/eas-build-job": "21.0.2",
|
||||
"@expo/logger": "21.0.0",
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
"@expo/turtle-spawn": "21.0.0",
|
||||
@@ -36,10 +36,10 @@
|
||||
"env-paths": "^2.2.1",
|
||||
"fs-extra": "^11.2.0",
|
||||
"joi": "^17.13.1",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash": "^4.18.1",
|
||||
"nullthrows": "^1.1.1",
|
||||
"semver": "^7.6.2",
|
||||
"tar": "7.5.7",
|
||||
"tar": "7.5.19",
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/steps",
|
||||
"version": "21.0.0",
|
||||
"version": "21.0.2",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -27,7 +27,7 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/eas-build-job": "21.0.0",
|
||||
"@expo/eas-build-job": "21.0.2",
|
||||
"@expo/logger": "21.0.0",
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
"arg": "^5.0.2",
|
||||
|
||||
@@ -5,6 +5,7 @@ import { BuildStepGlobalContext } from './BuildStepContext';
|
||||
import { BuildWorkflow } from './BuildWorkflow';
|
||||
import { BuildWorkflowValidator } from './BuildWorkflowValidator';
|
||||
import { BuildConfigError } from './errors';
|
||||
import { AnchorHooks } from './hooks';
|
||||
import { duplicates } from './utils/expodash/duplicates';
|
||||
import { uniq } from './utils/expodash/uniq';
|
||||
|
||||
@@ -30,9 +31,13 @@ export abstract class AbstractConfigParser {
|
||||
}
|
||||
|
||||
public async parseAsync(): Promise<BuildWorkflow> {
|
||||
const { buildSteps, buildFunctionById } =
|
||||
const { buildSteps, buildFunctionById, hooksByAnchorStep } =
|
||||
await this.parseConfigToBuildStepsAndBuildFunctionByIdMappingAsync();
|
||||
const workflow = new BuildWorkflow(this.ctx, { buildSteps, buildFunctions: buildFunctionById });
|
||||
const workflow = new BuildWorkflow(this.ctx, {
|
||||
buildSteps,
|
||||
buildFunctions: buildFunctionById,
|
||||
hooksByAnchorStep,
|
||||
});
|
||||
await new BuildWorkflowValidator(workflow).validateAsync();
|
||||
return workflow;
|
||||
}
|
||||
@@ -40,6 +45,7 @@ export abstract class AbstractConfigParser {
|
||||
protected abstract parseConfigToBuildStepsAndBuildFunctionByIdMappingAsync(): Promise<{
|
||||
buildSteps: BuildStep[];
|
||||
buildFunctionById: BuildFunctionById;
|
||||
hooksByAnchorStep: ReadonlyMap<BuildStep, AnchorHooks>;
|
||||
}>;
|
||||
|
||||
private validateExternalFunctions(externalFunctions?: BuildFunction[]): void {
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
} from './BuildStepInput';
|
||||
import { BuildStepOutput, BuildStepOutputProvider } from './BuildStepOutput';
|
||||
import { BuildConfigError } from './errors';
|
||||
import { AnchorHooks } from './hooks';
|
||||
|
||||
export class BuildConfigParser extends AbstractConfigParser {
|
||||
private readonly configPath: string;
|
||||
@@ -59,6 +60,7 @@ export class BuildConfigParser extends AbstractConfigParser {
|
||||
protected async parseConfigToBuildStepsAndBuildFunctionByIdMappingAsync(): Promise<{
|
||||
buildSteps: BuildStep[];
|
||||
buildFunctionById: BuildFunctionById;
|
||||
hooksByAnchorStep: ReadonlyMap<BuildStep, AnchorHooks>;
|
||||
}> {
|
||||
const config = await readAndValidateBuildConfigFromPathAsync(this.configPath, {
|
||||
externalFunctionIds: this.getExternalFunctionFullIds(),
|
||||
@@ -81,6 +83,9 @@ export class BuildConfigParser extends AbstractConfigParser {
|
||||
return {
|
||||
buildSteps,
|
||||
buildFunctionById: buildFunctions,
|
||||
// config.yml builds have no hooks — the payload `hooks` field never
|
||||
// reaches this parser.
|
||||
hooksByAnchorStep: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { HookAnchorId } from '@expo/eas-build-job';
|
||||
import assert from 'assert';
|
||||
|
||||
import { BuildRuntimePlatform } from './BuildRuntimePlatform';
|
||||
@@ -6,11 +7,28 @@ import { BuildStepGlobalContext } from './BuildStepContext';
|
||||
import { BuildStepEnv } from './BuildStepEnv';
|
||||
import { BuildStepInputProvider } from './BuildStepInput';
|
||||
import { BuildStepOutputProvider } from './BuildStepOutput';
|
||||
import { BuildConfigError } from './errors';
|
||||
import { createCustomFunctionCall } from './utils/customFunction';
|
||||
|
||||
export type BuildFunctionById = Record<string, BuildFunction>;
|
||||
export type BuildFunctionCallInputs = Record<string, unknown>;
|
||||
|
||||
export function createBuildFunctionByIdMapping(
|
||||
buildFunctions: readonly BuildFunction[]
|
||||
): BuildFunctionById {
|
||||
const buildFunctionById: BuildFunctionById = {};
|
||||
for (const buildFunction of buildFunctions) {
|
||||
const fullId = buildFunction.getFullId();
|
||||
// Without the fence, last-write-wins would let the provider's array order
|
||||
// silently decide which implementation a `uses:` step resolves to.
|
||||
if (buildFunctionById[fullId] !== undefined) {
|
||||
throw new BuildConfigError(`Build function with id ${fullId} is already defined.`);
|
||||
}
|
||||
buildFunctionById[fullId] = buildFunction;
|
||||
}
|
||||
return buildFunctionById;
|
||||
}
|
||||
|
||||
export class BuildFunction {
|
||||
public readonly namespace?: string;
|
||||
public readonly id: string;
|
||||
@@ -23,6 +41,11 @@ export class BuildFunction {
|
||||
public readonly fn?: BuildStepFunction;
|
||||
public readonly shell?: string;
|
||||
public readonly __metricsId?: string;
|
||||
// The hook anchor this function declares itself as (the `__metricsId`
|
||||
// pattern). Registry-typed, so an unregistered anchor is a compile error;
|
||||
// a step-level `__hook_id` stamp overrides it (see
|
||||
// StepsConfigParser.resolveStepAnchor for the resolution rules).
|
||||
public readonly __hookId?: HookAnchorId;
|
||||
|
||||
constructor({
|
||||
namespace,
|
||||
@@ -36,6 +59,7 @@ export class BuildFunction {
|
||||
customFunctionModulePath,
|
||||
shell,
|
||||
__metricsId,
|
||||
__hookId,
|
||||
}: {
|
||||
namespace?: string;
|
||||
id: string;
|
||||
@@ -48,6 +72,7 @@ export class BuildFunction {
|
||||
fn?: BuildStepFunction;
|
||||
shell?: string;
|
||||
__metricsId?: string;
|
||||
__hookId?: HookAnchorId;
|
||||
}) {
|
||||
assert(
|
||||
command !== undefined || fn !== undefined || customFunctionModulePath !== undefined,
|
||||
@@ -75,6 +100,7 @@ export class BuildFunction {
|
||||
this.shell = shell;
|
||||
this.customFunctionModulePath = customFunctionModulePath;
|
||||
this.__metricsId = __metricsId;
|
||||
this.__hookId = __hookId;
|
||||
}
|
||||
|
||||
public getFullId(): string {
|
||||
@@ -134,6 +160,7 @@ export class BuildFunction {
|
||||
ifCondition,
|
||||
timeoutMs,
|
||||
__metricsId: this.__metricsId,
|
||||
__hookId: this.__hookId,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { JobInterpolationContext } from '@expo/eas-build-job';
|
||||
import { HookAnchorId, JobInterpolationContext } from '@expo/eas-build-job';
|
||||
import assert from 'assert';
|
||||
import { Buffer } from 'buffer';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import util from 'util';
|
||||
|
||||
import { BuildRuntimePlatform } from './BuildRuntimePlatform';
|
||||
import { BuildStepContext, BuildStepGlobalContext } from './BuildStepContext';
|
||||
@@ -22,7 +23,7 @@ import {
|
||||
} from './BuildTemporaryFiles';
|
||||
import { BuildStepRuntimeError } from './errors';
|
||||
import { interpolateJobContext } from './interpolation';
|
||||
import { jsepEval } from './utils/jsepEval';
|
||||
import { evaluateIfCondition } from './utils/jsepEval';
|
||||
import { BIN_PATH } from './utils/shell/bin';
|
||||
import { getShellCommandAndArgs } from './utils/shell/command';
|
||||
import { spawnAsync } from './utils/shell/spawn';
|
||||
@@ -135,6 +136,7 @@ export class BuildStep extends BuildStepOutputAccessor {
|
||||
public readonly ifCondition?: string;
|
||||
public readonly timeoutMs?: number;
|
||||
public readonly __metricsId?: string;
|
||||
public readonly __hookId?: HookAnchorId;
|
||||
public status: BuildStepStatus;
|
||||
private readonly outputsDir: string;
|
||||
private readonly envsDir: string;
|
||||
@@ -162,6 +164,7 @@ export class BuildStep extends BuildStepOutputAccessor {
|
||||
ifCondition,
|
||||
timeoutMs,
|
||||
__metricsId,
|
||||
__hookId,
|
||||
}: {
|
||||
id: string;
|
||||
displayName: string;
|
||||
@@ -176,6 +179,7 @@ export class BuildStep extends BuildStepOutputAccessor {
|
||||
ifCondition?: string;
|
||||
timeoutMs?: number;
|
||||
__metricsId?: string;
|
||||
__hookId?: HookAnchorId;
|
||||
}
|
||||
) {
|
||||
assert(command !== undefined || fn !== undefined, 'Either command or fn must be defined.');
|
||||
@@ -195,6 +199,7 @@ export class BuildStep extends BuildStepOutputAccessor {
|
||||
this.ifCondition = ifCondition;
|
||||
this.timeoutMs = timeoutMs;
|
||||
this.__metricsId = __metricsId;
|
||||
this.__hookId = __hookId;
|
||||
this.status = BuildStepStatus.NEW;
|
||||
|
||||
const logger = ctx.baseLogger.child({
|
||||
@@ -270,13 +275,21 @@ export class BuildStep extends BuildStepOutputAccessor {
|
||||
);
|
||||
this.status = BuildStepStatus.SUCCESS;
|
||||
} catch (err) {
|
||||
this.ctx.logger.error({ err });
|
||||
// Downstream error handling relies on real Errors; wrap non-Error
|
||||
// throwables here, at the only step-execution boundary.
|
||||
const error =
|
||||
err instanceof Error
|
||||
? err
|
||||
: new BuildStepRuntimeError(
|
||||
`Build step "${this.displayName}" threw a non-Error value: ${util.inspect(err)}`
|
||||
);
|
||||
this.ctx.logger.error({ err: error });
|
||||
this.ctx.logger.error(
|
||||
{ marker: BuildStepLogMarker.END_STEP, result: BuildStepStatus.FAIL },
|
||||
`Build step "${this.displayName}" failed`
|
||||
);
|
||||
this.status = BuildStepStatus.FAIL;
|
||||
throw err;
|
||||
throw error;
|
||||
} finally {
|
||||
this.executed = true;
|
||||
|
||||
@@ -311,16 +324,9 @@ export class BuildStep extends BuildStepOutputAccessor {
|
||||
return !hasAnyPreviousStepFailed;
|
||||
}
|
||||
|
||||
let ifCondition = this.ifCondition;
|
||||
|
||||
if (ifCondition.startsWith('${{') && ifCondition.endsWith('}}')) {
|
||||
ifCondition = ifCondition.slice(3, -2);
|
||||
} else if (ifCondition.startsWith('${') && ifCondition.endsWith('}')) {
|
||||
ifCondition = ifCondition.slice(2, -1);
|
||||
}
|
||||
|
||||
return Boolean(
|
||||
jsepEval(ifCondition, {
|
||||
return evaluateIfCondition(
|
||||
this.ifCondition,
|
||||
this.ctx.global.getIfConditionContext({
|
||||
inputs:
|
||||
this.inputs?.reduce(
|
||||
(acc, input) => {
|
||||
@@ -331,12 +337,7 @@ export class BuildStep extends BuildStepOutputAccessor {
|
||||
},
|
||||
{} as Record<string, unknown>
|
||||
) ?? {},
|
||||
eas: {
|
||||
runtimePlatform: this.ctx.global.runtimePlatform,
|
||||
...this.ctx.global.staticContext,
|
||||
env: this.getScriptEnv(),
|
||||
},
|
||||
...this.getInterpolationContext(),
|
||||
env: this.getScriptEnv(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
import { BuildRuntimePlatform } from './BuildRuntimePlatform';
|
||||
import { BuildStep, BuildStepOutputAccessor, SerializedBuildStepOutputAccessor } from './BuildStep';
|
||||
import { BuildStepEnv } from './BuildStepEnv';
|
||||
import { StepMetric, StepMetricInput } from './StepMetrics';
|
||||
import { StepMetric, StepMetricInput, StepMetricResult, WorkflowHookMetric } from './StepMetrics';
|
||||
import { BuildStepRuntimeError } from './errors';
|
||||
import { hashFiles } from './utils/hashFiles';
|
||||
import {
|
||||
@@ -41,6 +41,7 @@ export interface ExternalBuildContextProvider {
|
||||
readonly env: BuildStepEnv;
|
||||
updateEnv(env: BuildStepEnv): void;
|
||||
reportStepMetric?(metric: StepMetric): void;
|
||||
reportWorkflowHookMetric?(metric: WorkflowHookMetric): void;
|
||||
}
|
||||
|
||||
export interface SerializedBuildStepGlobalContext {
|
||||
@@ -147,17 +148,42 @@ export class BuildStepGlobalContext {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One builder for both step-level and hook-entry-level `if:` contexts so
|
||||
* the two cannot drift; the real differences (step inputs, step-level env
|
||||
* overrides) ride in as parameters.
|
||||
*/
|
||||
public getIfConditionContext({
|
||||
inputs,
|
||||
env,
|
||||
}: {
|
||||
inputs: Record<string, unknown>;
|
||||
env: BuildStepEnv;
|
||||
}): Record<string, unknown> {
|
||||
return {
|
||||
inputs,
|
||||
eas: this.getEasContext(env),
|
||||
...this.getInterpolationContext(),
|
||||
env,
|
||||
};
|
||||
}
|
||||
|
||||
// The one definition of the user-visible `eas.*` namespace shape.
|
||||
private getEasContext(env: BuildStepEnv): Record<string, unknown> {
|
||||
return {
|
||||
runtimePlatform: this.runtimePlatform,
|
||||
...this.staticContext,
|
||||
env,
|
||||
};
|
||||
}
|
||||
|
||||
public interpolate<InterpolableType extends string | object>(
|
||||
value: InterpolableType
|
||||
): InterpolableType {
|
||||
return interpolateWithGlobalContext(value, path => {
|
||||
return (
|
||||
getObjectValueForInterpolation(path, {
|
||||
eas: {
|
||||
runtimePlatform: this.runtimePlatform,
|
||||
...this.staticContext,
|
||||
env: this.env,
|
||||
},
|
||||
eas: this.getEasContext(this.env),
|
||||
})?.toString() ?? ''
|
||||
);
|
||||
});
|
||||
@@ -187,6 +213,22 @@ export class BuildStepGlobalContext {
|
||||
this.provider.reportStepMetric?.(stepMetric);
|
||||
}
|
||||
|
||||
public collectStepMetric(step: BuildStep, result: StepMetricResult, durationMs: number): void {
|
||||
if (!step.__metricsId) {
|
||||
return;
|
||||
}
|
||||
this.addStepMetric({ metricsId: step.__metricsId, result, durationMs });
|
||||
}
|
||||
|
||||
public reportWorkflowHookMetric(metric: WorkflowHookMetric): void {
|
||||
try {
|
||||
this.provider.reportWorkflowHookMetric?.(metric);
|
||||
} catch (err) {
|
||||
// Telemetry must never fail the job or mask a step's error.
|
||||
this.baseLogger.debug({ err }, 'Reporting the workflow hook metric failed');
|
||||
}
|
||||
}
|
||||
|
||||
public wasCheckedOut(): boolean {
|
||||
return this.didCheckOut;
|
||||
}
|
||||
|
||||
@@ -1,68 +1,290 @@
|
||||
import { HookAnchorId } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
|
||||
import { BuildFunctionById } from './BuildFunction';
|
||||
import { BuildRuntimePlatform } from './BuildRuntimePlatform';
|
||||
import { BuildStep } from './BuildStep';
|
||||
import { BuildStepGlobalContext } from './BuildStepContext';
|
||||
import { StepMetricResult } from './StepMetrics';
|
||||
import { AnchorHooks, HookEntry } from './hooks';
|
||||
import { evaluateIfCondition } from './utils/jsepEval';
|
||||
|
||||
export class BuildWorkflow {
|
||||
public readonly buildSteps: BuildStep[];
|
||||
public readonly buildFunctions: BuildFunctionById;
|
||||
// Hook steps do NOT join buildSteps; they execute around their anchor step
|
||||
// below, keyed by anchor BuildStep instance so each occurrence carries its
|
||||
// own per-occurrence hook entries.
|
||||
public readonly hooksByAnchorStep: ReadonlyMap<BuildStep, AnchorHooks>;
|
||||
|
||||
constructor(
|
||||
private readonly ctx: BuildStepGlobalContext,
|
||||
{ buildSteps, buildFunctions }: { buildSteps: BuildStep[]; buildFunctions: BuildFunctionById }
|
||||
{
|
||||
buildSteps,
|
||||
buildFunctions,
|
||||
hooksByAnchorStep,
|
||||
}: {
|
||||
buildSteps: BuildStep[];
|
||||
buildFunctions: BuildFunctionById;
|
||||
hooksByAnchorStep?: ReadonlyMap<BuildStep, AnchorHooks>;
|
||||
}
|
||||
) {
|
||||
this.buildSteps = buildSteps;
|
||||
this.buildFunctions = buildFunctions;
|
||||
this.hooksByAnchorStep = hooksByAnchorStep ?? new Map();
|
||||
}
|
||||
|
||||
public get runtimePlatform(): BuildRuntimePlatform {
|
||||
return this.ctx.runtimePlatform;
|
||||
}
|
||||
|
||||
/**
|
||||
* All steps in execution order: before-hooks → anchor → after-hooks per
|
||||
* occurrence. This is the validation view; execution itself walks
|
||||
* `buildSteps` and expands hooks around each anchor.
|
||||
*/
|
||||
public getExecutionOrderedSteps(): BuildStep[] {
|
||||
const orderedSteps: BuildStep[] = [];
|
||||
for (const step of this.buildSteps) {
|
||||
const hooks = this.hooksByAnchorStep.get(step);
|
||||
orderedSteps.push(
|
||||
...(hooks?.before ?? []).flatMap(entry => entry.steps),
|
||||
step,
|
||||
...(hooks?.after ?? []).flatMap(entry => entry.steps)
|
||||
);
|
||||
}
|
||||
return orderedSteps;
|
||||
}
|
||||
|
||||
public async executeAsync(): Promise<void> {
|
||||
let maybeError: Error | null = null;
|
||||
for (const step of this.buildSteps) {
|
||||
let shouldExecuteStep = false;
|
||||
// Boolean, not error truthiness — gate-evaluation throws bypass
|
||||
// BuildStep's non-Error normalization.
|
||||
let hasFailed = false;
|
||||
let firstError: unknown;
|
||||
const recordFailure = (err: unknown): void => {
|
||||
if (!hasFailed) {
|
||||
hasFailed = true;
|
||||
firstError = err;
|
||||
}
|
||||
this.ctx.markAsFailed();
|
||||
};
|
||||
|
||||
for (const step of this.buildSteps) {
|
||||
const hooks = this.hooksByAnchorStep.get(step);
|
||||
|
||||
// The anchor's gate is evaluated ONCE and decides for the whole
|
||||
// occurrence; an anchor's `if:` cannot see its before-hooks' outputs.
|
||||
let shouldExecuteStep = false;
|
||||
try {
|
||||
shouldExecuteStep = step.shouldExecuteStep();
|
||||
} catch (err: any) {
|
||||
step.ctx.logger.error({ err });
|
||||
step.ctx.logger.error(
|
||||
`Runner failed to evaluate if it should execute step "${step.displayName}", using step's if condition "${step.ifCondition}". This can be caused by trying to access non-existing object property. If you think this is a bug report it here: https://github.com/expo/eas-cli/issues.`
|
||||
logConditionEvaluationError(
|
||||
step.ctx.logger,
|
||||
err,
|
||||
`step "${step.displayName}"`,
|
||||
step.ifCondition
|
||||
);
|
||||
maybeError = maybeError ?? err;
|
||||
this.ctx.markAsFailed();
|
||||
recordFailure(err);
|
||||
}
|
||||
|
||||
if (shouldExecuteStep) {
|
||||
const startTime = performance.now();
|
||||
let stepResult: StepMetricResult;
|
||||
try {
|
||||
await step.executeAsync();
|
||||
stepResult = 'success';
|
||||
} catch (err: any) {
|
||||
stepResult = 'failed';
|
||||
maybeError = maybeError ?? err;
|
||||
this.ctx.markAsFailed();
|
||||
} finally {
|
||||
this.collectStepMetrics(step, stepResult!, performance.now() - startTime);
|
||||
}
|
||||
} else {
|
||||
if (!shouldExecuteStep) {
|
||||
step.skip();
|
||||
for (const entry of [...(hooks?.before ?? []), ...(hooks?.after ?? [])]) {
|
||||
for (const hookStep of entry.steps) {
|
||||
hookStep.skip();
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const before = await this.executeHookSideAsync(hooks, 'before');
|
||||
if (before.failedLocally) {
|
||||
recordFailure(before.firstError);
|
||||
step.skip();
|
||||
for (const entry of hooks?.after ?? []) {
|
||||
for (const hookStep of entry.steps) {
|
||||
hookStep.skip();
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// LOCAL outcome, not the global flag.
|
||||
let anchorFailed = false;
|
||||
const startTime = performance.now();
|
||||
let stepResult: StepMetricResult = 'success';
|
||||
try {
|
||||
await step.executeAsync();
|
||||
} catch (err: any) {
|
||||
stepResult = 'failed';
|
||||
anchorFailed = true;
|
||||
recordFailure(err);
|
||||
} finally {
|
||||
this.ctx.collectStepMetric(step, stepResult, performance.now() - startTime);
|
||||
}
|
||||
|
||||
const after = await this.executeHookSideAsync(
|
||||
hooks,
|
||||
'after',
|
||||
anchorFailed ? 'failed' : 'success'
|
||||
);
|
||||
if (after.failedLocally) {
|
||||
recordFailure(after.firstError);
|
||||
}
|
||||
}
|
||||
|
||||
if (maybeError) {
|
||||
throw maybeError;
|
||||
if (hasFailed) {
|
||||
throw firstError;
|
||||
}
|
||||
}
|
||||
|
||||
private collectStepMetrics(step: BuildStep, result: StepMetricResult, durationMs: number): void {
|
||||
if (!step.__metricsId) {
|
||||
return;
|
||||
private async executeHookSideAsync(
|
||||
hooks: AnchorHooks | undefined,
|
||||
timing: 'before' | 'after',
|
||||
anchorResult?: StepMetricResult
|
||||
): Promise<{ failedLocally: boolean; firstError: unknown }> {
|
||||
if (hooks === undefined) {
|
||||
return { failedLocally: false, firstError: undefined };
|
||||
}
|
||||
|
||||
this.ctx.addStepMetric({
|
||||
metricsId: step.__metricsId,
|
||||
result,
|
||||
durationMs,
|
||||
return await executeHookStepsAsync(this.ctx, hooks[timing], {
|
||||
anchor: hooks.anchor,
|
||||
timing,
|
||||
anchorResult,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes hook entries around an anchor: the engine-public execution
|
||||
* primitive. Catches condition-evaluation errors AND execution errors, marks
|
||||
* global failure, preserves the FIRST local error, and reports ONE
|
||||
* `WorkflowHookMetric` per executed hook side.
|
||||
*
|
||||
* Default-run rules (a step or entry with no `if:`):
|
||||
* - `before`: runs unless a failure occurred within THIS hook sequence;
|
||||
* failures predating the call are ignored — "runs iff the anchor runs".
|
||||
* - `after`: runs unconditionally — past the anchor's own failure AND past an
|
||||
* earlier after-entry's failure.
|
||||
* A user `if:` is always evaluated against the real global context, so
|
||||
* `failure()` / `success()` keep their global meaning on both sides.
|
||||
*/
|
||||
export async function executeHookStepsAsync(
|
||||
ctx: BuildStepGlobalContext,
|
||||
entries: HookEntry[],
|
||||
options: {
|
||||
anchor: HookAnchorId;
|
||||
timing: 'before' | 'after';
|
||||
/** The anchor's LOCAL outcome; set by the caller on after-timing calls. */
|
||||
anchorResult?: StepMetricResult;
|
||||
}
|
||||
): Promise<{ failedLocally: boolean; firstError: unknown }> {
|
||||
let failedLocally = false;
|
||||
let firstError: unknown;
|
||||
const recordFailure = (err: unknown): void => {
|
||||
if (!failedLocally) {
|
||||
failedLocally = true;
|
||||
firstError = err;
|
||||
}
|
||||
ctx.markAsFailed();
|
||||
};
|
||||
|
||||
let anyStepExecuted = false;
|
||||
for (const entry of entries) {
|
||||
// Truthiness, not presence: an empty `if:` means "no condition", the same
|
||||
// as BuildStep.shouldExecuteStep treats it.
|
||||
const entryHasExplicitCondition = Boolean(entry.ifCondition);
|
||||
if (entry.ifCondition) {
|
||||
let entryEligible = false;
|
||||
try {
|
||||
entryEligible = evaluateIfCondition(
|
||||
entry.ifCondition,
|
||||
// A group entry has no step to hang the authored condition on, so
|
||||
// its `if:` gets the shared context with no step inputs.
|
||||
ctx.getIfConditionContext({ inputs: {}, env: ctx.env })
|
||||
);
|
||||
} catch (err) {
|
||||
logConditionEvaluationError(
|
||||
entry.steps[0]?.ctx.logger ?? ctx.baseLogger,
|
||||
err,
|
||||
'the hook group step',
|
||||
entry.ifCondition
|
||||
);
|
||||
recordFailure(err);
|
||||
}
|
||||
if (!entryEligible) {
|
||||
for (const step of entry.steps) {
|
||||
step.skip();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let entryFailed = false;
|
||||
for (const step of entry.steps) {
|
||||
let shouldExecuteStep = false;
|
||||
if (step.ifCondition) {
|
||||
try {
|
||||
shouldExecuteStep = step.shouldExecuteStep();
|
||||
} catch (err) {
|
||||
logConditionEvaluationError(
|
||||
step.ctx.logger,
|
||||
err,
|
||||
`step "${step.displayName}"`,
|
||||
step.ifCondition
|
||||
);
|
||||
recordFailure(err);
|
||||
entryFailed = true;
|
||||
}
|
||||
} else {
|
||||
// Before-side: a no-`if:` step runs unless this hook sequence has
|
||||
// already failed. An entry whose explicit condition evaluated true
|
||||
// behaves like a single step whose if: passed — the entry's no-`if:`
|
||||
// steps ignore failures from EARLIER entries (only within-entry
|
||||
// failures skip them).
|
||||
shouldExecuteStep =
|
||||
options.timing === 'after' || (entryHasExplicitCondition ? !entryFailed : !failedLocally);
|
||||
}
|
||||
if (!shouldExecuteStep) {
|
||||
step.skip();
|
||||
continue;
|
||||
}
|
||||
anyStepExecuted = true;
|
||||
const startTime = performance.now();
|
||||
let stepResult: StepMetricResult = 'success';
|
||||
try {
|
||||
await step.executeAsync();
|
||||
} catch (err) {
|
||||
stepResult = 'failed';
|
||||
entryFailed = true;
|
||||
recordFailure(err);
|
||||
} finally {
|
||||
ctx.collectStepMetric(step, stepResult, performance.now() - startTime);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (anyStepExecuted) {
|
||||
ctx.reportWorkflowHookMetric({
|
||||
anchor: options.anchor,
|
||||
timing: options.timing,
|
||||
result: failedLocally ? 'failed' : 'success',
|
||||
...(options.anchorResult !== undefined ? { anchorResult: options.anchorResult } : null),
|
||||
});
|
||||
}
|
||||
|
||||
return { failedLocally, firstError };
|
||||
}
|
||||
|
||||
// The one wording for an `if:` that could not be evaluated — anchor steps,
|
||||
// hook steps, and hook group entries all log through here.
|
||||
function logConditionEvaluationError(
|
||||
logger: bunyan,
|
||||
err: unknown,
|
||||
subject: string,
|
||||
ifCondition: string | undefined
|
||||
): void {
|
||||
logger.error({ err });
|
||||
logger.error(
|
||||
`Runner failed to evaluate if it should execute ${subject}, using its if condition "${ifCondition}". This can be caused by trying to access non-existing object property. If you think this is a bug report it here: https://github.com/expo/eas-cli/issues.`
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import { BuildRuntimePlatform } from './BuildRuntimePlatform';
|
||||
import { BuildStep } from './BuildStep';
|
||||
import { BuildStepInputValueTypeName } from './BuildStepInput';
|
||||
import { BuildWorkflow } from './BuildWorkflow';
|
||||
// Type-only to keep the runtime module graph acyclic (BuildWorkflow imports
|
||||
// hooks.ts, which imports this module for the shared aggregate checks).
|
||||
import type { BuildWorkflow } from './BuildWorkflow';
|
||||
import { BuildConfigError, BuildWorkflowError } from './errors';
|
||||
import { duplicates } from './utils/expodash/duplicates';
|
||||
import { nullthrows } from './utils/nullthrows';
|
||||
@@ -13,134 +16,23 @@ export class BuildWorkflowValidator {
|
||||
constructor(private readonly workflow: BuildWorkflow) {}
|
||||
|
||||
public async validateAsync(): Promise<void> {
|
||||
// Hook steps don't join buildSteps, so validation is NOT free for them:
|
||||
// every aggregate check runs over the execution-ordered view (before →
|
||||
// anchor → after per occurrence), or hook steps would silently escape
|
||||
// id-uniqueness, output-reference, and platform validation.
|
||||
const orderedView = this.workflow.getExecutionOrderedSteps();
|
||||
const errors: BuildConfigError[] = [];
|
||||
errors.push(...this.validateUniqueStepIds());
|
||||
errors.push(...this.validateInputs());
|
||||
errors.push(...this.validateAllowedPlatforms());
|
||||
errors.push(
|
||||
...collectAggregateStepErrors(orderedView, {
|
||||
runtimePlatform: this.workflow.runtimePlatform,
|
||||
})
|
||||
);
|
||||
errors.push(...(await this.validateCustomFunctionModulesAsync()));
|
||||
if (errors.length !== 0) {
|
||||
throw new BuildWorkflowError('Build workflow is invalid.', errors);
|
||||
}
|
||||
}
|
||||
|
||||
private validateUniqueStepIds(): BuildConfigError[] {
|
||||
const stepIds = this.workflow.buildSteps.map(({ id }) => id);
|
||||
const duplicatedStepIds = duplicates(stepIds);
|
||||
if (duplicatedStepIds.length === 0) {
|
||||
return [];
|
||||
} else {
|
||||
const error = new BuildConfigError(
|
||||
`Duplicated step IDs: ${duplicatedStepIds.map(i => `"${i}"`).join(', ')}`
|
||||
);
|
||||
return [error];
|
||||
}
|
||||
}
|
||||
|
||||
private validateInputs(): BuildConfigError[] {
|
||||
const errors: BuildConfigError[] = [];
|
||||
|
||||
const allStepIds = new Set(this.workflow.buildSteps.map(s => s.id));
|
||||
const visitedStepByStepId: Record<string, BuildStep> = {};
|
||||
for (const currentStep of this.workflow.buildSteps) {
|
||||
for (const currentStepInput of currentStep.inputs ?? []) {
|
||||
if (currentStepInput.required && currentStepInput.rawValue === undefined) {
|
||||
const error = new BuildConfigError(
|
||||
`Input parameter "${currentStepInput.id}" for step "${currentStep.displayName}" is required but it was not set.`
|
||||
);
|
||||
errors.push(error);
|
||||
}
|
||||
|
||||
const currentType =
|
||||
typeof currentStepInput.rawValue === 'object'
|
||||
? BuildStepInputValueTypeName.JSON
|
||||
: typeof currentStepInput.rawValue;
|
||||
if (
|
||||
currentStepInput.rawValue !== undefined &&
|
||||
!currentStepInput.isRawValueStepOrContextReference() &&
|
||||
currentType !== currentStepInput.allowedValueTypeName
|
||||
) {
|
||||
const error = new BuildConfigError(
|
||||
`Input parameter "${currentStepInput.id}" for step "${
|
||||
currentStep.displayName
|
||||
}" is set to "${
|
||||
typeof currentStepInput.rawValue === 'object'
|
||||
? JSON.stringify(currentStepInput.rawValue)
|
||||
: currentStepInput.rawValue
|
||||
}" which is not of type "${
|
||||
currentStepInput.allowedValueTypeName
|
||||
}" or is not step or context reference.`
|
||||
);
|
||||
errors.push(error);
|
||||
}
|
||||
|
||||
if (currentStepInput.defaultValue === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (!currentStepInput.isRawValueOneOfAllowedValues()) {
|
||||
const error = new BuildConfigError(
|
||||
`Input parameter "${currentStepInput.id}" for step "${
|
||||
currentStep.displayName
|
||||
}" is set to "${
|
||||
currentStepInput.rawValue
|
||||
}" which is not one of the allowed values: ${nullthrows(currentStepInput.allowedValues)
|
||||
.map(i => `"${i}"`)
|
||||
.join(', ')}.`
|
||||
);
|
||||
errors.push(error);
|
||||
}
|
||||
const paths =
|
||||
typeof currentStepInput.defaultValue === 'string'
|
||||
? findOutputPaths(currentStepInput.defaultValue)
|
||||
: [];
|
||||
for (const { stepId: referencedStepId, outputId: referencedStepOutputId } of paths) {
|
||||
if (!(referencedStepId in visitedStepByStepId)) {
|
||||
if (allStepIds.has(referencedStepId)) {
|
||||
const error = new BuildConfigError(
|
||||
`Input parameter "${currentStepInput.id}" for step "${currentStep.displayName}" uses an expression that references an output parameter from the future step "${referencedStepId}".`
|
||||
);
|
||||
errors.push(error);
|
||||
} else {
|
||||
const error = new BuildConfigError(
|
||||
`Input parameter "${currentStepInput.id}" for step "${currentStep.displayName}" uses an expression that references an output parameter from a non-existent step "${referencedStepId}".`
|
||||
);
|
||||
errors.push(error);
|
||||
}
|
||||
} else {
|
||||
if (!visitedStepByStepId[referencedStepId].hasOutputParameter(referencedStepOutputId)) {
|
||||
const error = new BuildConfigError(
|
||||
`Input parameter "${currentStepInput.id}" for step "${currentStep.displayName}" uses an expression that references an undefined output parameter "${referencedStepOutputId}" from step "${referencedStepId}".`
|
||||
);
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
visitedStepByStepId[currentStep.id] = currentStep;
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
private validateAllowedPlatforms(): BuildConfigError[] {
|
||||
const errors: BuildConfigError[] = [];
|
||||
for (const step of this.workflow.buildSteps) {
|
||||
if (!step.canBeRunOnRuntimePlatform()) {
|
||||
const error = new BuildConfigError(
|
||||
`Step "${step.displayName}" is not allowed on platform "${
|
||||
step.ctx.global.runtimePlatform
|
||||
}". Allowed platforms for this step are: ${nullthrows(
|
||||
step.supportedRuntimePlatforms,
|
||||
`step.supportedRuntimePlatforms can't be falsy if canBeRunOnRuntimePlatform() is false`
|
||||
)
|
||||
.map(p => `"${p}"`)
|
||||
.join(', ')}.`
|
||||
);
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
private async validateCustomFunctionModulesAsync(): Promise<BuildConfigError[]> {
|
||||
const errors: BuildConfigError[] = [];
|
||||
for (const buildFunction of Object.values(this.workflow.buildFunctions)) {
|
||||
@@ -166,3 +58,138 @@ export class BuildWorkflowValidator {
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The aggregate step checks (unique ids, input/output references, runtime
|
||||
* platform allowance) over an execution-ordered view of steps. Shared by the
|
||||
* workflow validator and `validateHookStepsAsync`.
|
||||
*/
|
||||
export function collectAggregateStepErrors(
|
||||
steps: readonly BuildStep[],
|
||||
{ runtimePlatform }: { runtimePlatform: BuildRuntimePlatform }
|
||||
): BuildConfigError[] {
|
||||
return [
|
||||
...validateUniqueStepIds(steps),
|
||||
...validateInputs(steps),
|
||||
...validateAllowedPlatforms(steps, runtimePlatform),
|
||||
];
|
||||
}
|
||||
|
||||
function validateUniqueStepIds(steps: readonly BuildStep[]): BuildConfigError[] {
|
||||
const stepIds = steps.map(({ id }) => id);
|
||||
const duplicatedStepIds = duplicates(stepIds);
|
||||
if (duplicatedStepIds.length === 0) {
|
||||
return [];
|
||||
} else {
|
||||
const error = new BuildConfigError(
|
||||
`Duplicated step IDs: ${duplicatedStepIds.map(i => `"${i}"`).join(', ')}`
|
||||
);
|
||||
return [error];
|
||||
}
|
||||
}
|
||||
|
||||
function validateInputs(steps: readonly BuildStep[]): BuildConfigError[] {
|
||||
const errors: BuildConfigError[] = [];
|
||||
|
||||
const allStepIds = new Set(steps.map(s => s.id));
|
||||
const visitedStepByStepId: Record<string, BuildStep> = {};
|
||||
for (const currentStep of steps) {
|
||||
for (const currentStepInput of currentStep.inputs ?? []) {
|
||||
if (currentStepInput.required && currentStepInput.rawValue === undefined) {
|
||||
const error = new BuildConfigError(
|
||||
`Input parameter "${currentStepInput.id}" for step "${currentStep.displayName}" is required but it was not set.`
|
||||
);
|
||||
errors.push(error);
|
||||
}
|
||||
|
||||
const currentType =
|
||||
typeof currentStepInput.rawValue === 'object'
|
||||
? BuildStepInputValueTypeName.JSON
|
||||
: typeof currentStepInput.rawValue;
|
||||
if (
|
||||
currentStepInput.rawValue !== undefined &&
|
||||
!currentStepInput.isRawValueStepOrContextReference() &&
|
||||
currentType !== currentStepInput.allowedValueTypeName
|
||||
) {
|
||||
const error = new BuildConfigError(
|
||||
`Input parameter "${currentStepInput.id}" for step "${
|
||||
currentStep.displayName
|
||||
}" is set to "${
|
||||
typeof currentStepInput.rawValue === 'object'
|
||||
? JSON.stringify(currentStepInput.rawValue)
|
||||
: currentStepInput.rawValue
|
||||
}" which is not of type "${
|
||||
currentStepInput.allowedValueTypeName
|
||||
}" or is not step or context reference.`
|
||||
);
|
||||
errors.push(error);
|
||||
}
|
||||
|
||||
if (currentStepInput.defaultValue === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (!currentStepInput.isRawValueOneOfAllowedValues()) {
|
||||
const error = new BuildConfigError(
|
||||
`Input parameter "${currentStepInput.id}" for step "${
|
||||
currentStep.displayName
|
||||
}" is set to "${
|
||||
currentStepInput.rawValue
|
||||
}" which is not one of the allowed values: ${nullthrows(currentStepInput.allowedValues)
|
||||
.map(i => `"${i}"`)
|
||||
.join(', ')}.`
|
||||
);
|
||||
errors.push(error);
|
||||
}
|
||||
const paths =
|
||||
typeof currentStepInput.defaultValue === 'string'
|
||||
? findOutputPaths(currentStepInput.defaultValue)
|
||||
: [];
|
||||
for (const { stepId: referencedStepId, outputId: referencedStepOutputId } of paths) {
|
||||
if (!(referencedStepId in visitedStepByStepId)) {
|
||||
if (allStepIds.has(referencedStepId)) {
|
||||
const error = new BuildConfigError(
|
||||
`Input parameter "${currentStepInput.id}" for step "${currentStep.displayName}" uses an expression that references an output parameter from the future step "${referencedStepId}".`
|
||||
);
|
||||
errors.push(error);
|
||||
} else {
|
||||
const error = new BuildConfigError(
|
||||
`Input parameter "${currentStepInput.id}" for step "${currentStep.displayName}" uses an expression that references an output parameter from a non-existent step "${referencedStepId}".`
|
||||
);
|
||||
errors.push(error);
|
||||
}
|
||||
} else {
|
||||
if (!visitedStepByStepId[referencedStepId].hasOutputParameter(referencedStepOutputId)) {
|
||||
const error = new BuildConfigError(
|
||||
`Input parameter "${currentStepInput.id}" for step "${currentStep.displayName}" uses an expression that references an undefined output parameter "${referencedStepOutputId}" from step "${referencedStepId}".`
|
||||
);
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
visitedStepByStepId[currentStep.id] = currentStep;
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateAllowedPlatforms(
|
||||
steps: readonly BuildStep[],
|
||||
runtimePlatform: BuildRuntimePlatform
|
||||
): BuildConfigError[] {
|
||||
const errors: BuildConfigError[] = [];
|
||||
for (const step of steps) {
|
||||
if (!step.canBeRunOnRuntimePlatform()) {
|
||||
const error = new BuildConfigError(
|
||||
`Step "${step.displayName}" is not allowed on platform "${runtimePlatform}". Allowed platforms for this step are: ${nullthrows(
|
||||
step.supportedRuntimePlatforms,
|
||||
`step.supportedRuntimePlatforms can't be falsy if canBeRunOnRuntimePlatform() is false`
|
||||
)
|
||||
.map(p => `"${p}"`)
|
||||
.join(', ')}.`
|
||||
);
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { HookAnchorId } from '@expo/eas-build-job';
|
||||
|
||||
export type StepMetricResult = 'success' | 'failed';
|
||||
|
||||
export type StepMetricInput = {
|
||||
@@ -9,3 +11,16 @@ export type StepMetricInput = {
|
||||
export type StepMetric = StepMetricInput & {
|
||||
platform: 'darwin' | 'linux';
|
||||
};
|
||||
|
||||
/**
|
||||
* One event per executed hook side (skipped hooks report nothing). `result` is
|
||||
* the hook's own outcome, aggregated across the side's entries; `anchorResult`
|
||||
* is the wrapped anchor step's outcome — after-timing only, and LOCAL: an
|
||||
* anchor running green past an earlier failure via `always()` is 'success'.
|
||||
*/
|
||||
export type WorkflowHookMetric = {
|
||||
anchor: HookAnchorId;
|
||||
timing: 'before' | 'after';
|
||||
result: StepMetricResult;
|
||||
anchorResult?: StepMetricResult;
|
||||
};
|
||||
@@ -1,15 +1,18 @@
|
||||
import {
|
||||
FunctionStep,
|
||||
ShellStep,
|
||||
HookAnchorId,
|
||||
Hooks,
|
||||
Step,
|
||||
isHookAnchorId,
|
||||
isStepFunctionStep,
|
||||
isStepShellStep,
|
||||
parseHookKey,
|
||||
validateSteps,
|
||||
} from '@expo/eas-build-job';
|
||||
import assert from 'node:assert';
|
||||
|
||||
import { AbstractConfigParser } from './AbstractConfigParser';
|
||||
import { BuildFunction, BuildFunctionById } from './BuildFunction';
|
||||
import { BuildFunction, BuildFunctionById, createBuildFunctionByIdMapping } from './BuildFunction';
|
||||
import {
|
||||
BuildFunctionGroup,
|
||||
BuildFunctionGroupById,
|
||||
@@ -17,20 +20,31 @@ import {
|
||||
} from './BuildFunctionGroup';
|
||||
import { BuildStep } from './BuildStep';
|
||||
import { BuildStepGlobalContext } from './BuildStepContext';
|
||||
import { BuildStepOutput } from './BuildStepOutput';
|
||||
import { BuildConfigError } from './errors';
|
||||
import {
|
||||
AnchorHooks,
|
||||
HookEntry,
|
||||
constructHookEntriesFromValidatedSteps,
|
||||
createBuildStepFromShellStep,
|
||||
validateAllStepFunctionsExist,
|
||||
} from './hooks';
|
||||
|
||||
export class StepsConfigParser extends AbstractConfigParser {
|
||||
private readonly steps: Step[];
|
||||
private readonly hooks: Hooks;
|
||||
|
||||
constructor(
|
||||
ctx: BuildStepGlobalContext,
|
||||
{
|
||||
steps,
|
||||
hooks,
|
||||
externalFunctions,
|
||||
externalFunctionGroups,
|
||||
}: {
|
||||
steps: Step[];
|
||||
// Required (not `hooks?:`) so a call site cannot silently forget to pass
|
||||
// the job's hooks — forgetting drops them without a trace.
|
||||
hooks: Hooks | undefined;
|
||||
externalFunctions?: BuildFunction[];
|
||||
externalFunctionGroups?: BuildFunctionGroup[];
|
||||
}
|
||||
@@ -41,70 +55,173 @@ export class StepsConfigParser extends AbstractConfigParser {
|
||||
});
|
||||
|
||||
this.steps = steps;
|
||||
this.hooks = hooks ?? {};
|
||||
}
|
||||
|
||||
protected async parseConfigToBuildStepsAndBuildFunctionByIdMappingAsync(): Promise<{
|
||||
buildSteps: BuildStep[];
|
||||
buildFunctionById: BuildFunctionById;
|
||||
hooksByAnchorStep: ReadonlyMap<BuildStep, AnchorHooks>;
|
||||
}> {
|
||||
const validatedSteps = validateSteps(this.steps);
|
||||
StepsConfigParser.validateAllFunctionsExist(validatedSteps, {
|
||||
const validatedHooks = this.validateHooks();
|
||||
// Hook steps are validated like job steps: a hook `uses:` naming an
|
||||
// unknown function must be a BuildConfigError, not an assertion crash.
|
||||
validateAllStepFunctionsExist([...validatedSteps, ...Object.values(validatedHooks).flat()], {
|
||||
externalFunctionIds: this.getExternalFunctionFullIds(),
|
||||
externalFunctionGroupIds: this.getExternalFunctionGroupFullIds(),
|
||||
});
|
||||
|
||||
const buildFunctionById = this.createBuildFunctionByIdMappingForExternalFunctions();
|
||||
const buildFunctionById = createBuildFunctionByIdMapping(this.externalFunctions ?? []);
|
||||
const buildFunctionGroupById = createBuildFunctionGroupByIdMapping(
|
||||
this.externalFunctionGroups ?? []
|
||||
);
|
||||
|
||||
// Only the job's own steps are scanned — steps constructed from hooks are
|
||||
// never treated as anchors (no nesting). Construction order (before →
|
||||
// anchor → after per occurrence; groups expand first) keeps generated
|
||||
// step ids identical across the splicing→engine rollout.
|
||||
const buildSteps: BuildStep[] = [];
|
||||
const hooksByAnchorStep = new Map<BuildStep, AnchorHooks>();
|
||||
|
||||
for (const stepConfig of validatedSteps) {
|
||||
buildSteps.push(
|
||||
...this.createBuildStepsFromStepConfig(stepConfig, {
|
||||
buildFunctionById,
|
||||
buildFunctionGroupById,
|
||||
})
|
||||
);
|
||||
const maybeFunctionGroup = isStepFunctionStep(stepConfig)
|
||||
? buildFunctionGroupById[stepConfig.uses]
|
||||
: undefined;
|
||||
if (maybeFunctionGroup !== undefined) {
|
||||
// The group expands FIRST (its internal steps get their ids), then the
|
||||
// anchors found among expanded steps get their hook steps constructed.
|
||||
// TODO: allow to set id, name, working_directory, shell, env and if
|
||||
// for function groups
|
||||
const expandedSteps = maybeFunctionGroup.createBuildStepsFromFunctionGroupCall(this.ctx, {
|
||||
callInputs: stepConfig.with,
|
||||
});
|
||||
buildSteps.push(...expandedSteps);
|
||||
for (const expandedStep of expandedSteps) {
|
||||
const anchorId = expandedStep.__hookId;
|
||||
if (anchorId === undefined) {
|
||||
continue;
|
||||
}
|
||||
const anchorHooks = this.constructAnchorHooks(anchorId, validatedHooks, {
|
||||
buildFunctionById,
|
||||
buildFunctionGroupById,
|
||||
});
|
||||
if (anchorHooks !== undefined) {
|
||||
hooksByAnchorStep.set(expandedStep, anchorHooks);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const anchorId = StepsConfigParser.resolveStepAnchor(stepConfig, buildFunctionById);
|
||||
if (anchorId === undefined) {
|
||||
buildSteps.push(this.createBuildStepFromNonGroupStepConfig(stepConfig, buildFunctionById));
|
||||
continue;
|
||||
}
|
||||
const maps = { buildFunctionById, buildFunctionGroupById };
|
||||
const before = this.constructHookSideEntries(anchorId, 'before', validatedHooks, maps);
|
||||
const anchorStep = this.createBuildStepFromNonGroupStepConfig(stepConfig, buildFunctionById);
|
||||
buildSteps.push(anchorStep);
|
||||
const after = this.constructHookSideEntries(anchorId, 'after', validatedHooks, maps);
|
||||
if (before.length > 0 || after.length > 0) {
|
||||
hooksByAnchorStep.set(anchorStep, { anchor: anchorId, before, after });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
buildSteps,
|
||||
buildFunctionById,
|
||||
hooksByAnchorStep,
|
||||
};
|
||||
}
|
||||
|
||||
private createBuildFunctionByIdMappingForExternalFunctions(): BuildFunctionById {
|
||||
const result: BuildFunctionById = {};
|
||||
|
||||
if (this.externalFunctions === undefined) {
|
||||
return result;
|
||||
private validateHooks(): Record<string, Step[]> {
|
||||
const validatedHooks: Record<string, Step[]> = {};
|
||||
for (const [hookKey, hookSteps] of Object.entries(this.hooks)) {
|
||||
// A worker must not fail on a hook key newer than itself, so unregistered
|
||||
// keys skip validation entirely (their steps may reference functions this
|
||||
// worker lacks).
|
||||
if (parseHookKey(hookKey) === null) {
|
||||
continue;
|
||||
}
|
||||
// An empty array is a deliberate no-op (e.g. opting out of a default);
|
||||
// any other non-step-array shape falls through to validateSteps below so
|
||||
// it errors instead of being dropped silently.
|
||||
if (Array.isArray(hookSteps) && hookSteps.length === 0) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
validatedHooks[hookKey] = validateSteps(hookSteps);
|
||||
} catch (err) {
|
||||
throw new BuildConfigError(
|
||||
`Invalid steps in "hooks.${hookKey}": ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const buildFunction of this.externalFunctions) {
|
||||
const fullId = buildFunction.getFullId();
|
||||
result[fullId] = buildFunction;
|
||||
}
|
||||
return result;
|
||||
return validatedHooks;
|
||||
}
|
||||
|
||||
private createBuildStepsFromStepConfig(
|
||||
stepConfig: Step,
|
||||
{
|
||||
buildFunctionById,
|
||||
buildFunctionGroupById,
|
||||
}: {
|
||||
/**
|
||||
* Resolves a non-group step's anchor. Stamp PRESENCE wins, not stamp
|
||||
* registration: an unregistered stamp value makes the step inert — it never
|
||||
* falls through to the invoked function's declaration (a newer server
|
||||
* stamping a function step with a future anchor must not silently rebind it
|
||||
* to the function's older anchor on this worker). Only when the field is
|
||||
* absent does a function step resolve via its function's own declaration.
|
||||
*/
|
||||
private static resolveStepAnchor(
|
||||
step: Step,
|
||||
buildFunctionById: BuildFunctionById
|
||||
): HookAnchorId | undefined {
|
||||
if (step.__hook_id !== undefined) {
|
||||
return isHookAnchorId(step.__hook_id) ? step.__hook_id : undefined;
|
||||
}
|
||||
if (isStepFunctionStep(step)) {
|
||||
return buildFunctionById[step.uses]?.__hookId;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private constructAnchorHooks(
|
||||
anchorId: HookAnchorId,
|
||||
validatedHooks: Record<string, Step[]>,
|
||||
maps: {
|
||||
buildFunctionById: BuildFunctionById;
|
||||
buildFunctionGroupById: BuildFunctionGroupById;
|
||||
}
|
||||
): BuildStep[] {
|
||||
): AnchorHooks | undefined {
|
||||
const before = this.constructHookSideEntries(anchorId, 'before', validatedHooks, maps);
|
||||
const after = this.constructHookSideEntries(anchorId, 'after', validatedHooks, maps);
|
||||
if (before.length === 0 && after.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return { anchor: anchorId, before, after };
|
||||
}
|
||||
|
||||
private constructHookSideEntries(
|
||||
anchorId: HookAnchorId,
|
||||
side: 'before' | 'after',
|
||||
validatedHooks: Record<string, Step[]>,
|
||||
maps: {
|
||||
buildFunctionById: BuildFunctionById;
|
||||
buildFunctionGroupById: BuildFunctionGroupById;
|
||||
}
|
||||
): HookEntry[] {
|
||||
const hookSteps = validatedHooks[`${side}_${anchorId}`];
|
||||
if (hookSteps === undefined) {
|
||||
return [];
|
||||
}
|
||||
return constructHookEntriesFromValidatedSteps(this.ctx, hookSteps, maps);
|
||||
}
|
||||
|
||||
private createBuildStepFromNonGroupStepConfig(
|
||||
stepConfig: Step,
|
||||
buildFunctionById: BuildFunctionById
|
||||
): BuildStep {
|
||||
if (isStepShellStep(stepConfig)) {
|
||||
return [this.createBuildStepFromShellStepConfig(stepConfig)];
|
||||
return createBuildStepFromShellStep(this.ctx, stepConfig);
|
||||
} else if (isStepFunctionStep(stepConfig)) {
|
||||
return this.createBuildStepsFromFunctionStepConfig(stepConfig, {
|
||||
buildFunctionById,
|
||||
buildFunctionGroupById,
|
||||
});
|
||||
return this.createBuildStepFromFunctionStepConfig(stepConfig, buildFunctionById);
|
||||
} else {
|
||||
throw new BuildConfigError(
|
||||
'Invalid job step configuration detected. Step must be shell or function step'
|
||||
@@ -112,113 +229,21 @@ export class StepsConfigParser extends AbstractConfigParser {
|
||||
}
|
||||
}
|
||||
|
||||
private createBuildStepFromShellStepConfig(step: ShellStep): BuildStep {
|
||||
const id = BuildStep.getNewId(step.id);
|
||||
const displayName =
|
||||
step.name ??
|
||||
step.id ??
|
||||
step.run
|
||||
.split('\n')
|
||||
.find(line => line.trim())
|
||||
?.trim() ??
|
||||
step.run;
|
||||
const outputs =
|
||||
step.outputs && this.createBuildStepOutputsFromDefinition(step.outputs, displayName);
|
||||
return new BuildStep(this.ctx, {
|
||||
id,
|
||||
displayName,
|
||||
outputs,
|
||||
workingDirectory: step.working_directory,
|
||||
shell: step.shell,
|
||||
command: step.run,
|
||||
env: step.env,
|
||||
ifCondition: step.if,
|
||||
__metricsId: step.__metrics_id,
|
||||
});
|
||||
}
|
||||
|
||||
private createBuildStepsFromFunctionStepConfig(
|
||||
private createBuildStepFromFunctionStepConfig(
|
||||
step: FunctionStep,
|
||||
{
|
||||
buildFunctionById,
|
||||
buildFunctionGroupById,
|
||||
}: {
|
||||
buildFunctionById: BuildFunctionById;
|
||||
buildFunctionGroupById: BuildFunctionGroupById;
|
||||
}
|
||||
): BuildStep[] {
|
||||
const functionId = step.uses;
|
||||
const maybeFunctionGroup = buildFunctionGroupById[functionId];
|
||||
if (maybeFunctionGroup) {
|
||||
// TODO: allow to set id, name, working_directory, shell, env and if for function groups
|
||||
return maybeFunctionGroup.createBuildStepsFromFunctionGroupCall(this.ctx, {
|
||||
callInputs: step.with,
|
||||
});
|
||||
}
|
||||
|
||||
const buildFunction = buildFunctionById[functionId];
|
||||
buildFunctionById: BuildFunctionById
|
||||
): BuildStep {
|
||||
const buildFunction = buildFunctionById[step.uses];
|
||||
assert(buildFunction, 'function ID must be ID of function or function group');
|
||||
|
||||
return [
|
||||
buildFunction.createBuildStepFromFunctionCall(this.ctx, {
|
||||
id: step.id,
|
||||
name: step.name,
|
||||
callInputs: step.with,
|
||||
workingDirectory: step.working_directory,
|
||||
shell: step.shell,
|
||||
env: step.env,
|
||||
ifCondition: step.if,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
private createBuildStepOutputsFromDefinition(
|
||||
stepOutputs: Required<ShellStep>['outputs'],
|
||||
stepDisplayName: string
|
||||
): BuildStepOutput[] {
|
||||
return stepOutputs.map(
|
||||
entry =>
|
||||
new BuildStepOutput(this.ctx, {
|
||||
id: entry.name,
|
||||
stepDisplayName,
|
||||
required: entry.required ?? true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private static validateAllFunctionsExist(
|
||||
steps: Step[],
|
||||
{
|
||||
externalFunctionIds,
|
||||
externalFunctionGroupIds,
|
||||
}: {
|
||||
externalFunctionIds: string[];
|
||||
externalFunctionGroupIds: string[];
|
||||
}
|
||||
): void {
|
||||
const calledFunctionsOrFunctionGroupsSet = new Set<string>();
|
||||
for (const step of steps) {
|
||||
if (step.uses) {
|
||||
calledFunctionsOrFunctionGroupsSet.add(step.uses);
|
||||
}
|
||||
}
|
||||
const calledFunctionsOrFunctionGroup = Array.from(calledFunctionsOrFunctionGroupsSet);
|
||||
const externalFunctionIdsSet = new Set(externalFunctionIds);
|
||||
const externalFunctionGroupsIdsSet = new Set(externalFunctionGroupIds);
|
||||
const nonExistentFunctionsOrFunctionGroups = calledFunctionsOrFunctionGroup.filter(
|
||||
calledFunctionOrFunctionGroup => {
|
||||
return (
|
||||
!externalFunctionIdsSet.has(calledFunctionOrFunctionGroup) &&
|
||||
!externalFunctionGroupsIdsSet.has(calledFunctionOrFunctionGroup)
|
||||
);
|
||||
}
|
||||
);
|
||||
if (nonExistentFunctionsOrFunctionGroups.length > 0) {
|
||||
throw new BuildConfigError(
|
||||
`Calling non-existent functions: ${nonExistentFunctionsOrFunctionGroups
|
||||
.map(f => `"${f}"`)
|
||||
.join(', ')}.`
|
||||
);
|
||||
}
|
||||
return buildFunction.createBuildStepFromFunctionCall(this.ctx, {
|
||||
id: step.id,
|
||||
name: step.name,
|
||||
callInputs: step.with,
|
||||
workingDirectory: step.working_directory,
|
||||
shell: step.shell,
|
||||
env: step.env,
|
||||
ifCondition: step.if,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ describe('Publish Update job', () => {
|
||||
}),
|
||||
];
|
||||
const stepsParser = new StepsConfigParser(ctx, {
|
||||
hooks: undefined,
|
||||
steps: [
|
||||
{
|
||||
uses: 'eas/checkout',
|
||||
|
||||
@@ -105,6 +105,31 @@ describe(BuildFunction, () => {
|
||||
expect(step.displayName).toBe('Test function');
|
||||
expect(step.fn).toBe(fn);
|
||||
});
|
||||
it('stamps the declared hook anchor on created build steps', () => {
|
||||
const ctx = createGlobalContextMock();
|
||||
const buildFunction = new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'install_node_modules',
|
||||
command: 'npm install',
|
||||
__hookId: 'install_node_modules',
|
||||
});
|
||||
const step = buildFunction.createBuildStepFromFunctionCall(ctx, {
|
||||
workingDirectory: ctx.defaultWorkingDirectory,
|
||||
});
|
||||
expect(step.__hookId).toBe('install_node_modules');
|
||||
});
|
||||
it('passes the function metrics id to created build steps', () => {
|
||||
const ctx = createGlobalContextMock();
|
||||
const buildFunction = new BuildFunction({
|
||||
id: 'test1',
|
||||
command: 'echo 123',
|
||||
__metricsId: 'eas/test1',
|
||||
});
|
||||
const step = buildFunction.createBuildStepFromFunctionCall(ctx, {
|
||||
workingDirectory: ctx.defaultWorkingDirectory,
|
||||
});
|
||||
expect(step.__metricsId).toBe('eas/test1');
|
||||
});
|
||||
it('works with custom JS/TS function', () => {
|
||||
const ctx = createGlobalContextMock();
|
||||
const buildFunction = new BuildFunction({
|
||||
|
||||
@@ -0,0 +1,638 @@
|
||||
import fs from 'fs/promises';
|
||||
|
||||
import { Hooks, Step } from '@expo/eas-build-job';
|
||||
|
||||
import { createGlobalContextMock } from './utils/context';
|
||||
import { BuildFunction } from '../BuildFunction';
|
||||
import { BuildFunctionGroup } from '../BuildFunctionGroup';
|
||||
import { BuildStepStatus } from '../BuildStep';
|
||||
import { BuildStepGlobalContext } from '../BuildStepContext';
|
||||
import { BuildWorkflow } from '../BuildWorkflow';
|
||||
import { StepsConfigParser } from '../StepsConfigParser';
|
||||
import { WorkflowHookMetric } from '../StepMetrics';
|
||||
import { BuildStepRuntimeError } from '../errors';
|
||||
|
||||
describe('BuildWorkflow hook execution', () => {
|
||||
let ctx: BuildStepGlobalContext;
|
||||
let executionLog: string[];
|
||||
let metrics: WorkflowHookMetric[];
|
||||
|
||||
beforeEach(async () => {
|
||||
executionLog = [];
|
||||
metrics = [];
|
||||
ctx = createGlobalContextMock({
|
||||
reportWorkflowHookMetric: metric => metrics.push(metric),
|
||||
});
|
||||
await fs.mkdir(ctx.defaultWorkingDirectory, { recursive: true });
|
||||
await fs.mkdir(ctx.stepsInternalBuildDirectory, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(ctx.stepsInternalBuildDirectory, { recursive: true, force: true });
|
||||
await fs.rm(ctx.defaultWorkingDirectory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function recordingFunction(id: string, { failWith }: { failWith?: unknown } = {}): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'test',
|
||||
id,
|
||||
fn: () => {
|
||||
executionLog.push(id);
|
||||
if (failWith !== undefined) {
|
||||
throw failWith;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function anchorFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'install_node_modules',
|
||||
name: 'Install node modules',
|
||||
fn: () => {
|
||||
executionLog.push('anchor');
|
||||
},
|
||||
__hookId: 'install_node_modules',
|
||||
});
|
||||
}
|
||||
|
||||
function failingAnchorFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'install_node_modules',
|
||||
name: 'Install node modules',
|
||||
fn: () => {
|
||||
executionLog.push('anchor');
|
||||
throw new Error('anchor failed');
|
||||
},
|
||||
__hookId: 'install_node_modules',
|
||||
});
|
||||
}
|
||||
|
||||
async function parseAsync({
|
||||
steps,
|
||||
hooks,
|
||||
externalFunctions,
|
||||
externalFunctionGroups,
|
||||
}: {
|
||||
steps: Step[];
|
||||
hooks: Hooks | undefined;
|
||||
externalFunctions: BuildFunction[];
|
||||
externalFunctionGroups?: BuildFunctionGroup[];
|
||||
}): Promise<BuildWorkflow> {
|
||||
const parser = new StepsConfigParser(ctx, {
|
||||
steps,
|
||||
hooks,
|
||||
externalFunctions,
|
||||
externalFunctionGroups,
|
||||
});
|
||||
return await parser.parseAsync();
|
||||
}
|
||||
|
||||
function hookStepStatuses(workflow: BuildWorkflow): Record<string, BuildStepStatus> {
|
||||
const statuses: Record<string, BuildStepStatus> = {};
|
||||
for (const anchorHooks of workflow.hooksByAnchorStep.values()) {
|
||||
for (const entry of [...anchorHooks.before, ...anchorHooks.after]) {
|
||||
for (const step of entry.steps) {
|
||||
statuses[step.id] = step.status;
|
||||
}
|
||||
}
|
||||
}
|
||||
return statuses;
|
||||
}
|
||||
|
||||
describe('hooks run iff the anchor runs', () => {
|
||||
it('skips before AND after hooks (displayed as skipped) when the anchor if: is false', async () => {
|
||||
const workflow = await parseAsync({
|
||||
steps: [{ uses: 'eas/install_node_modules', if: '${{ false }}' }],
|
||||
hooks: {
|
||||
before_install_node_modules: [{ uses: 'test/before', id: 'before-hook' }],
|
||||
after_install_node_modules: [{ uses: 'test/after', id: 'after-hook' }],
|
||||
},
|
||||
externalFunctions: [
|
||||
anchorFunction(),
|
||||
recordingFunction('before'),
|
||||
recordingFunction('after'),
|
||||
],
|
||||
});
|
||||
await workflow.executeAsync();
|
||||
expect(executionLog).toEqual([]);
|
||||
expect(hookStepStatuses(workflow)).toEqual({
|
||||
'before-hook': BuildStepStatus.SKIPPED,
|
||||
'after-hook': BuildStepStatus.SKIPPED,
|
||||
});
|
||||
});
|
||||
|
||||
it('never runs hooks of a skipped anchor, even with if: always() on the hook step', async () => {
|
||||
const workflow = await parseAsync({
|
||||
steps: [{ uses: 'eas/install_node_modules', if: '${{ false }}' }],
|
||||
hooks: {
|
||||
after_install_node_modules: [
|
||||
{ uses: 'test/after', id: 'after-hook', if: '${{ always() }}' },
|
||||
],
|
||||
},
|
||||
externalFunctions: [anchorFunction(), recordingFunction('after')],
|
||||
});
|
||||
await workflow.executeAsync();
|
||||
expect(executionLog).toEqual([]);
|
||||
expect(hookStepStatuses(workflow)['after-hook']).toBe(BuildStepStatus.SKIPPED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the always() matrix (baseline failure is ignored, in-sequence failure is not)', () => {
|
||||
it('runs no-if before hooks when the anchor runs past a prior failure via always()', async () => {
|
||||
const workflow = await parseAsync({
|
||||
steps: [{ uses: 'test/boom' }, { uses: 'eas/install_node_modules', if: '${{ always() }}' }],
|
||||
hooks: {
|
||||
before_install_node_modules: [{ uses: 'test/before', id: 'before-hook' }],
|
||||
after_install_node_modules: [{ uses: 'test/after', id: 'after-hook' }],
|
||||
},
|
||||
externalFunctions: [
|
||||
anchorFunction(),
|
||||
recordingFunction('boom', { failWith: new Error('boom failed') }),
|
||||
recordingFunction('before'),
|
||||
recordingFunction('after'),
|
||||
],
|
||||
});
|
||||
await expect(workflow.executeAsync()).rejects.toThrow('boom failed');
|
||||
expect(executionLog).toEqual(['boom', 'before', 'anchor', 'after']);
|
||||
});
|
||||
|
||||
it('a before hook failing IN the sequence skips subsequent no-if before entries, the anchor, and after hooks', async () => {
|
||||
const workflow = await parseAsync({
|
||||
steps: [{ uses: 'eas/install_node_modules' }],
|
||||
hooks: {
|
||||
before_install_node_modules: [
|
||||
{ uses: 'test/failing-before', id: 'failing-before' },
|
||||
{ uses: 'test/second-before', id: 'second-before' },
|
||||
],
|
||||
after_install_node_modules: [{ uses: 'test/after', id: 'after-hook' }],
|
||||
},
|
||||
externalFunctions: [
|
||||
anchorFunction(),
|
||||
recordingFunction('failing-before', { failWith: new Error('before failed') }),
|
||||
recordingFunction('second-before'),
|
||||
recordingFunction('after'),
|
||||
],
|
||||
});
|
||||
await expect(workflow.executeAsync()).rejects.toThrow('before failed');
|
||||
expect(executionLog).toEqual(['failing-before']);
|
||||
const statuses = hookStepStatuses(workflow);
|
||||
expect(statuses['second-before']).toBe(BuildStepStatus.SKIPPED);
|
||||
expect(statuses['after-hook']).toBe(BuildStepStatus.SKIPPED);
|
||||
expect(workflow.buildSteps[0].status).toBe(BuildStepStatus.SKIPPED);
|
||||
});
|
||||
|
||||
it('failure() and success() on hook steps keep their global meaning', async () => {
|
||||
const workflow = await parseAsync({
|
||||
steps: [{ uses: 'test/boom' }, { uses: 'eas/install_node_modules', if: '${{ always() }}' }],
|
||||
hooks: {
|
||||
before_install_node_modules: [
|
||||
{ uses: 'test/on-failure', id: 'on-failure', if: '${{ failure() }}' },
|
||||
{ uses: 'test/on-success', id: 'on-success', if: '${{ success() }}' },
|
||||
],
|
||||
},
|
||||
externalFunctions: [
|
||||
anchorFunction(),
|
||||
recordingFunction('boom', { failWith: new Error('boom failed') }),
|
||||
recordingFunction('on-failure'),
|
||||
recordingFunction('on-success'),
|
||||
],
|
||||
});
|
||||
await expect(workflow.executeAsync()).rejects.toThrow('boom failed');
|
||||
expect(executionLog).toEqual(['boom', 'on-failure', 'anchor']);
|
||||
expect(hookStepStatuses(workflow)['on-success']).toBe(BuildStepStatus.SKIPPED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('after hooks on anchor failure', () => {
|
||||
it('runs no-if and failure() after hooks past a failed anchor; success() skips', async () => {
|
||||
const workflow = await parseAsync({
|
||||
steps: [{ uses: 'eas/install_node_modules' }],
|
||||
hooks: {
|
||||
after_install_node_modules: [
|
||||
{ uses: 'test/no-if', id: 'no-if' },
|
||||
{ uses: 'test/on-success', id: 'on-success', if: '${{ success() }}' },
|
||||
{ uses: 'test/on-failure', id: 'on-failure', if: '${{ failure() }}' },
|
||||
],
|
||||
},
|
||||
externalFunctions: [
|
||||
failingAnchorFunction(),
|
||||
recordingFunction('no-if'),
|
||||
recordingFunction('on-success'),
|
||||
recordingFunction('on-failure'),
|
||||
],
|
||||
});
|
||||
await expect(workflow.executeAsync()).rejects.toThrow('anchor failed');
|
||||
expect(executionLog).toEqual(['anchor', 'no-if', 'on-failure']);
|
||||
expect(hookStepStatuses(workflow)['on-success']).toBe(BuildStepStatus.SKIPPED);
|
||||
});
|
||||
|
||||
it("the anchor's error outranks a failed after hook's error", async () => {
|
||||
const workflow = await parseAsync({
|
||||
steps: [{ uses: 'eas/install_node_modules' }],
|
||||
hooks: {
|
||||
after_install_node_modules: [{ uses: 'test/failing-after', id: 'failing-after' }],
|
||||
},
|
||||
externalFunctions: [
|
||||
failingAnchorFunction(),
|
||||
recordingFunction('failing-after', { failWith: new Error('after failed') }),
|
||||
],
|
||||
});
|
||||
await expect(workflow.executeAsync()).rejects.toThrow('anchor failed');
|
||||
expect(executionLog).toEqual(['anchor', 'failing-after']);
|
||||
});
|
||||
|
||||
it('an earlier failed after entry does not stop later no-if after entries (after-chain continuation)', async () => {
|
||||
const workflow = await parseAsync({
|
||||
steps: [{ uses: 'eas/install_node_modules' }],
|
||||
hooks: {
|
||||
after_install_node_modules: [
|
||||
{ uses: 'test/failing-after', id: 'failing-after' },
|
||||
{ uses: 'test/second-after', id: 'second-after' },
|
||||
],
|
||||
},
|
||||
externalFunctions: [
|
||||
anchorFunction(),
|
||||
recordingFunction('failing-after', { failWith: new Error('after failed') }),
|
||||
recordingFunction('second-after'),
|
||||
],
|
||||
});
|
||||
await expect(workflow.executeAsync()).rejects.toThrow('after failed');
|
||||
expect(executionLog).toEqual(['anchor', 'failing-after', 'second-after']);
|
||||
});
|
||||
|
||||
it('a green anchor with a failed after hook fails the job with the after hook error', async () => {
|
||||
const workflow = await parseAsync({
|
||||
steps: [{ uses: 'eas/install_node_modules' }],
|
||||
hooks: {
|
||||
after_install_node_modules: [{ uses: 'test/failing-after', id: 'failing-after' }],
|
||||
},
|
||||
externalFunctions: [
|
||||
anchorFunction(),
|
||||
recordingFunction('failing-after', { failWith: new Error('after failed') }),
|
||||
],
|
||||
});
|
||||
await expect(workflow.executeAsync()).rejects.toThrow('after failed');
|
||||
expect(executionLog).toEqual(['anchor', 'failing-after']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('before hook shell semantics', () => {
|
||||
it('a hook step failure suppressed with || true keeps the anchor running', async () => {
|
||||
const workflow = await parseAsync({
|
||||
steps: [{ uses: 'eas/install_node_modules' }],
|
||||
hooks: {
|
||||
before_install_node_modules: [{ run: 'false || true', id: 'suppressed' }],
|
||||
},
|
||||
externalFunctions: [anchorFunction()],
|
||||
});
|
||||
await workflow.executeAsync();
|
||||
expect(executionLog).toEqual(['anchor']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('anchor gate errors', () => {
|
||||
it('a throwing anchor if: marks the job failed, skips the anchor and its hooks, and later failure() steps still run', async () => {
|
||||
const workflow = await parseAsync({
|
||||
steps: [
|
||||
{ uses: 'eas/install_node_modules', if: '${{ nonexistent.object.property }}' },
|
||||
{ uses: 'test/cleanup', id: 'cleanup', if: '${{ failure() }}' },
|
||||
],
|
||||
hooks: {
|
||||
before_install_node_modules: [{ uses: 'test/before', id: 'before-hook' }],
|
||||
},
|
||||
externalFunctions: [
|
||||
anchorFunction(),
|
||||
recordingFunction('before'),
|
||||
recordingFunction('cleanup'),
|
||||
],
|
||||
});
|
||||
await expect(workflow.executeAsync()).rejects.toThrow();
|
||||
expect(executionLog).toEqual(['cleanup']);
|
||||
expect(hookStepStatuses(workflow)['before-hook']).toBe(BuildStepStatus.SKIPPED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dormancy and the falsy-throw bugfix', () => {
|
||||
it('a hookless workflow executes as before', async () => {
|
||||
const workflow = await parseAsync({
|
||||
steps: [{ uses: 'test/one' }, { uses: 'test/two' }],
|
||||
hooks: undefined,
|
||||
externalFunctions: [recordingFunction('one'), recordingFunction('two')],
|
||||
});
|
||||
await workflow.executeAsync();
|
||||
expect(executionLog).toEqual(['one', 'two']);
|
||||
expect(metrics).toEqual([]);
|
||||
});
|
||||
|
||||
it('a step throwing a falsy value fails the job with a descriptive wrapped error', async () => {
|
||||
const workflow = await parseAsync({
|
||||
steps: [
|
||||
{ uses: 'test/falsy-boom' },
|
||||
{ uses: 'test/skipped-after', id: 'skipped-after' },
|
||||
{ uses: 'test/cleanup', if: '${{ failure() }}' },
|
||||
],
|
||||
hooks: undefined,
|
||||
externalFunctions: [
|
||||
new BuildFunction({
|
||||
namespace: 'test',
|
||||
id: 'falsy-boom',
|
||||
fn: () => {
|
||||
executionLog.push('falsy-boom');
|
||||
return Promise.reject(undefined);
|
||||
},
|
||||
}),
|
||||
recordingFunction('skipped-after'),
|
||||
recordingFunction('cleanup'),
|
||||
],
|
||||
});
|
||||
const execution = workflow.executeAsync();
|
||||
await expect(execution).rejects.toBeInstanceOf(BuildStepRuntimeError);
|
||||
await expect(execution).rejects.toThrow('threw a non-Error value: undefined');
|
||||
expect(executionLog).toEqual(['falsy-boom', 'cleanup']);
|
||||
});
|
||||
|
||||
it('the first failure wins even when it was a falsy throw', async () => {
|
||||
const workflow = await parseAsync({
|
||||
steps: [{ uses: 'test/falsy-boom' }, { uses: 'test/late-boom', if: '${{ always() }}' }],
|
||||
hooks: undefined,
|
||||
externalFunctions: [
|
||||
new BuildFunction({
|
||||
namespace: 'test',
|
||||
id: 'falsy-boom',
|
||||
fn: () => Promise.reject(undefined),
|
||||
}),
|
||||
new BuildFunction({
|
||||
namespace: 'test',
|
||||
id: 'late-boom',
|
||||
fn: () => {
|
||||
throw new Error('later');
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
await expect(workflow.executeAsync()).rejects.toThrow('threw a non-Error value');
|
||||
});
|
||||
});
|
||||
|
||||
describe('group hook entries', () => {
|
||||
function createGroup(
|
||||
id: string,
|
||||
childIds: string[]
|
||||
): {
|
||||
group: BuildFunctionGroup;
|
||||
functions: BuildFunction[];
|
||||
} {
|
||||
const functions = childIds.map(childId => recordingFunction(childId));
|
||||
return {
|
||||
group: new BuildFunctionGroup({
|
||||
namespace: 'test',
|
||||
id,
|
||||
createBuildStepsFromFunctionGroupCall: globalCtx =>
|
||||
functions.map(fn => fn.createBuildStepFromFunctionCall(globalCtx)),
|
||||
}),
|
||||
functions,
|
||||
};
|
||||
}
|
||||
|
||||
it('executes a function group in an after hook', async () => {
|
||||
const { group, functions } = createGroup('group', ['child-one', 'child-two']);
|
||||
const workflow = await parseAsync({
|
||||
steps: [{ uses: 'eas/install_node_modules' }],
|
||||
hooks: { after_install_node_modules: [{ uses: 'test/group' }] },
|
||||
externalFunctions: [anchorFunction(), ...functions],
|
||||
externalFunctionGroups: [group],
|
||||
});
|
||||
await workflow.executeAsync();
|
||||
expect(executionLog).toEqual(['anchor', 'child-one', 'child-two']);
|
||||
});
|
||||
|
||||
it('a group hook entry with if: false executes NOTHING (entry-level ifCondition)', async () => {
|
||||
const { group, functions } = createGroup('group', ['child-one', 'child-two']);
|
||||
const workflow = await parseAsync({
|
||||
steps: [{ uses: 'eas/install_node_modules' }],
|
||||
hooks: { before_install_node_modules: [{ uses: 'test/group', if: '${{ false }}' }] },
|
||||
externalFunctions: [anchorFunction(), ...functions],
|
||||
externalFunctionGroups: [group],
|
||||
});
|
||||
await workflow.executeAsync();
|
||||
expect(executionLog).toEqual(['anchor']);
|
||||
const anchorHooks = [...workflow.hooksByAnchorStep.values()][0];
|
||||
for (const step of anchorHooks.before[0].steps) {
|
||||
expect(step.status).toBe(BuildStepStatus.SKIPPED);
|
||||
}
|
||||
});
|
||||
|
||||
it('a group entry whose if: passes runs its steps past an earlier entry failure', async () => {
|
||||
const { group, functions } = createGroup('group', ['child-one', 'child-two']);
|
||||
const workflow = await parseAsync({
|
||||
steps: [{ uses: 'eas/install_node_modules' }],
|
||||
hooks: {
|
||||
before_install_node_modules: [
|
||||
{ uses: 'test/failing-before', id: 'failing-before' },
|
||||
{ uses: 'test/group', if: '${{ always() }}' },
|
||||
],
|
||||
},
|
||||
externalFunctions: [
|
||||
anchorFunction(),
|
||||
recordingFunction('failing-before', { failWith: new Error('before failed') }),
|
||||
...functions,
|
||||
],
|
||||
externalFunctionGroups: [group],
|
||||
});
|
||||
await expect(workflow.executeAsync()).rejects.toThrow('before failed');
|
||||
expect(executionLog).toEqual(['failing-before', 'child-one', 'child-two']);
|
||||
// ONE event for the whole before side, with the side's aggregated
|
||||
// result (the first entry failed); the skipped anchor reports nothing.
|
||||
expect(metrics).toEqual([
|
||||
{
|
||||
anchor: 'install_node_modules',
|
||||
timing: 'before',
|
||||
result: 'failed',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('an entry-level condition evaluation error fails the hook (and the job), not silently ignored', async () => {
|
||||
const { group, functions } = createGroup('group', ['child-one']);
|
||||
const workflow = await parseAsync({
|
||||
steps: [{ uses: 'eas/install_node_modules' }],
|
||||
hooks: {
|
||||
before_install_node_modules: [
|
||||
{ uses: 'test/group', if: '${{ nonexistent.object.property }}' },
|
||||
],
|
||||
},
|
||||
externalFunctions: [anchorFunction(), ...functions],
|
||||
externalFunctionGroups: [group],
|
||||
});
|
||||
await expect(workflow.executeAsync()).rejects.toThrow();
|
||||
expect(executionLog).toEqual([]);
|
||||
expect(workflow.buildSteps[0].status).toBe(BuildStepStatus.SKIPPED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the eas.workflow.hook metric callback', () => {
|
||||
it('reports one event per executed hook side with anchor, timing, and result', async () => {
|
||||
const workflow = await parseAsync({
|
||||
steps: [{ uses: 'eas/install_node_modules' }],
|
||||
hooks: {
|
||||
before_install_node_modules: [{ run: 'true', id: 'run-before' }],
|
||||
after_install_node_modules: [{ uses: 'test/after', id: 'uses-after' }],
|
||||
},
|
||||
externalFunctions: [anchorFunction(), recordingFunction('after')],
|
||||
});
|
||||
await workflow.executeAsync();
|
||||
expect(metrics).toEqual([
|
||||
{
|
||||
anchor: 'install_node_modules',
|
||||
timing: 'before',
|
||||
result: 'success',
|
||||
},
|
||||
{
|
||||
anchor: 'install_node_modules',
|
||||
timing: 'after',
|
||||
result: 'success',
|
||||
anchorResult: 'success',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports nothing for skipped hooks', async () => {
|
||||
const workflow = await parseAsync({
|
||||
steps: [{ uses: 'eas/install_node_modules', if: '${{ false }}' }],
|
||||
hooks: {
|
||||
before_install_node_modules: [{ uses: 'test/before', id: 'before-hook' }],
|
||||
after_install_node_modules: [{ uses: 'test/after', id: 'after-hook' }],
|
||||
},
|
||||
externalFunctions: [
|
||||
anchorFunction(),
|
||||
recordingFunction('before'),
|
||||
recordingFunction('after'),
|
||||
],
|
||||
});
|
||||
await workflow.executeAsync();
|
||||
expect(metrics).toEqual([]);
|
||||
});
|
||||
|
||||
it('tags after events with the anchor LOCAL outcome: failed anchor', async () => {
|
||||
const workflow = await parseAsync({
|
||||
steps: [{ uses: 'eas/install_node_modules' }],
|
||||
hooks: { after_install_node_modules: [{ uses: 'test/after', id: 'after-hook' }] },
|
||||
externalFunctions: [failingAnchorFunction(), recordingFunction('after')],
|
||||
});
|
||||
await expect(workflow.executeAsync()).rejects.toThrow('anchor failed');
|
||||
expect(metrics).toEqual([
|
||||
{
|
||||
anchor: 'install_node_modules',
|
||||
timing: 'after',
|
||||
result: 'success',
|
||||
anchorResult: 'failed',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('tags anchorResult success for an always() anchor executing green past an unrelated failure', async () => {
|
||||
const workflow = await parseAsync({
|
||||
steps: [{ uses: 'test/boom' }, { uses: 'eas/install_node_modules', if: '${{ always() }}' }],
|
||||
hooks: { after_install_node_modules: [{ uses: 'test/after', id: 'after-hook' }] },
|
||||
externalFunctions: [
|
||||
anchorFunction(),
|
||||
recordingFunction('boom', { failWith: new Error('boom failed') }),
|
||||
recordingFunction('after'),
|
||||
],
|
||||
});
|
||||
await expect(workflow.executeAsync()).rejects.toThrow('boom failed');
|
||||
expect(metrics).toEqual([
|
||||
{
|
||||
anchor: 'install_node_modules',
|
||||
timing: 'after',
|
||||
result: 'success',
|
||||
anchorResult: 'success',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('a multi-child group hook reports exactly ONE event with the aggregated result', async () => {
|
||||
const failingChild = new BuildFunction({
|
||||
namespace: 'test',
|
||||
id: 'failing-child',
|
||||
fn: () => {
|
||||
executionLog.push('failing-child');
|
||||
throw new Error('child failed');
|
||||
},
|
||||
});
|
||||
const okChild = recordingFunction('ok-child');
|
||||
const group = new BuildFunctionGroup({
|
||||
namespace: 'test',
|
||||
id: 'group',
|
||||
// The failing child comes FIRST: after-side no-`if:` steps continue
|
||||
// past sibling failures, so ok-child must still execute.
|
||||
createBuildStepsFromFunctionGroupCall: globalCtx => [
|
||||
failingChild.createBuildStepFromFunctionCall(globalCtx),
|
||||
okChild.createBuildStepFromFunctionCall(globalCtx),
|
||||
],
|
||||
});
|
||||
const workflow = await parseAsync({
|
||||
steps: [{ uses: 'eas/install_node_modules' }],
|
||||
hooks: { after_install_node_modules: [{ uses: 'test/group' }] },
|
||||
externalFunctions: [anchorFunction(), okChild, failingChild],
|
||||
externalFunctionGroups: [group],
|
||||
});
|
||||
await expect(workflow.executeAsync()).rejects.toThrow('child failed');
|
||||
expect(executionLog).toEqual(['anchor', 'failing-child', 'ok-child']);
|
||||
expect(metrics).toEqual([
|
||||
{
|
||||
anchor: 'install_node_modules',
|
||||
timing: 'after',
|
||||
result: 'failed',
|
||||
anchorResult: 'success',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execution order', () => {
|
||||
it('executes multi-occurrence wraps in order, each occurrence with its own hooks', async () => {
|
||||
const workflow = await parseAsync({
|
||||
steps: [{ uses: 'eas/install_node_modules' }, { uses: 'eas/install_node_modules' }],
|
||||
hooks: {
|
||||
before_install_node_modules: [{ uses: 'test/before' }],
|
||||
after_install_node_modules: [{ uses: 'test/after' }],
|
||||
},
|
||||
externalFunctions: [
|
||||
anchorFunction(),
|
||||
recordingFunction('before'),
|
||||
recordingFunction('after'),
|
||||
],
|
||||
});
|
||||
await workflow.executeAsync();
|
||||
expect(executionLog).toEqual(['before', 'anchor', 'after', 'before', 'anchor', 'after']);
|
||||
});
|
||||
|
||||
it('executes hooks around an anchored function inside a group expansion', async () => {
|
||||
const anchor = anchorFunction();
|
||||
const plain = recordingFunction('plain');
|
||||
const group = new BuildFunctionGroup({
|
||||
namespace: 'test',
|
||||
id: 'group',
|
||||
createBuildStepsFromFunctionGroupCall: globalCtx => [
|
||||
plain.createBuildStepFromFunctionCall(globalCtx),
|
||||
anchor.createBuildStepFromFunctionCall(globalCtx),
|
||||
],
|
||||
});
|
||||
const workflow = await parseAsync({
|
||||
steps: [{ uses: 'test/group' }],
|
||||
hooks: {
|
||||
before_install_node_modules: [{ uses: 'test/before' }],
|
||||
after_install_node_modules: [{ uses: 'test/after' }],
|
||||
},
|
||||
externalFunctions: [anchor, plain, recordingFunction('before'), recordingFunction('after')],
|
||||
externalFunctionGroups: [group],
|
||||
});
|
||||
await workflow.executeAsync();
|
||||
expect(executionLog).toEqual(['plain', 'before', 'anchor', 'after']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,586 @@
|
||||
import { Hooks, Step } from '@expo/eas-build-job';
|
||||
|
||||
import { createGlobalContextMock } from './utils/context';
|
||||
import { getErrorAsync } from './utils/error';
|
||||
import { BuildFunction } from '../BuildFunction';
|
||||
import { BuildFunctionGroup } from '../BuildFunctionGroup';
|
||||
import { BuildRuntimePlatform } from '../BuildRuntimePlatform';
|
||||
import { BuildStepGlobalContext } from '../BuildStepContext';
|
||||
import { BuildStepInput, BuildStepInputValueTypeName } from '../BuildStepInput';
|
||||
import { BuildWorkflow } from '../BuildWorkflow';
|
||||
import { StepsConfigParser } from '../StepsConfigParser';
|
||||
import { BuildConfigError, BuildWorkflowError } from '../errors';
|
||||
import { constructHookEntriesAsync, validateHookStepsAsync } from '../hooks';
|
||||
|
||||
function createInstallNodeModulesFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'install_node_modules',
|
||||
name: 'Install node modules',
|
||||
command: 'npm install',
|
||||
__hookId: 'install_node_modules',
|
||||
});
|
||||
}
|
||||
|
||||
function createCheckoutFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'checkout',
|
||||
name: 'Checkout',
|
||||
command: 'echo checkout',
|
||||
__hookId: 'checkout',
|
||||
});
|
||||
}
|
||||
|
||||
async function parseWorkflowAsync({
|
||||
ctx,
|
||||
steps,
|
||||
hooks,
|
||||
externalFunctions,
|
||||
externalFunctionGroups,
|
||||
}: {
|
||||
ctx: BuildStepGlobalContext;
|
||||
steps: Step[];
|
||||
hooks: Hooks | undefined;
|
||||
externalFunctions?: BuildFunction[];
|
||||
externalFunctionGroups?: BuildFunctionGroup[];
|
||||
}): Promise<BuildWorkflow> {
|
||||
const parser = new StepsConfigParser(ctx, {
|
||||
steps,
|
||||
hooks,
|
||||
externalFunctions: externalFunctions ?? [
|
||||
createInstallNodeModulesFunction(),
|
||||
createCheckoutFunction(),
|
||||
],
|
||||
externalFunctionGroups,
|
||||
});
|
||||
return await parser.parseAsync();
|
||||
}
|
||||
|
||||
function orderedDisplayNames(workflow: BuildWorkflow): string[] {
|
||||
return workflow.getExecutionOrderedSteps().map(step => step.displayName);
|
||||
}
|
||||
|
||||
describe('StepsConfigParser hook construction', () => {
|
||||
let ctx: BuildStepGlobalContext;
|
||||
|
||||
beforeEach(() => {
|
||||
ctx = createGlobalContextMock();
|
||||
});
|
||||
|
||||
it('parses identically when hooks are undefined or empty (dormancy)', async () => {
|
||||
for (const hooks of [undefined, {}]) {
|
||||
const workflow = await parseWorkflowAsync({
|
||||
ctx: createGlobalContextMock(),
|
||||
steps: [{ uses: 'eas/checkout' }, { run: 'echo hi', id: 'user-step' }],
|
||||
hooks,
|
||||
});
|
||||
expect(workflow.buildSteps.map(step => step.displayName)).toEqual(['Checkout', 'user-step']);
|
||||
expect(workflow.hooksByAnchorStep.size).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('attaches before and after hook entries to a function anchor without splicing them into buildSteps', async () => {
|
||||
const workflow = await parseWorkflowAsync({
|
||||
ctx,
|
||||
steps: [{ uses: 'eas/checkout' }, { uses: 'eas/install_node_modules' }],
|
||||
hooks: {
|
||||
before_install_node_modules: [{ run: 'echo before', id: 'before-hook' }],
|
||||
after_install_node_modules: [{ run: 'echo after', id: 'after-hook' }],
|
||||
},
|
||||
});
|
||||
// Hook steps never join buildSteps; the engine executes them around the anchor.
|
||||
expect(workflow.buildSteps.map(step => step.displayName)).toEqual([
|
||||
'Checkout',
|
||||
'Install node modules',
|
||||
]);
|
||||
const installStep = workflow.buildSteps[1];
|
||||
const anchorHooks = workflow.hooksByAnchorStep.get(installStep);
|
||||
expect(anchorHooks?.anchor).toBe('install_node_modules');
|
||||
expect(anchorHooks?.before.map(entry => entry.steps.map(step => step.displayName))).toEqual([
|
||||
['before-hook'],
|
||||
]);
|
||||
expect(anchorHooks?.after.map(entry => entry.steps.map(step => step.displayName))).toEqual([
|
||||
['after-hook'],
|
||||
]);
|
||||
expect(orderedDisplayNames(workflow)).toEqual([
|
||||
'Checkout',
|
||||
'before-hook',
|
||||
'Install node modules',
|
||||
'after-hook',
|
||||
]);
|
||||
});
|
||||
|
||||
it('generates step ids in the spliced order (before → anchor → after) for id parity', async () => {
|
||||
const workflow = await parseWorkflowAsync({
|
||||
ctx,
|
||||
steps: [{ uses: 'eas/install_node_modules' }],
|
||||
hooks: {
|
||||
before_install_node_modules: [{ run: 'echo before' }],
|
||||
after_install_node_modules: [{ run: 'echo after' }],
|
||||
},
|
||||
});
|
||||
const orderedIds = workflow.getExecutionOrderedSteps().map(step => step.id);
|
||||
// Generated ids are sequential (step-NNN); execution order must equal
|
||||
// generation order with no ids consumed and discarded in between,
|
||||
// exactly as splicing produced it.
|
||||
const numbers = orderedIds.map(id => Number(id.replace('step-', '')));
|
||||
for (let i = 1; i < numbers.length; i++) {
|
||||
expect(numbers[i]).toBe(numbers[i - 1] + 1);
|
||||
}
|
||||
});
|
||||
|
||||
it('resolves a stamped shell step (merged maestro_cloud) to its anchor', async () => {
|
||||
const workflow = await parseWorkflowAsync({
|
||||
ctx,
|
||||
steps: [{ run: 'echo maestro cloud', id: 'maestro_cloud', __hook_id: 'maestro_cloud' }],
|
||||
hooks: {
|
||||
before_maestro_cloud: [{ run: 'echo before', id: 'before-hook' }],
|
||||
after_maestro_cloud: [{ run: 'echo after', id: 'after-hook' }],
|
||||
},
|
||||
});
|
||||
expect(orderedDisplayNames(workflow)).toEqual(['before-hook', 'maestro_cloud', 'after-hook']);
|
||||
});
|
||||
|
||||
it('wraps every occurrence of an anchored function independently (per-occurrence entries)', async () => {
|
||||
const workflow = await parseWorkflowAsync({
|
||||
ctx,
|
||||
steps: [{ uses: 'eas/install_node_modules' }, { uses: 'eas/install_node_modules' }],
|
||||
hooks: {
|
||||
before_install_node_modules: [{ run: 'echo before' }],
|
||||
after_install_node_modules: [{ run: 'echo after' }],
|
||||
},
|
||||
});
|
||||
expect(orderedDisplayNames(workflow)).toEqual([
|
||||
'echo before',
|
||||
'Install node modules',
|
||||
'echo after',
|
||||
'echo before',
|
||||
'Install node modules',
|
||||
'echo after',
|
||||
]);
|
||||
expect(workflow.hooksByAnchorStep.size).toBe(2);
|
||||
const [firstHooks, secondHooks] = [...workflow.hooksByAnchorStep.values()];
|
||||
expect(firstHooks.before[0].steps[0]).not.toBe(secondHooks.before[0].steps[0]);
|
||||
});
|
||||
|
||||
it('never treats hook-constructed steps as anchors (no nesting, direct anchored function)', async () => {
|
||||
const workflow = await parseWorkflowAsync({
|
||||
ctx,
|
||||
steps: [{ uses: 'eas/install_node_modules' }],
|
||||
hooks: {
|
||||
before_install_node_modules: [{ uses: 'eas/install_node_modules', id: 'hook-install' }],
|
||||
},
|
||||
});
|
||||
expect(workflow.hooksByAnchorStep.size).toBe(1);
|
||||
const anchorHooks = [...workflow.hooksByAnchorStep.values()][0];
|
||||
expect(anchorHooks.before[0].steps[0].id).toBe('hook-install');
|
||||
// The hook step invoking the anchored function got no hooks of its own.
|
||||
expect(workflow.hooksByAnchorStep.get(anchorHooks.before[0].steps[0])).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores unknown hook keys without erroring (worker skew constraint)', async () => {
|
||||
const workflow = await parseWorkflowAsync({
|
||||
ctx,
|
||||
steps: [{ uses: 'eas/install_node_modules' }],
|
||||
hooks: {
|
||||
before_some_future_anchor: [{ run: 'echo never' }],
|
||||
not_a_hook_key: [{ run: 'echo never' }],
|
||||
},
|
||||
});
|
||||
expect(orderedDisplayNames(workflow)).toEqual(['Install node modules']);
|
||||
});
|
||||
|
||||
it('keeps unknown hook keys fully inert even when their steps reference unknown functions', async () => {
|
||||
// A newer server may send hooks for anchors (and functions) this worker
|
||||
// does not know yet — they must never fail the parse.
|
||||
const workflow = await parseWorkflowAsync({
|
||||
ctx,
|
||||
steps: [{ uses: 'eas/install_node_modules' }],
|
||||
hooks: {
|
||||
before_some_future_anchor: [{ uses: 'eas/some_future_function' }],
|
||||
also_not_a_hook_key: ['not even steps' as unknown as Step],
|
||||
},
|
||||
});
|
||||
expect(orderedDisplayNames(workflow)).toEqual(['Install node modules']);
|
||||
});
|
||||
|
||||
it('ignores hook keys whose anchor is not present in the steps', async () => {
|
||||
const workflow = await parseWorkflowAsync({
|
||||
ctx,
|
||||
steps: [{ uses: 'eas/checkout' }],
|
||||
hooks: { before_install_node_modules: [{ run: 'echo never' }] },
|
||||
});
|
||||
expect(orderedDisplayNames(workflow)).toEqual(['Checkout']);
|
||||
});
|
||||
|
||||
it('treats an explicit empty hook array as a deliberate no-op', async () => {
|
||||
const workflow = await parseWorkflowAsync({
|
||||
ctx,
|
||||
steps: [{ uses: 'eas/install_node_modules' }],
|
||||
hooks: { before_install_node_modules: [] },
|
||||
});
|
||||
expect(workflow.hooksByAnchorStep.size).toBe(0);
|
||||
});
|
||||
|
||||
it('validates hook step arrays like job steps (BuildConfigError, not a crash)', async () => {
|
||||
const error = await getErrorAsync<BuildConfigError>(async () => {
|
||||
await parseWorkflowAsync({
|
||||
ctx,
|
||||
steps: [{ uses: 'eas/install_node_modules' }],
|
||||
// Simulates a malformed payload arriving over the wire (no intake validation).
|
||||
hooks: { before_install_node_modules: [{ id: 'neither-run-nor-uses' } as unknown as Step] },
|
||||
});
|
||||
});
|
||||
expect(error).toBeInstanceOf(BuildConfigError);
|
||||
expect(error.message).toMatch(/before_install_node_modules/);
|
||||
});
|
||||
|
||||
it('rejects a non-array hook value with BuildConfigError instead of dropping it silently', async () => {
|
||||
const error = await getErrorAsync<BuildConfigError>(async () => {
|
||||
await parseWorkflowAsync({
|
||||
ctx,
|
||||
steps: [{ uses: 'eas/install_node_modules' }],
|
||||
// Simulates a malformed payload arriving over the wire.
|
||||
hooks: { before_install_node_modules: 'echo hi' as unknown as Step[] },
|
||||
});
|
||||
});
|
||||
expect(error).toBeInstanceOf(BuildConfigError);
|
||||
expect(error.message).toMatch(/before_install_node_modules/);
|
||||
});
|
||||
|
||||
it('rejects a hook function step naming an unknown function with BuildConfigError', async () => {
|
||||
const error = await getErrorAsync<BuildConfigError>(async () => {
|
||||
await parseWorkflowAsync({
|
||||
ctx,
|
||||
steps: [{ uses: 'eas/install_node_modules' }],
|
||||
hooks: { before_install_node_modules: [{ uses: 'eas/nonexistent_function' }] },
|
||||
});
|
||||
});
|
||||
expect(error).toBeInstanceOf(BuildConfigError);
|
||||
expect(error.message).toMatch(/nonexistent_function/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('constructHookEntriesAsync (public API)', () => {
|
||||
it('treats an empty hook step array as a valid no-op', async () => {
|
||||
const ctx = createGlobalContextMock();
|
||||
await expect(
|
||||
constructHookEntriesAsync(ctx, [], {
|
||||
externalFunctions: [createInstallNodeModulesFunction()],
|
||||
})
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects duplicate function ids instead of letting array order pick the implementation', async () => {
|
||||
const ctx = createGlobalContextMock();
|
||||
await expect(
|
||||
constructHookEntriesAsync(ctx, [{ uses: 'eas/install_node_modules' }], {
|
||||
externalFunctions: [createInstallNodeModulesFunction(), createInstallNodeModulesFunction()],
|
||||
})
|
||||
).rejects.toThrow('already defined');
|
||||
});
|
||||
|
||||
it('validateHookStepsAsync rejects duplicate ids across an ordered view (public API)', async () => {
|
||||
const ctx = createGlobalContextMock();
|
||||
const entries = await constructHookEntriesAsync(
|
||||
ctx,
|
||||
[
|
||||
{ run: 'echo a', id: 'dup' },
|
||||
{ run: 'echo b', id: 'dup' },
|
||||
],
|
||||
{}
|
||||
);
|
||||
await expect(
|
||||
validateHookStepsAsync(
|
||||
ctx,
|
||||
entries.flatMap(entry => entry.steps)
|
||||
)
|
||||
).rejects.toThrow('Hook steps are invalid.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('StepsConfigParser stamp semantics', () => {
|
||||
let ctx: BuildStepGlobalContext;
|
||||
|
||||
beforeEach(() => {
|
||||
ctx = createGlobalContextMock();
|
||||
});
|
||||
|
||||
it('a stamp matching the invoked function declaration anchors the step', async () => {
|
||||
const workflow = await parseWorkflowAsync({
|
||||
ctx,
|
||||
steps: [{ uses: 'eas/install_node_modules', __hook_id: 'install_node_modules' }],
|
||||
hooks: { before_install_node_modules: [{ run: 'echo before', id: 'before-hook' }] },
|
||||
});
|
||||
expect(orderedDisplayNames(workflow)).toEqual(['before-hook', 'Install node modules']);
|
||||
});
|
||||
|
||||
it('a stamp CONFLICTING with the function declaration wins over it', async () => {
|
||||
const workflow = await parseWorkflowAsync({
|
||||
ctx,
|
||||
// install_node_modules function, stamped as the submit anchor.
|
||||
steps: [{ uses: 'eas/install_node_modules', __hook_id: 'submit' }],
|
||||
hooks: {
|
||||
before_submit: [{ run: 'echo before submit', id: 'submit-hook' }],
|
||||
before_install_node_modules: [{ run: 'echo never' }],
|
||||
},
|
||||
});
|
||||
expect(orderedDisplayNames(workflow)).toEqual(['submit-hook', 'Install node modules']);
|
||||
});
|
||||
|
||||
it('an UNREGISTERED stamp on a declaring function step is inert — no fallback to the declaration', async () => {
|
||||
// A newer server stamping a future anchor must render the step inert on
|
||||
// this worker, never silently rebind it to the function's older anchor.
|
||||
const workflow = await parseWorkflowAsync({
|
||||
ctx,
|
||||
steps: [{ uses: 'eas/install_node_modules', __hook_id: 'some_future_anchor' }],
|
||||
hooks: { before_install_node_modules: [{ run: 'echo never' }] },
|
||||
});
|
||||
expect(orderedDisplayNames(workflow)).toEqual(['Install node modules']);
|
||||
expect(workflow.hooksByAnchorStep.size).toBe(0);
|
||||
});
|
||||
|
||||
it('an unstamped step invoking a declaring function resolves via the declaration', async () => {
|
||||
const workflow = await parseWorkflowAsync({
|
||||
ctx,
|
||||
steps: [{ uses: 'eas/install_node_modules' }],
|
||||
hooks: { before_install_node_modules: [{ run: 'echo before', id: 'before-hook' }] },
|
||||
});
|
||||
expect(orderedDisplayNames(workflow)).toEqual(['before-hook', 'Install node modules']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('StepsConfigParser hooks with function groups', () => {
|
||||
let ctx: BuildStepGlobalContext;
|
||||
let checkoutFunction: BuildFunction;
|
||||
let installFunction: BuildFunction;
|
||||
|
||||
beforeEach(() => {
|
||||
ctx = createGlobalContextMock();
|
||||
checkoutFunction = createCheckoutFunction();
|
||||
installFunction = createInstallNodeModulesFunction();
|
||||
});
|
||||
|
||||
function createGroup(id = 'group'): BuildFunctionGroup {
|
||||
return new BuildFunctionGroup({
|
||||
namespace: 'test',
|
||||
id,
|
||||
createBuildStepsFromFunctionGroupCall: globalCtx => [
|
||||
checkoutFunction.createBuildStepFromFunctionCall(globalCtx),
|
||||
installFunction.createBuildStepFromFunctionCall(globalCtx),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
it('attaches hooks to anchored functions inside a group expansion (late construction)', async () => {
|
||||
const workflow = await parseWorkflowAsync({
|
||||
ctx,
|
||||
steps: [{ uses: 'test/group' }],
|
||||
hooks: {
|
||||
before_install_node_modules: [{ run: 'echo before', id: 'before-hook' }],
|
||||
after_install_node_modules: [{ run: 'echo after', id: 'after-hook' }],
|
||||
},
|
||||
externalFunctions: [checkoutFunction, installFunction],
|
||||
externalFunctionGroups: [createGroup()],
|
||||
});
|
||||
expect(orderedDisplayNames(workflow)).toEqual([
|
||||
'Checkout',
|
||||
'before-hook',
|
||||
'Install node modules',
|
||||
'after-hook',
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves group expansions without anchored functions untouched', async () => {
|
||||
const group = new BuildFunctionGroup({
|
||||
namespace: 'test',
|
||||
id: 'group',
|
||||
createBuildStepsFromFunctionGroupCall: globalCtx => [
|
||||
new BuildFunction({
|
||||
id: 'not_an_anchor',
|
||||
command: 'echo x',
|
||||
}).createBuildStepFromFunctionCall(globalCtx),
|
||||
],
|
||||
});
|
||||
const workflow = await parseWorkflowAsync({
|
||||
ctx,
|
||||
steps: [{ uses: 'test/group' }],
|
||||
hooks: { before_install_node_modules: [{ run: 'echo never' }] },
|
||||
externalFunctions: [],
|
||||
externalFunctionGroups: [group],
|
||||
});
|
||||
expect(workflow.buildSteps).toHaveLength(1);
|
||||
expect(workflow.hooksByAnchorStep.size).toBe(0);
|
||||
});
|
||||
|
||||
it('parses a function group inside an after hook as ONE entry with the expansion inside', async () => {
|
||||
const workflow = await parseWorkflowAsync({
|
||||
ctx,
|
||||
steps: [{ uses: 'eas/install_node_modules' }],
|
||||
hooks: { after_install_node_modules: [{ uses: 'test/group' }] },
|
||||
externalFunctions: [installFunction, checkoutFunction],
|
||||
externalFunctionGroups: [createGroup()],
|
||||
});
|
||||
const anchorHooks = [...workflow.hooksByAnchorStep.values()][0];
|
||||
expect(anchorHooks.after).toHaveLength(1);
|
||||
expect(anchorHooks.after[0].steps.map(step => step.displayName)).toEqual([
|
||||
'Checkout',
|
||||
'Install node modules',
|
||||
]);
|
||||
});
|
||||
|
||||
it('carries the authored if: of a group hook step on the entry (expansion drops step-level if:)', async () => {
|
||||
const workflow = await parseWorkflowAsync({
|
||||
ctx,
|
||||
steps: [{ uses: 'eas/install_node_modules' }],
|
||||
hooks: { before_install_node_modules: [{ uses: 'test/group', if: '${{ failure() }}' }] },
|
||||
externalFunctions: [installFunction, checkoutFunction],
|
||||
externalFunctionGroups: [createGroup()],
|
||||
});
|
||||
const anchorHooks = [...workflow.hooksByAnchorStep.values()][0];
|
||||
expect(anchorHooks.before[0].ifCondition).toBe('${{ failure() }}');
|
||||
});
|
||||
|
||||
it('does not attach hooks inside a group expansion that is itself a hook step (no nesting)', async () => {
|
||||
const workflow = await parseWorkflowAsync({
|
||||
ctx,
|
||||
steps: [{ uses: 'eas/install_node_modules' }],
|
||||
hooks: { before_install_node_modules: [{ uses: 'test/group' }] },
|
||||
externalFunctions: [installFunction, checkoutFunction],
|
||||
externalFunctionGroups: [createGroup()],
|
||||
});
|
||||
// Exactly one anchor: the job's own install step. The install step inside
|
||||
// the hook's group expansion got nothing.
|
||||
expect(workflow.hooksByAnchorStep.size).toBe(1);
|
||||
const anchorHooks = [...workflow.hooksByAnchorStep.values()][0];
|
||||
for (const hookStep of anchorHooks.before.flatMap(entry => entry.steps)) {
|
||||
expect(workflow.hooksByAnchorStep.get(hookStep)).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('a REGISTERED stamp on a group call is inert like any other stamp on a group call', async () => {
|
||||
const workflow = await parseWorkflowAsync({
|
||||
ctx,
|
||||
steps: [{ uses: 'test/group', __hook_id: 'submit' }],
|
||||
hooks: { before_submit: [{ run: 'echo never' }] },
|
||||
externalFunctions: [installFunction, checkoutFunction],
|
||||
externalFunctionGroups: [createGroup()],
|
||||
});
|
||||
// The group expands normally; the stamp never matches an anchor, so the
|
||||
// submit hook stays unmatched (ignored, not run).
|
||||
expect(workflow.buildSteps).toHaveLength(2);
|
||||
expect(workflow.hooksByAnchorStep.size).toBe(0);
|
||||
});
|
||||
|
||||
it('treats an UNREGISTERED-stamped group call as an inert ordinary step (skew outranks the group fence)', async () => {
|
||||
const workflow = await parseWorkflowAsync({
|
||||
ctx,
|
||||
steps: [{ uses: 'test/group', __hook_id: 'some_future_anchor' }],
|
||||
hooks: { before_install_node_modules: [{ run: 'echo before', id: 'before-hook' }] },
|
||||
externalFunctions: [installFunction, checkoutFunction],
|
||||
externalFunctionGroups: [createGroup()],
|
||||
});
|
||||
// The group expands normally; its inner anchored functions still anchor.
|
||||
expect(workflow.buildSteps).toHaveLength(2);
|
||||
expect(workflow.hooksByAnchorStep.size).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('StepsConfigParser hook validation view', () => {
|
||||
let ctx: BuildStepGlobalContext;
|
||||
|
||||
beforeEach(() => {
|
||||
ctx = createGlobalContextMock();
|
||||
});
|
||||
|
||||
it('fails parseAsync when a hook step id collides with a job step id', async () => {
|
||||
const error = await getErrorAsync<BuildWorkflowError>(async () => {
|
||||
await parseWorkflowAsync({
|
||||
ctx,
|
||||
steps: [{ uses: 'eas/install_node_modules' }, { run: 'echo hi', id: 'my-step' }],
|
||||
hooks: { before_install_node_modules: [{ run: 'echo hook', id: 'my-step' }] },
|
||||
});
|
||||
});
|
||||
expect(error).toBeInstanceOf(BuildWorkflowError);
|
||||
});
|
||||
|
||||
it('fails parseAsync when two hooks of different keys collide on step id', async () => {
|
||||
const error = await getErrorAsync<BuildWorkflowError>(async () => {
|
||||
await parseWorkflowAsync({
|
||||
ctx,
|
||||
steps: [{ uses: 'eas/install_node_modules' }],
|
||||
hooks: {
|
||||
before_install_node_modules: [{ run: 'echo a', id: 'dup' }],
|
||||
after_install_node_modules: [{ run: 'echo b', id: 'dup' }],
|
||||
},
|
||||
});
|
||||
});
|
||||
expect(error).toBeInstanceOf(BuildWorkflowError);
|
||||
});
|
||||
|
||||
it('validates output references through the ordered view: a step before the anchor cannot reference an after-hook output', async () => {
|
||||
const consumer = new BuildFunction({
|
||||
namespace: 'test',
|
||||
id: 'consumer',
|
||||
command: 'echo consume',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'value',
|
||||
required: true,
|
||||
defaultValue: '${ steps.late-hook.value }',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
],
|
||||
});
|
||||
const error = await getErrorAsync<BuildWorkflowError>(async () => {
|
||||
await parseWorkflowAsync({
|
||||
ctx,
|
||||
steps: [{ uses: 'test/consumer' }, { uses: 'eas/install_node_modules' }],
|
||||
hooks: {
|
||||
after_install_node_modules: [
|
||||
{ run: 'echo hook', id: 'late-hook', outputs: [{ name: 'value' }] },
|
||||
],
|
||||
},
|
||||
externalFunctions: [createInstallNodeModulesFunction(), consumer],
|
||||
});
|
||||
});
|
||||
expect(error).toBeInstanceOf(BuildWorkflowError);
|
||||
expect((error as BuildWorkflowError).errors[0].message).toMatch(/future step "late-hook"/);
|
||||
|
||||
// The mirror case is valid: a step AFTER the anchor sees the after-hook's
|
||||
// output, because the ordered view places the hook before it.
|
||||
await expect(
|
||||
parseWorkflowAsync({
|
||||
ctx: createGlobalContextMock(),
|
||||
steps: [{ uses: 'eas/install_node_modules' }, { uses: 'test/consumer' }],
|
||||
hooks: {
|
||||
after_install_node_modules: [
|
||||
{ run: 'echo hook', id: 'late-hook', outputs: [{ name: 'value' }] },
|
||||
],
|
||||
},
|
||||
externalFunctions: [createInstallNodeModulesFunction(), consumer],
|
||||
})
|
||||
).resolves.toBeInstanceOf(BuildWorkflow);
|
||||
});
|
||||
|
||||
it('validates runtime-platform allowance for hook steps through the same aggregate view', async () => {
|
||||
const darwinOnly = new BuildFunction({
|
||||
namespace: 'test',
|
||||
id: 'darwin_only',
|
||||
command: 'echo darwin',
|
||||
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
|
||||
});
|
||||
const error = await getErrorAsync<BuildWorkflowError>(async () => {
|
||||
await parseWorkflowAsync({
|
||||
// The mock context runs on LINUX.
|
||||
ctx,
|
||||
steps: [{ uses: 'eas/install_node_modules' }],
|
||||
hooks: { before_install_node_modules: [{ uses: 'test/darwin_only' }] },
|
||||
externalFunctions: [createInstallNodeModulesFunction(), darwinOnly],
|
||||
});
|
||||
});
|
||||
expect(error).toBeInstanceOf(BuildWorkflowError);
|
||||
expect((error as BuildWorkflowError).errors[0].message).toMatch(/not allowed on platform/);
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ describe(StepsConfigParser, () => {
|
||||
const error = getError<BuildStepRuntimeError>(() => {
|
||||
// eslint-disable-next-line no-new
|
||||
new StepsConfigParser(ctx, {
|
||||
hooks: undefined,
|
||||
steps: [],
|
||||
externalFunctions: [
|
||||
new BuildFunction({ id: 'abc', command: 'echo 123' }),
|
||||
@@ -33,6 +34,7 @@ describe(StepsConfigParser, () => {
|
||||
const error = getError<BuildStepRuntimeError>(() => {
|
||||
// eslint-disable-next-line no-new
|
||||
new StepsConfigParser(ctx, {
|
||||
hooks: undefined,
|
||||
steps: [
|
||||
{
|
||||
run: 'test',
|
||||
@@ -64,6 +66,7 @@ describe(StepsConfigParser, () => {
|
||||
expect(() => {
|
||||
// eslint-disable-next-line no-new
|
||||
new StepsConfigParser(ctx, {
|
||||
hooks: undefined,
|
||||
steps: [
|
||||
{
|
||||
run: 'test',
|
||||
@@ -85,6 +88,7 @@ describe(StepsConfigParser, () => {
|
||||
expect(() => {
|
||||
// eslint-disable-next-line no-new
|
||||
new StepsConfigParser(ctx, {
|
||||
hooks: undefined,
|
||||
steps: [
|
||||
{
|
||||
run: 'test',
|
||||
@@ -114,6 +118,7 @@ describe(StepsConfigParser, () => {
|
||||
it('throws an error when calling non-existent function', async () => {
|
||||
const ctx = createGlobalContextMock();
|
||||
const parser = new StepsConfigParser(ctx, {
|
||||
hooks: undefined,
|
||||
steps: [
|
||||
{
|
||||
run: 'test',
|
||||
@@ -131,6 +136,7 @@ describe(StepsConfigParser, () => {
|
||||
it('throws an error if steps are empty array', async () => {
|
||||
const ctx = createGlobalContextMock();
|
||||
const parser = new StepsConfigParser(ctx, {
|
||||
hooks: undefined,
|
||||
steps: [],
|
||||
});
|
||||
await expect(parser.parseAsync()).rejects.toThrow(
|
||||
@@ -141,6 +147,7 @@ describe(StepsConfigParser, () => {
|
||||
it('returns a BuildWorkflow object', async () => {
|
||||
const ctx = createGlobalContextMock();
|
||||
const parser = new StepsConfigParser(ctx, {
|
||||
hooks: undefined,
|
||||
steps: [
|
||||
{
|
||||
run: 'test',
|
||||
@@ -164,6 +171,7 @@ describe(StepsConfigParser, () => {
|
||||
it('parses steps and external functions into build workflow with steps', async () => {
|
||||
const ctx = createGlobalContextMock();
|
||||
const parser = new StepsConfigParser(ctx, {
|
||||
hooks: undefined,
|
||||
steps: [
|
||||
{
|
||||
run: 'command1',
|
||||
|
||||
@@ -12,11 +12,12 @@ import {
|
||||
ExternalBuildContextProvider,
|
||||
} from '../../BuildStepContext';
|
||||
import { BuildStepEnv } from '../../BuildStepEnv';
|
||||
import { StepMetric } from '../../StepMetrics';
|
||||
import { StepMetric, WorkflowHookMetric } from '../../StepMetrics';
|
||||
|
||||
export class MockContextProvider implements ExternalBuildContextProvider {
|
||||
private _env: BuildStepEnv = {};
|
||||
public reportStepMetric?: (metric: StepMetric) => void;
|
||||
public reportWorkflowHookMetric?: (metric: WorkflowHookMetric) => void;
|
||||
|
||||
constructor(
|
||||
public readonly logger: bunyan,
|
||||
@@ -48,6 +49,7 @@ interface BuildContextParams {
|
||||
relativeWorkingDirectory?: string;
|
||||
staticContextContent?: JobInterpolationContext;
|
||||
reportStepMetric?: (metric: StepMetric) => void;
|
||||
reportWorkflowHookMetric?: (metric: WorkflowHookMetric) => void;
|
||||
}
|
||||
|
||||
export function createStepContextMock({
|
||||
@@ -85,6 +87,7 @@ export function createGlobalContextMock({
|
||||
relativeWorkingDirectory,
|
||||
staticContextContent,
|
||||
reportStepMetric,
|
||||
reportWorkflowHookMetric,
|
||||
}: BuildContextParams = {}): BuildStepGlobalContext {
|
||||
const resolvedProjectTargetDirectory =
|
||||
projectTargetDirectory ?? path.join(os.tmpdir(), 'eas-build', uuidv4());
|
||||
@@ -102,5 +105,8 @@ export function createGlobalContextMock({
|
||||
if (reportStepMetric) {
|
||||
provider.reportStepMetric = reportStepMetric;
|
||||
}
|
||||
if (reportWorkflowHookMetric) {
|
||||
provider.reportWorkflowHookMetric = reportWorkflowHookMetric;
|
||||
}
|
||||
return new BuildStepGlobalContext(provider, skipCleanup ?? false);
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import {
|
||||
HookAnchorId,
|
||||
ShellStep,
|
||||
Step,
|
||||
isStepFunctionStep,
|
||||
validateSteps,
|
||||
} from '@expo/eas-build-job';
|
||||
import assert from 'node:assert';
|
||||
|
||||
import { BuildFunction, BuildFunctionById, createBuildFunctionByIdMapping } from './BuildFunction';
|
||||
import {
|
||||
BuildFunctionGroup,
|
||||
BuildFunctionGroupById,
|
||||
createBuildFunctionGroupByIdMapping,
|
||||
} from './BuildFunctionGroup';
|
||||
import { BuildStep } from './BuildStep';
|
||||
import { BuildStepGlobalContext } from './BuildStepContext';
|
||||
import { BuildStepOutput } from './BuildStepOutput';
|
||||
import { collectAggregateStepErrors } from './BuildWorkflowValidator';
|
||||
import { BuildConfigError, BuildWorkflowError } from './errors';
|
||||
|
||||
/**
|
||||
* One entry per AUTHORED hook step — the unit the user wrote. The wrapper
|
||||
* earns its place twice. (1) A function-group call vanishes at expansion, so
|
||||
* the entry is the authored `if:`'s only home, evaluated once for the whole
|
||||
* group; `run:` and single-function entries keep the `if:` on the step itself
|
||||
* — the condition never exists in two places. Entry conditions see the global
|
||||
* context only; a group call's `with:` inputs are not visible to them.
|
||||
* (2) An entry whose explicit `if:` passed behaves like a single step whose
|
||||
* `if:` passed: its no-`if:` steps run past earlier entries' failures, while
|
||||
* within-entry failures still skip later siblings.
|
||||
*/
|
||||
export interface HookEntry {
|
||||
steps: BuildStep[];
|
||||
ifCondition?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The hook entries attached to one anchor step occurrence. Carries the anchor
|
||||
* id because a stamped anchor is not reverse-derivable from a `BuildStep`.
|
||||
*/
|
||||
export interface AnchorHooks {
|
||||
anchor: HookAnchorId;
|
||||
before: HookEntry[];
|
||||
after: HookEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs hook entries from authored hook steps: ONE authored step → ONE
|
||||
* entry, anchor discovery disabled (a hook step invoking an anchored function
|
||||
* or a function group never becomes an anchor itself — no nesting). Validates
|
||||
* step shapes and function existence; the aggregate (cross-key) validation is
|
||||
* `validateHookStepsAsync`.
|
||||
*
|
||||
* Promise-returning by contract (like `validateHookStepsAsync`) even though
|
||||
* the body is synchronous: this API is pre-published for the native hook
|
||||
* runner (phase 3, not yet landed), and widening sync → async later would
|
||||
* break its callers.
|
||||
*/
|
||||
export async function constructHookEntriesAsync(
|
||||
ctx: BuildStepGlobalContext,
|
||||
steps: Step[],
|
||||
{
|
||||
externalFunctions,
|
||||
externalFunctionGroups,
|
||||
}: {
|
||||
externalFunctions?: BuildFunction[];
|
||||
externalFunctionGroups?: BuildFunctionGroup[];
|
||||
}
|
||||
): Promise<HookEntry[]> {
|
||||
// An empty array is a valid no-op (e.g. opting out of a default hook);
|
||||
// validateSteps requires at least one step, so short-circuit before it —
|
||||
// but ONLY for a real array (malformed wire input must still reach
|
||||
// validateSteps and be rejected, not silently disable the hook).
|
||||
if (Array.isArray(steps) && steps.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const validatedSteps = validateSteps(steps);
|
||||
const buildFunctionById = createBuildFunctionByIdMapping(externalFunctions ?? []);
|
||||
const buildFunctionGroupById = createBuildFunctionGroupByIdMapping(externalFunctionGroups ?? []);
|
||||
validateAllStepFunctionsExist(validatedSteps, {
|
||||
externalFunctionIds: Object.keys(buildFunctionById),
|
||||
externalFunctionGroupIds: Object.keys(buildFunctionGroupById),
|
||||
});
|
||||
return constructHookEntriesFromValidatedSteps(ctx, validatedSteps, {
|
||||
buildFunctionById,
|
||||
buildFunctionGroupById,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate validation over an execution-ordered view of steps (unique ids,
|
||||
* output references, runtime-platform allowance). The parser runs it over
|
||||
* `job steps + hooks` (through `BuildWorkflowValidator`).
|
||||
*/
|
||||
export async function validateHookStepsAsync(
|
||||
ctx: BuildStepGlobalContext,
|
||||
orderedView: readonly BuildStep[]
|
||||
): Promise<void> {
|
||||
const errors = collectAggregateStepErrors(orderedView, {
|
||||
runtimePlatform: ctx.runtimePlatform,
|
||||
});
|
||||
if (errors.length !== 0) {
|
||||
throw new BuildWorkflowError('Hook steps are invalid.', errors);
|
||||
}
|
||||
}
|
||||
|
||||
/** Package-internal: the parser path — steps already validated, function maps already built. */
|
||||
export function constructHookEntriesFromValidatedSteps(
|
||||
ctx: BuildStepGlobalContext,
|
||||
validatedSteps: Step[],
|
||||
{
|
||||
buildFunctionById,
|
||||
buildFunctionGroupById,
|
||||
}: {
|
||||
buildFunctionById: BuildFunctionById;
|
||||
buildFunctionGroupById: BuildFunctionGroupById;
|
||||
}
|
||||
): HookEntry[] {
|
||||
const entries: HookEntry[] = [];
|
||||
for (const step of validatedSteps) {
|
||||
if (!isStepFunctionStep(step)) {
|
||||
entries.push({
|
||||
steps: [createBuildStepFromShellStep(ctx, step)],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const maybeFunctionGroup = buildFunctionGroupById[step.uses];
|
||||
if (maybeFunctionGroup !== undefined) {
|
||||
entries.push({
|
||||
steps: maybeFunctionGroup.createBuildStepsFromFunctionGroupCall(ctx, {
|
||||
callInputs: step.with,
|
||||
}),
|
||||
...(step.if ? { ifCondition: step.if } : null),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const buildFunction = buildFunctionById[step.uses];
|
||||
assert(buildFunction, 'function ID must be ID of function or function group');
|
||||
entries.push({
|
||||
steps: [
|
||||
buildFunction.createBuildStepFromFunctionCall(ctx, {
|
||||
id: step.id,
|
||||
name: step.name,
|
||||
callInputs: step.with,
|
||||
workingDirectory: step.working_directory,
|
||||
shell: step.shell,
|
||||
env: step.env,
|
||||
ifCondition: step.if,
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** Package-internal: shared shell-step construction (job steps and hook steps). */
|
||||
export function createBuildStepFromShellStep(
|
||||
ctx: BuildStepGlobalContext,
|
||||
step: ShellStep
|
||||
): BuildStep {
|
||||
const id = BuildStep.getNewId(step.id);
|
||||
const displayName =
|
||||
step.name ??
|
||||
step.id ??
|
||||
step.run
|
||||
.split('\n')
|
||||
.find(line => line.trim())
|
||||
?.trim() ??
|
||||
step.run;
|
||||
const outputs = step.outputs?.map(
|
||||
entry =>
|
||||
new BuildStepOutput(ctx, {
|
||||
id: entry.name,
|
||||
stepDisplayName: displayName,
|
||||
required: entry.required ?? true,
|
||||
})
|
||||
);
|
||||
return new BuildStep(ctx, {
|
||||
id,
|
||||
displayName,
|
||||
outputs,
|
||||
workingDirectory: step.working_directory,
|
||||
shell: step.shell,
|
||||
command: step.run,
|
||||
env: step.env,
|
||||
ifCondition: step.if,
|
||||
__metricsId: step.__metrics_id,
|
||||
});
|
||||
}
|
||||
|
||||
/** Package-internal. */
|
||||
export function validateAllStepFunctionsExist(
|
||||
steps: Step[],
|
||||
{
|
||||
externalFunctionIds,
|
||||
externalFunctionGroupIds,
|
||||
}: {
|
||||
externalFunctionIds: string[];
|
||||
externalFunctionGroupIds: string[];
|
||||
}
|
||||
): void {
|
||||
const calledFunctionsOrFunctionGroupsSet = new Set<string>();
|
||||
for (const step of steps) {
|
||||
if (step.uses) {
|
||||
calledFunctionsOrFunctionGroupsSet.add(step.uses);
|
||||
}
|
||||
}
|
||||
const externalFunctionIdsSet = new Set(externalFunctionIds);
|
||||
const externalFunctionGroupsIdsSet = new Set(externalFunctionGroupIds);
|
||||
const nonExistentFunctionsOrFunctionGroups = Array.from(
|
||||
calledFunctionsOrFunctionGroupsSet
|
||||
).filter(
|
||||
calledFunctionOrFunctionGroup =>
|
||||
!externalFunctionIdsSet.has(calledFunctionOrFunctionGroup) &&
|
||||
!externalFunctionGroupsIdsSet.has(calledFunctionOrFunctionGroup)
|
||||
);
|
||||
if (nonExistentFunctionsOrFunctionGroups.length > 0) {
|
||||
throw new BuildConfigError(
|
||||
`Calling non-existent functions: ${nonExistentFunctionsOrFunctionGroups
|
||||
.map(f => `"${f}"`)
|
||||
.join(', ')}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,13 @@ export { BuildStepContext } from './BuildStepContext';
|
||||
export { readAndValidateBuildConfigFromPathAsync } from './BuildConfig';
|
||||
export { BuildConfigParser } from './BuildConfigParser';
|
||||
export { StepsConfigParser } from './StepsConfigParser';
|
||||
export { AnchorHooks, HookEntry, constructHookEntriesAsync, validateHookStepsAsync } from './hooks';
|
||||
export { BuildFunction } from './BuildFunction';
|
||||
export { BuildRuntimePlatform } from './BuildRuntimePlatform';
|
||||
export { BuildStepInput, BuildStepInputValueTypeName } from './BuildStepInput';
|
||||
export { BuildStepOutput } from './BuildStepOutput';
|
||||
export { BuildStepGlobalContext, ExternalBuildContextProvider } from './BuildStepContext';
|
||||
export { BuildWorkflow } from './BuildWorkflow';
|
||||
export { BuildWorkflow, executeHookStepsAsync } from './BuildWorkflow';
|
||||
export { BuildStepEnv } from './BuildStepEnv';
|
||||
export { BuildFunctionGroup } from './BuildFunctionGroup';
|
||||
export { BuildStep } from './BuildStep';
|
||||
@@ -16,4 +17,4 @@ export * from './interpolation';
|
||||
export * from './utils/shell/spawn';
|
||||
export * from './utils/jsepEval';
|
||||
export * from './utils/hashFiles';
|
||||
export { StepMetric, StepMetricResult } from './StepMetrics';
|
||||
export { StepMetric, StepMetricResult, WorkflowHookMetric } from './StepMetrics';
|
||||
@@ -194,3 +194,22 @@ export function jsepEval(expression: string, context?: Record<string, unknown>):
|
||||
const tree = jsep(expression);
|
||||
return evaluateExpressionNode(tree, context ?? {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates a step-level or hook-entry-level `if:` condition: strips the
|
||||
* optional `${{ }}` / `${ }` delimiters and coerces the result to boolean.
|
||||
* The evaluation context is the caller's — step conditions include the step's
|
||||
* inputs and env, entry conditions the global context only.
|
||||
*/
|
||||
export function evaluateIfCondition(
|
||||
ifCondition: string,
|
||||
context: Record<string, unknown>
|
||||
): boolean {
|
||||
let condition = ifCondition;
|
||||
if (condition.startsWith('${{') && condition.endsWith('}}')) {
|
||||
condition = condition.slice(3, -2);
|
||||
} else if (condition.startsWith('${') && condition.endsWith('}')) {
|
||||
condition = condition.slice(2, -1);
|
||||
}
|
||||
return Boolean(jsepEval(condition, context));
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/template-file",
|
||||
"version": "21.0.0",
|
||||
"version": "21.0.2",
|
||||
"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": {
|
||||
"lodash": "^4.17.21"
|
||||
"lodash": "^4.18.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.12",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/worker",
|
||||
"version": "21.0.0",
|
||||
"version": "21.0.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "yarn watch",
|
||||
@@ -19,17 +19,17 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/core": "7.24.7",
|
||||
"@expo/build-tools": "21.0.0",
|
||||
"@expo/build-tools": "21.0.2",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/config-types": "55.0.5",
|
||||
"@expo/downloader": "21.0.0",
|
||||
"@expo/eas-build-job": "21.0.0",
|
||||
"@expo/eas-build-job": "21.0.2",
|
||||
"@expo/logger": "21.0.0",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "21.0.0",
|
||||
"@expo/template-file": "21.0.0",
|
||||
"@expo/steps": "21.0.2",
|
||||
"@expo/template-file": "21.0.2",
|
||||
"@expo/turtle-spawn": "21.0.0",
|
||||
"@hapi/boom": "10.0.1",
|
||||
"check-disk-space": "3.4.0",
|
||||
@@ -39,16 +39,16 @@
|
||||
"koa": "3.1.2",
|
||||
"koa-body": "7.0.1",
|
||||
"koa-router": "14.0.0",
|
||||
"lodash": "4.17.21",
|
||||
"lodash": "4.18.1",
|
||||
"micromatch": "4.0.8",
|
||||
"node-fetch": "2.7.0",
|
||||
"node-os-utils": "1.3.7",
|
||||
"nullthrows": "1.1.1",
|
||||
"semver": "7.6.3",
|
||||
"tar": "7.5.7",
|
||||
"tar": "7.5.19",
|
||||
"uuid": "3.4.0",
|
||||
"uuidv7": "1.1.0",
|
||||
"ws": "8.18.0",
|
||||
"ws": "8.21.1",
|
||||
"xml2js": "0.6.2",
|
||||
"zod": "4.1.8"
|
||||
},
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { RuntimeSettings } from '@expo/build-tools';
|
||||
import { Platform, Workflow } from '@expo/eas-build-job';
|
||||
import os from 'os';
|
||||
import v8 from 'v8';
|
||||
|
||||
import config from '../config';
|
||||
import { getBuildEnv, getGradleMemoryOptions } from '../env';
|
||||
import { getBuildEnv, getGradleMemoryOptions, getNodeMemoryOptions } from '../env';
|
||||
|
||||
describe(getBuildEnv.name, () => {
|
||||
const originalSentryDsn = config.sentry.dsn;
|
||||
@@ -19,6 +20,7 @@ describe(getBuildEnv.name, () => {
|
||||
EAS_BUILD_MAVEN_CACHE_URL: process.env.EAS_BUILD_MAVEN_CACHE_URL,
|
||||
EAS_BUILD_COCOAPODS_CACHE_URL: process.env.EAS_BUILD_COCOAPODS_CACHE_URL,
|
||||
NPM_CONFIG_REGISTRY: process.env.NPM_CONFIG_REGISTRY,
|
||||
NODE_OPTIONS: process.env.NODE_OPTIONS,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -28,6 +30,7 @@ describe(getBuildEnv.name, () => {
|
||||
delete process.env.EAS_BUILD_MAVEN_CACHE_URL;
|
||||
delete process.env.EAS_BUILD_COCOAPODS_CACHE_URL;
|
||||
delete process.env.NPM_CONFIG_REGISTRY;
|
||||
delete process.env.NODE_OPTIONS;
|
||||
jest.spyOn(RuntimeSettings, 'getNpmCacheUrl').mockReturnValue(null);
|
||||
jest.spyOn(RuntimeSettings, 'getNodeJsCacheUrl').mockReturnValue(null);
|
||||
jest.spyOn(RuntimeSettings, 'getMavenCacheUrl').mockReturnValue(null);
|
||||
@@ -47,6 +50,7 @@ describe(getBuildEnv.name, () => {
|
||||
restoreEnv('EAS_BUILD_MAVEN_CACHE_URL', originalCacheUrls.EAS_BUILD_MAVEN_CACHE_URL);
|
||||
restoreEnv('EAS_BUILD_COCOAPODS_CACHE_URL', originalCacheUrls.EAS_BUILD_COCOAPODS_CACHE_URL);
|
||||
restoreEnv('NPM_CONFIG_REGISTRY', originalCacheUrls.NPM_CONFIG_REGISTRY);
|
||||
restoreEnv('NODE_OPTIONS', originalCacheUrls.NODE_OPTIONS);
|
||||
mockProcessPlatform(originalPlatform);
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
@@ -299,6 +303,63 @@ describe(getBuildEnv.name, () => {
|
||||
maxHeapSize: '1g',
|
||||
});
|
||||
});
|
||||
|
||||
it('increases the Node.js heap limit on workers with enough memory', () => {
|
||||
jest.spyOn(process, 'constrainedMemory').mockReturnValue(6 * 1024 ** 3);
|
||||
jest.spyOn(v8, 'getHeapStatistics').mockReturnValue({
|
||||
heap_size_limit: 2 * 1024 ** 3,
|
||||
} as ReturnType<typeof v8.getHeapStatistics>);
|
||||
|
||||
expect(getNodeMemoryOptions()).toBe('--max-old-space-size=3072');
|
||||
});
|
||||
|
||||
it('does not increase the Node.js heap limit on workers with less than 6 GiB of memory', () => {
|
||||
jest.spyOn(process, 'constrainedMemory').mockReturnValue(6 * 1024 ** 3 - 1);
|
||||
jest.spyOn(v8, 'getHeapStatistics').mockReturnValue({
|
||||
heap_size_limit: 2 * 1024 ** 3,
|
||||
} as ReturnType<typeof v8.getHeapStatistics>);
|
||||
|
||||
expect(getNodeMemoryOptions()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not increase an existing Node.js heap limit of at least 3 GiB', () => {
|
||||
jest.spyOn(process, 'constrainedMemory').mockReturnValue(6 * 1024 ** 3);
|
||||
jest.spyOn(v8, 'getHeapStatistics').mockReturnValue({
|
||||
heap_size_limit: 3 * 1024 ** 3,
|
||||
} as ReturnType<typeof v8.getHeapStatistics>);
|
||||
|
||||
expect(getNodeMemoryOptions()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps Node.js options inherited from the worker environment in override position', () => {
|
||||
process.env.NODE_OPTIONS = '--max-old-space-size=2048 --enable-source-maps';
|
||||
jest.spyOn(process, 'constrainedMemory').mockReturnValue(6 * 1024 ** 3);
|
||||
jest.spyOn(v8, 'getHeapStatistics').mockReturnValue({
|
||||
heap_size_limit: 2 * 1024 ** 3,
|
||||
} as ReturnType<typeof v8.getHeapStatistics>);
|
||||
|
||||
const env = getBuildEnv({
|
||||
job: {
|
||||
platform: Platform.ANDROID,
|
||||
type: Workflow.MANAGED,
|
||||
builderEnvironment: {
|
||||
env: {},
|
||||
},
|
||||
username: 'expo-user',
|
||||
} as any,
|
||||
projectId: 'project-id',
|
||||
metadata: {
|
||||
buildProfile: 'production',
|
||||
gitCommitHash: 'abc123',
|
||||
username: 'expo-user',
|
||||
} as any,
|
||||
buildId: 'build-id',
|
||||
});
|
||||
|
||||
expect(env.NODE_OPTIONS).toBe(
|
||||
'--max-old-space-size=3072 --max-old-space-size=2048 --enable-source-maps'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function mockProcessPlatform(platform: NodeJS.Platform): void {
|
||||
|
||||
@@ -4,12 +4,15 @@ import { spawnSync } from 'child_process';
|
||||
import micromatch from 'micromatch';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import v8 from 'v8';
|
||||
|
||||
import config from './config';
|
||||
import { Environment } from './constants';
|
||||
import { androidImagesWithJavaVersionLowerThen11 } from './external/turtle';
|
||||
import { getAccessedEnvs } from './utils/env';
|
||||
|
||||
const GIB_IN_BYTES = 1024 ** 3;
|
||||
|
||||
// keep in sync with local-build-plugin env vars
|
||||
// see packages/local-build-plugin/src/build.ts
|
||||
export function getBuildEnv({
|
||||
@@ -50,6 +53,9 @@ export function getBuildEnv({
|
||||
setEnv(env, 'EAS_BUILD_WORKINGDIR', path.join(config.workingdir, 'build'));
|
||||
setEnv(env, 'EAS_BUILD_PROJECT_ID', projectId);
|
||||
|
||||
// Keep inherited options last so they override singleton options supplied by the worker.
|
||||
setEnv(env, 'NODE_OPTIONS', [getNodeMemoryOptions(), env.NODE_OPTIONS].filter(Boolean).join(' '));
|
||||
|
||||
const runnerPlatform = job.platform;
|
||||
if (runnerPlatform === Platform.IOS) {
|
||||
setEnv(env, 'EAS_BUILD_COCOAPODS_CACHE_URL', cocoapodsCacheUrl);
|
||||
@@ -179,3 +185,17 @@ export function getGradleMemoryOptions() {
|
||||
maxHeapSize: `${Math.max(1, Math.round(totalMemoryGb / 4))}g`,
|
||||
};
|
||||
}
|
||||
|
||||
export function getNodeMemoryOptions(): string | undefined {
|
||||
// constrainedMemory() returns 0 when there is no container or process memory limit.
|
||||
const memoryLimit = process.constrainedMemory() || os.totalmem();
|
||||
const heapLimit = v8.getHeapStatistics().heap_size_limit;
|
||||
|
||||
// V8 defaults to a roughly 2 GiB heap on smaller workers. Raise the ceiling for build child
|
||||
// processes only when the worker has enough memory and Node has not already selected a larger one.
|
||||
if (memoryLimit < 6 * GIB_IN_BYTES || heapLimit >= 3 * GIB_IN_BYTES) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return '--max-old-space-size=3072';
|
||||
}
|
||||
@@ -18,7 +18,7 @@
|
||||
"chalk": "5.2.0",
|
||||
"dateformat": "4.5.1",
|
||||
"fs-extra": "11.2.0",
|
||||
"lodash-es": "4.17.21",
|
||||
"lodash-es": "4.18.1",
|
||||
"marked": "3.0.4",
|
||||
"semver": "7.5.4",
|
||||
"tslib": "2.6.2"
|
||||
|
||||
Generated
+5
-5
@@ -213,10 +213,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"lodash-es@npm:4.17.21":
|
||||
version: 4.17.21
|
||||
resolution: "lodash-es@npm:4.17.21"
|
||||
checksum: 10c0/fb407355f7e6cd523a9383e76e6b455321f0f153a6c9625e21a8827d10c54c2a2341bd2ae8d034358b60e07325e1330c14c224ff582d04612a46a4f0479ff2f2
|
||||
"lodash-es@npm:4.18.1":
|
||||
version: 4.18.1
|
||||
resolution: "lodash-es@npm:4.18.1"
|
||||
checksum: 10c0/35d4dcf87ef07f8d090f409447575800108057e360b445f590d0d25d09e3d1e33a163d2fc100d4d072b0f901d5e2fc533cd7c4bfd8eeb38a06abec693823c8b8
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -257,7 +257,7 @@ __metadata:
|
||||
chalk: "npm:5.2.0"
|
||||
dateformat: "npm:4.5.1"
|
||||
fs-extra: "npm:11.2.0"
|
||||
lodash-es: "npm:4.17.21"
|
||||
lodash-es: "npm:4.18.1"
|
||||
marked: "npm:3.0.4"
|
||||
semver: "npm:7.5.4"
|
||||
ts-node: "npm:10.9.2"
|
||||
|
||||
Reference in new issue
Block a user