Compare commits

..
1142 changed files with 25493 additions and 169189 deletions

No files matched your search

-11
View File
@@ -1,11 +0,0 @@
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
version = 1
name = "eas-cli"
[setup]
script = '''
mise trust
mise install
yarn
yarn typecheck
'''
+1 -7
View File
@@ -1,14 +1,8 @@
**/build/**
**/dist/**
**/coverage/**
**/fixtures/**
!packages/eas-cli/src/commands/build/**
!packages/eas-cli/src/build/**
packages/eas-cli/src/graphql/generated.ts
packages/eas-cli/schema/**
packages/eas-cli/graphql.schema.json
packages/build-tools/schema.graphql
node_modules
ts-declarations
ts-declarations
+44
View File
@@ -0,0 +1,44 @@
module.exports = {
root: true,
extends: ['universe/node'],
plugins: ['async-protect', 'node'],
rules: {
'no-console': 'warn',
'no-constant-condition': ['warn', { checkLoops: false }],
'sort-imports': [
'warn',
{
ignoreDeclarationSort: true,
},
],
curly: 'warn',
'import/no-cycle': 'error',
'import/no-extraneous-dependencies': [
'error',
{ devDependencies: ['**/__tests__/**/*', '**/__mocks__/**/*'] },
],
'import/no-relative-packages': 'error',
'no-restricted-imports': [
'error',
{
paths: [
{
name: 'lodash',
message: "Don't use lodash, it's heavy!",
},
],
},
],
'no-underscore-dangle': ['error', { allow: ['__typename'] }],
'async-protect/async-suffix': 'error',
'node/no-sync': 'error',
},
overrides: [
{
files: ['**/__tests__/**/*.ts', '**/__mocks__/**/*.ts'],
rules: {
'async-protect/async-suffix': 'off',
},
},
],
};
+1
View File
@@ -1,3 +1,4 @@
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->
# Why
-11
View File
@@ -1,11 +0,0 @@
name: setup-mise
description: Install tools via mise
runs:
using: "composite"
steps:
- uses: jdx/mise-action@146a28175021df8ca24f8ee1828cc2a60f980bd5 # v3.5.1
with:
install: true
cache: true
experimental: true
+1 -1
View File
@@ -2,7 +2,7 @@ commentConfiguration:
preamble: 'Subscribed to pull request'
epilogue: 'Generated by [CodeMention](https://github.com/expo/expo/actions/workflows/codemention.yaml)'
rules:
- patterns: ['packages/eas-cli/**']
- patterns: ['**/*']
mentions: ['douglowder']
- patterns: ['packages/eas-cli/src/update/**']
mentions: ['wschurman', 'quinlanj']
-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,8 +12,6 @@ jobs:
profile: system-test
custom_job:
name: Custom Job
runs_on: linux-medium-nested-virtualization
steps:
- name: Echo
run: echo "This is a custom job step"
- uses: eas/start_android_emulator
@@ -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
@@ -25,7 +25,7 @@ jobs:
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- uses: ./.github/actions/setup-mise
- uses: volta-cli/action@5c175f92dea6f48441c436471e6479dbc192e194 # v4
- uses: ./.github/internal-actions/setup-gcloud
+3 -4
View File
@@ -1,7 +1,6 @@
name: CHANGELOG.md PR check
on:
workflow_dispatch:
pull_request:
types:
- opened
@@ -18,7 +17,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 +25,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/add-pr-comment@v2.1.0
if: always()
with:
message-id: changelog-entry-check
+2 -8
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 a tag or branch) because this runs under
# pull_request_target with a write-scoped token — a re-pointed tag or
# moving branch would allow unreviewed upstream code to run here.
- uses: tobyhs/codemention@ebac5877393ab8693fd73b42708c8552043e9a3b # post-v1.5.2
- uses: tobyhs/[email protected]
with:
githubToken: ${{ secrets.GITHUB_TOKEN }}
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: |
-37
View File
@@ -1,37 +0,0 @@
name: License
on:
schedule:
- cron: '0 0 1 1-12/3 *' # Run every three months
push:
branches:
- main
paths:
- .github/workflows/license.yml
workflow_dispatch:
jobs:
update:
runs-on: ubuntu-latest
name: Update BSL change date
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
token: ${{ secrets.EXPO_BOT_PAT }}
- name: Check last commit date
run: |
# The date in the license
change_date=$(sed --quiet --regexp-extended 's/^Change Date:\s+([0-9]{4}-[0-9]{2}-[0-9]{2})/\1/p' < LICENSE-BUSL)
[ -z "$change_date" ] && exit 1
new_change_date=$(date --date "+3 years" --iso-8601)
if [ "$(date --date "$new_change_date" +%s)" -gt "$(date --date "$change_date" +%s)" ]; then
sed --in-place "s/$change_date/$new_change_date/" LICENSE-BUSL
git config user.name 'Expo Bot'
git config user.email [email protected]
git add LICENSE-BUSL
git commit -m "[license] Update BSL Change Date to $new_change_date"
if [ "$(git branch --show-current)" = main ]; then
git push
fi
fi
+4 -4
View File
@@ -24,12 +24,12 @@ jobs:
runs-on: ubuntu-latest
env:
GITHUB_TOKEN: ${{ secrets.EXPO_BOT_PAT }}
NPM_ACCESS_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
INPUT_VERSION: ${{ github.event.inputs.version }}
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"
@@ -46,7 +46,7 @@ jobs:
echo "cmd = $cmd"
exit 0
fi
echo //registry.npmjs.org/:_authToken=$NPM_ACCESS_TOKEN > ~/.npmrc
echo //registry.npmjs.org/:_authToken=$NODE_AUTH_TOKEN > ~/.npmrc
$cmd
- name: Add latest-eas-build tag
run: |
@@ -60,5 +60,5 @@ jobs:
echo "Only moving the staging tag, skipping the production tag"
exit 0
fi
echo //registry.npmjs.org/:_authToken=$NPM_ACCESS_TOKEN > ~/.npmrc
echo //registry.npmjs.org/:_authToken=$NODE_AUTH_TOKEN > ~/.npmrc
$cmd
+153 -23
View File
@@ -1,7 +1,7 @@
on:
push:
tags:
- "v*" # Push events to matching v*, i.e. v1.0, v20.15.10
- 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10
name: Release workflow
@@ -9,67 +9,197 @@ jobs:
release:
name: Create a release
runs-on: ubuntu-latest
permissions:
contents: write
outputs:
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:
tag_name: ${{ github.ref }}
release_name: ${{ github.ref }}
draft: true
build-linux:
name: Build for Linux
runs-on: ubuntu-latest
needs: release
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: 22
- run: yarn install --frozen-lockfile --check-files
- run: yarn build
- name: Ensure GraphQL schema and generated code is up-to-date
working-directory: ${{ github.workspace }}/packages/eas-cli
run: |
yarn verify-graphql-code
- name: Build tarballs
working-directory: ${{ github.workspace }}/packages/eas-cli
run: |
yarn pretarball-ci
yarn oclif pack:tarballs --no-xz --targets linux-x64,linux-arm
- id: x64
run: echo "filename=$(ls eas-*-x64.tar.gz)" >> $GITHUB_OUTPUT
working-directory: ${{ github.workspace }}/packages/eas-cli/dist
- id: arm
run: echo "filename=$(ls eas-*-arm.tar.gz)" >> $GITHUB_OUTPUT
working-directory: ${{ github.workspace }}/packages/eas-cli/dist
- name: Upload linux-x64 tarball
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.release.outputs.upload_url }}
asset_path: packages/eas-cli/dist/${{ steps.x64.outputs.filename }}
asset_name: ${{ steps.x64.outputs.filename }}
asset_content_type: application/gzip
- name: Upload linux-arm tarball
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.release.outputs.upload_url }}
asset_path: packages/eas-cli/dist/${{ steps.arm.outputs.filename }}
asset_name: ${{ steps.arm.outputs.filename }}
asset_content_type: application/gzip
build-mac:
name: Build for macOS
# Use macOS to get the correct platform specific dependencies
runs-on: macos-latest
needs: release
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: 22
- run: brew install python-setuptools
env:
HOMEBREW_NO_AUTO_UPDATE: 1
- run: yarn install --frozen-lockfile --check-files
- run: yarn build
- name: Build tarballs
working-directory: ${{ github.workspace }}/packages/eas-cli
run: |
yarn pretarball-ci
yarn oclif pack:tarballs --no-xz --targets darwin-x64,darwin-arm64
- id: x64
run: echo "filename=$(ls eas-*-x64.tar.gz)" >> $GITHUB_OUTPUT
working-directory: ${{ github.workspace }}/packages/eas-cli/dist
- id: arm64
run: echo "filename=$(ls eas-*-arm64.tar.gz)" >> $GITHUB_OUTPUT
working-directory: ${{ github.workspace }}/packages/eas-cli/dist
- name: Upload darwin-x64 tarball
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.release.outputs.upload_url }}
asset_path: packages/eas-cli/dist/${{ steps.x64.outputs.filename }}
asset_name: ${{ steps.x64.outputs.filename }}
asset_content_type: application/gzip
- name: Upload darwin-arm64 tarball
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.release.outputs.upload_url }}
asset_path: packages/eas-cli/dist/${{ steps.arm64.outputs.filename }}
asset_name: ${{ steps.arm64.outputs.filename }}
asset_content_type: application/gzip
build-windows:
name: Build for Windows
runs-on: ubuntu-latest
needs: release
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: 22
- run: yarn install --frozen-lockfile --check-files
- run: sudo apt-get install nsis
- run: yarn build
- run: |
yarn pretarball-ci
yarn oclif pack:win
working-directory: ${{ github.workspace }}/packages/eas-cli
- id: x64
run: echo "filename=$(ls eas-*-x64.exe)" >> $GITHUB_OUTPUT
working-directory: ${{ github.workspace }}/packages/eas-cli/dist/win32
- id: x86
run: echo "filename=$(ls eas-*-x86.exe)" >> $GITHUB_OUTPUT
working-directory: ${{ github.workspace }}/packages/eas-cli/dist/win32
- name: Upload x64 installer
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.release.outputs.upload_url }}
asset_path: packages/eas-cli/dist/win32/${{ steps.x64.outputs.filename }}
asset_name: ${{ steps.x64.outputs.filename }}
asset_content_type: application/gzip
- name: Upload x86 installer
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.release.outputs.upload_url }}
asset_path: packages/eas-cli/dist/win32/${{ steps.x86.outputs.filename }}
asset_name: ${{ steps.x86.outputs.filename }}
asset_content_type: application/gzip
publish-to-npm:
name: Publish to npm
runs-on: ubuntu-latest
needs: release
permissions:
id-token: write
needs: [build-linux, build-mac, build-windows]
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: ./.github/actions/setup-mise
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
registry-url: 'https://registry.npmjs.org/'
scope: 'expo'
node-version: 22
- name: Install dependencies
run: yarn install --immutable
run: yarn install --frozen-lockfile --check-files
- name: Build
run: yarn build
- name: Publish packages to npm
run: yarn run -T lerna publish from-package --yes --no-verify-access
- name: Add latest-eas-build-staging tag
run: yarn lerna publish from-package --yes --no-verify-access
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
- name: Add latest-eas-build-staging tag
run: |
VERSION=$(cat lerna.json | jq -r .version)
echo //registry.npmjs.org/:_authToken=$NODE_AUTH_TOKEN > ~/.npmrc
npm dist-tag add eas-cli@$VERSION latest-eas-build-staging
- name: Add latest-eas-build tag
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
- name: Add latest-eas-build tag
run: |
VERSION=$(cat lerna.json | jq -r .version)
echo //registry.npmjs.org/:_authToken=$NODE_AUTH_TOKEN > ~/.npmrc
npm dist-tag add eas-cli@$VERSION latest-eas-build
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
release-changelog:
name: Update changelog and publish release
runs-on: ubuntu-latest
needs: publish-to-npm
permissions:
contents: write
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@v2
with:
token: ${{ secrets.EXPO_BOT_PAT }}
- uses: ./.github/actions/setup-mise
- uses: actions/setup-node@v2
with:
node-version: 22
- name: Install dependencies
working-directory: ./scripts
run: yarn install --immutable
run: yarn install --frozen-lockfile --check-files
- name: Resolve version
run: echo "EAS_CLI_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
- name: Update changelog
working-directory: ./scripts
run: yarn release-changelog $EAS_CLI_VERSION > /tmp/current-changelog
run: yarn --silent release-changelog $EAS_CLI_VERSION > /tmp/current-changelog
- name: Publish release
run: gh release edit v$EAS_CLI_VERSION --draft=false -F /tmp/current-changelog
env:
@@ -91,13 +221,13 @@ 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 }}
SLACK_ICON: https://github.com/expo-bot.png?size=48
SLACK_TITLE: "Released eas-cli@${{ env.EAS_CLI_VERSION }}"
SLACK_TITLE: 'Released eas-cli@${{ env.EAS_CLI_VERSION }}'
SLACK_MESSAGE: ${{ env.SLACK_CHANGELOG }}
SLACK_USERNAME: "EAS CLI CI"
SLACK_USERNAME: 'EAS CLI CI'
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
SLACK_FOOTER: ""
SLACK_FOOTER: ''
+23
View File
@@ -0,0 +1,23 @@
name: Size
on:
pull_request:
types: [synchronize, opened]
concurrency:
group: size-${{ github.ref }}
cancel-in-progress: true
jobs:
compare:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: 20
- uses: preactjs/compressed-size-action@v2
with:
build-script: 'build:tarballs:linux'
repo-token: '${{ secrets.GITHUB_TOKEN }}'
pattern: './packages/eas-cli/dist/*.tar.gz'
compression: 'none'
strip-hash: "v\\d+.\\d+.\\d+-\\w+-"
clean-script: 'clean'
+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
+6 -4
View File
@@ -17,9 +17,11 @@ jobs:
name: Ensure GraphQL schema and generated code is up-to-date
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2
- name: Setup tools
uses: ./.github/actions/setup-mise
- run: yarn install --immutable
- uses: actions/checkout@v2
- name: Setup node
uses: actions/setup-node@v2
with:
node-version: 22
- run: yarn install --frozen-lockfile
- run: yarn verify-graphql-code
working-directory: packages/eas-cli
+6 -4
View File
@@ -21,8 +21,10 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2
- name: Setup tools
uses: ./.github/actions/setup-mise
- run: yarn install --immutable
- uses: actions/checkout@v2
- name: Setup node
uses: actions/setup-node@v2
with:
node-version: 20
- run: yarn install --frozen-lockfile --check-files
- run: yarn typecheck
+14 -37
View File
@@ -21,57 +21,34 @@ jobs:
coverage: true
name: Test with Node ${{ matrix.node }}
steps:
- uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2
- name: Setup tools
uses: ./.github/actions/setup-mise
env:
MISE_NODE_VERSION: ${{ matrix.node }}
- name: Install dependencies
run: yarn install --immutable
env:
YARN_ENABLE_HARDENED_MODE: ${{ matrix.coverage == true && '1' || '0' }}
- uses: actions/checkout@v2
- name: Setup node
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node }}
- run: yarn install --frozen-lockfile --check-files
- run: yarn typecheck
- run: |
git config --global user.email "[email protected]"
git config --global user.name "Test"
- name: Fetch base commit for PR diffing
if: ${{ github.event_name == 'pull_request' }}
# Lerna needs the base commit to compute affected packages.
run: git fetch --no-tags --depth=1 origin ${{ github.event.pull_request.base.sha }}
- name: Run tests (affected)
# PRs: only run tests for changed packages + dependents.
if: ${{ !matrix.coverage && github.event_name == 'pull_request' }}
run: yarn run -T lerna run test --since ${{ github.event.pull_request.base.sha }} --include-dependents
- run: yarn test
if: ${{ !matrix.coverage }}
env:
NODE_OPTIONS: --max-old-space-size=8192
- name: Run tests (all)
# Pushes to main: run all package tests.
if: ${{ !matrix.coverage && github.event_name == 'push' }}
run: yarn run -T lerna run test
env:
NODE_OPTIONS: --max-old-space-size=8192
- name: Run tests (coverage)
# Coverage on PRs and main; per-package reports are uploaded to Codecov.
- run: yarn test --coverage
if: ${{ matrix.coverage }}
run: yarn run -T lerna run test -- --coverage
env:
# Oclif seems to cause OOM errors when testing with coverage enabled.
# Remove this whenever we solved those OOM errors.
NODE_OPTIONS: --max-old-space-size=8192
- run: yarn lint
- run: yarn fmt:check
- name: Install scripts dependencies
if: ${{ matrix.coverage }}
run: yarn install --immutable
- run: yarn lint --max-warnings=0
- run: yarn install --frozen-lockfile --check-files
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 }}
- if: ${{ matrix.coverage }}
run: yarn lint-changelog
- run: yarn lint-changelog
notify-slack:
runs-on: ubuntu-latest
@@ -79,7 +56,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:
+5 -3
View File
@@ -22,12 +22,14 @@ 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
token: ${{ secrets.EXPO_BOT_PAT }}
- uses: ./.github/actions/setup-mise
- uses: actions/setup-node@v4
with:
node-version: 22
- run: |
# Lerna will not detect changes correctly without having all tags
git fetch --depth=1 origin +refs/tags/*:refs/tags/*
@@ -35,7 +37,7 @@ jobs:
git checkout main
git config --global user.email "[email protected]"
git config --global user.name "Expo CI"
yarn install --immutable
yarn install --frozen-lockfile --check-files
yarn build
if [[ "$INPUT_VERSION" == "" ]]; then
echo "Releasing with default version"
@@ -0,0 +1,81 @@
name: Open a PR bumping eas-build modules
on: workflow_dispatch
jobs:
open-pr:
name: Open a PR bumping eas-build modules
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: main
token: ${{ secrets.EXPO_BOT_PAT }}
- name: Setup node
uses: actions/setup-node@v2
with:
node-version: 20
- run: yarn install --frozen-lockfile
- name: Configure git
run: |
git config --global user.email "[email protected]"
git config --global user.name "Expo Bot"
- name: Upgrade eas-build modules
run: |
for DIR in packages/eas-cli packages/eas-json; do
pushd $DIR >/dev/null 2>&1
# packages are ordered according to output of `lerna list --toposort`
# https://github.com/expo/turtle-v2/pull/568
for PACKAGE in "@expo/downloader" "@expo/eas-build-job" "@expo/logger" "@expo/steps" "@expo/turtle-spawn" "@expo/build-tools"; do
if grep -q $PACKAGE package.json; then
echo "[$DIR] yarn --exact add $PACKAGE"
yarn add --exact $PACKAGE
fi
done
popd >/dev/null 2>&1
done
- name: Set a branch name
id: bump-branch-name
run: |
HASH="$(echo $RANDOM | md5sum | head -c 10)"
BRANCH_NAME="@expo-bot/eas-build-bump-$HASH"
echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_OUTPUT
- name: Create a branch, commit, and push
run: |
BRANCH_NAME="${{ steps.bump-branch-name.outputs.BRANCH_NAME }}"
git checkout -b $BRANCH_NAME
git add .
git commit -m "Upgrade eas-build dependencies"
git push origin $BRANCH_NAME
- name: Open a PR to main
uses: repo-sync/pull-request@v2
id: open-pr
with:
source_branch: '${{ steps.bump-branch-name.outputs.BRANCH_NAME }}'
destination_branch: 'main'
pr_title: 'Upgrade eas-build dependencies'
pr_body: ':arrow_up: Upgrades [`eas-build`](https://github.com/expo/eas-build) dependencies.'
pr_reviewer: 'sjchmiela'
github_token: ${{ secrets.EXPO_BOT_PAT }}
- uses: actions/github-script@v3
with:
github-token: ${{ secrets.EXPO_BOT_PAT }}
script: |
github.issues.createComment({
issue_number: ${{ steps.open-pr.outputs.pr_number }},
owner: context.repo.owner,
repo: context.repo.repo,
body: "/changelog-entry chore Upgrade [`eas-build`](https://github.com/expo/eas-build) dependencies."
})
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- uses: ./.github/actions/setup-mise
- uses: volta-cli/action@5c175f92dea6f48441c436471e6479dbc192e194 # v4
- name: Create test app
run: npx -y create-expo-app@latest ${{ runner.temp }}/test-app --template tabs
+7 -12
View File
@@ -5,16 +5,13 @@ on:
branches: [main]
paths:
- "packages/worker/**"
- "packages/build-tools/**"
- "packages/eas-build-job/**"
- "packages/steps/**"
- "packages/logger/**"
- "packages/downloader/**"
- "packages/template-file/**"
- "packages/turtle-spawn/**"
- .github/workflows/worker.yml
- .github/workflows/build-and-deploy-worker.yml
- yarn.lock
pull_request:
paths:
- "packages/worker/**"
- .github/workflows/worker.yml
- .github/workflows/build-and-deploy-worker.yml
concurrency:
group: worker-${{ github.ref }}-${{ github.event_name }}
@@ -26,11 +23,9 @@ jobs:
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- uses: ./.github/actions/setup-mise
- uses: volta-cli/action@5c175f92dea6f48441c436471e6479dbc192e194 # v4
- run: yarn install --immutable
- run: yarn typecheck
- run: yarn install --frozen-lockfile --check-files
- name: Run unit tests
working-directory: packages/worker
-7
View File
@@ -3,24 +3,17 @@ packages/*/build
packages/eas-cli/dist
packages/eas-cli/tmp
scripts/build
dist
# TypeScript
**/*.tsbuildinfo
# Code editors
.idea
.history
.vscode
*.swp
*.swo
# macOS
.DS_Store
# Node
node_modules
**/.yarn/install-state.gz
*-debug.log
*-error.log
-22
View File
@@ -1,22 +0,0 @@
{
"printWidth": 100,
"tabWidth": 2,
"singleQuote": true,
"bracketSameLine": true,
"trailingComma": "es5",
"arrowParens": "avoid",
"endOfLine": "lf",
"ignorePatterns": [
".github",
"lerna.json",
"**/build/**",
"**/dist/**",
"**/coverage/**",
"**/fixtures/**",
"packages/eas-cli/README.md",
"packages/eas-cli/schema/**",
"packages/eas-cli/graphql.schema.json",
"packages/eas-cli/src/graphql/generated.ts",
"packages/build-tools/schema.graphql"
]
}
-77
View File
@@ -1,77 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"categories": {
"correctness": "off",
"suspicious": "off",
"pedantic": "off",
"perf": "off",
"restriction": "off",
"style": "off",
"nursery": "off"
},
"ignorePatterns": [
"**/build/**",
"**/dist/**",
"**/coverage/**",
"**/fixtures/**",
"**/__fixtures__/**",
".github/**",
"packages/eas-cli/schema/**",
"packages/eas-cli/graphql.schema.json",
"packages/build-tools/schema.graphql"
],
"plugins": ["import", "node", "typescript"],
"jsPlugins": [
{
"name": "async-protect",
"specifier": "eslint-plugin-async-protect"
}
],
"rules": {
"no-console": "warn",
"no-constant-condition": [
"warn",
{
"checkLoops": false
}
],
"sort-imports": [
"warn",
{
"ignoreDeclarationSort": true
}
],
"curly": "warn",
"default": "off",
"named": "off",
"namespace": "off",
"no-cycle": "error",
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "lodash",
"message": "Don't use lodash, it's heavy!"
}
]
}
],
"no-underscore-dangle": [
"error",
{
"allow": ["__typename"]
}
],
"async-protect/async-suffix": "error",
"node/no-sync": "error"
},
"overrides": [
{
"files": ["**/__tests__/**/*.ts", "**/__mocks__/**/*.ts"],
"rules": {
"async-protect/async-suffix": "off"
}
}
]
}
+9
View File
@@ -0,0 +1,9 @@
{
"printWidth": 100,
"tabWidth": 2,
"singleQuote": true,
"bracketSameLine": true,
"trailingComma": "es5",
"arrowParens": "avoid",
"endOfLine": "auto"
}
+3 -3
View File
@@ -1,10 +1,10 @@
{
"eslint.enable": true,
"search.exclude": {
"**/node_modules": true,
"**/bower_components": true,
"**/*.code-search": true,
"**/src/**/build/**": false,
"**/src/build/**": false,
},
"js/ts.tsdk.path": "node_modules/typescript/lib"
}
}
}
+1
View File
@@ -0,0 +1 @@
save-prefix ""
-9
View File
@@ -1,9 +0,0 @@
defaultSemverRangePrefix: ''
nodeLinker: node-modules
npmMinimalAgeGate: 2d
npmPreapprovedPackages:
- '@expo/*'
- 'eslint-plugin-universe'
- 'oxlint-config-universe'
-6
View File
@@ -1,6 +0,0 @@
# AGENTS.md
When working with this repository follow instructions from CLAUDE.md.
- [./CLAUDE.md](./CLAUDE.md)
- Before committing, run the formatter on modified files.
+1 -455
View File
@@ -8,465 +8,11 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- [eas-cli] Add diff output to `eas env:pull`. ([#4036](https://github.com/expo/eas-cli/pull/4036) by [@khamilowicz](https://github.com/khamilowicz))
- [eas-cli] Support drill-down from event lists in observe:session. ([#3987](https://github.com/expo/eas-cli/pull/3987) by [@douglowder](https://github.com/douglowder))
### 🐛 Bug fixes
### 🧹 Chores
## [21.0.2](https://github.com/expo/eas-cli/releases/tag/v21.0.2) - 2026-07-17
### 🐛 Bug fixes
- [eas-cli] Remove temporary working directories after `eas build:inspect` copies inspect output, avoiding leftover disk usage after failed builds. ([#3981](https://github.com/expo/eas-cli/pull/3981) by [@szdziedzic](https://github.com/szdziedzic))
### 🧹 Chores
- [eas-cli] Bump `tmp` to `0.2.7` to resolve [Dependabot alert 423](https://github.com/expo/eas-cli/security/dependabot/423). ([#3965](https://github.com/expo/eas-cli/pull/3965) by [@szdziedzic](https://github.com/szdziedzic))
- [worker] Bump `ws` to patched releases to resolve [Dependabot alert 462](https://github.com/expo/eas-cli/security/dependabot/462). ([#3963](https://github.com/expo/eas-cli/pull/3963) by [@szdziedzic](https://github.com/szdziedzic))
- Bump `lodash` and `lodash-es` to `4.18.1` across workspaces and upgrade GraphQL Code Generator to remove the remaining vulnerable transitive copy for Dependabot alert 354. ([#4009](https://github.com/expo/eas-cli/pull/4009) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-cli] Bump `sigstore` to `4.1.1` to resolve [Dependabot alert 475](https://github.com/expo/eas-cli/security/dependabot/475). ([#3961](https://github.com/expo/eas-cli/pull/3961) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-cli] Bump `axios` to patched releases to resolve [Dependabot alert 439](https://github.com/expo/eas-cli/security/dependabot/439). ([#3964](https://github.com/expo/eas-cli/pull/3964) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-cli] Bump `handlebars` to `4.7.9` to resolve [Dependabot alert 334](https://github.com/expo/eas-cli/security/dependabot/334). ([#3958](https://github.com/expo/eas-cli/pull/3958) by [@szdziedzic](https://github.com/szdziedzic))
## [21.0.1](https://github.com/expo/eas-cli/releases/tag/v21.0.1) - 2026-07-15
### 🎉 New features
- [eas-cli] Add `eas project:delete` command to delete a project — the current directory's project by default, or any project passed as an argument (`@account/slug` or ID). Confirmation requires the project's full name, typed at the interactive prompt or passed via `--dangerously-confirm-deletion` (required in non-interactive mode). Deletion requires sudo mode: the command prompts for your password (and OTP) to upgrade the session when the server requires it. Deletion was previously only possible from the website. ([#4012](https://github.com/expo/eas-cli/pull/4012) by [@brentvatne](https://github.com/brentvatne))
- [eas-cli] Add `eas browse` to open the project page on expo.dev in a web browser, with an optional page argument (e.g. `eas browse build`, `eas browse hosting`) for project subpages and a `--no-browser` flag to print the URL. `eas open` is now an alias of `eas browse`. ([#4003](https://github.com/expo/eas-cli/pull/4003) by [@jonsamp](https://github.com/jonsamp))
- [eas-cli] Add `--dev-domain` flag to `eas deploy` to choose the project's preview URL (`<name>.expo.app`) without prompting, allowing first-time deploys with `--non-interactive`. ([#4004](https://github.com/expo/eas-cli/pull/4004) by [@brentvatne](https://github.com/brentvatne))
- [eas-cli] Add `-p` as an alias for `--platform` in `eas simulator:start`. ([#3902](https://github.com/expo/eas-cli/pull/3902) by [@brentvatne](https://github.com/brentvatne))
### 🧹 Chores
- [eas-cli][build-tools][local-build-plugin][worker] Bump `tar` to `7.5.19`. ([#3971](https://github.com/expo/eas-cli/pull/3971) by [@szdziedzic](https://github.com/szdziedzic))
## [21.0.0](https://github.com/expo/eas-cli/releases/tag/v21.0.0) - 2026-07-13
### 🛠 Breaking changes
- [eas-cli] Remove `eas onboarding` command (aliases `init:onboarding`, `project:onboarding`). ([#3993](https://github.com/expo/eas-cli/pull/3993) by [@byronkarlen](https://github.com/byronkarlen))
### 🎉 New features
- [eas-cli] Improve `eas workflow:create`: add a `--template` flag, generate a placeholder workflow when a file name is passed, use shorter default file names (`build.yml`, `update.yml`, `deploy.yml`), configure EAS Build and EAS Update automatically when the chosen template requires them, set default app identifiers without prompting for the development build and deploy templates, install `expo-dev-client` during development build setup, and tighten the generated comments and next steps. ([#3943](https://github.com/expo/eas-cli/pull/3943) by [@jonsamp](https://github.com/jonsamp))
- [eas-cli] `eas integrations:posthog:dashboard` now opens PostHog via a signed-in link, skipping the login prompt. ([#3975](https://github.com/expo/eas-cli/pull/3975) by [@gwdp](https://github.com/gwdp))
- [build-tools] Add `eas/posthog_capture_event` workflow function to send PostHog events from workflow runs. ([#3934](https://github.com/expo/eas-cli/pull/3934) by [@gwdp](https://github.com/gwdp))
- [build-tools] Add `eas/posthog_flag_rollout` workflow function to update PostHog feature flags from workflow runs. ([#3944](https://github.com/expo/eas-cli/pull/3944) by [@gwdp](https://github.com/gwdp))
- [build-tools] Add `eas/posthog_wait_for_metric` workflow function to gate workflow runs on a PostHog metric. ([#3945](https://github.com/expo/eas-cli/pull/3945) by [@gwdp](https://github.com/gwdp))
- [build-tools] Add `eas/posthog_upload_sourcemaps` workflow function to upload source maps to PostHog. ([#3947](https://github.com/expo/eas-cli/pull/3947) by [@gwdp](https://github.com/gwdp))
- [build-tools] Add `eas/posthog_annotation` workflow function to create PostHog annotations from workflow runs. ([#3948](https://github.com/expo/eas-cli/pull/3948) by [@gwdp](https://github.com/gwdp))
- [build-tools] Add `eas/posthog_wait_for_query` workflow function to gate workflow runs on a PostHog query. ([#3949](https://github.com/expo/eas-cli/pull/3949) by [@gwdp](https://github.com/gwdp))
### 🐛 Bug fixes
- [eas-cli] Rethrow Apple errors during capability sync instead of silently succeeding. ([#3989](https://github.com/expo/eas-cli/pull/3989) by [@douglowder](https://github.com/douglowder))
- [build-tools] Skip embedded bundle upload for development client builds instead of warning that the bundle is missing. ([#3940](https://github.com/expo/eas-cli/pull/3940) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Accept a navigation metric name in observe:metrics when passed in on the command line. ([#3973](https://github.com/expo/eas-cli/pull/3973) by [@douglowder](https://github.com/douglowder))
- [eas-cli] Retry uploading assets that don't finish processing during `eas update`, instead of failing the update. ([#3918](https://github.com/expo/eas-cli/pull/3918) by [@gwdp](https://github.com/gwdp))
### 🧹 Chores
- Upgrade Expo plist dependencies to use `@xmldom/xmldom` `0.8.13` without a root resolution, addressing Dependabot alert 372. ([#4010](https://github.com/expo/eas-cli/pull/4010) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-cli] add agent detection to analytics events. ([#3983](https://github.com/expo/eas-cli/pull/3983) by [@davidmokos](https://github.com/davidmokos))
- Bump `glob` 10.x to a patched version in the root lockfile. ([#3979](https://github.com/expo/eas-cli/pull/3979) by [@szdziedzic](https://github.com/szdziedzic))
- [build-tools] Bump `jws` to a patched version in the root lockfile. ([#3978](https://github.com/expo/eas-cli/pull/3978) by [@szdziedzic](https://github.com/szdziedzic))
- [worker] Bump `koa` to `3.1.2`. ([#3974](https://github.com/expo/eas-cli/pull/3974) by [@szdziedzic](https://github.com/szdziedzic))
- [steps] Bump `cross-spawn` to a patched version in the TypeScript custom function fixture. ([#3977](https://github.com/expo/eas-cli/pull/3977) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-cli] Bump `node-forge` to `1.4.0`. ([#3970](https://github.com/expo/eas-cli/pull/3970) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-cli] Bump `fast-xml-builder` to `1.2.1` to resolve [Dependabot alert 400](https://github.com/expo/eas-cli/security/dependabot/400). ([#3966](https://github.com/expo/eas-cli/pull/3966) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-cli] Bump `form-data` to patched releases to resolve [Dependabot alert 465](https://github.com/expo/eas-cli/security/dependabot/465). ([#3962](https://github.com/expo/eas-cli/pull/3962) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-cli] Bump `fast-xml-parser` to `4.5.7` to resolve [Dependabot alert 286](https://github.com/expo/eas-cli/security/dependabot/286). ([#3960](https://github.com/expo/eas-cli/pull/3960) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-cli] Bump `shell-quote` to `1.9.0` to resolve [Dependabot alert 440](https://github.com/expo/eas-cli/security/dependabot/440). ([#3959](https://github.com/expo/eas-cli/pull/3959) by [@szdziedzic](https://github.com/szdziedzic))
## [20.5.1](https://github.com/expo/eas-cli/releases/tag/v20.5.1) - 2026-07-01
### 🐛 Bug fixes
- [eas-cli] Fix Free plan users being warned they had reached their build limit while still well below it. Overage warnings are now shown only for paid plans with an actual billable overage cost. ([#3882](https://github.com/expo/eas-cli/pull/3882) by [@sarahlane8](https://github.com/sarahlane8))
- [eas-cli] Count only genuine per-worker build overages in the pre-build usage warning, so the reported number of builds beyond included credits is accurate. ([#3932](https://github.com/expo/eas-cli/pull/3932) by [@sarahlane8](https://github.com/sarahlane8))
- [eas-cli] Select correct tvOS build target for non-interactive Apple builds when EXPO_TV env variable is set. ([#3907](https://github.com/expo/eas-cli/pull/3907) by [@douglowder](https://github.com/douglowder))
### 🧹 Chores
- [eas-cli] Bump `@expo/apple-utils` to `2.1.22`. ([#3913](https://github.com/expo/eas-cli/pull/3913) by [@sjchmiela](https://github.com/sjchmiela))
## [20.5.0](https://github.com/expo/eas-cli/releases/tag/v20.5.0) - 2026-06-29
### 🎉 New features
- [eas-cli] `eas integrations:posthog:connect` now supports existing PostHog user connection. ([#3895](https://github.com/expo/eas-cli/pull/3895) by [@gwdp](https://github.com/gwdp))
## [20.4.0](https://github.com/expo/eas-cli/releases/tag/v20.4.0) - 2026-06-25
### 🎉 New features
- [eas-cli] New command `observe:session` for inspecting events by session ID. ([#3868](https://github.com/expo/eas-cli/pull/3868) by [@douglowder](https://github.com/douglowder))
- [eas-cli] Add tiered build-credit usage warnings (approaching/at/over) before each build, lowering the warning threshold to 80% to match email notifications. ([#3882](https://github.com/expo/eas-cli/pull/3882) by [@sarahlane8](https://github.com/sarahlane8))
### 🐛 Bug fixes
- [eas-cli] Fix `eas update --channel` failing with "Channel has no branches associated with it" when the channel was auto-created by a build. A branch with the channel's name is now created and linked, matching the behavior for a channel that doesn't exist yet. ([#3891](https://github.com/expo/eas-cli/pull/3891) by [@gwdp](https://github.com/gwdp))
## [20.3.0](https://github.com/expo/eas-cli/releases/tag/v20.3.0) - 2026-06-18
### 🎉 New features
- [eas-cli] Add `eas account:audit [ACCOUNT_NAME]` command to view an account's audit logs. ([#3863](https://github.com/expo/eas-cli/pull/3863) by [@keith-kurak](https://github.com/keith-kurak))
- [eas-cli] `eas update:view [GROUP_ID]` now also accepts a platform-specific update ID, resolving it to and displaying its update group. ([#3864](https://github.com/expo/eas-cli/pull/3864) by [@keith-kurak](https://github.com/keith-kurak))
- [eas-cli] Add `eas integrations:posthog:connect` command. ([#3836](https://github.com/expo/eas-cli/pull/3836) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas integrations:posthog:dashboard` command. ([#3837](https://github.com/expo/eas-cli/pull/3837) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas integrations:posthog:disconnect` command. ([#3838](https://github.com/expo/eas-cli/pull/3838) by [@gwdp](https://github.com/gwdp))
### 🐛 Bug fixes
- [eas-cli] `eas integrations:posthog:connect` no longer aborts when `npx expo install` reports an error (for example, on a dynamic app config); it finishes writing the environment variables and prints any remaining manual steps. ([#3877](https://github.com/expo/eas-cli/pull/3877) by [@gwdp](https://github.com/gwdp))
### 🧹 Chores
- [eas-cli] Sync new app agent templates from `expo/llm-configs` before publishing. ([#3865](https://github.com/expo/eas-cli/pull/3865) by [@davidmokos](https://github.com/davidmokos))
- [eas-cli] Simplify 2FA now that SMS is no longer supported. ([#3859](https://github.com/expo/eas-cli/pull/3859) by [@wschurman](https://github.com/wschurman))
## [20.2.0](https://github.com/expo/eas-cli/releases/tag/v20.2.0) - 2026-06-15
### 🎉 New features
- [eas-cli] Non-interactive iOS App Store and Enterprise builds can now use the App Store Connect API key stored in EAS credentials as a submission key to validate and repair provisioning profiles on Apple servers, without requiring `EXPO_ASC_*` environment variables or an interactive Apple login. ([#3805](https://github.com/expo/eas-cli/pull/3805) by [@sswrk](https://github.com/sswrk))
- [eas-cli] Add support for non-interactive `eas update:rollback [GROUP_ID]`. The given update group must be the latest for its branch and runtime version; the previous update group is republished, or a roll back to the embedded update is published if there is none. ([#3825](https://github.com/expo/eas-cli/pull/3825) by [@quinlanj](https://github.com/quinlanj))
### 🐛 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))
- [eas-cli] Fix `update:embedded` topic showing the `update:embedded:delete` subcommand description in the command listing. ([#3866](https://github.com/expo/eas-cli/pull/3866) by [@jc-expo](https://github.com/jc-expo))
## [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
- [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))
## [18.11.0](https://github.com/expo/eas-cli/releases/tag/v18.11.0) - 2026-05-05
### 🎉 New features
- [eas-cli] Add `--simulator` to `eas build:dev` and `eas build:run` to select which iOS simulator to install and run builds on. ([#3637](https://github.com/expo/eas-cli/pull/3637) by [@mmichels-brex](https://github.com/mmichels-brex))
### 🐛 Bug fixes
- [eas-cli] Fix Convex team invite output after skipped or unnecessary invitations. ([#3672](https://github.com/expo/eas-cli/pull/3672) by [@fiberjw](https://github.com/fiberjw))
- Make directories marked as read-only writable when creating a tar, to prevent the cross-filesystem issues when extracting the tar. ([#3489](https://github.com/expo/eas-cli/pull/3489) by [@sswrk](https://github.com/sswrk))
## [18.10.0](https://github.com/expo/eas-cli/releases/tag/v18.10.0) - 2026-05-04
### 🎉 New features
- [eas-cli] Add `eas integrations:convex` commands to manage Convex integrations for EAS projects. ([#3575](https://github.com/expo/eas-cli/pull/3575) by [@fiberjw](https://github.com/fiberjw))
- [eas-cli] New command `observe:logs` for custom events. ([#3638](https://github.com/expo/eas-cli/pull/3638) by [@douglowder](https://github.com/douglowder))
### 🐛 Bug fixes
- [eas-cli] Create portable project archives on all platforms to normalize cross-platform tar metadata and permissions. ([#3234](https://github.com/expo/eas-cli/pull/3234) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-cli] Remove hardcoded `builderEnvironment.image` override in `eas build:resign`. ([#3661](https://github.com/expo/eas-cli/pull/3661) by [@hSATAC](https://github.com/hSATAC))
- [eas-cli] Fix `eas update --json` intermittently failing with JSON parse errors during "Computing project fingerprints" by passing `silent: true` to `@expo/fingerprint` to suppress subprocess stdout pollution. ([#3659](https://github.com/expo/eas-cli/pull/3659) by [@Mookiies](https://github.com/Mookiies))
## [18.9.1](https://github.com/expo/eas-cli/releases/tag/v18.9.1) - 2026-04-30
## [18.9.0](https://github.com/expo/eas-cli/releases/tag/v18.9.0) - 2026-04-30
### 🎉 New features
- [eas-cli] Add `eas integrations:asc` commands to manage App Store Connect integrations for EAS projects. ([#3558](https://github.com/expo/eas-cli/pull/3558) by [@sswrk](https://github.com/sswrk))
- [eas-cli] Allow `eas build:download` to accept a build ID. ([#3655](https://github.com/expo/eas-cli/pull/3655) by [@douglowder](https://github.com/douglowder))
- [eas-cli] Add `--id` alias for `--build-id` flag in `eas build:download`. ([#3656](https://github.com/expo/eas-cli/pull/3656) by [@brentvatne](https://github.com/brentvatne))
### 🐛 Bug fixes
- [steps] Fix workflow job context interpolation when a string contains multiple `${{ }}` expressions on the same line (for example a changelog built from two context values). ([#3644](https://github.com/expo/eas-cli/pull/3644) by [@gwdp](https://github.com/gwdp))
## [18.8.1](https://github.com/expo/eas-cli/releases/tag/v18.8.1) - 2026-04-23
## [18.8.0](https://github.com/expo/eas-cli/releases/tag/v18.8.0) - 2026-04-22
### 🎉 New features
- [eas-cli] Support custom params in `observe:events` results. ([#3620](https://github.com/expo/eas-cli/pull/3620) by [@douglowder](https://github.com/douglowder))
- [build-tools] Add `eas/deploy` function for EAS Hosting web deployments. ([#3598](https://github.com/expo/eas-cli/pull/3598) by [@gwdp](https://github.com/gwdp))
- [build-tools] Add `eas/export` function for Expo web exports. ([#3598](https://github.com/expo/eas-cli/pull/3598) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas update:insights <groupId>` command to display launch, crash, unique-user, and payload-size metrics for an update group. ([#3614](https://github.com/expo/eas-cli/pull/3614) by [@kadikraman](https://github.com/kadikraman))
- [eas-cli] Add `--insights` flag to `eas update:view <groupId>` to append the same metrics below the existing output. ([#3614](https://github.com/expo/eas-cli/pull/3614) by [@kadikraman](https://github.com/kadikraman))
- [eas-cli] Add `eas channel:insights --channel <name> --runtime-version <version>` command to display adoption and crash metrics for a channel + runtime version. ([#3614](https://github.com/expo/eas-cli/pull/3614) by [@kadikraman](https://github.com/kadikraman))
### 🐛 Bug fixes
- [eas-cli] Fix `metadata:push` not deleting video previews from App Store Connect when removed from config. ([#3603](https://github.com/expo/eas-cli/pull/3603) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] Warn and skip unknown preview types in `metadata:push` with a helpful suggestion when the `APP_` screenshot prefix is mistakenly used. ([#3603](https://github.com/expo/eas-cli/pull/3603) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] Delete all existing previews in a preview set before uploading to avoid Apple's "Too many app previews" error. ([#3603](https://github.com/expo/eas-cli/pull/3603) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] Fixes for `observe` commands, including an issue for apps with many update IDs. ([#3609](https://github.com/expo/eas-cli/pull/3609) by [@douglowder](https://github.com/douglowder))
- [eas-cli] Add existing capability identifiers. ([#3615](https://github.com/expo/eas-cli/pull/3615) by [@jakex7](https://github.com/jakex7))
## [18.7.0](https://github.com/expo/eas-cli/releases/tag/v18.7.0) - 2026-04-14
### 🎉 New features
- [build-tools] Add `eas/read_package_json` and `eas/read_app_config` functions ([#3585](https://github.com/expo/eas-cli/pull/3585) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `--skip-bundler` to `eas build:dev` to install and run a development build without starting the local Expo/Metro server. ([#3604](https://github.com/expo/eas-cli/pull/3604) by [@mmichels-brex](https://github.com/mmichels-brex))
## [18.6.0](https://github.com/expo/eas-cli/releases/tag/v18.6.0) - 2026-04-10
### 🎉 New features
- [eas-cli] Add missing Apple metadata attributes for age ratings and content descriptions. ([#3584](https://github.com/expo/eas-cli/pull/3584) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] Add App Clip metadata support to `metadata:push` and `metadata:pull` (default experience action, per-locale subtitle and header image, App Store review invocation URLs). ([#3590](https://github.com/expo/eas-cli/pull/3590) by [@EvanBacon](https://github.com/EvanBacon))
- [build-tools] Add `working_directory` input to `eas/build` function group for custom builds. ([#3582](https://github.com/expo/eas-cli/pull/3582) by [@szdziedzic](https://github.com/szdziedzic))
### 🐛 Bug fixes
- [steps] Coerce numeric env values to strings in workflow step configuration. ([#3583](https://github.com/expo/eas-cli/pull/3583) by [@szdziedzic](https://github.com/szdziedzic))
- [build-tools][eas-cli] Detect iOS Development provisioning profiles and set correct code signing identity instead of treating them as Ad Hoc. ([#3496](https://github.com/expo/eas-cli/pull/3496) by [@qwertey6](https://github.com/qwertey6))
- [build-tools] Prevent detecting Yarn Modern as Classic based on lockfile ([#3572](https://github.com/expo/eas-cli/pull/3572) by [@kitten](https://github.com/kitten))
- [build-tools] Early stop `eas/start_android_emulator` when Android emulator host setup is invalid. ([#3580](https://github.com/expo/eas-cli/pull/3580) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Bump `@expo/apple-utils` to `2.1.18` to fix `metadata:push` failing on `ageRatingDeclarations` due to the removed `gamblingAndContests` attribute. ([#3588](https://github.com/expo/eas-cli/pull/3588) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] Bump `@expo/apple-utils` to `2.1.19` to fix image and video uploads via `metadata:push` getting stuck in `AWAITING_UPLOAD` state. The asset client was inheriting Bearer token injection from the App Store Connect API client, which caused S3 presigned URL uploads to be silently mishandled by Apple's CDN. Fixes screenshots, previews, and App Clip header image uploads. ([#3590](https://github.com/expo/eas-cli/pull/3590) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] `metadata:pull` now preserves screenshot, video preview, and App Clip header image entries with placeholder paths when the asset is in an unrendered state, so users can recover broken records by replacing the file or removing the entry instead of having entries silently dropped from `store.config.json`. ([#3590](https://github.com/expo/eas-cli/pull/3590) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] Surface hosting deployment's asset upload errors sooner ([#3600](https://github.com/expo/eas-cli/pull/3600) by [@kitten](https://github.com/kitten))
## [18.5.0](https://github.com/expo/eas-cli/releases/tag/v18.5.0) - 2026-04-02
### 🎉 New features
- [eas-cli] Add screenshots and previews support to `metadata:push` and `metadata:pull`. ([#3301](https://github.com/expo/eas-cli/pull/3301) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] Add `--non-interactive` flag to `metadata:push` and `metadata:pull` commands with ASC API Key auth support. ([#3548](https://github.com/expo/eas-cli/pull/3548) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] Add `observe:metrics`, `observe:events`, and `observe:versions` commands. ([#3401](https://github.com/expo/eas-cli/pull/3401) by [@ubax](https://github.com/ubax)), [@douglowder](https://github.com/douglowder)
### 🐛 Bug fixes
- [eas-cli] Fix workflow:logs for builds using built-in EAS build steps. ([#3523](https://github.com/expo/eas-cli/pull/3523) by [@douglowder](https://github.com/douglowder))
- [build-tools][worker] Read Expo app config with `expo config` CLI invocation before falling back to `@expo/config` ([#3536](https://github.com/expo/eas-cli/pull/3536) by [@kitten](https://github.com/kitten))
- Fix `hasIgnoredIosProjectAsync()` always returning `false` for ignored iOS projects. ([#3562](https://github.com/expo/eas-cli/pull/3562) by [@sjchmiela](https://github.com/sjchmiela))
### 🧹 Chores
- Bump apple-utils. ([#3545](https://github.com/expo/eas-cli/pull/3545) by [@quinlanj](https://github.com/quinlanj))
- [eas-cli][build-tools][worker] Bump `@expo/config` and `@expo/config-plugins`. ([#3527](https://github.com/expo/eas-cli/pull/3527) by [@quinlanj](https://github.com/quinlanj))
## [18.4.0](https://github.com/expo/eas-cli/releases/tag/v18.4.0) - 2026-03-16
### 🎉 New features
- Auto-detect non-interactive mode in CI and non-TTY environments. ([#3486](https://github.com/expo/eas-cli/pull/3486) by [@EvanBacon](https://github.com/EvanBacon))
- Add `skip-build-if-not-found` flag to `eas build:dev`. ([#3495](https://github.com/expo/eas-cli/pull/3495) by [@mmichels-brex](https://github.com/mmichels-brex))
## [18.3.0](https://github.com/expo/eas-cli/releases/tag/v18.3.0) - 2026-03-10
### 🎉 New features
- Show user email in `eas account:view` output. ([#3485](https://github.com/expo/eas-cli/pull/3485) by [@brentvatne](https://github.com/brentvatne))
## [18.2.0](https://github.com/expo/eas-cli/releases/tag/v18.2.0) - 2026-03-09
### 🎉 New features
- Auto-enable `--non-interactive` when `--json` is passed, so users no longer need to specify both flags. ([#3476](https://github.com/expo/eas-cli/pull/3476) by [@krystofwoldrich](https://github.com/krystofwoldrich))
- Auto-generate Android keystore in non-interactive mode instead of throwing an error. ([#3477](https://github.com/expo/eas-cli/pull/3477) by [@EvanBacon](https://github.com/EvanBacon))
- Add `--source-maps` flag to prevent uploading server sourcemaps ([#3478](https://github.com/expo/eas-cli/pull/3478) by [@hassankhan](https://github.com/hassankhan))
### 🐛 Bug fixes
- Check all certificates in provisioning profile during verification instead of only the first one. ([#3484](https://github.com/expo/eas-cli/pull/3484) by [@qwertey6](https://github.com/qwertey6))
- Provide an override for the new --environment flag requirement in the update command. ([#3442](https://github.com/expo/eas-cli/pull/3442) by [@douglowder](https://github.com/douglowder))
- Add missing `--include=dev` for `npm install` commands ([#3459](https://github.com/expo/eas-cli/pull/3459) by [@kitten](https://github.com/kitten))
- Add missing `--production false` for `yarn install` commands for Yarn Classic ([#3459](https://github.com/expo/eas-cli/pull/3459) by [@kitten](https://github.com/kitten))
### 🧹 Chores
- Upgrade `oclif` to v4. ([#3472](https://github.com/expo/eas-cli/pull/3472) by [@sjchmiela](https://github.com/sjchmiela))
## [18.1.0](https://github.com/expo/eas-cli/releases/tag/v18.1.0) - 2026-03-04
### 🎉 New features
- Support YAML anchors and aliases when parsing workflow files. ([#3435](https://github.com/expo/eas-cli/pull/3435) by [@sjchmiela](https://github.com/sjchmiela))
### 🐛 Bug fixes
- Remove --environment flag requirement for update:configure. ([#3440](https://github.com/expo/eas-cli/pull/3440) by [@douglowder](https://github.com/douglowder))
- Fix login spinner interfering with prompts in `eas go` command when not logged in. ([#3451](https://github.com/expo/eas-cli/pull/3451) by [@byronkarlen](https://github.com/byronkarlen))
## [18.0.6](https://github.com/expo/eas-cli/releases/tag/v18.0.6) - 2026-02-27
## [18.0.5](https://github.com/expo/eas-cli/releases/tag/v18.0.5) - 2026-02-25
### 🛠 Breaking changes
- Require --environment flag for update commands when SDK >= 55. ([#3398](https://github.com/expo/eas-cli/pull/3398) by [@douglowder](https://github.com/douglowder))
## [18.0.4](https://github.com/expo/eas-cli/releases/tag/v18.0.4) - 2026-02-23
### 🐛 Bug fixes
- Revert authorization code flow with PKCE for browser-based login. ([#3422](https://github.com/expo/eas-cli/pull/3422) by [@byronkarlen](https://github.com/byronkarlen))
## [18.0.3](https://github.com/expo/eas-cli/releases/tag/v18.0.3) - 2026-02-20
### 🎉 New features
- Use authorization code flow with PKCE for browser-based login. ([#3398](https://github.com/expo/eas-cli/pull/3398) by [@byronkarlen](https://github.com/byronkarlen))
## [18.0.1](https://github.com/expo/eas-cli/releases/tag/v18.0.1) - 2026-02-12
### 🛠 Breaking changes
- Drop support for Node 18 and Node 19. Require Node 20+. ([#3343](https://github.com/expo/eas-cli/pull/3343) by [@sjchmiela](https://github.com/sjchmiela))
### 🎉 New features
- Add `--browser` flag to `eas login` for browser-based authentication. ([#3312](https://github.com/expo/eas-cli/pull/3312) by [@byronkarlen](https://github.com/byronkarlen))
### 🐛 Bug fixes
- Hide progress bar in build credits warning when usage reaches 100%. ([#3371](https://github.com/expo/eas-cli/pull/3371) by [@mackenco](https://github.com/mackenco))
### 🧹 Chores
- Upgrade `tar` to v7. ([#3327](https://github.com/expo/eas-cli/pull/3327) by [@KarolRzeminski](https://github.com/KarolRzeminski))
## [16.32.0](https://github.com/expo/eas-cli/releases/tag/v16.32.0) - 2026-01-30
### 🎉 New features
- List available options in non-interactive mode errors instead of generic "unable to select" messages, making CLI usable by agents and scripts. ([#3359](https://github.com/expo/eas-cli/pull/3359) by [@EvanBacon](https://github.com/EvanBacon))
- Hidden beta version of new `eas account:usage` command for viewing account usage and billing estimates. ([#3334](https://github.com/expo/eas-cli/pull/3334) by [@douglowder](https://github.com/douglowder), [@EvanBacon](https://github.com/EvanBacon))
- Add `--no-bytecode` and `--source-maps [mode]` flags to `eas update`. ([#3339](https://github.com/expo/eas-cli/pull/3339) by [@brentvatne](https://github.com/brentvatne))
- Add App Clip bundle identifier registration support for multi-target iOS builds. ([#3300](https://github.com/expo/eas-cli/pull/3300) by [@evanbacon](https://github.com/evanbacon))
- Warn in `eas build` when creating a production build from an app that uses Expo Go for development ([#3073](https://github.com/expo/eas-cli/pull/3073) by [@vonovak](https://github.com/vonovak))
- Add `--runtime-version` and `--platform` filters to `eas update:list`. ([#3261](https://github.com/expo/eas-cli/pull/3261) by [@HarelSultan](https://github.com/HarelSultan))
### 🐛 Bug fixes
- Fix `--source-maps` flag compatibility with older SDKs that only support it as a boolean flag. ([#3341](https://github.com/expo/eas-cli/pull/3341) by [@brentvatne](https://github.com/brentvatne))
- Use `--dump-sourcemaps` as fallback when `--source-maps` is not provided to `eas update`, for backwards compatibility. ([8cc324e1](https://github.com/expo/eas-cli/commit/8cc324e1) by [@brentvatne](https://github.com/brentvatne))
- Fix `metadata:pull` failing for apps with only a live version by falling back to live app version and info. ([#3299](https://github.com/expo/eas-cli/pull/3299) by [@EvanBacon](https://github.com/EvanBacon))
- eas init should fix and validate project name and slug. ([#3277](https://github.com/expo/eas-cli/pull/3277) by [@douglowder](https://github.com/douglowder))
@@ -474,7 +20,6 @@ This is the log of notable changes to EAS CLI and related packages.
- Test with node 24. ([#3270](https://github.com/expo/eas-cli/pull/3270) by [@wschurman](https://github.com/wschurman))
- Delete channel in background. ([#3278](https://github.com/expo/eas-cli/pull/3278) by [@quinlanj](https://github.com/quinlanj))
- Add warning to channel:delete. ([#3335](https://github.com/expo/eas-cli/pull/3335) by [@quinlanj](https://github.com/quinlanj))
## [16.28.0](https://github.com/expo/eas-cli/releases/tag/v16.28.0) - 2025-11-20
@@ -2039,6 +1584,7 @@ This is the log of notable changes to EAS CLI and related packages.
- Add rollback disambiguation command. ([#2004](https://github.com/expo/eas-cli/pull/2004) by [@wschurman](https://github.com/wschurman))
- Detect devices that fail to be provisioned, list them to the user and show the explanation message with the link to the devices page to check actual status. ([#2011](https://github.com/expo/eas-cli/pull/2011) by [@radoslawkrzemien](https://github.com/radoslawkrzemien))
- Add info to EAS Update asset upload process about asset counts and limits. ([#2013](https://github.com/expo/eas-cli/pull/2013) by [@wschurman](https://github.com/wschurman))
- .nvmrc support for setting node version. ([#1954](https://github.com/expo/eas-cli/pull/1954) by [@khamilowicz](https://github.com/khamilowicz))
### 🐛 Bug fixes
+19 -204
View File
@@ -4,77 +4,30 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Monorepo Structure
This is a **Lerna-based monorepo** containing the EAS CLI and all supporting build libraries.
### CLI & Configuration Packages
This is a **Lerna-based monorepo** with four packages:
1. **[packages/eas-cli](./packages/eas-cli/CLAUDE.md)** - The main CLI tool (published as `eas-cli`)
- 95+ commands organized by domain (build, submit, update, channel, credentials, etc.)
- Built on oclif (Open CLI Framework)
- Entry point: `packages/eas-cli/bin/run`
2. **[packages/eas-json](./packages/eas-json/CLAUDE.md)** - EAS configuration parser (published as `@expo/eas-json`)
- Validates `eas.json` configuration files using Joi schemas
- Manages build, submit, and deploy profiles
- Supports JSON5 via golden-fleece
3. **[packages/eas-build-cache-provider](./packages/eas-build-cache-provider/CLAUDE.md)** - Build cache plugin (published as `eas-build-cache-provider`)
- Optimizes build caching for Expo CLI
### Build Execution Packages
4. **[packages/eas-build-job](./packages/eas-build-job)** - Build job definitions (published as `@expo/eas-build-job`)
- Defines all data structures for build operations (Job, BuildPhase, BuildMode, Platform, Workflow)
- Provides type definitions and validation schemas (Zod, Joi)
- Contains BuildPhase enum that defines the traditional build pipeline stages
- Key exports: `Job`, `BuildPhase`, `BuildMode`, `BuildTrigger`, `Workflow`, `ArchiveSource`
- **Foundation that all other build packages depend on**
5. **[packages/build-tools](./packages/build-tools)** - Build execution engine (published as `@expo/build-tools`)
- Orchestrates all build operations through `BuildContext<T extends Job>`
- Contains platform-specific builders: `androidBuilder()`, `iosBuilder()`, `runCustomBuildAsync()`
- Manages build phases, artifact uploading, caching, credentials
- Provides functions for custom builds
- Integrates with GraphQL API
6. **[packages/steps](./packages/steps)** - Custom build workflow engine (published as `@expo/steps`)
- Framework for defining and executing custom build steps
- Key abstractions:
- `BuildWorkflow`: Orchestrates sequential step execution
- `BuildStep`: Individual executable unit with inputs/outputs
- `BuildStepGlobalContext`: Manages shared state and interpolation
- `BuildStepContext`: Per-step execution context
- Supports conditional execution with `if` expressions (using jsep)
- Template interpolation: `${{ steps.step-id.outputs.outputName }}`
- Parses build configs from YAML/JSON
7. **[packages/local-build-plugin](./packages/local-build-plugin)** - Local build execution (published as `eas-cli-local-build-plugin`)
- Allows running EAS builds locally on developer machines
- Entry point: `packages/local-build-plugin/src/main.ts`
- Sets `EAS_BUILD_RUNNER=local-build-plugin` environment variable
- Reuses all build-tools logic for consistency with cloud builds
8. **[packages/worker](./packages/worker/CLAUDE.md)** - Turtle Worker service (private, not published)
4. **[packages/worker](./packages/worker/CLAUDE.md)** - Turtle Worker service (private, not published)
- Runs on EAS build VMs/pods
- WebSocket server for communication with Turtle Launcher
- Wraps `@expo/build-tools` to execute actual React Native builds
### Supporting Packages
9. **[packages/logger](./packages/logger)** - Bunyan-based structured logging (published as `@expo/logger`)
- Used by all build packages
10. **[packages/downloader](./packages/downloader)** - HTTP file downloading with retry logic (published as `@expo/downloader`)
11. **[packages/turtle-spawn](./packages/turtle-spawn)** - Child process spawning with error handling (published as `@expo/turtle-spawn`)
12. **[packages/template-file](./packages/template-file)** - Lodash-based template string interpolation (published as `@expo/template-file`)
13. **[packages/create-eas-build-function](./packages/create-eas-build-function)** - CLI scaffolding tool for custom build functions (published as `create-eas-build-function`)
14. **[packages/expo-cocoapods-proxy](./packages/expo-cocoapods-proxy)** - Ruby gem for CocoaPods proxy (published as `expo-cocoapods-proxy` gem)
> **Note**: Some packages have their own CLAUDE.md with package-specific guidance. Click the links above to view them.
> **Note**: Each package has its own CLAUDE.md with package-specific guidance. Click the links above to view them.
## Common Development Commands
@@ -98,26 +51,20 @@ yarn start-allow-unused
### Testing
```bash
yarn run -T lerna run test # Run all tests across packages
yarn run -T lerna run test -- --watch # Watch mode across packages (heavier)
#
# If you use `yarn test`, remember to pass args through two separators:
# yarn test -- -- --watch
yarn test # Run all tests across packages (Jest multi-project)
yarn test --watch # Run tests in watch mode
# Run tests for a specific package
# Run tests for specific package
cd packages/eas-cli && yarn test
cd packages/eas-json && yarn test
cd packages/worker && yarn test
cd packages/build-tools && yarn test
cd packages/steps && yarn test
```
### Type Checking & Linting
```bash
yarn typecheck # TypeScript type check all packages
yarn lint # Oxlint across all packages
yarn fmt:check # Oxfmt format check
yarn lint # ESLint across all packages
```
### Running EAS CLI Locally
@@ -134,20 +81,6 @@ alias easd="$(pwd)/packages/eas-cli/bin/run"
easd build --help
```
### Local Build Testing
Set up environment variables for testing local builds:
```bash
export EAS_LOCAL_BUILD_PLUGIN_PATH=$HOME/expo/eas-cli/bin/eas-cli-local-build-plugin
export EAS_LOCAL_BUILD_WORKINGDIR=$HOME/expo/eas-build-workingdir
export EAS_LOCAL_BUILD_SKIP_CLEANUP=1
export EAS_LOCAL_BUILD_ARTIFACTS_DIR=$HOME/expo/eas-build-workingdir/results
# Then run build with --local flag in eas-cli
eas build --local
```
## Build System
Each package has independent TypeScript compilation:
@@ -156,91 +89,17 @@ Each package has independent TypeScript compilation:
- `eas-json`: `src/` → `build/`
- `eas-build-cache-provider`: `src/` → `build/`
- `worker`: `src/` → `dist/`
- `build-tools`: `src/` → `dist/`
- `eas-build-job`: `src/` → `dist/`
- `steps`: `src/` → `dist/` (CommonJS only)
- `local-build-plugin`: `src/` → `dist/`
- Other packages: `src/` → `dist/`
TypeScript configs:
- `tsconfig.json` - Base configuration (extends @tsconfig/node20)
- `tsconfig.json` - Base configuration (extends @tsconfig/node18)
- `tsconfig.build.json` - Production builds
- `tsconfig.allowUnused.json` - Development with relaxed rules
## Key Architectural Patterns
### Build Phases
Most traditional build operations are wrapped in phases for tracking:
```typescript
await ctx.runBuildPhase(BuildPhase.INSTALL_DEPENDENCIES, async () => {
// Phase logic here
});
```
Phases can be marked as skipped, warning, or failed for granular reporting.
### Context Objects
- **BuildContext** (`build-tools`): For traditional builds, wraps Job, manages phases/artifacts/caching
- **CustomBuildContext** (`build-tools`): Implements `ExternalBuildContextProvider`, bridges BuildContext to steps framework, used in custom builds and generic jobs
- **BuildStepGlobalContext** (`steps`): Manages step outputs, interpolation, shared state
- **BuildStepContext** (`steps`): Per-step context with working directory and logger
### Custom Build Steps
Steps are defined with:
- `id`: Unique identifier
- `name`: Display name
- `run`: Command or function reference
- `if`: Optional condition (`${{ always() }}`, `${{ success() }}`, etc.)
- `inputs`: Key-value inputs to the step
- `outputs`: Named outputs accessible to later steps
Built-in step functions are in `packages/build-tools/src/steps/functions/`
### Conditional Execution
Uses jsep for expression evaluation:
```yaml
if: ${{ steps.previous_step.outputs.success == 'true' && env.ENVIRONMENT == 'production' }}
```
### Artifact Management
Artifacts tracked as `ArtifactToUpload`:
- Managed artifacts (APK, IPA, AAB) with specific handling
- Generic artifacts for any file
- Upload via `ctx.uploadArtifact()` or `upload-artifact` step
## Package Interdependencies
```
eas-cli → @expo/eas-json
→ @expo/build-tools → @expo/eas-build-job
→ @expo/steps → @expo/eas-build-job
→ @expo/logger
→ @expo/turtle-spawn
→ @expo/downloader
→ @expo/template-file
local-build-plugin → @expo/build-tools → @expo/eas-build-job
→ @expo/turtle-spawn
worker → @expo/build-tools
```
Most packages depend on `@expo/eas-build-job` as the source of truth for types.
## Testing Architecture
- **Framework**: Jest with per-package configuration
- **Shared base config**: `jest/jest.shared.config.ts`
- **Framework**: Jest with multi-project configuration
- **Root config**: `jest.config.ts` (combines eas-cli, eas-json, worker)
- **Test locations**: `__tests__/` directories alongside source files
- **Mocking**:
- File system: `memfs`
@@ -250,36 +109,11 @@ Most packages depend on `@expo/eas-build-job` as the source of truth for types.
Run a single test file:
```bash
cd packages/eas-cli && yarn test <path-to-test-file>
yarn test <path-to-test-file>
# Example:
cd packages/eas-cli && yarn test src/project/__tests__/projectUtils-test.ts
yarn test packages/eas-cli/src/project/__tests__/projectUtils-test.ts
```
## Common Development Scenarios
### Adding a Built-in Step Function
1. Create file in `packages/build-tools/src/steps/functions/yourFunction.ts`
2. Export `createYourFunctionBuildFunction()` following existing patterns
3. Add to `getEasFunctions()` in `packages/build-tools/src/steps/functions/easFunctions.ts`
4. Function receives `BuildStepContext` and input/output maps
### Adding Error Detection
1. Add pattern detection in `packages/build-tools/src/buildErrors/detectError.ts`
2. Implement resolver for better error messages
3. Helps users understand and fix build failures
### Working with Platform-Specific Builders
- **Android builder** (`packages/build-tools/src/builders/android.ts`): Gradle-based, handles APK/AAB generation, also see `functionGroups/build.ts`
- **iOS builder** (`packages/build-tools/src/builders/ios.ts`): Fastlane/Xcode-based, handles IPA generation, also see `functionGroups/build.ts`
- Both use `runBuilderWithHooksAsync()` which runs build result hooks (on-success, on-error, on-complete) from package.json
### Introducing Breaking Changes to Job API (@expo/eas-build-job)
If you want to introduce breaking changes to the `@expo/eas-build-job` package, contact one of the CODEOWNERS to coordinate changes with EAS build servers and GraphQL API. Describe what changes you want to make and why. After everything is deployed to production, you can introduce a PR that relies on the new implementation.
## Environment Variables
```bash
@@ -296,14 +130,11 @@ export EXPO_DEBUG=1 # Enable debug logging
### Validation
All changes should be validated before committing. At minimum run package-level tests (e.g. `jest-unit`)
for touched code, then run type checks/linting and format the repo:
All changes should be validated with TypeScript and the linter before committing:
```bash
yarn fmt # Apply Oxfmt formatting
yarn typecheck # Validate TypeScript types
yarn lint # Run Oxlint
yarn fmt:check # Run Oxfmt format check
yarn lint # Run ESLint
```
### Logging
@@ -322,13 +153,6 @@ Log.error('Error message');
When creating pull requests, make sure to adhere to [PULL_REQUEST_TEMPLATE](./.github/PULL_REQUEST_TEMPLATE).
### Commit Messages
- When possible, prepend commit messages with `[PACKAGE-BEING-CHANGED]`, e.g. `[steps] Add new step function`
- Do not use prefixes like `chore:` and `feat:`
- Commit messages should be concise. Only complex changes should be longer than one line
- Commit changes in logical groups
## Release Process
1. Update `CHANGELOG.md` in the appropriate package
@@ -339,17 +163,8 @@ When creating pull requests, make sure to adhere to [PULL_REQUEST_TEMPLATE](./.g
3. GitHub Actions workflow handles release automation
4. Notifications sent to Slack #eas-cli channel
## Licensing
This repository contains packages under different licenses:
- **MIT License**: `eas-cli`, `@expo/eas-json`, `@expo/eas-build-job`, `eas-build-cache-provider`
- **BUSL-1.1 (Business Source License)**: `@expo/build-tools`, `@expo/steps`, `@expo/logger`, `@expo/downloader`, `@expo/turtle-spawn`, `@expo/template-file`, `eas-cli-local-build-plugin`
See `LICENSE` (MIT) and `LICENSE-BUSL` (BUSL-1.1) for details.
## Important Notes
- **Node Version**: Requires Node.js (managed via Mise)
- **Package Manager**: Uses Yarn 4.12.0
- **Node Version**: Requires Node.js >= 18.0.0 (managed via Volta)
- **Package Manager**: Uses Yarn 1.22.21
- **Compilation Target**: CommonJS with Node resolution
+2 -2
View File
@@ -65,11 +65,11 @@ For development against staging API:
## Working on local builds (`eas build --local`)
See [`CLAUDE.md`](./CLAUDE.md) for how to set up your environment when making changes to [`eas-cli-local-build-plugin`](./packages/local-build-plugin) and/or [`build-tools`](./packages/build-tools).
See https://github.com/expo/eas-build/blob/main/DEVELOPMENT.md for how to set up your environment when making changes to [`eas-cli-local-build-plugin`](https://github.com/expo/eas-build/tree/main/packages/local-build-plugin) and/or [`build-tools`](https://github.com/expo/eas-build/tree/main/packages/build-tools).
## Testing
From the repository root, run `yarn run -T lerna run test`. For a single package, run `yarn test` in that package directory.
Run `yarn test` either in the repository root or in a package directory that you're working on.
## Releasing
-100
View File
@@ -1,100 +0,0 @@
Business Source License 1.1
Parameters
Licensor: 650 Industries, Inc.
Licensed Work: EAS Build
The Licensed Work is (c) 2021 650 Industries, Inc.
Additional Use Grant: You may make use of the Licensed Work, provided that you do
not use the Licensed Work for commercial offerings such as
a CI/CD service or application build service that allows
third parties (other than your employees and contractors)
to access the functionality of and directly benefit from the
functionality of the Licensed Work.
Change Date: 2029-07-15
Change License: MIT
For information about alternative licensing arrangements for the Software,
please visit: https://expo.dev/pricing/
Notice
The Business Source License (this document, or the "License") is not an Open
Source license. However, the Licensed Work will eventually be made available
under an Open Source License, as stated in this License.
License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved.
"Business Source License" is a trademark of MariaDB Corporation Ab.
-----------------------------------------------------------------------------
Business Source License 1.1
Terms
The Licensor hereby grants you the right to copy, modify, create derivative
works, redistribute, and make non-production use of the Licensed Work. The
Licensor may make an Additional Use Grant, above, permitting limited
production use.
Effective on the Change Date, or the fourth anniversary of the first publicly
available distribution of a specific version of the Licensed Work under this
License, whichever comes first, the Licensor hereby grants you rights under
the terms of the Change License, and the rights granted in the paragraph
above terminate.
If your use of the Licensed Work does not comply with the requirements
currently in effect as described in this License, you must purchase a
commercial license from the Licensor, its affiliated entities, or authorized
resellers, or you must refrain from using the Licensed Work.
All copies of the original and modified Licensed Work, and derivative works
of the Licensed Work, are subject to this License. This License applies
separately for each version of the Licensed Work and the Change Date may vary
for each version of the Licensed Work released by Licensor.
You must conspicuously display this License on each original or modified copy
of the Licensed Work. If you receive the Licensed Work in original or
modified form from a third party, the terms and conditions set forth in this
License apply to your use of that work.
Any use of the Licensed Work in violation of this License will automatically
terminate your rights under this License for the current and all other
versions of the Licensed Work.
This License does not grant you any right in any trademark or logo of
Licensor or its affiliates (provided that you may use a trademark or logo of
Licensor as expressly required by this License).
TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON
AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS,
EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND
TITLE.
MariaDB hereby grants you permission to use this License’s text to license
your works, and to refer to it using the trademark "Business Source License",
as long as you comply with the Covenants of Licensor below.
Covenants of Licensor
In consideration of the right to use this License’s text and the "Business
Source License" name and trademark, Licensor covenants to MariaDB, and to all
other recipients of the licensed work to be provided by Licensor:
1. To specify as the Change License the GPL Version 2.0 or any later version,
or a license that is compatible with GPL Version 2.0 or a later version,
where "compatible" means that software provided under the Change License can
be included in a program with software provided under GPL Version 2.0 or a
later version. Licensor may specify additional Change Licenses without
limitation.
2. To either: (a) specify an additional grant of rights to use that does not
impose any additional restriction on the right granted in this License, as
the Additional Use Grant; or (b) insert the text "None".
3. To specify a Change Date.
4. Not to modify this License in any other way.
-21
View File
@@ -1,21 +0,0 @@
The MIT License (MIT)
Copyright (c) 2020-present 650 Industries, Inc. (aka Expo)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-1
View File
@@ -1 +0,0 @@
../packages/local-build-plugin/bin/run
+17
View File
@@ -0,0 +1,17 @@
import type { Config } from 'jest';
// eslint-disable-next-line import/no-relative-packages
import easCliConfig from './packages/eas-cli/jest.config';
// eslint-disable-next-line import/no-relative-packages
import easJsonConfig from './packages/eas-json/jest.config';
// eslint-disable-next-line import/no-relative-packages
import workerConfig from './packages/worker/jest.config.unit';
const config: Config = {
projects: [easCliConfig, easJsonConfig, workerConfig],
testPathIgnorePatterns: ['.*'],
watchPlugins: ['jest-watch-typeahead/filename', 'jest-watch-typeahead/testname'],
collectCoverageFrom: ['<rootDir>/src/**/*.{ts,tsx,js,jsx}'],
};
export default config;
+9
View File
@@ -0,0 +1,9 @@
import type { Config } from 'jest';
const config: Config = {
preset: 'ts-jest',
testEnvironment: 'node',
testRegex: '/__tests__/.*(test|spec)\\.[jt]sx?$',
};
export default config;
-8
View File
@@ -1,8 +0,0 @@
import type { Config } from '@jest/types';
const config: Config.InitialOptions = {
preset: 'ts-jest',
testEnvironment: 'node',
};
export default config;
+2 -2
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "21.0.2",
"version": "16.28.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
}
-8
View File
@@ -1,8 +0,0 @@
[env]
_.path = "./tools/bin"
MISE_NODE_COREPACK = "true"
[tools]
gcloud = "570.0.0"
jq = "1.8.1"
node = "20.19.4"
+16 -13
View File
@@ -6,33 +6,36 @@
],
"scripts": {
"build": "lerna run build",
"build:tarballs:linux": "yarn build-for-size-check",
"build-for-size-check": "yarn build && yarn workspace eas-cli pretarball-ci && CLI_SIZE_CHECK=1 yarn workspace eas-cli oclif pack:tarballs --no-xz --targets linux-x64",
"typecheck": "lerna run typecheck",
"start": "lerna run watch --stream --parallel",
"start-allow-unused": "lerna run watch-allow-unused --stream --parallel",
"watch": "yarn start",
"eas": "packages/eas-cli/bin/run",
"lint": "oxlint --type-aware --import-plugin --tsconfig ./tsconfig.oxlint.json .",
"fmt": "oxfmt .",
"fmt:check": "oxfmt --check .",
"lint": "eslint .",
"lint-changelog": "./scripts/bin/run lint-changelog",
"release": "lerna run rebuild --concurrency 1 && ./scripts/bin/run release",
"test": "lerna run test",
"clean": "lerna run clean && rimraf node_modules coverage"
"release": "lerna run rebuild && ./scripts/bin/run release",
"test": "jest",
"clean": "lerna run clean && rm -rf node_modules coverage"
},
"devDependencies": {
"@types/jest": "29.5.11",
"@types/node": "20.14.8",
"eslint": "8.56.0",
"eslint-config-universe": "13.0.0",
"eslint-plugin-async-protect": "3.1.0",
"eslint-plugin-node": "11.1.0",
"jest": "29.7.0",
"jest-watch-typeahead": "2.2.2",
"lerna": "9.0.3",
"oclif": "^4.22.85",
"oxfmt": "0.28.0",
"oxlint": "1.43.0",
"oxlint-tsgolint": "0.11.1",
"rimraf": "3.0.2",
"lerna": "8.2.3",
"oclif": "^3.9.0",
"prettier": "3.1.1",
"ts-jest": "29.1.1",
"typescript": "5.5.4"
},
"packageManager": "[email protected]"
"volta": {
"node": "20.11.0",
"yarn": "1.22.21"
}
}
-10
View File
@@ -1,10 +0,0 @@
node_modules/
dist/
bin/record-sim
.secrets*
!.secrets.example
!.secrets.test
src/graphql-env.d.ts
schema.graphql
-17
View File
@@ -1,17 +0,0 @@
{
"extends": ["../../.oxlintrc.json"],
"jsPlugins": [
{
"name": "async-protect",
"specifier": "eslint-plugin-async-protect"
}
],
"rules": {
"async-protect/async-suffix": "off",
"no-underscore-dangle": "off",
"node/no-sync": "off",
"no-console": "off",
"no-void": "off",
"no-restricted-imports": "off"
}
}
-7
View File
@@ -1,7 +0,0 @@
# @expo/build-tools
`@expo/build-tools` is the core library for the EAS Build service. It implements the build process for React Native projects and for managed Expo applications.
## Repository
https://github.com/expo/eas-cli/tree/main/packages/build-tools
-23
View File
@@ -1,23 +0,0 @@
#!/usr/bin/env bash
set -eo pipefail
NAME=$1
VALUE=$2
if [[ -z "$__EAS_BUILD_ENVS_DIR" ]]; then
echo "Set __EAS_BUILD_ENVS_DIR"
exit 1
fi
if [[ -z "$NAME" || -z "$VALUE" ]]; then
echo "Usage: set-env NAME VALUE"
exit 2
fi
if [[ "$NAME" == *"="* ]]; then
echo "Environment name can't include ="
exit 1
fi
echo -n $VALUE > $__EAS_BUILD_ENVS_DIR/$NAME
@@ -1,13 +0,0 @@
import type { Config } from '@jest/types';
import baseConfig from '../../../jest/jest.shared.config';
const config: Config.InitialOptions = {
...baseConfig,
rootDir: '../src',
testMatch: ['**/__integration-tests__/*.test.ts'],
clearMocks: true,
setupFilesAfterEnv: ['<rootDir>/../jest/setup-tests.ts'],
};
export default config;
-20
View File
@@ -1,20 +0,0 @@
import { fs, vol } from 'memfs';
import os from 'node:os';
if (process.env.NODE_ENV !== 'test') {
throw new Error("NODE_ENV environment variable must be set to 'test'.");
}
// Always mock:
jest.mock('fs');
jest.mock('node:fs', () => fs);
jest.mock('fs/promises');
jest.mock('node:fs/promises', () => fs.promises);
jest.mock('node-fetch');
beforeEach(() => {
vol.reset();
vol.fromNestedJSON({
[os.tmpdir()]: {},
});
});
-16
View File
@@ -1,16 +0,0 @@
import type { Config } from '@jest/types';
import baseConfig from '../../../jest/jest.shared.config';
const config: Config.InitialOptions = {
...baseConfig,
rootDir: '../src',
testMatch: [
'**/__tests__/*.test.ts',
...(process.platform === 'darwin' ? ['**/__tests__/*.test.ios.ts'] : []),
],
clearMocks: true,
setupFilesAfterEnv: ['<rootDir>/../jest/setup-tests.ts'],
};
export default config;
-103
View File
@@ -1,103 +0,0 @@
{
"name": "@expo/build-tools",
"version": "21.0.2",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
"repository": {
"type": "git",
"url": "https://github.com/expo/eas-cli.git",
"directory": "packages/build-tools"
},
"files": [
"dist",
"resources",
"templates",
"bin"
],
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"start": "yarn watch",
"prewatch": "yarn gql",
"watch": "tsc --watch --preserveWatchOutput",
"prebuild": "yarn gql",
"build": "tsc",
"build:record-sim": "mkdir -p bin && record_sim_bin_path=$(swift build -c release --package-path resources/record-sim --build-path resources/record-sim/.build --show-bin-path) && swift build -c release --package-path resources/record-sim --build-path resources/record-sim/.build && cp \"$record_sim_bin_path/record-sim\" bin/record-sim && chmod +x bin/record-sim",
"typecheck": "tsc",
"prepack": "rimraf dist \"*.tsbuildinfo\" && yarn gql && tsc -p tsconfig.build.json",
"jest-unit": "jest --config jest/unit-config.ts",
"jest-integration": "jest --config jest/integration-config.ts",
"jest-unit-watch": "jest --config jest/unit-config.ts --watch",
"jest-integration-watch": "jest --config jest/integration-config.ts --watch",
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\"",
"gql": "gql.tada generate-schema ${API_SERVER_URL:-https://api.expo.dev}/graphql --output ./schema.graphql && gql.tada generate-output",
"gql:local": "API_SERVER_URL=http://api.expo.test yarn gql",
"test": "yarn jest-unit"
},
"dependencies": {
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/downloader": "21.0.0",
"@expo/eas-build-job": "21.0.2",
"@expo/env": "^0.4.0",
"@expo/logger": "21.0.0",
"@expo/package-manager": "1.9.10",
"@expo/plist": "^0.3.5",
"@expo/results": "^1.0.0",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "21.0.2",
"@expo/template-file": "21.0.2",
"@expo/turtle-spawn": "21.0.0",
"@expo/xcpretty": "^4.3.1",
"@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",
"fast-xml-parser": "^4.4.1",
"fs-extra": "^11.2.0",
"gql.tada": "^1.8.13",
"joi": "^17.13.1",
"jose": "5.10.0",
"lodash": "^4.18.1",
"node-fetch": "^2.7.0",
"node-forge": "^1.3.1",
"node-stream-zip": "1.15.0",
"nullthrows": "^1.1.1",
"plist": "^3.1.0",
"promise-limit": "^2.7.0",
"promise-retry": "^2.0.1",
"resolve-from": "^5.0.0",
"retry": "^0.13.1",
"semver": "^7.6.2",
"tar": "7.5.19",
"uuid": "9.0.1",
"yaml": "^2.8.1",
"zod": "^4.3.5"
},
"devDependencies": {
"@expo/repack-app": "~0.6.1",
"@types/fs-extra": "^11.0.4",
"@types/jest": "^29.5.12",
"@types/lodash": "^4.17.4",
"@types/node": "20.14.2",
"@types/node-fetch": "^2.6.11",
"@types/node-forge": "^1.3.11",
"@types/plist": "^3.0.5",
"@types/promise-retry": "^1.1.6",
"@types/retry": "^0.12.5",
"@types/semver": "^7.5.8",
"@types/uuid": "^9.0.8",
"bunyan": "^1.8.15",
"jest": "^29.7.0",
"memfs": "^4.17.1",
"nock": "13.4.0",
"rimraf": "3.0.2",
"ts-jest": "^29.1.4",
"ts-mockito": "^2.6.1",
"tslib": "^2.6.3",
"typescript": "^5.5.4",
"uuid": "^9.0.1"
}
}
@@ -1,4 +0,0 @@
.build/
.swiftpm/
DerivedData/
.DS_Store
@@ -1,28 +0,0 @@
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "record-sim",
platforms: [
.macOS(.v14),
],
products: [
.library(name: "RecordSim", targets: ["RecordSim"]),
.executable(name: "record-sim", targets: ["record-sim"]),
],
targets: [
.target(
name: "RecordSim",
linkerSettings: [
.linkedFramework("AVFoundation"),
.linkedFramework("CoreGraphics"),
.linkedFramework("CoreMedia"),
.linkedFramework("CoreVideo"),
.linkedFramework("IOSurface"),
.linkedFramework("UniformTypeIdentifiers"),
]
),
.executableTarget(name: "record-sim", dependencies: ["RecordSim"]),
]
)
@@ -1,79 +0,0 @@
# RecordSim
Small Swift library for non-exclusive iOS Simulator screen recording.
It implements one recording path:
1. Listen to SimRenderServer framebuffer callbacks.
2. Copy the live `IOSurface` immediately into an owned `CVPixelBuffer`.
3. Feed one continuous `AVAssetWriter`.
4. Emit Apple HLS/CMAF-style fragmented MP4 segments.
The output is suitable for uploading while recording. `session.json` contains
the ordered segment metadata needed to build a playlist later.
## Output
```text
session/
init.mp4
session.json
segments/
segment-000000.m4s
segment-000001.m4s
```
`init.mp4` is the fMP4 initialization segment. Individual `.m4s` files are not
standalone MP4 files. `session.json` stores the wall-clock timestamp for the
first video frame plus the metadata needed to build a playlist: HLS version,
target duration, media sequence, `initSegment`, and the ordered `segments` array
with file path and duration for each media segment.
## CLI
```bash
swift run record-sim \
--udid <BOOTED_SIMULATOR_UDID> \
--output /tmp/sim-recording \
--segment-duration 120 \
--fps 30 \
--bitrate 30000000 \
--codec h264
```
For sparse 2h sessions, `--segment-duration 120` is a reasonable default: about
60 media objects plus the init segment. The recorder appends a single duplicate
hold frame near each segment boundary when the simulator is idle, so long idle
periods still produce uploadable segments without encoding idle frames at 60fps.
Use `--segment-duration 0` to write one continuous `recording.mp4` instead of
`init.mp4` plus media segments.
Frame timestamps use monotonic host time, not wall-clock time. The recorder also
probes the framebuffer seed once per second; if the surface changes without
callbacks for 5 seconds, it captures that frame and rewires the private callback
registration.
## Library
```swift
let recorder = SimulatorRecorder(
configuration: SimulatorRecordingConfiguration(
deviceUDID: udid,
outputDirectory: sessionDirectory,
segmentDuration: 120
)
)
recorder.onSegment = { segment in
// Enqueue upload of segment.url.
}
try recorder.start()
try recorder.waitUntilFirstFrame()
// ...
let manifest = try recorder.stop()
```
`session.json` contains the precise wall-clock time for video PTS zero. In
segmented mode it also contains the init segment and ordered media segment
file/duration entries; in single-file mode it points at `recording.mp4`.
@@ -1,192 +0,0 @@
import Foundation
import IOSurface
import ObjectiveC
final class FramebufferDisplaySource {
struct SurfaceSnapshot {
let surface: IOSurface
let width: Int
let height: Int
let seed: UInt32
}
private let deviceUDID: String
private let callbackQueue: DispatchQueue
private let onFrame: () -> Void
private let onSurfaceChange: () -> Void
private var descriptors: [NSObject] = []
private var callbackUUIDs: [ObjectIdentifier: NSUUID] = [:]
private var retainedBlocks: [AnyObject] = []
private var ioClient: NSObject?
init(
deviceUDID: String,
callbackQueue: DispatchQueue,
onFrame: @escaping () -> Void,
onSurfaceChange: @escaping () -> Void
) {
self.deviceUDID = deviceUDID
self.callbackQueue = callbackQueue
self.onFrame = onFrame
self.onSurfaceChange = onSurfaceChange
}
func start() throws {
try PrivateSimulatorFrameworks.load()
guard let device = SimulatorDeviceLookup.find(udid: deviceUDID) else {
throw RecorderError.make(1, "Simulator \(deviceUDID) not found")
}
let state = device.value(forKey: "stateString") as? String ?? "unknown"
guard state == "Booted" else {
throw RecorderError.make(2, "Simulator \(deviceUDID) is not booted (state: \(state))")
}
guard
let io = device.perform(NSSelectorFromString("io"))?.takeUnretainedValue() as? NSObject
else {
throw RecorderError.make(3, "Failed to get simulator IO client")
}
ioClient = io
try wireUpFramebuffer()
}
func stop() {
unregisterCallbacks()
descriptors.removeAll()
retainedBlocks.removeAll()
ioClient = nil
}
func surfaceSnapshot() -> SurfaceSnapshot? {
let surfaceSelector = NSSelectorFromString("framebufferSurface")
var bestSnapshot: SurfaceSnapshot?
var bestArea = 0
for descriptor in descriptors {
guard let surfaceObject = descriptor.perform(surfaceSelector)?.takeUnretainedValue()
else {
continue
}
let surface = unsafeBitCast(surfaceObject, to: IOSurface.self)
let width = IOSurfaceGetWidth(surface)
let height = IOSurfaceGetHeight(surface)
let area = width * height
if area > bestArea {
bestSnapshot = SurfaceSnapshot(
surface: surface,
width: width,
height: height,
seed: IOSurfaceGetSeed(surface)
)
bestArea = area
}
}
return bestSnapshot
}
func rewireFramebuffer() throws {
try wireUpFramebuffer()
}
private func wireUpFramebuffer() throws {
guard let io = ioClient else {
throw RecorderError.make(3, "No simulator IO client")
}
io.perform(NSSelectorFromString("updateIOPorts"))
let nextDescriptors = try findFramebufferDescriptors(io: io)
unregisterCallbacks()
descriptors = nextDescriptors
retainedBlocks.removeAll()
do {
for descriptor in descriptors {
try registerCallbacks(descriptor: descriptor)
}
} catch {
unregisterCallbacks()
descriptors.removeAll()
retainedBlocks.removeAll()
throw error
}
}
private func findFramebufferDescriptors(io: NSObject) throws -> [NSObject] {
guard let ports = io.value(forKey: "deviceIOPorts") as? [NSObject] else {
throw RecorderError.make(4, "Failed to read simulator IO ports")
}
let portIdentifierSelector = NSSelectorFromString("portIdentifier")
let descriptorSelector = NSSelectorFromString("descriptor")
let surfaceSelector = NSSelectorFromString("framebufferSurface")
var candidates: [NSObject] = []
for port in ports {
guard port.responds(to: portIdentifierSelector),
let portIdentifier = port.perform(portIdentifierSelector)?.takeUnretainedValue(),
"\(portIdentifier)" == "com.apple.framebuffer.display",
port.responds(to: descriptorSelector),
let descriptor = port.perform(descriptorSelector)?.takeUnretainedValue()
as? NSObject,
descriptor.responds(to: surfaceSelector)
else {
continue
}
candidates.append(descriptor)
}
if candidates.isEmpty {
throw RecorderError.make(5, "No framebuffer display descriptor found")
}
return candidates
}
private func registerCallbacks(descriptor: NSObject) throws {
let selector = NSSelectorFromString(
"registerScreenCallbacksWithUUID:callbackQueue:frameCallback:surfacesChangedCallback:propertiesChangedCallback:"
)
guard descriptor.responds(to: selector) else {
throw RecorderError.make(6, "Framebuffer descriptor does not support screen callbacks")
}
guard let msgSendPointer = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "objc_msgSend")
else {
throw RecorderError.make(7, "objc_msgSend not found")
}
typealias MsgSend =
@convention(c) (
AnyObject, Selector, AnyObject, AnyObject, AnyObject, AnyObject, AnyObject
) -> Void
let msgSend = unsafeBitCast(msgSendPointer, to: MsgSend.self)
let uuid = NSUUID()
callbackUUIDs[ObjectIdentifier(descriptor)] = uuid
let frameCallback: @convention(block) () -> Void = { [weak self] in
self?.onFrame()
}
let surfacesCallback: @convention(block) (AnyObject?, AnyObject?) -> Void = {
[weak self] _, _ in
self?.onSurfaceChange()
}
let propertiesCallback: @convention(block) () -> Void = {}
retainedBlocks.append(frameCallback as AnyObject)
retainedBlocks.append(surfacesCallback as AnyObject)
retainedBlocks.append(propertiesCallback as AnyObject)
msgSend(
descriptor,
selector,
uuid,
callbackQueue as AnyObject,
frameCallback as AnyObject,
surfacesCallback as AnyObject,
propertiesCallback as AnyObject
)
}
private func unregisterCallbacks() {
let selector = NSSelectorFromString("unregisterScreenCallbacksWithUUID:")
for descriptor in descriptors {
if let uuid = callbackUUIDs[ObjectIdentifier(descriptor)],
descriptor.responds(to: selector)
{
descriptor.perform(selector, with: uuid)
}
}
callbackUUIDs.removeAll()
}
}
@@ -1,96 +0,0 @@
import Foundation
import ObjectiveC
enum XcodeDeveloperDirectory {
static let current: String = {
if let developerDir = ProcessInfo.processInfo.environment["DEVELOPER_DIR"],
!developerDir.isEmpty
{
return developerDir
}
let fallback = "/Applications/Xcode.app/Contents/Developer"
let pipe = Pipe()
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/xcode-select")
process.arguments = ["-p"]
process.standardOutput = pipe
do {
try process.run()
} catch {
return fallback
}
process.waitUntilExit()
return String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? fallback
}()
}
enum PrivateSimulatorFrameworks {
static func load() throws {
try loadResult.get()
}
private static let loadResult: Result<Void, Error> = {
let developerDir = XcodeDeveloperDirectory.current
let candidates = [
"/Library/Developer/PrivateFrameworks/CoreSimulator.framework/CoreSimulator",
"\(developerDir)/Library/PrivateFrameworks/CoreSimulator.framework/CoreSimulator",
"\(developerDir)/../SharedFrameworks/SimulatorKit.framework/SimulatorKit",
"\(developerDir)/Library/PrivateFrameworks/SimulatorKit.framework/SimulatorKit",
]
var failures: [String] = []
for path in candidates {
if dlopen(path, RTLD_NOW) == nil {
let message = dlerror().map { String(cString: $0) } ?? "unknown error"
failures.append("\(path): \(message)")
}
}
guard NSClassFromString("SimServiceContext") != nil else {
let tried = candidates.joined(separator: ", ")
let errors = failures.isEmpty ? "no dlopen errors" : failures.joined(separator: "; ")
return .failure(
RecorderError.make(
8,
"Failed to load CoreSimulator/SimulatorKit (tried: \(tried); errors: \(errors))"
)
)
}
return .success(())
}()
}
enum SimulatorDeviceLookup {
private static let context: NSObject? = {
guard let contextClass = NSClassFromString("SimServiceContext") as? NSObject.Type else {
return nil
}
let sharedSelector = NSSelectorFromString("sharedServiceContextForDeveloperDir:error:")
return contextClass.perform(
sharedSelector, with: XcodeDeveloperDirectory.current, with: nil)?
.takeUnretainedValue() as? NSObject
}()
static func find(udid: String) -> NSObject? {
guard let context else {
return nil
}
let deviceSetSelector = NSSelectorFromString("defaultDeviceSetWithError:")
guard
let deviceSet = context.perform(deviceSetSelector, with: nil)?
.takeUnretainedValue() as? NSObject
else {
return nil
}
guard let devices = deviceSet.value(forKey: "devices") as? [NSObject] else {
return nil
}
return devices.first {
($0.value(forKey: "UDID") as? NSUUID)?.uuidString == udid
}
}
static func stateString(udid: String) -> String? {
find(udid: udid)?.value(forKey: "stateString") as? String
}
}
@@ -1,77 +0,0 @@
import Foundation
public enum RecorderCodec: String, Codable, Sendable {
case h264
case hevc
}
public struct SimulatorRecordingConfiguration: Sendable {
public var deviceUDID: String
public var outputDirectory: URL
public var fps: Int
public var bitrate: Int
public var codec: RecorderCodec
public var segmentDuration: TimeInterval
public var maxPendingFrames: Int
public var overwrite: Bool
public init(
deviceUDID: String,
outputDirectory: URL,
fps: Int = 30,
bitrate: Int = 30_000_000,
codec: RecorderCodec = .h264,
segmentDuration: TimeInterval = 120,
maxPendingFrames: Int = 8,
overwrite: Bool = true
) {
self.deviceUDID = deviceUDID
self.outputDirectory = outputDirectory
self.fps = fps
self.bitrate = bitrate
self.codec = codec
self.segmentDuration = segmentDuration
self.maxPendingFrames = maxPendingFrames
self.overwrite = overwrite
}
}
public enum SegmentKind: String, Codable, Sendable {
case initialization
case media
}
public struct SegmentOutput: Sendable {
public let kind: SegmentKind
public let relativePath: String
public let url: URL
public let byteCount: Int
public let durationSeconds: Double?
}
public struct MediaSegmentRecord: Codable, Sendable {
public let file: String
public let durationSeconds: Double
}
public struct FirstFrameWallClock: Codable, Sendable {
public let unixMs: Int64
public let iso8601: String
}
public enum RecordingFinalizationStage: Sendable {
case captureStopped
case videoSaved
}
public struct RecordingManifest: Codable, Sendable {
public let firstFrameWallClock: FirstFrameWallClock
public let width: Int
public let height: Int
public let hlsVersion: Int?
public let hlsTargetDurationSeconds: Int?
public let hlsMediaSequence: Int?
public let recording: String?
public let initSegment: String?
public let segments: [MediaSegmentRecord]
}
@@ -1,140 +0,0 @@
import AVFoundation
import Foundation
final class RecordingOutputWriter: NSObject, AVAssetWriterDelegate {
private let rootDirectory: URL
private let segmentsDirectory: URL
let singleRecordingURL: URL
private let stateQueue = DispatchQueue(label: "record-sim.segment-writer")
private let notificationQueue = DispatchQueue(label: "record-sim.segment-notifications")
private let onSegment: ((SegmentOutput) -> Void)?
private var initSegmentPath: String?
private var segments: [MediaSegmentRecord] = []
private var nextMediaSegmentIndex = 0
private var firstError: Error?
init(rootDirectory: URL, onSegment: ((SegmentOutput) -> Void)?) {
self.rootDirectory = rootDirectory
self.segmentsDirectory = rootDirectory.appendingPathComponent("segments", isDirectory: true)
self.singleRecordingURL = rootDirectory.appendingPathComponent("recording.mp4")
self.onSegment = onSegment
super.init()
}
func prepare(overwrite: Bool, segmented: Bool) throws {
if overwrite {
try? FileManager.default.removeItem(at: rootDirectory)
}
try FileManager.default.createDirectory(at: rootDirectory, withIntermediateDirectories: true)
if segmented {
try FileManager.default.createDirectory(at: segmentsDirectory, withIntermediateDirectories: true)
}
}
var error: Error? {
stateQueue.sync { firstError }
}
func assetWriter(
_ writer: AVAssetWriter,
didOutputSegmentData segmentData: Data,
segmentType: AVAssetSegmentType,
segmentReport: AVAssetSegmentReport?
) {
var notification: SegmentOutput?
stateQueue.sync {
if firstError != nil {
return
}
do {
switch segmentType {
case .initialization:
let relativePath = "init.mp4"
let url = rootDirectory.appendingPathComponent(relativePath)
try segmentData.write(to: url, options: .atomic)
initSegmentPath = relativePath
notification = SegmentOutput(
kind: .initialization,
relativePath: relativePath,
url: url,
byteCount: segmentData.count,
durationSeconds: nil
)
case .separable:
let index = nextMediaSegmentIndex
let relativePath = String(format: "segments/segment-%06d.m4s", index)
let url = rootDirectory.appendingPathComponent(relativePath)
try segmentData.write(to: url, options: .atomic)
let videoReport = segmentReport?.trackReports.first(where: { $0.mediaType == .video })
let duration = videoReport.flatMap { timeSeconds($0.duration) } ?? 0
let record = MediaSegmentRecord(
file: relativePath,
durationSeconds: duration
)
nextMediaSegmentIndex += 1
segments.append(record)
notification = SegmentOutput(
kind: .media,
relativePath: relativePath,
url: url,
byteCount: segmentData.count,
durationSeconds: duration
)
default:
break
}
} catch {
firstError = error
}
}
if let notification, let onSegment {
notificationQueue.async {
onSegment(notification)
}
}
}
@discardableResult
func writeManifest(
configuration: SimulatorRecordingConfiguration,
firstFrameWallClock: Date,
width: Int,
height: Int
) throws -> RecordingManifest {
try stateQueue.sync {
if let firstError {
throw firstError
}
let segmented = configuration.segmentDuration > 0
if segmented, initSegmentPath == nil {
throw RecorderError.make(50, "Missing initialization segment")
}
if segmented, segments.isEmpty {
throw RecorderError.make(51, "No media segments were written")
}
let targetDuration = segmented
? max(1, Int(ceil(segments.map(\.durationSeconds).max() ?? configuration.segmentDuration)))
: nil
let manifest = RecordingManifest(
firstFrameWallClock: FirstFrameWallClock(
unixMs: unixMs(firstFrameWallClock),
iso8601: iso8601(firstFrameWallClock)
),
width: width,
height: height,
hlsVersion: segmented ? 7 : nil,
hlsTargetDurationSeconds: targetDuration,
hlsMediaSequence: segmented ? 0 : nil,
recording: segmented ? nil : "recording.mp4",
initSegment: segmented ? initSegmentPath : nil,
segments: segments
)
let data = try JSONEncoder.prettySorted.encode(manifest)
try data.write(to: rootDirectory.appendingPathComponent("session.json"), options: .atomic)
return manifest
}
}
}
@@ -1,644 +0,0 @@
import AVFoundation
import CoreVideo
import Foundation
import IOSurface
import UniformTypeIdentifiers
public final class SimulatorRecorder {
public var onSegment: ((SegmentOutput) -> Void)?
public var onSimulatorStopped: ((String) -> Void)?
public var onFinalizationStage: ((RecordingFinalizationStage) -> Void)?
private static let callbackStalenessTimeout: TimeInterval = 5
private static let firstFrameRewireInterval: TimeInterval = 1
private let configuration: SimulatorRecordingConfiguration
private let callbackQueue = DispatchQueue(
label: "record-sim.frame-callbacks", qos: .userInteractive)
private let writerQueue = DispatchQueue(label: "record-sim.asset-writer", qos: .userInitiated)
private let eventQueue = DispatchQueue(label: "record-sim.events", qos: .utility)
private let pendingLock = NSLock()
private var pendingFrames = 0
private var displaySource: FramebufferDisplaySource?
private var outputWriter: RecordingOutputWriter?
private var writer: AVAssetWriter?
private var input: AVAssetWriterInput?
private var adaptor: AVAssetWriterInputPixelBufferAdaptor?
private var boundaryTimer: DispatchSourceTimer?
private var monotonicClock = MonotonicClock()
private var firstAcceptedCaptureTime: CMTime?
private var firstAcceptedWallClock: Date?
private var recordingWidth: Int?
private var recordingHeight: Int?
private var lastPTS: CMTime?
private var lastSeed: UInt32?
private var lastFrameCallbackElapsed: TimeInterval?
private var lastFirstFrameRewireElapsed: TimeInterval = 0
private var lastAppendedPixelBuffer: CVPixelBuffer?
private var nextBoundaryElapsed: TimeInterval = 0
private var simulatorStoppedReason: String?
private var stopped = false
private var firstFrameReady = false
private let firstFrameSemaphore = DispatchSemaphore(value: 0)
private var firstError: Error?
public init(configuration: SimulatorRecordingConfiguration) {
self.configuration = configuration
}
public func start() throws {
try validateConfiguration()
let outputWriter = RecordingOutputWriter(
rootDirectory: configuration.outputDirectory,
onSegment: { [weak self] segment in
self?.onSegment?(segment)
})
try outputWriter.prepare(
overwrite: configuration.overwrite, segmented: configuration.segmentDuration > 0)
self.outputWriter = outputWriter
monotonicClock = MonotonicClock()
nextBoundaryElapsed = configuration.segmentDuration
lastFirstFrameRewireElapsed = 0
stopped = false
let source = FramebufferDisplaySource(
deviceUDID: configuration.deviceUDID,
callbackQueue: callbackQueue,
onFrame: { [weak self] in
self?.captureFrame(force: false, reason: .callback)
},
onSurfaceChange: { [weak self] in
self?.captureFrame(force: true, reason: .surfaceChange)
}
)
try source.start()
displaySource = source
startBoundaryTimer()
}
public func waitUntilFirstFrame(timeoutSeconds: TimeInterval = 15) throws {
let alreadyReady = try writerQueue.sync {
if let firstError {
throw firstError
}
return firstFrameReady
}
if alreadyReady {
return
}
if firstFrameSemaphore.wait(timeout: .now() + timeoutSeconds) == .timedOut {
throw RecorderError.make(20, "Timed out waiting for first frame")
}
try writerQueue.sync {
if let firstError {
throw firstError
}
if !firstFrameReady {
throw RecorderError.make(21, "Recorder stopped before first frame")
}
}
}
public func simulatorStopReason() -> String? {
callbackQueue.sync {
simulatorStoppedReason
}
}
@discardableResult
public func stop() throws -> RecordingManifest {
callbackQueue.sync {
stopped = true
boundaryTimer?.cancel()
boundaryTimer = nil
displaySource?.stop()
displaySource = nil
}
onFinalizationStage?(.captureStopped)
return try writerQueue.sync {
if let firstError {
throw firstError
}
guard let writer, let input, let outputWriter else {
let error = RecorderError.make(22, "No frames were captured")
firstError = error
signalFirstFrameReadyIfNeeded()
throw error
}
try appendTailFrameIfNeeded(finalPTS: monotonicClock.elapsedTime())
input.markAsFinished()
let semaphore = DispatchSemaphore(value: 0)
writer.finishWriting {
semaphore.signal()
}
if semaphore.wait(timeout: .now() + 60) == .timedOut {
throw RecorderError.make(23, "Timed out finishing AVAssetWriter")
}
if writer.status == .failed {
throw writer.error ?? RecorderError.make(24, "AVAssetWriter failed")
}
if let error = outputWriter.error {
throw error
}
onFinalizationStage?(.videoSaved)
guard let firstAcceptedWallClock else {
throw RecorderError.make(25, "Missing first frame wall-clock timestamp")
}
guard let recordingWidth, let recordingHeight else {
throw RecorderError.make(27, "Missing recording dimensions")
}
return try outputWriter.writeManifest(
configuration: configuration,
firstFrameWallClock: firstAcceptedWallClock,
width: recordingWidth,
height: recordingHeight
)
}
}
private enum CaptureReason {
case callback
case surfaceChange
case boundary
case healthProbe
}
private func validateConfiguration() throws {
guard !configuration.deviceUDID.isEmpty else {
throw RecorderError.make(30, "deviceUDID must not be empty")
}
guard configuration.fps > 0, configuration.fps <= 120 else {
throw RecorderError.make(31, "fps must be between 1 and 120")
}
guard configuration.bitrate > 0 else {
throw RecorderError.make(32, "bitrate must be positive")
}
guard configuration.segmentDuration >= 0 else {
throw RecorderError.make(33, "segmentDuration must be non-negative")
}
guard configuration.maxPendingFrames > 0 else {
throw RecorderError.make(34, "maxPendingFrames must be positive")
}
}
private func startBoundaryTimer() {
let timer = DispatchSource.makeTimerSource(queue: callbackQueue)
timer.schedule(deadline: .now() + 1, repeating: 1, leeway: .milliseconds(100))
timer.setEventHandler { [weak self] in
self?.probeSimulatorState()
self?.probeFramebufferHealth()
self?.appendBoundaryFrameIfNeeded()
}
timer.resume()
boundaryTimer = timer
}
private func appendBoundaryFrameIfNeeded() {
if stopped {
return
}
guard configuration.segmentDuration > 0 else {
return
}
writerQueue.sync {
guard firstAcceptedCaptureTime != nil else {
return
}
let currentPTSSeconds = CMTimeGetSeconds(
normalizedPresentationTime(for: monotonicClock.elapsedTime()))
guard currentPTSSeconds >= nextBoundaryElapsed else {
return
}
let boundary = nextBoundaryElapsed
guard let lastPTS else {
return
}
do {
if CMTimeGetSeconds(lastPTS) < boundary {
let boundaryPTS = CMTime(seconds: boundary, preferredTimescale: 1_000_000_000)
guard try appendHeldFrame(at: boundaryPTS) else {
return
}
}
nextBoundaryElapsed = boundary + configuration.segmentDuration
} catch {
firstError = error
signalFirstFrameReadyIfNeeded()
}
}
}
private func captureFrame(force: Bool, reason: CaptureReason) {
if stopped {
return
}
if reason == .callback {
lastFrameCallbackElapsed = monotonicClock.elapsedSeconds()
}
guard let snapshot = displaySource?.surfaceSnapshot() else {
return
}
let surface = snapshot.surface
let surfaceWidth = snapshot.width
let surfaceHeight = snapshot.height
guard surfaceWidth > 0, surfaceHeight > 0 else {
return
}
let seed = snapshot.seed
let matchesLastAppendedSeed = writerQueue.sync { lastSeed == seed }
if !force, matchesLastAppendedSeed {
return
}
guard reservePendingFrame() else {
return
}
let capturedAt = monotonicClock.elapsedTime()
let capturedAtWallClock = Date()
do {
let pixelBuffer = try copySurfaceToOwnedPixelBuffer(
surface,
width: surfaceWidth,
height: surfaceHeight
)
appendOwned(
pixelBuffer: pixelBuffer,
seed: seed,
capturedAt: capturedAt,
capturedAtWallClock: capturedAtWallClock
)
} catch {
releasePendingFrame()
writerQueue.async {
self.firstError = error
self.signalFirstFrameReadyIfNeeded()
}
}
}
private func probeFramebufferHealth() {
guard !stopped, let displaySource else {
return
}
let elapsed = monotonicClock.elapsedSeconds()
if !isFirstFrameReady(),
elapsed - lastFirstFrameRewireElapsed >= Self.firstFrameRewireInterval
{
lastFirstFrameRewireElapsed = elapsed
rewireFramebuffer()
captureFrame(force: true, reason: .healthProbe)
return
}
guard let snapshot = displaySource.surfaceSnapshot(),
snapshot.width > 0,
snapshot.height > 0
else {
rewireFramebuffer()
return
}
let observedSeed = snapshot.seed
let lastAppendedSeed = writerQueue.sync { lastSeed }
guard let lastAppendedSeed, observedSeed != lastAppendedSeed else {
return
}
let lastCallback = lastFrameCallbackElapsed ?? 0
if elapsed - lastCallback >= Self.callbackStalenessTimeout {
captureFrame(force: false, reason: .healthProbe)
rewireFramebuffer()
}
}
private func probeSimulatorState() {
guard !stopped, simulatorStoppedReason == nil else {
return
}
let state = SimulatorDeviceLookup.stateString(udid: configuration.deviceUDID) ?? "missing"
guard state != "Booted" else {
return
}
simulatorStoppedReason = state
let error = RecorderError.make(26, "Simulator stopped before first frame (state: \(state))")
writerQueue.async {
if self.firstError == nil, !self.firstFrameReady {
self.firstError = error
self.signalFirstFrameReadyIfNeeded()
}
}
let onSimulatorStopped = onSimulatorStopped
eventQueue.async {
onSimulatorStopped?(state)
}
}
private func rewireFramebuffer() {
guard let displaySource else {
return
}
do {
try displaySource.rewireFramebuffer()
} catch {
writerQueue.async {
self.firstError = error
self.signalFirstFrameReadyIfNeeded()
}
}
}
private func reservePendingFrame() -> Bool {
pendingLock.lock()
defer { pendingLock.unlock() }
if pendingFrames >= configuration.maxPendingFrames {
return false
}
pendingFrames += 1
return true
}
private func releasePendingFrame() {
pendingLock.lock()
pendingFrames = max(0, pendingFrames - 1)
pendingLock.unlock()
}
private func copySurfaceToOwnedPixelBuffer(
_ surface: IOSurface,
width: Int,
height: Int
) throws -> CVPixelBuffer {
let pool = try writerQueue.sync {
if let firstError {
throw firstError
}
if writer == nil {
try startWriter(width: width, height: height)
}
guard let pool = adaptor?.pixelBufferPool else {
throw RecorderError.make(40, "AVAssetWriter pixel buffer pool is unavailable")
}
return pool
}
var output: CVPixelBuffer?
let status = CVPixelBufferPoolCreatePixelBuffer(kCFAllocatorDefault, pool, &output)
guard status == kCVReturnSuccess, let destination = output else {
throw RecorderError.make(41, "Failed to allocate pixel buffer from pool: \(status)")
}
let destinationLockStatus = CVPixelBufferLockBaseAddress(destination, [])
guard destinationLockStatus == kCVReturnSuccess else {
throw RecorderError.make(50, "Failed to lock pixel buffer: \(destinationLockStatus)")
}
let surfaceLockStatus = IOSurfaceLock(surface, .readOnly, nil)
guard surfaceLockStatus == KERN_SUCCESS else {
CVPixelBufferUnlockBaseAddress(destination, [])
throw RecorderError.make(49, "Failed to lock IOSurface: \(surfaceLockStatus)")
}
defer {
IOSurfaceUnlock(surface, .readOnly, nil)
CVPixelBufferUnlockBaseAddress(destination, [])
}
guard let destinationAddress = CVPixelBufferGetBaseAddress(destination) else {
throw RecorderError.make(42, "Pixel buffer has no base address")
}
let sourceAddress = IOSurfaceGetBaseAddress(surface)
let sourceStride = IOSurfaceGetBytesPerRow(surface)
let destinationStride = CVPixelBufferGetBytesPerRow(destination)
let copyWidth = min(width, IOSurfaceGetWidth(surface), CVPixelBufferGetWidth(destination))
let copyHeight = min(
height, IOSurfaceGetHeight(surface), CVPixelBufferGetHeight(destination))
let copyBytes = min(copyWidth * 4, sourceStride, destinationStride)
for row in 0..<copyHeight {
memcpy(
destinationAddress.advanced(by: row * destinationStride),
sourceAddress.advanced(by: row * sourceStride),
copyBytes
)
}
return destination
}
private func appendOwned(
pixelBuffer: CVPixelBuffer,
seed: UInt32,
capturedAt: CMTime,
capturedAtWallClock: Date
) {
writerQueue.async {
defer { self.releasePendingFrame() }
if self.firstError != nil {
return
}
do {
try self.appendOnWriterQueue(
pixelBuffer: pixelBuffer,
seed: seed,
capturedAt: capturedAt,
capturedAtWallClock: capturedAtWallClock
)
} catch {
self.firstError = error
self.signalFirstFrameReadyIfNeeded()
}
}
}
private func appendOnWriterQueue(
pixelBuffer: CVPixelBuffer,
seed: UInt32,
capturedAt: CMTime,
capturedAtWallClock: Date
) throws {
guard let writer, let input, let adaptor else {
throw RecorderError.make(43, "AVAssetWriter is not initialized")
}
let isFirstFrame = firstAcceptedCaptureTime == nil
let pts = isFirstFrame ? .zero : normalizedPresentationTime(for: capturedAt)
if let lastPTS, CMTimeCompare(pts, lastPTS) <= 0 {
return
}
guard try appendPendingBoundaryFrames(before: pts) else {
return
}
guard input.isReadyForMoreMediaData else {
return
}
if !adaptor.append(pixelBuffer, withPresentationTime: pts) {
throw writer.error ?? RecorderError.make(44, "Failed to append pixel buffer")
}
if isFirstFrame {
firstAcceptedCaptureTime = capturedAt
firstAcceptedWallClock = capturedAtWallClock
}
lastPTS = pts
lastSeed = seed
lastAppendedPixelBuffer = pixelBuffer
signalFirstFrameReadyIfNeeded()
}
private func appendTailFrameIfNeeded(finalPTS: CMTime) throws {
let deadline = Date().addingTimeInterval(5)
while true {
if try appendTailFrame(finalPTS: finalPTS) {
return
}
if Date() >= deadline {
throw RecorderError.make(45, "Timed out waiting to append tail frame")
}
Thread.sleep(forTimeInterval: 0.05)
}
}
private func appendTailFrame(finalPTS: CMTime) throws -> Bool {
let pts = normalizedPresentationTime(for: finalPTS)
return try appendHeldFrame(at: pts)
}
private func appendPendingBoundaryFrames(before pts: CMTime) throws -> Bool {
guard configuration.segmentDuration > 0,
firstAcceptedCaptureTime != nil
else {
return true
}
while CMTimeGetSeconds(pts) > nextBoundaryElapsed {
if let lastPTS, CMTimeGetSeconds(lastPTS) >= nextBoundaryElapsed {
nextBoundaryElapsed += configuration.segmentDuration
continue
}
let boundaryPTS = CMTime(
seconds: nextBoundaryElapsed, preferredTimescale: 1_000_000_000)
guard try appendHeldFrame(at: boundaryPTS) else {
return false
}
nextBoundaryElapsed += configuration.segmentDuration
}
return true
}
private func appendHeldFrame(at pts: CMTime) throws -> Bool {
guard let input,
let adaptor,
let lastPTS,
let lastAppendedPixelBuffer
else {
return true
}
if CMTimeCompare(pts, lastPTS) <= 0 {
return true
}
guard input.isReadyForMoreMediaData else {
return false
}
if !adaptor.append(lastAppendedPixelBuffer, withPresentationTime: pts) {
throw writer?.error ?? RecorderError.make(45, "Failed to append held frame")
}
self.lastPTS = pts
return true
}
private func normalizedPresentationTime(for capturedAt: CMTime) -> CMTime {
let start = firstAcceptedCaptureTime ?? capturedAt
return CMTimeSubtract(capturedAt, start)
}
private func startWriter(width: Int, height: Int) throws {
guard let outputWriter else {
throw RecorderError.make(46, "Missing output writer")
}
let writer: AVAssetWriter
if configuration.segmentDuration > 0 {
writer = AVAssetWriter(contentType: .mpeg4Movie)
writer.delegate = outputWriter
writer.outputFileTypeProfile = .mpeg4AppleHLS
writer.preferredOutputSegmentInterval = CMTime(
seconds: configuration.segmentDuration,
preferredTimescale: 600
)
} else {
writer = try AVAssetWriter(outputURL: outputWriter.singleRecordingURL, fileType: .mp4)
}
writer.initialSegmentStartTime = .zero
let input = AVAssetWriterInput(
mediaType: .video,
outputSettings: assetWriterSettings(width: width, height: height)
)
input.expectsMediaDataInRealTime = true
let attributes: [String: Any] = [
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
kCVPixelBufferWidthKey as String: width,
kCVPixelBufferHeightKey as String: height,
kCVPixelBufferIOSurfacePropertiesKey as String: [:],
]
let adaptor = AVAssetWriterInputPixelBufferAdaptor(
assetWriterInput: input,
sourcePixelBufferAttributes: attributes
)
guard writer.canAdd(input) else {
throw RecorderError.make(47, "AVAssetWriter cannot add video input")
}
writer.add(input)
guard writer.startWriting() else {
throw writer.error ?? RecorderError.make(48, "AVAssetWriter failed to start")
}
writer.startSession(atSourceTime: .zero)
self.writer = writer
self.input = input
self.adaptor = adaptor
recordingWidth = width
recordingHeight = height
}
private func assetWriterSettings(width: Int, height: Int) -> [String: Any] {
let codecType: AVVideoCodecType = configuration.codec == .hevc ? .hevc : .h264
var compression: [String: Any] = [
AVVideoAverageBitRateKey: configuration.bitrate,
AVVideoExpectedSourceFrameRateKey: configuration.fps,
AVVideoAllowFrameReorderingKey: true,
]
if configuration.segmentDuration > 0 {
compression[AVVideoMaxKeyFrameIntervalKey] = max(
1,
Int((configuration.segmentDuration * Double(configuration.fps)).rounded())
)
}
if configuration.codec == .h264 {
compression[AVVideoProfileLevelKey] = AVVideoProfileLevelH264HighAutoLevel
}
return [
AVVideoCodecKey: codecType,
AVVideoWidthKey: width,
AVVideoHeightKey: height,
AVVideoCompressionPropertiesKey: compression,
]
}
private func signalFirstFrameReadyIfNeeded() {
if firstFrameReady {
return
}
firstFrameReady = true
firstFrameSemaphore.signal()
}
private func isFirstFrameReady() -> Bool {
writerQueue.sync {
firstFrameReady
}
}
}
@@ -1,60 +0,0 @@
import CoreMedia
import Foundation
enum RecorderError {
static func make(_ code: Int, _ message: String) -> NSError {
NSError(
domain: "RecordSim",
code: code,
userInfo: [NSLocalizedDescriptionKey: message]
)
}
}
extension JSONEncoder {
static var prettySorted: JSONEncoder {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
return encoder
}
}
func timeSeconds(_ time: CMTime) -> Double? {
guard time.isValid, !time.isIndefinite, !time.isNegativeInfinity, !time.isPositiveInfinity else {
return nil
}
return CMTimeGetSeconds(time)
}
func iso8601(_ date: Date) -> String {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
formatter.timeZone = TimeZone(secondsFromGMT: 0)
return formatter.string(from: date)
}
func unixMs(_ date: Date) -> Int64 {
Int64((date.timeIntervalSince1970 * 1000).rounded())
}
struct MonotonicClock {
private static let timescale: CMTimeScale = 1_000_000_000
let startedAtUptimeNanoseconds: UInt64
init() {
startedAtUptimeNanoseconds = DispatchTime.now().uptimeNanoseconds
}
func elapsedSeconds() -> TimeInterval {
TimeInterval(elapsedNanoseconds()) / TimeInterval(Self.timescale)
}
func elapsedTime() -> CMTime {
CMTime(value: CMTimeValue(elapsedNanoseconds()), timescale: Self.timescale)
}
private func elapsedNanoseconds() -> UInt64 {
DispatchTime.now().uptimeNanoseconds - startedAtUptimeNanoseconds
}
}
@@ -1,159 +0,0 @@
import Foundation
import RecordSim
struct CLIOptions {
var udid: String?
var output: String?
var segmentDuration: TimeInterval = 120
var fps = 30
var bitrate = 30_000_000
var codec: RecorderCodec = .h264
}
private var requestedStopSignal: CInt = 0
private var simulatorStopped: CInt = 0
private func handleStopSignal(_ signal: CInt) {
requestedStopSignal = signal
}
private func isStopRequested() -> Bool {
requestedStopSignal != 0 || simulatorStopped != 0
}
func usage() -> String {
"""
Usage:
record-sim --udid <UDID> --output <session-dir> [options]
Options:
--segment-duration <seconds> Target fMP4 media segment duration. Use 0 for one MP4. Default: 120
--fps <fps> Expected source frame rate. Default: 30
--bitrate <bits/sec> AVAssetWriter average bitrate. Default: 30000000
--codec <h264|hevc> Video codec. Default: h264
-h, --help Show this help
"""
}
func parseOptions(_ args: [String]) throws -> CLIOptions {
var options = CLIOptions()
var i = 0
func next(_ flag: String) throws -> String {
i += 1
guard i < args.count else {
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "Missing value for \(flag)"])
}
return args[i]
}
while i < args.count {
let arg = args[i]
switch arg {
case "--udid", "-u":
options.udid = try next(arg)
case "--output", "--output-dir", "--out", "-o":
options.output = try next(arg)
case "--segment-duration":
options.segmentDuration = Double(try next(arg)) ?? -1
case "--fps":
options.fps = Int(try next(arg)) ?? 0
case "--bitrate":
options.bitrate = Int(try next(arg)) ?? 0
case "--codec":
let raw = try next(arg)
guard let codec = RecorderCodec(rawValue: raw) else {
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "--codec must be h264 or hevc"])
}
options.codec = codec
case "--help", "-h":
print(usage())
exit(0)
default:
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "Unknown argument: \(arg)"])
}
i += 1
}
guard options.udid != nil else {
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "Missing required --udid"])
}
guard options.output != nil else {
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "Missing required --output"])
}
guard options.segmentDuration >= 0 else {
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "--segment-duration must be non-negative"])
}
guard options.fps > 0, options.fps <= 120 else {
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "--fps must be between 1 and 120"])
}
guard options.bitrate > 0 else {
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "--bitrate must be positive"])
}
return options
}
do {
signal(SIGINT, handleStopSignal)
signal(SIGTERM, handleStopSignal)
let options = try parseOptions(Array(CommandLine.arguments.dropFirst()))
let configuration = SimulatorRecordingConfiguration(
deviceUDID: options.udid!,
outputDirectory: URL(fileURLWithPath: options.output!),
fps: options.fps,
bitrate: options.bitrate,
codec: options.codec,
segmentDuration: options.segmentDuration
)
let recorder = SimulatorRecorder(configuration: configuration)
recorder.onSimulatorStopped = { state in
simulatorStopped = 1
fputs("[record-sim] simulator stopped state=\(state); finalizing recording\n", stderr)
}
recorder.onFinalizationStage = { stage in
switch stage {
case .captureStopped:
fputs("[record-sim] screen capture stopped; saving final video\n", stderr)
case .videoSaved:
fputs("[record-sim] final video saved; writing recording details\n", stderr)
}
}
recorder.onSegment = { segment in
switch segment.kind {
case .initialization:
fputs("[record-sim] init \(segment.relativePath) bytes=\(segment.byteCount)\n", stderr)
case .media:
let duration = segment.durationSeconds ?? 0
fputs(String(format: "[record-sim] segment %@ duration=%.3fs bytes=%d\n", segment.relativePath, duration, segment.byteCount), stderr)
}
}
try recorder.start()
try recorder.waitUntilFirstFrame()
fputs("[record-sim] recording -> \(options.output!)\n", stderr)
while !isStopRequested() {
Thread.sleep(forTimeInterval: 0.1)
}
if requestedStopSignal != 0 {
let signalName = switch requestedStopSignal {
case SIGINT: "SIGINT"
case SIGTERM: "SIGTERM"
default: "signal \(requestedStopSignal)"
}
fputs("[record-sim] received \(signalName); finishing recording\n", stderr)
}
let manifest = try recorder.stop()
if let recording = manifest.recording {
fputs("[record-sim] done recording=\(recording)\n", stderr)
} else {
fputs("[record-sim] done segments=\(manifest.segments.count)\n", stderr)
}
fputs("[record-sim] manifest \(URL(fileURLWithPath: options.output!).appendingPathComponent("session.json").path)\n", stderr)
} catch {
fputs("error: \(error.localizedDescription)\n", stderr)
exit(1)
}
@@ -1,25 +0,0 @@
import bunyan from 'bunyan';
export function createLogger(): bunyan {
const logger = {
info: () => {},
debug: () => {},
error: () => {},
warn: () => {},
child: _fields => logger,
} as bunyan;
return logger;
}
export enum LoggerLevel {
TRACE = 'trace',
DEBUG = 'debug',
INFO = 'info',
WARN = 'warn',
ERROR = 'error',
FATAL = 'fatal',
}
export enum PipeMode {
COMBINED = 'combined',
}
-21
View File
@@ -1,21 +0,0 @@
import { fs } from 'memfs';
const fsRealpath = fs.realpath;
(fsRealpath as any).native = fsRealpath;
const fsRm = (
path: string,
options: object,
callback: (err: NodeJS.ErrnoException | null) => void
): void => {
fs.promises
.rm(path, options)
.then(() => {
callback(null);
})
.catch(err => {
callback(err);
});
};
module.exports = { ...fs, realpath: fsRealpath, rm: fsRm };
@@ -1,7 +0,0 @@
import { fs } from 'memfs';
const fsRm = (path: string, options: object): Promise<void> => {
return fs.promises.rm(path, options);
};
module.exports = { ...fs.promises, rm: fsRm };
@@ -1,237 +0,0 @@
import {
BuildPhase,
BuildPhaseResult,
BuildTrigger,
Job,
Metadata,
Platform,
} 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 });
const ctx = new BuildContext(
{
triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION,
secrets: {
robotAccessToken,
environmentSecrets: [
{
name: 'TEST_SECRET',
value: 'test-secret-value',
},
],
},
} as Job,
{
env: {
__API_SERVER_URL: 'http://api.expo.test',
},
workingdir: '/workingdir',
logger: createMockLogger(),
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
uploadArtifact: jest.fn(),
}
);
ctx.updateJobInformation({} as Job, {} as Metadata);
expect(ctx.job.secrets).toEqual({
robotAccessToken,
environmentSecrets: [
{
name: 'TEST_SECRET',
value: 'test-secret-value',
},
],
});
const newRobotAccessToken = randomUUID();
ctx.updateJobInformation(
{
secrets: {
robotAccessToken: newRobotAccessToken,
environmentSecrets: [
{
name: 'TEST_SECRET',
value: 'new-test-secret-value',
},
{
name: 'TEST_SECRET_2',
value: 'test-secret-value-2',
},
],
},
} as Job,
{} as Metadata
);
expect(ctx.job.secrets).toEqual({
robotAccessToken: newRobotAccessToken,
environmentSecrets: [
{ name: 'TEST_SECRET', value: 'test-secret-value' },
{ name: 'TEST_SECRET', value: 'new-test-secret-value' },
{ name: 'TEST_SECRET_2', value: 'test-secret-value-2' },
],
});
});
it('should not lose workflowInterpolationContext', async () => {
const robotAccessToken = randomUUID();
await vol.promises.mkdir('/workingdir/eas-environment-secrets/', { recursive: true });
const ctx = new BuildContext(
{
triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION,
secrets: {
robotAccessToken,
environmentSecrets: [
{
name: 'TEST_SECRET',
value: 'test-secret-value',
},
],
},
workflowInterpolationContext: {
foo: 'bar',
} as any,
} as Job,
{
env: {
__API_SERVER_URL: 'http://api.expo.test',
},
workingdir: '/workingdir',
logger: createMockLogger(),
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
uploadArtifact: jest.fn(),
}
);
ctx.updateJobInformation({} as Job, {} as Metadata);
expect(ctx.job.workflowInterpolationContext).toEqual({
foo: 'bar',
});
});
it('overwrites existing environment variables when updating env', async () => {
await vol.promises.mkdir('/workingdir/eas-environment-secrets/', { recursive: true });
const ctx = new BuildContext(
{
triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION,
secrets: {},
} as Job,
{
env: {
__API_SERVER_URL: 'http://api.expo.test',
EXISTING_ENV: 'old-value',
REMOVED_ENV: 'old-value',
},
workingdir: '/workingdir',
logger: createMockLogger(),
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
uploadArtifact: jest.fn(),
}
);
ctx.updateEnv({
EXISTING_ENV: 'new-value',
REMOVED_ENV: undefined,
NEW_ENV: 'new-env-value',
});
expect(ctx.env.EXISTING_ENV).toBe('new-value');
expect(ctx.env.REMOVED_ENV).toBeUndefined();
expect(ctx.env.NEW_ENV).toBe('new-env-value');
});
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,160 +0,0 @@
import { BuildTrigger, Ios, Metadata } from '@expo/eas-build-job';
import { StepMetric } from '@expo/steps';
import { createTestIosJob } from './utils/job';
import { createMockLogger } from './utils/logger';
import { BuildContext } from '../context';
import { CustomBuildContext } from '../customBuildContext';
import { uploadStepMetricsToWwwAsync } from '../utils/stepMetrics';
jest.mock('../utils/stepMetrics');
const mockUploadStepMetricsToWwwAsync = jest.mocked(uploadStepMetricsToWwwAsync);
describe(CustomBuildContext, () => {
it('should not lose workflowInterpolationContext', () => {
const contextUploadArtifact = jest.fn();
const ctx = new BuildContext(
createTestIosJob({
triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION,
workflowInterpolationContext: {
foo: 'bar',
} as unknown as Ios.Job['workflowInterpolationContext'],
}),
{
env: {
__API_SERVER_URL: 'http://api.expo.test',
},
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
uploadArtifact: contextUploadArtifact,
workingdir: '',
}
);
const customContext = new CustomBuildContext(ctx);
expect(customContext.job.workflowInterpolationContext).toStrictEqual({
foo: 'bar',
});
customContext.updateJobInformation({} as Ios.Job, {} as Metadata);
expect(customContext.job.workflowInterpolationContext).toStrictEqual({
foo: 'bar',
});
});
describe('reportStepMetric', () => {
const expoApiV2BaseUrl = 'http://exp.test/--/api/v2/';
const workflowJobId = 'test-workflow-job-id';
const robotAccessToken = 'test-robot-token';
const sampleMetric: StepMetric = {
metricsId: 'eas/checkout',
result: 'success',
durationMs: 1000,
platform: 'linux',
};
function createCustomBuildContext(
overrides: {
expoApiV2BaseUrl?: string | undefined;
includeRobotAccessToken?: boolean;
includeWorkflowJobId?: boolean;
} = {}
): CustomBuildContext {
const job = createTestIosJob();
if (overrides.includeRobotAccessToken !== false) {
(job.secrets as any).robotAccessToken = robotAccessToken;
}
const env: Record<string, string> = {
__API_SERVER_URL: 'http://api.expo.test',
};
if (overrides.includeWorkflowJobId !== false) {
env.__WORKFLOW_JOB_ID = workflowJobId;
}
const ctx = new BuildContext(job, {
env,
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
uploadArtifact: jest.fn(),
workingdir: '/workingdir',
expoApiV2BaseUrl:
'expoApiV2BaseUrl' in overrides ? overrides.expoApiV2BaseUrl : expoApiV2BaseUrl,
});
return new CustomBuildContext(ctx);
}
beforeEach(() => {
jest.clearAllMocks();
mockUploadStepMetricsToWwwAsync.mockResolvedValue(undefined);
});
it('calls uploadStepMetricsToWwwAsync when all required fields are present', () => {
const customCtx = createCustomBuildContext();
customCtx.reportStepMetric(sampleMetric);
expect(mockUploadStepMetricsToWwwAsync).toHaveBeenCalledTimes(1);
expect(mockUploadStepMetricsToWwwAsync).toHaveBeenCalledWith(
expect.objectContaining({
workflowJobId,
robotAccessToken,
expoApiV2BaseUrl,
stepMetrics: [sampleMetric],
})
);
});
it('does not call upload when workflowJobId is missing', () => {
const customCtx = createCustomBuildContext({ includeWorkflowJobId: false });
customCtx.reportStepMetric(sampleMetric);
expect(mockUploadStepMetricsToWwwAsync).not.toHaveBeenCalled();
});
it('does not call upload when robotAccessToken is missing', () => {
const customCtx = createCustomBuildContext({ includeRobotAccessToken: false });
customCtx.reportStepMetric(sampleMetric);
expect(mockUploadStepMetricsToWwwAsync).not.toHaveBeenCalled();
});
it('does not call upload when expoApiV2BaseUrl is missing', () => {
const customCtx = createCustomBuildContext({ expoApiV2BaseUrl: undefined });
customCtx.reportStepMetric(sampleMetric);
expect(mockUploadStepMetricsToWwwAsync).not.toHaveBeenCalled();
});
it('drainPendingMetricUploads awaits all pending uploads', async () => {
let resolveUpload!: () => void;
const uploadPromise = new Promise<void>(resolve => {
resolveUpload = resolve;
});
mockUploadStepMetricsToWwwAsync.mockReturnValue(uploadPromise);
const customCtx = createCustomBuildContext();
customCtx.reportStepMetric(sampleMetric);
let drained = false;
const drainPromise = customCtx.drainPendingMetricUploads().then(() => {
drained = true;
});
await new Promise(r => setImmediate(r));
expect(drained).toBe(false);
resolveUpload();
await drainPromise;
expect(drained).toBe(true);
});
it('drainPendingMetricUploads settles even if an upload rejects', async () => {
mockUploadStepMetricsToWwwAsync.mockRejectedValue(new Error('upload failed'));
const customCtx = createCustomBuildContext();
customCtx.reportStepMetric(sampleMetric);
await expect(customCtx.drainPendingMetricUploads()).resolves.not.toThrow();
});
});
});
@@ -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,159 +0,0 @@
import { BuildPhase } from '@expo/eas-build-job';
import { BuildStepGlobalContext, StepsConfigParser } from '@expo/steps';
import { runGenericJobAsync } from '../generic';
import { CustomBuildContext } from '../customBuildContext';
import { uploadJobOutputsToWwwAsync } from '../utils/outputs';
jest.mock('../customBuildContext');
jest.mock('../utils/outputs');
jest.mock('../common/projectSources');
jest.mock('../steps/easFunctions', () => ({ getEasFunctions: jest.fn().mockReturnValue([]) }));
jest.mock('../steps/easFunctionGroups', () => ({
getEasFunctionGroups: jest.fn().mockReturnValue([]),
}));
jest.mock('@expo/steps');
jest.mock('fs/promises');
const mockUploadJobOutputsToWwwAsync = jest.mocked(uploadJobOutputsToWwwAsync);
describe(runGenericJobAsync, () => {
const expoApiV2BaseUrl = 'http://exp.test/--/api/v2/';
let mockDrainPendingMetricUploads: jest.Mock;
let mockCtx: any;
beforeEach(() => {
jest.clearAllMocks();
mockDrainPendingMetricUploads = jest.fn().mockResolvedValue(undefined);
(CustomBuildContext as unknown as jest.Mock).mockImplementation(() => ({
projectSourceDirectory: '/tmp/src',
env: { __WORKFLOW_JOB_ID: 'test-workflow-job-id' },
drainPendingMetricUploads: mockDrainPendingMetricUploads,
}));
(BuildStepGlobalContext as unknown as jest.Mock).mockImplementation(() => ({
env: { __WORKFLOW_JOB_ID: 'test-workflow-job-id' },
}));
mockCtx = {
expoApiV2BaseUrl,
job: {
secrets: { robotAccessToken: 'test-token' },
steps: [],
},
logger: {
info: jest.fn(),
debug: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
child: jest.fn().mockReturnThis(),
},
runBuildPhase: jest.fn(async (_phase: BuildPhase, fn: () => Promise<any>) => fn()),
};
mockUploadJobOutputsToWwwAsync.mockResolvedValue(undefined);
});
it('awaits drainPendingMetricUploads in COMPLETE_JOB phase', async () => {
const mockWorkflow = { executeAsync: jest.fn().mockResolvedValue(undefined) };
(StepsConfigParser as unknown as jest.Mock).mockImplementation(() => ({
parseAsync: jest.fn().mockResolvedValue(mockWorkflow),
}));
let resolveDrain!: () => void;
mockDrainPendingMetricUploads.mockReturnValue(
new Promise<void>(resolve => {
resolveDrain = resolve;
})
);
let resolved = false;
const resultPromise = runGenericJobAsync(mockCtx).then(() => {
resolved = true;
});
await new Promise(r => setImmediate(r));
expect(resolved).toBe(false);
resolveDrain();
await resultPromise;
expect(resolved).toBe(true);
});
it('awaits drainPendingMetricUploads even when outputs upload fails', async () => {
const mockWorkflow = { executeAsync: jest.fn().mockResolvedValue(undefined) };
(StepsConfigParser as unknown as jest.Mock).mockImplementation(() => ({
parseAsync: jest.fn().mockResolvedValue(mockWorkflow),
}));
mockUploadJobOutputsToWwwAsync.mockRejectedValue(new Error('outputs upload failed'));
let resolveDrain!: () => void;
mockDrainPendingMetricUploads.mockReturnValue(
new Promise<void>(resolve => {
resolveDrain = resolve;
})
);
let rejected = false;
const resultPromise = runGenericJobAsync(mockCtx).catch(err => {
rejected = true;
throw err;
});
await new Promise(r => setImmediate(r));
expect(rejected).toBe(false);
resolveDrain();
await expect(resultPromise).rejects.toThrow('outputs upload failed');
expect(rejected).toBe(true);
});
it('passes the job hooks into the steps parser', async () => {
// @expo/steps is fully mocked here — assert the plumbing only (real
// insertion behavior is covered by the parser's own tests).
const mockWorkflow = { executeAsync: jest.fn().mockResolvedValue(undefined) };
(StepsConfigParser as unknown as jest.Mock).mockImplementation(() => ({
parseAsync: jest.fn().mockResolvedValue(mockWorkflow),
}));
mockCtx.job.hooks = { before_install_node_modules: [{ run: 'echo hi' }] };
await runGenericJobAsync(mockCtx);
expect(StepsConfigParser).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ hooks: mockCtx.job.hooks })
);
});
it('passes hooks: undefined into the steps parser for a hook-less job (dormancy)', async () => {
const mockWorkflow = { executeAsync: jest.fn().mockResolvedValue(undefined) };
(StepsConfigParser as unknown as jest.Mock).mockImplementation(() => ({
parseAsync: jest.fn().mockResolvedValue(mockWorkflow),
}));
await runGenericJobAsync(mockCtx);
expect(StepsConfigParser).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ hooks: undefined })
);
});
it('throws before workflow execution when expoApiV2BaseUrl is missing', async () => {
mockCtx.expoApiV2BaseUrl = undefined;
const mockWorkflow = { executeAsync: jest.fn() };
(StepsConfigParser as unknown as jest.Mock).mockImplementation(() => ({
parseAsync: jest.fn().mockResolvedValue(mockWorkflow),
}));
await expect(runGenericJobAsync(mockCtx)).rejects.toThrow(
'expoApiV2BaseUrl is required for generic jobs'
);
expect(CustomBuildContext).not.toHaveBeenCalled();
});
});
@@ -1,329 +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 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();
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' });
jest.mocked(fetch).mockReset();
});
it('fetches runtime settings from the staging and production buckets', async () => {
jest.mocked(fetch).mockResolvedValue(response({}));
await RuntimeSettings.loadAsync({ environment: 'staging' });
await RuntimeSettings.loadAsync({ environment: 'production' });
await RuntimeSettings.loadAsync({ environment: 'test' });
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' });
mockProcessPlatform('linux');
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
expect(RuntimeSettings.isUsingIosPrecompiledModulesEnabled()).toBe(false);
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' });
await RuntimeSettings.loadAsync({ environment: 'staging' });
mockProcessPlatform('linux');
expect(RuntimeSettings.getNpmCacheUrl()).toBe('https://npm.example');
expect(RuntimeSettings.isUsingIosPrecompiledModulesEnabled()).toBe(false);
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' });
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' });
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 job environment flags to enable cache URLs from worker environment variables', async () => {
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
await RuntimeSettings.loadAsync({
environment: 'test',
env: {
EAS_USE_NPM_CACHE: '1',
},
});
mockProcessPlatform('linux');
expect(RuntimeSettings.getNpmCacheUrl()).toBe('https://npm.example');
});
it('returns null when job env enables cache but worker env var is empty', async () => {
process.env.EAS_NPM_CACHE_URL = '';
await RuntimeSettings.loadAsync({
environment: 'test',
env: {
EAS_USE_NPM_CACHE: '1',
},
});
mockProcessPlatform('linux');
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
});
it('does not use cache URLs from the job environment', async () => {
await RuntimeSettings.loadAsync({
environment: 'test',
env: {
EAS_USE_NPM_CACHE: '1',
EAS_NPM_CACHE_URL: 'https://job-npm.example',
},
});
mockProcessPlatform('linux');
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
});
it('does not enable cache when runtime settings explicitly disable it, even with job env flag', async () => {
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
jest.mocked(fetch).mockResolvedValue(response({ caches: { linux: { npm: false } } }));
await RuntimeSettings.loadAsync({
environment: 'staging',
env: {
EAS_USE_NPM_CACHE: '1',
},
});
mockProcessPlatform('linux');
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
});
it('disables cache when EAS_BUILD_DISABLE_NPM_CACHE is set', async () => {
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
jest.mocked(fetch).mockResolvedValue(response({ caches: { linux: { npm: true } } }));
await RuntimeSettings.loadAsync({
environment: 'staging',
env: {
EAS_BUILD_DISABLE_NPM_CACHE: '1',
},
});
mockProcessPlatform('linux');
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
});
it('disables cache when EAS_BUILD_DISABLE_MAVEN_CACHE is set', async () => {
process.env.EAS_MAVEN_CACHE_URL = 'https://maven.example';
jest.mocked(fetch).mockResolvedValue(response({ caches: { linux: { maven: true } } }));
await RuntimeSettings.loadAsync({
environment: 'staging',
env: {
EAS_BUILD_DISABLE_MAVEN_CACHE: '1',
},
});
mockProcessPlatform('linux');
expect(RuntimeSettings.getMavenCacheUrl()).toBeNull();
});
it('disables cache when EAS_BUILD_DISABLE_COCOAPODS_CACHE is set', async () => {
process.env.EAS_COCOAPODS_CACHE_URL = 'https://pods.example';
jest.mocked(fetch).mockResolvedValue(response({ caches: { darwin: { cocoapods: true } } }));
await RuntimeSettings.loadAsync({
environment: 'staging',
env: {
EAS_BUILD_DISABLE_COCOAPODS_CACHE: '1',
},
});
mockProcessPlatform('darwin');
expect(RuntimeSettings.getCocoapodsCacheUrl()).toBeNull();
});
it('allows job environment flags to disable remotely enabled cache URLs', async () => {
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
jest.mocked(fetch).mockResolvedValue(response({ caches: { linux: { npm: true } } }));
await RuntimeSettings.loadAsync({
environment: 'staging',
env: {
EAS_USE_NPM_CACHE: '0',
},
});
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' });
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;
}
@@ -1,116 +0,0 @@
import { bunyan } from '@expo/logger';
import {
BuildRuntimePlatform,
BuildStepContext,
BuildStepEnv,
BuildStepGlobalContext,
ExternalBuildContextProvider,
} from '@expo/steps';
import os from 'os';
import path from 'path';
import { v4 as uuidv4 } from 'uuid';
import { createTestIosJob } from './job';
import { createMockLogger } from './logger';
import { BuildContext } from '../../context';
import { CustomBuildContext } from '../../customBuildContext';
export class MockContextProvider implements ExternalBuildContextProvider {
private _env: BuildStepEnv = {};
constructor(
public readonly logger: bunyan,
public readonly runtimePlatform: BuildRuntimePlatform,
public readonly projectSourceDirectory: string,
public readonly projectTargetDirectory: string,
public readonly defaultWorkingDirectory: string,
public readonly buildLogsDirectory: string,
public readonly staticContextContent: Record<string, any> = {}
) {}
public get env(): BuildStepEnv {
return this._env;
}
public staticContext(): any {
return { ...this.staticContextContent };
}
public updateEnv(env: BuildStepEnv): void {
this._env = env;
}
}
interface BuildContextParams {
buildId?: string;
logger?: bunyan;
skipCleanup?: boolean;
runtimePlatform?: BuildRuntimePlatform;
projectSourceDirectory?: string;
projectTargetDirectory?: string;
relativeWorkingDirectory?: string;
staticContextContent?: Record<string, any>;
}
export function createStepContextMock({
buildId,
logger,
skipCleanup,
runtimePlatform,
projectSourceDirectory,
projectTargetDirectory,
relativeWorkingDirectory,
staticContextContent,
}: BuildContextParams = {}): BuildStepContext {
const globalCtx = createGlobalContextMock({
buildId,
logger,
skipCleanup,
runtimePlatform,
projectSourceDirectory,
projectTargetDirectory,
relativeWorkingDirectory,
staticContextContent,
});
return new BuildStepContext(globalCtx, {
logger: logger ?? createMockLogger(),
relativeWorkingDirectory,
});
}
export function createCustomBuildContextMock(): CustomBuildContext {
const ctx = new BuildContext(createTestIosJob({}), {
env: {
__API_SERVER_URL: 'http://api.expo.test',
},
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
uploadArtifact: jest.fn(),
workingdir: '',
});
return new CustomBuildContext(ctx);
}
export function createGlobalContextMock({
logger,
skipCleanup,
runtimePlatform,
projectSourceDirectory,
projectTargetDirectory,
relativeWorkingDirectory,
staticContextContent,
}: BuildContextParams = {}): BuildStepGlobalContext {
const resolvedProjectTargetDirectory =
projectTargetDirectory ?? path.join(os.tmpdir(), 'eas-build', uuidv4());
return new BuildStepGlobalContext(
new MockContextProvider(
logger ?? createMockLogger(),
runtimePlatform ?? BuildRuntimePlatform.LINUX,
projectSourceDirectory ?? '/non/existent/dir',
resolvedProjectTargetDirectory,
relativeWorkingDirectory
? path.resolve(resolvedProjectTargetDirectory, relativeWorkingDirectory)
: resolvedProjectTargetDirectory,
'/non/existent/dir',
staticContextContent ?? {}
),
skipCleanup ?? false
);
}
@@ -1,94 +0,0 @@
import {
Android,
ArchiveSourceType,
BuildMode,
BuildTrigger,
Ios,
Platform,
Workflow,
} from '@expo/eas-build-job';
import { randomUUID } from 'crypto';
const androidCredentials: Android.BuildSecrets['buildCredentials'] = {
keystore: {
dataBase64: 'MjEzNwo=',
keystorePassword: 'pass1',
keyAlias: 'alias',
keyPassword: 'pass2',
},
};
const iosCredentials: Ios.BuildCredentials = {
testapp: {
provisioningProfileBase64: '',
distributionCertificate: {
dataBase64: '',
password: '',
},
},
};
export function createTestAndroidJob({
buildCredentials = androidCredentials,
}: {
buildCredentials?: Android.BuildSecrets['buildCredentials'];
} = {}): Android.Job {
return {
mode: BuildMode.BUILD,
platform: Platform.ANDROID,
triggeredBy: BuildTrigger.EAS_CLI,
type: Workflow.GENERIC,
projectArchive: {
type: ArchiveSourceType.URL,
url: 'https://turtle-v2-test-fixtures.s3.us-east-2.amazonaws.com/project.tar.gz',
},
projectRootDirectory: '.',
applicationArchivePath: './android/app/build/outputs/apk/release/*.apk',
cache: {
clear: false,
disabled: false,
paths: [],
},
secrets: {
buildCredentials,
},
appId: randomUUID(),
initiatingUserId: randomUUID(),
};
}
export function createTestIosJob({
buildCredentials = iosCredentials,
triggeredBy = BuildTrigger.EAS_CLI,
workflowInterpolationContext,
}: {
buildCredentials?: Ios.BuildCredentials;
triggeredBy?: Ios.Job['triggeredBy'];
workflowInterpolationContext?: Ios.Job['workflowInterpolationContext'];
} = {}): Ios.Job {
return {
mode: BuildMode.BUILD,
platform: Platform.IOS,
triggeredBy,
type: Workflow.GENERIC,
projectArchive: {
type: ArchiveSourceType.URL,
url: 'https://turtle-v2-test-fixtures.s3.us-east-2.amazonaws.com/project.tar.gz',
},
scheme: 'turtlebareproj',
buildConfiguration: 'Release',
applicationArchivePath: './ios/build/*.ipa',
projectRootDirectory: '.',
cache: {
clear: false,
disabled: false,
paths: [],
},
secrets: {
buildCredentials,
},
appId: randomUUID(),
initiatingUserId: randomUUID(),
workflowInterpolationContext,
};
}
@@ -1,12 +0,0 @@
import { bunyan } from '@expo/logger';
export function createMockLogger({ logToConsole = false } = {}): bunyan {
const logger = {
info: jest.fn(logToConsole ? console.info : () => {}),
debug: jest.fn(logToConsole ? console.debug : () => {}),
error: jest.fn(logToConsole ? console.error : () => {}),
warn: jest.fn(logToConsole ? console.warn : () => {}),
child: jest.fn().mockImplementation(() => createMockLogger({ logToConsole })),
} as unknown as bunyan;
return logger;
}
@@ -1,147 +0,0 @@
import { AndroidConfig } from '@expo/config-plugins';
import { vol } from 'memfs';
import path from 'path';
import {
AndroidMetadataName,
androidGetNativelyDefinedChannelAsync,
androidGetNativelyDefinedRuntimeVersionAsync,
androidSetChannelNativelyAsync,
androidSetRuntimeVersionNativelyAsync,
} from '../expoUpdates';
jest.mock('fs');
const originalFs = jest.requireActual('fs');
const channel = 'easupdatechannel';
const manifestPath = '/app/android/app/src/main/AndroidManifest.xml';
afterEach(() => {
vol.reset();
});
describe(androidSetChannelNativelyAsync, () => {
it('sets the channel', async () => {
vol.fromJSON(
{
'android/app/src/main/AndroidManifest.xml': originalFs.readFileSync(
path.join(__dirname, 'fixtures/NoMetadataAndroidManifest.xml'),
'utf-8'
),
},
'/app'
);
const ctx = {
getReactNativeProjectDirectory: () => '/app',
job: { updates: { channel } },
logger: { info: () => {} },
};
await androidSetChannelNativelyAsync(ctx as any);
const newAndroidManifest = await AndroidConfig.Manifest.readAndroidManifestAsync(manifestPath);
const newValue = AndroidConfig.Manifest.getMainApplicationMetaDataValue(
newAndroidManifest,
AndroidMetadataName.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY
);
expect(newValue).toBeDefined();
expect(JSON.parse(newValue!)).toEqual({ 'expo-channel-name': channel });
});
});
describe(androidGetNativelyDefinedChannelAsync, () => {
it('gets the channel', async () => {
vol.fromJSON(
{
'android/app/src/main/AndroidManifest.xml': originalFs.readFileSync(
path.join(__dirname, 'fixtures/AndroidManifestWithChannel.xml'),
'utf-8'
),
},
'/app'
);
const ctx = {
getReactNativeProjectDirectory: () => '/app',
logger: { info: () => {} },
};
await expect(androidGetNativelyDefinedChannelAsync(ctx as any)).resolves.toBe('staging-123');
});
});
describe(androidGetNativelyDefinedRuntimeVersionAsync, () => {
it('gets the native runtime version', async () => {
vol.fromJSON(
{
'android/app/src/main/AndroidManifest.xml': originalFs.readFileSync(
path.join(__dirname, 'fixtures/AndroidManifestWithRuntimeVersion.xml'),
'utf-8'
),
},
'/app'
);
const ctx = {
getReactNativeProjectDirectory: () => '/app',
job: {},
logger: { info: () => {} },
};
const nativelyDefinedRuntimeVersion = await androidGetNativelyDefinedRuntimeVersionAsync(
ctx as any
);
expect(nativelyDefinedRuntimeVersion).toBe('exampleruntimeversion');
});
});
describe(androidSetRuntimeVersionNativelyAsync, () => {
it('sets the runtime version when nothing is set natively', async () => {
vol.fromJSON(
{
'android/app/src/main/AndroidManifest.xml': originalFs.readFileSync(
path.join(__dirname, 'fixtures/NoMetadataAndroidManifest.xml'),
'utf-8'
),
},
'/app'
);
const ctx = {
getReactNativeProjectDirectory: () => '/app',
job: {},
logger: { info: () => {} },
};
await androidSetRuntimeVersionNativelyAsync(ctx as any, '1.2.3');
const newAndroidManifest = await AndroidConfig.Manifest.readAndroidManifestAsync(manifestPath);
const newValue = AndroidConfig.Manifest.getMainApplicationMetaDataValue(
newAndroidManifest,
AndroidMetadataName.RUNTIME_VERSION
);
expect(newValue).toBe('1.2.3');
});
it('updates the runtime version when value is already set natively', async () => {
vol.fromJSON(
{
'android/app/src/main/AndroidManifest.xml': originalFs.readFileSync(
path.join(__dirname, 'fixtures/AndroidManifestWithRuntimeVersion.xml'),
'utf-8'
),
},
'/app'
);
const ctx = {
getReactNativeProjectDirectory: () => '/app',
job: {},
logger: { info: () => {} },
};
await androidSetRuntimeVersionNativelyAsync(ctx as any, '1.2.3');
const newAndroidManifest = await AndroidConfig.Manifest.readAndroidManifestAsync(manifestPath);
const newValue = AndroidConfig.Manifest.getMainApplicationMetaDataValue(
newAndroidManifest,
AndroidMetadataName.RUNTIME_VERSION
);
expect(newValue).toBe('1.2.3');
});
});
@@ -1,14 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.expo.mycoolapp">
<uses-permission android:name="android.permission.INTERNET"/>
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme">
<activity android:name=".MainActivity" android:launchMode="singleTask" android:label="@string/app_name" android:configChanges="keyboard|keyboardHidden|orientation|screenSize" android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<activity android:name="com.facebook.react.devsupport.DevSettingsActivity"/>
<meta-data android:name="expo.modules.updates.EXPO_RELEASE_CHANNEL" android:value="default"/>
<meta-data android:name="expo.modules.updates.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY" android:value="{&quot;expo-channel-name&quot;:&quot;staging-123&quot;}"/>
</application>
</manifest>
@@ -1,13 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.expo.mycoolapp">
<uses-permission android:name="android.permission.INTERNET"/>
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme">
<activity android:name=".MainActivity" android:launchMode="singleTask" android:label="@string/app_name" android:configChanges="keyboard|keyboardHidden|orientation|screenSize" android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<activity android:name="com.facebook.react.devsupport.DevSettingsActivity"/>
<meta-data android:name="expo.modules.updates.EXPO_RELEASE_CHANNEL" android:value="example_channel"/>
</application>
</manifest>
@@ -1,14 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.expo.mycoolapp">
<uses-permission android:name="android.permission.INTERNET"/>
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme">
<activity android:name=".MainActivity" android:launchMode="singleTask" android:label="@string/app_name" android:configChanges="keyboard|keyboardHidden|orientation|screenSize" android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<activity android:name="com.facebook.react.devsupport.DevSettingsActivity"/>
<meta-data android:name="expo.modules.updates.EXPO_RELEASE_CHANNEL" android:value="default"/>
<meta-data android:name="expo.modules.updates.EXPO_RUNTIME_VERSION" android:value="exampleruntimeversion"/>
</application>
</manifest>
@@ -1,27 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.expo.mycoolapp">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".MainApplication"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:allowBackup="true"
android:theme="@style/AppTheme">
<activity
android:name=".MainActivity"
android:launchMode="singleTask"
android:label="@string/app_name"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity android:name="com.facebook.react.devsupport.DevSettingsActivity" />
</application>
</manifest>
@@ -1,40 +0,0 @@
import { Android } from '@expo/eas-build-job';
import fs from 'fs-extra';
import nullthrows from 'nullthrows';
import path from 'path';
import { v4 as uuidv4 } from 'uuid';
import { BuildContext } from '../context';
async function restoreCredentials(ctx: BuildContext<Android.Job>): Promise<void> {
const { buildCredentials } = nullthrows(
ctx.job.secrets,
'Secrets must be defined for non-custom builds'
);
if (!buildCredentials) {
// TODO: sentry (should be detected earlier)
throw new Error('secrets are missing in the job object');
}
ctx.logger.info("Writing secrets to the project's directory");
const keystorePath = path.join(ctx.buildDirectory, `keystore-${uuidv4()}`);
await fs.writeFile(
keystorePath,
new Uint8Array(Buffer.from(buildCredentials.keystore.dataBase64, 'base64'))
);
const credentialsJson = {
android: {
keystore: {
keystorePath,
keystorePassword: buildCredentials.keystore.keystorePassword,
keyAlias: buildCredentials.keystore.keyAlias,
keyPassword: buildCredentials.keystore.keyPassword,
},
},
};
await fs.writeFile(
path.join(ctx.buildDirectory, 'credentials.json'),
JSON.stringify(credentialsJson)
);
}
export { restoreCredentials };
@@ -1,106 +0,0 @@
import { AndroidConfig } from '@expo/config-plugins';
import { BuildJob, Job } from '@expo/eas-build-job';
import assert from 'assert';
import fs from 'fs-extra';
import { BuildContext } from '../context';
export enum AndroidMetadataName {
UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY = 'expo.modules.updates.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY',
RUNTIME_VERSION = 'expo.modules.updates.EXPO_RUNTIME_VERSION',
}
export async function androidSetRuntimeVersionNativelyAsync(
ctx: BuildContext<Job>,
runtimeVersion: string
): Promise<void> {
const manifestPath = await AndroidConfig.Paths.getAndroidManifestAsync(
ctx.getReactNativeProjectDirectory()
);
if (!(await fs.pathExists(manifestPath))) {
throw new Error(`Couldn't find Android manifest at ${manifestPath}`);
}
const androidManifest = await AndroidConfig.Manifest.readAndroidManifestAsync(manifestPath);
const mainApp = AndroidConfig.Manifest.getMainApplicationOrThrow(androidManifest);
AndroidConfig.Manifest.addMetaDataItemToMainApplication(
mainApp,
AndroidMetadataName.RUNTIME_VERSION,
runtimeVersion,
'value'
);
await AndroidConfig.Manifest.writeAndroidManifestAsync(manifestPath, androidManifest);
}
export async function androidSetChannelNativelyAsync(ctx: BuildContext<BuildJob>): Promise<void> {
assert(ctx.job.updates?.channel, 'updates.channel must be defined');
const manifestPath = await AndroidConfig.Paths.getAndroidManifestAsync(
ctx.getReactNativeProjectDirectory()
);
if (!(await fs.pathExists(manifestPath))) {
throw new Error(`Couldn't find Android manifest at ${manifestPath}`);
}
const androidManifest = await AndroidConfig.Manifest.readAndroidManifestAsync(manifestPath);
const mainApp = AndroidConfig.Manifest.getMainApplicationOrThrow(androidManifest);
const stringifiedUpdatesRequestHeaders = AndroidConfig.Manifest.getMainApplicationMetaDataValue(
androidManifest,
AndroidMetadataName.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY
);
AndroidConfig.Manifest.addMetaDataItemToMainApplication(
mainApp,
AndroidMetadataName.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY,
JSON.stringify({
...JSON.parse(stringifiedUpdatesRequestHeaders ?? '{}'),
'expo-channel-name': ctx.job.updates.channel,
}),
'value'
);
await AndroidConfig.Manifest.writeAndroidManifestAsync(manifestPath, androidManifest);
}
export async function androidGetNativelyDefinedChannelAsync(
ctx: BuildContext<Job>
): Promise<string | null> {
const manifestPath = await AndroidConfig.Paths.getAndroidManifestAsync(
ctx.getReactNativeProjectDirectory()
);
if (!(await fs.pathExists(manifestPath))) {
return null;
}
const androidManifest = await AndroidConfig.Manifest.readAndroidManifestAsync(manifestPath);
const stringifiedUpdatesRequestHeaders = AndroidConfig.Manifest.getMainApplicationMetaDataValue(
androidManifest,
AndroidMetadataName.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY
);
try {
const updatesRequestHeaders = JSON.parse(stringifiedUpdatesRequestHeaders ?? '{}');
return updatesRequestHeaders['expo-channel-name'] ?? null;
} catch (err: any) {
throw new Error(
`Failed to parse ${AndroidMetadataName.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY} from AndroidManifest.xml: ${err.message}`
);
}
}
export async function androidGetNativelyDefinedRuntimeVersionAsync(
ctx: BuildContext<Job>
): Promise<string | null> {
const manifestPath = await AndroidConfig.Paths.getAndroidManifestAsync(
ctx.getReactNativeProjectDirectory()
);
if (!(await fs.pathExists(manifestPath))) {
return null;
}
const androidManifest = await AndroidConfig.Manifest.readAndroidManifestAsync(manifestPath);
return AndroidConfig.Manifest.getMainApplicationMetaDataValue(
androidManifest,
AndroidMetadataName.RUNTIME_VERSION
);
}
-112
View File
@@ -1,112 +0,0 @@
import { Android, Env, Job, Platform } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import spawn from '@expo/turtle-spawn';
import fs from 'fs-extra';
import path from 'path';
import { BuildContext } from '../context';
export async function ensureLFLineEndingsInGradlewScript<TJob extends Job>(
ctx: BuildContext<TJob>
): Promise<void> {
const gradlewPath = path.join(ctx.getReactNativeProjectDirectory(), 'android', 'gradlew');
const gradlewContent = await fs.readFile(gradlewPath, 'utf8');
if (gradlewContent.includes('\r')) {
ctx.logger.info('Replacing CRLF line endings with LF in gradlew script');
await fs.writeFile(gradlewPath, gradlewContent.replace(/\r\n/g, '\n'), 'utf8');
}
}
export async function runGradleCommand(
ctx: BuildContext<Job>,
{
logger,
gradleCommand,
androidDir,
extraEnv,
}: { logger: bunyan; gradleCommand: string; androidDir: string; extraEnv?: Env }
): Promise<void> {
logger.info(`Running 'gradlew ${gradleCommand}' in ${androidDir}`);
await fs.chmod(path.join(androidDir, 'gradlew'), 0o755);
const verboseFlag = ctx.env['EAS_VERBOSE'] === '1' ? '--info' : '';
const shouldResetOOMScore =
ctx.env.EAS_BUILD_RUNNER === 'eas-build' && process.platform === 'linux';
await spawn(
'bash',
[
'-c',
getGradleShellCommand({
gradleCommand,
oomScoreAdj: shouldResetOOMScore ? 0 : undefined,
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',
},
}
);
}
export function getGradleShellCommand({
gradleCommand,
oomScoreAdj,
verboseFlag,
}: {
gradleCommand: string;
oomScoreAdj?: number;
verboseFlag: string;
}): string {
const oomScoreAdjPrefix =
oomScoreAdj === undefined ? '' : `echo ${oomScoreAdj} > /proc/$$/oom_score_adj || true; `;
return `${oomScoreAdjPrefix}exec ./gradlew ${gradleCommand} --profile ${verboseFlag}`;
}
// Version envs should be set at the beginning of the build, but when building
// from github those values are resolved later.
function resolveVersionOverridesEnvs(ctx: BuildContext<Job>): Env {
const extraEnvs: Env = {};
if (
ctx.job.platform === Platform.ANDROID &&
ctx.job.version?.versionCode &&
!ctx.env.EAS_BUILD_ANDROID_VERSION_CODE
) {
extraEnvs.EAS_BUILD_ANDROID_VERSION_CODE = ctx.job.version.versionCode;
}
if (
ctx.job.platform === Platform.ANDROID &&
ctx.job.version?.versionName &&
!ctx.env.EAS_BUILD_ANDROID_VERSION_NAME
) {
extraEnvs.EAS_BUILD_ANDROID_VERSION_NAME = ctx.job.version.versionName;
}
return extraEnvs;
}
export function resolveGradleCommand(job: Android.Job): string {
if (job.gradleCommand) {
return job.gradleCommand;
} else if (job.developmentClient) {
return ':app:assembleDebug';
} else if (!job.buildType) {
return ':app:bundleRelease';
} else if (job.buildType === Android.BuildType.APK) {
return ':app:assembleRelease';
} else {
return ':app:bundleRelease';
}
}
@@ -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');
}
@@ -1,312 +0,0 @@
import { BuildMode, BuildPhase, Job, Platform, errors } from '@expo/eas-build-job';
import { vol } from 'memfs';
import path from 'path';
import { resolveBuildPhaseErrorAsync } from '../detectError';
jest.mock('fs');
const originalFs = jest.requireActual('fs');
afterEach(() => {
vol.reset();
});
describe(resolveBuildPhaseErrorAsync, () => {
it('detects log for corrupted npm package', async () => {
const fakeError = new Error();
const err = await resolveBuildPhaseErrorAsync(
fakeError,
[
'[stderr] WARN tarball tarball data for @typescript-eslint/[email protected] (sha512-cozo/GbwixVR0sgfHItz3t1yXu521yn71Wj6PlYCFA3WPhy51CUPkifFKfBis91bDclGmAY45hhaAXVjdn4new==) seems to be corrupted. Trying again.',
],
{
job: { platform: Platform.ANDROID } as Job,
phase: BuildPhase.INSTALL_DEPENDENCIES,
env: {},
},
'/fake/path'
);
expect(err.errorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
expect(err.message).toBe(
'Unknown error. See logs of the Install dependencies build phase for more information.'
);
expect(err.trackingCode).toBe('NPM_CORRUPTED_PACKAGE');
expect(err.metadata).toEqual({
packageName: '@typescript-eslint/[email protected]',
});
});
it('detects log for invalid bundler and reports it to user', async () => {
const fakeError = new Error();
const err = await resolveBuildPhaseErrorAsync(
fakeError,
[
"/System/Library/Frameworks/Ruby.framework/Versions/2.6/usr/lib/ruby/2.6.0/rubygems/dependency.rb:313:in `to_specs': Could not find 'bundler' (2.2.3) required by your /Users/expo/project/build/ios/Gemfile.lock. (Gem::MissingSpecVersionError)",
],
{
job: { platform: Platform.IOS } as Job,
phase: BuildPhase.RUN_FASTLANE,
env: {},
},
'/fake/path'
);
expect(err.errorCode).toBe('EAS_BUILD_UNSUPPORTED_BUNDLER_VERSION_ERROR');
expect(err.message).toBe(
'Your project requires a different version of the Ruby "bundler" program than the version installed in this EAS Build environment. You can specify which version of "bundler" to install by specifying the version under "build"→[buildProfileName]→"ios"→"bundler" in eas.json.'
);
expect(err.trackingCode).toBeUndefined();
expect(err.metadata).toBeUndefined();
});
it('does not detect errors if they show up in different build phase', async () => {
const fakeError = new Error();
const err = await resolveBuildPhaseErrorAsync(
fakeError,
[
"/System/Library/Frameworks/Ruby.framework/Versions/2.6/usr/lib/ruby/2.6.0/rubygems/dependency.rb:313:in `to_specs': Could not find 'bundler' (2.2.3) required by your /Users/expo/project/build/ios/Gemfile.lock. (Gem::MissingSpecVersionError)",
],
{
job: { platform: Platform.IOS } as Job,
phase: BuildPhase.INSTALL_DEPENDENCIES, // it should be in RUN_FASTLANE
env: {},
},
'/fake/path'
);
expect(err.errorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
expect(err.message).toBe(
'Unknown error. See logs of the Install dependencies build phase for more information.'
);
expect(err.trackingCode).toBeUndefined();
});
it('detects npm cache error if cache is enabled', async () => {
const mockEnv = {
EAS_BUILD_NPM_CACHE_URL: 'https://dominik.sokal.pl/npm/cache',
};
const fakeError = new Error();
const err = await resolveBuildPhaseErrorAsync(
fakeError,
[`Blah blah Error ... ${mockEnv.EAS_BUILD_NPM_CACHE_URL}`],
{
job: { platform: Platform.ANDROID } as Job,
phase: BuildPhase.INSTALL_DEPENDENCIES,
env: mockEnv,
},
'/fake/path'
);
expect(err.errorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
expect(err.message).toBe(
'Unknown error. See logs of the Install dependencies build phase for more information.'
);
expect(err.trackingCode).toBe('NPM_CACHE_ERROR');
});
it('does not detect npm cache error if cache is disabled', async () => {
const mockEnv = {
EAS_BUILD_NPM_CACHE_URL: 'https://dominik.sokal.pl/npm/cache',
};
const fakeError = new Error();
const err = await resolveBuildPhaseErrorAsync(
fakeError,
[`Blah blah Error ... ${mockEnv.EAS_BUILD_NPM_CACHE_URL}`],
{
job: { platform: Platform.ANDROID } as Job,
phase: BuildPhase.INSTALL_DEPENDENCIES,
env: {},
},
'/fake/path'
);
expect(err.errorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
expect(err.message).toBe(
'Unknown error. See logs of the Install dependencies build phase for more information.'
);
expect(err.trackingCode).toBeUndefined();
});
it('detects xcode line error', async () => {
vol.fromJSON({
'/path/to/xcodelogs.log': originalFs.readFileSync(
path.resolve('./src/buildErrors/__tests__/fixtures/xcode.log'),
'utf-8'
),
});
const fakeError = new Error();
const err = await resolveBuildPhaseErrorAsync(
fakeError,
[''],
{
job: { platform: Platform.IOS } as Job,
phase: BuildPhase.RUN_FASTLANE,
env: {},
},
'/path/to/'
);
expect(err.errorCode).toBe('XCODE_RESOURCE_BUNDLE_CODE_SIGNING_ERROR');
expect(err.trackingCode).toBeUndefined();
});
it('detects minimum deployment target error correctly', async () => {
const fakeError = new Error();
const err = await resolveBuildPhaseErrorAsync(
fakeError,
[
'CocoaPods could not find compatible versions for pod "react-native-google-maps":16 In Podfile:17 react-native-google-maps (from `/Users/expo/workingdir/build/node_modules/react-native-maps`)18Specs satisfying the `react-native-google-maps (from `/Users/expo/workingdir/build/node_modules/react-native-maps`)` dependency were found, but they required a higher minimum deployment target.19Error: Compatible versions of some pods could not be resolved.',
],
{
job: { platform: Platform.IOS } as Job,
phase: BuildPhase.INSTALL_PODS,
env: {},
},
'/fake/path'
);
expect(err.errorCode).toBe('EAS_BUILD_HIGHER_MINIMUM_DEPLOYMENT_TARGET_ERROR');
expect(err.trackingCode).toBeUndefined();
});
it('detects provisioning profile mismatch error correctly', async () => {
const fakeError = new Error();
const err = await resolveBuildPhaseErrorAsync(
fakeError,
[
`No provisioning profile for application: '_floatsignTemp/Payload/EcoBatteryPREVIEW.app' with bundle identifier 'com.ecobattery.ecobattery-preview'`,
],
{
job: { platform: Platform.IOS, mode: BuildMode.RESIGN } as Job,
phase: BuildPhase.RUN_FASTLANE,
env: {},
},
'/fake/path'
);
expect(err.errorCode).toBe('EAS_BUILD_RESIGN_PROVISIONING_PROFILE_MISMATCH_ERROR');
expect(err.trackingCode).toBeUndefined();
});
it('detects generic "Run Fastlane" error for resign correctly', async () => {
const fakeError = new Error();
const err = await resolveBuildPhaseErrorAsync(
fakeError,
[`other error`],
{
job: { platform: Platform.IOS, mode: BuildMode.RESIGN } as Job,
phase: BuildPhase.RUN_FASTLANE,
env: {},
},
'/fake/path'
);
expect(err.errorCode).toBe('EAS_BUILD_UNKNOWN_FASTLANE_RESIGN_ERROR');
expect(err.trackingCode).toBeUndefined();
});
it('detects build error in "Run Fastlane" phase correctly', async () => {
const fakeError = new Error();
const err = await resolveBuildPhaseErrorAsync(
fakeError,
[`some build error`],
{
job: { platform: Platform.IOS, mode: BuildMode.BUILD } as Job,
phase: BuildPhase.RUN_FASTLANE,
env: {},
},
'/fake/path'
);
expect(err.errorCode).toBe('EAS_BUILD_UNKNOWN_FASTLANE_ERROR');
expect(err.trackingCode).toBeUndefined();
});
it('detects provisioning profile mismatch error correctly', async () => {
const xcodeLogs = `Intermediates.noindex/Pods.build/Release-iphonesimulator/Yoga.build/Objects-normal/arm64/82b82416624d2658e5098eb0a28c15c5-common-args.resp
-target arm64-apple-ios15.0-simulator '-std=gnu++14' '-stdlib=libc++' -fmodules '-fmodules-cache-path=/Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/ModuleCache.noindex' '-fmodule-name=yoga' -fpascal-strings -Os -fno-common '-DPOD_CONFIGURATION_RELEASE=1' '-DCOCOAPODS=1' -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator18.1.sdk -g -iquote /Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/Build/Intermediates.noindex/Pods.build/Release-iphonesimulator/Yoga.build/Yoga-generated-files.hmap -I/Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/Build/Intermediates.noindex/Pods.build/Release-iphonesimulator/Yoga.build/Yoga-own-target-headers.hmap -I/Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/Build/Intermediates.noindex/Pods.build/Release-iphonesimulator/Yoga.build/Yoga-all-non-framework-target-headers.hmap -ivfsoverlay /Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/Build/Intermediates.noindex/Pods.build/Release-iphonesimulator/Pods-8699adb1dd336b26511df848a716bd42-VFS-iphonesimulator/all-product-headers.yaml -iquote /Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/Build/Intermediates.noindex/Pods.build/Release-iphonesimulator/Yoga.build/Yoga-project-headers.hmap -I/Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/Build/Products/Release-iphonesimulator/Yoga/include -I/Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/Pods/Headers/Private -I/Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/Pods/Headers/Private/Yoga -I/Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/Pods/Headers/Public -I/Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/Pods/Headers/Public/Yoga -I/Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/Build/Intermediates.noindex/Pods.build/Release-iphonesimulator/Yoga.build/DerivedSources-normal/arm64 -I/Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/Build/Intermediates.noindex/Pods.build/Release-iphonesimulator/Yoga.build/DerivedSources/arm64 -I/Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/Build/Intermediates.noindex/Pods.build/Release-iphonesimulator/Yoga.build/DerivedSources -F/Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/Build/Products/Release-iphonesimulator/Yoga
MkDir /Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/Build/Products/Release-iphonesimulator/expo-dev-menu/EXDevMenu.bundle (in target 'expo-dev-menu-EXDevMenu' from project 'Pods')
cd /Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/Pods
/bin/mkdir -p /Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/Build/Products/Release-iphonesimulator/expo-dev-menu/EXDevMenu.bundle
MkDir /Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/Build/Products/Release-iphonesimulator/expo-dev-launcher/EXDevLauncher.bundle (in target 'expo-dev-launcher-EXDevLauncher' from project 'Pods')
cd /Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/Pods
/bin/mkdir -p /Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/Build/Products/Release-iphonesimulator/expo-dev-launcher/EXDevLauncher.bundle
ProcessXCFramework /Users/expo/workingdir/build/packages/video/ios/Vendor/dependency/ProgrammaticAccessLibrary.xcframework /Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/Build/Products/Release-iphonesimulator/ProgrammaticAccessLibrary.framework ios simulator
cd /Users/expo/workingdir/build/packages/apps/NFLNetwork/ios
builtin-process-xcframework --xcframework /Users/expo/workingdir/build/packages/video/ios/Vendor/dependency/ProgrammaticAccessLibrary.xcframework --platform ios --environment simulator --target-path /Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/Build/Products/Release-iphonesimulator
/Users/expo/workingdir/build/packages/video/ios/Vendor/dependency/ProgrammaticAccessLibrary.xcframework:1:1: error: The signature of “ProgrammaticAccessLibrary.xcframework” cannot be verified.
note: A sealed resource is missing or invalid
note: /Users/expo/workingdir/build/packages/video/ios/Vendor/dependency/ProgrammaticAccessLibrary.xcframework: a sealed resource is missing or invalid
file modified: /Users/expo/workingdir/build/packages/video/ios/Vendor/dependency/ProgrammaticAccessLibrary.xcframework/ios-arm64_x86_64-simulator/ProgrammaticAccessLibrary.framework/ProgrammaticAccessLibrary
file modified: /Users/expo/workingdir/build/packages/video/ios/Vendor/dependency/ProgrammaticAccessLibrary.xcframework/ios-arm64/ProgrammaticAccessLibrary.framework/ProgrammaticAccessLibrary
error: Some other error
log
log
log
error: The last one
log
note`;
vol.fromJSON({
'/path/to/xcode.log': xcodeLogs,
});
const fakeError = new Error();
const err = await resolveBuildPhaseErrorAsync(
fakeError,
[`some logs`],
{
job: { platform: Platform.IOS, mode: BuildMode.BUILD } as Job,
phase: BuildPhase.RUN_FASTLANE,
env: {},
},
'/path/to'
);
expect(err.errorCode).toBe('XCODE_BUILD_ERROR');
expect(err.trackingCode).toBeUndefined();
expect(err.message)
.toBe(`The "Run fastlane" step failed because of an error in the Xcode build process. We automatically detected following errors in your Xcode build logs:
- The signature of “ProgrammaticAccessLibrary.xcframework” cannot be verified.
- Some other error
- The last one
Refer to "Xcode Logs" below for additional, more detailed logs.`);
});
it('detects MAVEN_CACHE_ERROR correctly', async () => {
const err = await resolveBuildPhaseErrorAsync(
new Error(),
[`https://szymon.pl/maven/cache`],
{
job: { platform: Platform.ANDROID, mode: BuildMode.BUILD } as Job,
phase: BuildPhase.RUN_GRADLEW,
env: {
EAS_BUILD_MAVEN_CACHE_URL: 'https://szymon.pl/maven/cache',
},
},
'/fake/path'
);
expect(err.errorCode).toBe('EAS_BUILD_UNKNOWN_GRADLE_ERROR');
expect(err.trackingCode).toBe('MAVEN_CACHE_ERROR');
expect(err.message).toBe(
`Gradle build failed with unknown error. See logs for the "Run gradlew" phase for more information.`
);
});
it('does not throw MAVEN_CACHE_ERROR if "Could not find BlurView-version-2.0.3.jar" log is present', async () => {
const err = await resolveBuildPhaseErrorAsync(
new Error(),
[`Could not find BlurView-version-2.0.3.jar sth sthelse https://szymon.pl/maven/cache`],
{
job: { platform: Platform.ANDROID, mode: BuildMode.BUILD } as Job,
phase: BuildPhase.RUN_GRADLEW,
env: {
EAS_BUILD_MAVEN_CACHE_URL: 'https://szymon.pl/maven/cache',
},
},
'/fake/path'
);
expect(err.errorCode).toBe('EAS_BUILD_UNKNOWN_GRADLE_ERROR');
expect(err.trackingCode).toBeUndefined();
expect(err.message).toBe(
`Gradle build failed with unknown error. See logs for the "Run gradlew" phase for more information.`
);
});
});
@@ -1 +0,0 @@
/Users/expo/workingdir/build/managed/ios/Pods/Pods.xcodeproj: error: Signing for "EXConstants-EXConstants" requires a development team. Select a development team in the Signing & Capabilities editor. (in target 'EXConstants-EXConstants' from project 'Pods')
@@ -1,364 +0,0 @@
import { BuildPhase, Platform } from '@expo/eas-build-job';
import escapeRegExp from 'lodash/escapeRegExp';
import { ErrorContext, ErrorHandler } from './errors.types';
export class TrackedBuildError extends Error {
constructor(
public errorCode: string,
public message: string,
public metadata?: Record<string, unknown>
) {
super(message);
}
}
export const buildErrorHandlers: ErrorHandler<TrackedBuildError>[] = [
{
platform: Platform.IOS,
phase: BuildPhase.INSTALL_PODS,
// example log:
// CDN: trunk URL couldn't be downloaded: https://cdn.jsdelivr.net/cocoa/Specs/2/a/e/MultiplatformBleAdapter/0.0.3/MultiplatformBleAdapter.podspec.json Response: 429 429: Too Many Requests
regexp: /CDN: trunk URL couldn't be downloaded.* Response: 429 429: Too Many Requests/,
createError: () =>
new TrackedBuildError('COCOAPODS_TO_MANY_REQUEST', 'cocoapods: too many requests'),
},
{
phase: BuildPhase.INSTALL_DEPENDENCIES,
// Host key verification failed.
// fatal: Could not read from remote repository.
regexp: /Host key verification failed\.\nfatal: Could not read from remote repository/,
createError: () => new TrackedBuildError('NPM_INSTALL_SSH_AUTHENTICATION', 'Missing ssh key.'),
},
{
phase: BuildPhase.INSTALL_DEPENDENCIES,
// error [email protected]: The engine "node" is incompatible with this module. Expected version "14". Got "16.13.2"
// error Found incompatible module.
regexp: /The engine "node" is incompatible with this module\. Expected version/,
createError: () =>
new TrackedBuildError('NODE_ENGINE_INCOMPATIBLE', 'node: Incompatible engine field.'),
},
{
phase: BuildPhase.INSTALL_DEPENDENCIES,
// error An unexpected error occurred: "https://registry.yarnpkg.com/@react-native/normalize-color/-/normalize-color-2.0.0.tgz: Request failed \"500 Internal Server Error\"".
// or
// error An unexpected error occurred: "https://registry.yarnpkg.com/request/-/request-2.88.2.tgz: Request failed \"503 Service Unavailable\"".
regexp: /An unexpected error occurred: "https:\/\/registry.yarnpkg.com\/.*Request failed \\"5/,
createError: () => new TrackedBuildError('YARN_REGISTRY_5XX_RESPONSE', 'yarn: 5xx response.'),
},
{
phase: BuildPhase.PREBUILD,
// Input is required, but Expo CLI is in non-interactive mode
// or
// CommandError: Input is required, but 'npx expo' is in non-interactive mode.
regexp: /Input is required, but .* is in non-interactive mode/,
createError: () =>
new TrackedBuildError(
'EXPO_CLI_INPUT_REQUIRED_ERROR',
`expo-cli: Input required in non-interactive mode.`
),
},
{
platform: Platform.IOS,
phase: BuildPhase.PREBUILD,
// [03:03:05] [ios.infoPlist]: withIosInfoPlistBaseMod: GoogleService-Info.plist is empty
regexp: /withIosInfoPlistBaseMod: GoogleService-Info\.plist is empty/,
createError: () =>
new TrackedBuildError(
'EXPO_CLI_EMPTY_GOOGLE_SERVICES_PLIST_ERROR',
`expo-cli: Empty GoogleService-Info.plist.`
),
},
{
platform: Platform.IOS,
phase: BuildPhase.PREBUILD,
// [01:52:04] [ios.xcodeproj]: withIosXcodeprojBaseMod: Path to GoogleService-Info.plist is not defined. Please specify the `expo.ios.googleServicesFile` field in app.json.
regexp: /withIosXcodeprojBaseMod: Path to GoogleService-Info\.plist is not defined/,
createError: () =>
new TrackedBuildError(
'EXPO_CLI_NOT_DEFINED_GOOGLE_SERVICES_PLIST_ERROR',
`expo-cli: Path to GoogleService-Info.plist is not defined.`
),
},
{
phase: BuildPhase.PREBUILD,
// Error: [android.dangerous]: withAndroidDangerousBaseMod: ENOENT: no such file or directory, open './assets/adaptive-icon.png'
// at Object.openSync (node:fs:585:3)
// at readFileSync (node:fs:453:35)
// at calculateHash (/home/expo/workingdir/build/node_modules/@expo/image-utils/build/Cache.js:14:91)
// at createCacheKey (/home/expo/workingdir/build/node_modules/@expo/image-utils/build/Cache.js:19:18)
// at Object.createCacheKeyWithDirectoryAsync (/home/expo/workingdir/build/node_modules/@expo/image-utils/build/Cache.js:24:33)
// at generateImageAsync (/home/expo/workingdir/build/node_modules/@expo/image-utils/build/Image.js:151:34)
// at async generateIconAsync (/home/expo/workingdir/build/node_modules/@expo/prebuild-config/build/plugins/icons/withAndroidIcons.js:369:11)
// at async /home/expo/workingdir/build/node_modules/@expo/prebuild-config/build/plugins/icons/withAndroidIcons.js:310:21
// at async Promise.all (index 0)
// at async generateMultiLayerImageAsync (/home/expo/workingdir/build/node_modules/@expo/prebuild-config/build/plugins/icons/withAndroidIcons.js:306:3)
// or
// Error: [ios.dangerous]: withIosDangerousBaseMod: ENOENT: no such file or directory, open './assets/images/app_icon_staging.png'
// at Object.openSync (fs.js:497:3)
// at readFileSync (fs.js:393:35)
// at calculateHash (/Users/expo/workingdir/build/node_modules/@expo/image-utils/build/Cache.js:14:91)
// at createCacheKey (/Users/expo/workingdir/build/node_modules/@expo/image-utils/build/Cache.js:19:18)
// at Object.createCacheKeyWithDirectoryAsync (/Users/expo/workingdir/build/node_modules/@expo/image-utils/build/Cache.js:24:33)
// at generateImageAsync (/Users/expo/workingdir/build/node_modules/@expo/image-utils/build/Image.js:151:34)
// at async setIconsAsync (/Users/expo/workingdir/build/node_modules/@expo/prebuild-config/build/plugins/icons/withIosIcons.js:169:15)
// at async /Users/expo/workingdir/build/node_modules/@expo/prebuild-config/build/plugins/icons/withIosIcons.js:71:5
// at async action (/Users/expo/workingdir/build/node_modules/@expo/config-plugins/build/plugins/withMod.js:235:23)
// at async interceptingMod (/Users/expo/workingdir/build/node_modules/@expo/config-plugins/build/plugins/withMod.js:126:21)
regexp:
/ENOENT: no such file or directory[\s\S]*prebuild-config\/build\/plugins\/icons\/with(Android|Ios)Icons\.js/,
createError: () => new TrackedBuildError('EXPO_CLI_MISSING_ICON', 'expo-cli: Missing icon.'),
},
{
phase: BuildPhase.PREBUILD,
// Cannot determine which native SDK version your project uses because the module `expo` is not installed. Please install it with `yarn add expo` and try again.
regexp:
/Cannot determine which native SDK version your project uses because the module `expo` is not installed/,
createError: () =>
new TrackedBuildError('EXPO_CLI_EXPO_PACKAGE_MISSING', 'expo-cli: "expo" package missing.'),
},
{
phase: BuildPhase.INSTALL_PODS,
// The Swift pod `FirebaseCoreInternal` depends upon `GoogleUtilities`, which does not define modules. To opt into those targets generating module maps (which is necessary to import them from Swift when building as static
regexp: /The Swift pod .* depends upon .* which does not define modules/,
createError: () =>
new TrackedBuildError(
'SWIFT_POD_INCOMPATIBLE_DEPENDENCY',
'pod: Swift pod depends on a pod that does not define modules.'
),
},
{
platform: Platform.IOS,
phase: BuildPhase.INSTALL_PODS,
// Adding spec repo `24-repository-cocoapods-proxy` with CDN `http://10.254.24.7:8081/repository/cocoapods-proxy/`
// [!] No podspec exists at path `/Users/expo/.cocoapods/repos/24-repository-cocoapods-proxy/Specs/1/9/2/libwebp/1.2.0/libwebp.podspec.json`.
regexp: /Adding spec repo .* with CDN .*\n\s*\[!\] No podspec exists at path `(.*)`/,
// Some pods are hosted on git registries that are not supported e.g. chromium.googlesource.com
createError: (match: RegExpMatchArray) =>
new TrackedBuildError(
'COCOAPODS_CACHE_INCOMPATIBLE_REPO_ERROR',
`cocoapods: Missing podspec ${match[1]}.`
),
},
{
platform: Platform.IOS,
phase: BuildPhase.INSTALL_PODS,
// [!] Invalid `Podfile` file: 783: unexpected token at 'info Run CLI with --verbose flag for more details.
regexp:
/\[!\] Invalid `Podfile` file: .* unexpected token at 'info Run CLI with --verbose flag for more details./,
createError: () =>
new TrackedBuildError('NODE_ENV_PRODUCTION_DEFINED', 'npm: NODE_ENV=production was defined.'),
},
...[BuildPhase.INSTALL_DEPENDENCIES, BuildPhase.PREBUILD].map(phase => ({
phase,
// example log:
// [stderr] WARN tarball tarball data for @typescript-eslint/[email protected] (sha512-cozo/GbwixVR0sgfHItz3t1yXu521yn71Wj6PlYCFA3WPhy51CUPkifFKfBis91bDclGmAY45hhaAXVjdn4new==) seems to be corrupted. Trying again.
regexp: /tarball tarball data for ([^ ]*) .* seems to be corrupted. Trying again/,
createError: (match: RegExpMatchArray) =>
new TrackedBuildError('NPM_CORRUPTED_PACKAGE', `npm: corrupted package ${match[1]}`, {
packageName: match[1],
}),
})),
...[BuildPhase.INSTALL_DEPENDENCIES, BuildPhase.PREBUILD].map(phase => ({
phase,
regexp: ({ env }: ErrorContext) =>
env.EAS_BUILD_NPM_CACHE_URL
? new RegExp(escapeRegExp(env.EAS_BUILD_NPM_CACHE_URL))
: undefined,
createError: () => new TrackedBuildError('NPM_CACHE_ERROR', `npm: cache error`),
})),
{
platform: Platform.IOS,
phase: BuildPhase.INSTALL_PODS,
regexp: ({ env }: ErrorContext) =>
env.EAS_BUILD_COCOAPODS_CACHE_URL ? /Error installing/ : undefined,
createError: () =>
new TrackedBuildError(
'COCOAPODS_CACHE_INSTALLING_POD_ERROR',
`cocoapods: error installing a pod using internal cache instance`
),
},
{
platform: Platform.IOS,
phase: BuildPhase.INSTALL_PODS,
regexp: ({ env }: ErrorContext) =>
env.EAS_BUILD_COCOAPODS_CACHE_URL ? /No podspec exists at path/ : undefined,
createError: () =>
new TrackedBuildError(
'COCOAPODS_CACHE_NO_PODSPEC_EXISTS_AT_PATH_ERROR',
`cocoapods: error fetching a podspec through internal cache instance`
),
},
{
platform: Platform.IOS,
phase: BuildPhase.INSTALL_PODS,
regexp: ({ env }: ErrorContext) =>
env.EAS_BUILD_COCOAPODS_CACHE_URL
? new RegExp(escapeRegExp(env.EAS_BUILD_COCOAPODS_CACHE_URL))
: undefined,
createError: () => new TrackedBuildError('COCOAPODS_CACHE_ERROR', `cocoapods: cache error`),
},
{
phase: BuildPhase.INSTALL_PODS,
// [!] Invalid `Podfile` file: uninitialized constant Pod::Podfile::FlipperConfiguration.
regexp: /\[!\] Invalid `Podfile` file/,
createError: () => new TrackedBuildError('INVALID_PODFILE', 'pod: Invalid Podfile file.'),
},
{
phase: BuildPhase.INSTALL_DEPENDENCIES,
// info There appears to be trouble with your network connection. Retrying...
regexp: /info There appears to be trouble with your network connection. Retrying/,
createError: () =>
new TrackedBuildError(
'YARN_INSTALL_TROUBLE_WITH_NETWORK_CONNECTION',
'yarn: There appears to be trouble with your network connection'
),
},
{
phase: BuildPhase.RUN_GRADLEW,
platform: Platform.ANDROID,
// Android Gradle plugin requires Java 11 to run. You are currently using Java 1.8
regexp: /Android Gradle plugin requires Java .* to run. You are currently using Java/,
createError: () =>
new TrackedBuildError('INCOMPATIBLE_JAVA_VERSION', 'gradle: Incompatible java version.'),
},
{
phase: BuildPhase.RUN_GRADLEW,
platform: Platform.ANDROID,
// /home/expo/workingdir/build/android/app/src/main/AndroidManifest.xml:27:9-33:20 Error:
// android:exported needs to be explicitly specified for element <activity#androidx.test.core.app.InstrumentationActivityInvoker$EmptyActivity>. Apps targeting Android 12 and higher are required to specify an explicit value for `android:exported` when the corresponding component has an intent filter defined. See https://developer.android.com/guide/topics/manifest/activity-element#exported for details.
regexp:
/Apps targeting Android 12 and higher are required to specify an explicit value for `android:exported`/,
createError: () =>
new TrackedBuildError(
'REQUIRE_EXPLICIT_EXPORTED_ANDROID_12',
'Apps targeting Android 12 and higher are required to specify an explicit value for `android:exported`.'
),
},
{
phase: BuildPhase.RUN_GRADLEW,
platform: Platform.ANDROID,
// > A failure occurred while executing com.android.build.gradle.internal.res.Aapt2CompileRunnable
// > Android resource compilation failed
// ERROR:/home/expo/workingdir/build/android/app/src/main/res/mipmap-mdpi/ic_launcher.png: AAPT: error: failed to read PNG signature: file does not start with PNG signature.
regexp: /AAPT: error: failed to read PNG signature: file does not start with PNG signature/,
createError: () =>
new TrackedBuildError('INVALID_PNG_SIGNATURE', 'gradle: Invalid PNG signature.'),
},
{
phase: BuildPhase.RUN_GRADLEW,
platform: Platform.ANDROID,
// Execution failed for task ':app:processReleaseGoogleServices'.
// > Malformed root json
regexp: /Execution failed for task ':app:process.*GoogleServices'.*\s.*Malformed root json/,
createError: () =>
new TrackedBuildError(
'GRADLE_MALFORMED_GOOGLE_SERVICES_JSON',
'gradle: Malformed google-services.json.'
),
},
{
phase: BuildPhase.RUN_GRADLEW,
platform: Platform.ANDROID,
// Execution failed for task ':app:processDebugGoogleServices'.
// > Missing project_info object
regexp:
/Execution failed for task ':app:process.*GoogleServices'.*\s.*Missing project_info object/,
createError: () =>
new TrackedBuildError(
'GRADLE_MALFORMED_GOOGLE_SERVICES_JSON',
'gradle: Missing project_info object.'
),
},
{
phase: BuildPhase.RUN_GRADLEW,
platform: Platform.ANDROID,
// Execution failed for task ':app:bundleReleaseJsAndAssets'.
// > Process 'command 'node'' finished with non-zero exit value 1
regexp:
/Execution failed for task ':app:bundleReleaseJsAndAssets'.*\s.*Process 'command 'node'' finished with non-zero exit value/,
createError: () =>
new TrackedBuildError(
'GRADLE_BUILD_BUNDLER_ERROR',
"gradle: ':app:bundleReleaseJsAndAssets' failed."
),
},
{
platform: Platform.ANDROID,
phase: BuildPhase.RUN_GRADLEW,
// > Could not resolve org.jetbrains.kotlin:kotlin-annotation-processing-gradle:1.5.10.
// Required by:
// project :expo-updates
// > Could not resolve org.jetbrains.kotlin:kotlin-annotation-processing-gradle:1.5.10.
// > Could not get resource 'https://lsdkfjsdlkjf.com/android/releases/org/jetbrains/kotlin/kotlin-annotation-processing-gradle/1.5.10/kotlin-annotation-processing-gradle-1.5.10.pom'.
// > Could not HEAD 'https://slkdfjldskjfl.com/android/releases/org/jetbrains/kotlin/kotlin-annotation-processing-gradle/1.5.10/kotlin-annotation-processing-gradle-1.5.10.pom'.
// > Connect to sdlkfjsdlkf.com:443 [slkdfjdslk.com/38.178.101.5] failed: connect timed out
regexp: /Could not get resource.*\n.*Could not HEAD 'https/,
createError: () =>
new TrackedBuildError('MAVEN_REGISTRY_CONNECTION_ERROR', `maven: registry connection error`),
},
{
platform: Platform.ANDROID,
phase: BuildPhase.RUN_GRADLEW,
regexp: ({ env }: ErrorContext) =>
env.EAS_BUILD_MAVEN_CACHE_URL
? // The BlurView 2.0.3 jar was removed from jitpack.io
// and it caused false positive MAVEN_CACHE_ERROR errors being reported.
new RegExp(
`^(?!.*Could not find BlurView-version-2\\.0\\.3\\.jar).*${escapeRegExp(
env.EAS_BUILD_MAVEN_CACHE_URL
)}`
)
: undefined,
createError: () => new TrackedBuildError('MAVEN_CACHE_ERROR', `maven: cache error`),
},
{
phase: BuildPhase.RUN_FASTLANE,
platform: Platform.IOS,
// error: exportArchive: exportOptionsPlist error for key "iCloudContainerEnvironment": expected one of {Development, Production}, but no value was provided
regexp: /exportArchive: exportOptionsPlist error for key "iCloudContainerEnvironment"/,
createError: () =>
new TrackedBuildError(
'MISSING_ICLOUD_CONTAINER_ENVIRONMENT',
'fastlane: Missing iCloudContainerEnvironment in exportOptionsPlist.'
),
},
{
phase: BuildPhase.RUN_FASTLANE,
platform: Platform.IOS,
// The following build commands failed:
// PhaseScriptExecution [CP-User]\ Generate\ app.manifest\ for\ expo-updates /Users/expo/Library/Developer/Xcode/DerivedData/Kenkohub-eqseedlxbgrzjqagscbclhbtstwh/Build/Intermediates.noindex/ArchiveIntermediates/Kenkohub/IntermediateBuildFilesPath/Pods.build/Release-iphoneos/EXUpdates.build/Script-BB6B5FD28815C045A20B2E5E3FEEBD6E.sh (in target 'EXUpdates' from project 'Pods')
regexp:
/The following build commands failed.*\s.*\[CP-User\]\\ Generate\\ app\.manifest\\ for\\ expo-updates/,
createError: () =>
new TrackedBuildError(
'XCODE_BUILD_UPDATES_PHASE_SCRIPT',
'fastlane: Generating app.manifest for expo-updates failed.'
),
},
{
phase: BuildPhase.RUN_FASTLANE,
platform: Platform.IOS,
// The following build commands failed:
// CompileC /Users/expo/Library/Developer/Xcode/DerivedData/Docent-amxxhphjfdtkpxecgidgzvwvnvtc/Build/Intermediates.noindex/ArchiveIntermediates/Docent/IntermediateBuildFilesPath/Pods.build/Release-iphoneos/Flipper-Folly.build/Objects-normal/arm64/SSLErrors.o /Users/expo/workingdir/build/ios/Pods/Flipper-Folly/folly/io/async/ssl/SSLErrors.cpp normal arm64 c++ com.apple.compilers.llvm.clang.1_0.compiler (in target 'Flipper-Folly' from project 'Pods')
regexp: /in target 'Flipper-Folly' from project 'Pods'/,
createError: () =>
new TrackedBuildError(
'FLIPPER_FOLLY_COMPILE_ERROR',
'fastlane: Flipper-Folly compile error.'
),
},
{
phase: BuildPhase.RUN_FASTLANE,
platform: Platform.IOS,
// The following build commands failed:
// PhaseScriptExecution Bundle\ React\ Native\ code\ and\ images /Users/expo/Library/Developer/Xcode/DerivedData/cnaxwpahkhcjluhigkcwrturapmm/Build/Intermediates.noindex/ArchiveIntermediates/Test/IntermediateBuildFilesPath/Test.build/Release-iphoneos/Test.build/Script-00DD1BFF151E006B06BC.sh (in target 'Test' from project 'Test')
regexp:
/The following build commands failed.*\s.*PhaseScriptExecution Bundle\\ React\\ Native\\ code\\ and\\ images \/Users\/expo/,
createError: () =>
new TrackedBuildError(
'XCODE_BUILD_BUNDLER_ERROR',
'fastlane: Bundle React Native code and images failed.'
),
},
];
@@ -1,92 +0,0 @@
import { BuildPhase, errors } from '@expo/eas-build-job';
import fs from 'fs-extra';
import { buildErrorHandlers } from './buildErrorHandlers';
import { ErrorContext, ErrorHandler, XCODE_BUILD_PHASE } from './errors.types';
import { userErrorHandlers } from './userErrorHandlers';
import { findXcodeBuildLogsPathAsync } from '../ios/xcodeBuildLogs';
async function maybeReadXcodeBuildLogs(
phase: BuildPhase,
buildLogsDirectory: string
): Promise<string | undefined> {
if (phase !== BuildPhase.RUN_FASTLANE) {
return;
}
try {
const xcodeBuildLogsPath = await findXcodeBuildLogsPathAsync(buildLogsDirectory);
if (!xcodeBuildLogsPath) {
return;
}
return await fs.readFile(xcodeBuildLogsPath, 'utf-8');
} catch {
return undefined;
}
}
function resolveError<TError extends Error>(
errorHandlers: ErrorHandler<TError>[],
logLines: string[],
errorContext: ErrorContext,
xcodeBuildLogs?: string
): TError | undefined {
const { job, phase } = errorContext;
const { platform } = job;
const logs = logLines.join('\n');
const handlers = errorHandlers
.filter(handler => handler.platform === platform || !handler.platform)
.filter(
handler =>
(handler.phase === XCODE_BUILD_PHASE && phase === BuildPhase.RUN_FASTLANE) ||
handler.phase === phase ||
!handler.phase
)
.filter(handler => ('mode' in job && handler.mode === job.mode) || !handler.mode);
for (const handler of handlers) {
const regexp =
typeof handler.regexp === 'function' ? handler.regexp(errorContext) : handler.regexp;
if (!regexp) {
continue;
}
const match =
handler.phase === XCODE_BUILD_PHASE ? xcodeBuildLogs?.match(regexp) : logs.match(regexp);
if (match) {
return handler.createError(match, errorContext);
}
}
return undefined;
}
export async function resolveBuildPhaseErrorAsync(
error: any,
logLines: string[],
errorContext: ErrorContext,
buildLogsDirectory: string
): Promise<errors.ExpoError> {
const { phase } = errorContext;
if (error instanceof errors.ExpoError) {
error.buildPhase ??= phase;
return error;
}
const xcodeBuildLogs = await maybeReadXcodeBuildLogs(phase, buildLogsDirectory);
const userError =
resolveError(userErrorHandlers, logLines, errorContext, xcodeBuildLogs) ??
new errors.UnknownError(errorContext.phase);
const buildError = resolveError(buildErrorHandlers, logLines, errorContext, xcodeBuildLogs);
const trackingCode =
buildError && buildError.errorCode !== userError.errorCode ? buildError.errorCode : undefined;
return new errors.UserError(userError.errorCode, userError.message, {
trackingCode,
docsUrl: userError.docsUrl,
buildPhase: phase,
metadata: buildError?.metadata,
cause: error,
});
}
@@ -1,17 +0,0 @@
import { BuildMode, BuildPhase, Env, Job, Platform } from '@expo/eas-build-job';
export interface ErrorContext<TJob extends Job = Job> {
phase: BuildPhase;
job: TJob;
env: Env;
}
export const XCODE_BUILD_PHASE = 'XCODE_BUILD';
export interface ErrorHandler<T extends Error> {
regexp: RegExp | ((ctx: ErrorContext) => RegExp | undefined);
platform?: Platform;
phase?: BuildPhase | typeof XCODE_BUILD_PHASE;
mode?: BuildMode;
createError: (matchResult: RegExpMatchArray, errCtx: ErrorContext) => T | undefined;
}
@@ -1,317 +0,0 @@
import { BuildMode, BuildPhase, Platform, Workflow, errors } from '@expo/eas-build-job';
import { ErrorHandler, XCODE_BUILD_PHASE } from './errors.types';
import UserError = errors.UserError;
export const userErrorHandlers: ErrorHandler<UserError>[] = [
{
platform: Platform.IOS,
phase: BuildPhase.INSTALL_PODS,
regexp: /requires CocoaPods version/,
// example log:
// [!] `React` requires CocoaPods version `>= 1.10.1`, which is not satisfied by your current version, `1.10.0`.
createError: () =>
new UserError(
'EAS_BUILD_UNSUPPORTED_COCOAPODS_VERSION_ERROR',
`Your project requires a newer version of CocoaPods. You can update it in the build profile in eas.json by either:
- changing the current version under key "cocoapods"
- switching to an image that supports that version under key "image"`,
{ docsUrl: 'https://docs.expo.dev/build-reference/eas-json/' }
),
},
{
platform: Platform.IOS,
phase: BuildPhase.RUN_FASTLANE,
regexp: /Could not find 'bundler' (.*) required by your/,
// example log:
// /System/Library/Frameworks/Ruby.framework/Versions/2.6/usr/lib/ruby/2.6.0/rubygems/dependency.rb:313:in `to_specs': Could not find 'bundler' (2.2.3) required by your /Users/expo/project/build/ios/Gemfile.lock. (Gem::MissingSpecVersionError)
createError: () =>
new UserError(
'EAS_BUILD_UNSUPPORTED_BUNDLER_VERSION_ERROR',
`Your project requires a different version of the Ruby "bundler" program than the version installed in this EAS Build environment. You can specify which version of "bundler" to install by specifying the version under "build"→[buildProfileName]→"ios"→"bundler" in eas.json.`,
{ docsUrl: 'https://docs.expo.dev/build-reference/eas-json/' }
),
},
{
platform: Platform.ANDROID,
phase: BuildPhase.RUN_GRADLEW,
// example log:
// > Failed to read key keyalias from store "/build/workingdir/build/generic/keystore-5787e6af-3002-4cb7-8a57-3e73d13313c2.jks": Invalid keystore format
regexp: /Invalid keystore format/,
createError: () =>
new UserError(
'EAS_BUILD_INVALID_KEYSTORE_FORMAT_ERROR',
'The keystore used in this build is malformed or it has an unsupported type. Make sure you provided the correct file.'
),
},
{
platform: Platform.ANDROID,
phase: BuildPhase.RUN_GRADLEW,
// example log:
// > Failed to read key keyalias from store "/build/workingdir/build/generic/keystore-286069a8-4bb9-48a6-add9-acf6b58ea06d.jks": null
regexp: /Failed to read key[^\n]+from store/,
createError: () =>
new UserError(
'EAS_BUILD_INVALID_KEYSTORE_ALIAS_ERROR',
'The alias specified for this keystore does not exist. Make sure you specified the correct value.'
),
},
{
platform: Platform.ANDROID,
phase: BuildPhase.PREBUILD,
// example log:
// [15:42:05] Error: Cannot copy google-services.json from /build/workingdir/build/managed/abc to /build/workingdir/build/managed/android/app/google-services.json
// or
// [11:17:29] [android.dangerous]: withAndroidDangerousBaseMod: Cannot copy google-services.json from /home/expo/workingdir/build/test/test-google-services.json to /home/expo/workingdir/build/android/app/google-services.json. Please make sure the source and destination paths exist.
// [11:17:29] Error: [android.dangerous]: withAndroidDangerousBaseMod: Cannot copy google-services.json from /home/expo/workingdir/build/test/test-google-services.json to /home/expo/workingdir/build/android/app/google-services.json. Please make sure the source and destination paths exist.
regexp: /Cannot copy google-services\.json/,
createError: () =>
new UserError(
'EAS_BUILD_MISSING_GOOGLE_SERVICES_JSON_ERROR',
'"google-services.json" is missing, make sure that the file exists. Remember that EAS Build only uploads the files tracked by git. Use EAS environment variables to provide EAS Build with the file.',
{ docsUrl: 'https://docs.expo.dev/eas/environment-variables/#file-environment-variables' }
),
},
{
platform: Platform.ANDROID,
phase: BuildPhase.RUN_GRADLEW,
// Execution failed for task ':app:processReleaseGoogleServices'.
// > File google-services.json is missing. The Google Services Plugin cannot function without it.
// Searched Location:
regexp:
/File google-services\.json is missing\. The Google Services Plugin cannot function without it/,
createError: () =>
new UserError(
'EAS_BUILD_MISSING_GOOGLE_SERVICES_JSON_ERROR',
'"google-services.json" is missing, make sure that the file exists. Remember that EAS Build only uploads the files tracked by git. Use EAS environment variables to provide EAS Build with the file.',
{ docsUrl: 'https://docs.expo.dev/eas/environment-variables/#file-environment-variables' }
),
},
{
platform: Platform.IOS,
phase: BuildPhase.PREBUILD,
// example log:
// [08:44:18] ENOENT: no such file or directory, copyfile '/Users/expo/workingdir/build/managed/abc' -> '/Users/expo/workingdir/build/managed/ios/testapp/GoogleService-Info.plist'
regexp: /ENOENT: no such file or directory, copyfile .*GoogleService-Info.plist/,
createError: () =>
new UserError(
'EAS_BUILD_MISSING_GOOGLE_SERVICES_PLIST_ERROR',
'"GoogleService-Info.plist" is missing, make sure that the file exists. Remember that EAS Build only uploads the files tracked by git. Use EAS environment variables to provide EAS Build with the file.',
{ docsUrl: 'https://docs.expo.dev/eas/environment-variables/#file-environment-variables' }
),
},
{
platform: Platform.IOS,
phase: BuildPhase.INSTALL_PODS,
// example log:
// [!] CocoaPods could not find compatible versions for pod "react-native-google-maps"
// In Podfile:
// react-native-google-maps (from `/Users/expo/workingdir/build/node_modules/react-native-maps`)
// Specs satisfying the `react-native-google-maps (from `/Users/expo/workingdir/build/node_modules/react-native-maps`)` dependency were found, but they required a higher minimum deployment target.
// Error: Compatible versions of some pods could not be resolved.
regexp:
/Specs satisfying the `(.*)` dependency were found, but they required a higher minimum deployment target/,
createError: (_, { job }) => {
return new UserError(
'EAS_BUILD_HIGHER_MINIMUM_DEPLOYMENT_TARGET_ERROR',
`Some pods require a higher minimum deployment target.
${
'type' in job && job.type === Workflow.MANAGED
? 'You can use the expo-build-properties config plugin (https://docs.expo.dev/versions/latest/sdk/build-properties/) to override the default native build properties and set a different minimum deployment target.'
: 'You need to manually update the minimum deployment target in your project to resolve this issue.'
}
`
);
},
},
{
platform: Platform.IOS,
phase: BuildPhase.INSTALL_PODS,
// example log:
// [!] CocoaPods could not find compatible versions for pod "Firebase/Core":
// In snapshot (Podfile.lock):
// Firebase/Core (= 6.14.0)
// In Podfile:
// EXFirebaseCore (from `../node_modules/expo-firebase-core/ios`) was resolved to 3.0.0, which depends on
// Firebase/Core (= 7.7.0)
// You have either:
// * out-of-date source repos which you can update with `pod repo update` or with `pod install --repo-update`.
// * changed the constraints of dependency `Firebase/Core` inside your development pod `EXFirebaseCore`.
// You should run `pod update Firebase/Core` to apply changes you've made.
regexp: /CocoaPods could not find compatible versions for pod /,
createError: () => {
return new UserError(
'EAS_BUILD_INCOMPATIBLE_PODS_ERROR',
`Compatible versions of some pods could not be resolved.
You are seeing this error because either:
- Some of the pods used in your project depend on different versions of the same pod. See logs for more information.
- If you are caching Podfile.lock using "cache" field in eas.json, then versions there might not match required values in Podspecs of some installed libraries. To fix this, you can re-run build command with "--clear-cache" option, or select "Clear cache and retry build" on the build page.
`
);
},
},
{
phase: BuildPhase.INSTALL_DEPENDENCIES,
// example log:
// [stderr] npm ERR! Fix the upstream dependency conflict, or retry
// [stderr] npm ERR! this command with --force, or --legacy-peer-deps
// [stderr] npm ERR! to accept an incorrect (and potentially broken) dependency resolution.
regexp:
/Fix the upstream dependency conflict, or retry.*\s.*this command with --force, or --legacy-peer-deps/,
createError: (matchResult: RegExpMatchArray) => {
if (matchResult.length >= 2) {
return new UserError(
'EAS_BUILD_NPM_CONFLICTING_PEER_DEPENDENCIES',
`Some of your peer dependencies are not compatible. The recommended approach is to fix your dependencies by resolving any conflicts listed by "npm install". As a temporary workaround you can:
- Add ".npmrc" file with "legacy-peer-deps=true" and commit that to your repo.
- Delete package-lock.json and use yarn instead. It does not enforce peer dependencies.
- Downgrade to older version of npm on EAS Build, by adding "npm install -g npm@version" in "eas-build-pre-install" script in package.json.`
);
}
return undefined;
},
},
{
phase: BuildPhase.INSTALL_DEPENDENCIES,
// example log:
// [stderr] error https://github.com/expo/react-native/archive/sdk-41.0.0.tar.gz: Integrity check failed for "react-native" (computed integrity doesn't match our records, got "sha512-3jHI2YufrJi7eIABRf/DN/I2yOkmIZ0vAyezTz+PAUJiEs4v//5LLojWEU+W53AZsnuaEMcl/4fVy4bd+OuUbA== sha1-o9QuQTXIkc8VozXPaZIullB9a40=")
regexp: /Integrity check failed for "(.*)" \(computed integrity doesn't match our records, got/,
createError: (matchResult: RegExpMatchArray) => {
if (matchResult.length >= 2) {
return new UserError(
'EAS_BUILD_YARN_LOCK_CHECKSUM_ERROR',
`Checksum for package "${matchResult[1]}" does not match value in registry. To fix that:
- run "yarn cache clean"
- remove yarn.lock (or only the section for that package)
- run "yarn install --force"`
);
}
return undefined;
},
},
{
phase: BuildPhase.INSTALL_DEPENDENCIES,
// example log:
// yarn install v1.22.17
// [1/4] Resolving packages...
// [2/4] Fetching packages...
// [1/4] Resolving packages...
// [2/4] Fetching packages...
// [stderr] error https://registry.yarnpkg.com/jest-util/-/jest-util-26.6.2.tgz: Extracting tar content of undefined failed, the file appears to be corrupt: "ENOENT: no such file or directory, chmod '/Users/expo/Library/Caches/Yarn/v6/npm-jest-util-26.6.2-907535dbe4d5a6cb4c47ac9b926f6af29576cbc1-integrity/node_modules/jest-util/build/pluralize.d.ts'"
regexp:
/\[1\/4\] Resolving packages...\s*\[2\/4\] Fetching packages...\s*\[1\/4\] Resolving packages...\s*\[2\/4\] Fetching packages.../,
createError: (matchResult: RegExpMatchArray) => {
if (matchResult) {
return new UserError(
'EAS_BUILD_YARN_MULTIPLE_INSTANCES_ERROR',
`One of project dependencies is starting new install process while the main one is still in progress, which might result in corrupted packages. Most likely the reason for error is "prepare" script in git-referenced dependency of your project. Learn more: https://github.com/yarnpkg/yarn/issues/7212#issuecomment-493720324`
);
}
return undefined;
},
},
{
platform: Platform.IOS,
phase: XCODE_BUILD_PHASE,
// Prepare packages
// Computing target dependency graph and provisioning inputs
// Create build description
// Build description signature: 33a5c28977280822abe5e7bd7fe02529
// Build description path: /Users/expo/Library/Developer/Xcode/DerivedData/testapp-fazozgerxcvvfifkipojsjftgyih/Build/Intermediates.noindex/ArchiveIntermediates/testapp/IntermediateBuildFilesPath/XCBuildData/33a5c28977280822abe5e7bd7fe02529-desc.xcbuild
// note: Building targets in dependency order
// /Users/expo/workingdir/build/managed/ios/Pods/Pods.xcodeproj: error: Signing for "EXConstants-EXConstants" requires a development team. Select a development team in the Signing & Capabilities editor. (in target 'EXConstants-EXConstants' from project 'Pods')
// /Users/expo/workingdir/build/managed/ios/Pods/Pods.xcodeproj: error: Signing for "React-Core-AccessibilityResources" requires a development team. Select a development team in the Signing & Capabilities editor. (in target 'React-Core-AccessibilityResources' from project 'Pods')
// warning: Run script build phase '[CP-User] Generate app.manifest for expo-updates' will be run during every build because it does not specify any outputs. To address this warning, either add output dependencies to the script phase, or configure it to run in every build by unchecking "Based on dependency analysis" in the script phase. (in target 'EXUpdates' from project 'Pods')
// warning: Run script build phase 'Start Packager' will be run during every build because it does not specify any outputs. To address this warning, either add output dependencies to the script phase, or configure it to run in every build by unchecking "Based on dependency analysis" in the script phase. (in target 'testapp' from project 'testapp')
// warning: Run script build phase 'Bundle React Native code and images' will be run during every build because it does not specify any outputs. To address this warning, either add output dependencies to the script phase, or configure it to run in every build by unchecking "Based on dependency analysis" in the script phase. (in target 'testapp' from project 'testapp')
// warning: Run script build phase '[CP-User] Generate app.config for prebuilt Constants.manifest' will be run during every build because it does not specify any outputs. To address this warning, either add output dependencies to the script phase, or configure it to run in every build by unchecking "Based on dependency analysis" in the script phase. (in target 'EXConstants' from project 'Pods')
// /Users/expo/workingdir/build/managed/ios/Pods/Pods.xcodeproj: error: Signing for "EXUpdates-EXUpdates" requires a development team. Select a development team in the Signing & Capabilities editor. (in target 'EXUpdates-EXUpdates' from project 'Pods')
regexp: /error: Signing for "[a-zA-Z-0-9_]+" requires a development team/,
createError: (_, { job }) =>
'type' in job && job.type === Workflow.MANAGED
? new UserError(
'XCODE_RESOURCE_BUNDLE_CODE_SIGNING_ERROR',
`Starting from Xcode 14, resource bundles are signed by default, which requires setting the development team for each resource bundle target.
To resolve this issue, downgrade to an older Xcode version using the "image" field in eas.json, or upgrade to SDK 46 or higher.`,
{
docsUrl:
'https://docs.expo.dev/build-reference/infrastructure/#ios-build-server-configurations',
}
)
: new UserError(
'XCODE_RESOURCE_BUNDLE_CODE_SIGNING_ERROR',
`Starting from Xcode 14, resource bundles are signed by default, which requires setting the development team for each resource bundle target.
To resolve this issue, downgrade to an older Xcode version using the "image" field in eas.json, or turn off signing resource bundles in your Podfile: https://expo.fyi/r/disable-bundle-resource-signing`,
{
docsUrl:
'https://docs.expo.dev/build-reference/infrastructure/#ios-build-server-configurations',
}
),
},
{
platform: Platform.ANDROID,
phase: BuildPhase.RUN_GRADLEW,
regexp: /.*/,
createError: () =>
new UserError(
errors.ErrorCode.UNKNOWN_GRADLE_ERROR,
'Gradle build failed with unknown error. See logs for the "Run gradlew" phase for more information.'
),
},
{
platform: Platform.IOS,
phase: BuildPhase.RUN_FASTLANE,
mode: BuildMode.RESIGN,
regexp: /No provisioning profile for application: '(.+)' with bundle identifier '(.+)'/,
createError: () =>
new UserError(
'EAS_BUILD_RESIGN_PROVISIONING_PROFILE_MISMATCH_ERROR',
`The bundle identifier in provisioning profile used to resign the app does not match the bundle identifier of the app selected to be resigned. See logs above for more information.`
),
},
{
platform: Platform.IOS,
phase: BuildPhase.RUN_FASTLANE,
mode: BuildMode.RESIGN,
regexp: /.*/,
createError: () =>
new UserError(
errors.ErrorCode.UNKNOWN_FASTLANE_RESIGN_ERROR,
`The "Run fastlane" step failed with an unknown error.`
),
},
{
platform: Platform.IOS,
phase: XCODE_BUILD_PHASE,
// MkDir /Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/Build/Products/Release-iphonesimulator/expo-dev-launcher/EXDevLauncher.bundle (in target 'expo-dev-launcher-EXDevLauncher' from project 'Pods')
// cd /Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/Pods
// /bin/mkdir -p /Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/Build/Products/Release-iphonesimulator/expo-dev-launcher/EXDevLauncher.bundle
// ProcessXCFramework /Users/expo/workingdir/build/packages/video/ios/Vendor/dependency/ProgrammaticAccessLibrary.xcframework /Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/Build/Products/Release-iphonesimulator/ProgrammaticAccessLibrary.framework ios simulator
// cd /Users/expo/workingdir/build/packages/apps/NFLNetwork/ios
// builtin-process-xcframework --xcframework /Users/expo/workingdir/build/packages/video/ios/Vendor/dependency/ProgrammaticAccessLibrary.xcframework --platform ios --environment simulator --target-path /Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/Build/Products/Release-iphonesimulator
// /Users/expo/workingdir/build/packages/video/ios/Vendor/dependency/ProgrammaticAccessLibrary.xcframework:1:1: error: The signature of “ProgrammaticAccessLibrary.xcframework” cannot be verified.
// note: A sealed resource is missing or invalid
// note: /Users/expo/workingdir/build/packages/video/ios/Vendor/dependency/ProgrammaticAccessLibrary.xcframework: a sealed resource is missing or invalid
// file modified: /Users/expo/workingdir/build/packages/video/ios/Vendor/dependency/ProgrammaticAccessLibrary.xcframework/ios-arm64_x86_64-simulator/ProgrammaticAccessLibrary.framework/ProgrammaticAccessLibrary
// file modified: /Users/expo/workingdir/build/packages/video/ios/Vendor/dependency/ProgrammaticAccessLibrary.xcframework/ios-arm64/ProgrammaticAccessLibrary.framework/ProgrammaticAccessLibrary
regexp: /error: .+/g,
createError: matchResult =>
new UserError(
'XCODE_BUILD_ERROR',
`The "Run fastlane" step failed because of an error in the Xcode build process. We automatically detected following errors in your Xcode build logs:\n${matchResult
.map(match => `- ${match.replace('error: ', '')}`)
.join('\n')}\nRefer to "Xcode Logs" below for additional, more detailed logs.`
),
},
{
platform: Platform.IOS,
phase: BuildPhase.RUN_FASTLANE,
regexp: /.*/,
createError: () =>
new UserError(
errors.ErrorCode.UNKNOWN_FASTLANE_ERROR,
`The "Run fastlane" step failed with an unknown error. Refer to "Xcode Logs" below for additional, more detailed logs.`
),
},
];
@@ -1,309 +0,0 @@
import { Android, BuildPhase, BuildPhaseResult } from '@expo/eas-build-job';
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 {
injectConfigureVersionGradleConfig,
injectCredentialsGradleConfig,
} from '../../steps/utils/android/gradleConfig';
jest.mock('../common', () => ({
runBuilderWithHooksAsync: jest.fn(async (ctx, buildFn) => {
await buildFn(ctx);
return ctx.artifacts;
}),
}));
jest.mock('../custom', () => ({
runCustomBuildAsync: jest.fn(),
}));
jest.mock('../../android/credentials');
jest.mock('../../android/gradle', () => ({
ensureLFLineEndingsInGradlewScript: jest.fn(),
resolveGradleCommand: jest.fn(() => ':app:bundleRelease'),
runGradleCommand: jest.fn(),
}));
jest.mock('../../common/eagerBundle', () => ({
eagerBundleAsync: jest.fn(),
shouldUseEagerBundle: jest.fn(() => false),
}));
jest.mock('../../common/prebuild', () => ({
prebuildAsync: jest.fn(),
}));
jest.mock('../../common/setup', () => ({
setupAsync: jest.fn(),
}));
jest.mock('../../steps/functions/restoreBuildCache', () => ({
cacheStatsAsync: jest.fn(),
restoreCcacheAsync: jest.fn(),
}));
jest.mock('../../steps/functions/saveBuildCache', () => ({
saveCcacheAsync: jest.fn(),
}));
jest.mock('../../steps/utils/android/gradleConfig', () => ({
...jest.requireActual('../../steps/utils/android/gradleConfig'),
injectConfigureVersionGradleConfig: jest.fn(),
injectCredentialsGradleConfig: jest.fn(),
}));
jest.mock('../../utils/artifacts', () => ({
uploadApplicationArchive: jest.fn(),
}));
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',
PRE_UPLOAD_ARTIFACTS: 'PRE_UPLOAD_ARTIFACTS',
},
runHookIfPresent: jest.fn(),
}));
jest.mock('../../utils/prepareBuildExecutable', () => ({
prepareExecutableAsync: jest.fn(),
}));
const datadogDistributionMock = jest.spyOn(Datadog, 'distribution');
describe(androidBuilder, () => {
beforeEach(() => {
jest.clearAllMocks();
vol.fromJSON(
{
'/workingdir/env/.gitkeep': '',
'/workingdir/build/package.json': JSON.stringify({
name: 'test-app',
version: '1.0.0',
}),
},
'/'
);
});
it('injects Android version config without build credentials', async () => {
const job: Android.Job = {
...createTestAndroidJob(),
secrets: {
buildCredentials: undefined,
},
version: {
versionCode: '42',
versionName: '1.2.3',
},
};
const ctx = new BuildContext(job, {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
},
uploadArtifact: jest.fn(),
});
await androidBuilder(ctx);
expect(injectConfigureVersionGradleConfig).toHaveBeenCalledWith(
expect.anything(),
'/workingdir/build',
{
versionCode: '42',
versionName: '1.2.3',
}
);
expect(injectCredentialsGradleConfig).not.toHaveBeenCalled();
expect(restoreCredentials).not.toHaveBeenCalled();
});
it('injects Android credentials config when build credentials are present', 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(injectCredentialsGradleConfig).toHaveBeenCalledWith(
expect.anything(),
'/workingdir/build'
);
expect(restoreCredentials).toHaveBeenCalledWith(ctx);
expect(injectConfigureVersionGradleConfig).not.toHaveBeenCalled();
});
it('marks the configure Android version phase as warning for legacy eas-build.gradle', async () => {
const job: Android.Job = {
...createTestAndroidJob(),
secrets: {
buildCredentials: undefined,
},
version: {
versionCode: '42',
versionName: '1.2.3',
},
};
const ctx = new BuildContext(job, {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
},
uploadArtifact: jest.fn(),
});
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.objectContaining({
build_phase: BuildPhase.CONFIGURE_ANDROID_VERSION.toLowerCase(),
result: BuildPhaseResult.WARNING,
})
);
});
it('marks the prepare credentials phase as warning for legacy eas-build.gradle', async () => {
const ctx = new BuildContext(createTestAndroidJob(), {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
},
uploadArtifact: jest.fn(),
});
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.objectContaining({
build_phase: BuildPhase.PREPARE_CREDENTIALS.toLowerCase(),
result: BuildPhaseResult.WARNING,
})
);
});
it('injects credentials and version config when both are configured', async () => {
const job: Android.Job = {
...createTestAndroidJob(),
version: {
versionCode: '42',
versionName: '1.2.3',
},
};
const ctx = new BuildContext(job, {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
},
uploadArtifact: jest.fn(),
});
vol.mkdirSync('/workingdir/build/android/app', { recursive: true });
vol.writeFileSync('/workingdir/build/android/app/eas-build.gradle', '// Legacy content');
await androidBuilder(ctx);
expect(injectCredentialsGradleConfig).toHaveBeenCalledWith(
expect.anything(),
'/workingdir/build'
);
expect(restoreCredentials).toHaveBeenCalledWith(ctx);
expect(injectConfigureVersionGradleConfig).toHaveBeenCalledWith(
expect.anything(),
'/workingdir/build',
{
versionCode: '42',
versionName: '1.2.3',
}
);
expect(datadogDistributionMock).toHaveBeenCalledWith(
'eas.build.phase_duration',
expect.any(Number),
expect.objectContaining({
build_phase: BuildPhase.PREPARE_CREDENTIALS.toLowerCase(),
result: BuildPhaseResult.WARNING,
})
);
expect(datadogDistributionMock).toHaveBeenCalledWith(
'eas.build.phase_duration',
expect.any(Number),
expect.objectContaining({
build_phase: BuildPhase.CONFIGURE_ANDROID_VERSION.toLowerCase(),
result: BuildPhaseResult.WARNING,
})
);
});
it('runs the builder through hooks', 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(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();
});
});
@@ -1,166 +0,0 @@
import { BuildJob } from '@expo/eas-build-job';
import { BuildWorkflow } from '@expo/steps';
import { vol } from 'memfs';
import { createTestIosJob } from '../../__tests__/utils/job';
import { createMockLogger } from '../../__tests__/utils/logger';
import { prepareProjectSourcesAsync } from '../../common/projectSources';
import { BuildContext } from '../../context';
import { CustomBuildContext } from '../../customBuildContext';
import { Datadog } from '../../datadog';
import { findAndUploadXcodeBuildLogsAsync } from '../../ios/xcodeBuildLogs';
import { runCustomBuildAsync } from '../custom';
jest.mock('../../common/projectSources');
jest.mock('../../ios/xcodeBuildLogs');
const findAndUploadXcodeBuildLogsAsyncMock = jest.mocked(findAndUploadXcodeBuildLogsAsync);
describe(runCustomBuildAsync, () => {
let ctx: BuildContext<BuildJob>;
const datadogLogSpy = jest.spyOn(Datadog, 'log');
beforeEach(() => {
const job = createTestIosJob();
jest.mocked(prepareProjectSourcesAsync).mockImplementation(async () => {
vol.mkdirSync('/workingdir/env', { recursive: true });
vol.mkdirSync('/workingdir/temporary-custom-build', { recursive: true });
vol.fromJSON(
{
'test.yaml': `
functions:
test_function:
name: Test function
path: ./custom-function
shell: /bin/zsh
supported_platforms:
- darwin
- linux
inputs:
- name
- profile
outputs:
- artifact_path
build:
steps:
- eas/checkout
`,
'custom-function/package.json': '{}',
},
'/workingdir/temporary-custom-build'
);
return { handled: true };
});
ctx = new BuildContext(
{
...job,
customBuildConfig: {
path: 'test.yaml',
},
},
{
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
},
uploadArtifact: jest.fn(),
}
);
});
it('calls findAndUploadXcodeBuildLogsAsync in an iOS job if its artifacts is empty', async () => {
await runCustomBuildAsync(ctx);
expect(findAndUploadXcodeBuildLogsAsyncMock).toHaveBeenCalled();
});
it('does not call findAndUploadXcodeBuildLogsAsync in an iOS job if artifacts is already present', async () => {
ctx.artifacts.XCODE_BUILD_LOGS = 'uploaded';
await runCustomBuildAsync(ctx);
expect(findAndUploadXcodeBuildLogsAsyncMock).not.toHaveBeenCalled();
});
it('retries checking out the project', async () => {
jest.mocked(prepareProjectSourcesAsync).mockImplementationOnce(async () => {
throw new Error('Failed to clone repository');
});
await runCustomBuildAsync(ctx);
expect(prepareProjectSourcesAsync).toHaveBeenCalledTimes(2);
});
it('reports user-provided custom functions when parsing the custom build config', async () => {
await runCustomBuildAsync(ctx);
expect(datadogLogSpy).toHaveBeenCalledWith('Custom build saw user-provided function', {
event: 'custom_build_user_provided_function',
custom_function_id: 'test_function',
custom_function_input_count: '2',
custom_function_module_path: '/workingdir/temporary-custom-build/custom-function',
custom_function_name: 'Test function',
custom_function_output_count: '1',
custom_function_shell: '/bin/zsh',
custom_function_supported_runtime_platforms: 'darwin,linux',
});
});
it('awaits drainPendingMetricUploads after workflow execution', async () => {
let resolveDrain!: () => void;
const drainSpy = jest
.spyOn(CustomBuildContext.prototype, 'drainPendingMetricUploads')
.mockReturnValue(
new Promise<void>(resolve => {
resolveDrain = resolve;
})
);
let resolved = false;
const resultPromise = runCustomBuildAsync(ctx).then(() => {
resolved = true;
});
await new Promise(r => setImmediate(r));
expect(resolved).toBe(false);
resolveDrain();
await resultPromise;
expect(resolved).toBe(true);
drainSpy.mockRestore();
});
it('awaits drainPendingMetricUploads even when workflow throws', async () => {
let resolveDrain!: () => void;
const drainSpy = jest
.spyOn(CustomBuildContext.prototype, 'drainPendingMetricUploads')
.mockReturnValue(
new Promise<void>(resolve => {
resolveDrain = resolve;
})
);
const executeSpy = jest
.spyOn(BuildWorkflow.prototype, 'executeAsync')
.mockRejectedValue(new Error('workflow failed'));
let rejected = false;
const resultPromise = runCustomBuildAsync(ctx).catch(err => {
rejected = true;
throw err;
});
await new Promise(r => setImmediate(r));
expect(rejected).toBe(false);
resolveDrain();
await expect(resultPromise).rejects.toThrow('workflow failed');
expect(rejected).toBe(true);
drainSpy.mockRestore();
executeSpy.mockRestore();
});
});
@@ -1,247 +0,0 @@
import { Android, BuildMode, BuildPhase, Workflow } from '@expo/eas-build-job';
import nullthrows from 'nullthrows';
import path from 'path';
import { runBuilderWithHooksAsync } from './common';
import { runCustomBuildAsync } from './custom';
import { restoreCredentials } from '../android/credentials';
import {
ensureLFLineEndingsInGradlewScript,
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';
import { Artifacts, BuildContext, SkipNativeBuildError } from '../context';
import {
cacheStatsAsync,
restoreCcacheAsync,
restoreGradleCacheAsync,
} from '../steps/functions/restoreBuildCache';
import { saveCcacheAsync, saveGradleCacheAsync } from '../steps/functions/saveBuildCache';
import {
injectConfigureVersionGradleConfig,
injectCredentialsGradleConfig,
warnIfLegacyEasBuildGradleExists,
} from '../steps/utils/android/gradleConfig';
import { uploadApplicationArchive } from '../utils/artifacts';
import {
configureExpoUpdatesIfInstalledAsync,
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync,
} from '../utils/expoUpdates';
import { uploadEmbeddedBundleAsync } from '../utils/expoUpdatesEmbedded';
import { Hook, runHookIfPresent } from '../utils/hooks';
import { prepareExecutableAsync } from '../utils/prepareBuildExecutable';
export default async function androidBuilder(ctx: BuildContext<Android.Job>): Promise<Artifacts> {
if (ctx.job.mode === BuildMode.BUILD) {
await prepareExecutableAsync(ctx);
return await runBuilderWithHooksAsync(ctx, buildAsync);
} else if (ctx.job.mode === BuildMode.RESIGN) {
throw new Error('Not implemented');
} else if (ctx.job.mode === BuildMode.CUSTOM || ctx.job.mode === BuildMode.REPACK) {
return await runCustomBuildAsync(ctx);
} else {
throw new Error('Not implemented');
}
}
async function buildAsync(ctx: BuildContext<Android.Job>): Promise<void> {
await setupAsync(ctx);
const evictUsedBefore = new Date();
const workingDirectory = ctx.getReactNativeProjectDirectory();
const hasNativeCode = ctx.job.type === Workflow.GENERIC;
if (hasNativeCode) {
await ctx.runBuildPhase(BuildPhase.FIX_GRADLEW, async () => {
await ensureLFLineEndingsInGradlewScript(ctx);
});
}
await ctx.runBuildPhase(BuildPhase.PREBUILD, async () => {
if (hasNativeCode) {
ctx.markBuildPhaseSkipped();
ctx.logger.info(
'Skipped running "expo prebuild" because the "android" directory already exists. Learn more about the build process: https://docs.expo.dev/build-reference/android-builds/'
);
return;
}
await prebuildAsync(ctx, {
logger: ctx.logger,
workingDir: ctx.getReactNativeProjectDirectory(),
});
});
await ctx.runBuildPhase(BuildPhase.RESTORE_CACHE, async () => {
if (ctx.isLocal) {
ctx.logger.info('Local builds do not support restoring cache');
return;
}
await ctx.cacheManager?.restoreCache(ctx);
await restoreCcacheAsync({
logger: ctx.logger,
workingDirectory,
platform: ctx.job.platform,
env: ctx.env,
secrets: ctx.job.secrets,
});
await restoreGradleCacheAsync({
logger: ctx.logger,
workingDirectory,
env: ctx.env,
secrets: ctx.job.secrets,
});
});
await ctx.runBuildPhase(BuildPhase.POST_INSTALL_HOOK, async () => {
await runHookIfPresent(ctx, Hook.POST_INSTALL);
});
const resolvedExpoUpdatesRuntimeVersion = await ctx.runBuildPhase(
BuildPhase.CALCULATE_EXPO_UPDATES_RUNTIME_VERSION,
async () => {
return await resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync({
cwd: ctx.getReactNativeProjectDirectory(),
logger: ctx.logger,
appConfig: await ctx.appConfig,
platform: ctx.job.platform,
workflow: ctx.job.type,
env: ctx.env,
});
}
);
if (
nullthrows(ctx.job.secrets, 'Secrets must be defined for non-custom builds').buildCredentials
) {
await ctx.runBuildPhase(BuildPhase.PREPARE_CREDENTIALS, async () => {
await warnIfLegacyEasBuildGradleExists(ctx);
await restoreCredentials(ctx);
await injectCredentialsGradleConfig(ctx.logger, ctx.getReactNativeProjectDirectory());
});
}
if (ctx.job.version?.versionCode || ctx.job.version?.versionName) {
await ctx.runBuildPhase(BuildPhase.CONFIGURE_ANDROID_VERSION, async () => {
await warnIfLegacyEasBuildGradleExists(ctx);
await injectConfigureVersionGradleConfig(ctx.logger, ctx.getReactNativeProjectDirectory(), {
versionCode: ctx.job.version?.versionCode,
versionName: ctx.job.version?.versionName,
});
});
}
await ctx.runBuildPhase(BuildPhase.CONFIGURE_EXPO_UPDATES, async () => {
await configureExpoUpdatesIfInstalledAsync(ctx, {
resolvedRuntimeVersion: resolvedExpoUpdatesRuntimeVersion?.runtimeVersion ?? null,
resolvedFingerprintSources: resolvedExpoUpdatesRuntimeVersion?.fingerprintSources ?? null,
});
});
if (ctx.skipNativeBuild) {
throw new SkipNativeBuildError('Skipping Gradle build');
}
if (!ctx.env.EAS_BUILD_DISABLE_BUNDLE_JAVASCRIPT_STEP && shouldUseEagerBundle(ctx.metadata)) {
await ctx.runBuildPhase(BuildPhase.EAGER_BUNDLE, async () => {
await eagerBundleAsync({
platform: ctx.job.platform,
workingDir: ctx.getReactNativeProjectDirectory(),
logger: ctx.logger,
env: {
...ctx.env,
...(resolvedExpoUpdatesRuntimeVersion?.runtimeVersion
? {
EXPO_UPDATES_FINGERPRINT_OVERRIDE:
resolvedExpoUpdatesRuntimeVersion?.runtimeVersion,
EXPO_UPDATES_WORKFLOW_OVERRIDE: ctx.job.type,
}
: null),
},
packageManager: ctx.packageManager,
});
});
}
await ctx.runBuildPhase(BuildPhase.RUN_GRADLEW, async () => {
const gradleCommand = resolveGradleCommand(ctx.job);
await runGradleCommand(ctx, {
logger: ctx.logger,
gradleCommand,
androidDir: path.join(ctx.getReactNativeProjectDirectory(), 'android'),
...(resolvedExpoUpdatesRuntimeVersion?.runtimeVersion
? {
extraEnv: {
EXPO_UPDATES_FINGERPRINT_OVERRIDE: resolvedExpoUpdatesRuntimeVersion.runtimeVersion,
EXPO_UPDATES_WORKFLOW_OVERRIDE: ctx.job.type,
},
}
: null),
});
});
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);
});
await ctx.runBuildPhase(BuildPhase.UPLOAD_APPLICATION_ARCHIVE, async () => {
await uploadApplicationArchive(ctx, {
patternOrPath: ctx.job.applicationArchivePath ?? 'android/app/build/outputs/**/*.{apk,aab}',
rootDir: ctx.getReactNativeProjectDirectory(),
logger: ctx.logger,
});
});
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.');
return;
}
await ctx.cacheManager?.saveCache(ctx);
await saveCcacheAsync({
logger: ctx.logger,
workingDirectory,
platform: ctx.job.platform,
evictUsedBefore,
env: ctx.env,
secrets: ctx.job.secrets,
});
await saveGradleCacheAsync({
logger: ctx.logger,
workingDirectory,
env: ctx.env,
secrets: ctx.job.secrets,
});
});
await ctx.runBuildPhase(BuildPhase.CACHE_STATS, async () => {
await cacheStatsAsync({
logger: ctx.logger,
env: ctx.env,
secrets: ctx.job.secrets,
});
});
}
@@ -1,52 +0,0 @@
import { BuildJob, BuildPhase, Ios, Platform } from '@expo/eas-build-job';
import { Artifacts, BuildContext } from '../context';
import { findAndUploadXcodeBuildLogsAsync } from '../ios/xcodeBuildLogs';
import { maybeFindAndUploadBuildArtifacts } from '../utils/artifacts';
import { Hook, runHookIfPresent } from '../utils/hooks';
export async function runBuilderWithHooksAsync<T extends BuildJob>(
ctx: BuildContext<T>,
builderAsync: (ctx: BuildContext<T>) => Promise<void>
): Promise<Artifacts> {
try {
let buildSuccess = true;
try {
await builderAsync(ctx);
await ctx.runBuildPhase(BuildPhase.ON_BUILD_SUCCESS_HOOK, async () => {
await runHookIfPresent(ctx, Hook.ON_BUILD_SUCCESS);
});
} catch (err: any) {
buildSuccess = false;
await ctx.runBuildPhase(BuildPhase.ON_BUILD_ERROR_HOOK, async () => {
await runHookIfPresent(ctx, Hook.ON_BUILD_ERROR);
});
throw err;
} finally {
await ctx.runBuildPhase(BuildPhase.ON_BUILD_COMPLETE_HOOK, async () => {
await runHookIfPresent(ctx, Hook.ON_BUILD_COMPLETE, {
extraEnvs: {
EAS_BUILD_STATUS: buildSuccess ? 'finished' : 'errored',
},
});
});
if (ctx.job.platform === Platform.IOS) {
await findAndUploadXcodeBuildLogsAsync(ctx as BuildContext<Ios.Job>, {
logger: ctx.logger,
});
}
await ctx.runBuildPhase(BuildPhase.UPLOAD_BUILD_ARTIFACTS, async () => {
await maybeFindAndUploadBuildArtifacts(ctx, {
logger: ctx.logger,
});
});
}
} catch (err: any) {
err.artifacts = ctx.artifacts;
throw err;
}
return ctx.artifacts;
}
-137
View File
@@ -1,137 +0,0 @@
import { BuildJob, BuildPhase, BuildTrigger, Ios, Platform } from '@expo/eas-build-job';
import {
BuildConfigParser,
BuildStepGlobalContext,
BuildWorkflow,
StepsConfigParser,
errors,
} from '@expo/steps';
import assert from 'assert';
import fs from 'fs/promises';
import nullthrows from 'nullthrows';
import path from 'path';
import { resolveEnvFromBuildProfileAsync } from '../common/easBuildInternal';
import { prepareProjectSourcesAsync } from '../common/projectSources';
import { Artifacts, BuildContext } from '../context';
import { CustomBuildContext } from '../customBuildContext';
import { Datadog } from '../datadog';
import { findAndUploadXcodeBuildLogsAsync } from '../ios/xcodeBuildLogs';
import { getEasFunctionGroups } from '../steps/easFunctionGroups';
import { getEasFunctions } from '../steps/easFunctions';
import { retryAsync } from '../utils/retry';
export async function runCustomBuildAsync(ctx: BuildContext<BuildJob>): Promise<Artifacts> {
const customBuildCtx = new CustomBuildContext(ctx);
await ctx.runBuildPhase(BuildPhase.PREPARE_PROJECT, async () => {
await retryAsync(
async () => {
await fs.rm(customBuildCtx.projectSourceDirectory, { recursive: true, force: true });
await fs.mkdir(customBuildCtx.projectSourceDirectory, { recursive: true });
await prepareProjectSourcesAsync(ctx, customBuildCtx.projectSourceDirectory);
},
{
retryOptions: {
retries: 3,
retryIntervalMs: 1_000,
},
}
);
});
if (ctx.job.triggeredBy === BuildTrigger.GIT_BASED_INTEGRATION) {
// We need to setup envs from eas.json
const env = await resolveEnvFromBuildProfileAsync(ctx, {
cwd: path.join(customBuildCtx.projectSourceDirectory, ctx.job.projectRootDirectory ?? '.'),
});
ctx.updateEnv(env);
customBuildCtx.updateEnv(ctx.env);
}
assert(
'steps' in ctx.job || 'customBuildConfig' in ctx.job,
'Steps or custom build config path are required in custom jobs'
);
const globalContext = new BuildStepGlobalContext(customBuildCtx, false);
const easFunctions = getEasFunctions(customBuildCtx);
const easFunctionGroups = getEasFunctionGroups(customBuildCtx);
const parser = ctx.job.steps
? new StepsConfigParser(globalContext, {
externalFunctions: easFunctions,
externalFunctionGroups: easFunctionGroups,
steps: ctx.job.steps,
hooks: ctx.job.hooks,
})
: new BuildConfigParser(globalContext, {
externalFunctions: easFunctions,
externalFunctionGroups: easFunctionGroups,
configPath: path.join(
ctx.getReactNativeProjectDirectory(customBuildCtx.projectSourceDirectory),
nullthrows(
ctx.job.customBuildConfig?.path,
'Steps or custom build config path are required in custom jobs'
)
),
});
const workflow = await ctx.runBuildPhase(BuildPhase.PARSE_CUSTOM_WORKFLOW_CONFIG, async () => {
try {
return await parser.parseAsync();
} catch (parseError: any) {
ctx.logger.error('Failed to parse the custom build config file.');
if (parseError instanceof errors.BuildWorkflowError) {
for (const err of parseError.errors) {
ctx.logger.error({ err });
}
}
throw parseError;
}
});
logUserProvidedCustomFunctions(workflow);
try {
try {
await workflow.executeAsync();
} finally {
if (!ctx.artifacts.XCODE_BUILD_LOGS && ctx.job.platform === Platform.IOS) {
try {
await findAndUploadXcodeBuildLogsAsync(ctx as BuildContext<Ios.Job>, {
logger: ctx.logger,
});
} catch {
// do nothing, it's a non-breaking error.
}
}
await customBuildCtx.drainPendingMetricUploads();
}
} catch (err: any) {
err.artifacts = ctx.artifacts;
throw err;
}
return ctx.artifacts;
}
function logUserProvidedCustomFunctions(workflow: BuildWorkflow): void {
for (const buildFunction of Object.values(workflow.buildFunctions)) {
if (!buildFunction.customFunctionModulePath) {
continue;
}
Datadog.log('Custom build saw user-provided function', {
event: 'custom_build_user_provided_function',
custom_function_id: buildFunction.getFullId(),
custom_function_module_path: buildFunction.customFunctionModulePath,
custom_function_input_count: String(buildFunction.inputProviders?.length ?? 0),
custom_function_output_count: String(buildFunction.outputProviders?.length ?? 0),
...(buildFunction.name ? { custom_function_name: buildFunction.name } : {}),
...(buildFunction.shell ? { custom_function_shell: buildFunction.shell } : {}),
...(buildFunction.supportedRuntimePlatforms
? {
custom_function_supported_runtime_platforms:
buildFunction.supportedRuntimePlatforms.join(','),
}
: {}),
});
}
}
@@ -1,4 +0,0 @@
import androidBuilder from './android';
import iosBuilder from './ios';
export { androidBuilder, iosBuilder };
-361
View File
@@ -1,361 +0,0 @@
import { IOSConfig } from '@expo/config-plugins';
import { BuildMode, BuildPhase, Ios, ManagedArtifactType, Workflow } from '@expo/eas-build-job';
import plist from '@expo/plist';
import fs from 'fs-extra';
import nullthrows from 'nullthrows';
import { runBuilderWithHooksAsync } from './common';
import { runCustomBuildAsync } from './custom';
import { eagerBundleAsync, shouldUseEagerBundle } from '../common/eagerBundle';
import { prebuildAsync } from '../common/prebuild';
import { setupAsync } from '../common/setup';
import { Artifacts, BuildContext } from '../context';
import { configureXcodeProject } from '../ios/configure';
import CredentialsManager from '../ios/credentials/manager';
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';
import { uploadApplicationArchive } from '../utils/artifacts';
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';
const INSTALL_PODS_WARN_TIMEOUT_MS = 15 * 60 * 1000; // 15 minutes
const INSTALL_PODS_KILL_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
class InstallPodsTimeoutError extends Error {}
export default async function iosBuilder(ctx: BuildContext<Ios.Job>): Promise<Artifacts> {
if (ctx.job.mode === BuildMode.BUILD) {
await prepareExecutableAsync(ctx);
return await runBuilderWithHooksAsync(ctx, buildAsync);
} else if (ctx.job.mode === BuildMode.RESIGN) {
return await resignAsync(ctx);
} else if (ctx.job.mode === BuildMode.CUSTOM || ctx.job.mode === BuildMode.REPACK) {
return await runCustomBuildAsync(ctx);
} else {
throw new Error('Not implemented');
}
}
async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
await setupAsync(ctx);
const hasNativeCode = ctx.job.type === Workflow.GENERIC;
const evictUsedBefore = new Date();
const credentialsManager = new CredentialsManager(ctx);
const workingDirectory = ctx.getReactNativeProjectDirectory();
let fastlaneResult: { derivedDataPath: string; workspacePath: string } | undefined;
try {
const credentials = await ctx.runBuildPhase(BuildPhase.PREPARE_CREDENTIALS, async () => {
return await credentialsManager.prepare();
});
await ctx.runBuildPhase(BuildPhase.PREBUILD, async () => {
if (hasNativeCode) {
ctx.markBuildPhaseSkipped();
ctx.logger.info(
'Skipped running "expo prebuild" because the "ios" directory already exists. Learn more about the build process: https://docs.expo.dev/build-reference/ios-builds/'
);
return;
}
const extraEnvs: Record<string, string> = credentials?.teamId
? { APPLE_TEAM_ID: credentials.teamId }
: {};
await prebuildAsync(ctx, {
logger: ctx.logger,
workingDir: ctx.getReactNativeProjectDirectory(),
options: { extraEnvs },
});
});
await ctx.runBuildPhase(BuildPhase.RESTORE_CACHE, async () => {
if (ctx.isLocal) {
ctx.logger.info('Local builds do not support restoring cache');
return;
}
await ctx.cacheManager?.restoreCache(ctx);
await restoreCcacheAsync({
logger: ctx.logger,
workingDirectory,
platform: ctx.job.platform,
env: ctx.env,
secrets: ctx.job.secrets,
});
});
await ctx.runBuildPhase(BuildPhase.INSTALL_PODS, async () => {
await runInstallPodsAsync(ctx);
});
await ctx.runBuildPhase(BuildPhase.POST_INSTALL_HOOK, async () => {
await runHookIfPresent(ctx, Hook.POST_INSTALL);
});
const resolvedExpoUpdatesRuntimeVersion = await ctx.runBuildPhase(
BuildPhase.CALCULATE_EXPO_UPDATES_RUNTIME_VERSION,
async () => {
return await resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync({
cwd: ctx.getReactNativeProjectDirectory(),
logger: ctx.logger,
appConfig: await ctx.appConfig,
platform: ctx.job.platform,
workflow: ctx.job.type,
env: ctx.env,
});
}
);
const buildConfiguration = resolveBuildConfiguration(ctx);
if (credentials) {
await ctx.runBuildPhase(BuildPhase.CONFIGURE_XCODE_PROJECT, async () => {
await configureXcodeProject(ctx, { credentials, buildConfiguration });
});
}
await ctx.runBuildPhase(BuildPhase.CONFIGURE_EXPO_UPDATES, async () => {
await configureExpoUpdatesIfInstalledAsync(ctx, {
resolvedRuntimeVersion: resolvedExpoUpdatesRuntimeVersion?.runtimeVersion ?? null,
resolvedFingerprintSources: resolvedExpoUpdatesRuntimeVersion?.fingerprintSources ?? null,
});
});
if (!ctx.env.EAS_BUILD_DISABLE_BUNDLE_JAVASCRIPT_STEP && shouldUseEagerBundle(ctx.metadata)) {
await ctx.runBuildPhase(BuildPhase.EAGER_BUNDLE, async () => {
await eagerBundleAsync({
platform: ctx.job.platform,
workingDir: ctx.getReactNativeProjectDirectory(),
logger: ctx.logger,
env: {
...ctx.env,
...(resolvedExpoUpdatesRuntimeVersion?.runtimeVersion
? {
EXPO_UPDATES_FINGERPRINT_OVERRIDE:
resolvedExpoUpdatesRuntimeVersion?.runtimeVersion,
EXPO_UPDATES_WORKFLOW_OVERRIDE: ctx.job.type,
}
: null),
},
packageManager: ctx.packageManager,
});
});
}
fastlaneResult = await ctx.runBuildPhase(BuildPhase.RUN_FASTLANE, async () => {
const scheme = resolveScheme(ctx);
const entitlements = await readEntitlementsAsync(ctx, { scheme, buildConfiguration });
return await runFastlaneGym(ctx, {
credentials,
scheme,
buildConfiguration,
entitlements,
...(resolvedExpoUpdatesRuntimeVersion?.runtimeVersion
? {
extraEnv: {
EXPO_UPDATES_FINGERPRINT_OVERRIDE:
resolvedExpoUpdatesRuntimeVersion?.runtimeVersion,
EXPO_UPDATES_WORKFLOW_OVERRIDE: ctx.job.type,
},
}
: null),
});
});
} finally {
await ctx.runBuildPhase(BuildPhase.CLEAN_UP_CREDENTIALS, async () => {
await credentialsManager.cleanUp();
});
}
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({
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);
});
await ctx.runBuildPhase(BuildPhase.UPLOAD_APPLICATION_ARCHIVE, async () => {
await uploadApplicationArchive(ctx, {
patternOrPath: resolveArtifactPath(ctx),
rootDir: ctx.getReactNativeProjectDirectory(),
logger: ctx.logger,
});
});
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.');
return;
}
await ctx.cacheManager?.saveCache(ctx);
await saveCcacheAsync({
logger: ctx.logger,
workingDirectory,
platform: ctx.job.platform,
evictUsedBefore,
env: ctx.env,
secrets: ctx.job.secrets,
});
});
await ctx.runBuildPhase(BuildPhase.CACHE_STATS, async () => {
await cacheStatsAsync({
logger: ctx.logger,
env: ctx.env,
secrets: ctx.job.secrets,
});
});
}
async function readEntitlementsAsync(
ctx: BuildContext<Ios.Job>,
{ scheme, buildConfiguration }: { scheme: string; buildConfiguration: string }
): Promise<object | null> {
try {
const applicationTargetName =
await IOSConfig.BuildScheme.getApplicationTargetNameForSchemeAsync(
ctx.getReactNativeProjectDirectory(),
scheme
);
const entitlementsPath = IOSConfig.Entitlements.getEntitlementsPath(
ctx.getReactNativeProjectDirectory(),
{
buildConfiguration,
targetName: applicationTargetName,
}
);
if (!entitlementsPath) {
return null;
}
const entitlementsRaw = await fs.readFile(entitlementsPath, 'utf8');
return plist.parse(entitlementsRaw);
} catch (err) {
ctx.logger.warn({ err }, 'Failed to read entitlements');
ctx.markBuildPhaseHasWarnings();
return null;
}
}
async function resignAsync(ctx: BuildContext<Ios.Job>): Promise<Artifacts> {
const applicationArchivePath = await ctx.runBuildPhase(
BuildPhase.DOWNLOAD_APPLICATION_ARCHIVE,
async () => {
return await downloadApplicationArchiveAsync(ctx);
}
);
const credentialsManager = new CredentialsManager(ctx);
try {
const credentials = await ctx.runBuildPhase(BuildPhase.PREPARE_CREDENTIALS, async () => {
return await credentialsManager.prepare();
});
await ctx.runBuildPhase(BuildPhase.RUN_FASTLANE, async () => {
await runFastlaneResign(ctx, {
credentials: nullthrows(credentials),
ipaPath: applicationArchivePath,
});
});
} finally {
await ctx.runBuildPhase(BuildPhase.CLEAN_UP_CREDENTIALS, async () => {
await credentialsManager.cleanUp();
});
}
await ctx.runBuildPhase(BuildPhase.UPLOAD_APPLICATION_ARCHIVE, async () => {
ctx.logger.info(`Application archive: ${applicationArchivePath}`);
await ctx.uploadArtifact({
artifact: {
type: ManagedArtifactType.APPLICATION_ARCHIVE,
paths: [applicationArchivePath],
},
logger: ctx.logger,
});
});
return ctx.artifacts;
}
async function runInstallPodsAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
let warnTimeout: NodeJS.Timeout | undefined;
let killTimeout: NodeJS.Timeout | undefined;
let timedOutToKill: boolean = false;
try {
const installPodsSpawnPromise = (
await installPods(ctx, {
infoCallbackFn: () => {
warnTimeout?.refresh();
killTimeout?.refresh();
},
})
).spawnPromise;
warnTimeout = setTimeout(() => {
ctx.logger.warn(
'"Install pods" phase takes longer then expected and it did not produce any logs in the past 15 minutes'
);
}, INSTALL_PODS_WARN_TIMEOUT_MS);
killTimeout = setTimeout(async () => {
timedOutToKill = true;
ctx.logger.error(
'"Install pods" phase takes a very long time and it did not produce any logs in the past 30 minutes. Most likely an unexpected error happened which caused the process to hang and it will be terminated'
);
const ppid = nullthrows(installPodsSpawnPromise.child.pid);
const pids = await getParentAndDescendantProcessPidsAsync(ppid);
pids.forEach(pid => {
process.kill(pid);
});
ctx.reportError?.('"Install pods" phase takes a very long time', undefined, {
extras: { buildId: ctx.env.EAS_BUILD_ID },
});
}, INSTALL_PODS_KILL_TIMEOUT_MS);
await installPodsSpawnPromise;
} catch (err: any) {
if (timedOutToKill) {
throw new InstallPodsTimeoutError('"Install pods" phase was inactive for over 30 minutes');
}
throw err;
} finally {
if (warnTimeout) {
clearTimeout(warnTimeout);
}
if (killTimeout) {
clearTimeout(killTimeout);
}
}
}
@@ -1,199 +0,0 @@
import { ArchiveSourceType, BuildJob, Platform, Workflow } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { resolveEnvFromBuildProfileAsync, runEasBuildInternalAsync } from '../easBuildInternal';
import { resolveEasCommandPrefixAndEnvAsync } from '../../utils/easCli';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../../utils/easCli', () => ({
...jest.requireActual('../../utils/easCli'),
resolveEasCommandPrefixAndEnvAsync: jest.fn(),
}));
describe('easBuildInternal', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(resolveEasCommandPrefixAndEnvAsync).mockResolvedValue({
cmd: 'npx',
args: ['-y', 'eas-cli@latest'],
extraEnv: {},
});
});
it('calls resolveEasCommandPrefixAndEnvAsync in runEasBuildInternalAsync', async () => {
const logger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
child: jest.fn(),
} as any;
const job = {
platform: 'android',
secrets: { robotAccessToken: 'token' },
} as unknown as BuildJob;
await expect(
runEasBuildInternalAsync({
job,
logger,
env: {},
cwd: '/tmp/project',
})
).rejects.toThrow('build profile is missing in a build from git-based integration.');
expect(resolveEasCommandPrefixAndEnvAsync).toHaveBeenCalledWith();
expect(spawn).not.toHaveBeenCalled();
});
it('calls resolveEasCommandPrefixAndEnvAsync in resolveEnvFromBuildProfileAsync', async () => {
const logger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
child: jest.fn(),
} as any;
const ctx = {
logger,
env: {},
job: { platform: 'ios' },
} as any;
await expect(resolveEnvFromBuildProfileAsync(ctx, { cwd: '/tmp/project' })).rejects.toThrow(
'build profile is missing in a build from git-based integration.'
);
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,256 +0,0 @@
import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
import { createMockLogger } from '../../__tests__/utils/logger';
import { Sentry } from '../../sentry';
import { PackageManager } from '../../utils/packageManager';
import { installDependenciesWithNpmCacheFallbackAsync } from '../installDependencies';
jest.mock('@expo/turtle-spawn', () => jest.fn());
jest.mock('../../sentry', () => ({
Sentry: {
capture: jest.fn(),
},
}));
describe(installDependenciesWithNpmCacheFallbackAsync, () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('retries without NPM_CONFIG_REGISTRY when npm cache install fails', async () => {
const logger = createMockLogger();
const npmCacheUrl = 'http://npm.staging.caches.eas-build.internal';
const error = Object.assign(
new Error('npm install --include=dev exited with non-zero code: 1'),
{
status: 1,
signal: null,
stdout: '',
stderr: `npm error request to ${npmCacheUrl}/left-pad failed, reason: getaddrinfo ENOTFOUND npm.staging.caches.eas-build.internal`,
}
);
jest
.mocked(spawn)
.mockReturnValueOnce(createSpawnPromise(Promise.reject(error)))
.mockReturnValueOnce(createSpawnPromise(Promise.resolve(createSpawnResult())));
await installDependenciesWithNpmCacheFallbackAsync({
packageManager: PackageManager.NPM,
env: {
EAS_VERBOSE: '1',
EAS_USE_NPM_CACHE: '1',
EAS_BUILD_NPM_CACHE_URL: npmCacheUrl,
},
logger,
cwd: '/tmp/build',
useFrozenLockfile: false,
});
expect(spawn).toHaveBeenCalledTimes(2);
expect(spawn).toHaveBeenNthCalledWith(1, 'npm', ['install', '--include=dev', '--verbose'], {
cwd: '/tmp/build',
logger,
infoCallbackFn: undefined,
lineTransformer: expect.any(Function),
env: {
EAS_VERBOSE: '1',
EAS_USE_NPM_CACHE: '1',
EAS_BUILD_NPM_CACHE_URL: npmCacheUrl,
NPM_CONFIG_REGISTRY: npmCacheUrl,
},
});
expect(spawn).toHaveBeenNthCalledWith(2, 'npm', ['install', '--include=dev', '--verbose'], {
cwd: '/tmp/build',
logger,
infoCallbackFn: undefined,
env: {
EAS_VERBOSE: '1',
EAS_USE_NPM_CACHE: '1',
EAS_BUILD_NPM_CACHE_URL: npmCacheUrl,
},
});
expect(logger.warn).toHaveBeenCalledWith(
`Failed to install dependencies using the npm cache registry (${npmCacheUrl}). Retrying without the npm cache registry.`
);
expect(Sentry.capture).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Failed to install dependencies using npm cache registry',
name: 'NpmCacheRegistryInstallError',
}),
{
level: 'warning',
tags: {
packageManager: 'npm',
},
extras: {
cwd: '/tmp/build',
npmCacheUrl,
useFrozenLockfile: false,
originalErrorMessage: 'npm install --include=dev exited with non-zero code: 1',
status: 1,
signal: null,
},
}
);
});
it('reports non-fatal npm cache registry errors from successful installs', async () => {
const logger = createMockLogger();
const npmCacheUrl = 'http://npm.staging.caches.eas-build.internal';
const auditErrorLine = `npm verbose audit error FetchError: request to ${npmCacheUrl}/-/npm/v1/security/advisories/bulk failed, reason: getaddrinfo ENOTFOUND npm.staging.caches.eas-build.internal`;
jest.mocked(spawn).mockImplementationOnce((_command, _args, options: any) => {
options.lineTransformer(auditErrorLine);
options.lineTransformer(
`npm http cache left-pad@${npmCacheUrl}/left-pad/-/left-pad-1.3.0.tgz 0ms (cache hit)`
);
return createSpawnPromise(Promise.resolve(createSpawnResult()));
});
await installDependenciesWithNpmCacheFallbackAsync({
packageManager: PackageManager.NPM,
env: {
EAS_USE_NPM_CACHE: '1',
EAS_BUILD_NPM_CACHE_URL: npmCacheUrl,
},
logger,
cwd: '/tmp/build',
useFrozenLockfile: false,
});
expect(spawn).toHaveBeenCalledTimes(1);
expect(Sentry.capture).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Non-fatal npm cache registry error during dependency install',
name: 'NpmCacheRegistryNonFatalError',
}),
{
level: 'warning',
tags: {
packageManager: 'npm',
},
extras: {
cwd: '/tmp/build',
npmCacheUrl,
useFrozenLockfile: false,
firstErrorLine: auditErrorLine,
errorLineCount: 1,
},
}
);
});
it('does not retry when install failure does not reference npm cache registry', async () => {
const logger = createMockLogger();
const error = Object.assign(
new Error('npm install --include=dev exited with non-zero code: 1'),
{
status: 1,
signal: null,
stdout: '',
stderr: 'npm error command sh -c node postinstall.js',
}
);
jest.mocked(spawn).mockReturnValueOnce(createSpawnPromise(Promise.reject(error)));
await expect(
installDependenciesWithNpmCacheFallbackAsync({
packageManager: PackageManager.NPM,
env: {
EAS_USE_NPM_CACHE: '1',
EAS_BUILD_NPM_CACHE_URL: 'http://npm.staging.caches.eas-build.internal',
},
logger,
cwd: '/tmp/build',
useFrozenLockfile: false,
})
).rejects.toThrow(error);
expect(spawn).toHaveBeenCalledTimes(1);
expect(Sentry.capture).not.toHaveBeenCalled();
});
it('does not retry when the npm cache is not enabled', async () => {
const logger = createMockLogger();
const npmCacheUrl = 'http://npm.staging.caches.eas-build.internal';
const error = Object.assign(
new Error('npm install --include=dev exited with non-zero code: 1'),
{
status: 1,
signal: null,
stdout: '',
stderr: `npm error request to ${npmCacheUrl}/left-pad failed, reason: getaddrinfo ENOTFOUND npm.staging.caches.eas-build.internal`,
}
);
jest.mocked(spawn).mockReturnValueOnce(createSpawnPromise(Promise.reject(error)));
await expect(
installDependenciesWithNpmCacheFallbackAsync({
packageManager: PackageManager.NPM,
env: {},
logger,
cwd: '/tmp/build',
useFrozenLockfile: false,
})
).rejects.toThrow(error);
expect(spawn).toHaveBeenCalledTimes(1);
expect(Sentry.capture).not.toHaveBeenCalled();
});
it('installs through the npm cache registry without retrying when the cache install succeeds', async () => {
const logger = createMockLogger();
const npmCacheUrl = 'http://npm.staging.caches.eas-build.internal';
jest
.mocked(spawn)
.mockReturnValueOnce(createSpawnPromise(Promise.resolve(createSpawnResult())));
await installDependenciesWithNpmCacheFallbackAsync({
packageManager: PackageManager.NPM,
env: {
EAS_USE_NPM_CACHE: '1',
EAS_BUILD_NPM_CACHE_URL: npmCacheUrl,
},
logger,
cwd: '/tmp/build',
useFrozenLockfile: false,
});
expect(spawn).toHaveBeenCalledTimes(1);
expect(spawn).toHaveBeenCalledWith('npm', ['install', '--include=dev'], {
cwd: '/tmp/build',
logger,
infoCallbackFn: undefined,
lineTransformer: expect.any(Function),
env: {
EAS_USE_NPM_CACHE: '1',
EAS_BUILD_NPM_CACHE_URL: npmCacheUrl,
NPM_CONFIG_REGISTRY: npmCacheUrl,
},
});
expect(Sentry.capture).not.toHaveBeenCalled();
});
});
function createSpawnPromise(result: Promise<SpawnResult>): SpawnPromise<SpawnResult> {
return Object.assign(result, {
child: {
pid: 123,
},
}) as SpawnPromise<SpawnResult>;
}
function createSpawnResult(): SpawnResult {
return {
pid: 123,
output: ['', ''],
stdout: '',
stderr: '',
status: 0,
signal: null,
};
}
Loaded 100 of 1142 files, more files were not shown because too many files have changed in this diff. Show more