Compare commits

..
Author SHA1 Message Date
AbbanMustafa 80494c297f redact 2026-05-11 17:31:31 -04:00
AbbanMustafa 9052e3e446 redact 2026-05-11 16:20:31 -04:00
AbbanMustafa ec6772317a redact 2026-05-11 15:07:25 -04:00
215 changed files with 2985 additions and 19504 deletions

No files matched your search

-16
View File
@@ -1,16 +0,0 @@
version: 2
updates:
# Keep SHA-pinned GitHub Actions fresh. Without this, pins never receive
# upstream security patches because tag movements no longer reach us.
- package-ecosystem: github-actions
directories:
- /
- /.github/actions/setup-mise
- /.github/internal-actions/setup-gcloud
- /.github/internal-actions/notify-slack-on-fail-or-recover
schedule:
interval: weekly
groups:
actions:
patterns:
- '*'
@@ -12,7 +12,7 @@ runs:
using: 'composite'
steps:
- name: Get previous workflow run status
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
uses: actions/github-script@v6
id: run-status
with:
script: |
@@ -28,7 +28,7 @@ runs:
- name: Send Slack Success Notification
if: fromJSON(steps.run-status.outputs.result).previous == 'failure' && fromJSON(steps.run-status.outputs.result).current == 'success'
uses: rtCamp/action-slack-notify@e31e87e03dd19038e411e38ae27cbad084a90661 # v2
uses: rtCamp/action-slack-notify@v2
env:
SLACK_CHANNEL: ${{ inputs.channel }}
SLACK_COLOR: good
@@ -41,7 +41,7 @@ runs:
- name: Send Slack Failure Notification
if: fromJSON(steps.run-status.outputs.result).current == 'failure'
uses: rtCamp/action-slack-notify@e31e87e03dd19038e411e38ae27cbad084a90661 # v2
uses: rtCamp/action-slack-notify@v2
env:
SLACK_CHANNEL: ${{ inputs.channel }}
SLACK_COLOR: danger
@@ -4,7 +4,7 @@ runs:
using: "composite"
steps:
- name: Auth gcloud
uses: google-github-actions/auth@c200f3691d83b41bf9bbd8638997a462592937ed # v2
uses: google-github-actions/auth@v2
with:
workload_identity_provider: 'projects/321830142373/locations/global/workloadIdentityPools/github/providers/expo'
project_id: exponentjs
+3 -3
View File
@@ -18,7 +18,7 @@ jobs:
name: Check CHANGELOG.md updated
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2
- uses: actions/checkout@v2
- name: Check if CHANGELOG.md was updated
id: changelog-updated
uses: tj-actions/changed-files@b1ba699b304f2083b602164e06a89b868c84f076
@@ -26,11 +26,11 @@ jobs:
files: CHANGELOG.md
- name: Fail if CHANGELOG.md was not updated and the "no changelog" label is absent
if: steps.changelog-updated.outputs.any_changed == 'false' && !contains(github.event.pull_request.labels.*.name, 'no changelog')
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
uses: actions/github-script@v6
with:
script: |
core.setFailed('Please add a changelog entry!')
- uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2
- uses: mshick/[email protected]
if: always()
with:
message-id: changelog-entry-check
+1 -7
View File
@@ -1,8 +1,5 @@
name: codemention
on:
# pull_request_target is required so fork PRs get a write token to post the
# mention comment. Safe because there is no checkout — the action only reads
# PR metadata via the GitHub API; no fork code is fetched or executed.
pull_request_target:
types: [opened, synchronize, ready_for_review]
jobs:
@@ -12,9 +9,6 @@ jobs:
contents: read
pull-requests: write
steps:
# Pinned to a commit SHA (not the v1.4.0 tag) because this runs under
# pull_request_target with a write-scoped token — a re-pointed tag from
# a compromised upstream account would give the attacker repo write access.
- uses: tobyhs/codemention@bb6bfb2c3ff1e6fee7ee37006bbee6d114057225 # v1.4.0
- uses: tobyhs/[email protected]
with:
githubToken: ${{ secrets.GITHUB_TOKEN }}
+5 -5
View File
@@ -8,7 +8,7 @@ jobs:
runs-on: ubuntu-20.04
if: "${{ contains(github.event.label.name, 'incomplete issue: missing or invalid repro') }}"
steps:
- uses: actions/github-script@ffc2c79a5b2490bd33e0a41c1de74b877714d736 # v3
- uses: actions/github-script@v3
with:
github-token: ${{ secrets.EXPO_BOT_GITHUB_TOKEN }}
script: |
@@ -52,7 +52,7 @@ jobs:
runs-on: ubuntu-20.04
if: "${{ contains(github.event.label.name, 'incomplete issue: missing info') }}"
steps:
- uses: actions/github-script@ffc2c79a5b2490bd33e0a41c1de74b877714d736 # v3
- uses: actions/github-script@v3
with:
github-token: ${{ secrets.EXPO_BOT_GITHUB_TOKEN }}
script: |
@@ -81,7 +81,7 @@ jobs:
runs-on: ubuntu-20.04
if: github.event.label.name == 'issue accepted'
steps:
- uses: actions/github-script@ffc2c79a5b2490bd33e0a41c1de74b877714d736 # v3
- uses: actions/github-script@v3
with:
github-token: ${{ secrets.EXPO_BOT_GITHUB_TOKEN }}
script: |
@@ -98,7 +98,7 @@ jobs:
runs-on: ubuntu-20.04
if: "${{ contains(github.event.label.name, 'invalid issue: question') }}"
steps:
- uses: actions/github-script@ffc2c79a5b2490bd33e0a41c1de74b877714d736 # v3
- uses: actions/github-script@v3
with:
github-token: ${{ secrets.EXPO_BOT_GITHUB_TOKEN }}
script: |
@@ -126,7 +126,7 @@ jobs:
runs-on: ubuntu-20.04
if: "${{ contains(github.event.label.name, 'invalid issue: feature request') }}"
steps:
- uses: actions/github-script@ffc2c79a5b2490bd33e0a41c1de74b877714d736 # v3
- uses: actions/github-script@v3
with:
github-token: ${{ secrets.EXPO_BOT_GITHUB_TOKEN }}
script: |
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
INPUT_DRY_RUN: ${{ github.event.inputs.dry_run }}
INPUT_STAGING_ONLY: ${{ github.event.inputs.staging_only }}
steps:
- uses: actions/setup-node@7c12f8017d5436eb855f1ed4399f037a36fbd9e8 # v2
- uses: actions/setup-node@v2
with:
registry-url: "https://registry.npmjs.org/"
scope: "expo"
+4 -4
View File
@@ -15,7 +15,7 @@ jobs:
upload_url: ${{ steps.create_release.outputs.upload_url }}
steps:
- id: create_release
uses: actions/create-release@0cb9c9b65d5d1901c1f53e5e66eaf4afd303e70e # v1
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
@@ -29,7 +29,7 @@ jobs:
permissions:
id-token: write
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-mise
- name: Install dependencies
run: yarn install --immutable
@@ -58,7 +58,7 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@v4
with:
token: ${{ secrets.EXPO_BOT_PAT }}
- uses: ./.github/actions/setup-mise
@@ -91,7 +91,7 @@ jobs:
cat /tmp/changelog.slack.md >> $GITHUB_ENV
echo 'EOF' >> $GITHUB_ENV
- name: Slack Notification
uses: rtCamp/action-slack-notify@e31e87e03dd19038e411e38ae27cbad084a90661 # v2
uses: rtCamp/action-slack-notify@v2
env:
SLACK_CHANNEL: eas-cli
SLACK_COLOR: ${{ job.status }}
+1 -1
View File
@@ -8,7 +8,7 @@ jobs:
close-issues:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@a20b814fb01b71def3bd6f56e7494d667ddf28da # v4
- uses: actions/stale@v4
with:
ascending: false
operations-per-run: 300
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
name: Ensure GraphQL schema and generated code is up-to-date
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2
- uses: actions/checkout@v2
- name: Setup tools
uses: ./.github/actions/setup-mise
- run: yarn install --immutable
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2
- uses: actions/checkout@v2
- name: Setup tools
uses: ./.github/actions/setup-mise
- run: yarn install --immutable
+3 -3
View File
@@ -21,7 +21,7 @@ jobs:
coverage: true
name: Test with Node ${{ matrix.node }}
steps:
- uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2
- uses: actions/checkout@v2
- name: Setup tools
uses: ./.github/actions/setup-mise
env:
@@ -66,7 +66,7 @@ jobs:
working-directory: ./scripts
env:
YARN_ENABLE_HARDENED_MODE: ${{ matrix.coverage == true && '1' || '0' }}
- uses: codecov/codecov-action@29386c70ef20e286228c72b668a06fd0e8399192 # v1
- uses: codecov/codecov-action@v1
if: ${{ matrix.coverage }}
with:
token: ${{ secrets.CODECOV_TOKEN }}
@@ -79,7 +79,7 @@ jobs:
name: Notify Slack
if: ${{ github.ref == 'refs/heads/main' && always() }}
steps:
- uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2
- uses: actions/checkout@v2
- name: Notify Slack
uses: ./.github/internal-actions/notify-slack-on-fail-or-recover
with:
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
INPUT_VERSION: ${{ github.event.inputs.version }}
INPUT_DRY_RUN: ${{ github.event.inputs.dry_run }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@v4
with:
ref: main
fetch-depth: 0
-7
View File
@@ -1,9 +1,2 @@
defaultSemverRangePrefix: ''
nodeLinker: node-modules
npmMinimalAgeGate: 2d
npmPreapprovedPackages:
- '@expo/*'
- 'eslint-plugin-universe'
- 'oxlint-config-universe'
+2 -124
View File
@@ -8,130 +8,6 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
### 🐛 Bug fixes
- [expo-cocoapods-proxy] Fix iOS worker tarball build failing on macOS Tahoe due to bundler incompatibility with RubyGems 4. ([#3824](https://github.com/expo/eas-cli/pull/3824) by [@gwdp](https://github.com/gwdp))
### 🧹 Chores
## [20.1.0](https://github.com/expo/eas-cli/releases/tag/v20.1.0) - 2026-06-05
### 🎉 New features
- [eas-cli] Add `eas update:embedded:view` command. ([#3810](https://github.com/expo/eas-cli/pull/3810) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas update:embedded:list` command. ([#3811](https://github.com/expo/eas-cli/pull/3811) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas update:embedded:delete` command. ([#3809](https://github.com/expo/eas-cli/pull/3809) by [@gwdp](https://github.com/gwdp))
- [build-tools] Auto-upload embedded bundle after build when `EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE` is set. ([#3767](https://github.com/expo/eas-cli/pull/3767) by [@gwdp](https://github.com/gwdp))
## [20.0.0](https://github.com/expo/eas-cli/releases/tag/v20.0.0) - 2026-05-29
### 🛠 Breaking changes
- [eas-cli] Rename observe commands: `observe:logs` → `observe:events` (events emitted via `logEvent`), previous `observe:events` → `observe:metrics` (individual performance metric samples), previous `observe:metrics` → `observe:metrics-summary` (aggregated stats by app version). ([#3778](https://github.com/expo/eas-cli/pull/3778) by [@kadikraman](https://github.com/kadikraman))
### 🎉 New features
- [eas-cli] `eas go` now prompts to select an Expo SDK version interactively when `--sdk-version` is not provided. ([#3768](https://github.com/expo/eas-cli/pull/3768) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas update:embedded:upload` command. ([#3720](https://github.com/expo/eas-cli/pull/3720) by [@gwdp](https://github.com/gwdp))
### 🐛 Bug fixes
- [build-tools] Fix `eas/start_ios_simulator` hanging on Xcode 26.4 by writing the readiness screenshot to a temp file instead of `/dev/null`. ([#3794](https://github.com/expo/eas-cli/pull/3794) by [@gwdp](https://github.com/gwdp))
- [build-tools] Disable `apsd` in the iOS Simulator after boot to stop a push-registration log storm that pegs `diagnosticd` CPU for the lifetime of the Simulator. ([#3795](https://github.com/expo/eas-cli/pull/3795) by [@gwdp](https://github.com/gwdp))
## [19.1.0](https://github.com/expo/eas-cli/releases/tag/v19.1.0) - 2026-05-25
### 🎉 New features
- [eas-cli] Add `--refresh-ad-hoc-provisioning-profile` flag to refresh managed ad-hoc provisioning profiles from App Store Connect before gathering build credentials in non-interactive mode. ([#3716](https://github.com/expo/eas-cli/pull/3716) by [@sswrk](https://github.com/sswrk))
- [eas-build-job] Add optional `refreshAdHocProvisioningProfile` field to iOS build jobs. ([#3717](https://github.com/expo/eas-cli/pull/3717) by [@sswrk](https://github.com/sswrk))
- [build-tools] Pass `refreshAdHocProvisioningProfile` through `eas build:internal` for git-based integration builds. ([#3770](https://github.com/expo/eas-cli/pull/3770) by [@sswrk](https://github.com/sswrk))
## [19.0.8](https://github.com/expo/eas-cli/releases/tag/v19.0.8) - 2026-05-21
### 🐛 Bug fixes
- [eas-cli] `eas go` now pre-selects the SDK version from the current project's `app.json` or `app.config.js` when available. ([#3776](https://github.com/expo/eas-cli/pull/3776) by [@gwdp](https://github.com/gwdp))
## [19.0.7](https://github.com/expo/eas-cli/releases/tag/v19.0.7) - 2026-05-21
## [19.0.6](https://github.com/expo/eas-cli/releases/tag/v19.0.6) - 2026-05-21
### 🐛 Bug fixes
- [build-tools] Update the minimum Expo version required for iOS precompiled modules to 55.0.26. ([#3771](https://github.com/expo/eas-cli/pull/3771) by [@chrfalch](https://github.com/chrfalch))
## [19.0.5](https://github.com/expo/eas-cli/releases/tag/v19.0.5) - 2026-05-20
### 🐛 Bug fixes
- [eas-cli] Improve Sentry diagnostics for managed iOS entitlements config fallback. ([#3763](https://github.com/expo/eas-cli/pull/3763) by [@sjchmiela](https://github.com/sjchmiela))
## [19.0.4](https://github.com/expo/eas-cli/releases/tag/v19.0.4) - 2026-05-20
## [19.0.1](https://github.com/expo/eas-cli/releases/tag/v19.0.1) - 2026-05-19
### 🐛 Bug fixes
- [eas-cli] Disable dotenv loading when resolving managed iOS entitlements. ([#3752](https://github.com/expo/eas-cli/pull/3752) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-cli] Restore the bundled config fallback for managed iOS entitlements introspection. ([#3753](https://github.com/expo/eas-cli/pull/3753) by [@sjchmiela](https://github.com/sjchmiela))
## [19.0.0](https://github.com/expo/eas-cli/releases/tag/v19.0.0) - 2026-05-19
### 🛠 Breaking changes
- [eas-cli] Make browser-based authentication the default for `eas login`. Use `--no-browser` for CLI-based login. ([#3746](https://github.com/expo/eas-cli/pull/3746) by [@byronkarlen](https://github.com/byronkarlen))
### 🎉 New features
- [eas-cli] Allow command `observe:routes` to filter results to specific route names. ([#3744](https://github.com/expo/eas-cli/pull/3744) by [@douglowder](https://github.com/douglowder))
### 🐛 Bug fixes
- [eas-cli] Check that `expo` is resolvable before assuming it's installed ([#3748](https://github.com/expo/eas-cli/pull/3748) by [@kitten](https://github.com/kitten))
### 🧹 Chores
- [worker] Report build phase durations stats. ([#3732](https://github.com/expo/eas-cli/pull/3732) by [@gwdp](https://github.com/gwdp))
## [18.13.1](https://github.com/expo/eas-cli/releases/tag/v18.13.1) - 2026-05-18
### 🎉 New features
- [eas-cli] Send the installed `expo` package version in build metadata. ([#3725](https://github.com/expo/eas-cli/pull/3725) by [@sjchmiela](https://github.com/sjchmiela))
### 🐛 Bug fixes
- [eas-cli] Prevent `npx` invocations that can be unreliable and fail when retrieving entitlements or project configs ([#3282](https://github.com/expo/eas-cli/pull/3282) by [@kitten](https://github.com/kitten))
## [18.13.0](https://github.com/expo/eas-cli/releases/tag/v18.13.0) - 2026-05-14
### 🎉 New features
- [eas-cli] New command `observe:routes` for navigation metrics. ([#3730](https://github.com/expo/eas-cli/pull/3730) by [@douglowder](https://github.com/douglowder))
### 🧹 Chores
- [eas-cli] Refactor observe commands to extract common flag definitions. ([#3731](https://github.com/expo/eas-cli/pull/3731) by [@douglowder](https://github.com/douglowder))
## [18.12.3](https://github.com/expo/eas-cli/releases/tag/v18.12.3) - 2026-05-13
## [18.12.2](https://github.com/expo/eas-cli/releases/tag/v18.12.2) - 2026-05-12
### 🐛 Bug fixes
- [eas-cli] Fix `eas go` to use server-side project setup and support multiple Expo Go SDK versions. ([#3657](https://github.com/expo/eas-cli/pull/3657) by [@gwdp](https://github.com/gwdp))
### 🧹 Chores
- [ci] Audit GitHub Actions workflows: harden `pull_request_target` usage, pin all external actions to commit SHAs, and add Dependabot to refresh them. ([#3718](https://github.com/expo/eas-cli/pull/3718) by [@brentvatne](https://github.com/brentvatne))
## [18.12.1](https://github.com/expo/eas-cli/releases/tag/v18.12.1) - 2026-05-12
## [18.12.0](https://github.com/expo/eas-cli/releases/tag/v18.12.0) - 2026-05-12
### 🎉 New features
- [eas-cli] Automatically save the Convex deployment URL as an EAS environment variable when connecting a Convex project. ([#3685](https://github.com/expo/eas-cli/pull/3685) by [@fiberjw](https://github.com/fiberjw))
### 🐛 Bug fixes
@@ -139,6 +15,8 @@ This is the log of notable changes to EAS CLI and related packages.
- [build-tools] Update the minimum Expo version required for iOS precompiled modules. ([#3677](https://github.com/expo/eas-cli/pull/3677) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-cli] recommend that users read the docs after connecting Convex ([#3683](https://github.com/expo/eas-cli/pull/3683) by [@fiberjw](https://github.com/fiberjw))
### 🧹 Chores
## [18.11.0](https://github.com/expo/eas-cli/releases/tag/v18.11.0) - 2026-05-05
### 🎉 New features
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "20.1.0",
"version": "18.11.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
-2
View File
@@ -3,6 +3,4 @@ _.path = "./tools/bin"
MISE_NODE_COREPACK = "true"
[tools]
gcloud = "570.0.0"
jq = "1.8.1"
node = "20.19.4"
+8 -10
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "20.1.0",
"version": "18.11.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -37,21 +37,19 @@
"dependencies": {
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/downloader": "20.0.0",
"@expo/eas-build-job": "20.1.0",
"@expo/downloader": "18.5.0",
"@expo/eas-build-job": "18.8.0",
"@expo/env": "^0.4.0",
"@expo/logger": "20.0.0",
"@expo/logger": "18.5.0",
"@expo/package-manager": "1.9.10",
"@expo/plist": "^0.2.0",
"@expo/results": "^1.0.0",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "20.1.0",
"@expo/template-file": "20.0.0",
"@expo/turtle-spawn": "20.0.0",
"@expo/steps": "18.9.0",
"@expo/template-file": "18.5.0",
"@expo/turtle-spawn": "18.5.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",
"bplist-parser": "0.3.2",
"fast-glob": "^3.3.2",
@@ -77,7 +75,7 @@
"zod": "^4.3.5"
},
"devDependencies": {
"@expo/repack-app": "~0.6.1",
"@expo/repack-app": "~0.4.1",
"@types/fs-extra": "^11.0.4",
"@types/jest": "^29.5.12",
"@types/lodash": "^4.17.4",
@@ -1,36 +1,14 @@
import {
BuildPhase,
BuildPhaseResult,
BuildTrigger,
Job,
Metadata,
Platform,
} from '@expo/eas-build-job';
import { BuildTrigger, Job, Metadata } from '@expo/eas-build-job';
import { randomUUID } from 'crypto';
import fs from 'fs-extra';
import { vol } from 'memfs';
import { createMockLogger } from './utils/logger';
import { BuildContext } from '../context';
import { Datadog } from '../datadog';
jest.mock('fs');
jest.mock('fs-extra');
jest.mock('../datadog', () => ({
Datadog: {
distribution: jest.fn(),
},
}));
const datadogDistributionMock = jest.mocked(Datadog.distribution);
describe('BuildContext', () => {
beforeEach(() => {
vol.reset();
jest.clearAllMocks();
(fs.readdir as unknown as jest.Mock).mockResolvedValue([]);
});
it('should merge secrets', async () => {
const robotAccessToken = randomUUID();
await vol.promises.mkdir('/workingdir/eas-environment-secrets/', { recursive: true });
@@ -138,68 +116,4 @@ describe('BuildContext', () => {
foo: 'bar',
});
});
it('emits build phase duration metrics for successful build phases', async () => {
const ctx = createTestBuildContext({ platform: Platform.IOS });
await ctx.runBuildPhase(BuildPhase.RUN_FASTLANE, async () => {});
expect(datadogDistributionMock).toHaveBeenCalledWith(
'eas.build.phase_duration',
expect.any(Number),
{
build_phase: 'run_fastlane',
platform: Platform.IOS,
result: BuildPhaseResult.SUCCESS,
}
);
});
it('emits build phase duration metrics for failed build phases', async () => {
const ctx = createTestBuildContext({ platform: Platform.ANDROID });
await expect(
ctx.runBuildPhase(BuildPhase.INSTALL_DEPENDENCIES, async () => {
throw new Error('boom');
})
).rejects.toThrow();
expect(datadogDistributionMock).toHaveBeenCalledWith(
'eas.build.phase_duration',
expect.any(Number),
{
build_phase: 'install_dependencies',
platform: Platform.ANDROID,
result: BuildPhaseResult.FAIL,
}
);
});
it('does not emit build phase duration metrics for non-platform jobs', async () => {
const ctx = createTestBuildContext({});
await ctx.runBuildPhase(BuildPhase.PREPARE_PROJECT, async () => {});
expect(datadogDistributionMock).not.toHaveBeenCalled();
});
});
function createTestBuildContext({ platform }: { platform?: Platform }): BuildContext {
vol.mkdirSync('/workingdir/env', { recursive: true });
return new BuildContext(
{
...(platform ? { platform } : {}),
builderEnvironment: { env: {} },
} as Job,
{
env: {
__API_SERVER_URL: 'http://api.expo.test',
},
workingdir: '/workingdir',
logger: createMockLogger(),
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
uploadArtifact: jest.fn(),
}
);
}
@@ -1,407 +0,0 @@
import { Response } from 'node-fetch';
import { Datadog } from '../datadog';
import { Sentry } from '../sentry';
import { turtleFetch } from '../utils/turtleFetch';
jest.mock('../sentry', () => ({
Sentry: {
capture: jest.fn(),
},
}));
jest.mock('../utils/turtleFetch', () => ({
turtleFetch: jest.fn(),
}));
const turtleFetchMock = jest.mocked(turtleFetch);
const sentryCaptureMock = jest.mocked(Sentry.capture);
describe('Datadog singleton', () => {
beforeEach(() => {
jest.clearAllMocks();
Datadog.setup(null);
});
it('POSTs distribution metrics to the turtle build metrics endpoint for build targets', () => {
turtleFetchMock.mockResolvedValueOnce({} as Response);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'token-abc',
target: { kind: 'build', turtleBuildId: 'build-id' },
});
Datadog.distribution('eas.build.phase_duration', 1234, {
build_phase: 'install_dependencies',
platform: 'ios',
result: 'success',
});
expect(turtleFetchMock).toHaveBeenCalledWith(
'https://api.expo.test/v2/turtle-builds/build-id/metrics',
'POST',
{
json: {
metrics: [
{
name: 'eas.build.phase_duration',
type: 'distribution',
value: 1234,
tags: {
build_phase: 'install_dependencies',
platform: 'ios',
result: 'success',
target: 'build',
},
},
],
},
headers: { Authorization: 'Bearer token-abc' },
retries: 2,
}
);
});
it('POSTs distribution metrics to the turtle job run metrics endpoint for jobRun targets', () => {
turtleFetchMock.mockResolvedValueOnce({} as Response);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'token-abc',
target: { kind: 'jobRun', turtleJobRunId: 'job-run-id' },
});
Datadog.distribution('eas.maestro.install', 1, {
maestro_version: '2.1.0',
});
expect(turtleFetchMock).toHaveBeenCalledWith(
'https://api.expo.test/v2/turtle-job-runs/job-run-id/metrics',
'POST',
{
json: {
metrics: [
{
name: 'eas.maestro.install',
type: 'distribution',
value: 1,
tags: {
maestro_version: '2.1.0',
target: 'job_run',
},
},
],
},
headers: { Authorization: 'Bearer token-abc' },
retries: 2,
}
);
});
it('POSTs logs to the turtle build logs endpoint with buildId for build targets', () => {
turtleFetchMock.mockResolvedValueOnce({} as Response);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'token-abc',
target: { kind: 'build', turtleBuildId: 'build-id' },
});
Datadog.log('artifact dry-run matched', {
event: 'find_artifacts_absolute_path_dry_run',
status: 'match',
});
expect(turtleFetchMock).toHaveBeenCalledWith(
'https://api.expo.test/v2/turtle-builds/logs',
'POST',
{
json: {
buildId: 'build-id',
message: 'artifact dry-run matched',
tags: {
event: 'find_artifacts_absolute_path_dry_run',
status: 'match',
target: 'build',
},
},
headers: { Authorization: 'Bearer token-abc' },
shouldThrowOnNotOk: false,
}
);
});
it('POSTs logs to the turtle job run logs endpoint with jobRunId for jobRun targets', () => {
turtleFetchMock.mockResolvedValueOnce({} as Response);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'token-abc',
target: { kind: 'jobRun', turtleJobRunId: 'job-run-id' },
});
Datadog.log('Gradle cache restored (hit)', {
cache_type: 'gradle',
});
expect(turtleFetchMock).toHaveBeenCalledWith(
'https://api.expo.test/v2/turtle-job-runs/logs',
'POST',
{
json: {
jobRunId: 'job-run-id',
message: 'Gradle cache restored (hit)',
tags: {
cache_type: 'gradle',
target: 'job_run',
},
},
headers: { Authorization: 'Bearer token-abc' },
shouldThrowOnNotOk: false,
}
);
});
it('is a no-op when setup is null', () => {
Datadog.setup(null);
Datadog.distribution('eas.build.phase_duration', 1);
Datadog.log('artifact dry-run matched');
expect(turtleFetchMock).not.toHaveBeenCalled();
});
it('swallows metric upload failures for build targets', async () => {
turtleFetchMock.mockRejectedValueOnce(new Error('network down'));
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'token-abc',
target: { kind: 'build', turtleBuildId: 'build-id' },
});
Datadog.distribution('eas.build.phase_duration', 1);
await flushPromises();
expect(turtleFetchMock).toHaveBeenCalled();
expect(sentryCaptureMock).toHaveBeenCalledWith(
'Failed to report turtle build metric',
expect.any(Error),
expect.objectContaining({
extras: {
metrics: [
{
name: 'eas.build.phase_duration',
type: 'distribution',
value: 1,
tags: { target: 'build' },
},
],
},
})
);
});
it('labels metric upload failures for jobRun targets with the jobRun kind', async () => {
turtleFetchMock.mockRejectedValueOnce(new Error('network down'));
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'token-abc',
target: { kind: 'jobRun', turtleJobRunId: 'job-run-id' },
});
Datadog.distribution('eas.maestro.install', 1);
await flushPromises();
expect(sentryCaptureMock).toHaveBeenCalledWith(
'Failed to report turtle jobRun metric',
expect.any(Error),
expect.objectContaining({
extras: {
metrics: [
{
name: 'eas.maestro.install',
type: 'distribution',
value: 1,
tags: { target: 'job_run' },
},
],
},
})
);
});
it('swallows log upload failures for build targets', async () => {
turtleFetchMock.mockRejectedValueOnce(new Error('network down'));
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'token-abc',
target: { kind: 'build', turtleBuildId: 'build-id' },
});
Datadog.log('artifact dry-run matched');
await flushPromises();
expect(turtleFetchMock).toHaveBeenCalled();
expect(sentryCaptureMock).toHaveBeenCalledWith(
'Failed to report turtle build log',
expect.any(Error),
expect.objectContaining({
extras: {
log: {
buildId: 'build-id',
message: 'artifact dry-run matched',
tags: { target: 'build' },
},
},
})
);
});
it('labels log upload failures for jobRun targets with the jobRun kind', async () => {
turtleFetchMock.mockRejectedValueOnce(new Error('network down'));
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'token-abc',
target: { kind: 'jobRun', turtleJobRunId: 'job-run-id' },
});
Datadog.log('Gradle cache restored (hit)');
await flushPromises();
expect(sentryCaptureMock).toHaveBeenCalledWith(
'Failed to report turtle jobRun log',
expect.any(Error),
expect.objectContaining({
extras: {
log: {
jobRunId: 'job-run-id',
message: 'Gradle cache restored (hit)',
tags: { target: 'job_run' },
},
},
})
);
});
it('uses the latest setup options', () => {
turtleFetchMock.mockResolvedValue({} as Response);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'first-token',
target: { kind: 'build', turtleBuildId: 'first-build-id' },
});
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'second-token',
target: { kind: 'jobRun', turtleJobRunId: 'job-run-id' },
});
Datadog.distribution('eas.build.phase_duration', 1);
Datadog.log('artifact dry-run matched');
expect(turtleFetchMock).toHaveBeenNthCalledWith(
1,
'https://api.expo.test/v2/turtle-job-runs/job-run-id/metrics',
'POST',
expect.objectContaining({
headers: { Authorization: 'Bearer second-token' },
})
);
expect(turtleFetchMock).toHaveBeenNthCalledWith(
2,
'https://api.expo.test/v2/turtle-job-runs/logs',
'POST',
expect.objectContaining({
headers: { Authorization: 'Bearer second-token' },
})
);
});
it('flushes pending metric uploads', async () => {
let resolveUpload!: (response: Response) => void;
turtleFetchMock.mockReturnValueOnce(
new Promise<Response>(resolve => {
resolveUpload = resolve;
})
);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'token-abc',
target: { kind: 'build', turtleBuildId: 'build-id' },
});
let flushed = false;
Datadog.distribution('eas.build.phase_duration', 1);
const flushPromise = Datadog.flushAsync().then(() => {
flushed = true;
});
await Promise.resolve();
expect(flushed).toBe(false);
resolveUpload({} as Response);
await flushPromise;
expect(flushed).toBe(true);
});
it('flushes pending log uploads', async () => {
let resolveUpload!: (response: Response) => void;
turtleFetchMock.mockReturnValueOnce(
new Promise<Response>(resolve => {
resolveUpload = resolve;
})
);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'token-abc',
target: { kind: 'build', turtleBuildId: 'build-id' },
});
let flushed = false;
Datadog.log('artifact dry-run matched');
const flushPromise = Datadog.flushAsync().then(() => {
flushed = true;
});
await Promise.resolve();
expect(flushed).toBe(false);
resolveUpload({} as Response);
await flushPromise;
expect(flushed).toBe(true);
});
it('only awaits uploads enqueued since the previous flush', async () => {
let resolveSecondUpload!: (response: Response) => void;
turtleFetchMock.mockReturnValueOnce(new Promise<Response>(() => {})).mockReturnValueOnce(
new Promise<Response>(resolve => {
resolveSecondUpload = resolve;
})
);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'token-abc',
target: { kind: 'build', turtleBuildId: 'build-id' },
});
// The first upload never settles; the first flush rotates it out of the queue.
Datadog.distribution('eas.build.phase_duration', 1);
void Datadog.flushAsync();
Datadog.distribution('eas.build.phase_duration', 2);
let flushed = false;
const flushPromise = Datadog.flushAsync().then(() => {
flushed = true;
});
resolveSecondUpload({} as Response);
await flushPromise;
expect(flushed).toBe(true);
});
});
async function flushPromises(): Promise<void> {
await new Promise(resolve => setImmediate(resolve));
}
@@ -1,317 +0,0 @@
import fetch from 'node-fetch';
import { RuntimeSettings } from '../runtimeSettings';
import { Sentry } from '../sentry';
jest.mock('node-fetch', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../sentry', () => ({
Sentry: {
capture: jest.fn(),
},
}));
describe('runtimeSettings', () => {
const logger = {
info: jest.fn(),
warn: jest.fn(),
};
const originalCacheUrls = {
EAS_NPM_CACHE_URL: process.env.EAS_NPM_CACHE_URL,
EAS_NODEJS_CACHE_URL: process.env.EAS_NODEJS_CACHE_URL,
EAS_MAVEN_CACHE_URL: process.env.EAS_MAVEN_CACHE_URL,
EAS_COCOAPODS_CACHE_URL: process.env.EAS_COCOAPODS_CACHE_URL,
};
const originalPlatform = process.platform;
afterEach(() => {
jest.mocked(fetch).mockReset();
jest.restoreAllMocks();
jest.mocked(Sentry.capture).mockReset();
logger.info.mockReset();
logger.warn.mockReset();
restoreEnv('EAS_NPM_CACHE_URL', originalCacheUrls.EAS_NPM_CACHE_URL);
restoreEnv('EAS_NODEJS_CACHE_URL', originalCacheUrls.EAS_NODEJS_CACHE_URL);
restoreEnv('EAS_MAVEN_CACHE_URL', originalCacheUrls.EAS_MAVEN_CACHE_URL);
restoreEnv('EAS_COCOAPODS_CACHE_URL', originalCacheUrls.EAS_COCOAPODS_CACHE_URL);
mockProcessPlatform(originalPlatform);
});
beforeEach(async () => {
jest.mocked(fetch).mockResolvedValue(response({}));
await RuntimeSettings.loadAsync({ environment: 'staging', logger: logger as any });
jest.mocked(fetch).mockReset();
logger.info.mockReset();
logger.warn.mockReset();
});
it('fetches runtime settings from the staging and production buckets', async () => {
jest.mocked(fetch).mockResolvedValue(response({}));
await RuntimeSettings.loadAsync({
environment: 'staging',
logger: logger as any,
cacheUrlFallbacks: {
npm: 'https://fallback-npm.example',
},
});
await RuntimeSettings.loadAsync({ environment: 'production', logger: logger as any });
await RuntimeSettings.loadAsync({ environment: 'test', logger: logger as any });
expect(fetch).toHaveBeenNthCalledWith(
1,
'https://storage.googleapis.com/eas-workflows-staging/runtime-settings.json',
{
signal: expect.anything(),
}
);
expect(fetch).toHaveBeenNthCalledWith(
2,
'https://storage.googleapis.com/eas-workflows-production/runtime-settings.json',
{
signal: expect.anything(),
}
);
expect(fetch).toHaveBeenCalledTimes(2);
});
it('does not use caches by default when remote settings are unavailable', async () => {
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
jest.mocked(fetch).mockResolvedValue(response({}, 404));
await RuntimeSettings.loadAsync({ environment: 'staging', logger: logger as any });
mockProcessPlatform('linux');
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
expect(RuntimeSettings.isUsingIosPrecompiledModulesEnabled()).toBe(false);
expect(logger.warn).toHaveBeenCalled();
expect(Sentry.capture).toHaveBeenCalledWith('Failed to fetch worker runtime settings', {
extras: {
status: 404,
url: 'https://storage.googleapis.com/eas-workflows-staging/runtime-settings.json',
},
level: 'warning',
});
});
it('does not override existing settings when remote settings are invalid', async () => {
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
jest
.mocked(fetch)
.mockResolvedValueOnce(response({ caches: { linux: { npm: true } } }))
.mockResolvedValueOnce(response({ iosPrecompiledModules: 'enabled' }));
await RuntimeSettings.loadAsync({ environment: 'staging', logger: logger as any });
await RuntimeSettings.loadAsync({ environment: 'staging', logger: logger as any });
mockProcessPlatform('linux');
expect(RuntimeSettings.getNpmCacheUrl()).toBe('https://npm.example');
expect(RuntimeSettings.isUsingIosPrecompiledModulesEnabled()).toBe(false);
expect(logger.warn).toHaveBeenCalled();
expect(Sentry.capture).toHaveBeenCalledWith(
'Failed to load worker runtime settings',
expect.any(Error),
{
extras: {
url: 'https://storage.googleapis.com/eas-workflows-staging/runtime-settings.json',
},
level: 'warning',
}
);
});
it('accepts partial runtime settings and ignores unknown fields', async () => {
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
process.env.EAS_NODEJS_CACHE_URL = 'https://node.example';
process.env.EAS_MAVEN_CACHE_URL = 'https://maven.example';
process.env.EAS_COCOAPODS_CACHE_URL = 'https://pods.example';
jest.mocked(fetch).mockResolvedValue(
response({
caches: {
linux: { npm: false, nodejs: true, maven: true },
darwin: { npm: true, maven: true, cocoapods: false },
},
iosPrecompiledModules: true,
unknownFutureSetting: true,
})
);
await RuntimeSettings.loadAsync({ environment: 'staging', logger: logger as any });
mockProcessPlatform('linux');
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
expect(RuntimeSettings.getNodeJsCacheUrl()).toBe('https://node.example');
expect(RuntimeSettings.getMavenCacheUrl()).toBe('https://maven.example');
mockProcessPlatform('darwin');
expect(RuntimeSettings.getMavenCacheUrl()).toBe('https://maven.example');
expect(RuntimeSettings.getCocoapodsCacheUrl()).toBeNull();
expect(RuntimeSettings.getNpmCacheUrl()).toBe('https://npm.example');
expect(RuntimeSettings.isUsingIosPrecompiledModulesEnabled()).toBe(true);
});
it('uses Maven and CocoaPods caches when enabled for the current platform and their environment variables exist', async () => {
process.env.EAS_MAVEN_CACHE_URL = 'https://maven.example';
process.env.EAS_COCOAPODS_CACHE_URL = 'https://pods.example';
jest.mocked(fetch).mockResolvedValue(
response({
caches: {
linux: { maven: true },
darwin: { maven: true, cocoapods: true },
},
})
);
await RuntimeSettings.loadAsync({ environment: 'staging', logger: logger as any });
mockProcessPlatform('linux');
expect(RuntimeSettings.getMavenCacheUrl()).toBe('https://maven.example');
mockProcessPlatform('darwin');
expect(RuntimeSettings.getMavenCacheUrl()).toBe('https://maven.example');
expect(RuntimeSettings.getCocoapodsCacheUrl()).toBe('https://pods.example');
});
it('uses cache URL fallbacks when enabled cache environment variables are not set', async () => {
jest.mocked(fetch).mockResolvedValue(
response({
caches: {
linux: { npm: true, nodejs: true, maven: true },
darwin: { cocoapods: true },
},
})
);
await RuntimeSettings.loadAsync({
environment: 'staging',
logger: logger as any,
cacheUrlFallbacks: {
npm: 'https://fallback-npm.example',
nodejs: 'https://fallback-node.example',
maven: 'https://fallback-maven.example',
cocoapods: 'https://fallback-pods.example',
},
});
mockProcessPlatform('linux');
expect(RuntimeSettings.getNpmCacheUrl()).toBe('https://fallback-npm.example');
expect(RuntimeSettings.getNodeJsCacheUrl()).toBe('https://fallback-node.example');
expect(RuntimeSettings.getMavenCacheUrl()).toBe('https://fallback-maven.example');
mockProcessPlatform('darwin');
expect(RuntimeSettings.getCocoapodsCacheUrl()).toBe('https://fallback-pods.example');
});
it('prefers cache URL environment variables over fallbacks', async () => {
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
jest.mocked(fetch).mockResolvedValue(
response({
caches: {
linux: { npm: true },
},
})
);
await RuntimeSettings.loadAsync({
environment: 'staging',
logger: logger as any,
cacheUrlFallbacks: {
npm: 'https://fallback-npm.example',
},
});
mockProcessPlatform('linux');
expect(RuntimeSettings.getNpmCacheUrl()).toBe('https://npm.example');
});
it('does not use cache URL fallbacks when runtime settings disable caches', async () => {
jest.mocked(fetch).mockResolvedValue(
response({
caches: {
linux: { npm: false },
},
})
);
await RuntimeSettings.loadAsync({
environment: 'staging',
logger: logger as any,
cacheUrlFallbacks: {
npm: 'https://fallback-npm.example',
},
});
mockProcessPlatform('linux');
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
});
it('clears cache URL fallbacks when loading without them', async () => {
jest.mocked(fetch).mockResolvedValue(response({ caches: { linux: { npm: true } } }));
await RuntimeSettings.loadAsync({
environment: 'staging',
logger: logger as any,
cacheUrlFallbacks: {
npm: 'https://fallback-npm.example',
},
});
await RuntimeSettings.loadAsync({ environment: 'staging', logger: logger as any });
mockProcessPlatform('linux');
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
});
it('does not use caches when runtime settings are not loaded', () => {
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
jest.isolateModules(() => {
const { RuntimeSettings } = require('../runtimeSettings');
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
expect(RuntimeSettings.isUsingIosPrecompiledModulesEnabled()).toBe(false);
});
});
it('reports when loaded runtime settings omit the current platform cache settings', async () => {
jest.mocked(fetch).mockResolvedValue(response({ caches: { linux: { npm: true } } }));
mockProcessPlatform('darwin');
await RuntimeSettings.loadAsync({ environment: 'staging', logger: logger as any });
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
expect(Sentry.capture).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Runtime settings are missing cache settings for platform "darwin"',
}),
{
extras: {
configuredPlatforms: ['linux'],
platform: 'darwin',
},
level: 'error',
}
);
});
});
function restoreEnv(key: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
function mockProcessPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', {
configurable: true,
value: platform,
});
}
function response(body: unknown, status = 200): any {
return {
ok: status >= 200 && status < 300,
status,
json: async () => body,
};
}
@@ -1,256 +0,0 @@
import * as sentryNode from '@sentry/node';
import { Sentry } from '../sentry';
jest.mock('@sentry/node');
describe('Sentry singleton', () => {
beforeEach(() => {
jest.mocked(sentryNode.init).mockReset();
jest.mocked(sentryNode.flush).mockReset();
jest.mocked(sentryNode.captureException).mockReset();
jest.mocked(sentryNode.captureMessage).mockReset();
jest.mocked(sentryNode.withScope).mockReset();
});
describe('setup', () => {
it('initializes the SDK with dsn + environment when dsn is non-null', () => {
Sentry.setup({ dsn: 'https://[email protected]/1', environment: 'production' });
expect(sentryNode.init).toHaveBeenCalledWith({
dsn: 'https://[email protected]/1',
environment: 'production',
});
});
it('passes default tags into the SDK init as initialScope', () => {
Sentry.setup({
dsn: 'https://[email protected]/1',
environment: 'production',
tags: { service: 'worker:darwin' },
});
expect(sentryNode.init).toHaveBeenCalledWith({
dsn: 'https://[email protected]/1',
environment: 'production',
initialScope: { tags: { service: 'worker:darwin' } },
});
});
it('skips SDK init when dsn is null', () => {
Sentry.setup({ dsn: null, environment: 'development' });
expect(sentryNode.init).not.toHaveBeenCalled();
});
it('passes the latest initialScope tags when setup is called more than once', () => {
Sentry.setup({
dsn: 'https://[email protected]/1',
environment: 'test',
tags: { service: 'first' },
});
Sentry.setup({
dsn: 'https://[email protected]/1',
environment: 'test',
tags: { service: 'second' },
});
expect(sentryNode.init).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ initialScope: { tags: { service: 'second' } } })
);
});
});
describe('capture', () => {
it('routes (msg) to captureMessage with msg only', () => {
const scope = mockWithScope();
Sentry.capture('plain message');
expect(sentryNode.captureMessage).toHaveBeenCalledWith('plain message');
expect(sentryNode.captureException).not.toHaveBeenCalled();
expect(scope.setExtra).not.toHaveBeenCalled();
});
it('routes (msg, options) and sets per-call tags/extras/level on the scope', () => {
const scope = mockWithScope();
Sentry.capture('something happened', {
tags: { phase: 'running-xclogparser' },
extras: { buildId: 'abc' },
level: 'warning',
});
expect(scope.setTags).toHaveBeenCalledWith({ phase: 'running-xclogparser' });
expect(scope.setExtras).toHaveBeenCalledWith({ buildId: 'abc' });
expect(scope.setLevel).toHaveBeenCalledWith('warning');
expect(sentryNode.captureMessage).toHaveBeenCalledWith('something happened');
expect(sentryNode.captureException).not.toHaveBeenCalled();
});
it('routes (msg, err) to captureException and sets msg as `message` extra when distinct from err.message', () => {
const scope = mockWithScope();
const err = new Error('boom');
Sentry.capture('something went wrong', err);
expect(scope.setExtra).toHaveBeenCalledWith('message', 'something went wrong');
expect(sentryNode.captureException).toHaveBeenCalledWith(err);
expect(sentryNode.captureMessage).not.toHaveBeenCalled();
});
it('routes (msg, err, options) to captureException with per-call tags/extras', () => {
const scope = mockWithScope();
const err = new Error('spawn ENOENT');
Sentry.capture('xclogparser failed', err, {
tags: { phase: 'running-xclogparser' },
extras: { buildId: 'abc' },
});
expect(scope.setTags).toHaveBeenCalledWith({ phase: 'running-xclogparser' });
expect(scope.setExtras).toHaveBeenCalledWith({ buildId: 'abc' });
expect(scope.setExtra).toHaveBeenCalledWith('message', 'xclogparser failed');
expect(sentryNode.captureException).toHaveBeenCalledWith(err);
});
it('does not set the `message` extra when msg equals err.message', () => {
const scope = mockWithScope();
const err = new Error('build failed');
Sentry.capture('build failed', err);
expect(scope.setExtra).not.toHaveBeenCalledWith('message', expect.anything());
expect(sentryNode.captureException).toHaveBeenCalledWith(err);
});
it('routes (err) alone to captureException with no scope mutations', () => {
const scope = mockWithScope();
const err = new Error('boom');
Sentry.capture(err);
expect(scope.setTags).not.toHaveBeenCalled();
expect(scope.setExtras).not.toHaveBeenCalled();
expect(scope.setLevel).not.toHaveBeenCalled();
expect(scope.setExtra).not.toHaveBeenCalled();
expect(sentryNode.captureException).toHaveBeenCalledWith(err);
expect(sentryNode.captureMessage).not.toHaveBeenCalled();
});
it('treats (msg, null) as a no-err call (parity with 3-arg null behavior)', () => {
const scope = mockWithScope();
Sentry.capture('forwarded null', null as unknown as Error);
expect(scope.setExtra).not.toHaveBeenCalled();
expect(sentryNode.captureMessage).toHaveBeenCalledWith('forwarded null');
expect(sentryNode.captureException).not.toHaveBeenCalled();
});
it('treats (msg, null, options) as a no-err call', () => {
const scope = mockWithScope();
Sentry.capture('forwarded null with opts', null as unknown as Error, {
tags: { phase: 'cleanup' },
});
expect(scope.setTags).toHaveBeenCalledWith({ phase: 'cleanup' });
expect(scope.setExtra).not.toHaveBeenCalled();
expect(sentryNode.captureMessage).toHaveBeenCalledWith('forwarded null with opts');
expect(sentryNode.captureException).not.toHaveBeenCalled();
});
it('treats (msg, undefined, options) as a no-err call (forwarding `Error | undefined` typed vars)', () => {
const scope = mockWithScope();
Sentry.capture('forwarded with no err', undefined, {
tags: { phase: 'install-dependencies' },
extras: { buildId: 'abc' },
});
expect(scope.setTags).toHaveBeenCalledWith({ phase: 'install-dependencies' });
expect(scope.setExtras).toHaveBeenCalledWith({ buildId: 'abc' });
expect(scope.setExtra).not.toHaveBeenCalled();
expect(sentryNode.captureMessage).toHaveBeenCalledWith('forwarded with no err');
expect(sentryNode.captureException).not.toHaveBeenCalled();
});
it('coerces 2-arg primitive thrown value to Error (e.g. `throw "boom"`)', () => {
mockWithScope();
Sentry.capture('caught a string', 'boom' as unknown as Error);
expect(sentryNode.captureException).toHaveBeenCalledWith(expect.any(Error));
const coerced = jest.mocked(sentryNode.captureException).mock.calls[0][0] as Error;
expect(coerced.message).toBe('boom');
expect(sentryNode.captureMessage).not.toHaveBeenCalled();
});
it('coerces non-Error middle arg to Error when caller passes 3 args (e.g. `catch (err: any)`)', () => {
const scope = mockWithScope();
Sentry.capture('something failed', 'raw string thrown' as unknown as Error, {
tags: { phase: 'install-dependencies' },
extras: { buildId: 'abc' },
});
expect(scope.setTags).toHaveBeenCalledWith({ phase: 'install-dependencies' });
expect(scope.setExtras).toHaveBeenCalledWith({ buildId: 'abc' });
expect(scope.setExtra).toHaveBeenCalledWith('message', 'something failed');
expect(sentryNode.captureException).toHaveBeenCalledWith(expect.any(Error));
const coerced = jest.mocked(sentryNode.captureException).mock.calls[0][0] as Error;
expect(coerced.message).toBe('raw string thrown');
expect(sentryNode.captureMessage).not.toHaveBeenCalled();
});
it('routes (err, options) to captureException with no message extra', () => {
const scope = mockWithScope();
const err = new Error('boom');
Sentry.capture(err, { tags: { phase: 'install-dependencies' }, level: 'warning' });
expect(scope.setTags).toHaveBeenCalledWith({ phase: 'install-dependencies' });
expect(scope.setLevel).toHaveBeenCalledWith('warning');
expect(scope.setExtra).not.toHaveBeenCalled();
expect(sentryNode.captureException).toHaveBeenCalledWith(err);
expect(sentryNode.captureMessage).not.toHaveBeenCalled();
});
});
describe('flush', () => {
it('forwards the timeout to the SDK and resolves with its result', async () => {
jest.mocked(sentryNode.flush).mockResolvedValue(true);
const result = await Sentry.flush(3000);
expect(sentryNode.flush).toHaveBeenCalledWith(3000);
expect(result).toBe(true);
});
it('defaults the timeout to 2000ms when not specified', async () => {
jest.mocked(sentryNode.flush).mockResolvedValue(true);
await Sentry.flush();
expect(sentryNode.flush).toHaveBeenCalledWith(2000);
});
});
});
function mockWithScope(): {
setTags: jest.Mock;
setExtras: jest.Mock;
setExtra: jest.Mock;
setLevel: jest.Mock;
} {
const scope = {
setTags: jest.fn(),
setExtras: jest.fn(),
setExtra: jest.fn(),
setLevel: jest.fn(),
};
jest.mocked(sentryNode.withScope).mockImplementation((cb: any) => {
cb(scope as unknown as sentryNode.Scope);
return undefined as any;
});
return scope;
}
+12 -21
View File
@@ -32,27 +32,18 @@ export async function runGradleCommand(
await fs.chmod(path.join(androidDir, 'gradlew'), 0o755);
const verboseFlag = ctx.env['EAS_VERBOSE'] === '1' ? '--info' : '';
const spawnPromise = spawn(
'bash',
['-c', `./gradlew ${gradleCommand} --profile ${verboseFlag}`],
{
cwd: androidDir,
logger,
lineTransformer: (line?: string) => {
if (!line || /^\.+$/.exec(line)) {
return null;
} else {
return line;
}
},
env: {
...ctx.env,
...extraEnv,
...resolveVersionOverridesEnvs(ctx),
LC_ALL: 'C.UTF-8',
},
}
);
const spawnPromise = spawn('bash', ['-c', `./gradlew ${gradleCommand} ${verboseFlag}`], {
cwd: androidDir,
logger,
lineTransformer: (line?: string) => {
if (!line || /^\.+$/.exec(line)) {
return null;
} else {
return line;
}
},
env: { ...ctx.env, ...extraEnv, ...resolveVersionOverridesEnvs(ctx), LC_ALL: 'C.UTF-8' },
});
if (ctx.env.EAS_BUILD_RUNNER === 'eas-build' && process.platform === 'linux') {
adjustOOMScore(spawnPromise, logger);
}
@@ -1,231 +0,0 @@
import { XMLParser } from 'fast-xml-parser';
import fs from 'fs-extra';
import path from 'path';
export interface GradleProfileTask {
path: string;
durationMs: number;
result: string;
}
export async function parseGradleProfile(androidDir: string): Promise<GradleProfileTask[]> {
const profileDir = path.join(androidDir, 'build', 'reports', 'profile');
// Gradle profile may not exist for all builds (e.g. custom, repack
// jobs). We should short circuit and not unnecessarily log.
if (!(await fs.pathExists(profileDir))) {
return [];
}
const files = await fs.readdir(profileDir);
const htmlFile = files
.filter(f => f.startsWith('profile-') && f.endsWith('.html'))
.sort()
.pop();
if (!htmlFile) {
throw new Error(`No Gradle profile HTML found in ${profileDir}`);
}
const html = await fs.readFile(path.join(profileDir, htmlFile), 'utf8');
// Locate the <h2>Task Execution</h2> heading and extract its <table>
const headingMatch = html.match(/<h2[^>]*>\s*Task Execution\s*<\/h2>/i);
if (!headingMatch || headingMatch.index === undefined) {
throw new Error('Could not find Task Execution section in Gradle profile');
}
const sectionStart = headingMatch.index;
const tableStart = html.indexOf('<table', sectionStart);
const tableEnd = html.indexOf('</table>', tableStart);
if (tableStart === -1 || tableEnd === -1) {
throw new Error('Could not find task execution table in Gradle profile');
}
const tableHtml = html.slice(tableStart, tableEnd + '</table>'.length);
const parser = new XMLParser({
ignoreAttributes: true,
isArray: name => name === 'tr' || name === 'td',
trimValues: true,
});
const parsed = parser.parse(tableHtml);
const rows: any[] = parsed?.table?.tbody?.tr ?? parsed?.table?.tr ?? [];
const tasks: GradleProfileTask[] = [];
for (const row of rows) {
const cells: unknown[] = row?.td;
if (!cells || cells.length < 2) {
continue;
}
const taskPath = String(cells[0] ?? '').trim();
const durationStr = String(cells[1] ?? '').trim();
const result = String(cells[2] ?? '').trim() || 'executed';
if (!taskPath || !durationStr) {
continue;
}
const durationMs = parseDurationToMs(durationStr);
tasks.push({ path: taskPath, durationMs, result: result.toLowerCase() });
}
return tasks;
}
function parseDurationToMs(duration: string): number {
// Gradle profile durations can be like "1.234s", "0.045s", "12.5s"
const secondsMatch = duration.match(/^([\d.]+)s$/);
if (secondsMatch) {
return Math.round(parseFloat(secondsMatch[1]) * 1000);
}
return 0;
}
function formatSeconds(ms: number): string {
const s = ms / 1000;
if (s < 0.1) {
return `${ms}ms`;
}
return `${s.toFixed(1)}s`;
}
export function formatGradleProfileReport(tasks: GradleProfileTask[]): string {
// Filter out tasks under 1 second
const significantTasks = tasks.filter(t => t.durationMs >= 1000);
// Separate module totals from individual tasks
const moduleTotals = significantTasks.filter(t => t.result === '(total)');
const individualTasks = significantTasks.filter(t => t.result !== '(total)');
// Group individual tasks by their module prefix
const moduleChildren = new Map<string, GradleProfileTask[]>();
const orphanTasks: GradleProfileTask[] = [];
for (const task of individualTasks) {
const parent = moduleTotals.find(m => task.path.startsWith(m.path + ':'));
if (parent) {
const children = moduleChildren.get(parent.path) ?? [];
children.push(task);
moduleChildren.set(parent.path, children);
} else {
orphanTasks.push(task);
}
}
// Sort module totals by duration, sort children within each module
const sortedModules = [...moduleTotals].sort((a, b) => b.durationMs - a.durationMs);
for (const children of moduleChildren.values()) {
children.sort((a, b) => b.durationMs - a.durationMs);
}
orphanTasks.sort((a, b) => b.durationMs - a.durationMs);
// Build display rows: [displayName, task]
const rows: { displayName: string; task: GradleProfileTask }[] = [];
for (const mod of sortedModules) {
rows.push({ displayName: mod.path, task: mod });
const children = moduleChildren.get(mod.path) ?? [];
for (let i = 0; i < children.length; i++) {
const isLast = i === children.length - 1;
const prefix = isLast ? ' └─ ' : ' ├─ ';
const shortName = children[i].path.slice(mod.path.length + 1);
rows.push({ displayName: prefix + shortName, task: children[i] });
}
}
for (const task of orphanTasks) {
rows.push({ displayName: task.path, task });
}
// Compute totals from individual tasks only (avoid double-counting)
const totalMs = individualTasks.reduce((sum, t) => sum + t.durationMs, 0);
const maxMs = totalMs || 1;
const nameWidth = Math.max(4, ...rows.map(r => r.displayName.length)) + 2;
const barMaxWidth = 20;
const header =
'┌─' +
'─'.repeat(nameWidth) +
'─┬────────────┬──────────┬────────────┬─' +
'─'.repeat(barMaxWidth) +
'─┐';
const divider =
'├─' +
'─'.repeat(nameWidth) +
'─┼────────────┼──────────┼────────────┼─' +
'─'.repeat(barMaxWidth) +
'─┤';
const footer =
'└─' +
'─'.repeat(nameWidth) +
'─┴────────────┴──────────┴────────────┴─' +
'─'.repeat(barMaxWidth) +
'─┘';
const taskCount = individualTasks.length;
const cachedCount = individualTasks.filter(t => t.result !== 'executed').length;
const lines: string[] = [];
lines.push('Gradle Build — Task Execution Profile');
const cachedSuffix = cachedCount > 0 ? ` (${cachedCount} cached/up-to-date)` : '';
lines.push(`${taskCount} tasks${cachedSuffix}, total task time: ${formatSeconds(totalMs)}`);
lines.push('% Time = share of total task execution time');
lines.push('');
lines.push(header);
lines.push(
'│ ' +
'Task'.padEnd(nameWidth) +
' │ ' +
'Duration'.padStart(10) +
' │ ' +
'% Time'.padStart(8) +
' │ ' +
'Result'.padEnd(10) +
' │ ' +
' '.repeat(barMaxWidth) +
' │'
);
lines.push(divider);
for (const row of rows) {
const pct = totalMs === 0 ? 0 : (row.task.durationMs / totalMs) * 100;
const barLength = Math.round((row.task.durationMs / maxMs) * barMaxWidth);
const bar = '█'.repeat(barLength) + '░'.repeat(barMaxWidth - barLength);
const result = row.task.result === '(total)' ? 'total' : row.task.result;
lines.push(
'│ ' +
row.displayName.padEnd(nameWidth) +
' │ ' +
formatSeconds(row.task.durationMs).padStart(10) +
' │ ' +
`${pct.toFixed(1)}%`.padStart(8) +
' │ ' +
result.padEnd(10) +
' │ ' +
bar +
' │'
);
}
lines.push(divider);
lines.push(
'│ ' +
'TOTAL'.padEnd(nameWidth) +
' │ ' +
formatSeconds(totalMs).padStart(10) +
' │ ' +
'100.0%'.padStart(8) +
' │ ' +
' '.repeat(10) +
' │ ' +
' '.repeat(barMaxWidth) +
' │'
);
lines.push(footer);
lines.push('');
return lines.join('\n');
}
@@ -4,9 +4,7 @@ import { vol } from 'memfs';
import { createTestAndroidJob } from '../../__tests__/utils/job';
import { createMockLogger } from '../../__tests__/utils/logger';
import { BuildContext } from '../../context';
import { Datadog } from '../../datadog';
import { restoreCredentials } from '../../android/credentials';
import { uploadEmbeddedBundleAsync } from '../../utils/expoUpdatesEmbedded';
import androidBuilder from '../android';
import { runBuilderWithHooksAsync } from '../common';
import {
@@ -58,9 +56,6 @@ jest.mock('../../utils/expoUpdates', () => ({
configureExpoUpdatesIfInstalledAsync: jest.fn(),
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync: jest.fn(async () => null),
}));
jest.mock('../../utils/expoUpdatesEmbedded', () => ({
uploadEmbeddedBundleAsync: jest.fn(),
}));
jest.mock('../../utils/hooks', () => ({
Hook: {
POST_INSTALL: 'POST_INSTALL',
@@ -72,11 +67,8 @@ jest.mock('../../utils/prepareBuildExecutable', () => ({
prepareExecutableAsync: jest.fn(),
}));
const datadogDistributionMock = jest.spyOn(Datadog, 'distribution');
describe(androidBuilder, () => {
beforeEach(() => {
jest.clearAllMocks();
vol.fromJSON(
{
'/workingdir/env/.gitkeep': '',
@@ -146,6 +138,7 @@ describe(androidBuilder, () => {
});
it('marks the configure Android version phase as warning for legacy eas-build.gradle', async () => {
const reportBuildPhaseStats = jest.fn();
const job: Android.Job = {
...createTestAndroidJob(),
secrets: {
@@ -164,23 +157,23 @@ describe(androidBuilder, () => {
__API_SERVER_URL: 'http://api.expo.test',
},
uploadArtifact: jest.fn(),
reportBuildPhaseStats,
});
vol.mkdirSync('/workingdir/build/android/app', { recursive: true });
vol.writeFileSync('/workingdir/build/android/app/eas-build.gradle', '// Legacy content');
await androidBuilder(ctx);
expect(datadogDistributionMock).toHaveBeenCalledWith(
'eas.build.phase_duration',
expect.any(Number),
expect(reportBuildPhaseStats).toHaveBeenCalledWith(
expect.objectContaining({
build_phase: BuildPhase.CONFIGURE_ANDROID_VERSION.toLowerCase(),
buildPhase: BuildPhase.CONFIGURE_ANDROID_VERSION,
result: BuildPhaseResult.WARNING,
})
);
});
it('marks the prepare credentials phase as warning for legacy eas-build.gradle', async () => {
const reportBuildPhaseStats = jest.fn();
const ctx = new BuildContext(createTestAndroidJob(), {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
@@ -189,23 +182,23 @@ describe(androidBuilder, () => {
__API_SERVER_URL: 'http://api.expo.test',
},
uploadArtifact: jest.fn(),
reportBuildPhaseStats,
});
vol.mkdirSync('/workingdir/build/android/app', { recursive: true });
vol.writeFileSync('/workingdir/build/android/app/eas-build.gradle', '// Legacy content');
await androidBuilder(ctx);
expect(datadogDistributionMock).toHaveBeenCalledWith(
'eas.build.phase_duration',
expect.any(Number),
expect(reportBuildPhaseStats).toHaveBeenCalledWith(
expect.objectContaining({
build_phase: BuildPhase.PREPARE_CREDENTIALS.toLowerCase(),
buildPhase: BuildPhase.PREPARE_CREDENTIALS,
result: BuildPhaseResult.WARNING,
})
);
});
it('injects credentials and version config when both are configured', async () => {
const reportBuildPhaseStats = jest.fn();
const job: Android.Job = {
...createTestAndroidJob(),
version: {
@@ -221,6 +214,7 @@ describe(androidBuilder, () => {
__API_SERVER_URL: 'http://api.expo.test',
},
uploadArtifact: jest.fn(),
reportBuildPhaseStats,
});
vol.mkdirSync('/workingdir/build/android/app', { recursive: true });
vol.writeFileSync('/workingdir/build/android/app/eas-build.gradle', '// Legacy content');
@@ -240,19 +234,15 @@ describe(androidBuilder, () => {
versionName: '1.2.3',
}
);
expect(datadogDistributionMock).toHaveBeenCalledWith(
'eas.build.phase_duration',
expect.any(Number),
expect(reportBuildPhaseStats).toHaveBeenCalledWith(
expect.objectContaining({
build_phase: BuildPhase.PREPARE_CREDENTIALS.toLowerCase(),
buildPhase: BuildPhase.PREPARE_CREDENTIALS,
result: BuildPhaseResult.WARNING,
})
);
expect(datadogDistributionMock).toHaveBeenCalledWith(
'eas.build.phase_duration',
expect.any(Number),
expect(reportBuildPhaseStats).toHaveBeenCalledWith(
expect.objectContaining({
build_phase: BuildPhase.CONFIGURE_ANDROID_VERSION.toLowerCase(),
buildPhase: BuildPhase.CONFIGURE_ANDROID_VERSION,
result: BuildPhaseResult.WARNING,
})
);
@@ -273,37 +263,4 @@ describe(androidBuilder, () => {
expect(runBuilderWithHooksAsync).toHaveBeenCalledWith(ctx, expect.any(Function));
});
it('runs the embedded bundle upload phase when EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE is set', async () => {
const ctx = new BuildContext(createTestAndroidJob(), {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE: '1',
},
uploadArtifact: jest.fn(),
});
await androidBuilder(ctx);
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(ctx);
});
it('skips the embedded bundle upload phase when EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE is not set', async () => {
const ctx = new BuildContext(createTestAndroidJob(), {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
},
uploadArtifact: jest.fn(),
});
await androidBuilder(ctx);
expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled();
});
});
@@ -10,8 +10,6 @@ import {
resolveGradleCommand,
runGradleCommand,
} from '../android/gradle';
import { parseGradleProfile, formatGradleProfileReport } from '../android/gradleProfile';
import { Sentry } from '../sentry';
import { eagerBundleAsync, shouldUseEagerBundle } from '../common/eagerBundle';
import { prebuildAsync } from '../common/prebuild';
import { setupAsync } from '../common/setup';
@@ -32,7 +30,6 @@ import {
configureExpoUpdatesIfInstalledAsync,
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync,
} from '../utils/expoUpdates';
import { uploadEmbeddedBundleAsync } from '../utils/expoUpdatesEmbedded';
import { Hook, runHookIfPresent } from '../utils/hooks';
import { prepareExecutableAsync } from '../utils/prepareBuildExecutable';
@@ -183,20 +180,6 @@ async function buildAsync(ctx: BuildContext<Android.Job>): Promise<void> {
});
});
await ctx.runBuildPhase(BuildPhase.GRADLE_BUILD_PROFILE, async () => {
try {
const androidDir = path.join(ctx.getReactNativeProjectDirectory(), 'android');
const profileTasks = await parseGradleProfile(androidDir);
if (profileTasks.length > 0) {
const report = formatGradleProfileReport(profileTasks);
ctx.logger.info(report);
}
} catch (err: any) {
Sentry.capture('Failed to parse Gradle build profile', err);
ctx.markBuildPhaseSkipped();
}
});
await ctx.runBuildPhase(BuildPhase.PRE_UPLOAD_ARTIFACTS_HOOK, async () => {
await runHookIfPresent(ctx, Hook.PRE_UPLOAD_ARTIFACTS);
});
@@ -209,12 +192,6 @@ async function buildAsync(ctx: BuildContext<Android.Job>): Promise<void> {
});
});
if (ctx.env.EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE) {
await ctx.runBuildPhase(BuildPhase.UPLOAD_EMBEDDED_BUNDLE, async () => {
await uploadEmbeddedBundleAsync(ctx);
});
}
await ctx.runBuildPhase(BuildPhase.SAVE_CACHE, async () => {
if (ctx.isLocal) {
ctx.logger.info('Local builds do not support saving cache.');
+6 -25
View File
@@ -16,7 +16,6 @@ import { runFastlaneGym, runFastlaneResign } from '../ios/fastlane';
import { installPods } from '../ios/pod';
import { downloadApplicationArchiveAsync } from '../ios/resign';
import { resolveArtifactPath, resolveBuildConfiguration, resolveScheme } from '../ios/resolve';
import { Sentry } from '../sentry';
import { parseAndReportXcactivitylog } from '../steps/utils/ios/xcactivitylog';
import { cacheStatsAsync, restoreCcacheAsync } from '../steps/functions/restoreBuildCache';
import { saveCcacheAsync } from '../steps/functions/saveBuildCache';
@@ -25,7 +24,6 @@ import {
configureExpoUpdatesIfInstalledAsync,
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync,
} from '../utils/expoUpdates';
import { uploadEmbeddedBundleAsync } from '../utils/expoUpdatesEmbedded';
import { Hook, runHookIfPresent } from '../utils/hooks';
import { prepareExecutableAsync } from '../utils/prepareBuildExecutable';
import { getParentAndDescendantProcessPidsAsync } from '../utils/processes';
@@ -175,28 +173,17 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
});
}
await ctx.runBuildPhase(BuildPhase.PARSE_XCACTIVITYLOG, async () => {
if (ctx.isLocal) {
ctx.logger.info('Local builds skip build performance analysis.');
return;
}
try {
const { derivedDataPath, workspacePath } = nullthrows(fastlaneResult);
const { skipped } = await parseAndReportXcactivitylog({
if (ctx.env.EXPERIMENTAL_EAS_XCACTIVITYLOG === '1') {
const { derivedDataPath, workspacePath } = nullthrows(fastlaneResult);
await ctx.runBuildPhase(BuildPhase.PARSE_XCACTIVITYLOG, async () => {
await parseAndReportXcactivitylog({
derivedDataPath,
workspacePath,
logger: ctx.logger,
proxyBaseUrl: ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL,
env: ctx.env,
});
if (skipped) {
ctx.markBuildPhaseSkipped();
}
} catch (err: any) {
Sentry.capture('Failed to parse xcactivitylog', err);
ctx.markBuildPhaseSkipped();
}
});
});
}
await ctx.runBuildPhase(BuildPhase.PRE_UPLOAD_ARTIFACTS_HOOK, async () => {
await runHookIfPresent(ctx, Hook.PRE_UPLOAD_ARTIFACTS);
@@ -210,12 +197,6 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
});
});
if (ctx.env.EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE) {
await ctx.runBuildPhase(BuildPhase.UPLOAD_EMBEDDED_BUNDLE, async () => {
await uploadEmbeddedBundleAsync(ctx);
});
}
await ctx.runBuildPhase(BuildPhase.SAVE_CACHE, async () => {
if (ctx.isLocal) {
ctx.logger.info('Local builds do not support saving cache.');
@@ -1,4 +1,4 @@
import { ArchiveSourceType, BuildJob, Platform, Workflow } from '@expo/eas-build-job';
import { BuildJob } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { resolveEnvFromBuildProfileAsync, runEasBuildInternalAsync } from '../easBuildInternal';
@@ -15,7 +15,7 @@ jest.mock('../../utils/easCli', () => ({
describe('easBuildInternal', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.resetAllMocks();
jest.mocked(resolveEasCommandPrefixAndEnvAsync).mockResolvedValue({
cmd: 'npx',
args: ['-y', 'eas-cli@latest'],
@@ -69,131 +69,4 @@ describe('easBuildInternal', () => {
expect(resolveEasCommandPrefixAndEnvAsync).toHaveBeenCalledWith();
expect(spawn).not.toHaveBeenCalled();
});
it('passes --refresh-ad-hoc-provisioning-profile to build:internal for iOS jobs with refreshAdHocProvisioningProfile', async () => {
const logger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
child: jest.fn(),
} as any;
const internalJob = {
platform: Platform.IOS,
type: Workflow.GENERIC,
triggeredBy: 'EAS_CLI',
projectArchive: { type: ArchiveSourceType.URL, url: 'https://example.com' },
projectRootDirectory: '.',
secrets: {
buildCredentials: {
testapp: {
distributionCertificate: {
dataBase64: 'YmluYXJ5Y29udGVudDE=',
password: 'distCertPassword',
},
provisioningProfileBase64: 'MnRuZXRub2N5cmFuaWI=',
},
},
},
initiatingUserId: 'user-id',
appId: 'app-id',
};
jest.mocked(spawn).mockResolvedValue({
stdout: Buffer.from(
JSON.stringify({
job: internalJob,
metadata: {},
})
),
stderr: Buffer.from(''),
} as any);
const job = {
platform: Platform.IOS,
buildProfile: 'preview',
refreshAdHocProvisioningProfile: true,
appId: 'app-id',
initiatingUserId: 'user-id',
secrets: { robotAccessToken: 'token' },
} as unknown as BuildJob;
await runEasBuildInternalAsync({
job,
logger,
env: {},
cwd: '/tmp/project',
});
expect(spawn).toHaveBeenCalledWith(
'npx',
expect.arrayContaining([
'build:internal',
'--platform',
Platform.IOS,
'--profile',
'preview',
'--refresh-ad-hoc-provisioning-profile',
]),
expect.any(Object)
);
});
it('preserves refreshAdHocProvisioningProfile from the incoming job', async () => {
const logger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
child: jest.fn(),
} as any;
const internalJob = {
platform: Platform.IOS,
type: Workflow.GENERIC,
triggeredBy: 'EAS_CLI',
projectArchive: { type: ArchiveSourceType.URL, url: 'https://example.com' },
projectRootDirectory: '.',
secrets: {
buildCredentials: {
testapp: {
distributionCertificate: {
dataBase64: 'YmluYXJ5Y29udGVudDE=',
password: 'distCertPassword',
},
provisioningProfileBase64: 'MnRuZXRub2N5cmFuaWI=',
},
},
},
initiatingUserId: 'user-id',
appId: 'app-id',
};
jest.mocked(spawn).mockResolvedValue({
stdout: Buffer.from(
JSON.stringify({
job: internalJob,
metadata: {},
})
),
stderr: Buffer.from(''),
} as any);
const job = {
platform: Platform.IOS,
buildProfile: 'preview',
refreshAdHocProvisioningProfile: true,
appId: 'app-id',
initiatingUserId: 'user-id',
secrets: { robotAccessToken: 'token' },
} as unknown as BuildJob;
const { newJob } = await runEasBuildInternalAsync({
job,
logger,
env: {},
cwd: '/tmp/project',
});
expect((newJob as any).refreshAdHocProvisioningProfile).toBe(true);
});
});
@@ -1,94 +0,0 @@
import { EventEmitter } from 'events';
import spawnAsync from '@expo/spawn-async';
import { ExpoRunFormatter } from '@expo/xcpretty';
import fg from 'fast-glob';
import { createMockLogger } from '../../__tests__/utils/logger';
import { Sentry } from '../../sentry';
import { XcodeBuildLogger } from '../xcpretty';
jest.mock('@expo/spawn-async');
jest.mock('@expo/xcpretty');
jest.mock('fast-glob');
jest.mock('../../sentry', () => ({
Sentry: {
setup: jest.fn(),
capture: jest.fn(),
flush: jest.fn(),
},
}));
describe(XcodeBuildLogger, () => {
let stdout: EventEmitter;
let formatter: { pipe: jest.Mock; getBuildSummary: jest.Mock };
beforeEach(() => {
jest.clearAllMocks();
stdout = new EventEmitter();
const spawnPromise = Object.assign(new Promise(() => {}), {
child: { stdout, kill: jest.fn() },
});
jest.mocked(spawnAsync).mockReturnValue(spawnPromise as any);
formatter = {
pipe: jest.fn(),
getBuildSummary: jest.fn().mockReturnValue('summary'),
};
jest.mocked(ExpoRunFormatter.create).mockReturnValue(formatter as any);
jest.mocked(fg).mockResolvedValue(['build.log'] as any);
});
async function startWatchingAsync(logger: ReturnType<typeof createMockLogger>): Promise<void> {
const buildLogger = new XcodeBuildLogger(logger, '/project');
await buildLogger.watchLogFiles('/logs');
}
it('logs formatted lines', async () => {
const logger = createMockLogger();
await startWatchingAsync(logger);
formatter.pipe.mockReturnValue(['line1', 'line2']);
stdout.emit('data', 'raw xcodebuild output');
expect(formatter.pipe).toHaveBeenCalledWith('raw xcodebuild output');
expect(logger.info).toHaveBeenCalledWith('line1');
expect(logger.info).toHaveBeenCalledWith('line2');
expect(Sentry.capture).not.toHaveBeenCalled();
});
it('falls back to raw logs when the formatter throws', async () => {
const logger = createMockLogger();
await startWatchingAsync(logger);
formatter.pipe.mockImplementation(() => {
throw new RangeError('Invalid array length');
});
stdout.emit('data', 'offending output');
expect(logger.info).toHaveBeenCalledWith('offending output');
});
it('reports the formatter error to Sentry only once', async () => {
const logger = createMockLogger();
await startWatchingAsync(logger);
const error = new RangeError('Invalid array length');
formatter.pipe.mockImplementation(() => {
throw error;
});
stdout.emit('data', 'offending output');
stdout.emit('data', 'more offending output');
expect(logger.info).toHaveBeenCalledWith('offending output');
expect(logger.info).toHaveBeenCalledWith('more offending output');
expect(Sentry.capture).toHaveBeenCalledTimes(1);
expect(Sentry.capture).toHaveBeenCalledWith(
'xcpretty formatter failed to parse xcodebuild logs',
error,
{ extras: { data: 'offending output' } }
);
});
});
@@ -1,12 +1,4 @@
import {
BuildJob,
Env,
Ios,
Metadata,
Platform,
sanitizeBuildJob,
sanitizeMetadata,
} from '@expo/eas-build-job';
import { BuildJob, Env, Metadata, sanitizeBuildJob, sanitizeMetadata } from '@expo/eas-build-job';
import { PipeMode, bunyan } from '@expo/logger';
import { BuildStepEnv } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
@@ -50,11 +42,6 @@ export async function runEasBuildInternalAsync<TJob extends BuildJob>({
autoSubmitArgs.push('--auto-submit');
}
const refreshAdHocProvisioningProfileArgs = [];
if (job.platform === Platform.IOS && job.refreshAdHocProvisioningProfile === true) {
refreshAdHocProvisioningProfileArgs.push('--refresh-ad-hoc-provisioning-profile');
}
try {
const result = await spawn(
cmd,
@@ -66,7 +53,6 @@ export async function runEasBuildInternalAsync<TJob extends BuildJob>({
'--profile',
buildProfile,
...autoSubmitArgs,
...refreshAdHocProvisioningProfileArgs,
],
{
cwd,
@@ -152,9 +138,6 @@ function validateEasBuildInternalResult<TJob extends BuildJob>({
// We want to retain values that we have set on the job.
appId: oldJob.appId,
initiatingUserId: oldJob.initiatingUserId,
...(oldJob.platform === Platform.IOS && oldJob.refreshAdHocProvisioningProfile === true
? { refreshAdHocProvisioningProfile: true }
: null),
}) as TJob;
assert(newJob.platform === oldJob.platform, 'eas-cli returned a job for a wrong platform');
const newMetadata = sanitizeMetadata(value.metadata);
+5 -8
View File
@@ -2,6 +2,7 @@ import { ExpoConfig } from '@expo/config';
import {
BuildPhase,
BuildPhaseResult,
BuildPhaseStats,
Env,
EnvironmentSecretType,
GenericArtifactType,
@@ -19,7 +20,6 @@ import fs from 'fs-extra';
import path from 'path';
import { resolveBuildPhaseErrorAsync } from './buildErrors/detectError';
import { Datadog } from './datadog';
import { readAppConfig } from './utils/appConfig';
import { createTemporaryEnvironmentSecretFile } from './utils/environmentSecrets';
import { PackageManager, resolvePackageManager } from './utils/packageManager';
@@ -64,6 +64,7 @@ export interface BuildContextOptions {
err?: Error,
options?: { tags?: Record<string, string>; extras?: Record<string, string> }
) => void;
reportBuildPhaseStats?: (stats: BuildPhaseStats) => void;
skipNativeBuild?: boolean;
metadata?: Metadata;
expoApiV2BaseUrl?: string;
@@ -96,6 +97,7 @@ export class BuildContext<TJob extends Job = Job> {
private buildPhaseSkipped = false;
private buildPhaseHasWarnings = false;
private _appConfig?: Promise<ExpoConfig>;
private readonly reportBuildPhaseStats?: (stats: BuildPhaseStats) => void;
public readonly graphqlClient: Client;
constructor(job: TJob, options: BuildContextOptions) {
@@ -111,6 +113,7 @@ export class BuildContext<TJob extends Job = Job> {
this._metadata = options.metadata;
this.skipNativeBuild = options.skipNativeBuild;
this.expoApiV2BaseUrl = options.expoApiV2BaseUrl;
this.reportBuildPhaseStats = options.reportBuildPhaseStats;
const environmentSecrets = this.getEnvironmentSecrets(job);
this._env = {
@@ -351,13 +354,7 @@ export class BuildContext<TJob extends Job = Job> {
}
await this.collectAndUpdateEnvVariablesAsync();
if (this.job.platform) {
Datadog.distribution('eas.build.phase_duration', durationMs, {
build_phase: this.buildPhase.toLowerCase(),
platform: this.job.platform,
result,
});
}
this.reportBuildPhaseStats?.({ buildPhase: this.buildPhase, result, durationMs });
if (!doNotMarkEnd) {
this.logger.info(
-97
View File
@@ -1,97 +0,0 @@
import { Sentry } from './sentry';
import { turtleFetch } from './utils/turtleFetch';
type MetricsTarget =
| { kind: 'build'; turtleBuildId: string }
| { kind: 'jobRun'; turtleJobRunId: string };
type DatadogSetupOptions = {
expoApiV2BaseUrl: string;
robotAccessToken: string;
target: MetricsTarget;
};
let setupOptions: DatadogSetupOptions | null = null;
let pendingUploads: Promise<unknown>[] = [];
function withTargetTag(
tags: Record<string, string>,
target: MetricsTarget
): Record<string, string> {
return { ...tags, target: target.kind === 'build' ? 'build' : 'job_run' };
}
export const Datadog = {
setup(opts: DatadogSetupOptions | null): void {
setupOptions = opts;
},
distribution(name: string, value: number, tags: Record<string, string> = {}): void {
if (!setupOptions) {
return;
}
const { expoApiV2BaseUrl, robotAccessToken, target } = setupOptions;
const metrics = [
{
name,
type: 'distribution' as const,
value,
tags: withTargetTag(tags, target),
},
];
const metricsPath =
target.kind === 'build'
? `turtle-builds/${target.turtleBuildId}/metrics`
: `turtle-job-runs/${target.turtleJobRunId}/metrics`;
const uploadPromise = turtleFetch(new URL(metricsPath, expoApiV2BaseUrl).toString(), 'POST', {
json: { metrics },
headers: {
Authorization: `Bearer ${robotAccessToken}`,
},
retries: 2,
}).catch(err => {
Sentry.capture(`Failed to report turtle ${target.kind} metric`, err, {
extras: { metrics },
});
});
pendingUploads.push(uploadPromise);
},
log(message: string, tags: Record<string, string> = {}): void {
if (!setupOptions) {
return;
}
const { expoApiV2BaseUrl, robotAccessToken, target } = setupOptions;
const tagsWithTarget = withTargetTag(tags, target);
const log =
target.kind === 'build'
? { buildId: target.turtleBuildId, message, tags: tagsWithTarget }
: { jobRunId: target.turtleJobRunId, message, tags: tagsWithTarget };
const logsPath = target.kind === 'build' ? 'turtle-builds/logs' : 'turtle-job-runs/logs';
const uploadPromise = turtleFetch(new URL(logsPath, expoApiV2BaseUrl).toString(), 'POST', {
json: log,
headers: {
Authorization: `Bearer ${robotAccessToken}`,
},
shouldThrowOnNotOk: false,
}).catch(err => {
Sentry.capture(`Failed to report turtle ${target.kind} log`, err, {
extras: { log },
});
});
pendingUploads.push(uploadPromise);
},
async flushAsync(): Promise<void> {
// Rotate so each flush only awaits its own batch; uploads enqueued during the
// await land in the fresh array for a later flush.
const uploads = pendingUploads;
pendingUploads = [];
await Promise.allSettled(uploads);
},
};
-7
View File
@@ -20,11 +20,4 @@ export { findAndUploadXcodeBuildLogsAsync } from './ios/xcodeBuildLogs';
export { Hook, runHookIfPresent } from './utils/hooks';
export { parseGradleProfile, formatGradleProfileReport } from './android/gradleProfile';
export type { GradleProfileTask } from './android/gradleProfile';
export * from './generic';
export { Datadog } from './datadog';
export { Sentry } from './sentry';
export * from './runtimeSettings';
@@ -9,7 +9,6 @@ exports[`fastfile createFastfileForResigningBuild should create Fastfile with al
"com.example.app" => "/path/to/profiles/main.mobileprovision",
"com.example.app.widget" => "/path/to/profiles/widget.mobileprovision",
},
use_app_entitlements: true,
keychain_path: "/Users/expo/Library/Keychains/login.keychain"
)
end
@@ -23,7 +22,6 @@ exports[`fastfile createFastfileForResigningBuild should handle empty provisioni
signing_identity: "iPhone Distribution",
provisioning_profile: {
},
use_app_entitlements: true,
keychain_path: "/tmp/keychain"
)
end
@@ -41,7 +39,6 @@ exports[`fastfile createFastfileForResigningBuild should handle multiple provisi
"com.example.app.extension" => "/profiles/extension.mobileprovision",
"com.example.app.intents" => "/profiles/intents.mobileprovision",
},
use_app_entitlements: true,
keychain_path: "/tmp/keychain"
)
end
@@ -56,7 +53,6 @@ exports[`fastfile createFastfileForResigningBuild should handle paths with speci
provisioning_profile: {
"com.example.app" => "/path/with spaces/profile (1).mobileprovision",
},
use_app_entitlements: true,
keychain_path: "/Users/expo/Library/Keychains/login keychain.keychain"
)
end
@@ -71,7 +67,6 @@ exports[`fastfile createFastfileForResigningBuild should handle single provision
provisioning_profile: {
"com.example.app" => "/tmp/profile.mobileprovision",
},
use_app_entitlements: true,
keychain_path: "/tmp/keychain"
)
end
@@ -86,7 +81,6 @@ exports[`fastfile createFastfileForResigningBuild should produce valid Ruby synt
provisioning_profile: {
"com.example.app" => "/path/to/profile.mobileprovision",
},
use_app_entitlements: true,
keychain_path: "/tmp/keychain"
)
end
@@ -1,6 +1,5 @@
import { Ios, getInstalledExpoPackageVersionAsync } from '@expo/eas-build-job';
import { Ios } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import fs from 'fs-extra';
import { vol } from 'memfs';
import path from 'path';
@@ -9,10 +8,6 @@ import { createMockLogger } from '../../__tests__/utils/logger';
import { BuildContext } from '../../context';
import { installPods } from '../pod';
jest.mock('@expo/eas-build-job', () => ({
...jest.requireActual('@expo/eas-build-job'),
getInstalledExpoPackageVersionAsync: jest.fn(),
}));
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
@@ -26,12 +21,10 @@ function makeIosBuildContext({
expoVersion,
cocoapodsCacheUrl,
usePrecompiledModules = true,
expoUsePrecompiledModules,
}: {
expoVersion?: string;
cocoapodsCacheUrl?: string;
usePrecompiledModules?: boolean;
expoUsePrecompiledModules?: string;
}): BuildContext<Ios.Job> {
vol.fromJSON({
[path.join(IOS_DIR, '.keep')]: '',
@@ -59,9 +52,6 @@ function makeIosBuildContext({
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
env: {
__API_SERVER_URL: 'http://api.expo.test',
...(expoUsePrecompiledModules
? { EXPO_USE_PRECOMPILED_MODULES: expoUsePrecompiledModules }
: {}),
...(cocoapodsCacheUrl ? { EAS_BUILD_COCOAPODS_CACHE_URL: cocoapodsCacheUrl } : {}),
},
uploadArtifact: jest.fn(),
@@ -71,10 +61,14 @@ function makeIosBuildContext({
describe(installPods, () => {
beforeEach(() => {
vol.reset();
jest.mocked(getInstalledExpoPackageVersionAsync).mockImplementation(async ({ projectDir }) => {
return (await fs.readJson(path.join(projectDir, 'node_modules/expo/package.json'))).version;
(spawn as jest.Mock).mockImplementation(command => {
if (command === 'node') {
return Promise.resolve({
stdout: Buffer.from(path.join(PROJECT_DIR, 'node_modules/expo/package.json')),
});
}
return Promise.resolve(undefined);
});
(spawn as jest.Mock).mockResolvedValue(undefined);
});
it('adds precompiled modules env var for Expo versions well above the minimum', async () => {
@@ -82,12 +76,17 @@ describe(installPods, () => {
await installPods(ctx, {});
expect(getInstalledExpoPackageVersionAsync).toHaveBeenCalledWith({
env: expect.objectContaining({
__API_SERVER_URL: 'http://api.expo.test',
}),
projectDir: PROJECT_DIR,
});
expect(spawn).toHaveBeenCalledWith(
'node',
['--print', "require.resolve('expo/package.json')"],
expect.objectContaining({
cwd: PROJECT_DIR,
env: expect.objectContaining({
__API_SERVER_URL: 'http://api.expo.test',
}),
stdio: 'pipe',
})
);
expect(spawn).toHaveBeenCalledWith(
'pod',
['install'],
@@ -131,14 +130,14 @@ describe(installPods, () => {
});
it('does not add precompiled modules env vars below the minimum Expo version', async () => {
const ctx = makeIosBuildContext({ expoVersion: '55.0.25' });
const ctx = makeIosBuildContext({ expoVersion: '55.0.17' });
await installPods(ctx, {});
expect((spawn as jest.Mock).mock.calls[0][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
expect((spawn as jest.Mock).mock.calls[1][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
expect(ctx.logger.info).toHaveBeenCalledWith(
expect.stringMatching(
/^Detected expo=55\.0\.25; not enabling precompiled modules use because precompiled modules require expo>=55\.0\.26\./
/^Detected expo=55\.0\.17; not enabling precompiled modules use because precompiled modules require expo>=/
)
);
});
@@ -148,7 +147,7 @@ describe(installPods, () => {
await installPods(ctx, {});
expect((spawn as jest.Mock).mock.calls[0][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
expect((spawn as jest.Mock).mock.calls[1][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
expect(ctx.logger.info).toHaveBeenCalledWith(
'Detected expo=invalid-version; not enabling precompiled modules use because the installed Expo package version is not a valid semver version.'
);
@@ -159,7 +158,7 @@ describe(installPods, () => {
await installPods(ctx, {});
expect((spawn as jest.Mock).mock.calls[0][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
expect((spawn as jest.Mock).mock.calls[1][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
expect(ctx.logger.info).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.objectContaining({ code: 'ENOENT' }) }),
'Failed to detect installed Expo package version; not enabling precompiled modules use.'
@@ -181,23 +180,4 @@ describe(installPods, () => {
).toBeUndefined();
expect(ctx.logger.info).not.toHaveBeenCalled();
});
it('does not override EXPO_USE_PRECOMPILED_MODULES=0 even when builder flag is enabled', async () => {
const ctx = makeIosBuildContext({
expoVersion: '999.0.0',
usePrecompiledModules: true,
expoUsePrecompiledModules: '0',
});
await installPods(ctx, {});
expect((spawn as jest.Mock).mock.calls[0][2].env.EXPO_USE_PRECOMPILED_MODULES).toBe('0');
expect(
(spawn as jest.Mock).mock.calls[0][2].env.EXPO_PRECOMPILED_MODULES_BASE_URL
).toBeUndefined();
expect(ctx.logger.info).toHaveBeenCalledWith(
'EXPO_USE_PRECOMPILED_MODULES=0 is set; not enabling precompiled modules use.'
);
expect(getInstalledExpoPackageVersionAsync).not.toHaveBeenCalled();
});
});
+1 -1
View File
@@ -10,8 +10,8 @@ import type { Credentials } from './credentials/manager';
import { createFastfileForResigningBuild } from './fastfile';
import { createGymfileForArchiveBuild, createGymfileForSimulatorBuild } from './gymfile';
import { isTVOS } from './tvos';
import { XcodeBuildLogger } from './xcpretty';
import { COMMON_FASTLANE_ENV } from '../common/fastlane';
import { XcodeBuildLogger } from '../common/xcpretty';
import { BuildContext, SkipNativeBuildError } from '../context';
export async function runFastlaneGym<TJob extends Ios.Job>(
+17 -12
View File
@@ -1,11 +1,12 @@
import { Env, Ios, getInstalledExpoPackageVersionAsync } from '@expo/eas-build-job';
import { Env, Ios } from '@expo/eas-build-job';
import spawn, { SpawnOptions, SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
import fs from 'fs-extra';
import path from 'path';
import semver from 'semver';
import { BuildContext } from '../context';
const MIN_PRECOMPILED_MODULES_EXPO_VERSION = '55.0.26';
const MIN_PRECOMPILED_MODULES_EXPO_VERSION = '55.0.21';
const PRECOMPILED_MODULES_BASE_URL =
'https://storage.googleapis.com/eas-build-precompiled-modules/';
@@ -46,21 +47,13 @@ export async function installPods<TJob extends Ios.Job>(
async function resolvePrecompiledModulesPodInstallEnvAsync<TJob extends Ios.Job>(
ctx: BuildContext<TJob>
): Promise<Env> {
if (ctx.env.EXPO_USE_PRECOMPILED_MODULES === '0') {
ctx.logger.info('EXPO_USE_PRECOMPILED_MODULES=0 is set; not enabling precompiled modules use.');
return {};
}
if (ctx.env.EAS_USE_PRECOMPILED_MODULES !== '1') {
if (ctx.job.builderEnvironment?.env?.EAS_USE_PRECOMPILED_MODULES !== '1') {
return {};
}
let expoPackageVersion: string;
try {
expoPackageVersion = await getInstalledExpoPackageVersionAsync({
env: ctx.env,
projectDir: ctx.getReactNativeProjectDirectory(),
});
expoPackageVersion = await getInstalledExpoPackageVersionAsync(ctx);
} catch (err) {
ctx.logger.info(
{ err },
@@ -100,6 +93,18 @@ async function resolvePrecompiledModulesPodInstallEnvAsync<TJob extends Ios.Job>
return env;
}
async function getInstalledExpoPackageVersionAsync<TJob extends Ios.Job>(
ctx: BuildContext<TJob>
): Promise<string> {
const { stdout } = await spawn('node', ['--print', "require.resolve('expo/package.json')"], {
cwd: ctx.getReactNativeProjectDirectory(),
env: ctx.env,
stdio: 'pipe',
});
const expoPackageJsonPath = stdout.toString().trim();
return (await fs.readJson(expoPackageJsonPath)).version;
}
function getPrecompiledModulesBaseUrl<TJob extends Ios.Job>(ctx: BuildContext<TJob>): string {
if (!ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL) {
return PRECOMPILED_MODULES_BASE_URL;
@@ -6,14 +6,11 @@ import fg from 'fast-glob';
import fs from 'fs-extra';
import path from 'path';
import { Sentry } from '../sentry';
const CHECK_FILE_INTERVAL_MS = 1000;
export class XcodeBuildLogger {
private loggerError?: Error;
private flushing: boolean = false;
private reportedFormatterError: boolean = false;
private logReaderPromise?: SpawnPromise<SpawnResult>;
private logsPath?: string;
@@ -63,21 +60,9 @@ export class XcodeBuildLogger {
this.logReaderPromise = spawnAsync('tail', ['-n', '+0', '-f', logsPath], { stdio: 'pipe' });
assert(this.logReaderPromise.child.stdout, 'stdout is not available');
this.logReaderPromise.child.stdout.on('data', (data: string) => {
try {
const lines = formatter.pipe(data.toString());
for (const line of lines) {
this.logger.info(line);
}
} catch (err: any) {
// The formatter can throw on unexpected xcodebuild output. Fall back to
// raw logs instead of crashing the process with an uncaught exception.
if (!this.reportedFormatterError) {
this.reportedFormatterError = true;
Sentry.capture('xcpretty formatter failed to parse xcodebuild logs', err, {
extras: { data: data.toString().slice(-2000) },
});
}
this.logger.info(data.toString());
const lines = formatter.pipe(data.toString());
for (const line of lines) {
this.logger.info(line);
}
});
await this.logReaderPromise;
-128
View File
@@ -1,128 +0,0 @@
import { type bunyan } from '@expo/logger';
import fetch from 'node-fetch';
import { z } from 'zod';
import { Sentry } from './sentry';
const ENVIRONMENT_TO_RUNTIME_SETTINGS_URL: Record<string, string | undefined> = {
staging: 'https://storage.googleapis.com/eas-workflows-staging/runtime-settings.json',
production: 'https://storage.googleapis.com/eas-workflows-production/runtime-settings.json',
};
const RuntimeSettingsSchema = z
.object({
caches: z.record(
z.string(),
z
.object({
npm: z.boolean(),
nodejs: z.boolean(),
maven: z.boolean(),
cocoapods: z.boolean(),
})
.partial()
),
iosPrecompiledModules: z.boolean(),
})
.partial();
type CacheUrlFallbacks = {
npm?: string | null;
nodejs?: string | null;
maven?: string | null;
cocoapods?: string | null;
};
let runtimeSettings: z.infer<typeof RuntimeSettingsSchema> = {};
let cacheUrlFallbacks: CacheUrlFallbacks = {};
export namespace RuntimeSettings {
export async function loadAsync({
environment,
logger,
cacheUrlFallbacks: nextCacheUrlFallbacks = {},
}: {
environment: string;
logger: bunyan;
cacheUrlFallbacks?: CacheUrlFallbacks;
}): Promise<void> {
cacheUrlFallbacks = nextCacheUrlFallbacks;
const url = ENVIRONMENT_TO_RUNTIME_SETTINGS_URL[environment];
if (!url) {
return;
}
try {
const response = await fetch(url, {
signal: AbortSignal.timeout(1000),
});
if (!response.ok) {
logger.warn(
{ url, status: response.status },
'Failed to fetch worker runtime settings, using defaults'
);
Sentry.capture('Failed to fetch worker runtime settings', {
extras: { url, status: response.status },
level: 'warning',
});
return;
}
runtimeSettings = RuntimeSettingsSchema.parse(await response.json());
if (runtimeSettings.caches && !runtimeSettings.caches[process.platform]) {
Sentry.capture(
new Error(
`Runtime settings are missing cache settings for platform "${process.platform}"`
),
{
extras: {
configuredPlatforms: Object.keys(runtimeSettings.caches),
platform: process.platform,
},
level: 'error',
}
);
}
logger.info({ url, settings: runtimeSettings }, 'Loaded worker runtime settings');
} catch (err) {
logger.warn({ err, url }, 'Failed to load worker runtime settings, using defaults');
Sentry.capture(
'Failed to load worker runtime settings',
err instanceof Error ? err : new Error(String(err)),
{
extras: { url },
level: 'warning',
}
);
}
}
export function isUsingIosPrecompiledModulesEnabled(): boolean {
return runtimeSettings.iosPrecompiledModules ?? false;
}
export function getNpmCacheUrl(): string | null {
return runtimeSettings.caches?.[process.platform]?.npm
? process.env.EAS_NPM_CACHE_URL || cacheUrlFallbacks.npm || null
: null;
}
export function getNodeJsCacheUrl(): string | null {
return runtimeSettings.caches?.[process.platform]?.nodejs
? process.env.EAS_NODEJS_CACHE_URL || cacheUrlFallbacks.nodejs || null
: null;
}
export function getMavenCacheUrl(): string | null {
return runtimeSettings.caches?.[process.platform]?.maven
? process.env.EAS_MAVEN_CACHE_URL || cacheUrlFallbacks.maven || null
: null;
}
export function getCocoapodsCacheUrl(): string | null {
return runtimeSettings.caches?.[process.platform]?.cocoapods
? process.env.EAS_COCOAPODS_CACHE_URL || cacheUrlFallbacks.cocoapods || null
: null;
}
}
-81
View File
@@ -1,81 +0,0 @@
import * as sentryNode from '@sentry/node';
type CaptureOptions = {
tags?: Record<string, string>;
extras?: Record<string, unknown>;
level?: sentryNode.SeverityLevel;
};
type SentryAPI = {
setup(opts: { dsn: string | null; environment: string; tags?: Record<string, string> }): void;
capture(msg: string, options?: CaptureOptions): void;
capture(msg: string, err: Error | undefined, options?: CaptureOptions): void;
capture(err: Error, options?: CaptureOptions): void;
flush(timeoutMs?: number): Promise<boolean>;
};
export const Sentry: SentryAPI = {
setup({ dsn, environment, tags }) {
if (dsn) {
sentryNode.init({
dsn,
environment,
...(tags ? { initialScope: { tags } } : {}),
});
}
},
capture(arg1: string | Error, arg2?: Error | CaptureOptions, arg3?: CaptureOptions): void {
let msg: string | undefined;
let err: Error | undefined;
let options: CaptureOptions = {};
if (arg1 instanceof Error) {
err = arg1;
options = (arg2 as CaptureOptions | undefined) ?? {};
} else {
msg = arg1;
if (arg3 !== undefined) {
// 3-arg form: arg2 unambiguously means err (null/undefined → no err)
options = arg3;
if (arg2 !== undefined && arg2 !== null) {
err = arg2 instanceof Error ? arg2 : new Error(String(arg2));
}
} else if (arg2 instanceof Error) {
err = arg2;
} else if (arg2 !== undefined && arg2 !== null) {
// 2-arg form: non-Error object → options; primitive → coerced err
if (typeof arg2 === 'object') {
options = arg2;
} else {
err = new Error(String(arg2));
}
}
}
sentryNode.withScope(scope => {
if (options.tags) {
scope.setTags(options.tags);
}
if (options.extras) {
scope.setExtras(options.extras);
}
if (options.level) {
scope.setLevel(options.level);
}
if (err) {
if (msg && err.message !== msg) {
scope.setExtra('message', msg);
}
sentryNode.captureException(err);
} else if (msg) {
sentryNode.captureMessage(msg);
}
});
},
flush(timeoutMs: number = 2000): Promise<boolean> {
return sentryNode.flush(timeoutMs);
},
};
@@ -41,10 +41,8 @@ import { createSaveCacheFunction } from './functions/saveCache';
import { createSendSlackMessageFunction } from './functions/sendSlackMessage';
import { createStartAgentDeviceRemoteSessionBuildFunction } from './functions/startAgentDeviceRemoteSession';
import { createStartAndroidEmulatorBuildFunction } from './functions/startAndroidEmulator';
import { createStartArgentRemoteSessionBuildFunction } from './functions/startArgentRemoteSession';
import { createStartCuttlefishDeviceBuildFunction } from './functions/startCuttlefishDevice';
import { createStartIosSimulatorBuildFunction } from './functions/startIosSimulator';
import { createStartServeSimRemoteSessionBuildFunction } from './functions/startServeSimRemoteSession';
import { createUploadArtifactBuildFunction } from './functions/uploadArtifact';
import { createUploadToAscBuildFunction } from './functions/uploadToAsc';
import { createSetUpNpmrcBuildFunction } from './functions/useNpmToken';
@@ -81,12 +79,10 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
generateGymfileFromTemplateFunction(),
runFastlaneFunction(),
parseXcactivitylogFunction(),
createStartAgentDeviceRemoteSessionBuildFunction(ctx),
createStartArgentRemoteSessionBuildFunction(ctx),
createStartAgentDeviceRemoteSessionBuildFunction(),
createStartAndroidEmulatorBuildFunction(),
createStartCuttlefishDeviceBuildFunction(),
createStartIosSimulatorBuildFunction(),
createStartServeSimRemoteSessionBuildFunction(ctx),
createInstallMaestroBuildFunction(),
createInstallPodsBuildFunction(),
@@ -1,90 +0,0 @@
import spawn from '@expo/turtle-spawn';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { Datadog } from '../../../datadog';
import { createInstallMaestroBuildFunction } from '../installMaestro';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../../../datadog', () => ({
Datadog: {
distribution: jest.fn(),
},
}));
const mockedSpawn = jest.mocked(spawn);
describe('createInstallMaestroBuildFunction', () => {
beforeEach(() => {
jest.clearAllMocks();
// `maestro --version` reports an installed version; `java -version` succeeds.
// With no requested version and Maestro present, the step skips installation.
mockedSpawn.mockImplementation((async (command: string) => ({
stdout: command === 'maestro' ? '1.41.0\n' : '',
})) as any);
});
it('reports the detected Maestro version to Datadog on EAS Build VMs', async () => {
const installMaestro = createInstallMaestroBuildFunction();
const globalCtx = createGlobalContextMock();
globalCtx.updateEnv({ EAS_BUILD_RUNNER: 'eas-build' });
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, { callInputs: {} });
await step.executeAsync();
expect(step.getOutputValueByName('maestro_version')).toBe('1.41.0');
expect(Datadog.distribution).toHaveBeenCalledWith('eas.maestro.install', 1, {
maestro_version: '1.41.0',
});
});
it('does not report to Datadog outside EAS Build VMs', async () => {
const installMaestro = createInstallMaestroBuildFunction();
const step = installMaestro.createBuildStepFromFunctionCall(createGlobalContextMock(), {
callInputs: {},
});
await step.executeAsync();
expect(Datadog.distribution).not.toHaveBeenCalled();
});
it('extracts the version when `maestro --version` prints an analytics notice', async () => {
mockedSpawn.mockImplementation((async (command: string) => ({
stdout:
command === 'maestro'
? 'Anonymous analytics enabled. To opt out, set MAESTRO_CLI_NO_ANALYTICS environment variable to any value before running Maestro.\n2.0.10\n'
: '',
})) as any);
const installMaestro = createInstallMaestroBuildFunction();
const globalCtx = createGlobalContextMock();
globalCtx.updateEnv({ EAS_BUILD_RUNNER: 'eas-build' });
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, { callInputs: {} });
await step.executeAsync();
expect(step.getOutputValueByName('maestro_version')).toBe('2.0.10');
expect(Datadog.distribution).toHaveBeenCalledWith('eas.maestro.install', 1, {
maestro_version: '2.0.10',
});
});
it('uses the trailing version when the notice itself contains an earlier version-like string', async () => {
mockedSpawn.mockImplementation((async (command: string) => ({
stdout: command === 'maestro' ? 'Analytics schema v2.0.0 enabled.\n2.0.10\n' : '',
})) as any);
const installMaestro = createInstallMaestroBuildFunction();
const globalCtx = createGlobalContextMock();
globalCtx.updateEnv({ EAS_BUILD_RUNNER: 'eas-build' });
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, { callInputs: {} });
await step.executeAsync();
expect(step.getOutputValueByName('maestro_version')).toBe('2.0.10');
});
});
@@ -679,7 +679,7 @@ describe('parseFailedFlowsFromJUnit', () => {
it('returns null when junit XML is truncated mid-tag (partial parse risk)', async () => {
// fast-xml-parser can produce a partial parse from truncated XML — without
// strict validation, retry-failed-only would only retry the visible failures and
// strict validation, smart retry would only retry the visible failures and
// silently skip flows that were cut off.
vol.fromJSON({
'/tmp/junit-reports/attempt-0.xml': `<?xml version="1.0"?>
@@ -208,7 +208,7 @@ describe('createMaestroTestsBuildFunction', () => {
);
});
it('runs all original flows on every retry when retry_failed_only=false', async () => {
it('runs all original flows on every retry when smart_retry=false', async () => {
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
const parseSpy = jest
.spyOn(parser, 'parseFailedFlowsFromJUnit')
@@ -218,7 +218,7 @@ describe('createMaestroTestsBuildFunction', () => {
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
retries: 1,
output_format: 'junit',
retry_failed_only: false,
smart_retry: false,
platform: 'android',
});
await step.executeAsync();
@@ -233,7 +233,7 @@ describe('createMaestroTestsBuildFunction', () => {
expect(parseSpy).not.toHaveBeenCalled();
});
it('subsets to failing flows on retry when retry_failed_only=true (default behaviour)', async () => {
it('subsets to failing flows on retry when smart_retry=true (default behaviour)', async () => {
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
jest.spyOn(parser, 'parseFailedFlowsFromJUnit').mockResolvedValue(['flows/b.yaml']);
@@ -241,7 +241,7 @@ describe('createMaestroTestsBuildFunction', () => {
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
retries: 1,
output_format: 'junit',
retry_failed_only: true,
smart_retry: true,
platform: 'android',
});
await step.executeAsync();
@@ -254,7 +254,7 @@ describe('createMaestroTestsBuildFunction', () => {
expect(a1).not.toContain('flows/a.yaml');
});
it('falls back to all-flows retry when retry_failed_only=true but output_format!=junit', async () => {
it('falls back to all-flows retry when smart_retry=true but output_format!=junit', async () => {
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
const parseSpy = jest.spyOn(parser, 'parseFailedFlowsFromJUnit');
@@ -262,7 +262,7 @@ describe('createMaestroTestsBuildFunction', () => {
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
retries: 1,
output_format: 'html',
retry_failed_only: true,
smart_retry: true,
platform: 'android',
});
await step.executeAsync();
@@ -282,7 +282,7 @@ describe('createMaestroTestsBuildFunction', () => {
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
retries: 1,
output_format: 'junit',
retry_failed_only: true,
smart_retry: true,
platform: 'android',
});
await step.executeAsync();
@@ -293,7 +293,7 @@ describe('createMaestroTestsBuildFunction', () => {
);
});
it('defaults retry_failed_only to true when omitted from inputs', async () => {
it('defaults smart_retry to true when omitted from inputs', async () => {
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
jest.spyOn(parser, 'parseFailedFlowsFromJUnit').mockResolvedValue(['flows/b.yaml']);
@@ -1,76 +0,0 @@
import spawn from '@expo/turtle-spawn';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
import { createStartIosSimulatorBuildFunction } from '../startIosSimulator';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../../../utils/IosSimulatorUtils', () => ({
IosSimulatorUtils: {
getAvailableDevicesAsync: jest.fn(),
getDeviceAsync: jest.fn(),
cloneAsync: jest.fn(),
startAsync: jest.fn(),
waitForReadyAsync: jest.fn(),
disableApsdAsync: jest.fn(),
},
}));
const mockedSpawn = jest.mocked(spawn);
const mockedUtils = jest.mocked(IosSimulatorUtils);
function createStep(callInputs?: Record<string, unknown>) {
const logger = createMockLogger();
const fn = createStartIosSimulatorBuildFunction();
const globalCtx = createGlobalContextMock({ logger });
const step = fn.createBuildStepFromFunctionCall(globalCtx, { callInputs });
return Object.assign(step, { logger });
}
describe(createStartIosSimulatorBuildFunction, () => {
beforeEach(() => {
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
mockedUtils.getAvailableDevicesAsync.mockResolvedValue([]);
mockedUtils.getDeviceAsync.mockResolvedValue(null);
mockedUtils.cloneAsync.mockResolvedValue(undefined);
mockedUtils.startAsync.mockResolvedValue({ udid: 'test-udid' as any });
mockedUtils.waitForReadyAsync.mockResolvedValue(undefined);
mockedUtils.disableApsdAsync.mockResolvedValue(undefined);
});
it('disables apsd on the main device and every clone', async () => {
mockedUtils.startAsync
.mockResolvedValueOnce({ udid: 'base' as any })
.mockResolvedValueOnce({ udid: 'clone-1' as any })
.mockResolvedValueOnce({ udid: 'clone-2' as any });
await createStep({ device_identifier: 'iPhone 15', count: 2 }).executeAsync();
expect(mockedUtils.disableApsdAsync).toHaveBeenCalledWith({
udid: 'base',
env: expect.any(Object),
});
expect(mockedUtils.disableApsdAsync).toHaveBeenCalledWith({
udid: 'clone-1',
env: expect.any(Object),
});
expect(mockedUtils.disableApsdAsync).toHaveBeenCalledWith({
udid: 'clone-2',
env: expect.any(Object),
});
});
it('continues when disabling apsd fails', async () => {
mockedUtils.disableApsdAsync.mockRejectedValue(new Error('apsd disable failed'));
await createStep({ device_identifier: 'iPhone 15', count: 2 }).executeAsync();
// Startup is not aborted: readiness is still awaited for each device.
expect(mockedUtils.waitForReadyAsync).toHaveBeenCalled();
});
});
@@ -15,8 +15,6 @@ import fs from 'fs';
import os from 'os';
import path from 'path';
import { Datadog } from '../../datadog';
export function createInstallMaestroBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
@@ -125,24 +123,13 @@ export function createInstallMaestroBuildFunction(): BuildFunction {
logger.info(`Maestro ${maestroVersionResult.value} is ready.`);
outputs.maestro_version.set(maestroVersionResult.value);
Datadog.distribution('eas.maestro.install', 1, {
maestro_version: maestroVersionResult.value,
});
},
});
}
async function getMaestroVersion({ env }: { env: BuildStepEnv }): Promise<string> {
const { stdout } = await spawn('maestro', ['--version'], { stdio: 'pipe', env });
// `maestro --version` can print an analytics notice to stdout before the version,
// e.g. "Anonymous analytics enabled. To opt out, set MAESTRO_CLI_NO_ANALYTICS...\n2.0.10".
// Take the last version-looking token: the real version is printed after the notice, so
// this stays correct even if the notice itself contains a version-like string. Keeps the
// step output and the eas.maestro.install metric tag clean. Best-effort only: fall back to
// the raw output if none is found, so we never fail the build over a version string.
const versions = stdout.match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/g);
return versions?.at(-1) ?? stdout.trim();
const maestroVersion = await spawn('maestro', ['--version'], { stdio: 'pipe', env });
return maestroVersion.stdout.trim();
}
async function installMaestro({
@@ -97,8 +97,8 @@ function dedupAndDetectDuplicates(flows: FlowEntry[], logger: bunyan): Map<strin
logger.warn(`Duplicate Maestro flow name "${name}" across paths: ${paths.join(', ')}.`);
}
logger.warn(
'Retry-failed-only is disabled for this run; will retry all flows on failure. ' +
'Give each Maestro flow a unique name (file basename or top-level name) to enable retry-failed-only.'
'Smart retry disabled for this run; will retry all flows on failure. ' +
'Give each Maestro flow a unique name (file basename or top-level name) to enable smart retry.'
);
return null;
}
@@ -215,7 +215,7 @@ export async function parseFailedFlowsFromJUnit(args: {
nameToPath: Map<string, string>;
}): Promise<string[] | null> {
// fast-xml-parser is lenient — truncated XML can produce a partial parse
// with some testcases dropped. Trusting that subset for retry-failed-only would
// with some testcases dropped. Trusting that subset for smart retry would
// skip retries for cut-off flows; reject any malformed XML and fall back
// to dumb retry.
let content: string;
@@ -98,7 +98,7 @@ export function createMaestroTestsBuildFunction(): BuildFunction {
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
BuildStepInput.createProvider({
id: 'retry_failed_only',
id: 'smart_retry',
required: false,
defaultValue: true,
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
@@ -192,7 +192,7 @@ export function createMaestroTestsBuildFunction(): BuildFunction {
inputs.shards.value,
'shards must be a positive integer.'
);
const retryFailedOnly = inputs.retry_failed_only.value as boolean;
const smartRetry = inputs.smart_retry.value as boolean;
try {
await fs.mkdir(junitReportDirectory, { recursive: true });
@@ -200,7 +200,7 @@ export function createMaestroTestsBuildFunction(): BuildFunction {
throw new SystemError('Failed to create JUnit report directory', { cause: err });
}
// null → duplicate flow names; retry-failed-only disabled, fall through to
// null → duplicate flow names; smart retry disabled, fall through to
// dumb retry (re-run everything) on failure.
const nameToPath = await buildFlowNameToPathMap({
inputFlowPaths: flowPaths,
@@ -213,7 +213,7 @@ export function createMaestroTestsBuildFunction(): BuildFunction {
// numeric err.status → maestro exited non-zero → retry.
// else (signal-only, OOM kill, unknown) → infra → SystemError, never
// downgraded to "tests failed".
// Retry-failed-only (junit mode): after a failed attempt, subset to the failing
// Smart retry (junit mode): after a failed attempt, subset to the failing
// flows. parseFailedFlowsFromJUnit returns null when the JUnit cannot be
// trusted; we then fall through to dumb retry (re-run everything).
let flowsToRun: string[] = flowPaths;
@@ -263,7 +263,7 @@ export function createMaestroTestsBuildFunction(): BuildFunction {
break;
}
if (retryFailedOnly && outputFormat === 'junit' && outputPath && nameToPath) {
if (smartRetry && outputFormat === 'junit' && outputPath && nameToPath) {
const failed = await parseFailedFlowsFromJUnit({
junitFile: outputPath,
nameToPath,
@@ -45,7 +45,6 @@ export function parseXcactivitylogFunction(): BuildFunction {
xclogparserVersion: version,
logger: stepCtx.logger,
proxyBaseUrl: env.EAS_BUILD_COCOAPODS_CACHE_URL,
env,
});
},
});
@@ -57,16 +57,6 @@ export function createRepackBuildFunction(): BuildFunction {
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: false,
}),
BuildStepInput.createProvider({
id: 'ios_signing_use_source_app_entitlements',
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: false,
}),
BuildStepInput.createProvider({
id: 'ios_signing_app_entitlements_path',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
}),
BuildStepInput.createProvider({
id: 'repack_version',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
@@ -148,12 +138,6 @@ export function createRepackBuildFunction(): BuildFunction {
iosSigningOptions: await resolveIosSigningOptionsAsync({
job: stepsCtx.global.staticContext.job,
logger: stepsCtx.logger,
useAppEntitlements: inputs.ios_signing_use_source_app_entitlements.value as
| boolean
| undefined,
entitlementsPath: inputs.ios_signing_app_entitlements_path.value as
| string
| undefined,
}),
logger: stepsCtx.logger,
spawnAsync: repackSpawnAsync,
@@ -291,13 +275,9 @@ export async function resolveAndroidSigningOptionsAsync({
export async function resolveIosSigningOptionsAsync({
job,
logger,
useAppEntitlements,
entitlementsPath,
}: {
job: Job;
logger: bunyan;
useAppEntitlements?: boolean;
entitlementsPath?: string;
}): Promise<IosSigningOptions | undefined> {
const iosJob = job as Ios.Job;
const buildCredentials = iosJob.secrets?.buildCredentials;
@@ -315,7 +295,5 @@ export async function resolveIosSigningOptionsAsync({
provisioningProfile,
keychainPath: credentials.keychainPath,
signingIdentity: credentials.applicationTargetProvisioningProfile.data.certificateCommonName,
useAppEntitlements,
entitlementsPath,
};
}
@@ -19,7 +19,6 @@ import {
generateDefaultBuildCacheKeyAsync,
getCcachePath,
} from '../../utils/cacheKey';
import { Datadog } from '../../datadog';
import { GRADLE_CACHE_KEY_PREFIX, generateGradleCacheKeyAsync } from '../../utils/gradleCacheKey';
import { TurtleFetchError, turtleFetch } from '../../utils/turtleFetch';
@@ -283,10 +282,22 @@ export async function restoreGradleCacheAsync({
`Gradle cache restored to ${gradleCachesPath} (${hitType === 'direct_hit' ? 'direct hit' : 'prefix match'})`
);
Datadog.log(`Gradle cache restored (${hitType})`, {
event: 'gradle_cache_restored',
cache_hit_type: hitType,
});
try {
await turtleFetch(new URL('v2/turtle-builds/logs', expoApiServerURL).toString(), 'POST', {
json: {
buildId: jobId,
message: `Gradle cache restored (${hitType})`,
tags: {
event: 'gradle_cache_restored',
cache_hit_type: hitType,
},
},
headers: {
Authorization: `Bearer ${robotAccessToken}`,
},
shouldThrowOnNotOk: false,
});
} catch {}
} catch (err: unknown) {
if (err instanceof TurtleFetchError && err.response?.status === 404) {
logger.info('No Gradle cache found for this key');
@@ -141,10 +141,6 @@ export async function uploadCacheAsync({
return;
}
const textResult = await asyncResult(response.text());
const errorMessage = extractValidationErrorMessage(textResult.value);
if (errorMessage) {
throw new Error(errorMessage);
}
throw new Error(`Unexpected response from server (${response.status}): ${textResult.value}`);
}
@@ -235,19 +231,6 @@ export async function uploadPublicCacheAsync({
logger.info(`Uploaded cache archive to ${archivePath} (${formatBytes(size)}).`);
}
function extractValidationErrorMessage(body: string | undefined): string | undefined {
if (!body) {
return undefined;
}
try {
const { errors } = JSON.parse(body);
if (errors?.[0]?.code === 'VALIDATION_ERROR') {
return errors[0].message;
}
} catch {}
return undefined;
}
export async function compressCacheAsync({
paths,
workingDirectory,
@@ -1,41 +1,29 @@
import { SystemError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import { bunyan } from '@expo/logger';
import {
BuildFunction,
BuildRuntimePlatform,
type BuildStepEnv,
BuildStepInput,
BuildStepInputValueTypeName,
} from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import childProcess from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { type CustomBuildContext } from '../../customBuildContext';
import { Sentry } from '../../sentry';
import {
type DetachedProcessHandle,
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
spawnDetached,
startNgrokTunnelAsync,
startServeSimWithTunnelAsync,
uploadRemoteSessionConfigAsync,
waitForFileAsync,
} from '../utils/remoteDeviceRunSession';
import { sleepAsync } from '../../utils/retry';
const AGENT_DEVICE_PACKAGE_NAME = 'agent-device';
const AGENT_DEVICE_REPO_URL = 'https://github.com/callstackincubator/agent-device.git';
const SRC_DIR = '/tmp/agent-device-src';
const RUN_DIR = '/tmp/agent-device';
const DAEMON_LOG = path.join(RUN_DIR, 'daemon.log');
const TUNNEL_LOG = path.join(RUN_DIR, 'cloudflared.log');
const DAEMON_JSON_PATH = path.join(os.homedir(), '.agent-device', 'daemon.json');
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
const CLOUDFLARED_LINUX_INSTALL_PATH = '/usr/local/bin/cloudflared';
const STARTUP_TIMEOUT_MS = 60_000;
export function createStartAgentDeviceRemoteSessionBuildFunction(
ctx: CustomBuildContext
): BuildFunction {
export function createStartAgentDeviceRemoteSessionBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'start_agent_device_remote_session',
@@ -49,67 +37,81 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
}),
],
fn: async ({ logger, global }, { inputs, env }) => {
// Fail fast before any expensive setup if the injected env
// vars are missing: DEVICE_RUN_SESSION_ID (to report the remote config
// back to the API server), EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN (base domain
// for our ngrok tunnels), and NGROK_AUTHTOKEN (to authenticate them).
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env);
const packageVersion = inputs.package_version.value as string | undefined;
const { runtimePlatform } = global;
logger.info(
`Starting agent-device remote session (version: ${packageVersion ?? 'latest'}, runtime: ${runtimePlatform}).`
);
logger.info(`Preparing runtime directory at ${RUN_DIR}.`);
await fs.promises.mkdir(RUN_DIR, { recursive: true });
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
logger.info(`Selecting Xcode developer directory: ${XCODE_DEVELOPER_DIR}.`);
await spawn('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], { env, logger });
}
logger.info('Launching agent-device daemon.');
const daemonProcess = await startAgentDeviceDaemonAsync({ packageVersion, env, logger });
logger.info('Ensuring cloudflared is installed.');
const cloudflaredCommand = await ensureCloudflaredInstalledAsync({
runtimePlatform,
env,
logger,
});
logger.info(
packageVersion
? `Cloning agent-device @ v${packageVersion} into ${SRC_DIR}.`
: `Cloning agent-device (latest) into ${SRC_DIR}.`
);
await cloneAgentDeviceAsync({ packageVersion, env, logger });
logger.info('Installing agent-device dependencies.');
await spawn('bun', ['install', '--production'], {
cwd: SRC_DIR,
env,
logger,
});
logger.info(`Launching agent-device daemon (log file: ${DAEMON_LOG}).`);
await spawnDetachedAsync({
command: 'bun run src/daemon.ts',
cwd: SRC_DIR,
logFile: DAEMON_LOG,
env: { ...env, AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' },
logger,
});
logger.info(`Waiting for daemon credentials at ${DAEMON_JSON_PATH}.`);
const { port: daemonPort, token: daemonToken } = await waitForDaemonInfoAsync({
daemonProcess,
await waitForFileAsync({
filePath: DAEMON_JSON_PATH,
timeoutMs: STARTUP_TIMEOUT_MS,
description: 'agent-device daemon',
});
const { port: daemonPort, token: daemonToken } = readDaemonInfo(DAEMON_JSON_PATH);
logger.info(`Daemon is listening on port ${daemonPort}; loaded auth token.`);
const agentDeviceRemoteSessionUrl = await startNgrokTunnelAsync({
port: daemonPort,
subdomainPrefix: 'agent-device',
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
logger.info(
`Starting cloudflared tunnel to http://localhost:${daemonPort} (log file: ${TUNNEL_LOG}).`
);
await spawnDetachedAsync({
command: `${cloudflaredCommand} tunnel --url "http://localhost:${daemonPort}"`,
logFile: TUNNEL_LOG,
env,
logger,
});
logger.info(`Tunnel is ready at ${agentDeviceRemoteSessionUrl}.`);
// serve-sim is iOS-only — only launch it (and report a webPreviewUrl)
// on Darwin. Android sessions go without a preview URL.
let webPreviewUrl: string | undefined;
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
const { previewUrl } = await startServeSimWithTunnelAsync({
baseDomain: ngrokTunnelDomain,
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
});
webPreviewUrl = previewUrl;
logger.info(`Web preview URL: ${webPreviewUrl}`);
}
await uploadRemoteSessionConfigAsync({
ctx,
deviceRunSessionId,
remoteConfig: {
agentDeviceRemoteSessionUrl,
agentDeviceRemoteSessionToken: daemonToken,
...(webPreviewUrl ? { webPreviewUrl } : {}),
},
logger,
logger.info('Waiting for a public tunnel URL.');
const tunnelUrl = await waitForMatchInLogAsync({
logFile: TUNNEL_LOG,
pattern: /https:\/\/[a-z0-9-]+\.trycloudflare\.com/,
timeoutMs: STARTUP_TIMEOUT_MS,
description: 'cloudflared tunnel',
});
logger.info(`Tunnel is ready at ${tunnelUrl}.`);
logger.info('Emitting agent-device credentials for the CLI to pick up:');
logger.info(`export AGENT_DEVICE_DAEMON_BASE_URL="${tunnelUrl}"`);
logger.info(`export AGENT_DEVICE_DAEMON_AUTH_TOKEN="${daemonToken}"`);
logger.info('Remote session is live. Keeping the job alive until the session is stopped.');
// Keep the turtle job alive so the daemon and tunnel stay reachable
@@ -119,91 +121,76 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
});
}
async function startAgentDeviceDaemonAsync({
packageVersion,
async function ensureCloudflaredInstalledAsync({
runtimePlatform,
env,
logger,
}: {
packageVersion: string | undefined;
env: BuildStepEnv;
runtimePlatform: BuildRuntimePlatform;
env: NodeJS.ProcessEnv;
logger: bunyan;
}): Promise<DetachedProcessHandle> {
const packageSpec = createAgentDevicePackageSpec(packageVersion);
try {
logger.info(`Installing ${packageSpec} globally with Bun.`);
await spawn('bun', ['add', '--global', packageSpec], {
env,
logger,
});
const daemonPath = getGlobalAgentDeviceDaemonPath(env);
if (!fs.existsSync(daemonPath)) {
throw new SystemError(`Expected agent-device daemon entry at ${daemonPath}.`);
}
logger.info(`Launching daemon from ${daemonPath}.`);
return spawnDetached({
command: 'node',
args: [daemonPath],
env: { ...env, AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' },
});
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
const bunVersion = await getBunVersionForDiagnosticsAsync(env);
Sentry.capture(
'Failed to start agent-device daemon from global Bun package; falling back to git clone',
error,
{
level: 'warning',
tags: {
phase: 'agent-device-daemon-start',
fallback: 'git-clone',
},
extras: {
packageSpec,
packageVersion: packageVersion ?? 'latest',
bunVersion,
bunInstallConfigured: Boolean(env.BUN_INSTALL?.trim()),
},
}
);
logger.warn(
`Failed to start daemon from global ${packageSpec}; falling back to git clone: ${error.message}`
);
return await startAgentDeviceDaemonFromGitAsync({ packageVersion, env, logger });
}): Promise<string> {
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
await ensureBrewPackageInstalledAsync({ name: 'cloudflared', env, logger });
return 'cloudflared';
}
}
async function startAgentDeviceDaemonFromGitAsync({
packageVersion,
env,
logger,
}: {
packageVersion: string | undefined;
env: BuildStepEnv;
logger: bunyan;
}): Promise<DetachedProcessHandle> {
logger.info(
packageVersion
? `Cloning agent-device @ v${packageVersion} into ${SRC_DIR}.`
: `Cloning agent-device (latest) into ${SRC_DIR}.`
);
await cloneAgentDeviceAsync({ packageVersion, env, logger });
logger.info('Installing agent-device dependencies.');
await spawn('bun', ['install', '--production'], {
cwd: SRC_DIR,
if (await isCommandAvailableAsync({ command: 'cloudflared', env })) {
return 'cloudflared';
}
const cloudflaredArch = cloudflaredLinuxArchForNodeArch(os.arch());
const downloadUrl = `https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-${cloudflaredArch}`;
logger.info(`Downloading cloudflared from ${downloadUrl} to ${CLOUDFLARED_LINUX_INSTALL_PATH}.`);
await spawn('sudo', ['curl', '-fsSL', '-o', CLOUDFLARED_LINUX_INSTALL_PATH, downloadUrl], {
env,
logger,
});
await spawn('sudo', ['chmod', '+x', CLOUDFLARED_LINUX_INSTALL_PATH], { env, logger });
// Return the absolute install path so the tunnel command works even when
// /usr/local/bin is not on the step's PATH.
return CLOUDFLARED_LINUX_INSTALL_PATH;
}
logger.info('Launching daemon from cloned agent-device source.');
return spawnDetached({
command: 'bun',
args: ['run', 'src/daemon.ts'],
cwd: SRC_DIR,
env: { ...env, AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' },
});
function cloudflaredLinuxArchForNodeArch(arch: string): 'amd64' | 'arm64' {
if (arch === 'x64') {
return 'amd64';
}
if (arch === 'arm64') {
return 'arm64';
}
throw new Error(
`Unsupported architecture for cloudflared on Linux: "${arch}". Expected "x64" or "arm64".`
);
}
async function ensureBrewPackageInstalledAsync({
name,
env,
logger,
}: {
name: string;
env: NodeJS.ProcessEnv;
logger: bunyan;
}): Promise<void> {
await spawn(
'bash',
['-c', `command -v ${name} >/dev/null 2>&1 || HOMEBREW_NO_AUTO_UPDATE=1 brew install ${name}`],
{ env, logger }
);
}
async function isCommandAvailableAsync({
command,
env,
}: {
command: string;
env: NodeJS.ProcessEnv;
}): Promise<boolean> {
try {
await spawn('bash', ['-c', `command -v ${command}`], { env, ignoreStdio: true });
return true;
} catch {
return false;
}
}
async function cloneAgentDeviceAsync({
@@ -212,7 +199,7 @@ async function cloneAgentDeviceAsync({
logger,
}: {
packageVersion: string | undefined;
env: BuildStepEnv;
env: NodeJS.ProcessEnv;
logger: bunyan;
}): Promise<void> {
const branchArgs = packageVersion ? ['--branch', `v${packageVersion}`] : [];
@@ -222,64 +209,98 @@ async function cloneAgentDeviceAsync({
});
}
async function waitForDaemonInfoAsync({
daemonProcess,
async function spawnDetachedAsync({
command,
cwd,
logFile,
env,
logger,
}: {
daemonProcess: DetachedProcessHandle;
}): Promise<{ port: number; token: string }> {
command: string;
cwd?: string;
logFile: string;
env: NodeJS.ProcessEnv;
logger: bunyan;
}): Promise<void> {
// Launch the process fully detached so this function returns immediately and
// the grandchild survives the step. Stdio goes to a log file so the daemon
// output can be polled, and we unref so Node doesn't wait on it.
const fd = fs.openSync(logFile, 'a');
try {
return await waitForFileAsync({
filePath: DAEMON_JSON_PATH,
timeoutMs: STARTUP_TIMEOUT_MS,
description: 'agent-device daemon credentials',
parse: parseDaemonInfo,
const child = childProcess.spawn('bash', ['-c', command], {
cwd,
env,
detached: true,
stdio: ['ignore', fd, fd],
});
} catch (err) {
const output = daemonProcess.getOutput();
throw new SystemError(
`${
err instanceof Error
? err.message
: `Timed out waiting for agent-device daemon credentials.`
}${output ? `\nagent-device daemon output:\n${output}` : ''}`
);
if (!child.pid) {
throw new Error(`Failed to spawn detached process: ${command}`);
}
child.unref();
logger.info(`Started detached process (pid ${child.pid}).`);
} finally {
fs.closeSync(fd);
}
}
async function getBunVersionForDiagnosticsAsync(env: BuildStepEnv): Promise<string> {
try {
const result = await spawn('bun', ['--version'], { stdio: 'pipe', env, cwd: os.tmpdir() });
return result.stdout.trim() || 'unknown';
} catch {
return 'unknown';
async function waitForMatchInLogAsync({
logFile,
pattern,
timeoutMs,
description,
}: {
logFile: string;
pattern: RegExp;
timeoutMs: number;
description: string;
}): Promise<string> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const content = await readFileOrEmptyAsync(logFile);
const match = pattern.exec(content);
if (match) {
return match[1] ?? match[0];
}
await sleepAsync(1_000);
}
}
function createAgentDevicePackageSpec(packageVersion: string | undefined): string {
const versionSpec = packageVersion ? packageVersion.replace(/^v(?=\d)/, '') : 'latest';
return `${AGENT_DEVICE_PACKAGE_NAME}@${versionSpec}`;
}
function getGlobalAgentDeviceDaemonPath(env: BuildStepEnv): string {
return path.join(
getBunInstallDirectory(env),
'install',
'global',
'node_modules',
AGENT_DEVICE_PACKAGE_NAME,
'dist',
'src',
'internal',
'daemon.js'
const tail = await readFileOrEmptyAsync(logFile);
throw new Error(
`Timed out waiting for ${description} to start. Last log contents:\n${tail || '<empty>'}`
);
}
function getBunInstallDirectory(env: BuildStepEnv): string {
const bunInstall = env.BUN_INSTALL?.trim();
return bunInstall ? bunInstall : path.join(os.homedir(), '.bun');
async function waitForFileAsync({
filePath,
timeoutMs,
description,
}: {
filePath: string;
timeoutMs: number;
description: string;
}): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
await fs.promises.access(filePath);
return;
} catch {
// not yet; keep polling
}
await sleepAsync(1_000);
}
throw new Error(`Timed out waiting for ${description} to write ${filePath}.`);
}
function parseDaemonInfo(raw: string): { port: number; token: string } {
async function readFileOrEmptyAsync(filePath: string): Promise<string> {
try {
return await fs.promises.readFile(filePath, 'utf8');
} catch {
return '';
}
}
function readDaemonInfo(filePath: string): { port: number; token: string } {
const raw = fs.readFileSync(filePath, 'utf8');
const parsed = JSON.parse(raw) as unknown;
if (
!parsed ||
@@ -287,9 +308,7 @@ function parseDaemonInfo(raw: string): { port: number; token: string } {
typeof (parsed as { httpPort: unknown }).httpPort !== 'number' ||
typeof (parsed as { token: unknown }).token !== 'string'
) {
throw new SystemError(
'Expected daemon credentials to contain { "httpPort": <number>, "token": "..." }.'
);
throw new Error(`Expected ${filePath} to contain { "httpPort": <number>, "token": "..." }.`);
}
const { httpPort, token } = parsed as { httpPort: number; token: string };
return { port: httpPort, token };
@@ -1,140 +0,0 @@
import { SystemError } from '@expo/eas-build-job';
import {
BuildFunction,
BuildRuntimePlatform,
BuildStepInput,
BuildStepInputValueTypeName,
} from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { z } from 'zod';
import { CustomBuildContext } from '../../customBuildContext';
import {
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
spawnDetached,
startNgrokTunnelAsync,
startServeSimWithTunnelAsync,
uploadRemoteSessionConfigAsync,
waitForFileAsync,
} from '../utils/remoteDeviceRunSession';
const ARGENT_PACKAGE_NAME = '@swmansion/argent';
const ARGENT_STATE_FILE = path.join(os.homedir(), '.argent', 'tool-server.json');
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
const STARTUP_TIMEOUT_MS = 60_000;
const ArgentToolServerStateSchema = z.object({ port: z.number() });
export function createStartArgentRemoteSessionBuildFunction(
ctx: CustomBuildContext
): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'start_argent_remote_session',
name: 'Start argent remote session',
__metricsId: 'eas/start_argent_remote_session',
inputProviders: [
BuildStepInput.createProvider({
id: 'package_version',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
fn: async ({ logger, global }, { inputs, env }) => {
// Fail fast before any expensive setup if the injected env
// vars are missing: DEVICE_RUN_SESSION_ID (to report the remote config
// back to the API server), EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN (base domain
// for our ngrok tunnels), and NGROK_AUTHTOKEN (to authenticate them).
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env);
const packageVersion = inputs.package_version.value as string | undefined;
const versionSpec = packageVersion ?? 'latest';
const { runtimePlatform } = global;
logger.info(
`Starting argent remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
);
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
logger.info(`Selecting Xcode developer directory: ${XCODE_DEVELOPER_DIR}.`);
await spawn('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], { env, logger });
}
// Stale state from a previous run would mask the new server's port.
await fs.promises.rm(ARGENT_STATE_FILE, { force: true });
logger.info(`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} via bunx.`);
// `argent mcp` is the public entry that triggers @argent/tools-client
// to spawn the tool-server detached + unref'd, so the tool-server
// outlives this MCP process. ARGENT_IDLE_TIMEOUT_MINUTES=0 disables the
// 30-min idle shutdown that would otherwise tear the tunnel down.
spawnDetached({
command: 'bunx',
args: [`${ARGENT_PACKAGE_NAME}@${versionSpec}`, 'mcp'],
env: { ...env, ARGENT_IDLE_TIMEOUT_MINUTES: '0' },
});
logger.info(`Waiting for argent tool-server state at ${ARGENT_STATE_FILE}.`);
const { port: toolServerPort } = await waitForFileAsync({
filePath: ARGENT_STATE_FILE,
timeoutMs: STARTUP_TIMEOUT_MS,
description: 'argent tool-server state',
parse: parseArgentToolServerState,
});
logger.info(`Argent tool-server is listening on port ${toolServerPort}.`);
const toolsUrl = await startNgrokTunnelAsync({
port: toolServerPort,
subdomainPrefix: 'argent',
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
logger,
});
logger.info(`Tunnel is ready at ${toolsUrl}.`);
// serve-sim is iOS-only — Android sessions go without a preview URL.
let webPreviewUrl: string | undefined;
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
const serveSim = await startServeSimWithTunnelAsync({
baseDomain: ngrokTunnelDomain,
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
});
webPreviewUrl = serveSim.previewUrl;
logger.info(`Web preview URL: ${webPreviewUrl}`);
}
await uploadRemoteSessionConfigAsync({
ctx,
deviceRunSessionId,
remoteConfig: {
toolsUrl,
...(webPreviewUrl ? { webPreviewUrl } : {}),
},
logger,
});
logger.info('Remote session is live. Keeping the job alive until the session is stopped.');
// Keep the turtle job alive so the tool-server and tunnel stay reachable
// until stopDeviceRunSession cancels the run.
await new Promise<never>(() => {});
},
});
}
function parseArgentToolServerState(raw: string): { port: number } {
const result = ArgentToolServerStateSchema.safeParse(JSON.parse(raw));
if (!result.success) {
throw new SystemError(
`Expected tool-server state to contain { "port": <number>, ... }: ${result.error.message}`
);
}
return result.data;
}
@@ -65,12 +65,6 @@ export function createStartIosSimulatorBuildFunction(): BuildFunction {
env,
});
try {
await IosSimulatorUtils.disableApsdAsync({ udid, env });
} catch (err) {
logger.warn({ err }, 'Failed to disable apsd in the Simulator.');
}
await IosSimulatorUtils.waitForReadyAsync({ udid, env });
logger.info('');
@@ -102,12 +96,6 @@ export function createStartIosSimulatorBuildFunction(): BuildFunction {
env,
});
try {
await IosSimulatorUtils.disableApsdAsync({ udid: cloneUdid, env });
} catch (err) {
logger.warn({ err }, 'Failed to disable apsd in the Simulator.');
}
await IosSimulatorUtils.waitForReadyAsync({
udid: cloneUdid,
env,
@@ -1,55 +0,0 @@
import { BuildFunction, BuildRuntimePlatform } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import { CustomBuildContext } from '../../customBuildContext';
import {
getDeviceRunSessionIdOrThrow,
getNgrokTunnelDomainOrThrow,
startServeSimWithTunnelAsync,
uploadRemoteSessionConfigAsync,
} from '../utils/remoteDeviceRunSession';
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
const STARTUP_TIMEOUT_MS = 60_000;
export function createStartServeSimRemoteSessionBuildFunction(
ctx: CustomBuildContext
): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'start_serve_sim_remote_session',
name: 'Start serve-sim remote session',
__metricsId: 'eas/start_serve_sim_remote_session',
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
fn: async ({ logger }, { env }) => {
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
logger.info('Starting serve-sim remote session.');
logger.info(`Selecting Xcode developer directory: ${XCODE_DEVELOPER_DIR}.`);
await spawn('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], { env, logger });
const { previewUrl, streamUrl } = await startServeSimWithTunnelAsync({
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 },
logger,
});
logger.info('Remote session is live. Keeping the job alive until the session is stopped.');
// Keep the turtle job alive so the serve-sim tunnel stays reachable
// until stopDeviceRunSession cancels the run.
await new Promise<never>(() => {});
},
});
}
@@ -23,27 +23,18 @@ export async function runGradleCommand({
logger.info(`Running 'gradlew ${gradleCommand} ${verboseFlag}' in ${androidDir}`);
await fs.chmod(path.join(androidDir, 'gradlew'), 0o755);
const spawnPromise = spawn(
'bash',
['-c', `./gradlew ${gradleCommand} --profile ${verboseFlag}`],
{
cwd: androidDir,
logger,
lineTransformer: (line?: string) => {
if (!line || /^\.+$/.exec(line)) {
return null;
} else {
return line;
}
},
env: {
...env,
...extraEnv,
LC_ALL: 'C.UTF-8',
},
}
);
const spawnPromise = spawn('bash', ['-c', `./gradlew ${gradleCommand} ${verboseFlag}`], {
cwd: androidDir,
logger,
lineTransformer: (line?: string) => {
if (!line || /^\.+$/.exec(line)) {
return null;
} else {
return line;
}
},
env: { ...env, ...extraEnv, LC_ALL: 'C.UTF-8' },
});
if (env.EAS_BUILD_RUNNER === 'eas-build' && process.platform === 'linux') {
adjustOOMScore(spawnPromise, logger);
}
@@ -4,12 +4,11 @@ import fs from 'fs-extra';
import path from 'path';
import { createMockLogger } from '../../../../__tests__/utils/logger';
import { Sentry } from '../../../../sentry';
import {
buildTargetMetrics,
isCompileStep,
collectTopLevelCompileSteps,
formatReport,
isCompileStep,
parseAndReportXcactivitylog,
} from '../xcactivitylog';
@@ -18,13 +17,6 @@ jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../../../../sentry', () => ({
Sentry: {
setup: jest.fn(),
capture: jest.fn(),
flush: jest.fn(),
},
}));
// Minimal fixture: two modules with compile steps
const FIXTURE_TWO_MODULES = {
@@ -195,26 +187,16 @@ const FIXTURE_OVERLAP_WITH_GAP = {
const mockedDownloadFile = jest.mocked(downloadFile);
const mockedSpawn = jest.mocked(spawn);
const TEST_ENV = { PATH: '/custom/bin:/usr/bin' };
function mockFilesystem({
tempDir = '/tmp/xclogparser-123',
jsonOutput = JSON.stringify(FIXTURE_TWO_MODULES),
buildLogEntries = ['12345-abc.xcactivitylog'] as string[] | Error,
}: {
tempDir?: string;
jsonOutput?: string;
buildLogEntries?: string[] | Error;
} = {}) {
jest.spyOn(fs, 'mkdtemp').mockImplementation(async () => tempDir);
jest.spyOn(fs, 'chmod').mockImplementation(async () => undefined);
jest.spyOn(fs, 'readFile').mockImplementation(async () => jsonOutput);
jest.spyOn(fs, 'readdir').mockImplementation((async () => {
if (buildLogEntries instanceof Error) {
throw buildLogEntries;
}
return buildLogEntries;
}) as any);
jest.spyOn(fs, 'rm').mockImplementation(async () => undefined);
return {
@@ -227,46 +209,38 @@ describe('parseAndReportXcactivitylog', () => {
beforeEach(() => {
mockedDownloadFile.mockReset();
mockedSpawn.mockReset();
jest.mocked(Sentry.capture).mockClear();
});
afterEach(() => {
jest.restoreAllMocks();
});
it('downloads xclogparser from GCS and logs resolution + final report when no binary is preinstalled', async () => {
it('downloads xclogparser from GCS and logs only the final report on success', async () => {
const logger = createMockLogger();
const { tempDir, reportPath } = mockFilesystem();
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn.mockRejectedValueOnce(new Error('xclogparser not found'));
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
const result = await parseAndReportXcactivitylog({
await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
});
expect(result).toEqual({ skipped: false });
expect(mockedSpawn).toHaveBeenNthCalledWith(1, 'xclogparser', ['version'], {
stdio: 'pipe',
env: TEST_ENV,
});
expect(mockedDownloadFile).toHaveBeenCalledWith(
'https://storage.googleapis.com/turtle-v2/xclogparser/XCLogParser-macOS-x86-64-arm64-v0.2.47.zip',
path.join(tempDir, 'XCLogParser-macOS-x86-64-arm64-v0.2.47.zip'),
{ retry: 3, timeout: 20_000 }
);
expect(mockedSpawn).toHaveBeenNthCalledWith(
2,
1,
'unzip',
['-q', path.join(tempDir, 'XCLogParser-macOS-x86-64-arm64-v0.2.47.zip'), '-d', tempDir],
{ stdio: 'pipe', env: TEST_ENV }
{ stdio: 'pipe' }
);
expect(mockedSpawn).toHaveBeenNthCalledWith(
3,
2,
path.join(tempDir, 'xclogparser'),
[
'parse',
@@ -276,22 +250,18 @@ describe('parseAndReportXcactivitylog', () => {
'/tmp/workspace',
'--reporter',
'json',
'--omit_warnings',
'--omit_notes',
'--trunc_large_issues',
'--output',
reportPath,
],
{ stdio: 'pipe', env: TEST_ENV }
{ stdio: 'pipe' }
);
expect(fs.readFile).toHaveBeenCalledWith(reportPath, 'utf8');
expect(logger.info).toHaveBeenCalledWith('Using downloaded xclogparser v0.2.47.');
expect(logger.info).toHaveBeenCalledTimes(1);
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('Xcode Build — Compile Metrics by Module')
);
expect(logger.debug).not.toHaveBeenCalled();
expect(logger.warn).not.toHaveBeenCalled();
expect(Sentry.capture).not.toHaveBeenCalled();
});
it('tries the proxy URL first and falls back to the direct GCS URL', async () => {
@@ -301,7 +271,6 @@ describe('parseAndReportXcactivitylog', () => {
mockedDownloadFile
.mockRejectedValueOnce(new Error('proxy failed'))
.mockResolvedValueOnce(undefined);
mockedSpawn.mockRejectedValueOnce(new Error('xclogparser not found'));
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
await parseAndReportXcactivitylog({
@@ -309,7 +278,6 @@ describe('parseAndReportXcactivitylog', () => {
workspacePath: '/tmp/workspace',
logger,
proxyBaseUrl: 'https://cache.example.com',
env: TEST_ENV,
});
expect(mockedDownloadFile).toHaveBeenNthCalledWith(
@@ -337,7 +305,6 @@ describe('parseAndReportXcactivitylog', () => {
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn
.mockRejectedValueOnce(new Error('xclogparser not found'))
.mockRejectedValueOnce(new Error('invalid zip'))
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any)
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any);
@@ -347,7 +314,6 @@ describe('parseAndReportXcactivitylog', () => {
workspacePath: '/tmp/workspace',
logger,
proxyBaseUrl: 'https://cache.example.com',
env: TEST_ENV,
});
expect(mockedDownloadFile).toHaveBeenNthCalledWith(
@@ -372,12 +338,11 @@ describe('parseAndReportXcactivitylog', () => {
expect(logger.warn).not.toHaveBeenCalled();
});
it('reports phase "parsing_xclogparser_output" when the JSON output is invalid', async () => {
it('skips reporting and logs debug when the JSON output is invalid', async () => {
const logger = createMockLogger();
mockFilesystem({ jsonOutput: '{not valid json' });
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn.mockRejectedValueOnce(new Error('xclogparser not found'));
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
await expect(
@@ -385,20 +350,18 @@ describe('parseAndReportXcactivitylog', () => {
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
})
).resolves.toEqual({ skipped: true });
).resolves.toBeUndefined();
expect(logger.info).toHaveBeenCalledWith('Build performance analysis skipped.');
expect(logger.warn).not.toHaveBeenCalled();
expect(Sentry.capture).toHaveBeenCalledWith(
'Build performance analysis failed during "parsing_xclogparser_output"',
expect.any(Error),
expect.objectContaining({ tags: { phase: 'parsing_xclogparser_output' } })
expect(logger.info).not.toHaveBeenCalled();
expect(logger.debug).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
'Failed to analyze build performance; continuing without a report'
);
expect(logger.warn).not.toHaveBeenCalled();
});
it('reports phase "parsing_xclogparser_output" when the JSON output does not match the expected schema', async () => {
it('skips reporting and logs debug when the JSON output does not match the expected schema', async () => {
const logger = createMockLogger();
mockFilesystem({
jsonOutput: JSON.stringify({
@@ -408,7 +371,6 @@ describe('parseAndReportXcactivitylog', () => {
});
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn.mockRejectedValueOnce(new Error('xclogparser not found'));
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
await expect(
@@ -416,211 +378,15 @@ describe('parseAndReportXcactivitylog', () => {
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
})
).resolves.toEqual({ skipped: true });
).resolves.toBeUndefined();
expect(logger.info).toHaveBeenCalledWith('Build performance analysis skipped.');
expect(logger.info).not.toHaveBeenCalled();
expect(logger.debug).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
'Failed to analyze build performance; continuing without a report'
);
expect(logger.warn).not.toHaveBeenCalled();
expect(Sentry.capture).toHaveBeenCalledWith(
'Build performance analysis failed during "parsing_xclogparser_output"',
expect.any(Error),
expect.objectContaining({ tags: { phase: 'parsing_xclogparser_output' } })
);
});
it('reports phase "running_xclogparser" when the xclogparser invocation fails', async () => {
const logger = createMockLogger();
mockFilesystem();
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn
.mockRejectedValueOnce(new Error('xclogparser not found')) // version detect
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any) // unzip
.mockRejectedValueOnce(new Error('spawn xclogparser ENOENT')); // xclogparser run
await expect(
parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
})
).resolves.toEqual({ skipped: true });
expect(logger.info).toHaveBeenCalledWith('Build performance analysis skipped.');
expect(logger.warn).not.toHaveBeenCalled();
expect(Sentry.capture).toHaveBeenCalledWith(
'Build performance analysis failed during "running_xclogparser"',
expect.objectContaining({ message: 'spawn xclogparser ENOENT' }),
expect.objectContaining({ tags: { phase: 'running_xclogparser' } })
);
});
it('surfaces spawn error stderr/stdout/status/signal as Sentry extras', async () => {
const logger = createMockLogger();
mockFilesystem();
const spawnError: any = new Error('xclogparser exited with non-zero code: 1');
spawnError.stderr = 'XCLogParser fatal: missing xcactivitylog\n';
spawnError.stdout = 'some stdout';
spawnError.status = 1;
spawnError.signal = null;
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn
.mockRejectedValueOnce(new Error('xclogparser not found')) // version detect
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any) // unzip
.mockRejectedValueOnce(spawnError); // xclogparser run
await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
});
expect(Sentry.capture).toHaveBeenCalledWith(
'Build performance analysis failed during "running_xclogparser"',
expect.any(Error),
{
tags: { phase: 'running_xclogparser' },
extras: {
exitStatus: 1,
signal: null,
stderr: 'XCLogParser fatal: missing xcactivitylog\n',
stdout: 'some stdout',
},
}
);
});
it('skips with actionable guidance when Logs/Build has no .xcactivitylog files', async () => {
const logger = createMockLogger();
mockFilesystem({ buildLogEntries: [] });
const result = await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
});
expect(result).toEqual({ skipped: true });
expect(mockedSpawn).not.toHaveBeenCalled();
expect(mockedDownloadFile).not.toHaveBeenCalled();
expect(Sentry.capture).not.toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining(
`Build performance analysis skipped: no .xcactivitylog files found at ${path.join(
'/tmp/derived-data',
'Logs',
'Build'
)}.`
)
);
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('custom ios/Gymfile'));
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('derived_data_path("./build")')
);
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('result_bundle(true)'));
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('result_bundle_path("./build/result-bundle.xcresult")')
);
});
it('skips when Logs/Build directory does not exist', async () => {
const logger = createMockLogger();
const enoent: any = new Error('ENOENT');
enoent.code = 'ENOENT';
mockFilesystem({ buildLogEntries: enoent });
const result = await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
});
expect(result).toEqual({ skipped: true });
expect(mockedSpawn).not.toHaveBeenCalled();
expect(mockedDownloadFile).not.toHaveBeenCalled();
expect(Sentry.capture).not.toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('Build performance analysis skipped: no .xcactivitylog files found')
);
});
it('captures to Sentry and skips when readdir fails with a non-ENOENT error', async () => {
const logger = createMockLogger();
const eacces: any = new Error('EACCES: permission denied');
eacces.code = 'EACCES';
mockFilesystem({ buildLogEntries: eacces });
const result = await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
});
expect(result).toEqual({ skipped: true });
expect(mockedSpawn).not.toHaveBeenCalled();
expect(mockedDownloadFile).not.toHaveBeenCalled();
expect(Sentry.capture).toHaveBeenCalledWith(
'Build performance analysis failed during "checking_xcactivitylog_existence"',
expect.objectContaining({ code: 'EACCES' }),
expect.objectContaining({
tags: { phase: 'checking_xcactivitylog_existence' },
})
);
expect(logger.info).toHaveBeenCalledWith('Build performance analysis skipped.');
expect(logger.info).not.toHaveBeenCalledWith(expect.stringContaining('custom ios/Gymfile'));
});
it('ignores non-xcactivitylog entries in Logs/Build when deciding to skip', async () => {
const logger = createMockLogger();
mockFilesystem({ buildLogEntries: ['LogStoreManifest.plist', 'some-other-file.txt'] });
const result = await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
});
expect(result).toEqual({ skipped: true });
expect(mockedSpawn).not.toHaveBeenCalled();
expect(Sentry.capture).not.toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('Build performance analysis skipped: no .xcactivitylog files found')
);
});
it('reports phase "downloading_xclogparser" when the direct download fails', async () => {
const logger = createMockLogger();
mockFilesystem();
mockedDownloadFile.mockRejectedValue(new Error('network unreachable'));
mockedSpawn.mockRejectedValueOnce(new Error('xclogparser not found'));
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
await expect(
parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
})
).resolves.toEqual({ skipped: true });
expect(logger.info).toHaveBeenCalledWith('Build performance analysis skipped.');
expect(logger.warn).not.toHaveBeenCalled();
expect(Sentry.capture).toHaveBeenCalledWith(
'Build performance analysis failed during "downloading_xclogparser"',
expect.objectContaining({ message: 'network unreachable' }),
expect.objectContaining({ tags: { phase: 'downloading_xclogparser' } })
);
});
it('swallows cleanup failures without emitting additional logs', async () => {
@@ -631,7 +397,6 @@ describe('parseAndReportXcactivitylog', () => {
});
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn.mockRejectedValueOnce(new Error('xclogparser not found'));
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
await expect(
@@ -639,174 +404,13 @@ describe('parseAndReportXcactivitylog', () => {
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
})
).resolves.toEqual({ skipped: false });
).resolves.toBeUndefined();
expect(logger.info).toHaveBeenCalledTimes(1);
expect(logger.debug).not.toHaveBeenCalled();
expect(logger.warn).not.toHaveBeenCalled();
});
describe('xclogparser version resolution', () => {
it('uses preinstalled binary when no version is requested', async () => {
const logger = createMockLogger();
const { reportPath } = mockFilesystem();
mockedSpawn
.mockResolvedValueOnce({ stdout: 'XCLogParser 0.2.50\n', stderr: '' } as any) // version detect
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any); // xclogparser parse
await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
});
expect(mockedDownloadFile).not.toHaveBeenCalled();
expect(mockedSpawn).not.toHaveBeenCalledWith('unzip', expect.any(Array), expect.any(Object));
expect(mockedSpawn).toHaveBeenNthCalledWith(
2,
'xclogparser',
[
'parse',
'--derived_data',
'/tmp/derived-data',
'--workspace',
'/tmp/workspace',
'--reporter',
'json',
'--omit_warnings',
'--omit_notes',
'--trunc_large_issues',
'--output',
reportPath,
],
{ stdio: 'pipe', env: TEST_ENV }
);
expect(logger.info).toHaveBeenCalledWith('Using preinstalled xclogparser v0.2.50.');
expect(logger.info).not.toHaveBeenCalledWith(expect.stringContaining('downloaded'));
expect(Sentry.capture).not.toHaveBeenCalled();
});
it('downloads default version when no version requested and no preinstall found', async () => {
const logger = createMockLogger();
mockFilesystem();
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn.mockRejectedValueOnce(new Error('xclogparser not found'));
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
});
expect(mockedDownloadFile).toHaveBeenCalledWith(
expect.stringContaining('XCLogParser-macOS-x86-64-arm64-v0.2.47.zip'),
expect.any(String),
expect.any(Object)
);
expect(logger.info).toHaveBeenCalledWith('Using downloaded xclogparser v0.2.47.');
});
it('uses preinstalled binary when requested version matches', async () => {
const logger = createMockLogger();
mockFilesystem();
mockedSpawn
.mockResolvedValueOnce({ stdout: 'XCLogParser 0.2.50\n', stderr: '' } as any) // version detect
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any); // xclogparser parse
await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
xclogparserVersion: 'v0.2.50',
logger,
env: TEST_ENV,
});
expect(mockedDownloadFile).not.toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith('Using preinstalled xclogparser v0.2.50.');
expect(logger.info).not.toHaveBeenCalledWith(expect.stringContaining('downloaded'));
});
it('downloads requested version when it does not match the preinstalled binary', async () => {
const logger = createMockLogger();
mockFilesystem();
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn
.mockResolvedValueOnce({ stdout: 'XCLogParser 0.2.48\n', stderr: '' } as any) // version detect
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any) // unzip
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any); // xclogparser parse
await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
xclogparserVersion: 'v0.2.50',
logger,
env: TEST_ENV,
});
expect(mockedDownloadFile).toHaveBeenCalledWith(
expect.stringContaining('XCLogParser-macOS-x86-64-arm64-v0.2.50.zip'),
expect.any(String),
expect.any(Object)
);
expect(logger.info).toHaveBeenCalledWith('Using downloaded xclogparser v0.2.50.');
});
it('downloads requested version when no preinstall is found', async () => {
const logger = createMockLogger();
mockFilesystem();
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn.mockRejectedValueOnce(new Error('xclogparser not found'));
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
xclogparserVersion: 'v0.2.50',
logger,
env: TEST_ENV,
});
expect(mockedDownloadFile).toHaveBeenCalledWith(
expect.stringContaining('XCLogParser-macOS-x86-64-arm64-v0.2.50.zip'),
expect.any(String),
expect.any(Object)
);
expect(logger.info).toHaveBeenCalledWith('Using downloaded xclogparser v0.2.50.');
});
it('falls back to download when the version output cannot be parsed', async () => {
const logger = createMockLogger();
mockFilesystem();
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn
.mockResolvedValueOnce({ stdout: 'something unexpected', stderr: '' } as any) // version detect
.mockResolvedValue({ stdout: '', stderr: '' } as any);
await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
});
expect(mockedDownloadFile).toHaveBeenCalledWith(
expect.stringContaining('XCLogParser-macOS-x86-64-arm64-v0.2.47.zip'),
expect.any(String),
expect.any(Object)
);
expect(logger.info).toHaveBeenCalledWith('Using downloaded xclogparser v0.2.47.');
});
});
});
describe('isCompileStep', () => {
@@ -3,8 +3,8 @@ import { BuildStepEnv } from '@expo/steps';
import spawn, { SpawnResult } from '@expo/turtle-spawn';
import path from 'path';
import { XcodeBuildLogger } from './xcpretty';
import { COMMON_FASTLANE_ENV } from '../../../common/fastlane';
import { XcodeBuildLogger } from '../../../common/xcpretty';
export async function runFastlaneGym({
workingDir,
@@ -1,126 +1,58 @@
import downloadFile from '@expo/downloader';
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import { BuildStepEnv } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'fs-extra';
import os from 'os';
import path from 'path';
import { z } from 'zod';
import { Sentry } from '../../../sentry';
const DEFAULT_XCLOGPARSER_VERSION = 'v0.2.47';
const XCLOGPARSER_DOWNLOAD_URL = 'https://storage.googleapis.com/turtle-v2/xclogparser';
const XCLOGPARSER_DOWNLOAD_TIMEOUT_MS = 20_000;
const XCLOGPARSER_OUTPUT_FILENAME = 'xcactivitylog.json';
/**
* Catches all internal failures (logged + routed to Sentry); returns
* `{ skipped: true }` when analysis did not produce a report so callers can
* mark the build phase skipped.
* Download xclogparser, parse xcactivitylog from derived data, and log a
* compile metrics report. Never throws — all errors are logged at debug level.
*
* Can be called from both the step-based flow (BuildFunction) and the
* traditional builder flow (runBuildPhase).
*/
export async function parseAndReportXcactivitylog({
derivedDataPath,
workspacePath,
xclogparserVersion,
xclogparserVersion = DEFAULT_XCLOGPARSER_VERSION,
logger,
proxyBaseUrl,
env,
}: {
derivedDataPath: string;
workspacePath: string;
xclogparserVersion?: string;
logger: bunyan;
proxyBaseUrl?: string;
env: BuildStepEnv;
}): Promise<{ skipped: boolean }> {
}): Promise<void> {
let tempDir: string | undefined;
let phase = 'checking_xcactivitylog_existence';
try {
const logsBuildDir = path.join(derivedDataPath, 'Logs', 'Build');
let buildLogEntries: string[];
try {
buildLogEntries = await fs.readdir(logsBuildDir);
} catch (err: any) {
if (err?.code !== 'ENOENT') {
throw err;
}
buildLogEntries = [];
}
const hasActivityLog = buildLogEntries.some(entry => entry.endsWith('.xcactivitylog'));
if (!hasActivityLog) {
logger.info(
[
`Build performance analysis skipped: no .xcactivitylog files found at ${logsBuildDir}.`,
'',
'This typically happens when your project has a custom ios/Gymfile. To enable',
'build performance analysis, add the following lines to your Gymfile:',
'',
' derived_data_path("./build")',
' result_bundle(true)',
' result_bundle_path("./build/result-bundle.xcresult")',
].join('\n')
);
return { skipped: true };
}
phase = 'creating_temp_directory';
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'xclogparser-'));
phase = 'resolving_xclogparser';
const preinstalledVersion = await detectPreinstalledXclogparserVersion(env);
let binaryPath: string;
let resolvedVersion: string;
if (
preinstalledVersion &&
(!xclogparserVersion || preinstalledVersion === xclogparserVersion)
) {
binaryPath = 'xclogparser';
resolvedVersion = preinstalledVersion;
logger.info(`Using preinstalled xclogparser ${resolvedVersion}.`);
} else {
phase = 'downloading_xclogparser';
resolvedVersion = xclogparserVersion ?? DEFAULT_XCLOGPARSER_VERSION;
binaryPath = await downloadXclogparser({
tempDir,
version: resolvedVersion,
logger,
proxyBaseUrl,
env,
});
logger.info(`Using downloaded xclogparser ${resolvedVersion}.`);
}
phase = 'running_xclogparser';
const xclogparserPath = await downloadXclogparser(
tempDir,
xclogparserVersion,
logger,
proxyBaseUrl
);
const jsonOutputPath = await runXclogparser({
binaryPath,
binaryPath: xclogparserPath,
derivedDataPath,
workspacePath,
outputDir: tempDir,
env,
});
phase = 'parsing_xclogparser_output';
const data = XcactivitylogDataSchemaZ.parse(
JSON.parse(await fs.readFile(jsonOutputPath, 'utf8'))
);
logger.info(formatReport(data));
return { skipped: false };
} catch (err: any) {
logger.info('Build performance analysis skipped.');
const msg = `Build performance analysis failed during "${phase}"`;
Sentry.capture(msg, err, {
tags: { phase },
extras: {
exitStatus: err?.status,
signal: err?.signal,
stderr: err?.stderr?.slice(-4000),
stdout: err?.stdout?.slice(-4000),
},
});
return { skipped: true };
} catch (err: unknown) {
logger.debug({ err }, 'Failed to analyze build performance; continuing without a report');
} finally {
if (tempDir) {
await asyncResult(fs.rm(tempDir, { force: true, recursive: true }));
@@ -128,28 +60,12 @@ export async function parseAndReportXcactivitylog({
}
}
async function detectPreinstalledXclogparserVersion(env: BuildStepEnv): Promise<string | null> {
const result = await asyncResult(spawn('xclogparser', ['version'], { stdio: 'pipe', env }));
if (!result.ok) {
return null;
}
const match = result.value.stdout.match(/(\d+\.\d+\.\d+)/);
return match ? `v${match[1]}` : null;
}
async function downloadXclogparser({
tempDir,
version,
logger,
proxyBaseUrl,
env,
}: {
tempDir: string;
version: string;
logger: bunyan;
proxyBaseUrl?: string;
env: BuildStepEnv;
}): Promise<string> {
async function downloadXclogparser(
tempDir: string,
version: string,
logger: bunyan,
proxyBaseUrl?: string
): Promise<string> {
const zipName = getXclogparserZipName(version);
const zipPath = path.join(tempDir, zipName);
const directUrl = `${XCLOGPARSER_DOWNLOAD_URL}/${zipName}`;
@@ -157,7 +73,7 @@ async function downloadXclogparser({
if (proxiedUrl) {
const proxiedDownloadResult = await asyncResult(
downloadAndUnpackXclogparser({ tempDir, zipPath, sourceUrl: proxiedUrl, env })
downloadAndUnpackXclogparser({ tempDir, zipPath, sourceUrl: proxiedUrl })
);
if (!proxiedDownloadResult.ok) {
logger.debug(
@@ -170,35 +86,31 @@ async function downloadXclogparser({
}
}
return await downloadAndUnpackXclogparser({ tempDir, zipPath, sourceUrl: directUrl, env });
return await downloadAndUnpackXclogparser({ tempDir, zipPath, sourceUrl: directUrl });
}
async function downloadAndUnpackXclogparser({
tempDir,
zipPath,
sourceUrl,
env,
}: {
tempDir: string;
zipPath: string;
sourceUrl: string;
env: BuildStepEnv;
}): Promise<string> {
await downloadFile(sourceUrl, zipPath, { retry: 3, timeout: XCLOGPARSER_DOWNLOAD_TIMEOUT_MS });
return await unpackXclogparser({ tempDir, zipPath, env });
return await unpackXclogparser({ tempDir, zipPath });
}
async function unpackXclogparser({
tempDir,
zipPath,
env,
}: {
tempDir: string;
zipPath: string;
env: BuildStepEnv;
}): Promise<string> {
await spawn('unzip', ['-q', zipPath, '-d', tempDir], { stdio: 'pipe', env });
await spawn('unzip', ['-q', zipPath, '-d', tempDir], { stdio: 'pipe' });
const binaryPath = path.join(tempDir, 'xclogparser');
await fs.chmod(binaryPath, 0o755);
@@ -221,13 +133,11 @@ async function runXclogparser({
derivedDataPath,
workspacePath,
outputDir,
env,
}: {
binaryPath: string;
derivedDataPath: string;
workspacePath: string;
outputDir: string;
env: BuildStepEnv;
}): Promise<string> {
const outputPath = path.join(outputDir, XCLOGPARSER_OUTPUT_FILENAME);
@@ -241,13 +151,10 @@ async function runXclogparser({
workspacePath,
'--reporter',
'json',
'--omit_warnings',
'--omit_notes',
'--trunc_large_issues',
'--output',
outputPath,
],
{ stdio: 'pipe', env }
{ stdio: 'pipe' }
);
return outputPath;
@@ -503,7 +410,7 @@ export function formatReport(data: XcactivitylogData): string {
' │ ' +
'100.0%'.padStart(pctWidth) +
' │ ' +
' '.repeat(wallWidth) +
'n/a'.padStart(wallWidth) +
' │ ' +
' '.repeat(barMaxWidth) +
' │'
@@ -0,0 +1,91 @@
import { bunyan } from '@expo/logger';
import spawnAsync, { SpawnPromise, SpawnResult } from '@expo/spawn-async';
import { ExpoRunFormatter } from '@expo/xcpretty';
import assert from 'assert';
import fg from 'fast-glob';
import fs from 'fs-extra';
import path from 'path';
const CHECK_FILE_INTERVAL_MS = 1000;
export class XcodeBuildLogger {
private loggerError?: Error;
private flushing: boolean = false;
private logReaderPromise?: SpawnPromise<SpawnResult>;
private logsPath?: string;
constructor(
private readonly logger: bunyan,
private readonly projectRoot: string
) {}
public async watchLogFiles(logsDirectory: string): Promise<void> {
while (!this.flushing) {
const logsFilename = await this.getBuildLogFilename(logsDirectory);
if (logsFilename) {
this.logsPath = path.join(logsDirectory, logsFilename);
void this.startBuildLogger(this.logsPath);
return;
}
await new Promise(res => setTimeout(res, CHECK_FILE_INTERVAL_MS));
}
}
public async flush(): Promise<void> {
this.flushing = true;
if (this.loggerError) {
throw this.loggerError;
}
if (this.logReaderPromise) {
this.logReaderPromise.child.kill('SIGINT');
try {
await this.logReaderPromise;
} catch {}
}
if (this.logsPath) {
await this.findBundlerErrors(this.logsPath);
}
}
private async getBuildLogFilename(logsDirectory: string): Promise<string | undefined> {
const paths = await fg('*.log', { cwd: logsDirectory });
return paths.length >= 1 ? paths[0] : undefined;
}
private async startBuildLogger(logsPath: string): Promise<void> {
try {
const formatter = ExpoRunFormatter.create(this.projectRoot, {
// TODO: Can provide xcode project name for better parsing
isDebug: false,
});
this.logReaderPromise = spawnAsync('tail', ['-n', '+0', '-f', logsPath], { stdio: 'pipe' });
assert(this.logReaderPromise.child.stdout, 'stdout is not available');
this.logReaderPromise.child.stdout.on('data', (data: string) => {
const lines = formatter.pipe(data.toString());
for (const line of lines) {
this.logger.info(line);
}
});
await this.logReaderPromise;
this.logger.info(formatter.getBuildSummary());
} catch (err: any) {
if (!this.flushing) {
this.loggerError = err;
}
}
}
private async findBundlerErrors(logsPath: string): Promise<void> {
try {
const logFile = await fs.readFile(logsPath, 'utf-8');
const match = logFile.match(
/Welcome to Metro!\s* Fast - Scalable - Integrated\s*([\s\S]*)Run CLI with --verbose flag for more details.\nCommand PhaseScriptExecution failed with a nonzero exit code/
);
if (match) {
this.logger.info(match[1]);
}
} catch (err) {
this.logger.error({ err }, 'Failed to read Xcode logs');
}
}
}
@@ -1,242 +0,0 @@
import { SystemError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { BuildStepEnv } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import * as ngrok from '@ngrok/ngrok';
import { graphql } from 'gql.tada';
import { randomBytes } from 'node:crypto';
import fs from 'node:fs';
import { CustomBuildContext } from '../../customBuildContext';
import { sleepAsync } from '../../utils/retry';
const START_DEVICE_RUN_SESSION_MUTATION = graphql(`
mutation StartDeviceRunSession($deviceRunSessionId: ID!, $remoteConfig: JSONObject!) {
deviceRunSession {
startDeviceRunSession(
deviceRunSessionId: $deviceRunSessionId
remoteConfig: $remoteConfig
) {
id
status
}
}
}
`);
export function getDeviceRunSessionIdOrThrow(env: BuildStepEnv): string {
const deviceRunSessionId = env.DEVICE_RUN_SESSION_ID;
if (!deviceRunSessionId) {
throw new SystemError(
'DEVICE_RUN_SESSION_ID is not set. ' +
'This step must run as part of a device run session ' +
'which injects DEVICE_RUN_SESSION_ID into the job environment.'
);
}
return deviceRunSessionId;
}
export function getNgrokTunnelDomainOrThrow(env: BuildStepEnv): string {
const baseDomain = env.EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN;
if (!baseDomain) {
throw new SystemError(
'EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN is not set. ' +
'This step must run as part of a device run session ' +
'which injects EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN into the job environment.'
);
}
return baseDomain;
}
export function getNgrokAuthtokenOrThrow(env: BuildStepEnv): string {
const authtoken = env.NGROK_AUTHTOKEN;
if (!authtoken) {
throw new SystemError(
'NGROK_AUTHTOKEN is not set. ' +
'This step must run as part of a device run session ' +
'which injects NGROK_AUTHTOKEN into the job environment.'
);
}
return authtoken;
}
export async function uploadRemoteSessionConfigAsync({
ctx,
deviceRunSessionId,
remoteConfig,
logger,
}: {
ctx: CustomBuildContext;
deviceRunSessionId: string;
remoteConfig: Record<string, unknown>;
logger: bunyan;
}): Promise<void> {
logger.info(
`Reporting remote config to the API server (device run session: ${deviceRunSessionId}).`
);
const result = await ctx.graphqlClient
.mutation(START_DEVICE_RUN_SESSION_MUTATION, { deviceRunSessionId, remoteConfig })
.toPromise();
if (result.error) {
throw new SystemError(
`Failed to start device run session ${deviceRunSessionId}: ${result.error.message}`
);
}
}
export type DetachedProcessHandle = {
getOutput: () => string;
};
export function spawnDetached({
command,
args,
cwd,
env,
}: {
command: string;
args: string[];
cwd?: string;
env: BuildStepEnv;
}): DetachedProcessHandle {
const promise = spawn(command, args, {
cwd,
env,
stdio: ['ignore', 'pipe', 'pipe'],
detached: true,
});
// We don't await the process — it should outlive this step. Failures show
// up in the captured output; suppress unhandled rejections here.
promise.catch(() => {});
promise.child.unref();
let output = '';
const appendChunk = (chunk: Buffer | string): void => {
output += chunk.toString();
};
promise.child.stdout?.on('data', appendChunk);
promise.child.stderr?.on('data', appendChunk);
return { getOutput: () => output };
}
export async function startServeSimWithTunnelAsync({
baseDomain,
env,
logger,
timeoutMs,
}: {
baseDomain: string;
env: BuildStepEnv;
logger: bunyan;
timeoutMs: number;
}): Promise<{ previewUrl: string; streamUrl: string }> {
logger.info('Launching serve-sim with tunnel.');
const serveSim = spawnDetached({
command: 'npx',
args: [
'serve-sim-szdziedzic@latest',
'--tunnel',
'--tunnel-provider',
'ngrok',
'--tunnel-domain',
baseDomain,
'--stream-max-dimension',
'1280',
'--stream-quality',
'0.55',
'--codec',
'webrtc',
],
env,
});
logger.info('Waiting for serve-sim to report tunnel and stream URLs.');
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 };
}
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>'}`
);
}
function matchLabeledUrl({
output,
label,
baseDomain,
}: {
output: string;
label: string;
baseDomain: string;
}): string | null {
const labelPattern = new RegExp(
`${label}:\\s*(https:\\/\\/[a-z0-9-]+\\.${escapeRegExp(baseDomain)})`
);
const match = labelPattern.exec(output);
return match ? match[1] : null;
}
export async function startNgrokTunnelAsync({
port,
subdomainPrefix,
baseDomain,
authtoken,
logger,
}: {
port: number;
subdomainPrefix: string;
baseDomain: string;
authtoken: string;
logger: bunyan;
}): Promise<string> {
const domain = `${subdomainPrefix}-${randomBytes(8).toString('hex')}.${baseDomain}`;
logger.info(`Starting ngrok tunnel ${domain} -> http://localhost:${port}.`);
// Run the ngrok agent in-process via the SDK; it keeps the session alive until
// the process exits, and the step blocks forever to hold it open.
const listener = await ngrok.forward({ addr: port, authtoken, domain });
const url = listener.url();
if (!url) {
throw new SystemError(`ngrok tunnel for ${domain} did not return a public URL.`);
}
return url;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export async function waitForFileAsync<T>({
filePath,
timeoutMs,
description,
parse,
}: {
filePath: string;
timeoutMs: number;
description: string;
parse: (raw: string) => T;
}): Promise<T> {
const deadline = Date.now() + timeoutMs;
let lastError: unknown;
while (Date.now() < deadline) {
try {
const raw = await fs.promises.readFile(filePath, 'utf8');
return parse(raw);
} catch (err) {
lastError = err;
}
await sleepAsync(1_000);
}
throw new SystemError(
`Timed out waiting for ${description} to be ready at ${filePath}${
lastError instanceof Error ? `: ${lastError.message}` : ''
}.`
);
}
@@ -5,7 +5,6 @@ export const FastfileResignTemplate = `lane :do_resign do
provisioning_profile: {<% _.forEach(PROFILES, function(profile) { %>
"<%- profile.BUNDLE_ID %>" => "<%- profile.PATH %>",<% }); %>
},
use_app_entitlements: true,
keychain_path: "<%- KEYCHAIN_PATH %>"
)
end
@@ -122,10 +122,9 @@ export namespace IosSimulatorUtils {
udid: IosSimulatorUuid;
env: NodeJS.ProcessEnv;
}): Promise<void> {
const readinessScreenshotPath = path.join(os.tmpdir(), 'eas-simulator-readiness.png');
await retryAsync(
async () => {
await spawn('xcrun', ['simctl', 'io', udid, 'screenshot', readinessScreenshotPath], {
await spawn('xcrun', ['simctl', 'io', udid, 'screenshot', '/dev/null'], {
env,
});
},
@@ -137,7 +136,6 @@ export namespace IosSimulatorUtils {
},
}
);
await fs.promises.rm(readinessScreenshotPath, { force: true });
// Wait for data migration to complete before declaring the simulator ready
// Based on WebKit's approach: https://trac.webkit.org/changeset/231452/webkit
@@ -157,25 +155,6 @@ export namespace IosSimulatorUtils {
);
}
export async function disableApsdAsync({
udid,
env,
}: {
udid: IosSimulatorUuid;
env: NodeJS.ProcessEnv;
}): Promise<void> {
await spawn(
'xcrun',
['simctl', 'spawn', udid, 'launchctl', 'disable', 'system/com.apple.apsd'],
{ env }
);
await spawn(
'xcrun',
['simctl', 'spawn', udid, 'launchctl', 'bootout', 'system/com.apple.apsd'],
{ env }
);
}
export async function collectLogsAsync({
deviceIdentifier,
env,
@@ -1,64 +0,0 @@
import spawn from '@expo/turtle-spawn';
import os from 'node:os';
import path from 'node:path';
import { IosSimulatorUtils } from '../IosSimulatorUtils';
import { retryAsync } from '../retry';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../retry', () => ({
retryAsync: jest.fn(async fn => await fn(0)),
}));
const mockedSpawn = jest.mocked(spawn);
describe('IosSimulatorUtils', () => {
beforeEach(() => {
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
});
describe(IosSimulatorUtils.waitForReadyAsync, () => {
it('takes the readiness screenshot into a writable temp file, not /dev/null', async () => {
await IosSimulatorUtils.waitForReadyAsync({
udid: 'test-udid' as any,
env: process.env,
});
expect(mockedSpawn).toHaveBeenCalledWith(
'xcrun',
[
'simctl',
'io',
'test-udid',
'screenshot',
path.join(os.tmpdir(), 'eas-simulator-readiness.png'),
],
{ env: process.env }
);
});
});
describe(IosSimulatorUtils.disableApsdAsync, () => {
it('disables and boots out apsd in the simulator', async () => {
await IosSimulatorUtils.disableApsdAsync({
udid: 'test-udid' as any,
env: process.env,
});
expect(mockedSpawn).toHaveBeenCalledWith(
'xcrun',
['simctl', 'spawn', 'test-udid', 'launchctl', 'disable', 'system/com.apple.apsd'],
{ env: process.env }
);
expect(mockedSpawn).toHaveBeenCalledWith(
'xcrun',
['simctl', 'spawn', 'test-udid', 'launchctl', 'bootout', 'system/com.apple.apsd'],
{ env: process.env }
);
});
});
});
@@ -71,26 +71,6 @@ describe(findArtifacts, () => {
expect(paths.length).toBe(2);
});
test('with absolute glob pattern', async () => {
await fs.mkdirp('/tmp');
await fs.mkdirp('/tmp/maestro_xctestrunner_xcodebuild_output123');
await fs.mkdirp('/tmp/maestro_xctestrunner_xcodebuild_output456');
const loggerMock = {
info: jest.fn(),
error: jest.fn(),
};
const paths = await findArtifacts({
rootDir: '/Users/expo/build',
patternOrPath: '/tmp/maestro_xctestrunner_xcodebuild_output*',
logger: loggerMock as any,
});
expect(loggerMock.error).toHaveBeenCalledTimes(0);
expect(paths).toEqual([
'/tmp/maestro_xctestrunner_xcodebuild_output123',
'/tmp/maestro_xctestrunner_xcodebuild_output456',
]);
});
test('with missing file in empty directory', async () => {
await fs.mkdirp('/dir1/dir2/dir3');
let errMsg = '';
@@ -1,273 +0,0 @@
import { Platform } from '@expo/eas-build-job';
import { BuildContext } from '../../context';
import * as expoUpdates from '../expoUpdates';
import { uploadEmbeddedBundleAsync } from '../expoUpdatesEmbedded';
import * as easCli from '../easCli';
import * as artifacts from '../artifacts';
jest.mock('../expoUpdates');
jest.mock('../easCli');
jest.mock('../artifacts');
const mockZipEntries = jest.fn();
const mockZipExtract = jest.fn();
const mockZipClose = jest.fn();
jest.mock('node-stream-zip', () => ({
__esModule: true,
default: {
async: jest.fn(() => ({
entries: mockZipEntries,
extract: mockZipExtract,
close: mockZipClose,
})),
},
}));
function zipEntryMap(entries: Record<string, true>): Record<string, { name: string }> {
return Object.fromEntries(Object.keys(entries).map(name => [name, { name }]));
}
function makeCtx(overrides: {
platform: Platform;
simulator?: boolean;
channel?: string;
env?: Record<string, string>;
}): BuildContext<any> {
const job =
overrides.platform === Platform.IOS
? {
platform: Platform.IOS,
simulator: overrides.simulator ?? false,
updates: overrides.channel ? { channel: overrides.channel } : undefined,
}
: {
platform: Platform.ANDROID,
updates: overrides.channel ? { channel: overrides.channel } : undefined,
};
return {
job,
env: overrides.env ?? {},
appConfig: Promise.resolve({
updates: { url: 'https://u.expo.dev/project-id' },
}),
logger: {
info: jest.fn(),
warn: jest.fn(),
},
markBuildPhaseSkipped: jest.fn(),
markBuildPhaseHasWarnings: jest.fn(),
getReactNativeProjectDirectory: () => '/project',
} as any;
}
describe('uploadEmbeddedBundleAsync', () => {
beforeEach(() => {
jest.mocked(expoUpdates.isEASUpdateConfigured).mockResolvedValue(true);
jest.mocked(easCli.runEasCliCommand).mockResolvedValue({} as any);
jest.mocked(artifacts.findArtifacts).mockResolvedValue([]);
mockZipEntries.mockResolvedValue({});
mockZipExtract.mockResolvedValue(undefined);
mockZipClose.mockResolvedValue(undefined);
});
afterEach(() => {
jest.clearAllMocks();
jest.restoreAllMocks();
});
it('skips when EAS Update is not configured', async () => {
jest.mocked(expoUpdates.isEASUpdateConfigured).mockResolvedValue(false);
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
await uploadEmbeddedBundleAsync(ctx);
expect(ctx.markBuildPhaseSkipped).toHaveBeenCalled();
expect(artifacts.findArtifacts).not.toHaveBeenCalled();
});
it('warns when no channel is configured and does not look for the archive', async () => {
const ctx = makeCtx({ platform: Platform.ANDROID });
await uploadEmbeddedBundleAsync(ctx);
expect(ctx.logger.warn).toHaveBeenCalledWith(
'Skipping embedded bundle upload: no channel configured for this build profile.'
);
expect(ctx.markBuildPhaseHasWarnings).toHaveBeenCalled();
expect(artifacts.findArtifacts).not.toHaveBeenCalled();
});
it('throws for an unsupported platform', async () => {
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
(ctx.job as { platform: string }).platform = 'web';
await expect(uploadEmbeddedBundleAsync(ctx)).rejects.toThrow(
'Uploading embedded updates is not supported for the web platform.'
);
expect(artifacts.findArtifacts).not.toHaveBeenCalled();
});
it('uploads from Android APK archives', async () => {
jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/app-release.apk']);
mockZipEntries.mockResolvedValue(
zipEntryMap({
'assets/index.android.bundle': true,
'assets/app.manifest': true,
})
);
const ctx = makeCtx({
platform: Platform.ANDROID,
channel: 'production',
env: { EAS_BUILD_ID: 'build-123' },
});
await uploadEmbeddedBundleAsync(ctx);
expect(mockZipExtract).toHaveBeenCalledWith(
'assets/index.android.bundle',
expect.stringContaining('index.android.bundle')
);
expect(easCli.runEasCliCommand).toHaveBeenCalledWith(
expect.objectContaining({
args: expect.arrayContaining([
'update:embedded:upload',
'--platform',
Platform.ANDROID,
'--channel',
'production',
'--build-id',
'build-123',
]),
})
);
});
it('uploads from Android AAB archives', async () => {
jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/app-release.aab']);
mockZipEntries.mockResolvedValue(
zipEntryMap({
'base/assets/index.android.bundle': true,
'base/assets/app.manifest': true,
})
);
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
await uploadEmbeddedBundleAsync(ctx);
expect(mockZipExtract).toHaveBeenCalledWith(
'base/assets/index.android.bundle',
expect.stringContaining('index.android.bundle')
);
expect(easCli.runEasCliCommand).toHaveBeenCalled();
});
it('uploads from iOS IPA archives', async () => {
jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/App.ipa']);
mockZipEntries.mockResolvedValue(
zipEntryMap({
'Payload/App.app/main.jsbundle': true,
'Payload/App.app/EXUpdates.bundle/app.manifest': true,
})
);
const ctx = makeCtx({ platform: Platform.IOS, channel: 'production' });
await uploadEmbeddedBundleAsync(ctx);
expect(easCli.runEasCliCommand).toHaveBeenCalledWith(
expect.objectContaining({
args: expect.arrayContaining(['--platform', Platform.IOS]),
})
);
});
it('skips simulator builds', async () => {
const ctx = makeCtx({ platform: Platform.IOS, simulator: true, channel: 'preview' });
await uploadEmbeddedBundleAsync(ctx);
expect(ctx.markBuildPhaseSkipped).toHaveBeenCalled();
expect(artifacts.findArtifacts).not.toHaveBeenCalled();
});
it('warns when bundle or manifest is missing from the archive', async () => {
jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/app-release.apk']);
mockZipEntries.mockResolvedValue(
zipEntryMap({
'assets/app.manifest': true,
})
);
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
await uploadEmbeddedBundleAsync(ctx);
expect(ctx.logger.warn).toHaveBeenCalledWith(
'Skipping embedded bundle upload: bundle or manifest not found in archive.'
);
expect(easCli.runEasCliCommand).not.toHaveBeenCalled();
});
it('warns when build archive is not found', async () => {
jest.mocked(artifacts.findArtifacts).mockResolvedValue([]);
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
await uploadEmbeddedBundleAsync(ctx);
expect(ctx.logger.warn).toHaveBeenCalledWith(
'Skipping embedded bundle upload: build archive not found.'
);
expect(ctx.markBuildPhaseHasWarnings).toHaveBeenCalled();
expect(easCli.runEasCliCommand).not.toHaveBeenCalled();
});
it('treats findArtifacts errors as no archive found', async () => {
jest.mocked(artifacts.findArtifacts).mockRejectedValue(new Error('glob failed'));
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
await uploadEmbeddedBundleAsync(ctx);
expect(ctx.logger.warn).toHaveBeenCalledWith(
'Skipping embedded bundle upload: build archive not found.'
);
expect(ctx.markBuildPhaseHasWarnings).toHaveBeenCalled();
expect(easCli.runEasCliCommand).not.toHaveBeenCalled();
});
it('warns and continues when CLI upload throws', async () => {
jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/app-release.apk']);
mockZipEntries.mockResolvedValue(
zipEntryMap({
'assets/index.android.bundle': true,
'assets/app.manifest': true,
})
);
jest.mocked(easCli.runEasCliCommand).mockRejectedValue(new Error('upload failed'));
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
await uploadEmbeddedBundleAsync(ctx);
expect(ctx.logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
'Failed to upload embedded bundle.'
);
expect(ctx.markBuildPhaseHasWarnings).toHaveBeenCalled();
});
it('swallows zip.close() failures so they do not mask the upload result', async () => {
jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/app-release.apk']);
mockZipEntries.mockResolvedValue(
zipEntryMap({
'assets/index.android.bundle': true,
'assets/app.manifest': true,
})
);
mockZipClose.mockRejectedValue(new Error('close failed'));
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
await expect(uploadEmbeddedBundleAsync(ctx)).resolves.toBeUndefined();
expect(easCli.runEasCliCommand).toHaveBeenCalled();
expect(mockZipClose).toHaveBeenCalled();
});
});
+5 -6
View File
@@ -19,12 +19,11 @@ export async function findArtifacts({
/** If provided, will log error suggesting possible files to upload. */
logger: bunyan | null;
}): Promise<string[]> {
const files =
path.isAbsolute(patternOrPath) && !fg.isDynamicPattern(patternOrPath)
? (await fs.pathExists(patternOrPath))
? [patternOrPath]
: []
: await fg(patternOrPath, { cwd: rootDir, onlyFiles: false });
const files = path.isAbsolute(patternOrPath)
? (await fs.pathExists(patternOrPath))
? [patternOrPath]
: []
: await fg(patternOrPath, { cwd: rootDir, onlyFiles: false });
if (files.length === 0) {
if (fg.isDynamicPattern(patternOrPath)) {
throw new FindArtifactsError(`There are no files matching pattern "${patternOrPath}"`);
@@ -1,118 +0,0 @@
import { Android, BuildJob, Ios, Platform } from '@expo/eas-build-job';
import { PipeMode } from '@expo/logger';
import { asyncResult } from '@expo/results';
import fs from 'fs-extra';
import os from 'os';
import path from 'path';
import StreamZip from 'node-stream-zip';
import { findArtifacts } from './artifacts';
import { runEasCliCommand } from './easCli';
import { resolveArtifactPath } from '../ios/resolve';
import { BuildContext } from '../context';
import { isEASUpdateConfigured } from './expoUpdates';
export async function uploadEmbeddedBundleAsync(ctx: BuildContext<BuildJob>): Promise<void> {
if (!(await isEASUpdateConfigured(ctx))) {
ctx.markBuildPhaseSkipped();
return;
}
const { platform } = ctx.job;
if (platform === Platform.IOS && (ctx.job as Ios.Job).simulator) {
ctx.markBuildPhaseSkipped();
return;
}
const channel = ctx.job.updates?.channel;
if (!channel) {
ctx.logger.warn(
'Skipping embedded bundle upload: no channel configured for this build profile.'
);
ctx.markBuildPhaseHasWarnings();
return;
}
const projectDir = ctx.getReactNativeProjectDirectory();
let archivePattern: string;
if (platform === Platform.IOS) {
archivePattern = resolveArtifactPath(ctx as BuildContext<Ios.Job>);
} else if (platform === Platform.ANDROID) {
archivePattern =
(ctx as BuildContext<Android.Job>).job.applicationArchivePath ??
'android/app/build/outputs/**/*.{apk,aab}';
} else {
throw new Error(`Uploading embedded updates is not supported for the ${platform} platform.`);
}
const [archivePath] = await findArtifacts({
rootDir: projectDir,
patternOrPath: archivePattern,
logger: null,
}).catch(() => [] as string[]);
if (!archivePath) {
ctx.logger.warn('Skipping embedded bundle upload: build archive not found.');
ctx.markBuildPhaseHasWarnings();
return;
}
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-embedded-bundle-'));
const bundleName = platform === Platform.IOS ? 'main.jsbundle' : 'index.android.bundle';
const bundlePath = path.join(tmpDir, bundleName);
const manifestPath = path.join(tmpDir, 'app.manifest');
const zip = new StreamZip.async({ file: archivePath });
try {
const entries = Object.values(await zip.entries());
const bundleEntry = entries.find(e =>
platform === Platform.IOS
? e.name.endsWith('/main.jsbundle')
: e.name.endsWith('assets/index.android.bundle')
);
const manifestEntry = entries.find(e =>
platform === Platform.IOS
? e.name.includes('EXUpdates.bundle/app.manifest')
: e.name.endsWith('assets/app.manifest')
);
if (!bundleEntry || !manifestEntry) {
ctx.logger.warn('Skipping embedded bundle upload: bundle or manifest not found in archive.');
ctx.markBuildPhaseHasWarnings();
return;
}
await zip.extract(bundleEntry.name, bundlePath);
await zip.extract(manifestEntry.name, manifestPath);
const args = [
'update:embedded:upload',
'--platform',
platform,
'--bundle',
bundlePath,
'--manifest',
manifestPath,
'--channel',
channel,
'--non-interactive',
];
if (ctx.env.EAS_BUILD_ID) {
args.push('--build-id', ctx.env.EAS_BUILD_ID);
}
await runEasCliCommand({
args,
options: {
cwd: projectDir,
env: ctx.env,
logger: ctx.logger,
mode: PipeMode.STDERR_ONLY_AS_STDOUT,
},
});
} catch (err: any) {
ctx.logger.warn({ err }, 'Failed to upload embedded bundle.');
ctx.markBuildPhaseHasWarnings();
} finally {
await asyncResult(zip.close());
}
}
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "20.1.0",
"version": "18.9.0",
"description": "Create functions for use in EAS Build custom builds.",
"keywords": [
"expo",
@@ -35,7 +35,7 @@
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/steps": "20.1.0"
"@expo/steps": "18.9.0"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/downloader",
"version": "20.0.0",
"version": "18.5.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -1,6 +1,6 @@
{
"name": "eas-build-cache-provider",
"version": "20.0.0",
"version": "18.5.0",
"description": "A build cache provider plugin for the Expo CLI",
"homepage": "https://github.com/expo/eas-cli",
"bugs": "https://github.com/expo/eas-cli/issues",
+2 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-build-job",
"version": "20.1.0",
"version": "18.8.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "MIT",
"author": "Expo <[email protected]>",
@@ -25,9 +25,7 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/logger": "20.0.0",
"@expo/results": "1.0.0",
"@expo/turtle-spawn": "20.0.0",
"@expo/logger": "18.5.0",
"joi": "^17.13.1",
"semver": "^7.6.2",
"zod": "^4.3.5"
@@ -534,47 +534,4 @@ describe('Ios.JobSchema', () => {
expect(value).toMatchObject(job);
expect(error).toBeFalsy();
});
test('accepts optional refreshAdHocProvisioningProfile', () => {
const job = {
secrets: {
buildCredentials,
},
type: Workflow.GENERIC,
platform: Platform.IOS,
projectArchive: {
type: ArchiveSourceType.URL,
url: 'http://localhost:3000',
},
projectRootDirectory: '.',
initiatingUserId: randomUUID(),
appId: randomUUID(),
refreshAdHocProvisioningProfile: true,
};
const { value, error } = Ios.JobSchema.validate(job, joiOptions);
expect(value.refreshAdHocProvisioningProfile).toBe(true);
expect(error).toBeFalsy();
});
test('does not require refreshAdHocProvisioningProfile', () => {
const job = {
secrets: {
buildCredentials,
},
type: Workflow.GENERIC,
platform: Platform.IOS,
projectArchive: {
type: ArchiveSourceType.URL,
url: 'http://localhost:3000',
},
projectRootDirectory: '.',
initiatingUserId: randomUUID(),
appId: randomUUID(),
};
const { value, error } = Ios.JobSchema.validate(job, joiOptions);
expect(value.refreshAdHocProvisioningProfile).toBeUndefined();
expect(error).toBeFalsy();
});
});
@@ -1,77 +0,0 @@
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { errors } from '..';
import { getInstalledExpoPackageVersionAsync } from '../projectPackage';
describe(getInstalledExpoPackageVersionAsync, () => {
it('returns the installed expo package version resolved by project node', async () => {
const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-build-job-package-test-'));
try {
await fs.mkdir(path.join(projectDir, 'node_modules/expo'), { recursive: true });
await fs.writeFile(
path.join(projectDir, 'node_modules/expo/package.json'),
JSON.stringify({ version: '55.0.17' })
);
await expect(getInstalledExpoPackageVersionAsync({ projectDir })).resolves.toBe('55.0.17');
} finally {
await fs.rm(projectDir, { recursive: true, force: true });
}
});
it('rejects when expo cannot be resolved from the project', async () => {
const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-build-job-package-test-'));
try {
await expect(getInstalledExpoPackageVersionAsync({ projectDir })).rejects.toMatchObject({
errorCode: 'EAS_BUILD_EXPO_PACKAGE_VERSION_NOT_FOUND',
});
await expect(getInstalledExpoPackageVersionAsync({ projectDir })).rejects.toBeInstanceOf(
errors.UserError
);
} finally {
await fs.rm(projectDir, { recursive: true, force: true });
}
});
it('rejects when expo package.json cannot be read', async () => {
const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-build-job-package-test-'));
try {
await fs.mkdir(path.join(projectDir, 'node_modules/expo'), { recursive: true });
await fs.writeFile(
path.join(projectDir, 'node_modules/expo/package.json'),
JSON.stringify({ version: '55.0.17' })
);
jest.spyOn(fs, 'readFile').mockRejectedValueOnce(new Error('read failed'));
const promise = getInstalledExpoPackageVersionAsync({ projectDir });
await expect(promise).rejects.toMatchObject({
errorCode: 'EAS_BUILD_EXPO_PACKAGE_VERSION_READ_FAILED',
});
await expect(promise).rejects.toBeInstanceOf(errors.UserError);
} finally {
await fs.rm(projectDir, { recursive: true, force: true });
}
});
it('rejects when the installed expo package version is not valid semver', async () => {
const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-build-job-package-test-'));
try {
await fs.mkdir(path.join(projectDir, 'node_modules/expo'), { recursive: true });
await fs.writeFile(
path.join(projectDir, 'node_modules/expo/package.json'),
JSON.stringify({ version: 'invalid-version' })
);
await expect(getInstalledExpoPackageVersionAsync({ projectDir })).rejects.toMatchObject({
errorCode: 'EAS_BUILD_EXPO_PACKAGE_VERSION_INVALID',
});
await expect(getInstalledExpoPackageVersionAsync({ projectDir })).rejects.toBeInstanceOf(
errors.UserError
);
} finally {
await fs.rm(projectDir, { recursive: true, force: true });
}
});
});
+6
View File
@@ -181,6 +181,12 @@ export const CacheSchema = Joi.object({
paths: Joi.array().items(Joi.string()).default([]),
});
export interface BuildPhaseStats {
buildPhase: BuildPhase;
result: BuildPhaseResult;
durationMs: number;
}
const GitHubContextZ = z.object({
triggering_actor: z.string().optional(),
event_name: z.enum(['push', 'pull_request', 'workflow_dispatch', 'schedule']),
+1 -1
View File
@@ -5,6 +5,7 @@ export {
ArchiveSource,
ArchiveSourceSchemaZ,
BuildMode,
BuildPhaseStats,
BuildTrigger,
EasCliNpmTags,
Env,
@@ -26,7 +27,6 @@ export * from './context';
export * from './generic';
export * from './step';
export * from './submission-config';
export * from './projectPackage';
const version = require('../package.json').version;
export { version };
-4
View File
@@ -144,8 +144,6 @@ export interface Job {
appId: string;
environment?: string;
refreshAdHocProvisioningProfile?: boolean;
}
const SecretsSchema = Joi.object({
@@ -231,8 +229,6 @@ export const JobSchema = Joi.object({
environment: Joi.string(),
refreshAdHocProvisioningProfile: Joi.boolean(),
workflowInterpolationContext: Joi.object().custom(workflowInterpolationContext =>
StaticWorkflowInterpolationContextZ.optional().parse(workflowInterpolationContext)
),
-6
View File
@@ -27,7 +27,6 @@ export enum BuildPhase {
*/
UPLOAD_ARTIFACTS = 'UPLOAD_ARTIFACTS',
UPLOAD_APPLICATION_ARCHIVE = 'UPLOAD_APPLICATION_ARCHIVE',
UPLOAD_EMBEDDED_BUNDLE = 'UPLOAD_EMBEDDED_BUNDLE',
UPLOAD_BUILD_ARTIFACTS = 'UPLOAD_BUILD_ARTIFACTS',
PREPARE_ARTIFACTS = 'PREPARE_ARTIFACTS',
CLEAN_UP_CREDENTIALS = 'CLEAN_UP_CREDENTIALS',
@@ -37,7 +36,6 @@ export enum BuildPhase {
// ANDROID
FIX_GRADLEW = 'FIX_GRADLEW',
RUN_GRADLEW = 'RUN_GRADLEW',
GRADLE_BUILD_PROFILE = 'GRADLE_BUILD_PROFILE',
// IOS
INSTALL_PODS = 'INSTALL_PODS',
@@ -92,7 +90,6 @@ export const buildPhaseDisplayName: Record<BuildPhase, string> = {
[BuildPhase.CACHE_STATS]: 'Cache stats',
[BuildPhase.UPLOAD_ARTIFACTS]: 'Upload artifacts',
[BuildPhase.UPLOAD_APPLICATION_ARCHIVE]: 'Upload application archive',
[BuildPhase.UPLOAD_EMBEDDED_BUNDLE]: 'Upload embedded bundle',
[BuildPhase.UPLOAD_BUILD_ARTIFACTS]: 'Upload build artifacts',
[BuildPhase.PREPARE_ARTIFACTS]: 'Prepare artifacts',
[BuildPhase.CLEAN_UP_CREDENTIALS]: 'Clean up credentials',
@@ -107,7 +104,6 @@ export const buildPhaseDisplayName: Record<BuildPhase, string> = {
// ANDROID
[BuildPhase.FIX_GRADLEW]: 'Fix gradlew',
[BuildPhase.RUN_GRADLEW]: 'Run gradlew',
[BuildPhase.GRADLE_BUILD_PROFILE]: 'Gradle build profile',
// IOS
[BuildPhase.INSTALL_PODS]: 'Install pods',
@@ -159,7 +155,6 @@ export const buildPhaseWebsiteId: Record<BuildPhase, string> = {
[BuildPhase.CACHE_STATS]: 'cache-stats',
[BuildPhase.UPLOAD_ARTIFACTS]: 'upload-artifacts',
[BuildPhase.UPLOAD_APPLICATION_ARCHIVE]: 'upload-application-archive',
[BuildPhase.UPLOAD_EMBEDDED_BUNDLE]: 'upload-embedded-bundle',
[BuildPhase.UPLOAD_BUILD_ARTIFACTS]: 'upload-build-artifacts',
[BuildPhase.PREPARE_ARTIFACTS]: 'prepare-artifacts',
[BuildPhase.CLEAN_UP_CREDENTIALS]: 'clean-up-credentials',
@@ -174,7 +169,6 @@ export const buildPhaseWebsiteId: Record<BuildPhase, string> = {
// ANDROID
[BuildPhase.FIX_GRADLEW]: 'fix-gradlew',
[BuildPhase.RUN_GRADLEW]: 'run-gradlew',
[BuildPhase.GRADLE_BUILD_PROFILE]: 'gradle-build-profile',
// IOS
[BuildPhase.INSTALL_PODS]: 'install-pods',
-6
View File
@@ -49,11 +49,6 @@ export type Metadata = {
*/
sdkVersion?: string;
/**
* Version of the expo package used in the project.
*/
expoPackageVersion?: string;
/**
* Runtime version (for Expo Updates)
*/
@@ -185,7 +180,6 @@ export const MetadataSchema = Joi.object({
distribution: Joi.string().valid('store', 'internal', 'simulator'),
credentialsSource: Joi.string().valid('local', 'remote'),
sdkVersion: Joi.string(),
expoPackageVersion: Joi.string(),
runtimeVersion: Joi.string(),
fingerprintHash: Joi.string(),
reactNativeVersion: Joi.string(),
@@ -1,54 +0,0 @@
import spawn from '@expo/turtle-spawn';
import { asyncResult } from '@expo/results';
import fs from 'fs/promises';
import semver from 'semver';
import { type Env } from './common';
import * as errors from './errors';
export async function getInstalledExpoPackageVersionAsync({
env = process.env,
projectDir,
}: {
env?: Env | NodeJS.ProcessEnv;
projectDir: string;
}): Promise<string> {
const expoPackageJsonPathResult = await asyncResult(
spawn('node', ['--print', "require.resolve('expo/package.json')"], {
cwd: projectDir,
env,
stdio: 'pipe',
})
);
if (!expoPackageJsonPathResult.ok) {
throw new errors.UserError(
'EAS_BUILD_EXPO_PACKAGE_VERSION_NOT_FOUND',
'Cannot resolve the installed expo package version because require.resolve("expo/package.json") failed.',
{ cause: expoPackageJsonPathResult.reason }
);
}
const expoPackageJsonPath = expoPackageJsonPathResult.value.stdout.toString().trim();
const expoPackageJsonResult = await asyncResult(readJsonAsync(expoPackageJsonPath));
if (!expoPackageJsonResult.ok) {
throw new errors.UserError(
'EAS_BUILD_EXPO_PACKAGE_VERSION_READ_FAILED',
'Cannot resolve the installed expo package version because expo/package.json could not be read.',
{ cause: expoPackageJsonResult.reason }
);
}
const expoPackageVersion = expoPackageJsonResult.value.version;
if (typeof expoPackageVersion !== 'string' || !semver.valid(expoPackageVersion)) {
throw new errors.UserError(
'EAS_BUILD_EXPO_PACKAGE_VERSION_INVALID',
'Cannot resolve the installed expo package version because expo/package.json has an invalid version.'
);
}
return expoPackageVersion;
}
async function readJsonAsync(filePath: string): Promise<any> {
return JSON.parse(await fs.readFile(filePath, 'utf8'));
}
+105 -379
View File
File diff suppressed because it is too large. Load diff
+16 -5253
View File
File diff suppressed because it is too large. Load diff
+5 -7
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "20.1.0",
"version": "18.11.0",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -47,11 +47,11 @@
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/eas-build-job": "20.1.0",
"@expo/eas-json": "20.1.0",
"@expo/eas-build-job": "18.8.0",
"@expo/eas-json": "18.8.0",
"@expo/env": "^1.0.0",
"@expo/json-file": "8.3.3",
"@expo/logger": "20.0.0",
"@expo/logger": "18.5.0",
"@expo/multipart-body-parser": "2.0.0",
"@expo/osascript": "2.1.4",
"@expo/package-manager": "1.9.10",
@@ -63,7 +63,7 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "20.1.0",
"@expo/steps": "18.9.0",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
@@ -78,7 +78,6 @@
"chalk": "4.1.2",
"cli-progress": "3.12.0",
"dateformat": "4.6.3",
"debug": "4.4.3",
"diff": "7.0.0",
"dotenv": "16.3.1",
"env-paths": "2.2.0",
@@ -139,7 +138,6 @@
"@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",
@@ -1,197 +0,0 @@
import { getConfigFilePaths } from '@expo/config';
import { getWorkflowRunUrl } from '../../build/utils/url';
import Go from '../../commands/go';
import { WorkflowRunStatus } from '../../graphql/generated';
import { WorkflowRunMutation } from '../../graphql/mutations/WorkflowRunMutation';
import { WorkflowRunQuery } from '../../graphql/queries/WorkflowRunQuery';
import Log from '../../log';
import { selectAsync } from '../../prompts';
import { getPrivateExpoConfigAsync } from '../../project/expoConfig';
import { uploadAccountScopedFileAsync } from '../../project/uploadAccountScopedFileAsync';
import { uploadAccountScopedProjectSourceAsync } from '../../project/uploadAccountScopedProjectSourceAsync';
import { ensureActorHasPrimaryAccount } from '../../user/actions';
import { detectProjectSdkVersionAsync } from '../../commands/go';
import { mockTestCommand } from './utils';
jest.mock('@expo/config', () => ({
...jest.requireActual('@expo/config'),
getConfigFilePaths: jest.fn(),
}));
jest.mock('../../project/expoConfig');
jest.mock('../../log', () => ({
__esModule: true,
default: {
log: jest.fn(),
withTick: jest.fn(),
newLine: jest.fn(),
succeed: jest.fn(),
debug: jest.fn(),
markFreshLine: jest.fn(),
error: jest.fn(),
},
learnMore: jest.fn().mockReturnValue(''),
}));
jest.mock('../../ora', () => ({
ora: jest.fn().mockReturnValue({
start: jest.fn().mockReturnThis(),
stop: jest.fn().mockReturnThis(),
fail: jest.fn().mockReturnThis(),
succeed: jest.fn().mockReturnThis(),
}),
}));
jest.mock('fs-extra', () => ({
ensureDir: jest.fn().mockResolvedValue(undefined),
writeFile: jest.fn().mockResolvedValue(undefined),
remove: jest.fn().mockResolvedValue(undefined),
}));
jest.mock('../../user/actions');
jest.mock('../../graphql/queries/WorkflowRunQuery');
jest.mock('../../graphql/mutations/WorkflowRunMutation');
jest.mock('../../project/uploadAccountScopedFileAsync');
jest.mock('../../project/uploadAccountScopedProjectSourceAsync');
jest.mock('../../build/utils/url');
jest.mock('../../prompts');
const mockGetConfigFilePaths = jest.mocked(getConfigFilePaths);
const mockGetPrivateExpoConfigAsync = jest.mocked(getPrivateExpoConfigAsync);
describe('detectProjectSdkVersionAsync', () => {
it('returns undefined when no config file exists', async () => {
mockGetConfigFilePaths.mockReturnValue({ staticConfigPath: null, dynamicConfigPath: null });
await expect(detectProjectSdkVersionAsync('/project')).resolves.toBeUndefined();
});
it('returns the sdkVersion from the project config', async () => {
mockGetConfigFilePaths.mockReturnValue({
staticConfigPath: '/project/app.json',
dynamicConfigPath: null,
});
mockGetPrivateExpoConfigAsync.mockResolvedValue({ sdkVersion: '55.0.0' } as any);
await expect(detectProjectSdkVersionAsync('/project')).resolves.toBe('55.0.0');
});
it('returns undefined when reading the config throws', async () => {
mockGetConfigFilePaths.mockReturnValue({
staticConfigPath: '/project/app.json',
dynamicConfigPath: null,
});
mockGetPrivateExpoConfigAsync.mockRejectedValue(new Error('config error'));
await expect(detectProjectSdkVersionAsync('/project')).resolves.toBeUndefined();
});
});
const mockAccount = { id: 'account-id', name: 'testuser' };
const mockActor = {
__typename: 'User' as const,
id: 'user-id',
username: 'testuser',
primaryAccount: mockAccount,
};
describe('Go command', () => {
beforeEach(() => {
jest.mocked(ensureActorHasPrimaryAccount).mockReturnValue(mockAccount as any);
jest.mocked(WorkflowRunQuery.expoGoRepackConfigurationAsync).mockResolvedValue({
files: [],
sdkVersion: '55.0.0',
} as any);
jest.mocked(WorkflowRunMutation.createExpoGoRepackWorkflowRunAsync).mockResolvedValue({
id: 'run-id',
} as any);
jest.mocked(uploadAccountScopedProjectSourceAsync).mockResolvedValue({
projectArchiveBucketKey: 'archive-key',
});
jest
.mocked(uploadAccountScopedFileAsync)
.mockResolvedValue({ fileBucketKey: 'file-key' } as any);
jest.mocked(getWorkflowRunUrl).mockReturnValue('https://expo.dev/run/123');
});
afterEach(() => {
jest.clearAllMocks();
});
function makeCmd(argv: string[] = []) {
const ctx = {
loggedIn: { actor: mockActor as any, graphqlClient: {} as any },
analytics: {} as any,
};
const cmd = mockTestCommand(Go, ['--bundle-id', 'com.test.go', ...argv], ctx);
jest.spyOn(cmd as any, 'ensureEasProjectAsync').mockResolvedValue('project-id');
jest.spyOn(cmd as any, 'setupCredentialsAsync').mockResolvedValue({ id: 'asc-app-id' });
jest.spyOn(cmd as any, 'monitorWorkflowJobsAsync').mockResolvedValue(WorkflowRunStatus.Success);
return cmd;
}
it('logs auto-selected SDK message and reports resolved version after dispatch', async () => {
mockGetConfigFilePaths.mockReturnValue({
staticConfigPath: '/app.json',
dynamicConfigPath: null,
});
mockGetPrivateExpoConfigAsync.mockResolvedValue({ sdkVersion: '55.0.0' } as any);
await makeCmd().run();
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('SDK 55'));
expect(Log.withTick).toHaveBeenCalledWith(expect.stringContaining('Using Expo Go SDK'));
});
it('skips auto-select log when --sdk-version flag is provided', async () => {
mockGetConfigFilePaths.mockReturnValue({
staticConfigPath: '/app.json',
dynamicConfigPath: null,
});
mockGetPrivateExpoConfigAsync.mockResolvedValue({ sdkVersion: '55.0.0' } as any);
await makeCmd(['--sdk-version', '55.0.0']).run();
expect(Log.log).not.toHaveBeenCalledWith(expect.stringContaining('Auto-selected'));
});
it('prompts for SDK version when no project config is found', async () => {
mockGetConfigFilePaths.mockReturnValue({ staticConfigPath: null, dynamicConfigPath: null });
jest.mocked(WorkflowRunQuery.expoGoSupportedSdkVersionsAsync).mockResolvedValue([
{ sdkVersion: '54.0.0', isLatest: false, isBeta: false, isDeprecated: false },
{ sdkVersion: '55.0.0', isLatest: true, isBeta: false, isDeprecated: false },
{ sdkVersion: '56.0.0', isLatest: false, isBeta: true, isDeprecated: false },
]);
jest.mocked(selectAsync).mockResolvedValue('55.0.0');
await makeCmd().run();
expect(selectAsync).toHaveBeenCalledWith(
'Select an Expo SDK version',
expect.arrayContaining([
expect.objectContaining({ title: 'SDK 54', value: '54.0.0' }),
expect.objectContaining({ title: 'SDK 55 (latest)', value: '55.0.0' }),
expect.objectContaining({ title: 'SDK 56 (beta)', value: '56.0.0' }),
]),
expect.objectContaining({ initial: '55.0.0' })
);
});
it('skips prompt when all versions are deprecated', async () => {
mockGetConfigFilePaths.mockReturnValue({ staticConfigPath: null, dynamicConfigPath: null });
jest
.mocked(WorkflowRunQuery.expoGoSupportedSdkVersionsAsync)
.mockResolvedValue([
{ sdkVersion: '54.0.0', isLatest: false, isBeta: false, isDeprecated: true },
]);
await makeCmd().run();
expect(selectAsync).not.toHaveBeenCalled();
});
it('falls back gracefully when supportedSdkVersions fetch fails', async () => {
mockGetConfigFilePaths.mockReturnValue({ staticConfigPath: null, dynamicConfigPath: null });
jest
.mocked(WorkflowRunQuery.expoGoSupportedSdkVersionsAsync)
.mockRejectedValue(new Error('network error'));
await makeCmd().run();
expect(selectAsync).not.toHaveBeenCalled();
});
});
@@ -42,7 +42,6 @@ export async function createBuildContextAsync<T extends Platform>({
customBuildConfigMetadata,
buildLoggerLevel,
freezeCredentials,
refreshAdHocProvisioningProfile: refreshAdHocProvisioningProfileFlag,
isVerboseLoggingEnabled,
whatToTest,
env,
@@ -66,7 +65,6 @@ export async function createBuildContextAsync<T extends Platform>({
customBuildConfigMetadata?: CustomBuildConfigMetadata;
buildLoggerLevel?: LoggerLevel;
freezeCredentials: boolean;
refreshAdHocProvisioningProfile?: boolean;
isVerboseLoggingEnabled: boolean;
whatToTest?: string;
env: Record<string, string>;
@@ -92,8 +90,6 @@ export async function createBuildContextAsync<T extends Platform>({
const requiredPackageManager = resolvePackageManager(projectDir);
const refreshAdHocProvisioningProfile = refreshAdHocProvisioningProfileFlag ?? false;
const credentialsCtx = new CredentialsContext({
projectInfo: { exp, projectId },
nonInteractive,
@@ -105,7 +101,6 @@ export async function createBuildContextAsync<T extends Platform>({
easJsonCliConfig,
vcsClient,
freezeCredentials,
refreshAdHocProvisioningProfile,
});
const devClientProperties = getDevClientEventProperties({
@@ -1,40 +0,0 @@
import { Platform } from '@expo/eas-build-job';
import { CredentialsSource } from '@expo/eas-json';
import { BuildContext } from '../../context';
import { ensureIosCredentialsAsync } from '../credentials';
describe(ensureIosCredentialsAsync, () => {
it('errors when refresh is enabled with local credentials source', async () => {
const buildCtx = {
buildProfile: {
credentialsSource: CredentialsSource.LOCAL,
simulator: false,
withoutCredentials: false,
},
credentialsCtx: {
refreshAdHocProvisioningProfile: true,
},
} as BuildContext<Platform.IOS>;
await expect(ensureIosCredentialsAsync(buildCtx, [])).rejects.toThrow(
'--refresh-ad-hoc-provisioning-profile cannot be used with credentialsSource "local". Use remote credentials or omit the flag.'
);
});
it('does not reject refresh when credentials source is remote', async () => {
const buildCtx = {
buildProfile: {
credentialsSource: CredentialsSource.REMOTE,
simulator: true,
withoutCredentials: false,
distribution: 'internal',
},
credentialsCtx: {
refreshAdHocProvisioningProfile: true,
},
} as BuildContext<Platform.IOS>;
await expect(ensureIosCredentialsAsync(buildCtx, [])).resolves.toBeUndefined();
});
});
+2 -10
View File
@@ -17,16 +17,6 @@ export async function ensureIosCredentialsAsync(
return;
}
const { credentialsSource } = buildCtx.buildProfile;
if (
buildCtx.credentialsCtx.refreshAdHocProvisioningProfile &&
credentialsSource === 'local'
) {
throw new Error(
'--refresh-ad-hoc-provisioning-profile cannot be used with credentialsSource "local". Use remote credentials or omit the flag.'
);
}
const provider = new IosCredentialsProvider(buildCtx.credentialsCtx, {
app: await getAppFromContextAsync(buildCtx.credentialsCtx),
targets,
@@ -34,6 +24,8 @@ export async function ensureIosCredentialsAsync(
enterpriseProvisioning: buildCtx.buildProfile.enterpriseProvisioning,
});
const { credentialsSource } = buildCtx.buildProfile;
logCredentialsSource(credentialsSource, Platform.IOS);
return {
credentials: await provider.getCredentialsAsync(credentialsSource),
@@ -112,9 +112,6 @@ export async function prepareJobAsync(
// See: https://github.com/expo/eas-build/pull/454
appId: ctx.projectId,
initiatingUserId: ctx.user.id,
...(ctx.credentialsCtx.refreshAdHocProvisioningProfile && {
refreshAdHocProvisioningProfile: true,
}),
};
return sanitizeBuildJob(job) as Ios.Job;
}
+4 -19
View File
@@ -1,11 +1,5 @@
import {
Metadata,
Platform,
getInstalledExpoPackageVersionAsync,
sanitizeMetadata,
} from '@expo/eas-build-job';
import { Metadata, Platform, sanitizeMetadata } from '@expo/eas-build-job';
import { IosEnterpriseProvisioning } from '@expo/eas-json';
import { asyncResult } from '@expo/results';
import fs from 'fs-extra';
import resolveFrom from 'resolve-from';
@@ -15,7 +9,9 @@ import { maybeResolveVersionsAsync as maybeResolveIosVersionsAsync } from './ios
import { LocalBuildMode } from './local';
import { BuildDistributionType } from './types';
import Log from '../log';
import { isExpoUpdatesInstalled } from '../project/projectUtils';
import {
isExpoUpdatesInstalled,
} from '../project/projectUtils';
import { readChannelSafelyAsync as readAndroidChannelSafelyAsync } from '../update/android/UpdatesModule';
import { readChannelSafelyAsync as readIosChannelSafelyAsync } from '../update/ios/UpdatesModule';
import { easCliVersion } from '../utils/easCli';
@@ -30,16 +26,6 @@ export async function collectMetadataAsync<T extends Platform>(
): Promise<Metadata> {
const channelObject = await resolveChannelAsync(ctx);
const distribution = ctx.buildProfile.distribution ?? BuildDistributionType.STORE;
const expoPackageVersionResult = await asyncResult(
getInstalledExpoPackageVersionAsync({
env: process.env,
projectDir: ctx.projectDir,
})
);
if (!expoPackageVersionResult.ok) {
Log.debug('Failed to resolve expo package version:');
Log.debug(expoPackageVersionResult.reason);
}
const metadata: Metadata = {
trackingContext: ctx.analyticsEventProperties,
...(await maybeResolveVersionsAsync(ctx)),
@@ -50,7 +36,6 @@ export async function collectMetadataAsync<T extends Platform>(
runtimeVersion: runtimeAndFingerprintMetadata?.runtimeVersion,
fingerprintHash: runtimeAndFingerprintMetadata?.fingerprintHash,
reactNativeVersion: await getReactNativeVersionAsync(ctx.projectDir),
expoPackageVersion: expoPackageVersionResult.value,
...channelObject,
distribution,
appName: ctx.exp.name,
@@ -103,7 +103,6 @@ export interface BuildFlags {
message?: string;
buildLoggerLevel?: LoggerLevel;
freezeCredentials: boolean;
refreshAdHocProvisioningProfile?: boolean;
isVerboseLoggingEnabled?: boolean;
whatToTest?: string;
simulator?: SimulatorRunTarget;
@@ -414,7 +413,6 @@ async function prepareAndStartBuildAsync({
customBuildConfigMetadata,
buildLoggerLevel: flags.buildLoggerLevel ?? (Log.isDebug ? LoggerLevel.DEBUG : undefined),
freezeCredentials: flags.freezeCredentials,
refreshAdHocProvisioningProfile: flags.refreshAdHocProvisioningProfile,
isVerboseLoggingEnabled: flags.isVerboseLoggingEnabled ?? false,
whatToTest: flags.whatToTest,
env,
-1
View File
@@ -35,5 +35,4 @@ export interface BuildFlags {
message?: string;
buildLoggerLevel?: LoggerLevel;
freezeCredentials: boolean;
refreshAdHocProvisioningProfile?: boolean;
}
@@ -74,10 +74,7 @@ describe(getProjectIdAsync, () => {
);
jest.mocked(findProjectRootAsync).mockResolvedValue('/app');
// NOTE(@kitten): Updating this test is easiest by letting it fallback to `@expo/config`
// This isn't a great solution, but the test is pretty involved
jest.mocked(isExpoInstalled).mockReturnValue(false);
jest.mocked(isExpoInstalled).mockReturnValue(true);
});
it('gets the project ID from app config if exists', async () => {
@@ -1,14 +1,12 @@
import { canAccessRepositoryUsingSshAsync, runGitCloneAsync } from '../../../onboarding/git';
import { installDependenciesAsync } from '../../../onboarding/installDependencies';
import { runCommandAsync } from '../../../onboarding/runCommand';
import { expoCommandAsync } from '../../../utils/expoCli';
import {
cloneTemplateAsync,
initializeGitRepositoryAsync,
installProjectDependenciesAsync,
} from '../commands';
jest.mock('../../../utils/expoCli');
jest.mock('../../../onboarding/git');
jest.mock('../../../onboarding/runCommand');
jest.mock('../../../onboarding/installDependencies');
@@ -81,11 +79,21 @@ describe('commands', () => {
packageManager: 'npm',
});
expect(expoCommandAsync).toHaveBeenCalledWith(
projectDir,
['install', 'expo-updates', '@expo/metro-runtime'],
{ silent: true }
);
expect(runCommandAsync).toHaveBeenCalledWith({
cwd: projectDir,
command: 'npx',
args: ['expo', 'install', 'expo-updates'],
showOutput: false,
showSpinner: false,
});
expect(runCommandAsync).toHaveBeenCalledWith({
cwd: projectDir,
command: 'npx',
args: ['expo', 'install', '@expo/metro-runtime'],
showOutput: false,
showSpinner: false,
});
});
});
@@ -7,7 +7,6 @@ import { canAccessRepositoryUsingSshAsync, runGitCloneAsync } from '../../onboar
import { PackageManager, installDependenciesAsync } from '../../onboarding/installDependencies';
import { runCommandAsync } from '../../onboarding/runCommand';
import { ora } from '../../ora';
import { expoCommandAsync } from '../../utils/expoCli';
export async function cloneTemplateAsync(targetProjectDir: string): Promise<string> {
const githubUsername = 'expo';
@@ -49,8 +48,16 @@ export async function installProjectDependenciesAsync(
});
const dependencies = ['expo-updates', '@expo/metro-runtime'];
spinner.text = `Installing ${dependencies.map(dep => chalk.bold(dep)).join(', ')}`;
await expoCommandAsync(projectDir, ['install', ...dependencies], { silent: true });
for (const dependency of dependencies) {
spinner.text = `Installing ${chalk.bold(dependency)}`;
await runCommandAsync({
cwd: projectDir,
command: 'npx',
args: ['expo', 'install', dependency],
showOutput: false,
showSpinner: false,
});
}
spinner.succeed(`Installed project dependencies`);
}
@@ -13,15 +13,14 @@ export default class AccountLogin extends EasCommand {
static override flags = {
// can pass either --sso or -s
sso: Flags.boolean({
description: 'Log in with SSO',
description: 'Login with SSO',
char: 's',
default: false,
}),
browser: Flags.boolean({
description: 'Log in with your browser (default; use --no-browser for CLI-based login)',
description: 'Login with your browser',
char: 'b',
default: true,
allowNo: true,
default: false,
}),
};
@@ -40,7 +40,6 @@ interface RawBuildFlags {
message?: string;
'build-logger-level'?: LoggerLevel;
'freeze-credentials': boolean;
'refresh-ad-hoc-provisioning-profile': boolean;
'verbose-logs'?: boolean;
'what-to-test'?: string;
}
@@ -122,11 +121,6 @@ export default class Build extends EasCommand {
default: false,
description: 'Prevent the build from updating credentials in non-interactive mode',
}),
'refresh-ad-hoc-provisioning-profile': Flags.boolean({
default: false,
description:
'Refresh managed ad-hoc provisioning profiles from App Store Connect before gathering build credentials',
}),
'verbose-logs': Flags.boolean({
default: false,
description: 'Use verbose logs for the build process',
@@ -194,20 +188,6 @@ export default class Build extends EasCommand {
flags: RawBuildFlags
): Omit<BuildFlags, 'requestedPlatform'> & { requestedPlatform?: RequestedPlatform } {
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
if (flags['refresh-ad-hoc-provisioning-profile']) {
if (!nonInteractive) {
Errors.error(
'--refresh-ad-hoc-provisioning-profile can only be used in non-interactive mode.',
{ exit: 1 }
);
}
if (flags['freeze-credentials']) {
Errors.error(
'Cannot use --refresh-ad-hoc-provisioning-profile with --freeze-credentials.',
{ exit: 1 }
);
}
}
if (!flags.local && flags.output) {
Errors.error('--output is allowed only for local builds', { exit: 1 });
}
@@ -269,7 +249,6 @@ export default class Build extends EasCommand {
message,
buildLoggerLevel: flags['build-logger-level'],
freezeCredentials: flags['freeze-credentials'],
refreshAdHocProvisioningProfile: flags['refresh-ad-hoc-provisioning-profile'],
isVerboseLoggingEnabled: flags['verbose-logs'],
whatToTest: flags['what-to-test'],
};
@@ -40,11 +40,6 @@ export default class BuildInternal extends EasCommand {
helpValue: 'PROFILE_NAME',
exclusive: ['auto-submit'],
}),
'refresh-ad-hoc-provisioning-profile': Flags.boolean({
default: false,
description:
'Refresh managed ad-hoc provisioning profiles from App Store Connect before gathering build credentials',
}),
};
static override contextDefinition = {
@@ -90,7 +85,6 @@ export default class BuildInternal extends EasCommand {
profile: flags.profile,
nonInteractive: true,
freezeCredentials: false,
refreshAdHocProvisioningProfile: flags['refresh-ad-hoc-provisioning-profile'],
wait: false,
clearCache: false,
json: true,
+217 -185
View File
@@ -1,5 +1,5 @@
import { ExpoConfig, getConfigFilePaths } from '@expo/config';
import { App, User, UserRole } from '@expo/apple-utils';
import spawnAsync from '@expo/spawn-async';
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import * as fs from 'fs-extra';
@@ -8,6 +8,7 @@ import * as path from 'path';
import { Analytics } from '../analytics/AnalyticsManager';
import EasCommand from '../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { saveProjectIdToAppConfigAsync } from '../commandUtils/context/contextUtils/getProjectIdAsync';
import { CredentialsContext } from '../credentials/context';
import { AppStoreApiKeyPurpose } from '../credentials/ios/actions/AscApiKeyUtils';
import { getAppFromContextAsync } from '../credentials/ios/actions/BuildCredentialsUtils';
@@ -21,48 +22,31 @@ import {
WorkflowProjectSourceType,
WorkflowRunStatus,
} from '../graphql/generated';
import { getWorkflowRunUrl } from '../build/utils/url';
import { AppMutation } from '../graphql/mutations/AppMutation';
import { WorkflowRunMutation } from '../graphql/mutations/WorkflowRunMutation';
import { AppQuery } from '../graphql/queries/AppQuery';
import { WorkflowRunQuery } from '../graphql/queries/WorkflowRunQuery';
import Log, { learnMore } from '../log';
import { confirmAsync, selectAsync } from '../prompts';
import { confirmAsync } from '../prompts';
import { ora } from '../ora';
import { getPrivateExpoConfigAsync } from '../project/expoConfig';
import { findProjectIdByAccountNameAndSlugNullableAsync } from '../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
import { uploadAccountScopedFileAsync } from '../project/uploadAccountScopedFileAsync';
import { uploadAccountScopedProjectSourceAsync } from '../project/uploadAccountScopedProjectSourceAsync';
import { ensureActorHasPrimaryAccount } from '../user/actions';
import { Actor, getActorDisplayName } from '../user/User';
import { sleepAsync } from '../utils/promise';
import { Client } from '../vcs/vcs';
import NoVcsClient from '../vcs/clients/noVcs';
import {
INVALID_BUNDLE_IDENTIFIER_MESSAGE,
isBundleIdentifierValid,
} from '../project/ios/bundleIdentifier';
import { resolveVcsClient } from '../vcs';
import { Client as VcsClient } from '../vcs/vcs';
function deriveBundleIdSlug(bundleId: string): string {
return bundleId.split('.').filter(Boolean).pop()!;
}
export async function detectProjectSdkVersionAsync(
projectDir: string
): Promise<string | undefined> {
const paths = getConfigFilePaths(projectDir);
if (!paths.staticConfigPath && !paths.dynamicConfigPath) {
return;
}
try {
return (await getPrivateExpoConfigAsync(projectDir)).sdkVersion;
} catch {
return;
}
}
// Expo Go release info - update when releasing a new version
const EXPO_GO_SDK_VERSION = '55';
const EXPO_GO_APP_VERSION = '55.0.11';
const EXPO_GO_BUILD_NUMBER = '1017799';
const TESTFLIGHT_GROUP_NAME = 'Team (Expo)';
async function setupTestFlightAsync(ascApp: App): Promise<void> {
// Create or get TestFlight group
let group;
for (let attempt = 0; attempt < 10; attempt++) {
try {
@@ -86,7 +70,7 @@ async function setupTestFlightAsync(ascApp: App): Promise<void> {
// Apple returns this error when the app isn't ready yet
if (error?.data?.errors?.some((e: any) => e.code === 'ENTITY_ERROR.RELATIONSHIP.INVALID')) {
if (attempt < 9) {
await sleepAsync(10_000);
await new Promise(resolve => setTimeout(resolve, 10000));
continue;
}
}
@@ -134,27 +118,30 @@ async function withSuppressedOutputAsync<T>(fn: () => Promise<T>): Promise<T> {
return true;
};
// Suppress stdout and console output during credential setup so its verbose
// log lines don't interleave with our progress spinners.
// Only suppress stdout, not stderr — ora writes spinner frames to stderr and
// patching it would freeze the spinner animation during suppressed async work.
process.stdout.write = capture as any;
console.log = () => {};
console.error = () => {};
console.warn = () => {};
let didThrow = false;
try {
return await fn();
} catch (error) {
didThrow = true;
process.stdout.write = originalStdoutWrite;
console.log = originalConsoleLog;
console.error = originalConsoleError;
console.warn = originalConsoleWarn;
if (capturedOutput) {
originalConsoleLog(capturedOutput);
}
throw error;
} finally {
process.stdout.write = originalStdoutWrite;
console.log = originalConsoleLog;
console.error = originalConsoleError;
console.warn = originalConsoleWarn;
if (didThrow && capturedOutput) {
originalConsoleLog(capturedOutput);
}
}
}
/* eslint-enable no-console */
@@ -172,10 +159,6 @@ export default class Go extends EasCommand {
description: 'App name',
default: 'My Expo Go',
}),
'sdk-version': Flags.string({
description: 'Expo Go SDK version to prepare (default: latest)',
required: false,
}),
credentials: Flags.boolean({
description: 'Interactively select credentials (default: auto-select)',
default: false,
@@ -204,43 +187,34 @@ export default class Go extends EasCommand {
});
Log.withTick(`Logged in as ${chalk.cyan(getActorDisplayName(actor))}`);
const detectedSdkVersion = await detectProjectSdkVersionAsync(process.cwd());
if (detectedSdkVersion && !flags['sdk-version']) {
Log.log(
`Current project using SDK ${detectedSdkVersion.split('.')[0]}. Auto-selected same version. To use a different version, pass --sdk-version.`
);
}
let sdkVersion = flags['sdk-version'] ?? detectedSdkVersion;
if (!sdkVersion) {
({ sdkVersion } = await this.selectSdkVersionAsync(graphqlClient));
}
const bundleId = flags['bundle-id'] ?? this.generateBundleId(actor);
if (!isBundleIdentifierValid(bundleId)) {
throw new Error(
`"${bundleId}" is not a valid iOS bundle identifier. ${INVALID_BUNDLE_IDENTIFIER_MESSAGE} Pass a valid identifier with --bundle-id.`
);
}
const appName = flags.name;
const slug = deriveBundleIdSlug(bundleId);
const appName = flags.name ?? 'My Expo Go';
const slug = bundleId.split('.').pop() || 'my-expo-go';
const setupSpinner = ora('Setting up project...').start();
let projectId: string;
const projectDir = path.join(os.tmpdir(), `eas-go-${slug}`);
await fs.emptyDir(projectDir);
const originalCwd = process.cwd();
process.chdir(projectDir);
const setupSpinner = ora('Creating project...').start();
// Step 1: Create project files and initialize git (silently)
try {
projectId = await withSuppressedOutputAsync(() =>
this.ensureEasProjectAsync(graphqlClient, actor, slug)
await withSuppressedOutputAsync(async () => {
await this.createProjectFilesAsync(projectDir, bundleId, appName);
await this.initGitRepoAsync(projectDir);
});
const vcsClient = resolveVcsClient();
// Step 2: Create/link EAS project (silently)
const projectId = await withSuppressedOutputAsync(() =>
this.ensureEasProjectAsync(graphqlClient, actor, projectDir, bundleId)
);
} catch (error) {
setupSpinner.fail();
throw error;
}
const tmpDir = path.join(os.tmpdir(), `eas-go-${Date.now()}`);
await fs.ensureDir(tmpDir);
const vcsClient = new NoVcsClient({ cwdOverride: tmpDir });
try {
// Step 3: Set up iOS credentials and create App Store Connect app
const ascApp = await this.setupCredentialsAsync(
slug,
projectDir,
projectId,
bundleId,
appName,
@@ -255,35 +229,28 @@ export default class Go extends EasCommand {
}
);
const {
workflowUrl,
workflowRunId,
sdkVersion: resolvedSdkVersion,
} = await this.dispatchWorkflowAsync(
// Step 4: Start workflow and monitor progress
const { workflowUrl, workflowRunId } = await this.runWorkflowAsync(
graphqlClient,
projectDir,
projectId,
actor,
bundleId,
appName,
ascApp.id,
sdkVersion,
tmpDir,
vcsClient
);
Log.withTick(`Using Expo Go SDK ${chalk.cyan(resolvedSdkVersion.split('.')[0])}`);
Log.withTick(`Build started: ${chalk.cyan(workflowUrl)}`);
Log.withTick(`Workflow started: ${chalk.cyan(workflowUrl)}`);
const status = await this.monitorWorkflowJobsAsync(graphqlClient, workflowRunId);
if (status === WorkflowRunStatus.Failure) {
throw new Error('Build failed');
throw new Error('Workflow failed');
} else if (status === WorkflowRunStatus.Canceled) {
throw new Error('Build was canceled');
throw new Error('Workflow was canceled');
}
// Step 5: Set up TestFlight group (silently)
try {
await setupTestFlightAsync(ascApp);
} catch (e) {
Log.debug('TestFlight group setup failed:', e);
} catch {
// Non-fatal: TestFlight group setup failure shouldn't block the user
}
Log.newLine();
@@ -299,58 +266,112 @@ export default class Go extends EasCommand {
{ learnMoreMessage: 'Learn more about Expo Go on TestFlight' }
)}`
);
} finally {
await fs.remove(tmpDir);
}
}
private async selectSdkVersionAsync(
graphqlClient: ExpoGraphqlClient
): Promise<{ sdkVersion: string | undefined }> {
let versions;
try {
versions = await WorkflowRunQuery.expoGoSupportedSdkVersionsAsync(graphqlClient);
} catch {
return { sdkVersion: undefined };
await fs.remove(projectDir);
} catch (error) {
Log.gray(`Project files preserved for debugging: ${projectDir}`);
throw error;
} finally {
process.chdir(originalCwd);
}
const selectable = versions.filter(v => !v.isDeprecated);
if (selectable.length === 0) {
return { sdkVersion: undefined };
}
const defaultVersion = selectable.find(v => v.isLatest) ?? selectable.at(-1);
return {
sdkVersion: await selectAsync(
'Select an Expo SDK version',
selectable.map(v => {
const major = v.sdkVersion.split('.')[0];
const title = v.isLatest
? `SDK ${major} (latest)`
: v.isBeta
? `SDK ${major} (beta)`
: `SDK ${major}`;
return { title, value: v.sdkVersion };
}),
{ initial: defaultVersion?.sdkVersion }
),
};
}
private generateBundleId(actor: Actor): string {
const username = ensureActorHasPrimaryAccount(actor).name;
const username = actor.accounts[0].name;
// Sanitize username for bundle ID: only alphanumeric and hyphens allowed
const sanitizedUsername = username
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')
.replace(/-{2,}/g, '-')
.replace(/^-+|-+$/g, '');
return `com.${sanitizedUsername}.expogo`;
.replace(/[^a-z0-9-]/g, '')
.replace(/^-+|-+$/g, ''); // trim leading/trailing hyphens
// Deterministic bundle ID per user + SDK version (reuses same ASC app)
return `com.${sanitizedUsername || 'app'}.expogo${EXPO_GO_SDK_VERSION}`;
}
private async createProjectFilesAsync(
projectDir: string,
bundleId: string,
appName: string
): Promise<void> {
const slug = bundleId.split('.').pop() || 'custom-expo-go';
const extensionBundleId = `${bundleId}.ExpoNotificationServiceExtension`;
const appJson = {
expo: {
name: appName,
slug,
version: EXPO_GO_APP_VERSION,
ios: {
bundleIdentifier: bundleId,
buildNumber: EXPO_GO_BUILD_NUMBER,
config: {
usesNonExemptEncryption: false,
},
},
extra: {
eas: {
build: {
experimental: {
ios: {
appExtensions: [
{
targetName: 'ExpoNotificationServiceExtension',
bundleIdentifier: extensionBundleId,
},
],
},
},
},
},
},
},
};
const easJson = {
cli: {
version: '>= 5.0.0',
},
build: {
production: {
distribution: 'store',
credentialsSource: 'remote',
},
},
submit: {
production: {
ios: {},
},
},
};
const packageJson = {
name: slug,
version: '1.0.0',
dependencies: {
expo: '~54.0.0',
},
};
await fs.writeJson(path.join(projectDir, 'app.json'), appJson, { spaces: 2 });
await fs.writeJson(path.join(projectDir, 'eas.json'), easJson, { spaces: 2 });
await fs.writeJson(path.join(projectDir, 'package.json'), packageJson, { spaces: 2 });
await spawnAsync('npm', ['install'], { cwd: projectDir });
}
private async initGitRepoAsync(projectDir: string): Promise<void> {
await spawnAsync('git', ['init'], { cwd: projectDir });
await spawnAsync('git', ['add', '.'], { cwd: projectDir });
await spawnAsync('git', ['commit', '-m', 'Initial commit'], { cwd: projectDir });
}
private async ensureEasProjectAsync(
graphqlClient: ExpoGraphqlClient,
actor: Actor,
slug: string
projectDir: string,
bundleId: string
): Promise<string> {
const account = ensureActorHasPrimaryAccount(actor);
const slug = bundleId.split('.').pop() || 'custom-expo-go';
const account = actor.accounts[0];
const existingProjectId = await findProjectIdByAccountNameAndSlugNullableAsync(
graphqlClient,
@@ -359,36 +380,38 @@ export default class Go extends EasCommand {
);
if (existingProjectId) {
await saveProjectIdToAppConfigAsync(projectDir, existingProjectId);
return existingProjectId;
}
return await AppMutation.createAppAsync(graphqlClient, {
const projectId = await AppMutation.createAppAsync(graphqlClient, {
accountId: account.id,
projectName: slug,
});
await saveProjectIdToAppConfigAsync(projectDir, projectId);
return projectId;
}
private async setupCredentialsAsync(
slug: string,
projectDir: string,
projectId: string,
bundleId: string,
appName: string,
graphqlClient: ExpoGraphqlClient,
actor: Actor,
analytics: Analytics,
vcsClient: Client,
vcsClient: VcsClient,
customizeCreds: boolean,
onBeforeAppleAuth?: () => void
): Promise<App> {
const exp = await getPrivateExpoConfigAsync(projectDir);
const extensionBundleId = `${bundleId}.ExpoNotificationServiceExtension`;
const exp: ExpoConfig = { name: appName, slug, ios: { bundleIdentifier: bundleId } };
const credentialsCtx = new CredentialsContext({
projectInfo: { exp, projectId },
nonInteractive: false,
autoAcceptCredentialReuse: !customizeCreds,
projectDir: process.cwd(),
projectDir,
user: actor,
graphqlClient,
analytics,
@@ -402,7 +425,7 @@ export default class Go extends EasCommand {
const targets: Target[] = [
{
targetName: slug,
targetName: exp.slug,
bundleIdentifier: bundleId,
entitlements: {},
},
@@ -414,25 +437,43 @@ export default class Go extends EasCommand {
},
];
await new SetUpBuildCredentials({
app,
targets,
distribution: 'store',
}).runAsync(credentialsCtx);
const ascApp = await withSuppressedOutputAsync(async () => {
await new SetUpBuildCredentials({
app,
targets,
distribution: 'store',
}).runAsync(credentialsCtx);
const appLookupParams = {
...app,
bundleIdentifier: bundleId,
};
await new SetUpAscApiKey(appLookupParams, AppStoreApiKeyPurpose.SUBMISSION_SERVICE).runAsync(
credentialsCtx
);
const appLookupParams = {
...app,
bundleIdentifier: bundleId,
};
await new SetUpAscApiKey(appLookupParams, AppStoreApiKeyPurpose.SUBMISSION_SERVICE).runAsync(
credentialsCtx
);
const ascApp = await ensureAppExistsAsync(userAuthCtx, {
name: appName,
bundleIdentifier: bundleId,
const ascAppResult = await ensureAppExistsAsync(userAuthCtx, {
name: appName,
bundleIdentifier: bundleId,
});
const easJsonPath = path.join(projectDir, 'eas.json');
const easJson = await fs.readJson(easJsonPath);
easJson.submit = easJson.submit || {};
easJson.submit.production = easJson.submit.production || {};
easJson.submit.production.ios = easJson.submit.production.ios || {};
easJson.submit.production.ios.ascAppId = ascAppResult.id;
await fs.writeJson(easJsonPath, easJson, { spaces: 2 });
await spawnAsync('git', ['add', 'eas.json'], { cwd: projectDir });
await spawnAsync('git', ['commit', '-m', 'Add ascAppId to eas.json'], { cwd: projectDir });
return ascAppResult;
});
const setupPushKeyAction = new SetUpPushKey(appLookupParams);
// Set up push notifications (outside suppressed block so prompts are visible)
const appLookupParamsForPushKey = { ...app, bundleIdentifier: bundleId };
const setupPushKeyAction = new SetUpPushKey(appLookupParamsForPushKey);
const isPushKeySetup = await setupPushKeyAction.isPushKeySetupAsync(credentialsCtx);
if (!isPushKeySetup) {
if (customizeCreds) {
@@ -451,30 +492,14 @@ export default class Go extends EasCommand {
return ascApp;
}
private async dispatchWorkflowAsync(
private async runWorkflowAsync(
graphqlClient: ExpoGraphqlClient,
projectDir: string,
projectId: string,
actor: Actor,
bundleId: string,
appName: string,
ascAppId: string,
sdkVersion: string | undefined,
tmpDir: string,
vcsClient: Client
): Promise<{ workflowUrl: string; workflowRunId: string; sdkVersion: string }> {
const account = ensureActorHasPrimaryAccount(actor);
const repackConfig = await WorkflowRunQuery.expoGoRepackConfigurationAsync(graphqlClient, {
appId: projectId,
ascAppId,
appName,
bundleId,
sdkVersion,
});
await Promise.all(
repackConfig.files.map(f => fs.writeFile(path.join(tmpDir, f.fileName), f.fileContents))
);
vcsClient: VcsClient
): Promise<{ workflowUrl: string; workflowRunId: string }> {
const account = actor.accounts[0];
const { projectArchiveBucketKey, easJsonBucketKey, packageJsonBucketKey } =
await withSuppressedOutputAsync(async () => {
@@ -483,35 +508,42 @@ export default class Go extends EasCommand {
vcsClient,
accountId: account.id,
});
const { fileBucketKey: easJsonBucketKey } = await uploadAccountScopedFileAsync({
graphqlClient,
accountId: account.id,
filePath: path.join(tmpDir, 'eas.json'),
filePath: path.join(projectDir, 'eas.json'),
maxSizeBytes: 1024 * 1024,
});
const { fileBucketKey: packageJsonBucketKey } = await uploadAccountScopedFileAsync({
graphqlClient,
accountId: account.id,
filePath: path.join(tmpDir, 'package.json'),
filePath: path.join(projectDir, 'package.json'),
maxSizeBytes: 1024 * 1024,
});
return { projectArchiveBucketKey, easJsonBucketKey, packageJsonBucketKey };
});
const result = await WorkflowRunMutation.createExpoGoRepackWorkflowRunAsync(graphqlClient, {
appId: projectId,
sdkVersion: repackConfig.sdkVersion,
projectSource: {
type: WorkflowProjectSourceType.Gcs,
projectArchiveBucketKey,
easJsonBucketKey,
packageJsonBucketKey,
},
});
const { id: workflowRunId } = await WorkflowRunMutation.createExpoGoRepackWorkflowRunAsync(
graphqlClient,
{
appId: projectId,
projectSource: {
type: WorkflowProjectSourceType.Gcs,
projectArchiveBucketKey,
easJsonBucketKey,
packageJsonBucketKey,
projectRootDirectory: '.',
},
}
);
const workflowUrl = getWorkflowRunUrl(account.name, deriveBundleIdSlug(bundleId), result.id);
const app = await AppQuery.byIdAsync(graphqlClient, projectId);
const workflowUrl = `https://expo.dev/accounts/${account.name}/projects/${app.slug}/workflows/${workflowRunId}`;
return { workflowUrl, workflowRunId: result.id, sdkVersion: repackConfig.sdkVersion };
return { workflowUrl, workflowRunId };
}
private async monitorWorkflowJobsAsync(
@@ -552,8 +584,8 @@ export default class Go extends EasCommand {
});
failedFetchesCount = 0;
const repackJob = workflowRun.jobs.find(j => j.key === 'build');
const submitJob = workflowRun.jobs.find(j => j.key === 'submit');
const repackJob = workflowRun.jobs.find(j => j.name === 'Repack Expo Go');
const submitJob = workflowRun.jobs.find(j => j.name === 'Submit to TestFlight');
if (!buildCompleted) {
if (repackJob?.status === WorkflowJobStatus.Success) {
Loaded 100 of 215 files, more files were not shown because too many files have changed in this diff. Show more