Compare commits

..
Author SHA1 Message Date
Douglas Lowder b3bba882b1 Add mutation for getting workflows from git ref 2025-11-20 16:30:50 -08:00
129 changed files with 1006 additions and 11850 deletions

No files matched your search

@@ -1,17 +0,0 @@
name: build-android
on:
workflow_dispatch: {}
jobs:
build:
name: Build Android
type: build
runs_on: linux-large
params:
platform: android
profile: system-test
custom_job:
name: Custom Job
steps:
- name: Echo
run: echo "This is a custom job step"
@@ -1,16 +0,0 @@
name: setup-gcloud
runs:
using: "composite"
steps:
- name: Auth gcloud
uses: google-github-actions/auth@v2
with:
workload_identity_provider: 'projects/321830142373/locations/global/workloadIdentityPools/github/providers/expo'
project_id: exponentjs
- name: Setup gcloud
uses: google-github-actions/setup-gcloud@e427ad8a34f8676edf47cf7d7925499adf3eb74f # v2
with:
version: 548.0.0
project_id: exponentjs
@@ -1,205 +0,0 @@
name: build-and-deploy-worker
# Assumes main branch always specifies an environment (staging/production).
# If environment is empty: PR testing build (no upload, no deploy)
# If environment is set: Build (unless cached on main), upload, and deploy
on:
workflow_dispatch:
inputs:
environment:
description: '"staging", "production" or empty'
type: string
required: true
workflow_call:
inputs:
environment:
description: '"staging", "production" or empty'
type: string
required: true
jobs:
build-and-deploy:
runs-on: ubuntu-latest
permissions:
id-token: write
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- uses: volta-cli/action@5c175f92dea6f48441c436471e6479dbc192e194 # v4
- uses: ./.github/internal-actions/setup-gcloud
- name: Check if worker already exists
id: check_worker_exists
# Check if tarballs for this SHA already exist
# If they do, we can skip building regardless of branch
if: inputs.environment != ''
run: |
for platform in android ios; do
GCP_TARBALL="worker-$platform-sha1-$GITHUB_SHA.tar.gz"
if ! gsutil stat gs://eas-build-worker-tarballs/$GCP_TARBALL; then
echo "worker_exists=false" >> "$GITHUB_OUTPUT"
exit 0
fi
done
echo "worker_exists=true" >> "$GITHUB_OUTPUT"
- name: Start EAS Workflow to build worker tarballs
id: eas_workflow
# Build if: PR testing (no env), or deploying but tarballs don't exist
if: inputs.environment == '' || steps.check_worker_exists.outputs.worker_exists == 'false'
working-directory: packages/worker
env:
EXPO_TOKEN: ${{ secrets.EXPO_DEV_EXPO_GITHUB_ROBOT_ACCESS_TOKEN }}
run: |
set -euo pipefail
# Only upload artifacts from EAS if GHA will upload them to GCS
# This avoids unnecessary artifact uploads for PR builds
SHOULD_UPLOAD_ARTIFACT="${{ inputs.environment != '' && steps.check_worker_exists.outputs.worker_exists == 'false' }}"
# Run workflow and capture output (includes run ID)
WORKFLOW_OUTPUT=$(npx -y eas-cli@latest workflow:run build-worker.yml \
--ref ${{ github.sha }} \
--input upload_artifact=$SHOULD_UPLOAD_ARTIFACT \
--non-interactive \
--no-wait \
--json)
WORKFLOW_RUN_ID=$(echo "$WORKFLOW_OUTPUT" | jq -r '.id')
if [ -z "$WORKFLOW_RUN_ID" ] || [ "$WORKFLOW_RUN_ID" = "null" ]; then
echo "Error: Could not determine workflow run ID"
echo "Workflow output:"
echo "$WORKFLOW_OUTPUT" | jq .
exit 1
fi
echo "workflow_run_id=$WORKFLOW_RUN_ID" >> "$GITHUB_OUTPUT"
- name: Wait for EAS workflow and capture artifacts
id: eas_artifacts
if: inputs.environment == '' || steps.check_worker_exists.outputs.worker_exists == 'false'
working-directory: packages/worker
env:
EXPO_TOKEN: ${{ secrets.EXPO_DEV_EXPO_GITHUB_ROBOT_ACCESS_TOKEN }}
run: |
set -euo pipefail
WORKFLOW_RUN_ID="${{ steps.eas_workflow.outputs.workflow_run_id }}"
if [ -z "$WORKFLOW_RUN_ID" ]; then
echo "Error: Missing workflow run ID"
exit 1
fi
SHOULD_UPLOAD_ARTIFACT="${{ inputs.environment != '' && steps.check_worker_exists.outputs.worker_exists == 'false' }}"
while true; do
VIEW_OUTPUT=$(npx -y eas-cli@latest workflow:view "$WORKFLOW_RUN_ID" \
--non-interactive \
--json)
STATUS=$(echo "$VIEW_OUTPUT" | jq -r '.status')
case "$STATUS" in
SUCCESS)
break
;;
FAILURE)
echo "EAS workflow failed."
echo "$VIEW_OUTPUT" | jq .
exit 1
;;
CANCELED)
echo "EAS workflow canceled."
echo "$VIEW_OUTPUT" | jq .
exit 1
;;
ACTION_REQUIRED)
echo "EAS workflow requires action."
echo "$VIEW_OUTPUT" | jq .
exit 1
;;
NEW|IN_PROGRESS)
sleep 7
;;
*)
echo "Unexpected workflow status: $STATUS"
echo "$VIEW_OUTPUT" | jq .
exit 1
;;
esac
done
# Skip artifact extraction if we didn't request uploads
if [ "$SHOULD_UPLOAD_ARTIFACT" == "false" ]; then
echo "Skipping artifact download - upload was disabled for this run"
exit 0
fi
# Extract artifact download URLs from workflow output
# Artifacts are in jobs[].artifacts[] for custom jobs
DOWNLOAD_IOS_URL=$(echo "$VIEW_OUTPUT" | jq -r '
.jobs[]
| .artifacts[]?
| select(.name == "worker-ios-tarball")
| .downloadUrl
' | head -1)
DOWNLOAD_ANDROID_URL=$(echo "$VIEW_OUTPUT" | jq -r '
.jobs[]
| .artifacts[]?
| select(.name == "worker-android-tarball")
| .downloadUrl
' | head -1)
if [ -z "$DOWNLOAD_IOS_URL" ] || [ "$DOWNLOAD_IOS_URL" = "null" ]; then
echo "Error: Could not find iOS artifact download URL"
echo "$VIEW_OUTPUT" | jq .
exit 1
fi
if [ -z "$DOWNLOAD_ANDROID_URL" ] || [ "$DOWNLOAD_ANDROID_URL" = "null" ]; then
echo "Error: Could not find Android artifact download URL"
echo "$VIEW_OUTPUT" | jq .
exit 1
fi
# Mask the download URLs
echo "::add-mask::$DOWNLOAD_IOS_URL"
echo "::add-mask::$DOWNLOAD_ANDROID_URL"
echo "download_ios_url=$DOWNLOAD_IOS_URL" >> "$GITHUB_OUTPUT"
echo "download_android_url=$DOWNLOAD_ANDROID_URL" >> "$GITHUB_OUTPUT"
- name: Cancel EAS workflow on GitHub cancellation
if: cancelled()
working-directory: packages/worker
env:
EXPO_TOKEN: ${{ secrets.EXPO_DEV_EXPO_GITHUB_ROBOT_ACCESS_TOKEN }}
run: |
set -euo pipefail
WORKFLOW_RUN_ID="${{ steps.eas_workflow.outputs.workflow_run_id }}"
if [ -n "$WORKFLOW_RUN_ID" ]; then
npx -y eas-cli@latest workflow:cancel "$WORKFLOW_RUN_ID" --non-interactive
fi
- name: Upload worker tarballs to GCP
# Upload when deploying if we built something new
# Don't upload PR builds (no environment) or when using cached tarballs
if: inputs.environment != '' && steps.check_worker_exists.outputs.worker_exists == 'false'
run: |
IOS_GCP_TARBALL="worker-ios-sha1-$GITHUB_SHA.tar.gz"
ANDROID_GCP_TARBALL="worker-android-sha1-$GITHUB_SHA.tar.gz"
curl -L "${{ steps.eas_artifacts.outputs.download_ios_url }}" | gsutil cp - gs://eas-build-worker-tarballs/$IOS_GCP_TARBALL
curl -L "${{ steps.eas_artifacts.outputs.download_android_url }}" | gsutil cp - gs://eas-build-worker-tarballs/$ANDROID_GCP_TARBALL
- name: Promote worker to ${{ inputs.environment }}
if: inputs.environment != ''
run: |
for platform in android ios; do
GCP_TARBALL="worker-$platform-sha1-$GITHUB_SHA.tar.gz"
WORKER_STAGING_TARBALL="worker-$platform-${{ inputs.environment }}.tar.gz"
gsutil cp gs://eas-build-worker-tarballs/$GCP_TARBALL gs://eas-build-worker-tarballs/$WORKER_STAGING_TARBALL
done
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
node: ['20', '22', '24']
node: ['20', '22']
include:
- node: '20'
coverage: true
-160
View File
@@ -1,160 +0,0 @@
name: system-tests
on:
workflow_dispatch: {}
workflow_call: {}
jobs:
system-tests:
runs-on: ubuntu-latest
env:
EXPO_TOKEN: ${{ secrets.STAGING_EXPO_DEV_EXPO_SERVICES_GITHUB_ROBOT_ACCESS_TOKEN }}
EXPO_STAGING: "1"
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- uses: volta-cli/action@5c175f92dea6f48441c436471e6479dbc192e194 # v4
- name: Create test app
run: npx -y create-expo-app@latest ${{ runner.temp }}/test-app --template tabs
- name: Link to EAS project
working-directory: ${{ runner.temp }}/test-app
run: npx -y eas-cli@latest init --id 6ae8028d-4e6d-4912-8eb2-87d4e6122880 --force --non-interactive
- name: Add bundle identifiers to app.json
working-directory: ${{ runner.temp }}/test-app
run: |
jq '.expo.ios.bundleIdentifier = "dev.expo.workersystemtests" | .expo.android.package = "dev.expo.workersystemtests"' app.json > app.json.tmp
mv app.json.tmp app.json
- name: Configure EAS Build
working-directory: ${{ runner.temp }}/test-app
run: npx -y eas-cli@latest build:configure --platform all
- name: Add system-test profile to eas.json
working-directory: ${{ runner.temp }}/test-app
run: |
jq '.build["system-test"] = { "ios": { "simulator": true }, "android": { "withoutCredentials": true } }' eas.json > eas.json.tmp
mv eas.json.tmp eas.json
- name: Setup EAS workflow
working-directory: ${{ runner.temp }}/test-app
run: |
mkdir -p .eas/workflows
cp ${{ github.workspace }}/.github/fixtures/eas-workflow-build-android.yml .eas/workflows/build-android.yml
- name: Run iOS build and Android workflow concurrently
working-directory: ${{ runner.temp }}/test-app
run: |
set -euo pipefail
BUILD_JSON=$(npx -y eas-cli@latest build --platform ios --profile system-test --non-interactive --no-wait --json)
BUILD_ID=$(echo "$BUILD_JSON" | jq -r 'if type=="array" then .[0].id // .[0].build.id // .[0].buildId else .id // .build.id // .buildId end // empty')
if [ -z "$BUILD_ID" ]; then
echo "Error: could not determine build ID"
echo "$BUILD_JSON" | jq .
exit 1
fi
echo "BUILD_ID=$BUILD_ID" >> "$GITHUB_ENV"
echo "Resolved BUILD_ID=$BUILD_ID"
WORKFLOW_JSON=$(npx -y eas-cli@latest workflow:run build-android.yml --non-interactive --no-wait --json)
WORKFLOW_RUN_ID=$(echo "$WORKFLOW_JSON" | jq -r '.id // .workflowRun.id // .workflowRunId // empty')
if [ -z "$WORKFLOW_RUN_ID" ]; then
echo "Error: could not determine workflow run ID"
echo "$WORKFLOW_JSON" | jq .
exit 1
fi
echo "WORKFLOW_RUN_ID=$WORKFLOW_RUN_ID" >> "$GITHUB_ENV"
echo "Resolved WORKFLOW_RUN_ID=$WORKFLOW_RUN_ID"
is_terminal_build_status() {
case "$1" in
FINISHED|ERRORED|CANCELED) return 0 ;;
*) return 1 ;;
esac
}
is_terminal_workflow_status() {
case "$1" in
SUCCESS|FAILURE|CANCELED|ACTION_REQUIRED) return 0 ;;
*) return 1 ;;
esac
}
BUILD_STATUS=""
WORKFLOW_STATUS=""
TIMEOUT_SECONDS=1800
START_TIME=$(date +%s)
while true; do
ELAPSED=$(( $(date +%s) - START_TIME ))
if [ "$ELAPSED" -ge "$TIMEOUT_SECONDS" ]; then
echo "Timeout: polling exceeded 30 minutes"
exit 1
fi
BUILD_STATUS=$(npx -y eas-cli@latest build:view "$BUILD_ID" --json | jq -r '.status // empty')
WORKFLOW_STATUS=$(npx -y eas-cli@latest workflow:view "$WORKFLOW_RUN_ID" --non-interactive --json | jq -r '.status // empty')
echo "iOS build status: $BUILD_STATUS"
echo "Android workflow status: $WORKFLOW_STATUS"
if is_terminal_build_status "$BUILD_STATUS" && is_terminal_workflow_status "$WORKFLOW_STATUS"; then
break
fi
sleep 7
done
EXIT_CODE=0
if [ "$BUILD_STATUS" != "FINISHED" ]; then
echo "Build did not finish successfully: $BUILD_STATUS"
EXIT_CODE=1
fi
if [ "$WORKFLOW_STATUS" != "SUCCESS" ]; then
echo "Workflow did not finish successfully: $WORKFLOW_STATUS"
EXIT_CODE=1
fi
exit "$EXIT_CODE"
- name: Cancel EAS runs on workflow cancellation
if: always() && (cancelled() || failure())
working-directory: ${{ runner.temp }}/test-app
run: |
set -euo pipefail
if [ -n "${BUILD_ID:-}" ]; then
echo "Canceling EAS build: $BUILD_ID"
BUILD_CANCEL_OUTPUT=$(npx -y eas-cli@latest build:cancel "$BUILD_ID" --non-interactive 2>&1 || true)
if [ -n "$BUILD_CANCEL_OUTPUT" ]; then
echo "$BUILD_CANCEL_OUTPUT"
else
echo "build:cancel produced no output"
fi
BUILD_STATUS=$(npx -y eas-cli@latest build:view "$BUILD_ID" --json 2>/dev/null | jq -r '.status // empty' || true)
if [ -n "$BUILD_STATUS" ]; then
echo "Post-cancel build status: $BUILD_STATUS"
else
echo "Post-cancel build status unavailable"
fi
fi
if [ -n "${WORKFLOW_RUN_ID:-}" ]; then
echo "Canceling EAS workflow run: $WORKFLOW_RUN_ID"
WORKFLOW_CANCEL_OUTPUT=$(npx -y eas-cli@latest workflow:cancel "$WORKFLOW_RUN_ID" --non-interactive 2>&1 || true)
if [ -n "$WORKFLOW_CANCEL_OUTPUT" ]; then
echo "$WORKFLOW_CANCEL_OUTPUT"
else
echo "workflow:cancel produced no output"
fi
WORKFLOW_STATUS=$(npx -y eas-cli@latest workflow:view "$WORKFLOW_RUN_ID" --non-interactive --json 2>/dev/null | jq -r '.status // empty' || true)
if [ -n "$WORKFLOW_STATUS" ]; then
echo "Post-cancel workflow status: $WORKFLOW_STATUS"
else
echo "Post-cancel workflow status unavailable"
fi
fi
-75
View File
@@ -1,75 +0,0 @@
name: worker
on:
push:
branches: [main]
paths:
- "packages/worker/**"
- .github/workflows/worker.yml
- .github/workflows/build-and-deploy-worker.yml
pull_request:
paths:
- "packages/worker/**"
- .github/workflows/worker.yml
- .github/workflows/build-and-deploy-worker.yml
concurrency:
group: worker-${{ github.ref }}-${{ github.event_name }}
cancel-in-progress: true
jobs:
worker-checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- uses: volta-cli/action@5c175f92dea6f48441c436471e6479dbc192e194 # v4
- run: yarn install --frozen-lockfile --check-files
- name: Run unit tests
working-directory: packages/worker
run: yarn test:unit
- name: Run integration tests
working-directory: packages/worker
run: yarn test:integration
worker-build:
needs:
- worker-checks
uses: ./.github/workflows/build-and-deploy-worker.yml
secrets: inherit
with:
environment: ${{ github.ref == 'refs/heads/main' && 'staging' || '' }}
system-tests-staging:
if: github.ref == 'refs/heads/main' && !failure() && !cancelled()
needs:
- worker-build
- worker-checks
uses: ./.github/workflows/worker-system-tests.yml
secrets: inherit
deploy-worker-production:
if: github.ref == 'refs/heads/main' && !failure() && !cancelled()
needs:
- system-tests-staging
uses: ./.github/workflows/build-and-deploy-worker.yml
secrets: inherit
with:
environment: production
notify-slack:
runs-on: ubuntu-latest
needs:
- deploy-worker-production
if: github.ref == 'refs/heads/main' && always() && !cancelled()
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- name: Notify Slack
uses: ./.github/internal-actions/notify-slack-on-fail-or-recover
with:
channel: turtle-bots
slack_webhook: ${{ secrets.SLACK_EAS_WORKFLOWS_WEBHOOK }}
-11
View File
@@ -8,21 +8,10 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- 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))
- 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 `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))
### 🧹 Chores
- 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
### 🎉 New features
-170
View File
@@ -1,170 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Monorepo Structure
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
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
> **Note**: Each package has its own CLAUDE.md with package-specific guidance. Click the links above to view them.
## Common Development Commands
### Setup
```bash
yarn install # Install all dependencies
yarn build # Build all packages in the monorepo
```
### Development
```bash
yarn start # Watch mode with parallel builds across all packages
yarn watch # Alias for start
# Development with relaxed TypeScript rules (allows unused variables)
yarn start-allow-unused
```
### Testing
```bash
yarn test # Run all tests across packages (Jest multi-project)
yarn test --watch # Run tests in watch mode
# Run tests for specific package
cd packages/eas-cli && yarn test
cd packages/eas-json && yarn test
cd packages/worker && yarn test
```
### Type Checking & Linting
```bash
yarn typecheck # TypeScript type check all packages
yarn lint # ESLint across all packages
```
### Running EAS CLI Locally
```bash
# From repository root:
yarn eas <command> # Run via root package script
# Or use the binary directly:
packages/eas-cli/bin/run <command>
# Recommended: Create an alias for development
alias easd="$(pwd)/packages/eas-cli/bin/run"
easd build --help
```
## Build System
Each package has independent TypeScript compilation:
- `eas-cli`: `src/` → `build/`
- `eas-json`: `src/` → `build/`
- `eas-build-cache-provider`: `src/` → `build/`
- `worker`: `src/` → `dist/`
TypeScript configs:
- `tsconfig.json` - Base configuration (extends @tsconfig/node18)
- `tsconfig.build.json` - Production builds
- `tsconfig.allowUnused.json` - Development with relaxed rules
## Testing Architecture
- **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`
- HTTP: `nock`
- Utilities: `ts-mockito`, `mockdate`
Run a single test file:
```bash
yarn test <path-to-test-file>
# Example:
yarn test packages/eas-cli/src/project/__tests__/projectUtils-test.ts
```
## Environment Variables
```bash
# API Configuration
export EXPO_STAGING=1 # Use staging API (https://staging-api.expo.dev)
export EXPO_LOCAL=1 # Use local API (http://127.0.0.1:3000)
# Development
export EAS_NO_VCS=1 # Disable version control checks
export EXPO_DEBUG=1 # Enable debug logging
```
## Code Style & Conventions
### Validation
All changes should be validated with TypeScript and the linter before committing:
```bash
yarn typecheck # Validate TypeScript types
yarn lint # Run ESLint
```
### Logging
Use the `Log` module from `@expo/logger`:
```typescript
import { Log } from '@expo/logger';
Log.log('Info message');
Log.warn('Warning message');
Log.error('Error message');
```
### Pull Request conventions
When creating pull requests, make sure to adhere to [PULL_REQUEST_TEMPLATE](./.github/PULL_REQUEST_TEMPLATE).
## Release Process
1. Update `CHANGELOG.md` in the appropriate package
2. Version bump is automated based on changelog:
- Breaking changes → MAJOR
- New features → MINOR
- Otherwise → PATCH
3. GitHub Actions workflow handles release automation
4. Notifications sent to Slack #eas-cli channel
## Important Notes
- **Node Version**: Requires Node.js >= 18.0.0 (managed via Volta)
- **Package Manager**: Uses Yarn 1.22.21
- **Compilation Target**: CommonJS with Node resolution
+1 -3
View File
@@ -4,11 +4,9 @@ import type { Config } from 'jest';
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],
projects: [easCliConfig, easJsonConfig],
testPathIgnorePatterns: ['.*'],
watchPlugins: ['jest-watch-typeahead/filename', 'jest-watch-typeahead/testname'],
collectCoverageFrom: ['<rootDir>/src/**/*.{ts,tsx,js,jsx}'],
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "16.29.0",
"version": "16.28.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+2 -2
View File
@@ -21,7 +21,7 @@
},
"devDependencies": {
"@types/jest": "29.5.11",
"@types/node": "20.14.8",
"@types/node": "20.11.0",
"eslint": "8.56.0",
"eslint-config-universe": "13.0.0",
"eslint-plugin-async-protect": "3.1.0",
@@ -32,7 +32,7 @@
"oclif": "^3.9.0",
"prettier": "3.1.1",
"ts-jest": "29.1.1",
"typescript": "5.5.4"
"typescript": "5.3.3"
},
"volta": {
"node": "20.11.0",
@@ -1,60 +0,0 @@
# CLAUDE.md - eas-build-cache-provider
This file provides guidance for working with the **eas-build-cache-provider** package.
## Overview
A build cache provider plugin for Expo CLI. Published as `eas-build-cache-provider` on npm.
- Provides remote caching for Expo builds
- Optimizes build times by caching intermediate build artifacts
- Integrates with EAS infrastructure
## Development Commands
```bash
yarn build # TypeScript compilation (src → build)
yarn watch # Watch mode
yarn typecheck # Type check
yarn test # Run tests
```
## Usage
### Installation
Install as a dev dependency in your Expo project:
```bash
npm install --save-dev eas-build-cache-provider
# or
yarn add --dev eas-build-cache-provider
```
### Configuration
Update your **app.json** to enable the EAS build cache provider:
```json
{
"expo": {
"experiments": {
"buildCacheProvider": "eas"
}
}
}
```
## How It Works
The build cache provider:
1. Hooks into Expo CLI's build process
2. Uploads build cache artifacts to EAS infrastructure
3. Downloads cached artifacts on subsequent builds
4. Improves build times by reusing cached dependencies and build outputs
## Important Notes
- **Experimental**: This is an experimental feature
- **EAS Integration**: Requires EAS account and project setup
- **Cache Scope**: Cache is scoped per project and platform
@@ -1,7 +1,7 @@
{
"name": "eas-build-cache-provider",
"description": "A build cache provider plugin for the Expo CLI",
"version": "16.29.0",
"version": "16.4.2",
"author": "Expo <[email protected]>",
"bugs": "https://github.com/expo/eas-cli/issues",
"dependencies": {
@@ -24,7 +24,7 @@
"@types/getenv": "^1.0.0",
"memfs": "3.4.13",
"rimraf": "3.0.2",
"typescript": "5.5.4"
"typescript": "5.3.3"
},
"engines": {
"node": ">=18.0.0"
-289
View File
@@ -1,289 +0,0 @@
# CLAUDE.md - eas-cli
This file provides guidance for working with the **eas-cli** package.
## Overview
The main CLI tool for Expo Application Services (EAS). Published as `eas-cli` on npm.
- **95+ commands** organized by domain
- Built on **oclif** (Open CLI Framework)
- Entry point: `bin/run`
## Development Commands
### Running Locally
```bash
# From repository root:
yarn eas <command>
# Or use the binary directly:
packages/eas-cli/bin/run <command>
# Recommended alias for development:
alias easd="$(pwd)/packages/eas-cli/bin/run"
easd build --help
```
### Build & Development
```bash
yarn build # TypeScript compilation (src → build)
yarn watch # Watch mode
yarn watch-allow-unused # Watch mode with relaxed TS rules
yarn typecheck # Type check without building
yarn typecheck-for-build # Type check for production build
```
### Testing
```bash
yarn test # Run all tests
yarn test <path-to-file> # Run specific test file
```
### GraphQL Code Generation
```bash
yarn generate-graphql-code # Generate TypeScript types from GraphQL schema
yarn verify-graphql-code # Verify GraphQL code is up to date (CI check)
```
The GraphQL schema is fetched from `https://staging-api.expo.dev/graphql` and generates:
- `src/graphql/generated.ts` - 499KB+ of TypeScript types
- Types for all queries, mutations, and fragments
### oclif Commands
```bash
yarn oclif readme # Auto-generate README.md from command metadata
```
## Command Architecture
### Base Command Class
All commands extend `EasCommand` (in `src/commandUtils/EasCommand.ts`).
### Context System
Commands declare required context via `ContextOptions`. This is a powerful system that provides commands with pre-initialized resources based on their needs.
Available context types:
- **LoggedIn**: Requires authentication
- Provides: `actor` (user info), `graphqlClient` (authenticated GraphQL client)
- **MaybeLoggedIn**: Optional authentication
- Provides: `actor` (may be undefined), `graphqlClient`
- **ProjectConfig**: Requires project with app.json/app.config
- Provides: `projectId`, `exp` (Expo config), project directory
- **ProjectDir**: Project directory context
- Provides: `projectDir`, `projectRoot`
- **VcsClient**: Version control system integration
- Provides: `vcsClient` (Git operations)
- **ServerSideEnvironmentVariables**: Environment variable management
- Provides: methods to interact with server-side env vars
Example command structure:
```typescript
import EasCommand from '../../commandUtils/EasCommand';
export default class Build extends EasCommand {
static override description = 'start a build';
static override ContextOptions = {
...EasCommand.ContextOptions.LoggedIn, // Requires auth
...EasCommand.ContextOptions.ProjectConfig, // Requires project
};
async runAsync(): Promise<void> {
// Context is automatically initialized
const { graphqlClient } = await this.getContextAsync(LoggedIn, {
nonInteractive: false,
});
const { projectId, exp } = await this.getContextAsync(ProjectConfig, {
nonInteractive: false,
});
// Your command logic here
}
}
```
### Command Discovery
- **Location**: Commands are auto-discovered from `build/commands/` by oclif
- **Manifest**: `oclif.manifest.json` is generated during `prepack`
- **Topics**: Organized into topics (account, build, channel, update, etc.)
## API Architecture
### GraphQL API (Primary)
**Location**: `src/graphql/`
**Client**: `ExpoGraphqlClient` (urql-based wrapper)
- Automatic retry logic with exponential backoff
- Cache management (cache-first vs network-only)
- Transient error handling
- Authentication via Bearer token or session secret
**Structure**:
```
src/graphql/
├── generated.ts # Generated TypeScript types
├── queries/ # 24+ query modules
├── mutations/ # 17+ mutation modules
└── types/ # GraphQL fragments
```
**Making GraphQL Requests**:
```typescript
// In a command with LoggedIn context:
const { graphqlClient } = await this.getContextAsync(LoggedIn, {
nonInteractive: false,
});
const result = await graphqlClient
.query(MyQuery, { variables: { id: projectId } })
.toPromise();
if (result.error) {
throw new Error(result.error.message);
}
const data = result.data;
```
### REST API v2 (Legacy)
**Client**: `ApiV2Client` in `src/api.ts`
**Base URLs**:
- Production: `https://api.expo.dev`
- Staging: `https://staging-api.expo.dev` (set `EXPO_STAGING=1`)
- Local: `http://127.0.0.1:3000` (set `EXPO_LOCAL=1`)
**Methods**: GET, POST, PUT, DELETE
**Error Handling**: `ApiV2Error` wrapper
## Directory Structure
```
src/
├── commands/ # CLI command implementations (organized by domain)
│ ├── account/ # Login, logout, account management
│ ├── build/ # Build orchestration commands
│ ├── branch/ # Update branch management
│ ├── channel/ # Update channel management
│ ├── credentials/ # iOS/Android credential management
│ ├── deploy/ # Deployment commands
│ ├── device/ # Apple device management
│ ├── env/ # Environment variable management
│ ├── fingerprint/ # Fingerprint comparison/generation
│ ├── metadata/ # App store metadata management
│ ├── project/ # Project initialization and info
│ ├── secret/ # Secret management (legacy)
│ ├── submit/ # App store submission
│ ├── update/ # EAS Update management
│ ├── webhook/ # Webhook management
│ └── workflow/ # Workflow management
├── commandUtils/ # Base command classes and shared utilities
├── build/ # Build orchestration logic
├── credentials/ # Credential management system
├── project/ # Project configuration and publishing
├── update/ # EAS Update functionality
├── submit/ # App store submission logic
├── user/ # Authentication and user management
├── graphql/ # GraphQL queries, mutations, and types
├── analytics/ # Rudder Analytics integration
├── vcs/ # Version control abstraction (Git)
└── utils/ # Shared utilities
```
## Common Patterns
### Working with eas.json
```typescript
import { EasJsonAccessor, EasJsonUtils } from '@expo/eas-json';
// Read eas.json
const accessor = EasJsonAccessor.fromProjectPath(projectDir);
const easJson = await EasJsonUtils.readAsync(projectDir);
// Access build profiles
const buildProfile = easJson.build?.[profileName];
```
### Project Context
```typescript
// In a command with ProjectConfig context:
const { projectId, exp } = await this.getContextAsync(ProjectConfig, {
nonInteractive: false,
});
// projectId: EAS project ID
// exp: Parsed Expo config (app.json/app.config.js)
```
### Authentication Flow
1. **SessionManager** handles login/logout (`src/user/SessionManager.ts`)
2. Authentication methods:
- Interactive login
- SSO via browser launcher
- Session secrets
- Access tokens
3. Credentials stored via `UserSettings`
### Feature Flags
- Server-side feature gating via `FeatureGating` class
- Environment variable overrides via `FeatureGateEnvOverrides`
- Retrieved from Actor during authentication
### Error Handling
Extend `CommandError` or use specific error classes in `src/commandUtils/errors.ts`:
```typescript
import { CommandError } from '../commandUtils/errors';
throw new CommandError('BUILD_FAILED', 'The build failed due to...');
```
### Analytics
- **Provider**: Rudder Analytics (`@expo/rudder-sdk-node`)
- Events tracked for builds, submissions, commands
- User can opt-out via settings
## Testing
- **Location**: `__tests__/` directories alongside source files
- **Mocking**:
- File system: `memfs`
- HTTP: `nock`
- Utilities: `ts-mockito`, `mockdate`
Example test:
```typescript
import { vol } from 'memfs';
import nock from 'nock';
describe('MyCommand', () => {
beforeEach(() => {
vol.reset();
nock.cleanAll();
});
it('should do something', async () => {
// Test implementation
});
});
```
## Important Notes
- **oclif**: Commands auto-discovered from `build/commands/`
- **README**: Auto-generated from oclif - update via `yarn oclif readme`
- **Manifest**: Generated during `prepack` step
- **Templates**: `src/commandUtils/new/templates` are copied to `build/` during build
- **Node Version**: >= 18.0.0
+94 -97
View File
@@ -176,7 +176,7 @@ ALIASES
$ eas login
```
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/account/login.ts)_
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/account/login.ts)_
## `eas account:logout`
@@ -193,7 +193,7 @@ ALIASES
$ eas logout
```
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/account/logout.ts)_
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/account/logout.ts)_
## `eas account:view`
@@ -210,7 +210,7 @@ ALIASES
$ eas whoami
```
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/account/view.ts)_
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/account/view.ts)_
## `eas analytics [STATUS]`
@@ -224,7 +224,7 @@ DESCRIPTION
display or change analytics settings
```
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/analytics.ts)_
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/analytics.ts)_
## `eas autocomplete [SHELL]`
@@ -276,7 +276,7 @@ DESCRIPTION
create a branch
```
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/branch/create.ts)_
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/branch/create.ts)_
## `eas branch:delete [NAME]`
@@ -297,7 +297,7 @@ DESCRIPTION
delete a branch
```
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/branch/delete.ts)_
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/branch/delete.ts)_
## `eas branch:list`
@@ -317,7 +317,7 @@ DESCRIPTION
list all branches
```
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/branch/list.ts)_
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/branch/list.ts)_
## `eas branch:rename`
@@ -337,7 +337,7 @@ DESCRIPTION
rename a branch
```
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/branch/rename.ts)_
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/branch/rename.ts)_
## `eas branch:view [NAME]`
@@ -360,7 +360,7 @@ DESCRIPTION
view a branch
```
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/branch/view.ts)_
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/branch/view.ts)_
## `eas build`
@@ -401,7 +401,7 @@ DESCRIPTION
start a build
```
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/build/index.ts)_
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/build/index.ts)_
## `eas build:cancel [BUILD_ID]`
@@ -420,7 +420,7 @@ DESCRIPTION
cancel a build
```
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/build/cancel.ts)_
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/build/cancel.ts)_
## `eas build:configure`
@@ -437,7 +437,7 @@ DESCRIPTION
configure the project to support EAS Build
```
_See code: [packages/eas-cli/src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/build/configure.ts)_
_See code: [packages/eas-cli/src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/build/configure.ts)_
## `eas build:delete [BUILD_ID]`
@@ -456,7 +456,7 @@ DESCRIPTION
delete a build
```
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/build/delete.ts)_
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/build/delete.ts)_
## `eas build:dev`
@@ -476,7 +476,7 @@ DESCRIPTION
run dev client simulator/emulator build with matching fingerprint or create a new one
```
_See code: [packages/eas-cli/src/commands/build/dev.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/build/dev.ts)_
_See code: [packages/eas-cli/src/commands/build/dev.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/build/dev.ts)_
## `eas build:download`
@@ -497,7 +497,7 @@ DESCRIPTION
download simulator/emulator builds for a given fingerprint hash
```
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/build/download.ts)_
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/build/download.ts)_
## `eas build:inspect`
@@ -532,7 +532,7 @@ DESCRIPTION
inspect the state of the project at specific build stages, useful for troubleshooting
```
_See code: [packages/eas-cli/src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/build/inspect.ts)_
_See code: [packages/eas-cli/src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/build/inspect.ts)_
## `eas build:list`
@@ -584,7 +584,7 @@ DESCRIPTION
list all builds for your project
```
_See code: [packages/eas-cli/src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/build/list.ts)_
_See code: [packages/eas-cli/src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/build/list.ts)_
## `eas build:resign`
@@ -613,7 +613,7 @@ DESCRIPTION
re-sign a build archive
```
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/build/resign.ts)_
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/build/resign.ts)_
## `eas build:run`
@@ -639,7 +639,7 @@ DESCRIPTION
run simulator/emulator builds from eas-cli
```
_See code: [packages/eas-cli/src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/build/run.ts)_
_See code: [packages/eas-cli/src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/build/run.ts)_
## `eas build:submit`
@@ -693,7 +693,7 @@ DESCRIPTION
get the latest version from EAS servers
```
_See code: [packages/eas-cli/src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/build/version/get.ts)_
_See code: [packages/eas-cli/src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/build/version/get.ts)_
## `eas build:version:set`
@@ -712,7 +712,7 @@ DESCRIPTION
update version of an app
```
_See code: [packages/eas-cli/src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/build/version/set.ts)_
_See code: [packages/eas-cli/src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/build/version/set.ts)_
## `eas build:version:sync`
@@ -731,7 +731,7 @@ DESCRIPTION
update a version in native code with a value stored on EAS servers
```
_See code: [packages/eas-cli/src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/build/version/sync.ts)_
_See code: [packages/eas-cli/src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/build/version/sync.ts)_
## `eas build:view [BUILD_ID]`
@@ -748,7 +748,7 @@ DESCRIPTION
view a build for your project
```
_See code: [packages/eas-cli/src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/build/view.ts)_
_See code: [packages/eas-cli/src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/build/view.ts)_
## `eas channel:create [NAME]`
@@ -769,7 +769,7 @@ DESCRIPTION
create a channel
```
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/channel/create.ts)_
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/channel/create.ts)_
## `eas channel:delete [NAME]`
@@ -790,7 +790,7 @@ DESCRIPTION
Delete a channel
```
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/channel/delete.ts)_
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/channel/delete.ts)_
## `eas channel:edit [NAME]`
@@ -812,7 +812,7 @@ DESCRIPTION
point a channel at a new branch
```
_See code: [packages/eas-cli/src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/channel/edit.ts)_
_See code: [packages/eas-cli/src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/channel/edit.ts)_
## `eas channel:list`
@@ -832,7 +832,7 @@ DESCRIPTION
list all channels
```
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/channel/list.ts)_
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/channel/list.ts)_
## `eas channel:pause [NAME]`
@@ -854,7 +854,7 @@ DESCRIPTION
pause a channel to stop it from sending updates
```
_See code: [packages/eas-cli/src/commands/channel/pause.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/channel/pause.ts)_
_See code: [packages/eas-cli/src/commands/channel/pause.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/channel/pause.ts)_
## `eas channel:resume [NAME]`
@@ -876,7 +876,7 @@ DESCRIPTION
resume a channel to start sending updates
```
_See code: [packages/eas-cli/src/commands/channel/resume.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/channel/resume.ts)_
_See code: [packages/eas-cli/src/commands/channel/resume.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/channel/resume.ts)_
## `eas channel:rollout [CHANNEL]`
@@ -909,7 +909,7 @@ DESCRIPTION
Roll a new branch out on a channel incrementally.
```
_See code: [packages/eas-cli/src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/channel/rollout.ts)_
_See code: [packages/eas-cli/src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/channel/rollout.ts)_
## `eas channel:view [NAME]`
@@ -932,7 +932,7 @@ DESCRIPTION
view a channel
```
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/channel/view.ts)_
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/channel/view.ts)_
## `eas config`
@@ -953,7 +953,7 @@ DESCRIPTION
display project configuration (app.json + eas.json)
```
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/config.ts)_
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/config.ts)_
## `eas credentials`
@@ -970,7 +970,7 @@ DESCRIPTION
manage credentials
```
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/credentials/index.ts)_
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/credentials/index.ts)_
## `eas credentials:configure-build`
@@ -988,7 +988,7 @@ DESCRIPTION
Set up credentials for building your project.
```
_See code: [packages/eas-cli/src/commands/credentials/configure-build.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/credentials/configure-build.ts)_
_See code: [packages/eas-cli/src/commands/credentials/configure-build.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/credentials/configure-build.ts)_
## `eas deploy [options]`
@@ -1016,7 +1016,7 @@ ALIASES
$ eas worker:deploy
```
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/deploy/index.ts)_
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/deploy/index.ts)_
## `eas deploy:alias`
@@ -1041,7 +1041,7 @@ ALIASES
$ eas deploy:promote
```
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/deploy/alias/index.ts)_
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/deploy/alias/index.ts)_
## `eas deploy:alias:delete [ALIAS_NAME]`
@@ -1062,7 +1062,7 @@ ALIASES
$ eas worker:alias:delete
```
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
## `eas deploy:delete [DEPLOYMENT_ID]`
@@ -1083,7 +1083,7 @@ ALIASES
$ eas worker:delete
```
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/deploy/delete.ts)_
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/deploy/delete.ts)_
## `eas deploy:promote`
@@ -1120,7 +1120,7 @@ DESCRIPTION
register new Apple Devices to use for internal distribution
```
_See code: [packages/eas-cli/src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/device/create.ts)_
_See code: [packages/eas-cli/src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/device/create.ts)_
## `eas device:delete`
@@ -1140,7 +1140,7 @@ DESCRIPTION
remove a registered device from your account
```
_See code: [packages/eas-cli/src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/device/delete.ts)_
_See code: [packages/eas-cli/src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/device/delete.ts)_
## `eas device:list`
@@ -1161,7 +1161,7 @@ DESCRIPTION
list all registered devices for your account
```
_See code: [packages/eas-cli/src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/device/list.ts)_
_See code: [packages/eas-cli/src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/device/list.ts)_
## `eas device:rename`
@@ -1182,7 +1182,7 @@ DESCRIPTION
rename a registered device
```
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/device/rename.ts)_
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/device/rename.ts)_
## `eas device:view [UDID]`
@@ -1196,7 +1196,7 @@ DESCRIPTION
view a device for your project
```
_See code: [packages/eas-cli/src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/device/view.ts)_
_See code: [packages/eas-cli/src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/device/view.ts)_
## `eas diagnostics`
@@ -1210,7 +1210,7 @@ DESCRIPTION
display environment info
```
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/diagnostics.ts)_
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/diagnostics.ts)_
## `eas env:create [ENVIRONMENT]`
@@ -1240,7 +1240,7 @@ DESCRIPTION
create an environment variable for the current project or account
```
_See code: [packages/eas-cli/src/commands/env/create.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/env/create.ts)_
_See code: [packages/eas-cli/src/commands/env/create.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/env/create.ts)_
## `eas env:delete [ENVIRONMENT]`
@@ -1265,7 +1265,7 @@ DESCRIPTION
delete an environment variable for the current project or account
```
_See code: [packages/eas-cli/src/commands/env/delete.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/env/delete.ts)_
_See code: [packages/eas-cli/src/commands/env/delete.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/env/delete.ts)_
## `eas env:exec ENVIRONMENT BASH_COMMAND`
@@ -1287,7 +1287,7 @@ DESCRIPTION
execute a command with environment variables from the selected environment
```
_See code: [packages/eas-cli/src/commands/env/exec.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/env/exec.ts)_
_See code: [packages/eas-cli/src/commands/env/exec.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/env/exec.ts)_
## `eas env:get [ENVIRONMENT]`
@@ -1312,7 +1312,7 @@ DESCRIPTION
view an environment variable for the current project or account
```
_See code: [packages/eas-cli/src/commands/env/get.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/env/get.ts)_
_See code: [packages/eas-cli/src/commands/env/get.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/env/get.ts)_
## `eas env:list [ENVIRONMENT]`
@@ -1338,7 +1338,7 @@ DESCRIPTION
list environment variables for the current project or account
```
_See code: [packages/eas-cli/src/commands/env/list.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/env/list.ts)_
_See code: [packages/eas-cli/src/commands/env/list.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/env/list.ts)_
## `eas env:pull [ENVIRONMENT]`
@@ -1360,7 +1360,7 @@ DESCRIPTION
pull environment variables for the selected environment to .env file
```
_See code: [packages/eas-cli/src/commands/env/pull.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/env/pull.ts)_
_See code: [packages/eas-cli/src/commands/env/pull.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/env/pull.ts)_
## `eas env:push [ENVIRONMENT]`
@@ -1382,7 +1382,7 @@ DESCRIPTION
push environment variables from .env file to the selected environment
```
_See code: [packages/eas-cli/src/commands/env/push.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/env/push.ts)_
_See code: [packages/eas-cli/src/commands/env/push.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/env/push.ts)_
## `eas env:update [ENVIRONMENT]`
@@ -1414,7 +1414,7 @@ DESCRIPTION
update an environment variable on the current project or account
```
_See code: [packages/eas-cli/src/commands/env/update.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/env/update.ts)_
_See code: [packages/eas-cli/src/commands/env/update.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/env/update.ts)_
## `eas fingerprint:compare [HASH1] [HASH2]`
@@ -1458,7 +1458,7 @@ EXAMPLES
$ eas fingerprint:compare <FINGERPRINT-HASH> --update-id <UPDATE-ID> # Compare fingerprint from update against provided fingerprint
```
_See code: [packages/eas-cli/src/commands/fingerprint/compare.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/fingerprint/compare.ts)_
_See code: [packages/eas-cli/src/commands/fingerprint/compare.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/fingerprint/compare.ts)_
## `eas fingerprint:generate`
@@ -1488,7 +1488,7 @@ EXAMPLES
$ eas fingerprint:generate --json --non-interactive --platform android # Output fingerprint json to stdout
```
_See code: [packages/eas-cli/src/commands/fingerprint/generate.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/fingerprint/generate.ts)_
_See code: [packages/eas-cli/src/commands/fingerprint/generate.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/fingerprint/generate.ts)_
## `eas help [COMMAND]`
@@ -1596,7 +1596,7 @@ DESCRIPTION
validate the local store configuration
```
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/metadata/lint.ts)_
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/metadata/lint.ts)_
## `eas metadata:pull`
@@ -1613,7 +1613,7 @@ DESCRIPTION
generate the local store configuration from the app stores
```
_See code: [packages/eas-cli/src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/metadata/pull.ts)_
_See code: [packages/eas-cli/src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/metadata/pull.ts)_
## `eas metadata:push`
@@ -1630,7 +1630,7 @@ DESCRIPTION
sync the local store configuration to the app stores
```
_See code: [packages/eas-cli/src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/metadata/push.ts)_
_See code: [packages/eas-cli/src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/metadata/push.ts)_
## `eas new [PATH]`
@@ -1681,7 +1681,7 @@ DESCRIPTION
open the project page in a web browser
```
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/open.ts)_
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/open.ts)_
## `eas project:info`
@@ -1695,7 +1695,7 @@ DESCRIPTION
information about the current project
```
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/project/info.ts)_
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/project/info.ts)_
## `eas project:init`
@@ -1718,7 +1718,7 @@ ALIASES
$ eas init
```
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/project/init.ts)_
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/project/init.ts)_
## `eas project:new [PATH]`
@@ -1741,7 +1741,7 @@ ALIASES
$ eas new
```
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/project/new.ts)_
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/project/new.ts)_
## `eas project:onboarding [TARGET_PROJECT_DIRECTORY]`
@@ -1759,7 +1759,7 @@ ALIASES
$ eas onboarding
```
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/project/onboarding.ts)_
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/project/onboarding.ts)_
## `eas submit`
@@ -1794,7 +1794,7 @@ ALIASES
$ eas build:submit
```
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/submit.ts)_
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/submit.ts)_
## `eas update`
@@ -1834,7 +1834,7 @@ DESCRIPTION
publish an update group
```
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/update/index.ts)_
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/update/index.ts)_
## `eas update:configure`
@@ -1854,7 +1854,7 @@ DESCRIPTION
configure the project to support EAS Update
```
_See code: [packages/eas-cli/src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/update/configure.ts)_
_See code: [packages/eas-cli/src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/update/configure.ts)_
## `eas update:delete GROUPID`
@@ -1875,7 +1875,7 @@ DESCRIPTION
delete all the updates in an update group
```
_See code: [packages/eas-cli/src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/update/delete.ts)_
_See code: [packages/eas-cli/src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/update/delete.ts)_
## `eas update:edit [GROUPID]`
@@ -1899,7 +1899,7 @@ DESCRIPTION
edit all the updates in an update group
```
_See code: [packages/eas-cli/src/commands/update/edit.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/update/edit.ts)_
_See code: [packages/eas-cli/src/commands/update/edit.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/update/edit.ts)_
## `eas update:list`
@@ -1907,24 +1907,21 @@ view the recent updates
```
USAGE
$ eas update:list [--branch <value> | --all] [-p android|ios|all] [--runtime-version <value>] [--offset
<value>] [--limit <value>] [--json --non-interactive]
$ eas update:list [--branch <value> | --all] [--offset <value>] [--limit <value>] [--json --non-interactive]
FLAGS
-p, --platform=(android|ios|all) Filter updates by platform
--all List updates on all branches
--branch=<value> List updates only on this branch
--json Enable JSON output, non-JSON messages will be printed to stderr.
--limit=<value> The number of items to fetch each query. Defaults to 25 and is capped at 50.
--non-interactive Run the command in non-interactive mode.
--offset=<value> Start queries from specified index. Use for paginating results. Defaults to 0.
--runtime-version=<value> Filter updates by runtime version
--all List updates on all branches
--branch=<value> List updates only on this branch
--json Enable JSON output, non-JSON messages will be printed to stderr.
--limit=<value> The number of items to fetch each query. Defaults to 25 and is capped at 50.
--non-interactive Run the command in non-interactive mode.
--offset=<value> Start queries from specified index. Use for paginating results. Defaults to 0.
DESCRIPTION
view the recent updates
```
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/update/list.ts)_
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/update/list.ts)_
## `eas update:republish`
@@ -1960,7 +1957,7 @@ DESCRIPTION
roll back to an existing update
```
_See code: [packages/eas-cli/src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/update/republish.ts)_
_See code: [packages/eas-cli/src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/update/republish.ts)_
## `eas update:revert-update-rollout`
@@ -1987,7 +1984,7 @@ DESCRIPTION
revert a rollout update for a project
```
_See code: [packages/eas-cli/src/commands/update/revert-update-rollout.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
_See code: [packages/eas-cli/src/commands/update/revert-update-rollout.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
## `eas update:roll-back-to-embedded`
@@ -2015,7 +2012,7 @@ DESCRIPTION
roll back to the embedded update
```
_See code: [packages/eas-cli/src/commands/update/roll-back-to-embedded.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
_See code: [packages/eas-cli/src/commands/update/roll-back-to-embedded.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
## `eas update:rollback`
@@ -2036,7 +2033,7 @@ DESCRIPTION
instead execute "eas update:republish" or "eas update:roll-back-to-embedded".
```
_See code: [packages/eas-cli/src/commands/update/rollback.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/update/rollback.ts)_
_See code: [packages/eas-cli/src/commands/update/rollback.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/update/rollback.ts)_
## `eas update:view GROUPID`
@@ -2056,7 +2053,7 @@ DESCRIPTION
update group details
```
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/update/view.ts)_
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/update/view.ts)_
## `eas upload`
@@ -2077,7 +2074,7 @@ DESCRIPTION
upload a local build and generate a sharable link
```
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/upload.ts)_
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/upload.ts)_
## `eas webhook:create`
@@ -2098,7 +2095,7 @@ DESCRIPTION
create a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/webhook/create.ts)_
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/webhook/create.ts)_
## `eas webhook:delete [ID]`
@@ -2118,7 +2115,7 @@ DESCRIPTION
delete a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/webhook/delete.ts)_
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/webhook/delete.ts)_
## `eas webhook:list`
@@ -2136,7 +2133,7 @@ DESCRIPTION
list webhooks
```
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/webhook/list.ts)_
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/webhook/list.ts)_
## `eas webhook:update`
@@ -2158,7 +2155,7 @@ DESCRIPTION
update a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/webhook/update.ts)_
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/webhook/update.ts)_
## `eas webhook:view ID`
@@ -2175,7 +2172,7 @@ DESCRIPTION
view a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/webhook/view.ts)_
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/webhook/view.ts)_
## `eas whoami`
@@ -2295,7 +2292,7 @@ DESCRIPTION
to cancel.
```
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/workflow/cancel.ts)_
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/workflow/cancel.ts)_
## `eas workflow:create [NAME]`
@@ -2315,7 +2312,7 @@ DESCRIPTION
create a new workflow configuration YAML file
```
_See code: [packages/eas-cli/src/commands/workflow/create.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/workflow/create.ts)_
_See code: [packages/eas-cli/src/commands/workflow/create.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/workflow/create.ts)_
## `eas workflow:logs [ID]`
@@ -2339,7 +2336,7 @@ DESCRIPTION
If no ID is passed in, you will be prompted to select from recent workflow runs for the current project.
```
_See code: [packages/eas-cli/src/commands/workflow/logs.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/workflow/logs.ts)_
_See code: [packages/eas-cli/src/commands/workflow/logs.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/workflow/logs.ts)_
## `eas workflow:run [FILE]`
@@ -2379,7 +2376,7 @@ FLAG DESCRIPTIONS
Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.
```
_See code: [packages/eas-cli/src/commands/workflow/run.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/workflow/run.ts)_
_See code: [packages/eas-cli/src/commands/workflow/run.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/workflow/run.ts)_
## `eas workflow:runs`
@@ -2404,7 +2401,7 @@ DESCRIPTION
list recent workflow runs for this project, with their IDs, statuses, and timestamps
```
_See code: [packages/eas-cli/src/commands/workflow/runs.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/workflow/runs.ts)_
_See code: [packages/eas-cli/src/commands/workflow/runs.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/workflow/runs.ts)_
## `eas workflow:status [WORKFLOW_RUN_ID]`
@@ -2432,7 +2429,7 @@ FLAG DESCRIPTIONS
Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.
```
_See code: [packages/eas-cli/src/commands/workflow/status.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/workflow/status.ts)_
_See code: [packages/eas-cli/src/commands/workflow/status.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/workflow/status.ts)_
## `eas workflow:validate PATH`
@@ -2452,7 +2449,7 @@ DESCRIPTION
validate a workflow configuration yaml file
```
_See code: [packages/eas-cli/src/commands/workflow/validate.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/workflow/validate.ts)_
_See code: [packages/eas-cli/src/commands/workflow/validate.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/workflow/validate.ts)_
## `eas workflow:view [ID]`
@@ -2474,5 +2471,5 @@ DESCRIPTION
workflow runs for the current project.
```
_See code: [packages/eas-cli/src/commands/workflow/view.ts](https://github.com/expo/eas-cli/blob/v16.29.0/packages/eas-cli/src/commands/workflow/view.ts)_
_See code: [packages/eas-cli/src/commands/workflow/view.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/workflow/view.ts)_
<!-- commandsstop -->
+566 -1356
View File
File diff suppressed because it is too large. Load diff
+5 -7
View File
@@ -1,25 +1,25 @@
{
"name": "eas-cli",
"description": "EAS command line tool",
"version": "16.29.0",
"version": "16.28.0",
"author": "Expo <[email protected]>",
"bin": {
"eas": "./bin/run"
},
"bugs": "https://github.com/expo/eas-cli/issues",
"dependencies": {
"@expo/apple-utils": "2.1.13",
"@expo/apple-utils": "2.1.12",
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "10.0.6",
"@expo/config-plugins": "9.0.12",
"@expo/eas-build-job": "1.0.243",
"@expo/eas-json": "16.29.0",
"@expo/eas-json": "16.25.0",
"@expo/env": "^1.0.0",
"@expo/json-file": "8.3.3",
"@expo/logger": "1.0.221",
"@expo/multipart-body-parser": "2.0.0",
"@expo/osascript": "2.1.4",
"@expo/package-manager": "1.9.10",
"@expo/package-manager": "1.7.0",
"@expo/pkcs12": "0.1.3",
"@expo/plist": "0.2.0",
"@expo/plugin-help": "5.1.23",
@@ -58,7 +58,6 @@
"https-proxy-agent": "5.0.1",
"ignore": "5.3.0",
"indent-string": "4.0.0",
"invariant": "^2.2.2",
"jks-js": "1.1.0",
"joi": "17.11.0",
"jsonwebtoken": "9.0.0",
@@ -108,7 +107,6 @@
"@types/express": "4.17.15",
"@types/fs-extra": "11.0.4",
"@types/getenv": "^1.0.0",
"@types/invariant": "^2.2.37",
"@types/jsonwebtoken": "8.5.1",
"@types/mime": "3.0.1",
"@types/node-fetch": "2.6.12",
@@ -131,7 +129,7 @@
"rimraf": "3.0.2",
"ts-mockito": "2.6.1",
"ts-node": "10.9.2",
"typescript": "5.5.4"
"typescript": "5.3.3"
},
"engines": {
"node": ">=18.0.0"
-39
View File
@@ -1,39 +0,0 @@
import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { withErrorHandlingAsync } from '../graphql/client';
import {
BackgroundJobReceiptDataFragment,
ScheduleChannelDeletionMutation,
ScheduleChannelDeletionMutationVariables,
} from '../graphql/generated';
import { BackgroundJobReceiptNode } from '../graphql/types/BackgroundJobReceipt';
export async function scheduleChannelDeletionAsync(
graphqlClient: ExpoGraphqlClient,
{
channelId,
}: {
channelId: string;
}
): Promise<BackgroundJobReceiptDataFragment> {
const result = await withErrorHandlingAsync(
graphqlClient
.mutation<ScheduleChannelDeletionMutation, ScheduleChannelDeletionMutationVariables>(
gql`
mutation ScheduleChannelDeletion($channelId: ID!) {
updateChannel {
scheduleUpdateChannelDeletion(channelId: $channelId) {
id
...BackgroundJobReceiptData
}
}
}
${BackgroundJobReceiptNode}
`,
{ channelId }
)
.toPromise()
);
return result.updateChannel.scheduleUpdateChannelDeletion;
}
@@ -1,41 +0,0 @@
import {
experienceToAccountName,
fullNameToSlug,
isValidExperienceName,
isValidSlugStrict,
} from '../experienceParser';
describe(isValidExperienceName, () => {
it.each([
['wat', false],
['@wat', false],
['@wat/', false],
['@wat/hello', true],
])('case %p', (candidate, shouldBeValid) => {
expect(isValidExperienceName(candidate)).toEqual(shouldBeValid);
});
});
describe(experienceToAccountName, () => {
it('parses account name', () => {
expect(experienceToAccountName('@wat/huh')).toEqual('wat');
});
});
describe(fullNameToSlug, () => {
it('parses slug', () => {
expect(fullNameToSlug('@wat/huh')).toEqual('huh');
});
});
describe(isValidSlugStrict, () => {
it.each([
['wat', true],
['wat123456-hello-what123456', true],
['@wat', false],
['@wat/', false],
['@wat/hello', false],
])('case %p', (candidate, shouldBeValid) => {
expect(isValidSlugStrict(candidate)).toEqual(shouldBeValid);
});
});
@@ -1,40 +0,0 @@
/**
* TODO: Move to eas-build repo
*/
import invariant from 'invariant';
const EXPERIENCE_NAME_REGEX = /^@(.*?)\/(.*)/; // @<captureGroup1>/<captureGroup2>
const SLUG_REGEX_STRICT = /^[a-zA-Z0-9_\\-]+$/; // from xdl-schemas slug
export const isValidExperienceName = (experienceName: string): boolean => {
const matches = experienceName.match(EXPERIENCE_NAME_REGEX);
return matches !== null && matches[1].length > 0 && matches[2].length > 0;
};
export const experienceToAccountName = (experienceName: string): string => {
const matches = experienceName.match(EXPERIENCE_NAME_REGEX);
const accountName = matches ? matches[1] : null;
invariant(accountName, `The experience name "${experienceName}" is malformed`);
return accountName;
};
export const fullNameToSlug = (fullName: string): string | null => {
const match = fullName.match(EXPERIENCE_NAME_REGEX);
return match ? match[2] : null;
};
export const isValidSlugStrict = (slug: string): boolean => SLUG_REGEX_STRICT.test(slug);
// keep this list in sync with our project creation CLI https://github.com/expo/expo/blob/main/packages/create-expo/src/Template.ts
export const FORBIDDEN_NAMES = [
'react-native',
'react',
'react-dom',
'react-native-web',
'expo',
'expo-router',
];
export function isSlugForbidden(slug: string): boolean {
return FORBIDDEN_NAMES.includes(slug);
}
@@ -1,33 +0,0 @@
import { isSlugForbidden, isValidExperienceName, isValidSlugStrict } from './experienceParser';
/**
* Perform the same validation as the server, to avoid GraphQL errors.
*/
export function validateFullNameAndSlug(fullName: string, slug: string): void {
if (!isValidExperienceName(fullName)) {
throw new Error(`Invalid project name: ${fullName}`);
}
if (!isValidSlugStrict(slug)) {
throw new Error(`Invalid slug: ${slug}`);
}
if (isSlugForbidden(slug)) {
throw new Error(
`Cannot create an app named "${slug}" because it would conflict with a dependency of the same name.`
);
}
if (fullName.length > 255) {
throw new Error(`Project full name (${fullName}) can not be longer than 255 characters.`);
}
}
/**
* Attempt to derive a valid slug name from the one passed in
*/
export function validSlugName(slug: string): string {
const chars = slug.split('');
const validChars = chars.filter(char => isValidSlugStrict(char));
return validChars.join('');
}
@@ -1,12 +1,19 @@
import { scheduleChannelDeletionAsync } from '../../channel/delete';
import gql from 'graphql-tag';
import { selectChannelOnAppAsync } from '../../channel/queries';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { withErrorHandlingAsync } from '../../graphql/client';
import {
DeleteUpdateChannelMutation,
DeleteUpdateChannelMutationVariables,
DeleteUpdateChannelResult,
} from '../../graphql/generated';
import { ChannelQuery } from '../../graphql/queries/ChannelQuery';
import Log from '../../log';
import { toggleConfirmAsync } from '../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { pollForBackgroundJobReceiptAsync } from '../../utils/pollForBackgroundJobReceiptAsync';
export default class ChannelDelete extends EasCommand {
static override description = 'Delete a channel';
@@ -70,7 +77,7 @@ export default class ChannelDelete extends EasCommand {
if (!nonInteractive) {
Log.addNewLineIfNone();
Log.warn(
`You are about to permanently delete channel: "${channelName}". Deleting this channel will also permanently remove all associated builds from your account. \nThis action is irreversible.`
`You are about to permanently delete channel: "${channelName}".\nThis action is irreversible.`
);
Log.newLine();
const confirmed = await toggleConfirmAsync({ message: 'Are you sure you wish to proceed?' });
@@ -80,14 +87,39 @@ export default class ChannelDelete extends EasCommand {
}
}
const receipt = await scheduleChannelDeletionAsync(graphqlClient, { channelId });
const successfulReceipt = await pollForBackgroundJobReceiptAsync(graphqlClient, receipt);
Log.debug('Deletion result', { successfulReceipt });
const deletionResult = await deleteChannelOnAppAsync(graphqlClient, {
channelId,
});
if (jsonFlag) {
printJsonOnlyOutput({ id: channelId });
printJsonOnlyOutput(deletionResult);
} else {
Log.withTick(`️Deleted channel "${channelName}".`);
}
}
}
async function deleteChannelOnAppAsync(
graphqlClient: ExpoGraphqlClient,
{ channelId }: DeleteUpdateChannelMutationVariables
): Promise<DeleteUpdateChannelResult> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<DeleteUpdateChannelMutation, DeleteUpdateChannelMutationVariables>(
gql`
mutation DeleteUpdateChannel($channelId: ID!) {
updateChannel {
deleteUpdateChannel(channelId: $channelId) {
id
}
}
}
`,
{
channelId,
}
)
.toPromise()
);
return data.updateChannel.deleteUpdateChannel;
}
+1 -1
View File
@@ -107,7 +107,7 @@ export default class EnvGet extends EasCommand {
let variable;
if (variables.length > 1) {
const variableInEnvironment = variables.find(v => v.environments?.includes(environment));
const variableInEnvironment = variables.find(v => v.environments?.includes(environment!));
if (!variableInEnvironment) {
throw new Error(
`Variable with name "${name}" not found in environment "${environment.toLocaleLowerCase()}"`
@@ -8,7 +8,6 @@ import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { saveProjectIdToAppConfigAsync } from '../../commandUtils/context/contextUtils/getProjectIdAsync';
import { EASNonInteractiveFlag } from '../../commandUtils/flags';
import { validSlugName, validateFullNameAndSlug } from '../../commandUtils/projectNameValidation';
import { Role } from '../../graphql/generated';
import { AppMutation } from '../../graphql/mutations/AppMutation';
import { AppQuery } from '../../graphql/queries/AppQuery';
@@ -277,9 +276,8 @@ export default class ProjectInit extends EasCommand {
throw new Error('No account selected for project. Canceling.');
}
const projectName = validSlugName(exp.slug); // This filters out invalid characters
const projectName = exp.slug;
const projectFullName = `@${accountName}/${projectName}`;
validateFullNameAndSlug(projectFullName, projectName);
const existingProjectIdOnServer = await findProjectIdByAccountNameAndSlugNullableAsync(
graphqlClient,
accountName,
@@ -5,7 +5,7 @@ import nullthrows from 'nullthrows';
import path from 'path';
import { instance, mock } from 'ts-mockito';
import UpdatePublish, { preprocessSourceMapsArg } from '..';
import UpdatePublish from '..';
import { ensureBranchExistsAsync } from '../../../branch/queries';
import {
DynamicPrivateProjectConfigContextField,
@@ -328,34 +328,3 @@ function mockTestExport({
return { inputDir: exportDir, platforms, runtimeVersion };
}
describe(preprocessSourceMapsArg, () => {
it('passes through when --source-maps has a value', () => {
const argv = ['--source-maps', 'inline', '--message', 'test'];
expect(preprocessSourceMapsArg(argv)).toEqual(argv);
});
it('inserts "true" when --source-maps has no value', () => {
// At end of args
expect(preprocessSourceMapsArg(['--branch', 'main', '--source-maps'])).toEqual([
'--branch',
'main',
'--source-maps',
'true',
]);
// Followed by another flag
expect(preprocessSourceMapsArg(['--source-maps', '--message', 'test'])).toEqual([
'--source-maps',
'true',
'--message',
'test',
]);
});
});
describe('hidden flags', () => {
it('has --no-bytecode and --source-maps as hidden flags', () => {
expect(UpdatePublish.flags['no-bytecode'].hidden).toBe(true);
expect(UpdatePublish.flags['source-maps'].hidden).toBe(true);
});
});
+1 -1
View File
@@ -168,7 +168,7 @@ export default class UpdateEdit extends EasCommand {
return await PublishMutation.setRolloutPercentageAsync(
graphqlClient,
u.updateId,
rolloutPercentageToSet
rolloutPercentageToSet!
);
})
);
+1 -45
View File
@@ -68,28 +68,6 @@ import formatFields from '../../utils/formatFields';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { maybeWarnAboutEasOutagesAsync } from '../../utils/statuspageService';
/**
* Preprocess argv to handle --source-maps with optional value.
* If --source-maps is followed by another flag (starts with -) or end of args,
* insert 'true' as the default value.
*/
export function preprocessSourceMapsArg(argv: string[]): string[] {
const result: string[] = [];
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
result.push(arg);
if (arg === '--source-maps') {
const nextArg = argv[i + 1];
// If no next arg or next arg is a flag, insert 'true' as the value
if (nextArg === undefined || nextArg.startsWith('-')) {
result.push('true');
}
}
}
return result;
}
type RawUpdateFlags = {
auto: boolean;
branch?: string;
@@ -99,8 +77,6 @@ type RawUpdateFlags = {
'input-dir': string;
'skip-bundler': boolean;
'clear-cache': boolean;
'no-bytecode': boolean;
'source-maps'?: string;
'private-key-path'?: string;
'emit-metadata': boolean;
'rollout-percentage'?: number;
@@ -118,8 +94,6 @@ type UpdateFlags = {
inputDir: string;
skipBundler: boolean;
clearCache: boolean;
noBytecode: boolean;
sourceMaps?: string;
privateKeyPath?: string;
emitMetadata: boolean;
rolloutPercentage?: number;
@@ -158,16 +132,6 @@ export default class UpdatePublish extends EasCommand {
description: `Clear the bundler cache before publishing`,
default: false,
}),
'no-bytecode': Flags.boolean({
description: `Skip generating Hermes bytecode (output plain JavaScript instead)`,
default: false,
hidden: true,
}),
'source-maps': Flags.string({
description: `Emit source maps. Options: true (default), inline, false`,
default: 'true',
hidden: true,
}),
'emit-metadata': Flags.boolean({
description: `Emit "eas-update-metadata.json" in the bundle folder with detailed information about the generated updates`,
default: false,
@@ -205,9 +169,7 @@ export default class UpdatePublish extends EasCommand {
};
async runAsync(): Promise<void> {
// Preprocess argv to handle --source-maps with optional value
const preprocessedArgv = preprocessSourceMapsArg(this.argv);
const { flags: rawFlags } = await this.parse(UpdatePublish, preprocessedArgv);
const { flags: rawFlags } = await this.parse(UpdatePublish);
const paginatedQueryOptions = getPaginatedQueryOptions(rawFlags);
const {
auto: autoFlag,
@@ -217,8 +179,6 @@ export default class UpdatePublish extends EasCommand {
inputDir,
skipBundler,
clearCache,
noBytecode,
sourceMaps,
privateKeyPath,
json: jsonFlag,
nonInteractive,
@@ -304,8 +264,6 @@ export default class UpdatePublish extends EasCommand {
exp,
platformFlag: requestedPlatform,
clearCache,
noBytecode,
sourceMaps,
extraEnv: maybeServerEnv,
});
bundleSpinner.succeed('Exported bundle(s)');
@@ -797,8 +755,6 @@ export default class UpdatePublish extends EasCommand {
inputDir: flags['input-dir'],
skipBundler,
clearCache: flags['clear-cache'] ? true : !!flags['environment'],
noBytecode: flags['no-bytecode'] ?? false,
sourceMaps: flags['source-maps'],
platform: flags.platform,
privateKeyPath: flags['private-key-path'],
rolloutPercentage: flags['rollout-percentage'],
+1 -36
View File
@@ -8,8 +8,6 @@ import {
getLimitFlagWithCustomValues,
getPaginatedQueryOptions,
} from '../../commandUtils/pagination';
import { AppPlatform } from '../../graphql/generated';
import { RequestedPlatform } from '../../platform';
import {
listAndRenderUpdateGroupsOnAppAsync,
listAndRenderUpdateGroupsOnBranchAsync,
@@ -29,14 +27,6 @@ export default class UpdateList extends EasCommand {
exclusive: ['branch'],
default: false,
}),
platform: Flags.enum<RequestedPlatform>({
options: Object.values(RequestedPlatform),
char: 'p',
description: 'Filter updates by platform',
}),
'runtime-version': Flags.string({
description: 'Filter updates by runtime version',
}),
...EasPaginatedQueryFlags,
limit: getLimitFlagWithCustomValues({ defaultTo: 25, limit: 50 }),
...EasNonInteractiveAndJsonFlags,
@@ -49,13 +39,7 @@ export default class UpdateList extends EasCommand {
async runAsync(): Promise<void> {
const { flags } = await this.parse(UpdateList);
const {
branch: branchFlag,
all,
json: jsonFlag,
'non-interactive': nonInteractive,
platform: requestedPlatform,
} = flags;
const { branch: branchFlag, all, json: jsonFlag, 'non-interactive': nonInteractive } = flags;
const {
projectId,
loggedIn: { graphqlClient },
@@ -68,16 +52,9 @@ export default class UpdateList extends EasCommand {
enableJsonOutput();
}
// Build filter object
const filter = {
platform: toAppPlatform(requestedPlatform),
runtimeVersions: flags['runtime-version'] ? [flags['runtime-version']] : undefined,
};
if (all) {
await listAndRenderUpdateGroupsOnAppAsync(graphqlClient, {
projectId,
filter,
paginatedQueryOptions,
});
} else {
@@ -85,7 +62,6 @@ export default class UpdateList extends EasCommand {
await listAndRenderUpdateGroupsOnBranchAsync(graphqlClient, {
projectId,
branchName: branchFlag,
filter,
paginatedQueryOptions,
});
} else {
@@ -111,20 +87,9 @@ export default class UpdateList extends EasCommand {
await listAndRenderUpdateGroupsOnBranchAsync(graphqlClient, {
projectId,
branchName: selectedBranch.name,
filter,
paginatedQueryOptions,
});
}
}
}
}
const toAppPlatform = (requestedPlatform?: RequestedPlatform): AppPlatform | undefined => {
if (!requestedPlatform || requestedPlatform === RequestedPlatform.All) {
return undefined;
} else if (requestedPlatform === RequestedPlatform.Android) {
return AppPlatform.Android;
} else {
return AppPlatform.Ios;
}
};
@@ -24,7 +24,7 @@ export class SetUpTargetBuildCredentials {
constructor(private readonly options: Options) {}
async runAsync(ctx: CredentialsContext): Promise<IosAppBuildCredentialsFragment> {
const { app, entitlements, target } = this.options;
const { app, entitlements } = this.options;
await ctx.bestEffortAppStoreAuthenticateAsync();
@@ -35,11 +35,7 @@ export class SetUpTargetBuildCredentials {
bundleIdentifier: app.bundleIdentifier,
projectName: app.projectName,
},
{
entitlements,
usesBroadcastPushNotifications: ctx.usesBroadcastPushNotifications,
parentBundleIdentifier: target.parentBundleIdentifier,
}
{ entitlements, usesBroadcastPushNotifications: ctx.usesBroadcastPushNotifications }
);
}
try {
@@ -11,22 +11,9 @@ import { assertContractMessagesAsync } from './contractMessages';
import Log from '../../../log';
import { ora } from '../../../ora';
/**
* The entitlement key that indicates a target is an App Clip.
* When present, the target requires special bundle ID registration with a parent app relationship.
*/
const APP_CLIP_PARENT_APPLICATION_IDENTIFIERS_ENTITLEMENT =
'com.apple.developer.parent-application-identifiers';
export interface IosCapabilitiesOptions {
entitlements: JSONObject;
usesBroadcastPushNotifications: boolean;
/**
* The bundle identifier of the parent app. Required for App Clips.
* App Clips are detected by the presence of the
* `com.apple.developer.parent-application-identifiers` entitlement.
*/
parentBundleIdentifier?: string;
}
export interface AppLookupParams {
@@ -56,13 +43,7 @@ export async function ensureBundleIdExistsWithNameAsync(
options?: IosCapabilitiesOptions
): Promise<void> {
const context = getRequestContext(authCtx);
// Detect App Clips by the presence of the parent-application-identifiers entitlement
const isAppClip = Boolean(
options?.entitlements?.[APP_CLIP_PARENT_APPLICATION_IDENTIFIERS_ENTITLEMENT]
);
const typeLabel = isAppClip ? 'App Clip bundle identifier' : 'bundle identifier';
const spinner = ora(`Linking ${typeLabel} ${chalk.dim(bundleIdentifier)}`).start();
const spinner = ora(`Linking bundle identifier ${chalk.dim(bundleIdentifier)}`).start();
let bundleId: BundleId | null;
try {
@@ -70,37 +51,23 @@ export async function ensureBundleIdExistsWithNameAsync(
bundleId = await BundleId.findAsync(context, { identifier: bundleIdentifier });
if (!bundleId) {
spinner.text = `Registering ${typeLabel} ${chalk.dim(bundleIdentifier)}`;
if (isAppClip && options?.parentBundleIdentifier) {
// App Clips require the parent bundle ID's opaque ID
bundleId = await createAppClipBundleIdAsync(context, {
name,
bundleIdentifier,
parentBundleIdentifier: options.parentBundleIdentifier,
});
} else {
// If it doesn't exist, create it
bundleId = await BundleId.createAsync(context, {
name,
identifier: bundleIdentifier,
});
}
spinner.text = `Registering bundle identifier ${chalk.dim(bundleIdentifier)}`;
// If it doesn't exist, create it
bundleId = await BundleId.createAsync(context, {
name,
identifier: bundleIdentifier,
});
}
spinner.succeed(
`${isAppClip ? 'App Clip bundle' : 'Bundle'} identifier registered ${chalk.dim(
bundleIdentifier
)}`
);
spinner.succeed(`Bundle identifier registered ${chalk.dim(bundleIdentifier)}`);
} catch (err: any) {
if (err.message.match(/An App ID with Identifier '(.*)' is not available/)) {
spinner.fail(
`The ${typeLabel} ${chalk.bold(bundleIdentifier)} is not available to team "${
`The bundle identifier ${chalk.bold(bundleIdentifier)} is not available to team "${
authCtx.team.name
}" (${authCtx.team.id}), change it in your app config and try again.`
);
} else {
spinner.fail(`Failed to register ${typeLabel} ${chalk.dim(bundleIdentifier)}`);
spinner.fail(`Failed to register bundle identifier ${chalk.dim(bundleIdentifier)}`);
// Assert contract errors for easier resolution when the user has an expired developer account.
if (
@@ -126,42 +93,6 @@ export async function ensureBundleIdExistsWithNameAsync(
}
}
/**
* Creates an App Clip bundle identifier.
* App Clips require the parent bundle ID's opaque ID for registration.
*/
async function createAppClipBundleIdAsync(
context: RequestContext,
{
name,
bundleIdentifier,
parentBundleIdentifier,
}: {
name: string;
bundleIdentifier: string;
parentBundleIdentifier: string;
}
): Promise<BundleId> {
// First, find the parent bundle ID to get its opaque ID
const parentBundleId = await BundleId.findAsync(context, {
identifier: parentBundleIdentifier,
});
if (!parentBundleId) {
throw new Error(
`Cannot create App Clip bundle identifier "${bundleIdentifier}" because the parent bundle identifier "${parentBundleIdentifier}" is not registered. ` +
`Please ensure the parent app's bundle identifier is registered first.`
);
}
// Create the App Clip bundle ID with the parent relationship
return await BundleId.createAppClipAsync(context, {
name,
identifier: bundleIdentifier,
parentBundleIdId: parentBundleId.id,
});
}
export async function syncCapabilitiesAsync(
bundleId: BundleId,
{ entitlements, ...rest }: IosCapabilitiesOptions
@@ -3,7 +3,7 @@ import { ManageIos } from './ManageIos';
import { Analytics } from '../../analytics/AnalyticsManager';
import { DynamicConfigContextFn } from '../../commandUtils/context/DynamicProjectConfigContextField';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { selectPlatformWithExitOptionAsync } from '../../platform';
import { selectPlatformAsync } from '../../platform';
import { Actor } from '../../user/User';
import { Client } from '../../vcs/vcs';
import { CredentialsContextProjectInfo } from '../context';
@@ -20,7 +20,7 @@ export class SelectPlatform {
) {}
async runAsync(): Promise<void> {
const platform = await selectPlatformWithExitOptionAsync(this.flagPlatform);
const platform = await selectPlatformAsync(this.flagPlatform);
if (platform === 'ios') {
await new ManageIos(this, process.cwd()).runAsync();
+108 -189
View File
@@ -691,6 +691,13 @@ export type AccountNotificationPreferenceInput = {
type: NotificationType;
};
export type AccountNotificationSubscriptionInput = {
accountId: Scalars['ID']['input'];
event: NotificationEvent;
type: NotificationType;
userId: Scalars['ID']['input'];
};
export type AccountQuery = {
__typename?: 'AccountQuery';
/** Query an Account by ID */
@@ -1381,7 +1388,6 @@ export type App = Project & {
likedBy: Array<Maybe<User>>;
logRocketProject?: Maybe<LogRocketProject>;
name: Scalars['String']['output'];
observe: AppObserve;
ownerAccount: Account;
/** @deprecated No longer supported */
packageName: Scalars['String']['output'];
@@ -1606,12 +1612,6 @@ export type AppLikedByArgs = {
};
/** Represents an Exponent App (or Experience in legacy terms) */
export type AppProfileImageUrlArgs = {
width?: InputMaybe<AppProfileImageWidth>;
};
/** Represents an Exponent App (or Experience in legacy terms) */
export type AppRuntimesArgs = {
after?: InputMaybe<Scalars['String']['input']>;
@@ -1983,102 +1983,13 @@ export type AppNotificationPreferenceInput = {
type: NotificationType;
};
export type AppObserve = {
__typename?: 'AppObserve';
byAppVersion: Array<AppVersionMetrics>;
events: AppObserveEventsConnection;
export type AppNotificationSubscriptionInput = {
appId: Scalars['ID']['input'];
event: NotificationEvent;
type: NotificationType;
userId: Scalars['ID']['input'];
};
export type AppObserveByAppVersionArgs = {
eventNames?: InputMaybe<Array<AppObserveEventName>>;
};
export type AppObserveEventsArgs = {
after?: InputMaybe<Scalars['String']['input']>;
before?: InputMaybe<Scalars['String']['input']>;
filter?: InputMaybe<AppObserveEventsFilter>;
first?: InputMaybe<Scalars['Int']['input']>;
last?: InputMaybe<Scalars['Int']['input']>;
orderBy?: InputMaybe<AppObserveEventsOrderBy>;
};
export type AppObserveEvent = {
__typename?: 'AppObserveEvent';
appBuildNumber: Scalars['String']['output'];
appIdentifier: Scalars['String']['output'];
appName: Scalars['String']['output'];
appVersion: Scalars['String']['output'];
category: Scalars['String']['output'];
clientVersion?: Maybe<Scalars['String']['output']>;
countryCode?: Maybe<Scalars['String']['output']>;
deviceLanguageTag?: Maybe<Scalars['String']['output']>;
deviceModel: Scalars['String']['output'];
deviceName?: Maybe<Scalars['String']['output']>;
deviceOs: Scalars['String']['output'];
deviceOsVersion: Scalars['String']['output'];
easClientId: Scalars['String']['output'];
eventBatchId: Scalars['ID']['output'];
expoSdkVersion?: Maybe<Scalars['String']['output']>;
id: Scalars['ID']['output'];
ingestedAt?: Maybe<Scalars['DateTime']['output']>;
metricName: Scalars['String']['output'];
metricValue: Scalars['Float']['output'];
name: Scalars['String']['output'];
parentSessionId?: Maybe<Scalars['String']['output']>;
reactNativeVersion?: Maybe<Scalars['String']['output']>;
sessionId?: Maybe<Scalars['String']['output']>;
tags: Scalars['JSON']['output'];
timestamp: Scalars['DateTime']['output'];
};
export type AppObserveEventEdge = {
__typename?: 'AppObserveEventEdge';
cursor: Scalars['String']['output'];
node: AppObserveEvent;
};
export enum AppObserveEventName {
BundleLoadTime = 'BUNDLE_LOAD_TIME',
LaunchTime = 'LAUNCH_TIME',
LoadTime = 'LOAD_TIME',
TimeToFirstRender = 'TIME_TO_FIRST_RENDER',
TimeToInteractive = 'TIME_TO_INTERACTIVE'
}
export type AppObserveEventsConnection = {
__typename?: 'AppObserveEventsConnection';
edges: Array<AppObserveEventEdge>;
pageInfo: PageInfo;
};
export type AppObserveEventsFilter = {
appVersion?: InputMaybe<Scalars['String']['input']>;
eventName?: InputMaybe<AppObserveEventName>;
platform?: InputMaybe<AppObservePlatform>;
};
export type AppObserveEventsOrderBy = {
direction: AppObserveEventsOrderByDirection;
field: AppObserveEventsOrderByField;
};
export enum AppObserveEventsOrderByDirection {
Asc = 'ASC',
Desc = 'DESC'
}
export enum AppObserveEventsOrderByField {
MetricValue = 'METRIC_VALUE',
Timestamp = 'TIMESTAMP'
}
export enum AppObservePlatform {
Android = 'ANDROID',
Ios = 'IOS'
}
export enum AppPlatform {
Android = 'ANDROID',
Ios = 'IOS'
@@ -2090,13 +2001,6 @@ export enum AppPrivacy {
Unlisted = 'UNLISTED'
}
export enum AppProfileImageWidth {
ExtraLarge = 'EXTRA_LARGE',
Large = 'LARGE',
Medium = 'MEDIUM',
Small = 'SMALL'
}
export type AppPushNotifications = {
__typename?: 'AppPushNotifications';
id: Scalars['ID']['output'];
@@ -2369,13 +2273,6 @@ export type AppVersionInput = {
storeVersion: Scalars['String']['input'];
};
export type AppVersionMetrics = {
__typename?: 'AppVersionMetrics';
android: Array<PlatformMetrics>;
appVersion: Scalars['String']['output'];
ios: Array<PlatformMetrics>;
};
export type AppVersionMutation = {
__typename?: 'AppVersionMutation';
/** Create an app version */
@@ -2756,7 +2653,6 @@ export type AppleTeam = {
appleTeamName?: Maybe<Scalars['String']['output']>;
appleTeamType?: Maybe<AppleTeamType>;
id: Scalars['ID']['output'];
lastDeletionAttemptTime?: Maybe<Scalars['DateTime']['output']>;
};
@@ -2789,8 +2685,6 @@ export type AppleTeamMutation = {
__typename?: 'AppleTeamMutation';
/** Create an Apple Team */
createAppleTeam: AppleTeam;
/** Delete an Apple Team in the background */
scheduleAppleTeamDeletion: BackgroundJobReceipt;
/** Update an Apple Team */
updateAppleTeam: AppleTeam;
};
@@ -2802,11 +2696,6 @@ export type AppleTeamMutationCreateAppleTeamArgs = {
};
export type AppleTeamMutationScheduleAppleTeamDeletionArgs = {
id: Scalars['ID']['input'];
};
export type AppleTeamMutationUpdateAppleTeamArgs = {
appleTeamUpdateInput: AppleTeamUpdateInput;
id: Scalars['ID']['input'];
@@ -3052,8 +2941,7 @@ export enum BackgroundJobResultType {
AuditLogsExport = 'AUDIT_LOGS_EXPORT',
GithubBuild = 'GITHUB_BUILD',
UserAuditLogsExport = 'USER_AUDIT_LOGS_EXPORT',
Void = 'VOID',
WorkflowsInsightsExport = 'WORKFLOWS_INSIGHTS_EXPORT'
Void = 'VOID'
}
export enum BackgroundJobState {
@@ -5963,18 +5851,6 @@ export type MeteredBillingStatus = {
EAS_UPDATE: Scalars['Boolean']['output'];
};
export type MetricStatistics = {
__typename?: 'MetricStatistics';
average?: Maybe<Scalars['Float']['output']>;
count?: Maybe<Scalars['Int']['output']>;
max?: Maybe<Scalars['Float']['output']>;
median?: Maybe<Scalars['Float']['output']>;
min?: Maybe<Scalars['Float']['output']>;
p80?: Maybe<Scalars['Float']['output']>;
p95?: Maybe<Scalars['Float']['output']>;
p99?: Maybe<Scalars['Float']['output']>;
};
export type Notification = {
__typename?: 'Notification';
accountName: Scalars['String']['output'];
@@ -6026,6 +5902,46 @@ export type NotificationPreferenceMutationSetAppNotificationPreferenceArgs = {
input: AppNotificationPreferenceInput;
};
export type NotificationSubscription = {
__typename?: 'NotificationSubscription';
account?: Maybe<Account>;
actor?: Maybe<Actor>;
app?: Maybe<App>;
createdAt: Scalars['DateTime']['output'];
event: NotificationEvent;
id: Scalars['ID']['output'];
type: NotificationType;
};
export type NotificationSubscriptionFilter = {
accountId?: InputMaybe<Scalars['ID']['input']>;
appId?: InputMaybe<Scalars['ID']['input']>;
event?: InputMaybe<NotificationEvent>;
type?: InputMaybe<NotificationType>;
};
export type NotificationSubscriptionMutation = {
__typename?: 'NotificationSubscriptionMutation';
subscribeToEventForAccount: SubscribeToNotificationResult;
subscribeToEventForApp: SubscribeToNotificationResult;
unsubscribe: UnsubscribeFromNotificationResult;
};
export type NotificationSubscriptionMutationSubscribeToEventForAccountArgs = {
input: AccountNotificationSubscriptionInput;
};
export type NotificationSubscriptionMutationSubscribeToEventForAppArgs = {
input: AppNotificationSubscriptionInput;
};
export type NotificationSubscriptionMutationUnsubscribeArgs = {
id: Scalars['ID']['input'];
};
export type NotificationThresholdExceeded = {
__typename?: 'NotificationThresholdExceeded';
count: Scalars['Int']['output'];
@@ -6139,13 +6055,6 @@ export type PinnedDashboardView = {
export type PlanEnablement = Concurrencies | EasTotalPlanEnablement;
export type PlatformMetrics = {
__typename?: 'PlatformMetrics';
category: Scalars['String']['output'];
name: Scalars['String']['output'];
statistics?: Maybe<MetricStatistics>;
};
export type Project = {
description: Scalars['String']['output'];
fullName: Scalars['String']['output'];
@@ -6470,6 +6379,11 @@ export type RootMutation = {
me: MeMutation;
/** Notification preference management */
notificationPreference: NotificationPreferenceMutation;
/**
* Mutations that modify a NotificationSubscription
* @deprecated Use notificationPreference mutation instead
*/
notificationSubscription: NotificationSubscriptionMutation;
/** Mutations that create, update, and delete Robots */
robot: RobotMutation;
/** Mutations for Sentry installations */
@@ -6502,7 +6416,6 @@ export type RootMutation = {
workflowJobApproval: WorkflowJobApprovalMutation;
workflowRevision: WorkflowRevisionMutation;
workflowRun: WorkflowRunMutation;
workflowsInsights: WorkflowsInsightsMutation;
};
@@ -6820,6 +6733,8 @@ export type SsoUser = Actor & UserActor & {
lastName?: Maybe<Scalars['String']['output']>;
/** @deprecated No longer supported */
location?: Maybe<Scalars['String']['output']>;
/** @deprecated Use Account/App.viewerNotificationPreferences instead */
notificationSubscriptions: Array<NotificationSubscription>;
pinnedApps: Array<App>;
pinnedDashboardViews: Array<PinnedDashboardView>;
preferences: UserPreferences;
@@ -6858,6 +6773,12 @@ export type SsoUserFeatureGatesArgs = {
};
/** Represents a human SSO (not robot) actor. */
export type SsoUserNotificationSubscriptionsArgs = {
filter?: InputMaybe<NotificationSubscriptionFilter>;
};
/** Represents a human SSO (not robot) actor. */
export type SsoUserSnacksArgs = {
limit: Scalars['Int']['input'];
@@ -7278,6 +7199,11 @@ export enum SubmissionStatus {
InQueue = 'IN_QUEUE'
}
export type SubscribeToNotificationResult = {
__typename?: 'SubscribeToNotificationResult';
notificationSubscription: NotificationSubscription;
};
export type SubscriptionDetails = {
__typename?: 'SubscriptionDetails';
addons: Array<AddonDetails>;
@@ -7342,6 +7268,11 @@ export type UniqueUsersOverTimeData = {
data: LineChartData;
};
export type UnsubscribeFromNotificationResult = {
__typename?: 'UnsubscribeFromNotificationResult';
notificationSubscription: NotificationSubscription;
};
export type Update = ActivityTimelineProjectActivity & {
__typename?: 'Update';
activityTimestamp: Scalars['DateTime']['output'];
@@ -7394,7 +7325,6 @@ export type UpdateBranch = {
appId: Scalars['ID']['output'];
createdAt: Scalars['DateTime']['output'];
id: Scalars['ID']['output'];
lastDeletionAttemptTime?: Maybe<Scalars['DateTime']['output']>;
latestActivity: Scalars['DateTime']['output'];
name: Scalars['String']['output'];
runtimes: RuntimesConnection;
@@ -7490,7 +7420,6 @@ export type UpdateChannel = {
createdAt: Scalars['DateTime']['output'];
id: Scalars['ID']['output'];
isPaused: Scalars['Boolean']['output'];
lastDeletionAttemptTime?: Maybe<Scalars['DateTime']['output']>;
latestRuntimes: ChannelRuntimesConnection;
name: Scalars['String']['output'];
runtimeInsights: UpdateChannelRuntimeInsights;
@@ -7521,10 +7450,7 @@ export type UpdateChannelMutation = {
* stringified JSON supplied to the mutation as a variable.
*/
createUpdateChannelForApp: UpdateChannel;
/**
* delete an EAS channel that doesn't point to any branches
* @deprecated Use scheduleUpdateChannelDeletion instead
*/
/** delete an EAS channel that doesn't point to any branches */
deleteUpdateChannel: DeleteUpdateChannelResult;
/**
* Edit an EAS channel.
@@ -7537,8 +7463,6 @@ export type UpdateChannelMutation = {
pauseUpdateChannel: UpdateChannel;
/** Resume updates for an EAS channel. */
resumeUpdateChannel: UpdateChannel;
/** Delete an EAS channel and all of its builds in the background */
scheduleUpdateChannelDeletion: BackgroundJobReceipt;
};
@@ -7569,11 +7493,6 @@ export type UpdateChannelMutationResumeUpdateChannelArgs = {
channelId: Scalars['ID']['input'];
};
export type UpdateChannelMutationScheduleUpdateChannelDeletionArgs = {
channelId: Scalars['ID']['input'];
};
export type UpdateChannelRuntimeInsights = {
__typename?: 'UpdateChannelRuntimeInsights';
cumulativeMetricsOverTime: ChannelRuntimeCumulativeMetricsOverTimeData;
@@ -7906,7 +7825,8 @@ export type User = Actor & UserActor & {
lastName?: Maybe<Scalars['String']['output']>;
/** @deprecated No longer supported */
location?: Maybe<Scalars['String']['output']>;
newEmailPendingVerification?: Maybe<Scalars['String']['output']>;
/** @deprecated Use Account/App.viewerNotificationPreferences instead */
notificationSubscriptions: Array<NotificationSubscription>;
/** Pending UserInvitations for this user. Only resolves for the viewer. */
pendingUserInvitations: Array<UserInvitation>;
pinnedApps: Array<App>;
@@ -7949,6 +7869,12 @@ export type UserFeatureGatesArgs = {
};
/** Represents a human (not robot) actor. */
export type UserNotificationSubscriptionsArgs = {
filter?: InputMaybe<NotificationSubscriptionFilter>;
};
/** Represents a human (not robot) actor. */
export type UserSnacksArgs = {
limit: Scalars['Int']['input'];
@@ -8012,6 +7938,8 @@ export type UserActor = {
lastName?: Maybe<Scalars['String']['output']>;
/** @deprecated No longer supported */
location?: Maybe<Scalars['String']['output']>;
/** @deprecated Use Account/App.viewerNotificationPreferences instead */
notificationSubscriptions: Array<NotificationSubscription>;
pinnedApps: Array<App>;
preferences: UserPreferences;
/** Associated accounts */
@@ -8049,6 +7977,12 @@ export type UserActorFeatureGatesArgs = {
};
/** A human user (type User or SSOUser) that can login to the Expo website, use Expo services, and be a member of accounts. */
export type UserActorNotificationSubscriptionsArgs = {
filter?: InputMaybe<NotificationSubscriptionFilter>;
};
/** A human user (type User or SSOUser) that can login to the Expo website, use Expo services, and be a member of accounts. */
export type UserActorSnacksArgs = {
limit: Scalars['Int']['input'];
@@ -9411,7 +9345,6 @@ export type WorkflowRun = ActivityTimelineProjectActivity & {
gitCommitMessage?: Maybe<Scalars['String']['output']>;
githubRepository?: Maybe<GitHubRepository>;
id: Scalars['ID']['output'];
inputs?: Maybe<Scalars['JSONObject']['output']>;
jobs: Array<WorkflowJob>;
name: Scalars['String']['output'];
pullRequestNumber?: Maybe<Scalars['Int']['output']>;
@@ -9528,18 +9461,6 @@ export type WorkflowRunsConnection = {
pageInfo: PageInfo;
};
export enum WorkflowsInsightsExportFormat {
Csv = 'CSV',
Ndjson = 'NDJSON'
}
export type WorkflowsInsightsExportInput = {
appId: Scalars['ID']['input'];
filters?: InputMaybe<WorkflowsInsightsFiltersInput>;
format: WorkflowsInsightsExportFormat;
timespan: WorkflowsInsightsTimespanInput;
};
export type WorkflowsInsightsFiltersInput = {
gitRefRequested?: InputMaybe<Array<Scalars['String']['input']>>;
statuses?: InputMaybe<Array<WorkflowRunStatus>>;
@@ -9553,16 +9474,6 @@ export type WorkflowsInsightsMetric = {
previousValue: Scalars['Float']['output'];
};
export type WorkflowsInsightsMutation = {
__typename?: 'WorkflowsInsightsMutation';
exportWorkflowRuns: BackgroundJobReceipt;
};
export type WorkflowsInsightsMutationExportWorkflowRunsArgs = {
input: WorkflowsInsightsExportInput;
};
export type WorkflowsInsightsOverviewMetrics = {
__typename?: 'WorkflowsInsightsOverviewMetrics';
activeWorkflows: WorkflowsInsightsMetric;
@@ -9652,13 +9563,6 @@ export type CreateUpdateBranchForAppMutationVariables = Exact<{
export type CreateUpdateBranchForAppMutation = { __typename?: 'RootMutation', updateBranch: { __typename?: 'UpdateBranchMutation', createUpdateBranchForApp: { __typename?: 'UpdateBranch', id: string, name: string } } };
export type ScheduleChannelDeletionMutationVariables = Exact<{
channelId: Scalars['ID']['input'];
}>;
export type ScheduleChannelDeletionMutation = { __typename?: 'RootMutation', updateChannel: { __typename?: 'UpdateChannelMutation', scheduleUpdateChannelDeletion: { __typename?: 'BackgroundJobReceipt', id: string, state: BackgroundJobState, tries: number, willRetry: boolean, resultId?: string | null, resultType: BackgroundJobResultType, resultData?: any | null, errorCode?: string | null, errorMessage?: string | null, createdAt: any, updatedAt: any } } };
export type CreateUpdateChannelOnAppMutationVariables = Exact<{
appId: Scalars['ID']['input'];
name: Scalars['String']['input'];
@@ -9697,6 +9601,13 @@ export type DeleteBuildMutationVariables = Exact<{
export type DeleteBuildMutation = { __typename?: 'RootMutation', build: { __typename?: 'BuildMutation', deleteBuild: { __typename?: 'Build', id: string } } };
export type DeleteUpdateChannelMutationVariables = Exact<{
channelId: Scalars['ID']['input'];
}>;
export type DeleteUpdateChannelMutation = { __typename?: 'RootMutation', updateChannel: { __typename?: 'UpdateChannelMutation', deleteUpdateChannel: { __typename?: 'DeleteUpdateChannelResult', id: string } } };
export type UpdateChannelBranchMappingMutationVariables = Exact<{
channelId: Scalars['ID']['input'];
branchMapping: Scalars['String']['input'];
@@ -10375,6 +10286,14 @@ export type DeleteWebhookMutationVariables = Exact<{
export type DeleteWebhookMutation = { __typename?: 'RootMutation', webhook: { __typename?: 'WebhookMutation', deleteWebhook: { __typename?: 'DeleteWebhookResult', id: string } } };
export type GetWorkflowRevisionsFromGitRefMutationVariables = Exact<{
appId: Scalars['ID']['input'];
gitRef: Scalars['String']['input'];
}>;
export type GetWorkflowRevisionsFromGitRefMutation = { __typename?: 'RootMutation', workflowRevision: { __typename?: 'WorkflowRevisionMutation', getWorkflowRevisionsFromGitRef: Array<{ __typename?: 'WorkflowRevision', id: string, yamlConfig: string, blobSha: string, commitSha?: string | null, createdAt: any, workflow: { __typename?: 'Workflow', id: string } }> } };
export type GetOrCreateWorkflowRevisionFromGitRefMutationVariables = Exact<{
appId: Scalars['ID']['input'];
fileName: Scalars['String']['input'];
@@ -6,6 +6,8 @@ import { withErrorHandlingAsync } from '../client';
import {
GetOrCreateWorkflowRevisionFromGitRefMutation,
GetOrCreateWorkflowRevisionFromGitRefMutationVariables,
GetWorkflowRevisionsFromGitRefMutation,
GetWorkflowRevisionsFromGitRefMutationVariables,
ValidateWorkflowYamlConfigMutation,
ValidateWorkflowYamlConfigMutationVariables,
WorkflowRevision,
@@ -19,7 +21,53 @@ export namespace WorkflowRevisionMutation {
fieldErrors: z.record(z.string(), z.array(z.string())),
}),
});
export async function getWorkflowRevisionsFromGitRefAsync(
graphqlClient: ExpoGraphqlClient,
{
appId,
gitRef,
}: {
appId: string;
gitRef: string;
}
): Promise<WorkflowRevision[] | undefined> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<
GetWorkflowRevisionsFromGitRefMutation,
GetWorkflowRevisionsFromGitRefMutationVariables
>(
gql`
mutation GetWorkflowRevisionsFromGitRef($appId: ID!, $gitRef: String!) {
workflowRevision {
getWorkflowRevisionsFromGitRef(appId: $appId, gitRef: $gitRef) {
id
yamlConfig
blobSha
commitSha
createdAt
workflow {
id
app {
id
name
slug
}
fileName
}
}
}
}
`,
{
appId,
gitRef,
}
)
.toPromise()
);
return (data.workflowRevision?.getWorkflowRevisionsFromGitRef as WorkflowRevision[]) ?? [];
}
export async function getOrCreateWorkflowRevisionFromGitRefAsync(
graphqlClient: ExpoGraphqlClient,
{
@@ -12,6 +12,12 @@ jest.mock('../../config/writer');
describe(AgeRatingTask, () => {
describe('prepareAsync', () => {
it('aborts when version is not loaded', async () => {
const promise = new AgeRatingTask().prepareAsync({ context: {} as any });
await expect(promise).rejects.toThrow('not prepared');
});
it('loads age rating from app version', async () => {
const scope = nock('https://api.appstoreconnect.apple.com')
.get(`/v1/${AppStoreVersion.type}/stub-id/ageRatingDeclaration`)
@@ -35,16 +35,14 @@ describe(AppInfoTask, () => {
});
describe('downloadAsync', () => {
it('skips when app info is not loaded', async () => {
it('aborts when app info is not loaded', async () => {
const writer = jest.mocked(new AppleConfigWriter());
await new AppInfoTask().downloadAsync({
const promise = new AppInfoTask().downloadAsync({
config: writer,
context: { info: undefined, infoLocales: [] } as any,
});
// Should not call any setter methods when info is not available
expect(writer.setCategories).not.toBeCalled();
await expect(promise).rejects.toThrow('info not initialized');
});
it('sets categories when info is loaded', async () => {
@@ -11,6 +11,12 @@ jest.mock('../../config/writer');
describe(AppReviewDetailTask, () => {
describe('prepareAsync', () => {
it('aborts when version is not loaded', async () => {
const promise = new AppReviewDetailTask().prepareAsync({ context: {} as any });
await expect(promise).rejects.toThrow('version not init');
});
it('loads review details from app version', async () => {
const scope = nock('https://api.appstoreconnect.apple.com')
.get(`/v1/${AppStoreVersion.type}/stub-id/appStoreReviewDetail`)
@@ -254,16 +254,13 @@ describe(AppVersionTask, () => {
});
describe('downloadAsync', () => {
it('skips when version is not loaded', async () => {
const writer = jest.mocked(new AppleConfigWriter());
await new AppVersionTask().downloadAsync({
config: writer,
it('aborts when version is not loaded', async () => {
const promise = new AppVersionTask().downloadAsync({
config: new AppleConfigWriter(),
context: { version: undefined } as any,
});
// Should not call any setter methods when version is not available
expect(writer.setVersion).not.toBeCalled();
await expect(promise).rejects.toThrow('not initialized');
});
it('sets version when loaded', async () => {
@@ -15,7 +15,8 @@ export class AgeRatingTask extends AppleTask {
public name = (): string => 'age rating declarations';
public async prepareAsync({ context }: TaskPrepareOptions): Promise<void> {
context.ageRating = (await context.version!.getAgeRatingDeclarationAsync()) ?? undefined;
assert(context.version, `App version information is not prepared, can't update age rating`);
context.ageRating = (await context.version.getAgeRatingDeclarationAsync()) ?? undefined;
}
public async downloadAsync({ config, context }: TaskDownloadOptions): Promise<void> {
@@ -18,32 +18,19 @@ export class AppInfoTask extends AppleTask {
public name = (): string => 'app information';
public async prepareAsync({ context }: TaskPrepareOptions): Promise<void> {
let info = await retryIfNullAsync(() => context.app.getEditAppInfoAsync());
// Fallback to live app info if no editable info is found (for metadata:pull)
if (!info) {
info = await context.app.getLiveAppInfoAsync();
}
if (!info) {
// No app info available - this is OK for metadata:pull
// when the app hasn't been set up yet
return;
}
const info = await retryIfNullAsync(() => context.app.getEditAppInfoAsync());
assert(info, 'Could not resolve the editable app info to update');
context.info = info;
context.infoLocales = await info.getLocalizationsAsync();
}
public async downloadAsync({ config, context }: TaskDownloadOptions): Promise<void> {
if (!context.info) {
// No app info available to download - skip silently
return;
}
assert(context.info, `App info not initialized, can't download info`);
config.setCategories(context.info.attributes);
for (const locale of context.infoLocales ?? []) {
for (const locale of context.infoLocales) {
config.setInfoLocale(locale.attributes);
}
}
@@ -16,7 +16,8 @@ export class AppReviewDetailTask extends AppleTask {
public name = (): string => 'app review detail';
public async prepareAsync({ context }: TaskPrepareOptions): Promise<void> {
context.reviewDetail = (await context.version!.getAppStoreReviewDetailAsync()) ?? undefined;
assert(context.version, `App version not initialized, can't download store review details`);
context.reviewDetail = (await context.version.getAppStoreReviewDetailAsync()) ?? undefined;
}
public async downloadAsync({ config, context }: TaskDownloadOptions): Promise<void> {
@@ -57,11 +57,7 @@ export class AppVersionTask extends AppleTask {
this.options
);
if (!version) {
// No live or editable version available - this is OK for metadata:pull
// when the app hasn't been submitted yet
return;
}
assert(version, 'Could not resolve a live or editable app version');
context.version = version;
context.versionIsFirst = versionIsFirst;
@@ -71,16 +67,13 @@ export class AppVersionTask extends AppleTask {
}
public async downloadAsync({ config, context }: TaskDownloadOptions): Promise<void> {
if (!context.version) {
// No version available to download - skip silently
return;
}
assert(context.version, `App version not initialized, can't download version`);
config.setVersion(context.version.attributes);
config.setVersionReleaseType(context.version.attributes);
config.setVersionReleasePhased(context.versionPhasedRelease?.attributes);
for (const locale of context.versionLocales ?? []) {
for (const locale of context.versionLocales) {
config.setVersionLocale(locale.attributes);
}
}
@@ -218,12 +211,6 @@ async function resolveVersionAsync(
version = await retryIfNullAsync(() => app.getEditAppStoreVersionAsync({ platform }));
}
// Fallback to live version if no editable version is found (for metadata:pull)
if (!version) {
version = await app.getLiveAppStoreVersionAsync({ platform });
versionIsLive = !!version;
}
const versions = await app.getAppStoreVersionsAsync({
query: { limit: 2, filter: { platform } },
});
+6 -28
View File
@@ -1,7 +1,6 @@
import { Platform } from '@expo/eas-build-job';
import { AppPlatform } from './graphql/generated';
import Log from './log';
import { promptAsync } from './prompts';
export const appPlatformDisplayNames: Record<AppPlatform, string> = {
@@ -48,42 +47,21 @@ export async function selectRequestedPlatformAsync(platform?: string): Promise<R
return requestedPlatform;
}
export async function selectPlatformWithExitOptionAsync(platform?: string): Promise<Platform> {
return await selectPlatformInternalAsync(platform, true);
}
export async function selectPlatformAsync(platform?: string): Promise<Platform> {
return await selectPlatformInternalAsync(platform, false);
}
async function selectPlatformInternalAsync(
platform?: string,
allowExit?: boolean
): Promise<Platform> {
if (platform && Object.values(Platform).includes(platform.toLowerCase() as Platform)) {
return platform.toLowerCase() as Platform;
}
const platformChoices: { title: string; value: Platform | 'Exit' }[] = [
{ title: 'Android', value: Platform.ANDROID },
{ title: 'iOS', value: Platform.IOS },
];
if (allowExit) {
platformChoices.push({ title: 'Exit', value: 'Exit' });
}
const result: any = await promptAsync({
const { resolvedPlatform } = await promptAsync({
type: 'select',
message: 'Select platform',
name: 'resolvedPlatform',
choices: platformChoices,
choices: [
{ title: 'Android', value: Platform.ANDROID },
{ title: 'iOS', value: Platform.IOS },
],
});
if (result.resolvedPlatform === 'Exit') {
Log.addNewLineIfNone();
Log.log('Exiting');
process.exit(0);
}
return result.resolvedPlatform;
return resolvedPlatform;
}
export function toPlatforms(requestedPlatform: RequestedPlatform): Platform[] {
@@ -9,11 +9,9 @@ import { AssetMetadataStatus } from '../../graphql/generated';
import { PublishMutation } from '../../graphql/mutations/PublishMutation';
import { PublishQuery } from '../../graphql/queries/PublishQuery';
import { RequestedPlatform } from '../../platform';
import { expoCommandAsync } from '../../utils/expoCli';
import {
MetadataJoi,
RawAsset,
buildBundlesAsync,
buildUnsortedUpdateInfoGroupAsync,
collectAssetsAsync,
convertAssetToUpdateInfoGroupFormatAsync,
@@ -32,11 +30,6 @@ import {
jest.mock('../../uploads');
jest.mock('fs');
jest.mock('../../utils/expoCli', () => ({
expoCommandAsync: jest.fn(),
shouldUseVersionedExpoCLI: jest.fn(() => true),
shouldUseVersionedExpoCLIWithExplicitPlatforms: jest.fn(() => true),
}));
const dummyFileBuffer = Buffer.from('dummy-file');
@@ -709,73 +702,3 @@ describe(uploadAssetsAsync, () => {
expect(onAssetUploadResultsChangedFn).toHaveBeenCalledTimes(3);
});
});
describe(buildBundlesAsync, () => {
const projectDir = '/test-project';
const inputDir = 'dist';
beforeEach(async () => {
jest.mocked(expoCommandAsync).mockClear();
await fs.mkdir(projectDir, { recursive: true });
await fs.writeFile(
path.join(projectDir, 'package.json'),
JSON.stringify({ name: 'test', version: '1.0.0' })
);
});
afterEach(async () => {
await fs.remove(projectDir);
});
it('passes --source-maps with value, omits when "false" or undefined', async () => {
// Passes value
await buildBundlesAsync({
projectDir,
inputDir,
exp: { sdkVersion: '50.0.0' },
platformFlag: 'all',
sourceMaps: 'inline',
});
expect(expoCommandAsync).toHaveBeenCalledWith(
projectDir,
expect.arrayContaining(['--source-maps', 'inline']),
expect.any(Object)
);
// Omits when "false"
jest.mocked(expoCommandAsync).mockClear();
await buildBundlesAsync({
projectDir,
inputDir,
exp: { sdkVersion: '50.0.0' },
platformFlag: 'all',
sourceMaps: 'false',
});
expect(jest.mocked(expoCommandAsync).mock.calls[0][1]).not.toContain('--source-maps');
});
it('passes --no-bytecode only when true', async () => {
await buildBundlesAsync({
projectDir,
inputDir,
exp: { sdkVersion: '50.0.0' },
platformFlag: 'all',
noBytecode: true,
});
expect(expoCommandAsync).toHaveBeenCalledWith(
projectDir,
expect.arrayContaining(['--no-bytecode']),
expect.any(Object)
);
jest.mocked(expoCommandAsync).mockClear();
await buildBundlesAsync({
projectDir,
inputDir,
exp: { sdkVersion: '50.0.0' },
platformFlag: 'all',
noBytecode: false,
});
expect(jest.mocked(expoCommandAsync).mock.calls[0][1]).not.toContain('--no-bytecode');
});
});
-8
View File
@@ -208,8 +208,6 @@ export async function buildBundlesAsync({
exp,
platformFlag,
clearCache,
noBytecode,
sourceMaps,
extraEnv,
}: {
projectDir: string;
@@ -217,8 +215,6 @@ export async function buildBundlesAsync({
exp: Pick<ExpoConfig, 'sdkVersion' | 'web'>;
platformFlag: ExpoCLIExportPlatformFlag;
clearCache?: boolean;
noBytecode?: boolean;
sourceMaps?: string;
extraEnv?: Record<string, string | undefined> | undefined;
}): Promise<void> {
const packageJSON = JsonFile.read(path.resolve(projectDir, 'package.json'));
@@ -266,8 +262,6 @@ export async function buildBundlesAsync({
'--dump-assetmap',
...platformArgs,
...(clearCache ? ['--clear'] : []),
...(noBytecode ? ['--no-bytecode'] : []),
...(sourceMaps && sourceMaps !== 'false' ? ['--source-maps', sourceMaps] : []),
],
{
extraEnv,
@@ -296,8 +290,6 @@ export async function buildBundlesAsync({
'--dump-assetmap',
`--platform=${platformFlag}`,
...(clearCache ? ['--clear'] : []),
...(noBytecode ? ['--no-bytecode'] : []),
...(sourceMaps && sourceMaps !== 'false' ? ['--source-maps', sourceMaps] : []),
],
{
extraEnv,
+1 -8
View File
@@ -36,11 +36,9 @@ export async function listAndRenderUpdateGroupsOnAppAsync(
graphqlClient: ExpoGraphqlClient,
{
projectId,
filter,
paginatedQueryOptions,
}: {
projectId: string;
filter?: ViewUpdateGroupsOnAppQueryVariables['filter'];
paginatedQueryOptions: PaginatedQueryOptions;
}
): Promise<void> {
@@ -49,7 +47,6 @@ export async function listAndRenderUpdateGroupsOnAppAsync(
limit: paginatedQueryOptions.limit ?? UPDATE_GROUPS_LIMIT,
offset: paginatedQueryOptions.offset,
appId: projectId,
filter,
});
renderUpdateGroupsOnApp({ updateGroups, paginatedQueryOptions });
} else {
@@ -57,7 +54,7 @@ export async function listAndRenderUpdateGroupsOnAppAsync(
limit: paginatedQueryOptions.limit ?? UPDATE_GROUPS_LIMIT,
offset: paginatedQueryOptions.offset,
queryToPerform: (limit, offset) =>
queryUpdateGroupsOnAppAsync(graphqlClient, { limit, offset, appId: projectId, filter }),
queryUpdateGroupsOnAppAsync(graphqlClient, { limit, offset, appId: projectId }),
promptOptions: {
title: 'Load more update groups?',
renderListItems: updateGroups => {
@@ -73,12 +70,10 @@ export async function listAndRenderUpdateGroupsOnBranchAsync(
{
projectId,
branchName,
filter,
paginatedQueryOptions,
}: {
projectId: string;
branchName: string;
filter?: ViewUpdateGroupsOnBranchQueryVariables['filter'];
paginatedQueryOptions: PaginatedQueryOptions;
}
): Promise<void> {
@@ -88,7 +83,6 @@ export async function listAndRenderUpdateGroupsOnBranchAsync(
offset: paginatedQueryOptions.offset,
appId: projectId,
branchName,
filter,
});
renderUpdateGroupsOnBranch({ updateGroups, branchName, paginatedQueryOptions });
} else {
@@ -101,7 +95,6 @@ export async function listAndRenderUpdateGroupsOnBranchAsync(
offset,
appId: projectId,
branchName,
filter,
}),
promptOptions: {
title: 'Load more update groups?',
@@ -4,7 +4,7 @@ import path from 'path';
import { ImageNonPngError, ImageTransparencyError, ensurePNGIsNotTransparentAsync } from '../image';
const TEST_SERVER_PORT = 9999;
const TEST_SERVER_PORT = 2137;
const fixturesPath = path.join(__dirname, 'fixtures');
const transparentPngPath = path.join(fixturesPath, 'icon-alpha.png');
-131
View File
@@ -1,131 +0,0 @@
# CLAUDE.md - eas-json
This file provides guidance for working with the **@expo/eas-json** package.
## Overview
A library for parsing and validating `eas.json` configuration files. Published as `@expo/eas-json` on npm.
- Validates `eas.json` configuration files using **Joi schemas**
- Manages build, submit, and deploy profiles
- Supports **JSON5** via golden-fleece
- Used by eas-cli and other Expo tools
## Development Commands
```bash
yarn build # TypeScript compilation (src → build)
yarn watch # Watch mode
yarn typecheck # Type check
yarn test # Run tests
```
## Architecture
### Schema Validation
The package uses **Joi** for schema validation:
**Location**: `src/schema.ts`
The schema defines valid structure for:
- Build profiles
- Submit profiles
- Deploy profiles
- CLI version constraints
### JSON5 Support
Uses **golden-fleece** library to support JSON5 syntax in `eas.json`:
- Comments
- Trailing commas
- Unquoted keys
- Single quotes
## Common Usage Patterns
### Reading eas.json
```typescript
import { EasJsonAccessor, EasJsonUtils } from '@expo/eas-json';
// Read eas.json from project directory
const easJson = await EasJsonUtils.readAsync(projectDir);
// Access build profiles
const buildProfile = easJson.build?.[profileName];
if (!buildProfile) {
throw new Error(`Build profile "${profileName}" not found`);
}
// Access submit profiles
const submitProfile = easJson.submit?.[profileName];
// Check CLI version constraint
const cliVersion = easJson.cli?.version;
```
### Using EasJsonAccessor
```typescript
import { EasJsonAccessor } from '@expo/eas-json';
// Create accessor from project path
const accessor = EasJsonAccessor.fromProjectPath(projectDir);
// Read raw eas.json
const rawJson = await accessor.readRawAsync();
// Read and validate eas.json
const easJson = await accessor.readAsync();
// Write eas.json
await accessor.writeAsync(easJson);
```
### Validating eas.json
```typescript
import { validateEasJsonAsync } from '@expo/eas-json';
try {
await validateEasJsonAsync(projectDir);
console.log('eas.json is valid');
} catch (error) {
console.error('Validation error:', error.message);
}
```
## Schema Structure
### Build Profiles
Build profiles can contain:
- `distribution`: Distribution type (store, internal, simulator)
- `platform`: Platform-specific configuration (iOS/Android)
- `env`: Environment variables
- `node`: Node.js version
- `yarn`: Yarn version
- `npm`: npm version
- `cache`: Cache configuration
- And many more build-specific options
### Submit Profiles
Submit profiles contain app store submission configuration:
- iOS: App Store Connect credentials, ASC App ID
- Android: Google Service Account key, track
### Deploy Profiles
Deploy profiles for EAS Deploy (worker deployments):
- `region`: Deployment region
- `nodeVersion`: Node.js version
- Environment configuration
## Important Notes
- **JSON5**: Full JSON5 syntax support via golden-fleece
- **Validation**: Joi schema validation with helpful error messages
- **Types**: Comprehensive TypeScript types exported
- **CLI Version**: Supports enforcing eas-cli version via `cli.version` field
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@expo/eas-json",
"description": "A library for interacting with eas.json",
"version": "16.29.0",
"version": "16.25.0",
"author": "Expo <[email protected]>",
"bugs": "https://github.com/expo/eas-cli/issues",
"dependencies": {
@@ -23,7 +23,7 @@
"@types/fs-extra": "11.0.4",
"memfs": "3.4.13",
"rimraf": "3.0.2",
"typescript": "5.5.4"
"typescript": "5.3.3"
},
"engines": {
"node": ">=18.0.0"
-11
View File
@@ -1,11 +0,0 @@
/.bundle/
/.yardoc
/_yardoc/
/coverage/
/doc/
/pkg/
/spec/reports/
/tmp/
# rspec failure tracking
.rspec_status
-3
View File
@@ -1,3 +0,0 @@
--format documentation
--color
--require spec_helper
@@ -1,8 +0,0 @@
Metrics/BlockLength:
Exclude:
- "spec/**/*"
Lint/AmbiguousBlockAssociation:
Exclude:
- "spec/**/*"
Metrics/MethodLength:
Max: 20
-12
View File
@@ -1,12 +0,0 @@
# frozen_string_literal: true
source 'https://rubygems.org'
git_source(:github) { |repo_name| "https://github.com/#{repo_name}" }
# Specify your gem's dependencies in expo-cocoapods-proxy.gemspec
gemspec
gem 'rubocop', '~> 1.48', group: :development
gem 'cocoapods', '~> 1.12'
-142
View File
@@ -1,142 +0,0 @@
PATH
remote: .
specs:
expo-cocoapods-proxy (0.1.0)
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.6)
rexml
activesupport (7.0.8)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2)
minitest (>= 5.1)
tzinfo (~> 2.0)
addressable (2.8.1)
public_suffix (>= 2.0.2, < 6.0)
algoliasearch (1.27.5)
httpclient (~> 2.8, >= 2.8.3)
json (>= 1.5.1)
ast (2.4.2)
atomos (0.1.3)
claide (1.1.0)
cocoapods (1.12.0)
addressable (~> 2.8)
claide (>= 1.0.2, < 2.0)
cocoapods-core (= 1.12.0)
cocoapods-deintegrate (>= 1.0.3, < 2.0)
cocoapods-downloader (>= 1.6.0, < 2.0)
cocoapods-plugins (>= 1.0.0, < 2.0)
cocoapods-search (>= 1.0.0, < 2.0)
cocoapods-trunk (>= 1.6.0, < 2.0)
cocoapods-try (>= 1.1.0, < 2.0)
colored2 (~> 3.1)
escape (~> 0.0.4)
fourflusher (>= 2.3.0, < 3.0)
gh_inspector (~> 1.0)
molinillo (~> 0.8.0)
nap (~> 1.0)
ruby-macho (>= 2.3.0, < 3.0)
xcodeproj (>= 1.21.0, < 2.0)
cocoapods-core (1.12.0)
activesupport (>= 5.0, < 8)
addressable (~> 2.8)
algoliasearch (~> 1.0)
concurrent-ruby (~> 1.1)
fuzzy_match (~> 2.0.4)
nap (~> 1.0)
netrc (~> 0.11)
public_suffix (~> 4.0)
typhoeus (~> 1.0)
cocoapods-deintegrate (1.0.5)
cocoapods-downloader (1.6.3)
cocoapods-plugins (1.0.0)
nap
cocoapods-search (1.0.1)
cocoapods-trunk (1.6.0)
nap (>= 0.8, < 2.0)
netrc (~> 0.11)
cocoapods-try (1.2.0)
colored2 (3.1.2)
concurrent-ruby (1.2.2)
diff-lcs (1.5.0)
escape (0.0.4)
ethon (0.16.0)
ffi (>= 1.15.0)
ffi (1.15.5)
fourflusher (2.3.1)
fuzzy_match (2.0.4)
gh_inspector (1.1.3)
httpclient (2.8.3)
i18n (1.14.1)
concurrent-ruby (~> 1.0)
json (2.6.3)
minitest (5.20.0)
molinillo (0.8.0)
nanaimo (0.3.0)
nap (1.1.0)
netrc (0.11.0)
parallel (1.22.1)
parser (3.2.1.1)
ast (~> 2.4.1)
public_suffix (4.0.7)
rainbow (3.1.1)
rake (10.5.0)
regexp_parser (2.7.0)
rexml (3.3.6)
strscan
rspec (3.12.0)
rspec-core (~> 3.12.0)
rspec-expectations (~> 3.12.0)
rspec-mocks (~> 3.12.0)
rspec-core (3.12.1)
rspec-support (~> 3.12.0)
rspec-expectations (3.12.2)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.12.0)
rspec-mocks (3.12.4)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.12.0)
rspec-support (3.12.0)
rubocop (1.48.1)
json (~> 2.3)
parallel (~> 1.10)
parser (>= 3.2.0.0)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0)
rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.26.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 3.0)
rubocop-ast (1.28.0)
parser (>= 3.2.1.0)
ruby-macho (2.5.1)
ruby-progressbar (1.13.0)
strscan (3.1.0)
typhoeus (1.4.0)
ethon (>= 0.9.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (2.4.2)
xcodeproj (1.25.0)
CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3)
claide (>= 1.0.2, < 2.0)
colored2 (~> 3.1)
nanaimo (~> 0.3.0)
rexml (>= 3.3.2, < 4.0)
PLATFORMS
ruby
DEPENDENCIES
bundler (~> 1.17)
cocoapods (~> 1.12)
expo-cocoapods-proxy!
rake (~> 10.0)
rspec (~> 3.0)
rubocop (~> 1.48)
BUNDLED WITH
2.1.4
-8
View File
@@ -1,8 +0,0 @@
# frozen_string_literal: true
require 'bundler/gem_tasks'
require 'rspec/core/rake_task'
RSpec::Core::RakeTask.new(:spec)
task default: :spec
-15
View File
@@ -1,15 +0,0 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
require 'bundler/setup'
require 'expo/cocoapods/proxy'
# You can add fixtures and/or initialization code here to make experimenting
# with your gem easier. You can also use a different console, if you like.
# (If you use this, don't forget to add pry to your Gemfile!)
# require "pry"
# Pry.start
require 'irb'
IRB.start(__FILE__)
-8
View File
@@ -1,8 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
set -vx
bundle install
# Do any other automated setup that you need to do here
@@ -1,38 +0,0 @@
# frozen_string_literal: true
lib = File.expand_path('lib', __dir__)
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
require 'expo_cocoapods_proxy/gem_version'
Gem::Specification.new do |spec|
spec.name = 'expo-cocoapods-proxy'
spec.version = ExpoCocoaPodsProxy::VERSION
spec.authors = ['Mike Hampton']
spec.email = ['[email protected]']
spec.summary = 'A Cocoapods plugin for routing requests through a proxy cache server.'
spec.homepage = 'https://expo.dev'
spec.license = 'MIT'
# Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host'
# to allow pushing to a single host or delete this section to allow pushing to any host.
if spec.respond_to?(:metadata)
spec.metadata['allowed_push_host'] = 'TODO: Set to \'http://mygemserver.com\''
spec.metadata['homepage_uri'] = spec.homepage
spec.metadata['source_code_uri'] = 'https://github.com/expo/eas-cli'
else
raise 'RubyGems 2.0 or newer is required to protect against ' \
'public gem pushes.'
end
spec.required_ruby_version = '>= 2.7', '< 4'
spec.files = Dir['lib/**/*.rb']
spec.bindir = 'exe'
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
spec.require_paths = ['lib']
spec.add_development_dependency 'bundler', '~> 1.17'
spec.add_development_dependency 'rake', '~> 10.0'
spec.add_development_dependency 'rspec', '~> 3.0'
end
@@ -1,3 +0,0 @@
# frozen_string_literal: true
require 'expo_cocoapods_proxy'
@@ -1,4 +0,0 @@
# frozen_string_literal: true
require 'expo_cocoapods_proxy/gem_version'
require 'expo_cocoapods_proxy/proxy'
@@ -1,6 +0,0 @@
# frozen_string_literal: true
module ExpoCocoaPodsProxy
VERSION = '0.1.0'
NAME = 'expo-cocoapods-proxy'
end
@@ -1,171 +0,0 @@
# frozen_string_literal: true
require 'cocoapods'
require 'cocoapods-downloader'
require 'uri'
module Pod
module Downloader
# Concrete Downloader class that provides support for proxying.
class Http
NETRC_FILENAME = '.netrc'
NETRC_ENV_VAR = 'NETRC'
CURLRC_FILENAME = '.curlrc'
CURL_HOME_ENV_VAR = 'CURL_HOME'
XDG_CONFIG_HOME_ENV_VAR = 'XDG_CONFIG_HOME'
ALLOWLIST = [
%r{https?://(api\.)?github\.com/},
%r{https?://sourceforge\.net/},
%r{https?://repo1\.maven\.org/},
%r{https?://boostorg\.jfrog\.io/},
%r{https?://www\.sqlite\.org/},
%r{https?://download\.videolan\.org/},
%r{https?://raw\.githubusercontent\.com/}
].freeze
alias orig_download_file download_file
def download_file_via_proxy(full_filename)
orig_url = url
@url = rewrite(url)
begin
orig_download_file(full_filename)
rescue ::StandardError
@url = orig_url
orig_download_file(full_filename)
end
end
def download_file(full_filename)
return orig_download_file(full_filename) unless should_proxy?
download_file_via_proxy(full_filename)
end
private
def should_proxy?
return false unless proxy
return false if netrc_present?
return false if curlrc_present?
return false unless headers.nil?
allowed?
end
def netrc_present?
File.exist?(File.expand_path("~/#{NETRC_FILENAME}")) || ENV[NETRC_ENV_VAR]
end
def curlrc_present?
prefix = ENV[CURL_HOME_ENV_VAR] || ENV[XDG_CONFIG_HOME_ENV_VAR] || Dir.home
File.exist?(File.join(prefix, CURLRC_FILENAME))
end
def allowed?
ALLOWLIST.any? { |reg| reg.match?(url) }
end
def proxy
ENV['EAS_BUILD_COCOAPODS_CACHE_URL']
end
def parsed_url
@parsed_url ||= URI.parse(url)
end
def rewrite(url)
url.gsub("#{parsed_url.scheme}://#{parsed_url.host}", "#{proxy}/#{parsed_url.host}")
end
end
# Concrete Downloader class that provides support for fetching GitHub
# tarballs directly.
class Git
ALLOWLIST = [
%r{https://github.com/}
].freeze
alias orig_download! download!
alias orig_checkout_options checkout_options
def download_from_github!
ref = options[:commit] || options[:tag] || options[:branch]
download_url = github_url_from_ref(ref)
begin
Http.new(target_path, download_url, {}).download
rescue ::StandardError
# primarily intended in the case of a private repo that the above will
# fail on
FileUtils.remove_dir(target_path, true)
orig_download!
end
end
def download!
return orig_download! unless should_proxy?
download_from_github!
end
def checkout_options
# this should work almost always
orig_checkout_options
rescue ::StandardError
hash_from_github
end
private
def should_proxy?
proxy && allowed? && !options[:submodules]
end
def allowed?
ALLOWLIST.any? { |reg| reg.match?(url) }
end
def proxy
ENV['EAS_BUILD_COCOAPODS_CACHE_URL']
end
def github_url_from_ref(ref)
base_url = url.chomp('.git').chomp('/')
return "#{base_url}/archive/#{ref}.tar.gz" unless ref.nil?
api_url = base_url.gsub('https://github.com/', 'https://api.github.com/repos/')
"#{api_url}/tarball"
end
def hash_from_github
return options[:commit] if options[:commit]
return options[:tag] if options[:tag]
command = [
'ls-remote',
'--',
url,
options[:branch]
]
output = Git.execute_command('git', command)
match = commit_from_ls_remote(output, options[:branch])
# we didn't find the commit, so just return something
return 'deadbeef' if match.nil?
match
end
def commit_from_ls_remote(output, branch_name)
return nil if branch_name.nil?
encoded_branch_name = branch_name.dup.force_encoding(Encoding::ASCII_8BIT)
match = %r{([a-z0-9]*)\trefs\/(heads|tags)\/#{Regexp.quote(encoded_branch_name)}}.match(output) # rubocop:disable Style/RedundantRegexpEscape
match[1] unless match.nil?
end
end
end
end
@@ -1,203 +0,0 @@
# frozen_string_literal: true
require 'pathname'
RSpec.describe ExpoCocoaPodsProxy do
describe 'With EAS_BUILD_COCOAPODS_CACHE_URL not set' do
it 'does not rewrite GitHub repos' do
path = Pathname.new('some/fake/path')
url = 'https://github.com/expo/expo.git'
opts = { branch: 'main' }
downloader = Pod::Downloader::Git.new(path, url, opts)
expect(downloader).to receive(:orig_download!) {}
downloader.download!
end
it 'does not proxy Http pods' do
path = Pathname.new('some/fake/path')
url = 'https://github.com/expo/test-repo/archive/main.tar.gz'
opts = {}
downloader = Pod::Downloader::Http.new(path, url, opts)
expect(downloader).to receive(:orig_download_file) {}
downloader.download_file(path.to_s)
expect(downloader).to have_attributes(url: url)
end
end
describe 'With EAS_BUILD_COCOAPODS_CACHE_URL set' do
before(:context) do
ENV['EAS_BUILD_COCOAPODS_CACHE_URL'] = 'http://localhost:9001'
end
it 'rewrites github urls when submodules is not set' do
path = Pathname.new('some/fake/path')
url = 'https://github.com/expo/test-repo.git'
opts = { branch: 'main' }
downloader = Pod::Downloader::Git.new(path, url, opts)
expect(downloader).to receive(:download_from_github!) {}
downloader.download!
end
it 'does not fetch tarballs when submodules is set' do
path = Pathname.new('some/fake/path')
url = 'https://github.com/expo/test-repo.git'
opts = { branch: 'main', submodules: true }
downloader = Pod::Downloader::Git.new(path, url, opts)
expect(downloader).to receive(:orig_download!) {}
downloader.download!
end
it 'does not create tarballs for non-github repos' do
path = Pathname.new('some/fake/path')
url = 'https://not-github.com/expo/test-repo.git'
opts = { branch: 'main' }
downloader = Pod::Downloader::Git.new(path, url, opts)
expect(downloader).to receive(:orig_download!) {}
downloader.download!
end
it 'modifies github repos to be tarballs' do
path = Pathname.new('some/fake/path')
url = 'https://github.com/expo/test-repo.git'
opts = { branch: 'main' }
expected_url = 'https://github.com/expo/test-repo/archive/main.tar.gz'
downloader = Pod::Downloader::Git.new(path, url, opts)
allow(Pod::Downloader::Http).to receive(:initialize).with(path, expected_url, anything)
expect_any_instance_of(Pod::Downloader::Http).to receive(:download) {}
downloader.download_from_github!
end
it 'modifies github repos to be tarballs from the api when branch not set' do
path = Pathname.new('some/fake/path')
url = 'https://github.com/expo/test-repo.git'
opts = {}
expected_url = 'https://api.github.com/repos/expo/test-repo/tarball'
downloader = Pod::Downloader::Git.new(path, url, opts)
allow(Pod::Downloader::Http).to receive(:initialize).with(path, expected_url, anything)
expect_any_instance_of(Pod::Downloader::Http).to receive(:download) {}
downloader.download_from_github!
end
it 'does not proxy when netrc is present' do
path = Pathname.new('some/fake/path')
url = 'https://github.com/expo/test-repo/archive/main.tar.gz'
opts = {}
downloader = Pod::Downloader::Http.new(path, url, opts)
expect(File).to receive(:exist?).with("#{Dir.home}/.netrc") { true }
expect(downloader).to receive(:orig_download_file) {}
downloader.download_file(path.to_s)
end
it 'does not proxy when NETRC env var is set' do
path = Pathname.new('some/fake/path')
url = 'https://github.com/expo/test-repo/archive/main.tar.gz'
opts = {}
downloader = Pod::Downloader::Http.new(path, url, opts)
allow(ENV).to receive(:[]).and_call_original
expect(ENV).to receive(:[]).with('NETRC').and_return('psyduck')
expect(downloader).to receive(:orig_download_file) {}
downloader.download_file(path.to_s)
expect(downloader).to have_attributes(url: url)
end
it 'falls back to original download when proxy fails' do
path = Pathname.new('some/fake/path')
url = 'https://github.com/expo/test-repo/archive/main.tar.gz'
opts = {}
downloader = Pod::Downloader::Http.new(path, url, opts)
expect(downloader).to receive(:orig_download_file).once.and_raise
expect(downloader).to receive(:orig_download_file).once {}
downloader.download_file(path.to_s)
expect(downloader).to have_attributes(url: url)
end
it 'fetches via proxy when url matches an allowlist regex' do
path = Pathname.new('some/fake/path')
url = 'https://github.com/expo/test-repo/archive/main.tar.gz'
opts = {}
downloader = Pod::Downloader::Http.new(path, url, opts)
expect(downloader).to receive(:download_file_via_proxy).once {}
downloader.download_file(path.to_s)
end
it 'does not proxy when url doesn\'t match an allowlist regex' do
path = Pathname.new('some/fake/path')
url = 'https://not-github.com/expo/test-repo/archive/main.tar.gz'
opts = {}
downloader = Pod::Downloader::Http.new(path, url, opts)
expect(downloader).to receive(:orig_download_file).once {}
downloader.download_file(path.to_s)
end
it 'does not proxy when curlrc is present in home directory' do
path = Pathname.new('some/fake/path')
url = 'https://github.com/expo/test-repo/archive/main.tar.gz'
opts = {}
downloader = Pod::Downloader::Http.new(path, url, opts)
allow(File).to receive(:exist?).and_call_original
expect(File).to receive(:exist?).at_least(1).times.with("#{Dir.home}/.curlrc") { true }
expect(downloader).to receive(:orig_download_file).once {}
downloader.download_file(path.to_s)
end
it 'does not proxy when curlrc is present in CURL_HOME' do
path = Pathname.new('some/fake/path')
url = 'https://github.com/expo/test-repo/archive/main.tar.gz'
opts = {}
curl_home = '/this/is/my/home'
downloader = Pod::Downloader::Http.new(path, url, opts)
allow(ENV).to receive(:[]).and_call_original
expect(ENV).to receive(:[]).with('CURL_HOME').and_return(curl_home)
allow(File).to receive(:exist?).and_call_original
expect(File).to receive(:exist?).at_least(1).times.with("#{curl_home}/.curlrc") { true }
expect(downloader).to receive(:orig_download_file).once {}
downloader.download_file(path.to_s)
end
it 'does not proxy when curlrc is present in XDG_CONFIG_HOME' do
path = Pathname.new('some/fake/path')
url = 'https://github.com/expo/test-repo/archive/main.tar.gz'
opts = {}
xdg_config_home = '/this/is/my/home'
downloader = Pod::Downloader::Http.new(path, url, opts)
allow(ENV).to receive(:[]).and_call_original
allow(ENV).to receive(:[]).with('XDG_CONFIG_HOME').and_return(xdg_config_home)
allow(File).to receive(:exist?).and_call_original
expect(File).to receive(:exist?).at_least(1).times.with("#{xdg_config_home}/.curlrc") { true }
expect(downloader).to receive(:orig_download_file).once {}
downloader.download_file(path.to_s)
end
end
end
@@ -1,16 +0,0 @@
# frozen_string_literal: true
require 'bundler/setup'
require 'expo_cocoapods_proxy/proxy'
RSpec.configure do |config|
# Enable flags like --only-failures and --next-failure
config.example_status_persistence_file_path = '.rspec_status'
# Disable RSpec exposing methods globally on `Module` and `main`
config.disable_monkey_patching!
config.expect_with :rspec do |c|
c.syntax = :expect
end
end
-19
View File
@@ -1,19 +0,0 @@
#!/usr/bin/env bash
source_up
access_secret_version() {
name="$1"
version="$2"
gcloud secrets versions access "$version" --secret "$name" --project exponentjs
}
source_secrets_from_gcsm_json() {
name="$1"
version="$2"
secrets=$(access_secret_version "$name" "$version")
eval "export $(echo "$secrets" | jq -r 'to_entries | map("\(.key)=\(.value)") | @sh')"
}
source_secrets_from_gcsm_json "turtle_v2__worker_tests" 5
@@ -1,44 +0,0 @@
name: Build Worker
on:
workflow_dispatch:
inputs:
upload_artifact:
description: Whether to upload artifacts
type: boolean
default: true
jobs:
build_android_worker:
name: Build Android Worker
runs_on: linux-medium
image: latest
steps:
- uses: eas/checkout
- name: Build Android worker tarball
run: ./package.sh worker-android.tar.gz android
- name: Upload Android worker tarball as artifact
if: ${{ inputs.upload_artifact }}
uses: eas/upload_artifact
with:
name: worker-android-tarball
path: packages/worker/worker-android.tar.gz
build_ios_worker:
name: Build iOS Worker
runs_on: macos-medium
image: latest
steps:
- uses: eas/checkout
- name: Build iOS worker tarball
run: ./package.sh worker-ios.tar.gz ios
- name: Upload iOS worker tarball as artifact
if: ${{ inputs.upload_artifact }}
uses: eas/upload_artifact
with:
name: worker-ios-tarball
path: packages/worker/worker-ios.tar.gz
-9
View File
@@ -1,9 +0,0 @@
#!/usr/bin/env bash
export ENVIRONMENT="development"
export PORT="3005"
export WORKER_PORT="3005"
export WORKER_IP="localhost"
export LOGGER_LEVEL="debug"
unset _VOLTA_TOOL_RECURSION
-31
View File
@@ -1,31 +0,0 @@
const rootEslintrc = require('../../.eslintrc.js');
module.exports = {
...rootEslintrc,
rules: {
...rootEslintrc.rules,
'async-protect/async-suffix': ['off'],
'no-underscore-dangle': [
'error',
{
allow: [...rootEslintrc.rules['no-underscore-dangle'][1].allow, '__WORKFLOW_JOB_ID'],
allowAfterThis: true,
},
],
'import/no-extraneous-dependencies': [
'error',
{
devDependencies: ['**/__integration__/**/*', '**/__unit__/**/*', '**/__mocks__/**/*'],
},
],
'node/no-sync': ['off'],
},
overrides: [
{
files: ['*.test.ts'],
rules: {
'no-console': 'off',
},
},
],
};
-5
View File
@@ -1,5 +0,0 @@
node_modules/
dist/
workingdir/*
!workingdir/.gitkeep
-158
View File
@@ -1,158 +0,0 @@
# CLAUDE.md - worker
This file provides guidance for working with the **worker** package.
## Overview
Turtle Worker is a service running on every EAS build worker VM or pod. It's a **private package** (not published to npm).
- Exposes a **WebSocket server** to communicate with Turtle Launcher
- Wrapper for `@expo/build-tools` library
- Receives build requests and handles actual React Native project builds
- Runs on EAS build infrastructure (VMs/pods)
## Development Commands
### Setup
```bash
# Copy environment configuration
cp ./.direnv/local/.envrc.example ./.direnv/local/.envrc
# Fill out the .envrc file with your secrets
# Then load it with direnv or source it manually
# Install dependencies (from repo root)
yarn install
# Build
yarn build
```
### Development
```bash
yarn start # Start the worker in development mode
# If you made changes in packages from libs/ directory:
# Type 'rs' in the console and press ENTER
# Or press CTRL+C, then UP arrow, then ENTER to restart
```
### Testing
```bash
yarn test # Run unit tests
```
## Architecture
### WebSocket Communication
The worker communicates with Turtle Launcher via WebSocket messages.
**Message Types** (defined in `/src/libs/turtle-common/src/messages.ts`):
1. **state-query** (launcher → worker)
- Launcher queries current worker state
2. **state-response** (worker → launcher)
- Worker responds with current state
- Possible statuses: `"new"`, `"in-progress"`, `"finished"`
3. **dispatch** (launcher → worker)
- Launcher dispatches a new build to worker
- Only sent when worker status is `"new"`
4. **success** / **error** (worker → launcher)
- Worker reports build completion
- Contains build results and artifacts
5. **close** (launcher → worker)
- Launcher initiates graceful shutdown of VM/pod
### Communication Flow
```
┌─────────────┐ ┌─────────────┐
│ Launcher │ │ Worker │
└──────┬──────┘ └──────┬──────┘
│ │
│ state-query │
├─────────────────────────────────>│
│ │
│ state-response (status: "new") │
│<─────────────────────────────────┤
│ │
│ dispatch (build request) │
├─────────────────────────────────>│
│ │
│ (build runs) │
│ │
│ success/error (build result) │
│<─────────────────────────────────┤
│ │
│ close (shutdown) │
├─────────────────────────────────>│
│ │
```
### Recovery Process
If the launcher goes down during a build, recovery happens when it comes back:
1. New launcher instance sends `state-query`
2. Worker responds with `state-response` containing current status:
- If build finished: launcher sends `close` message
- If build in progress: launcher takes no action, waits for completion
### Build Execution
Worker wraps `@expo/build-tools` library to execute builds:
- Receives build job configuration
- Sets up build environment
- Runs actual React Native build process
- Uploads artifacts
- Reports results back to launcher
## Deployment
### Distribution
Worker code is distributed via **GCS bucket**. Every pod/VM installs it during startup.
**Archive Structure**:
- Contains worker source code from repository
- All other dependencies downloaded from npm at install time
### Deployment Process
**For every commit on main**:
- GitHub Actions creates archives: `worker-{{platform}}-{{hash}}`
- Creates staging archives: `worker-{{platform}}-staging`
**Production Deployment**:
- Guarded with manual `approve` step in GitHub Actions
- Creates copy: `worker-{{platform}}-staging` → `worker-{{platform}}-production`
### Docker Images (Android)
- Built for every PR with changes in `infra/eas-build-worker-images/android/{{ imageName }}`
- Pushed to Docker registry hosted on GCloud
### VM Templates (iOS)
- Built for every PR with changes in `infra/eas-build-worker-images/ios/{{ templateName }}`
## Dependencies
Key dependencies:
- `@expo/build-tools` - Core build execution
- `koa`, `koa-body`, `koa-router` - HTTP server
- `ws` - WebSocket server
- `@sentry/node` - Error tracking
## Important Notes
- **Not Published**: This is a private package, not published to npm
- **Build Output**: `src/` → `dist/`
- **WebSocket**: Primary communication method with launcher
- **Stateful**: Maintains build state for recovery scenarios
- **Infrastructure**: Runs on EAS build VMs (iOS) and pods (Android)
-44
View File
@@ -1,44 +0,0 @@
# Turtle Worker
<h3 align=center>
<img src="./assets/turtle-workers.png">
</h3>
Turtle Worker is a service running on every worker VM or pod. It exposes a WebSocket server to communicate with [Turtle Launcher](/src/services/launcher). The service is basically a wrapper for @expo/build-tools library. It receives a build request and handles the actual build of the React Native project.
## WebSocket messages
There are a couple of message types that could be exchanged between a launcher and a worker, definition of those messages are [here](/src/libs/turtle-common/src/messages.ts).
Communication between worker and launcher starts with `state-query` message (`launcher -> worker`) and worker responds with `state-response`
that contains the current state of a worker. The next steps depend on the state of the worker:
- if this was a new instance of the worker then `state-response` message contained `"status": "new"` and the launcher will send `dispatch` method that should start a new build on the worker.
- if this happened during the recovery process and the build was already finished(either success or failure), then `state-response` contained information about the build result and launcher will send `close` message.
- if this happened during the recovery process and the build is still in progress, then `state-response` contained `"status": "in-progress"` and the launcher does not take any actions.
When the build is finished worker sends `success` or `error` messages with information about build results and the launcher will respond with `close` message that initiates a gracefull shutdown of a vm/pod.
If for some reason the launcher is down when the build has finished then the new launcher instance will attempt to recover that build as described above.
## Development
- Run `cp ./.direnv/local/.envrc.example ./.direnv/local/.envrc` and fill out the new file with your secrets.
- Run `yarn` (if you haven't run it in the root dir of the repo) and then `yarn start`.
- If you made a change in packages from directory `libs`, type `rs` in the console and press ENTER (or `CTRL+C`, `UP`, `ENTER`).
## Deployment
The worker code is distributed via GCS bucket and every pod/vm installs it as part of it's startup or configuration steps. Archive uploaded to GCS bucket contain version of worker source from the repository, but all other libraries that worker depends on are downloaded from npm.
### Worker code
- For every commit on main Github Actions job creates archive with worker code and node-modules. It will create two archives for every platform `worker-{{platform}}-{{hash}}` and `worker-{{platform}}-staging`.
- Deployment to production is guarded with `approve` step and it creating copy of `worker-{{platform}}-staging` under `worker-{{platform}}-production`.
#### Docker image
Github action builds a docker image for every PR with any change in `infra/eas-build-worker-images/android/{{ imageName }}`, after that image is pushed to docker registry hosted on gcloud.
### VM template
Github action builds a new vm template for every PR with any change in `infra/eas-build-worker-images/ios/{{ templateName }}`.
-12
View File
@@ -1,12 +0,0 @@
{
"expo": {
"name": "EAS Worker",
"slug": "eas-worker",
"platforms": ["ios"],
"extra": {
"eas": {
"projectId": "90798684-1384-4ce4-a73b-9f3fa3e3e368"
}
}
}
}
@@ -1,15 +0,0 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
rootDir: 'src',
testMatch: ['**/__integration__/*.test.ts'],
collectCoverage: true,
coverageReporters: ['json', 'lcov', 'text-summary'],
coverageDirectory: '../coverage/integration',
// Exclude main.ts - it has a self-executing main() that starts servers
coveragePathIgnorePatterns: ['/node_modules/', 'main\\.ts$'],
setupFilesAfterEnv: ['<rootDir>/../jest/integration-setup.ts'],
// Force exit after tests complete - internal async operations (logger streams,
// WebSocket cleanup timing) may keep handles open that prevent clean exit.
forceExit: true,
};
-10
View File
@@ -1,10 +0,0 @@
import type { Config } from 'jest';
export default {
preset: 'ts-jest',
testEnvironment: 'node',
rootDir: __dirname,
testMatch: ['**/__unit__/*.test.ts'],
clearMocks: true,
setupFilesAfterEnv: ['./jest/unit-setup.ts'],
} satisfies Config;
-16
View File
@@ -1,16 +0,0 @@
import logger from '../src/logger';
if (process.env.NODE_ENV !== 'test') {
throw new Error("NODE_ENV environment variable must be set to 'test'.");
}
if (!process.env.LISTENING_TO_UNHANDLED_REJECTION) {
process.on('unhandledRejection', reason => {
logger.error('UNHANDLED PROMISE REJECTION', reason);
});
// Avoid memory leak by adding too many listeners
process.env.LISTENING_TO_UNHANDLED_REJECTION = 'true';
}
// Always mock:
jest.mock('../src/CacheManager');
-20
View File
@@ -1,20 +0,0 @@
if (process.env.NODE_ENV !== 'test') {
throw new Error("NODE_ENV environment variable must be set to 'test'.");
}
process.env.ENVIRONMENT = 'test';
process.env.LOGGER_LEVEL = 'debug';
process.env.WORKER_RUNTIME_CONFIG_BASE64 = 'eyAiaW1hZ2UiOiAiIiB9'; // { "image": "" }
// eslint-disable-next-line no-underscore-dangle
process.env.__API_SERVER_URL = 'http://api.expo.test';
process.env.PORT = '3015';
// Always mock:
jest.mock('@expo/build-tools', () => {
return {
...jest.requireActual('@expo/build-tools'),
GCS: {
uploadWithSignedUrl: jest.fn(),
},
};
});
-84
View File
@@ -1,84 +0,0 @@
{
"name": "@expo/worker",
"version": "16.29.0",
"private": true,
"scripts": {
"start": "yarn watch",
"watch": "yarn compile --watch --preserveWatchOutput",
"build": "npm run clean && npm run compile",
"clean": "rm -rf ./dist",
"clean-all": "yarn clean && rm -rf node_modules",
"compile": "tsc -p tsconfig.build.json",
"prepublishOnly": "npm run build",
"test": "yarn test:unit",
"test:unit": "./resources/jest --config jest.config.unit.ts",
"test:unit:watch": "./resources/jest --config jest.config.unit.ts --watch",
"test:integration": "./resources/jest --runInBand --config jest.config.integration.js",
"test:integration:watch": "./resources/jest --runInBand --config jest.config.integration.js --watchAll",
"typecheck": "tsc"
},
"engines": {
"node": ">=18"
},
"dependencies": {
"@babel/core": "7.24.7",
"@expo/build-tools": "1.0.271",
"@expo/config-plugins": "8.0.8",
"@expo/config-types": "52.0.1",
"@expo/downloader": "1.0.260",
"@expo/eas-build-job": "1.0.265",
"@expo/logger": "1.0.260",
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "1.0.271",
"@expo/template-file": "1.0.260",
"@expo/turtle-spawn": "1.0.260",
"@hapi/boom": "10.0.1",
"@sentry/node": "7.77.0",
"check-disk-space": "3.4.0",
"fast-glob": "3.3.2",
"filesize": "7.0.0",
"flatted": "3.2.5",
"fs-extra": "11.2.0",
"koa": "3.1.1",
"koa-body": "7.0.1",
"koa-router": "14.0.0",
"lodash": "4.17.21",
"micromatch": "4.0.8",
"node-fetch": "2.7.0",
"node-os-utils": "1.3.7",
"nullthrows": "1.1.1",
"semver": "7.6.3",
"tar": "6.2.1",
"uuid": "3.4.0",
"ws": "8.18.0",
"xml2js": "0.6.2",
"zod": "4.1.8"
},
"devDependencies": {
"@types/babel__core": "7.20.5",
"@types/fs-extra": "11.0.4",
"@types/koa": "3.0.1",
"@types/koa-router": "7.4.9",
"@types/lodash": "4.17.4",
"@types/micromatch": "4.0.7",
"@types/node": "20.14.8",
"@types/node-fetch": "2.6.7",
"@types/node-os-utils": "1.3.0",
"@types/semver": "7.5.8",
"@types/supertest": "6.0.3",
"@types/tar": "6.1.10",
"@types/uuid": "8.3.4",
"@types/ws": "8.5.10",
"jest": "29.7.0",
"memfs": "3.4.1",
"supertest": "7.2.2",
"ts-jest": "29.1.4",
"typescript": "5.5.4"
},
"volta": {
"node": "20.18.3",
"yarn": "1.22.22"
}
}
-81
View File
@@ -1,81 +0,0 @@
#!/usr/bin/env bash
set -eo pipefail
set -x
ROOT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )"/../.. && pwd )"
WORKER_DIR=$( dirname "${BASH_SOURCE[0]}" )
OUTPUT_FILE=$1
PLATFORM=$2
if [[ -z "$OUTPUT_FILE" || -z "$PLATFORM" ]]; then
echo "Please specify the output file and target platform (.tar.gz)"
echo "Usage: ./package.sh OUTPUT_FILE (ios|android)"
echo "Example: ./package.sh worker.tar.gz ios"
exit -1
fi
if [[ "$OUTPUT_FILE" != *.tar.gz ]]; then
OUTPUT_FILE="$OUTPUT_FILE.tar.gz"
fi
echo "Building $OUTPUT_FILE"
tmp_dir=$(mktemp -d)
target_root_dir="$tmp_dir"
target_worker_dir="$tmp_dir/packages/worker"
mkdir -p "$target_worker_dir"
copy_package() {
src=$1
dst=$2
mkdir -p $dst
cp "$src/package.json" "$dst/package.json"
cp "$src/tsconfig.json" "$dst/tsconfig.json"
cp "$src/tsconfig.build.json" "$dst/tsconfig.build.json"
cp -r "$src/src" "$dst/src"
}
cp "$ROOT_DIR/yarn.lock" $target_root_dir
cp "$ROOT_DIR/package.json" $target_root_dir
cp "$ROOT_DIR/lerna.json" $target_root_dir
copy_package $WORKER_DIR $target_worker_dir
pushd $target_root_dir >/dev/null 2>&1
yarn install --silent
yarn build
yarn install --silent --production=true
rm -rf tsconfig.json tsconfig.build.json
popd >/dev/null 2>&1
if [[ "$PLATFORM" == "ios" ]]; then
# build plugin
pushd "$ROOT_DIR/packages/expo-cocoapods-proxy" >/dev/null 2>&1
if command -v brew &> /dev/null; then
eval "$(brew shellenv)"
else
echo "Error: brew command not found in PATH. Please ensure Homebrew is installed and in your PATH." >&2
exit 1
fi
bundle install
gem build expo-cocoapods-proxy.gemspec
mv expo-cocoapods-proxy-*.gem "$target_worker_dir/expo-cocoapods-proxy.gem"
popd >/dev/null 2>&1
fi
rm -rf $target_root_dir/.volta/
# Create backward-compatible symlink for orchestrator
# The orchestrator expects ./src/services/worker/dist/main.js
# but the new structure has ./packages/worker/dist/main.js
# We're only symlinking the specific file to discover any other legacy path usage
mkdir -p "$target_root_dir/src/services/worker/dist"
ln -s ../../../../packages/worker/dist/main.js "$target_root_dir/src/services/worker/dist/main.js"
tar zcf $OUTPUT_FILE -C $target_root_dir .
echo "Tarball is ready: $OUTPUT_FILE"
rm -rf $tmp_dir
-11
View File
@@ -1,11 +0,0 @@
#!/usr/bin/env bash
set -e
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
PACKAGE_DIR="$( cd "$DIR/.." && pwd)"
ROOT_DIR="$( cd "$DIR/../../../.." && pwd)"
pushd $PACKAGE_DIR >/dev/null 2>&1
./resources/jest --config jest.config.unit.ts
popd >/dev/null 2>&1
-12
View File
@@ -1,12 +0,0 @@
#!/usr/bin/env bash
set -e
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
ROOT_DIR="$( cd "$DIR/../../../.." && pwd)"
# shellcheck disable=SC1091
# shellcheck source=test-env.sh
source "$DIR/test-env.sh"
yarn run jest "$@"
@@ -1,48 +0,0 @@
#!/usr/bin/env bash
# Links/unlinks local eas-build packages for development.
#
# Prerequisites:
# Run `yarn link` in each package directory in eas-build repo first:
# cd /path/to/eas-build
# for pkg in build-tools steps eas-build-job downloader logger template-file turtle-spawn; do
# (cd packages/$pkg && yarn link)
# done
#
# Usage:
# ./resources/link-eas-build-packages.sh # Link all packages
# ./resources/link-eas-build-packages.sh --unlink # Unlink and restore npm versions
set -e
PACKAGES=(
"@expo/build-tools"
"@expo/steps"
"@expo/eas-build-job"
"@expo/downloader"
"@expo/logger"
"@expo/template-file"
"@expo/turtle-spawn"
)
if [[ "$1" == "--unlink" ]]; then
CMD="unlink"
echo "Unlinking eas-build packages..."
else
CMD="link"
echo "Linking eas-build packages..."
fi
for PACKAGE in "${PACKAGES[@]}"; do
echo "yarn $CMD $PACKAGE"
yarn $CMD "$PACKAGE"
done
if [[ "$1" == "--unlink" ]]; then
echo ""
echo "Restoring npm versions..."
yarn install --force
fi
echo ""
echo "Done!"
-10
View File
@@ -1,10 +0,0 @@
#!/usr/bin/env bash
export NODE_ENV="test"
export ENVIRONMENT="test"
export LOGGER_LEVEL="debug"
export WORKER_RUNTIME_CONFIG_BASE64="eyAiaW1hZ2UiOiAiIiB9" # { "image": "" }
export __API_SERVER_URL="http://api.expo.test"
export PORT="3015"
-131
View File
@@ -1,131 +0,0 @@
import { BuildContext, CacheManager, GCS } from '@expo/build-tools';
import downloadFile from '@expo/downloader';
import { BuildJob } from '@expo/eas-build-job';
import filesize from 'filesize';
import fs from 'fs-extra';
import path from 'path';
import tar from 'tar';
import config from './config';
type ContentLengthRange = { minSizeBytes: number; maxSizeBytes: number };
export class GCSCacheManager implements CacheManager {
private skipCacheUpdate = false;
public async saveCache(ctx: BuildContext<BuildJob>): Promise<void> {
if (this.skipCacheUpdate) {
return;
}
const paths = this.getPaths(ctx);
if (
(ctx.job?.cache?.disabled ?? false) ||
!config.buildCache.gcsSignedUploadUrlForBuildCache ||
paths.length === 0
) {
return;
}
ctx.logger.info('Saving to cache:');
paths.forEach(filePath => {
ctx.logger.info(`- ${filePath}`);
});
const archivePath = path.join(ctx.workingdir, 'cache-save.tar.gz');
await tar.create(
{
file: archivePath,
cwd: '/',
gzip: true,
preservePaths: true,
},
paths
);
const archiveSize = (await fs.stat(archivePath)).size;
const { maxSizeBytes } = this.allowedContentLengthRange;
const friendlyMaxSize = filesize(maxSizeBytes);
const friendlyArchiveSize = filesize(archiveSize);
if (archiveSize > maxSizeBytes) {
ctx.logger.error(
`Unable to save cache. Max size is ${friendlyMaxSize} but archive size is ${friendlyArchiveSize}.`
);
return;
}
try {
await GCS.uploadWithSignedUrl({
signedUrl: config.buildCache.gcsSignedUploadUrlForBuildCache,
srcGeneratorAsync: async () => {
return fs.createReadStream(archivePath);
},
});
} catch (err: any) {
ctx.logger.error({ err });
}
}
public async restoreCache(ctx: BuildContext<BuildJob>): Promise<void> {
const paths = this.getPaths(ctx);
if (
(ctx.job?.cache?.disabled ?? false) ||
!config.buildCache.gcsSignedBuildCacheDownloadUrl ||
paths.length === 0
) {
return;
}
ctx.logger.info('Restoring files from cache:');
paths.forEach(filePath => {
ctx.logger.info(`- ${filePath}`);
});
const archivePath = path.join(ctx.workingdir, 'cache-restore.tar.gz');
try {
await downloadFile(config.buildCache.gcsSignedBuildCacheDownloadUrl, archivePath, {});
} catch (err: any) {
ctx.logger.error({ err });
this.skipCacheUpdate = true; // if restore failed we don't want to update cache with new values
return;
}
// @ts-expect-error
await tar.extract(
{
file: archivePath,
cwd: '/',
preserveOwner: true,
preservePaths: true,
keep: true, // do not override existing files
},
paths
);
}
private getPaths(ctx: BuildContext<BuildJob>): string[] {
const paths = ctx.job.cache?.paths ?? [];
return paths.map(cachePath =>
path.isAbsolute(cachePath)
? cachePath
: path.resolve(ctx.buildDirectory, ctx.job.projectRootDirectory ?? '.', cachePath)
);
}
private get allowedContentLengthRange(): ContentLengthRange {
const headers = config.buildCache.gcsSignedUploadUrlForBuildCache?.headers;
const defaultValues = { minSizeBytes: -Infinity, maxSizeBytes: Infinity };
if (!headers) {
return defaultValues;
}
const values = headers['x-goog-content-length-range'];
if (!values) {
return defaultValues;
}
const [min, max] = values.split(',');
return {
minSizeBytes: Number(min),
maxSizeBytes: Number(max),
};
}
}
@@ -1,292 +0,0 @@
import {
BuildContext,
Hook,
findAndUploadXcodeBuildLogsAsync,
runHookIfPresent,
} from '@expo/build-tools';
import { Job } from '@expo/eas-build-job';
import fs from 'fs';
import { hostname } from 'os';
import path from 'path';
import WebSocket from 'ws';
import { WsHelper, unreachableCode } from './utils';
import { createTestAndroidJob, createTestIosJob } from './utils/jobs';
import config from '../config';
import logger from '../logger';
import { cleanUpWorkingdir, prepareWorkingdir } from '../workingdir';
import startWsServer from '../ws';
const buildId = config.buildId;
jest.setTimeout(10 * 1000);
let buildTimeout: NodeJS.Timeout;
// Mock @expo/build-tools at the library boundary
// Builders hang until buildTimeout fires so we can test abort during build
jest.mock('@expo/build-tools', () => {
const actual = jest.requireActual('@expo/build-tools');
return {
...actual,
Builders: {
androidBuilder: jest.fn(
async () =>
new Promise<void>(res => {
buildTimeout = setTimeout(() => {
res();
unreachableCode('build finished');
}, 10 * 1000);
})
),
iosBuilder: jest.fn(
async () =>
new Promise<void>(res => {
buildTimeout = setTimeout(() => {
res();
unreachableCode('build finished');
}, 10 * 1000);
})
),
},
findAndUploadXcodeBuildLogsAsync: jest.fn(() => {
logger.info('Uploading XCode logs');
}),
runHookIfPresent: jest.fn(),
};
});
jest.mock('../service', () => {
return function () {
const BuildService = new (jest.requireActual('../service').default)();
BuildService.checkForHangingWorker = jest.fn(async () => {});
return BuildService;
};
});
jest.mock('../config', () => {
const config = jest.requireActual('../config');
return {
__esModule: true,
...config,
default: {
...config.default,
buildId: 'e9b99e52-fb74-4927-be63-33d7447ddfd4',
},
};
});
describe('launcher aborts build', () => {
let port: number;
let server: WebSocket.Server;
let messageTimeout: NodeJS.Timeout;
beforeEach(async () => {
await prepareWorkingdir();
fs.writeFileSync(
path.join(config.workingdir, 'build', 'package.json'),
JSON.stringify({
scripts: {},
})
);
port = Math.floor(Math.random() * 10000 + 10000);
server = startWsServer(port);
logger.debug(`Listening on port ${port}`);
jest.clearAllMocks();
});
afterEach(async () => {
await new Promise<void>((resolve, reject) => {
server.close(err => (err ? reject(err) : resolve()));
});
});
afterAll(async () => {
await cleanUpWorkingdir();
});
describe('android', () => {
describe('build canceled', () => {
const job = createTestAndroidJob();
it('does not upload XCode logs and notifies launcher after cleanup', async () => {
await runTest({ job, buildCanceled: true });
expect(findAndUploadXcodeBuildLogsAsync).not.toHaveBeenCalled();
});
it('executes on_cancel hook if present', async () => {
fs.writeFileSync(
path.join(config.workingdir, 'build', 'package.json'),
JSON.stringify({
scripts: {
[Hook.ON_BUILD_CANCEL]: 'echo on_build_cancel',
},
})
);
await runTest({ job, buildCanceled: true });
expect(runHookIfPresent).toHaveBeenCalledWith(
expect.any(BuildContext),
Hook.ON_BUILD_CANCEL
);
});
it('executes on_cancel hook if present, hook throws an error, error is handled', async () => {
fs.writeFileSync(
path.join(config.workingdir, 'build', 'package.json'),
JSON.stringify({
scripts: {
[Hook.ON_BUILD_CANCEL]: 'this_is_not_a_valid_command',
},
})
);
jest.mocked(runHookIfPresent).mockImplementation(async () => {
throw new Error('Hook failed');
});
await runTest({ job, buildCanceled: true });
expect(runHookIfPresent).toHaveBeenCalledWith(
expect.any(BuildContext),
Hook.ON_BUILD_CANCEL
);
});
});
describe('build timed out', () => {
const job = createTestAndroidJob();
it('does not upload XCode logs and notifies launcher after cleanup', async () => {
await runTest({ job });
expect(findAndUploadXcodeBuildLogsAsync).not.toHaveBeenCalled();
});
});
});
describe('ios', () => {
describe('build canceled', () => {
const job = createTestIosJob();
it('uploads XCode logs and notifies launcher after cleanup', async () => {
await runTest({ job, buildCanceled: true });
expect(findAndUploadXcodeBuildLogsAsync).toHaveBeenCalled();
});
it('executes on_cancel hook if present', async () => {
fs.writeFileSync(
path.join(config.workingdir, 'build', 'package.json'),
JSON.stringify({
scripts: {
[Hook.ON_BUILD_CANCEL]: 'echo on_build_cancel',
},
})
);
await runTest({ job, buildCanceled: true });
expect(runHookIfPresent).toHaveBeenCalledWith(
expect.any(BuildContext),
Hook.ON_BUILD_CANCEL
);
});
it('executes on_cancel hook if present, hook throws an error, error is handled', async () => {
fs.writeFileSync(
path.join(config.workingdir, 'build', 'package.json'),
JSON.stringify({
scripts: {
[Hook.ON_BUILD_CANCEL]: 'this_is_not_a_valid_command',
},
})
);
jest.mocked(runHookIfPresent).mockImplementation(async () => {
throw Error('Hook failed');
});
await runTest({ job, buildCanceled: true });
expect(runHookIfPresent).toHaveBeenCalledWith(
expect.any(BuildContext),
Hook.ON_BUILD_CANCEL
);
});
});
describe('build timed out', () => {
const job = createTestIosJob();
it('uploads XCode logs and notifies launcher after cleanup', async () => {
await runTest({ job });
expect(findAndUploadXcodeBuildLogsAsync).toHaveBeenCalled();
});
it('does not execute on_cancel hook even if present', async () => {
fs.writeFileSync(
path.join(config.workingdir, 'build', 'package.json'),
JSON.stringify({
scripts: {
[Hook.ON_BUILD_CANCEL]: 'echo on_build_cancel',
},
})
);
await runTest({ job });
expect(runHookIfPresent).not.toHaveBeenCalled();
});
});
});
async function runTest({
job,
buildCanceled = false,
}: {
job: Job;
buildCanceled?: boolean;
}): Promise<void> {
const dispatchWS = new WebSocket(`ws://localhost:${port}?expo_vm_name=${hostname()}`);
const dispatchHelper = new WsHelper(dispatchWS);
await dispatchHelper.onOpen();
logger.info('Connection between launcher and worker open');
dispatchWS.send(
JSON.stringify({
type: 'dispatch',
buildId,
job,
initiatingUserId: '14367e1b-26fc-4c00-aedb-0629d78f8286',
metadata: {
trackingContext: {},
},
})
);
dispatchWS.close();
await dispatchHelper.onClose();
logger.info('Dispatch message sent by launcher and received');
const ws = new WebSocket(`ws://localhost:${port}?expo_vm_name=${hostname()}`);
const helper = new WsHelper(ws);
let abortedPromiseResolve: () => void;
const abortedPromise = new Promise<void>(res => {
abortedPromiseResolve = res;
});
const onMessage = jest.fn((message: any) => {
logger.debug('message received');
clearTimeout(messageTimeout);
try {
expect(message).toBeTruthy();
expect(message.type).toBe('aborted');
expect(message.reason).toBe(buildCanceled ? 'cancel' : 'timeout');
} catch (err) {
throw err;
} finally {
abortedPromiseResolve();
}
});
const openPromise = helper.onOpen();
helper.onMessage(onMessage);
await openPromise;
messageTimeout = setTimeout(() => {
unreachableCode('aborted timeout');
}, 3000);
ws.send(JSON.stringify({ type: 'abort', reason: buildCanceled ? 'cancel' : 'timeout' }));
logger.info('Abort message sent by launcher');
await abortedPromise;
logger.info('Cleanup complete and aborted message sent by worker');
ws.close();
clearTimeout(messageTimeout);
clearTimeout(buildTimeout);
}
});
@@ -1,228 +0,0 @@
import { hostname } from 'os';
import WebSocket from 'ws';
import { WsHelper, unreachableCode } from './utils';
import { createTestAndroidJob, createTestIosJob } from './utils/jobs';
import logger from '../logger';
import { cleanUpWorkingdir, prepareWorkingdir } from '../workingdir';
import startWsServer from '../ws';
const buildId = 'e9b99e52-fb74-4927-be63-33d7447ddfd4';
jest.setTimeout(30 * 1000);
// Mock @expo/build-tools at the library boundary - worker's build.ts runs for real
jest.mock('@expo/build-tools', () => {
const actual = jest.requireActual('@expo/build-tools');
return {
...actual,
Builders: {
androidBuilder: jest.fn(async () => ({
APPLICATION_ARCHIVE: 'test-android.aab',
})),
iosBuilder: jest.fn(async () => ({
APPLICATION_ARCHIVE: 'test-ios.ipa',
})),
},
};
});
jest.mock('../service', () => {
return function () {
const BuildService = new (jest.requireActual('../service').default)();
BuildService.checkForHangingWorker = jest.fn(async () => {});
return BuildService;
};
});
jest.mock('../config', () => {
const config = jest.requireActual('../config').default;
return {
...config,
buildId: 'e9b99e52-fb74-4927-be63-33d7447ddfd4',
};
});
jest.mock('../upload', () => {
return {
...jest.requireActual('../upload'),
uploadApplicationArchiveAsync: jest.fn(async () => 'uploaded-archive'),
};
});
describe('Job execution', () => {
let port: number;
let server: WebSocket.Server;
beforeEach(async () => {
await prepareWorkingdir();
port = Math.floor(Math.random() * 10000 + 10000);
server = startWsServer(port);
logger.debug(`Listening on port ${port}`);
});
afterEach(async () => {
await new Promise<void>((resolve, reject) => {
server.close(err => (err ? reject(err) : resolve()));
});
});
afterAll(async () => {
await cleanUpWorkingdir();
});
describe('Android build', () => {
it('should execute build and return success', async () => {
const ws = new WebSocket(`ws://localhost:${port}?expo_vm_name=${hostname()}`);
const helper = new WsHelper(ws);
let successPromiseResolve: () => void;
const successPromise = new Promise<void>(res => {
successPromiseResolve = res;
});
const onMessage = jest.fn((message: any) => {
logger.debug('message received');
try {
expect(message).toBeTruthy();
expect(message.type).toBe('success');
expect(message.applicationArchiveName).toBeTruthy();
} finally {
successPromiseResolve();
}
});
const openPromise = helper.onOpen();
helper.onMessage(onMessage);
await openPromise;
const messageTimeout = setTimeout(() => {
unreachableCode('build timeout');
}, 20000);
ws.send(
JSON.stringify({
type: 'dispatch',
buildId,
job: createTestAndroidJob(),
initiatingUserId: '14367e1b-26fc-4c00-aedb-0629d78f8286',
metadata: {
trackingContext: {},
buildProfile: 'production',
},
})
);
await successPromise;
clearTimeout(messageTimeout);
const closePromise = helper.onClose();
ws.send(JSON.stringify({ type: 'close' }));
await closePromise;
expect(helper.onErrorCb).not.toHaveBeenCalled();
expect(helper.onOpenCb).toHaveBeenCalled();
expect(helper.onMessageCb).toHaveBeenCalled();
expect(helper.onCloseCb).toHaveBeenCalled();
});
});
describe('iOS build', () => {
it('should execute build and return success', async () => {
const ws = new WebSocket(`ws://localhost:${port}?expo_vm_name=${hostname()}`);
const helper = new WsHelper(ws);
let successPromiseResolve: () => void;
const successPromise = new Promise<void>(res => {
successPromiseResolve = res;
});
const onMessage = jest.fn((message: any) => {
logger.debug('message received');
try {
expect(message).toBeTruthy();
expect(message.type).toBe('success');
expect(message.applicationArchiveName).toBeTruthy();
} finally {
successPromiseResolve();
}
});
const openPromise = helper.onOpen();
helper.onMessage(onMessage);
await openPromise;
const messageTimeout = setTimeout(() => {
unreachableCode('build timeout');
}, 20000);
ws.send(
JSON.stringify({
type: 'dispatch',
buildId,
job: createTestIosJob(),
initiatingUserId: '14367e1b-26fc-4c00-aedb-0629d78f8286',
metadata: {
trackingContext: {},
buildProfile: 'production',
},
})
);
await successPromise;
clearTimeout(messageTimeout);
const closePromise = helper.onClose();
ws.send(JSON.stringify({ type: 'close' }));
await closePromise;
expect(helper.onErrorCb).not.toHaveBeenCalled();
expect(helper.onOpenCb).toHaveBeenCalled();
expect(helper.onMessageCb).toHaveBeenCalled();
expect(helper.onCloseCb).toHaveBeenCalled();
});
});
describe('graceful shutdown', () => {
it('should handle close message after build completes', async () => {
const ws = new WebSocket(`ws://localhost:${port}?expo_vm_name=${hostname()}`);
const helper = new WsHelper(ws);
let successPromiseResolve: () => void;
const successPromise = new Promise<void>(res => {
successPromiseResolve = res;
});
const onMessage = jest.fn((message: any) => {
if (message.type === 'success') {
successPromiseResolve();
}
});
const openPromise = helper.onOpen();
helper.onMessage(onMessage);
await openPromise;
ws.send(
JSON.stringify({
type: 'dispatch',
buildId,
job: createTestAndroidJob(),
initiatingUserId: '14367e1b-26fc-4c00-aedb-0629d78f8286',
metadata: {
trackingContext: {},
},
})
);
await successPromise;
const closePromise = helper.onClose();
ws.send(JSON.stringify({ type: 'close' }));
await closePromise;
expect(helper.onCloseCb).toHaveBeenCalled();
});
});
});
@@ -1,189 +0,0 @@
import { hostname } from 'os';
import { setTimeout as setTimeoutAsync } from 'timers/promises';
import WebSocket from 'ws';
import { WsHelper, unreachableCode } from './utils';
import { createTestAndroidJob } from './utils/jobs';
import logger from '../logger';
import BuildService from '../service';
import { cleanUpWorkingdir, prepareWorkingdir } from '../workingdir';
import startWsServer from '../ws';
const buildId = 'f38532aa-81a8-4db7-915f-6e7afe46e22f';
const MAX_BUILD_TIME_MS = 10 * 1000; // 10 sec
jest.setTimeout(MAX_BUILD_TIME_MS);
// Timeouts for hanging worker detection tests
const HANGING_CHECK_TIMEOUT_MS = 50; // Must match the literal in jest.mock('../service')
const WAIT_FOR_HANGING_CHECK_MS = HANGING_CHECK_TIMEOUT_MS + 50; // Wait slightly longer than check timeout
// Mock @expo/build-tools at the library boundary
jest.mock('@expo/build-tools', () => {
const actual = jest.requireActual('@expo/build-tools');
return {
...actual,
Builders: {
androidBuilder: jest.fn(async () => ({
APPLICATION_ARCHIVE: 'test-android.aab',
})),
iosBuilder: jest.fn(async () => ({
APPLICATION_ARCHIVE: 'test-ios.ipa',
})),
},
};
});
jest.mock('../upload');
jest.mock('../config', () => {
const config = jest.requireActual('../config').default;
return {
...config,
buildId: 'f38532aa-81a8-4db7-915f-6e7afe46e22f',
};
});
let spyReportHangingWorker: jest.SpyInstance;
let partialMockService: BuildService;
jest.mock('../service', () => {
return function () {
const BuildService = new (jest.requireActual('../service').default)();
// Use literal value here because jest.mock is hoisted above const declarations
BuildService.getHangingWorkerCheckTimeoutMs = jest.fn(() => 50);
spyReportHangingWorker = jest.spyOn(BuildService, 'reportHangingWorker');
partialMockService = BuildService;
return BuildService;
};
});
function createDispatchMessage(): string {
return JSON.stringify({
type: 'dispatch',
buildId,
job: createTestAndroidJob(),
initiatingUserId: '14367e1b-26fc-4c00-aedb-0629d78f8286',
metadata: {
trackingContext: {},
},
});
}
async function setUpTestAsync(
port: number,
onMessage: (cb: (message: any) => void) => void
): Promise<[WebSocket, WsHelper, NodeJS.Timeout]> {
const ws = new WebSocket(`ws://localhost:${port}?expo_vm_name=${hostname()}`);
const helper = new WsHelper(ws);
const openPromise = helper.onOpen();
helper.onMessage(onMessage);
await openPromise;
const messageTimeout = setTimeout(() => {
unreachableCode('build timeout');
}, MAX_BUILD_TIME_MS);
return [ws, helper, messageTimeout];
}
describe('sending sentry report on hanging worker', () => {
let port: number;
let server: WebSocket.Server;
beforeEach(async () => {
await prepareWorkingdir();
port = Math.floor(Math.random() * 10000 + 10000);
server = startWsServer(port);
logger.debug(`Listening on port ${port}`);
});
afterEach(async () => {
await new Promise<void>((resolve, reject) => {
server.close(err => (err ? reject(err) : resolve()));
});
// Wait for any pending checkForHangingWorker timeouts to complete
await setTimeoutAsync(HANGING_CHECK_TIMEOUT_MS + 10);
// Use clearAllMocks instead of resetAllMocks to preserve mock implementations
jest.clearAllMocks();
});
afterAll(async () => {
await cleanUpWorkingdir();
});
// Helper to create fresh promise and message handler for each test
function createSuccessHandler(): {
successPromise: Promise<void>;
onMessage: jest.Mock;
} {
let successPromiseResolve: () => void;
const successPromise = new Promise<void>(res => {
successPromiseResolve = res;
});
const onMessage = jest.fn((message: any) => {
logger.debug('message received');
try {
expect(message).toBeTruthy();
expect(message.type).toBe('success');
} finally {
successPromiseResolve();
}
});
return { successPromise, onMessage };
}
describe('build successfully and send buildSuccess message', () => {
describe('close message received from launcher', () => {
it('should terminate worker without notifying sentry', async () => {
const { successPromise, onMessage } = createSuccessHandler();
const [ws, helper, messageTimeout] = await setUpTestAsync(port, onMessage);
ws.send(createDispatchMessage());
await successPromise;
clearTimeout(messageTimeout);
const closePromise = helper.onClose();
ws.send(JSON.stringify({ type: 'close' }));
await closePromise;
await setTimeoutAsync(WAIT_FOR_HANGING_CHECK_MS);
expect(spyReportHangingWorker).not.toHaveBeenCalled();
});
});
describe('close message received from launcher, but something went wrong and shouldCloseWorker is not true', () => {
it('should log message and notify sentry about possibly hanging', async () => {
const { successPromise, onMessage } = createSuccessHandler();
const [ws, helper, messageTimeout] = await setUpTestAsync(port, onMessage);
ws.send(createDispatchMessage());
await successPromise;
clearTimeout(messageTimeout);
const actualCloseWorker = partialMockService.closeWorker;
partialMockService.closeWorker = jest.fn(() => {});
ws.send(JSON.stringify({ type: 'close' }));
await setTimeoutAsync(WAIT_FOR_HANGING_CHECK_MS);
expect(spyReportHangingWorker).toHaveBeenCalled();
partialMockService.closeWorker = actualCloseWorker;
const closePromise = helper.onClose();
ws.send(JSON.stringify({ type: 'close' }));
await closePromise;
});
});
describe('no close message received from launcher in specified time', () => {
it('should log message and notify sentry about possibly hanging', async () => {
const { successPromise, onMessage } = createSuccessHandler();
const [ws, helper, messageTimeout] = await setUpTestAsync(port, onMessage);
ws.send(createDispatchMessage());
await successPromise;
clearTimeout(messageTimeout);
await setTimeoutAsync(WAIT_FOR_HANGING_CHECK_MS);
expect(spyReportHangingWorker).toHaveBeenCalled();
const closePromise = helper.onClose();
ws.send(JSON.stringify({ type: 'close' }));
await closePromise;
});
});
});
});
@@ -1,321 +0,0 @@
import { Builders } from '@expo/build-tools';
import { hostname } from 'os';
import { setTimeout as setTimeoutAsync } from 'timers/promises';
import WebSocket from 'ws';
import { WsHelper, unreachableCode } from './utils';
import { createTestAndroidJob } from './utils/jobs';
import logger from '../logger';
import { cleanUpWorkingdir, prepareWorkingdir } from '../workingdir';
import startWsServer from '../ws';
const buildId = 'e9b99e52-fb74-4927-be63-33d7447ddfd4';
jest.setTimeout(30 * 1000);
// Mock @expo/build-tools at the library boundary
jest.mock('@expo/build-tools', () => {
const actual = jest.requireActual('@expo/build-tools');
return {
...actual,
Builders: {
androidBuilder: jest.fn(async () => {
logger.debug('mocked androidBuilder');
await setTimeoutAsync(1000);
return {
APPLICATION_ARCHIVE: `application-${buildId}`,
};
}),
iosBuilder: jest.fn(async () => {
logger.debug('mocked iosBuilder');
await setTimeoutAsync(1000);
return {
APPLICATION_ARCHIVE: `application-${buildId}`,
};
}),
},
};
});
jest.mock('../service', () => {
return function () {
const BuildService = new (jest.requireActual('../service').default)();
BuildService.checkForHangingWorker = jest.fn(async () => {});
return BuildService;
};
});
jest.mock('../config', () => {
const config = jest.requireActual('../config').default;
return {
...config,
buildId: 'e9b99e52-fb74-4927-be63-33d7447ddfd4',
};
});
jest.mock('../upload', () => {
return {
...jest.requireActual('../upload'),
uploadApplicationArchiveAsync: async () => {
return `application-${buildId}`;
},
};
});
describe('State sync mechanism', () => {
let port: number;
let server: WebSocket.Server;
let messageTimeout: NodeJS.Timeout;
beforeEach(async () => {
await prepareWorkingdir();
port = Math.floor(Math.random() * 10000 + 10000);
server = startWsServer(port);
logger.debug(`Listening on port ${port}`);
});
afterEach(async () => {
await new Promise<void>((resolve, reject) => {
server.close(err => (err ? reject(err) : resolve()));
});
});
afterAll(async () => {
await cleanUpWorkingdir();
});
describe('query new worker', () => {
it("should return 'new' state", async () => {
const ws = new WebSocket(`ws://localhost:${port}?expo_vm_name=${hostname()}`);
const helper = new WsHelper(ws);
let stateResponsePromiseResolve: () => void;
const stateResponsePromise = new Promise<void>(res => {
stateResponsePromiseResolve = res;
});
const onMessage = jest.fn((message: any) => {
logger.debug('message received');
clearTimeout(messageTimeout);
try {
expect(message).toBeTruthy();
expect(message.type).toBe('state-response');
expect(message.status).toBe('new');
} catch (err) {
throw err;
} finally {
stateResponsePromiseResolve();
}
});
const openPromise = helper.onOpen();
helper.onMessage(onMessage);
await openPromise;
messageTimeout = setTimeout(() => {
unreachableCode('state-response timeout');
}, 3000);
ws.send(JSON.stringify({ type: 'state-query', buildId }));
await stateResponsePromise;
expect(helper.onErrorCb).not.toHaveBeenCalled();
expect(helper.onOpenCb).toHaveBeenCalled();
expect(helper.onMessageCb).toHaveBeenCalled();
expect(helper.onCloseCb).not.toHaveBeenCalled();
ws.close();
clearTimeout(messageTimeout);
});
});
describe('query worker after dispatch', () => {
it("should return 'in-progress' state", async () => {
const dispatchWS = new WebSocket(`ws://localhost:${port}?expo_vm_name=${hostname()}`);
const dispatchHelper = new WsHelper(dispatchWS);
await dispatchHelper.onOpen();
dispatchWS.send(
JSON.stringify({
type: 'dispatch',
buildId,
job: createTestAndroidJob(),
initiatingUserId: '14367e1b-26fc-4c00-aedb-0629d78f8286',
metadata: {
trackingContext: {},
},
})
);
dispatchWS.close();
await dispatchHelper.onClose();
const ws = new WebSocket(`ws://localhost:${port}?expo_vm_name=${hostname()}`);
const helper = new WsHelper(ws);
let stateResponsePromiseResolve: () => void;
const stateResponsePromise = new Promise<void>(res => {
stateResponsePromiseResolve = res;
});
const onMessage = jest.fn((message: any) => {
logger.debug('message received');
clearTimeout(messageTimeout);
try {
expect(message).toBeTruthy();
expect(message.type).toBe('state-response');
expect(message.status).toBe('in-progress');
} catch (err) {
throw err;
} finally {
stateResponsePromiseResolve();
}
});
const openPromise = helper.onOpen();
helper.onMessage(onMessage);
await openPromise;
messageTimeout = setTimeout(() => {
unreachableCode('state-response timeout');
}, 3000);
ws.send(
JSON.stringify({ type: 'state-query', buildId: 'e9b99e52-fb74-4927-be63-33d7447ddfd4' })
);
logger.debug('sent state-query');
await stateResponsePromise;
expect(helper.onErrorCb).not.toHaveBeenCalled();
expect(helper.onOpenCb).toHaveBeenCalled();
expect(helper.onMessageCb).toHaveBeenCalled();
expect(helper.onCloseCb).not.toHaveBeenCalled();
ws.close();
clearTimeout(messageTimeout);
});
});
describe('query worker after build', () => {
it("should return 'success' state", async () => {
const dispatchWS = new WebSocket(`ws://localhost:${port}?expo_vm_name=${hostname()}`);
const dispatchHelper = new WsHelper(dispatchWS);
await dispatchHelper.onOpen();
dispatchWS.send(
JSON.stringify({
type: 'dispatch',
buildId,
job: createTestAndroidJob(),
initiatingUserId: '14367e1b-26fc-4c00-aedb-0629d78f8286',
metadata: {
trackingContext: {},
},
})
);
dispatchWS.close();
await dispatchHelper.onClose();
await setTimeoutAsync(2000);
logger.debug('Establising new connection to worker');
const ws = new WebSocket(`ws://localhost:${port}?expo_vm_name=${hostname()}`);
const helper = new WsHelper(ws);
let stateResponsePromiseResolve: () => void;
const stateResponsePromise = new Promise<void>(res => {
stateResponsePromiseResolve = res;
});
const onMessage = jest.fn((message: any) => {
logger.debug('message received');
clearTimeout(messageTimeout);
try {
expect(message).toBeTruthy();
expect(message.applicationArchiveName).toBe(`application-${buildId}`);
expect(message.type).toBe('state-response');
expect(message.status).toBe('success');
} catch (err) {
throw err;
} finally {
stateResponsePromiseResolve();
}
});
const openPromise = helper.onOpen();
helper.onMessage(onMessage);
await openPromise;
messageTimeout = setTimeout(() => {
unreachableCode('state-response timeout');
}, 3000);
ws.send(JSON.stringify({ type: 'state-query', buildId }));
logger.debug('sent state-query');
await stateResponsePromise;
expect(helper.onErrorCb).not.toHaveBeenCalled();
expect(helper.onOpenCb).toHaveBeenCalled();
expect(helper.onMessageCb).toHaveBeenCalled();
expect(helper.onCloseCb).not.toHaveBeenCalled();
ws.close();
clearTimeout(messageTimeout);
});
});
describe('query worker after error', () => {
it("should return 'error' state with artifacts from error", async () => {
jest.mocked(Builders.androidBuilder).mockImplementation(async () => {
const error = new Error();
(error as any).artifacts = {
APPLICATION_ARCHIVE: `application-${buildId}`,
};
throw error;
});
const dispatchWS = new WebSocket(`ws://localhost:${port}?expo_vm_name=${hostname()}`);
const dispatchHelper = new WsHelper(dispatchWS);
await dispatchHelper.onOpen();
dispatchWS.send(
JSON.stringify({
type: 'dispatch',
buildId,
job: createTestAndroidJob(),
initiatingUserId: '14367e1b-26fc-4c00-aedb-0629d78f8286',
metadata: {
trackingContext: {},
},
})
);
dispatchWS.close();
await dispatchHelper.onClose();
logger.debug('Establising new connection to worker');
const ws = new WebSocket(`ws://localhost:${port}?expo_vm_name=${hostname()}`);
const helper = new WsHelper(ws);
let stateResponsePromiseResolve: () => void;
const stateResponsePromise = new Promise<void>(res => {
stateResponsePromiseResolve = res;
});
const onMessage = jest.fn((message: any) => {
logger.debug('message received');
clearTimeout(messageTimeout);
try {
expect(message).toBeTruthy();
expect(message.applicationArchiveName).toBe(`application-${buildId}`);
expect(message.type).toBe('state-response');
expect(message.status).toBe('error');
} catch (err) {
throw err;
} finally {
stateResponsePromiseResolve();
}
});
const openPromise = helper.onOpen();
helper.onMessage(onMessage);
await openPromise;
messageTimeout = setTimeout(() => {
unreachableCode('state-response timeout');
}, 3000);
ws.send(JSON.stringify({ type: 'state-query', buildId }));
logger.debug('sent state-query');
await stateResponsePromise;
expect(helper.onErrorCb).not.toHaveBeenCalled();
expect(helper.onOpenCb).toHaveBeenCalled();
expect(helper.onMessageCb).toHaveBeenCalled();
expect(helper.onCloseCb).not.toHaveBeenCalled();
ws.close();
clearTimeout(messageTimeout);
});
});
});
@@ -1,59 +0,0 @@
import WebSocket from 'ws';
import logger from '../logger';
export function unreachableCode(msg: string): void {
expect(msg).toBeFalsy();
}
export class WsHelper {
public ws: WebSocket;
public onOpenCb?: () => void = jest.fn();
public onCloseCb?: () => void = jest.fn();
public onMessageCb?: (msg: any) => void = jest.fn();
public onErrorCb = jest.fn();
constructor(ws: WebSocket) {
this.ws = ws;
this.ws.on('error', this.onErrorCb);
}
public onOpen(): Promise<void> {
return new Promise((res, rej) => {
const openTimeout = setTimeout(() => {
rej(new Error('onOpen rejected'));
}, 3000);
this.onOpenCb = jest.fn(() => {
logger.debug('ws open');
res();
clearTimeout(openTimeout);
});
this.ws.on('open', this.onOpenCb);
});
}
public onClose(): Promise<void> {
return new Promise((res, rej) => {
const closeTimeout = setTimeout(() => {
rej(new Error('onClose rejected'));
}, 3000);
this.onCloseCb = jest.fn(() => {
logger.debug('ws close');
res();
clearTimeout(closeTimeout);
});
this.ws.on('close', this.onCloseCb);
});
}
public onMessage(cb: (message: any) => void): void {
this.onMessageCb = jest.fn(raw => {
logger.debug({ raw }, 'ws message');
const parsed = JSON.parse(raw);
if (parsed.type !== 'build-phase-stats') {
cb(parsed);
}
});
this.ws.on('message', this.onMessageCb);
}
}
@@ -1,88 +0,0 @@
import {
Android,
ArchiveSourceType,
BuildMode,
BuildTrigger,
Ios,
Platform,
Workflow,
} from '@expo/eas-build-job';
import { randomUUID } from 'crypto';
const androidCredentials: Android.Keystore = {
dataBase64: 'Zm9v',
keystorePassword: 'pass1',
keyAlias: 'alias',
keyPassword: 'pass2',
};
export function createTestAndroidJob({
projectArchive,
}: { projectArchive?: Android.Job['projectArchive'] } = {}): Android.Job {
return {
mode: BuildMode.BUILD,
platform: Platform.ANDROID,
triggeredBy: BuildTrigger.EAS_CLI,
type: Workflow.GENERIC,
projectArchive: projectArchive ?? {
type: ArchiveSourceType.URL,
url: 'https://turtle-v2-test-fixtures.s3.us-east-2.amazonaws.com/project.tar.gz',
},
gradleCommand: ':app:bundleRelease',
applicationArchivePath: 'android/app/build/outputs/bundle/release/app.aab',
projectRootDirectory: '.',
cache: {
disabled: false,
clear: false,
paths: [],
},
secrets: {
buildCredentials: {
keystore: androidCredentials,
},
},
initiatingUserId: randomUUID(),
appId: randomUUID(),
};
}
const iosCredentials: Ios.BuildCredentials = {
testapp: {
provisioningProfileBase64: 'Zm9v',
distributionCertificate: {
dataBase64: 'Zm9v',
password: 'pass',
},
},
};
export function createTestIosJob({
buildCredentials = iosCredentials,
}: {
buildCredentials?: Ios.BuildCredentials;
} = {}): Ios.Job {
return {
mode: BuildMode.BUILD,
platform: Platform.IOS,
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',
},
scheme: 'turtlebareproj',
buildConfiguration: 'Release',
applicationArchivePath: './ios/build/*.ipa',
projectRootDirectory: '.',
cache: {
disabled: false,
clear: false,
paths: [],
},
secrets: {
buildCredentials,
},
initiatingUserId: randomUUID(),
appId: randomUUID(),
};
}
-6
View File
@@ -1,6 +0,0 @@
import { fs } from 'memfs';
const fsRealpath = fs.realpath;
(fsRealpath as any).native = fsRealpath;
module.exports = { ...fs, realpath: fsRealpath };
@@ -1,3 +0,0 @@
import { fs } from 'memfs';
module.exports = fs.promises;
-11
View File
@@ -1,11 +0,0 @@
export async function uploadApplicationArchiveAsync(): Promise<string> {
return 'https://expo.dev/artifacts/application.tar.gz';
}
export async function uploadBuildArtifactsAsync(): Promise<string> {
return 'https://expo.dev/artifacts/lol.tar.gz';
}
export async function uploadWithAnalyticsAsync(fnAsync: () => Promise<string>): Promise<string> {
return await fnAsync();
}
@@ -1,174 +0,0 @@
import { GCS } from '@expo/build-tools';
import downloadFile from '@expo/downloader';
import { Cache, Platform } from '@expo/eas-build-job';
import { randomUUID } from 'crypto';
import fs from 'fs-extra';
import { vol } from 'memfs';
import { Readable } from 'stream';
import { GCSCacheManager } from '../CacheManager';
jest.mock('fs');
jest.mock('fs/promises');
jest.mock('@expo/downloader', () => {
return jest.fn();
});
jest.mock('../config', () => ({
buildCache: {
gcsSignedUploadUrlForBuildCache: 'fakeurl',
gcsSignedBuildCacheDownloadUrl: 'fakeUrl',
},
}));
jest.mock('../sentry', () => ({
handleError: jest.fn(),
}));
async function saveAndRestoreCacheAsync(
setupBeforeSave: () => Promise<void> | void,
setupBeforeRestore: () => Promise<void> | void,
cacheConfig: Partial<Cache>
): Promise<void> {
await setupBeforeSave();
const manager = new GCSCacheManager();
const mockCtx = {
logger: { info: jest.fn(), error: console.error },
workingdir: '/projectRoot',
buildDirectory: '/projectRoot/build',
job: {
platform: Platform.ANDROID,
projectRootDirectory: '.',
cache: cacheConfig,
},
} as any;
let tarReadStream: Readable;
jest.mocked(GCS.uploadWithSignedUrl).mockImplementation(async ({ srcGeneratorAsync }) => {
const result = await srcGeneratorAsync();
tarReadStream = result;
return randomUUID();
});
await manager.saveCache(mockCtx);
const chunks = [];
for await (const chunk of tarReadStream!) {
chunks.push(chunk);
}
const tarContent = Buffer.concat(chunks);
vol.reset();
await setupBeforeRestore();
jest.mocked(downloadFile).mockImplementation(async (_url, archivePath: string) => {
await fs.mkdirp(mockCtx.workingdir);
await fs.writeFile(archivePath, tarContent);
});
await manager.restoreCache(mockCtx);
}
describe(GCSCacheManager, () => {
beforeEach(() => {
vol.reset();
});
afterEach(() => {
jest.mocked(GCS.uploadWithSignedUrl).mockReset();
jest.mocked(downloadFile).mockReset();
});
test('save and restore for a single file', async () => {
await saveAndRestoreCacheAsync(
() => {
vol.fromJSON({
'/projectRoot/build/index.ts': 'index.ts',
});
},
() => {},
{
paths: ['index.ts'],
}
);
expect(vol.toJSON()).toStrictEqual({
'/projectRoot/build/index.ts': 'index.ts',
'/projectRoot/cache-restore.tar.gz': expect.anything(),
});
});
test('save and restore for a single directory', async () => {
await saveAndRestoreCacheAsync(
() => {
vol.fromJSON({
'/projectRoot/build/src/index.ts': 'index.ts',
'/projectRoot/build/src/main.ts': 'index.ts',
});
},
() => {},
{
paths: ['src'],
}
);
expect(vol.toJSON()).toStrictEqual({
'/projectRoot/build/src/index.ts': 'index.ts',
'/projectRoot/build/src/main.ts': 'index.ts',
'/projectRoot/cache-restore.tar.gz': expect.anything(),
});
});
test('save and restore for a single directory outside of the project structure', async () => {
await saveAndRestoreCacheAsync(
() => {
vol.fromJSON({
'/projectRoot/build/src/index.ts': 'index.ts',
'/outsideProjectRoot/build/src/index.ts': 'index.ts',
});
},
() => {},
{
paths: ['/outsideProjectRoot/build'],
}
);
expect(vol.toJSON()).toStrictEqual({
'/outsideProjectRoot/build/src/index.ts': 'index.ts',
'/projectRoot/cache-restore.tar.gz': expect.anything(),
});
});
test('that save and restore does not override existing files', async () => {
await saveAndRestoreCacheAsync(
() => {
vol.fromJSON({
'/projectRoot/build/src/index.ts': 'index.ts',
'/projectRoot/build/src/main': 'index.ts',
});
},
() => {
vol.fromJSON({
'/projectRoot/build/src/index.ts': 'index2.ts',
});
},
{
paths: ['src'],
}
);
expect(vol.toJSON()).toStrictEqual({
'/projectRoot/build/src/index.ts': 'index2.ts',
'/projectRoot/build/src/main': 'index.ts',
'/projectRoot/cache-restore.tar.gz': expect.anything(),
});
});
test('save and restore for multiple locations', async () => {
await saveAndRestoreCacheAsync(
() => {
vol.fromJSON({
'/projectRoot/build/src/index.ts': 'index.ts',
'/outsideProjectRoot/build/src/index.ts': 'index.ts',
});
},
() => {},
{
paths: ['src', '/outsideProjectRoot/build'],
}
);
expect(vol.toJSON()).toStrictEqual({
'/outsideProjectRoot/build/src/index.ts': 'index.ts',
'/projectRoot/build/src/index.ts': 'index.ts',
'/projectRoot/cache-restore.tar.gz': expect.anything(),
});
});
});
@@ -1,39 +0,0 @@
import { Server } from 'http';
import request from 'supertest';
import { getWorkerVmMetrics } from '../metrics';
import { startServer } from '../metricsServer';
jest.mock('../metrics');
jest.mock('../logger');
describe('metricsServer', () => {
let server: Server;
beforeAll(() => {
server = startServer();
});
afterAll(done => {
server.close(done);
});
it('responds with metrics', async () => {
const mockMetrics = {
ram: { avaliableGb: 8, usedGb: 4, usagePercentage: 50 },
cpu: { usagePercentage: 10 },
disc: { avaliableGb: 100, usedGb: 50, usagePercentage: 50 },
};
jest.mocked(getWorkerVmMetrics).mockResolvedValue(mockMetrics);
const response = await request(server).get('/metrics');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockMetrics);
});
it('responds with 404 for unknown routes', async () => {
const response = await request(server).get('/unknown');
expect(response.status).toBe(404);
});
});
@@ -1,164 +0,0 @@
// @ts-nocheck
import { BuildContext } from '@expo/build-tools';
import { Android, Ios, Job } from '@expo/eas-build-job';
import spawn, { SpawnResult } from '@expo/turtle-spawn';
import { pathExists } from 'fs-extra';
import { prepareRuntimeEnvironment } from '../runtimeEnvironment';
jest.mock('fs-extra');
jest.mock('@expo/turtle-spawn');
const spawnResult: SpawnResult = {
output: ['stdout'],
status: 0,
signal: null,
stdout: '',
stderr: '',
};
const ctx: BuildContext<Job> = {
env: process.env,
logger: {
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
child: jest.fn(),
},
job: {} as Job,
};
const builderConfig: Ios.BuilderEnvironment | Android.BuilderEnvironment = {};
describe('prepareRuntimeEnvironment', () => {
beforeEach(() => {
jest.mocked(spawn).mockReset();
});
describe('installNode', () => {
describe('prepareRuntimeEnvironment', () => {
beforeEach(() => {
jest.mocked(pathExists).mockResolvedValue(true);
jest.mocked(spawn).mockImplementation((cmd, _args, opts) => {
if (cmd === 'bash') {
return Promise.resolve({
...spawnResult,
stdout:
'Downloading and installing node v16.20.1...\nNow using node v16.20.1 (npm v8.19.4)\n',
});
}
return Promise.resolve(spawnResult);
});
jest.mocked(spawn).mockResolvedValue(spawnResult);
});
it('should install the specified version of Node.js', async () => {
const version = '16.20.1';
builderConfig.node = version;
await prepareRuntimeEnvironment(ctx, builderConfig, false);
expect(spawn).toHaveBeenCalledWith(
'bash',
expect.arrayContaining([expect.stringContaining(`nvm install ${version}`)]),
expect.anything()
);
});
it('handles non-semver version of node', async () => {
const version = 'v16';
builderConfig.node = version;
await prepareRuntimeEnvironment(ctx, builderConfig, false);
expect(spawn).toHaveBeenCalledWith(
'bash',
expect.arrayContaining([expect.stringContaining(`nvm install ${version}`)]),
expect.anything()
);
});
it('installs Bun when a specified version is different from installed version', async () => {
let isFirstTimeCheckingBunVersion = true;
jest.mocked(spawn).mockImplementation((cmd, _args, _opts) => {
if (cmd === 'bun') {
const stdout = isFirstTimeCheckingBunVersion ? '1.0.0' : '2.0.0';
isFirstTimeCheckingBunVersion = false;
return Promise.resolve({
...spawnResult,
stdout,
});
}
return Promise.resolve(spawnResult);
});
await prepareRuntimeEnvironment(ctx, { bun: '2.0.0' }, false);
expect(spawn).toHaveBeenCalledWith('yarn', ['--version'], { stdio: 'pipe' });
expect(spawn).toHaveBeenCalledWith('pnpm', ['--version'], { stdio: 'pipe' });
expect(spawn).toHaveBeenCalledWith('bun', ['--version'], { stdio: 'pipe' });
expect(spawn).toHaveBeenCalledWith(
'curl',
['-fsSL', 'https://bun.sh/install', '-o', expect.anything()],
expect.anything()
);
expect(spawn).toHaveBeenCalledWith(
'bash',
[expect.anything(), 'bun-v2.0.0'],
expect.anything()
);
expect(spawn).toHaveBeenCalledWith('rm', [expect.anything()], expect.anything());
expect(spawn).toHaveBeenCalledWith('bun', ['--version'], expect.anything());
});
it('does not install Bun when a specified version is the same as the installed version', async () => {
jest.mocked(spawn).mockImplementation((cmd, _args, _opts) => {
if (cmd === 'bun') {
return Promise.resolve({
...spawnResult,
stdout: '2.0.0',
});
}
return Promise.resolve(spawnResult);
});
await prepareRuntimeEnvironment(ctx, { bun: '2.0.0' }, false);
expect(spawn).toHaveBeenCalledWith('yarn', ['--version'], { stdio: 'pipe' });
expect(spawn).toHaveBeenCalledWith('pnpm', ['--version'], { stdio: 'pipe' });
expect(spawn).toHaveBeenCalledWith('bun', ['--version'], { stdio: 'pipe' });
expect(spawn).not.toHaveBeenCalledWith(
'curl',
['-fsSL', 'https://bun.sh/install', '-o', expect.anything()],
expect.anything()
);
});
});
it('should throw an error if installation fails', async () => {
const version = 'invalid-version';
builderConfig.node = version;
jest.mocked(spawn).mockImplementation((cmd, _args, _opts) => {
if (cmd === 'bash') {
return Promise.resolve({
...spawnResult,
output: [
'',
"Version 'invalid-version' not found - try `nvm ls-remote` to browse available versions.\n",
],
stdout: '',
stderr:
"Version 'invalid-version' not found - try `nvm ls-remote` to browse available versions.\n",
status: 3,
});
}
return Promise.resolve(spawnResult);
});
jest.mocked(pathExists).mockResolvedValue(false);
await expect(prepareRuntimeEnvironment(ctx, builderConfig, false)).rejects.toThrow(
'Failed to install Node.js'
);
});
});
});
-611
View File
@@ -1,611 +0,0 @@
import { BuildContext, GCS } from '@expo/build-tools';
import { Job } from '@expo/eas-build-job';
import { randomBytes, randomUUID } from 'crypto';
import { vol } from 'memfs';
import { Response } from 'node-fetch';
import config from '../config';
import {
uploadApplicationArchiveAsync,
uploadBuildArtifactsAsync,
uploadWorkflowArtifactAsync,
} from '../upload';
import { turtleFetch } from '../utils/turtleFetch';
jest.mock('fs');
jest.mock('fs/promises');
jest.mock('../utils/turtleFetch', () => ({
...jest.requireActual('../utils/turtleFetch'),
turtleFetch: jest.fn(),
}));
const turtleFetchMock = jest.mocked(turtleFetch);
jest.mock('../config', () => ({
loggers: {
base: {
name: 'test',
},
},
sentry: {},
rudderstack: {},
wwwApiV2BaseUrl: 'https://api.expo.test/v2/',
}));
describe(uploadApplicationArchiveAsync.name, () => {
it('should throw if configuration is missing', async () => {
// @ts-expect-error
const ctx: BuildContext<Job> = {
env: {},
job: {
secrets: {},
} as Job,
};
vol.fromJSON({
'./artifact.ipa': JSON.stringify(randomBytes(20)),
});
await expect(
uploadApplicationArchiveAsync(ctx, {
artifactPaths: ['./artifact.ipa'],
buildId: 'buildId',
logger: ctx.logger,
})
).rejects.toThrow('env variables are not set');
ctx.env.__WORKFLOW_JOB_ID = randomUUID();
await expect(
uploadApplicationArchiveAsync(ctx, {
artifactPaths: ['./artifact.ipa'],
buildId: 'buildId',
logger: ctx.logger,
})
).rejects.toThrow('robot access token is not set');
});
it('should upload the application archive', async () => {
vol.fromJSON({
'./artifact.ipa': JSON.stringify(randomBytes(20)),
});
const workflowJobId = randomUUID();
// @ts-expect-error
const ctx: BuildContext<Job> = {
env: {
__WORKFLOW_JOB_ID: workflowJobId,
},
job: {
secrets: {
robotAccessToken: 'fake-token',
},
} as Job,
};
const bucketKey = `test/${randomUUID()}/artifact.ipa`;
const uploadUrl = `https://upload.url/${randomUUID()}`;
const testSignedUploadAuthorization = randomUUID();
turtleFetchMock.mockImplementation(async url => {
if (url === `https://api.expo.test/v2/workflows/${workflowJobId}/artifacts/`) {
return {
ok: true,
status: 200,
} as Response;
} else if (url === `https://api.expo.test/v2/workflows/${workflowJobId}/upload-sessions/`) {
return {
ok: true,
status: 200,
json: async () => ({
data: {
bucketKey,
url: uploadUrl,
headers: {
authorization: testSignedUploadAuthorization,
},
storageType: 'GCS',
},
}),
} as Response;
} else {
return {
ok: false,
status: 404,
} as Response;
}
});
await uploadApplicationArchiveAsync(ctx, {
artifactPaths: ['./artifact.ipa'],
buildId: randomUUID(),
logger: ctx.logger,
});
expect(GCS.uploadWithSignedUrl).toHaveBeenCalledWith(
expect.objectContaining({
signedUrl: {
url: uploadUrl,
headers: {
authorization: testSignedUploadAuthorization,
},
},
})
);
expect(turtleFetchMock).toHaveBeenCalledTimes(2);
expect(turtleFetchMock).toHaveBeenNthCalledWith(
1,
`https://api.expo.test/v2/workflows/${workflowJobId}/upload-sessions/`,
'POST',
expect.objectContaining({
json: { filename: expect.any(String), name: expect.any(String), size: expect.any(Number) },
headers: {
Authorization: `Bearer ${ctx.job.secrets!.robotAccessToken}`,
},
})
);
expect(turtleFetchMock).toHaveBeenNthCalledWith(
2,
`https://api.expo.test/v2/workflows/${workflowJobId}/artifacts/`,
'POST',
expect.objectContaining({
json: { source: { bucketKey, type: 'GCS' }, type: 'applicationArchive' },
headers: {
Authorization: `Bearer ${ctx.job.secrets!.robotAccessToken}`,
},
})
);
});
describe('with signed upload url provided via config', () => {
beforeAll(() => {
config.gcsSignedUploadUrlForApplicationArchive = {
url: 'https://upload.url/from-config',
headers: {
authorization: 'test-signed-upload-authorization',
},
};
});
afterAll(() => {
config.gcsSignedUploadUrlForApplicationArchive = null;
});
it('should fall back to signed upload URL if upload session fails', async () => {
vol.fromJSON({
'./artifact.ipa': JSON.stringify(randomBytes(20)),
});
const workflowJobId = randomUUID();
// @ts-expect-error
const ctx: BuildContext<Job> = {
env: {
__WORKFLOW_JOB_ID: workflowJobId,
},
job: {
secrets: {
robotAccessToken: 'fake-token',
},
} as Job,
};
turtleFetchMock.mockImplementation(async () => {
return {
json: async () => ({
data: {
bucketKey: 'test/bucketKey',
url: 'https://upload.url/from-www',
headers: {
authorization: 'test-signed-upload-authorization',
},
storageType: 'GCS',
},
}),
ok: true,
} as unknown as Response;
});
// GCS fails to upload to upload session from www.
jest.mocked(GCS.uploadWithSignedUrl).mockImplementation(async ({ signedUrl }) => {
if (signedUrl.url === 'https://upload.url/from-www') {
throw new Error('upload failed');
}
return signedUrl.url;
});
await uploadApplicationArchiveAsync(ctx, {
artifactPaths: ['./artifact.ipa'],
buildId: randomUUID(),
logger: ctx.logger,
});
expect(GCS.uploadWithSignedUrl).toHaveBeenCalledWith(
expect.objectContaining({
signedUrl: {
url: 'https://upload.url/from-config',
headers: {
authorization: 'test-signed-upload-authorization',
},
},
})
);
expect(turtleFetchMock).toHaveBeenCalledTimes(1);
});
});
});
describe(uploadBuildArtifactsAsync.name, () => {
it('should throw if configuration is missing', async () => {
// @ts-expect-error
const ctx: BuildContext<Job> = {
env: {},
job: {
secrets: {},
} as Job,
};
vol.fromJSON({
'./video.mp4': JSON.stringify(randomBytes(20)),
});
await expect(
uploadBuildArtifactsAsync(ctx, {
artifactPaths: ['./video.mp4'],
buildId: 'buildId',
logger: ctx.logger,
})
).rejects.toThrow('env variables are not set');
ctx.env.__WORKFLOW_JOB_ID = randomUUID();
await expect(
uploadBuildArtifactsAsync(ctx, {
artifactPaths: ['./video.mp4'],
buildId: 'buildId',
logger: ctx.logger,
})
).rejects.toThrow('robot access token is not set');
});
it('should upload the build artifacts', async () => {
vol.fromJSON({
'./video.mp4': JSON.stringify(randomBytes(20)),
});
const workflowJobId = randomUUID();
// @ts-expect-error
const ctx: BuildContext<Job> = {
env: {
__WORKFLOW_JOB_ID: workflowJobId,
},
job: {
secrets: {
robotAccessToken: 'fake-token',
},
} as Job,
};
const bucketKey = `test/${randomUUID()}/artifact.ipa`;
const uploadUrl = `https://upload.url/${randomUUID()}`;
const testSignedUploadAuthorization = randomUUID();
turtleFetchMock.mockImplementation(async url => {
if (url === `https://api.expo.test/v2/workflows/${workflowJobId}/artifacts/`) {
return {
ok: true,
status: 200,
} as Response;
} else if (url === `https://api.expo.test/v2/workflows/${workflowJobId}/upload-sessions/`) {
return {
ok: true,
status: 200,
json: async () => ({
data: {
bucketKey,
url: uploadUrl,
headers: {
authorization: testSignedUploadAuthorization,
},
storageType: 'GCS',
},
}),
} as Response;
} else {
return {
ok: false,
status: 404,
} as Response;
}
});
await uploadBuildArtifactsAsync(ctx, {
artifactPaths: ['./video.mp4'],
buildId: randomUUID(),
logger: ctx.logger,
});
expect(GCS.uploadWithSignedUrl).toHaveBeenCalledTimes(1);
expect(GCS.uploadWithSignedUrl).toHaveBeenCalledWith(
expect.objectContaining({
signedUrl: {
url: uploadUrl,
headers: {
authorization: testSignedUploadAuthorization,
},
},
})
);
expect(turtleFetchMock).toHaveBeenCalledTimes(2);
expect(turtleFetchMock).toHaveBeenNthCalledWith(
1,
`https://api.expo.test/v2/workflows/${workflowJobId}/upload-sessions/`,
'POST',
expect.objectContaining({
json: { filename: expect.any(String), name: expect.any(String), size: expect.any(Number) },
headers: {
Authorization: `Bearer ${ctx.job.secrets!.robotAccessToken}`,
},
})
);
expect(turtleFetchMock).toHaveBeenNthCalledWith(
2,
`https://api.expo.test/v2/workflows/${workflowJobId}/artifacts/`,
'POST',
expect.objectContaining({
json: { source: { bucketKey, type: 'GCS' }, type: 'buildArtifacts' },
headers: {
Authorization: `Bearer ${ctx.job.secrets!.robotAccessToken}`,
},
})
);
});
describe('with signed upload url provided via config', () => {
beforeAll(() => {
config.gcsSignedUploadUrlForBuildArtifacts = {
url: 'https://upload.url/from-config',
headers: {
authorization: 'test-signed-upload-authorization',
},
};
});
afterAll(() => {
config.gcsSignedUploadUrlForBuildArtifacts = null;
});
it('should fall back to signed upload URL if upload session fails', async () => {
vol.fromJSON({
'./video.mp4': JSON.stringify(randomBytes(20)),
});
const workflowJobId = randomUUID();
// @ts-expect-error
const ctx: BuildContext<Job> = {
env: {
__WORKFLOW_JOB_ID: workflowJobId,
},
job: {
secrets: {
robotAccessToken: 'fake-token',
},
} as Job,
};
turtleFetchMock.mockImplementation(async () => {
return {
json: async () => ({
data: {
bucketKey: 'test/bucketKey',
url: 'https://upload.url/from-www',
headers: {
authorization: 'test-signed-upload-authorization',
},
storageType: 'GCS',
},
}),
ok: true,
} as unknown as Response;
});
// GCS fails to upload to upload session from www.
jest.mocked(GCS.uploadWithSignedUrl).mockImplementation(async ({ signedUrl }) => {
if (signedUrl.url === 'https://upload.url/from-www') {
throw new Error('upload failed');
}
return signedUrl.url;
});
await uploadBuildArtifactsAsync(ctx, {
artifactPaths: ['./video.mp4'],
buildId: randomUUID(),
logger: ctx.logger,
});
expect(GCS.uploadWithSignedUrl).toHaveBeenCalledWith(
expect.objectContaining({
signedUrl: {
url: 'https://upload.url/from-config',
headers: {
authorization: 'test-signed-upload-authorization',
},
},
})
);
expect(turtleFetchMock).toHaveBeenCalledTimes(1);
});
});
});
describe('with signed upload url provided via www', () => {
beforeAll(() => {
config.gcsSignedUploadUrlForBuildArtifacts = null;
});
afterAll(() => {
config.gcsSignedUploadUrlForBuildArtifacts = null;
});
it('should use www signed upload url', async () => {
vol.fromJSON({
'./video.mp4': JSON.stringify(randomBytes(20)),
});
const buildId = randomUUID();
// @ts-expect-error
const ctx: BuildContext<Job> = {
env: {
EAS_BUILD_ID: buildId,
},
job: {
platform: 'ios',
secrets: {
robotAccessToken: 'fake-token',
},
} as Job,
};
const bucketKey = `test/${randomUUID()}/artifact.ipa`;
const uploadUrl = `https://upload.url/${randomUUID()}`;
const testSignedUploadAuthorization = randomUUID();
turtleFetchMock.mockImplementation(async url => {
if (url === `https://api.expo.test/v2/turtle-builds/${buildId}/artifacts/`) {
return {
ok: true,
status: 200,
} as Response;
} else if (url === `https://api.expo.test/v2/turtle-builds/${buildId}/upload-sessions/`) {
return {
ok: true,
status: 200,
json: async () => ({
data: {
bucketKey,
url: uploadUrl,
headers: {
authorization: testSignedUploadAuthorization,
},
storageType: 'R2',
},
}),
} as Response;
} else {
return {
ok: false,
status: 404,
} as Response;
}
});
await uploadBuildArtifactsAsync(ctx, {
artifactPaths: ['./video.mp4'],
buildId: randomUUID(),
logger: ctx.logger,
});
expect(GCS.uploadWithSignedUrl).toHaveBeenCalledWith(
expect.objectContaining({
signedUrl: {
url: uploadUrl,
headers: {
authorization: testSignedUploadAuthorization,
},
},
})
);
expect(turtleFetchMock).toHaveBeenCalledTimes(2);
expect(turtleFetchMock).toHaveBeenNthCalledWith(
1,
`https://api.expo.test/v2/turtle-builds/${buildId}/upload-sessions/`,
'POST',
expect.objectContaining({
json: {
filename: expect.any(String),
name: expect.any(String),
size: expect.any(Number),
},
headers: {
Authorization: `Bearer ${ctx.job.secrets!.robotAccessToken}`,
},
})
);
expect(turtleFetchMock).toHaveBeenNthCalledWith(
2,
`https://api.expo.test/v2/turtle-builds/${buildId}/artifacts/`,
'POST',
expect.objectContaining({
json: { source: { bucketKey, type: 'R2' }, type: 'buildArtifacts' },
headers: {
Authorization: `Bearer ${ctx.job.secrets!.robotAccessToken}`,
},
})
);
});
});
describe(uploadWorkflowArtifactAsync.name, () => {
it('should upload a workflow artifact', async () => {
vol.fromJSON({
'./video.mp4': JSON.stringify(randomBytes(20)),
});
const workflowJobId = randomUUID();
// @ts-expect-error
const ctx: BuildContext<Job> = {
env: {
__WORKFLOW_JOB_ID: workflowJobId,
},
job: {
secrets: {
robotAccessToken: 'fake-token',
},
} as Job,
};
const bucketKey = `test/${randomUUID()}/video.mp4`;
const uploadUrl = `https://upload.url/${randomUUID()}`;
const testSignedUploadAuthorization = randomUUID();
const expectedArtifactId = randomUUID();
turtleFetchMock.mockImplementation(async url => {
if (url === `https://api.expo.test/v2/workflows/${workflowJobId}/upload-sessions/`) {
return {
ok: true,
status: 200,
json: async () => ({
data: {
id: expectedArtifactId,
bucketKey,
url: uploadUrl,
headers: {
authorization: testSignedUploadAuthorization,
},
storageType: 'GCS',
},
}),
} as Response;
} else {
return {
ok: false,
status: 404,
} as Response;
}
});
const { artifactId } = await uploadWorkflowArtifactAsync(ctx, {
artifactPaths: ['./video.mp4'],
name: 'maestro-video',
logger: ctx.logger,
});
expect(artifactId).toBe(expectedArtifactId);
expect(GCS.uploadWithSignedUrl).toHaveBeenCalledTimes(1);
expect(GCS.uploadWithSignedUrl).toHaveBeenCalledWith(
expect.objectContaining({
signedUrl: {
url: uploadUrl,
headers: {
authorization: testSignedUploadAuthorization,
},
},
})
);
expect(turtleFetchMock).toHaveBeenCalledTimes(1);
expect(turtleFetchMock).toHaveBeenNthCalledWith(
1,
`https://api.expo.test/v2/workflows/${workflowJobId}/upload-sessions/`,
'POST',
expect.objectContaining({
json: { filename: 'video.mp4', name: 'maestro-video', size: expect.any(Number) },
headers: {
Authorization: `Bearer ${ctx.job.secrets!.robotAccessToken}`,
},
})
);
});
});
-130
View File
@@ -1,130 +0,0 @@
import { Artifacts, BuildContext, Builders, runGenericJobAsync } from '@expo/build-tools';
import {
Android,
BuildJob,
BuildMode,
BuildPhase,
BuildPhaseResult,
Generic,
Ios,
LogMarker,
Platform,
errors,
} from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import omit from 'lodash/omit';
import config from './config';
import { displayWorkerRuntimeInfo } from './displayRuntimeInfo';
import { Analytics, Event, logProjectDependenciesAsync } from './external/analytics';
import { prepareRuntimeEnvironment } from './runtimeEnvironment';
import { cleanUpWorkingdir } from './workingdir';
export async function build({
ctx,
buildId,
analytics,
}: {
ctx: BuildContext;
buildId: string;
analytics: Analytics;
}): Promise<Artifacts> {
const { job, logger } = ctx;
try {
analytics.logEvent(Event.WORKER_BUILD_START, {});
await ctx.runBuildPhase(
BuildPhase.SPIN_UP_BUILDER,
async () => {
displayWorkerRuntimeInfo(ctx);
ctx.logger.info(
{ job: omit(ctx.job, 'secrets', 'projectArchive') },
'Builder is ready, starting build'
);
},
{ doNotMarkStart: true }
);
await ctx.runBuildPhase(BuildPhase.INSTALL_CUSTOM_TOOLS, async () => {
if (ctx.job.builderEnvironment) {
await prepareRuntimeEnvironment(ctx, ctx.job.builderEnvironment);
}
});
let artifacts: Artifacts;
switch (job.platform) {
case Platform.ANDROID: {
artifacts = await Builders.androidBuilder(ctx as BuildContext<Android.Job>);
break;
}
case Platform.IOS: {
artifacts = await Builders.iosBuilder(ctx as BuildContext<Ios.Job>);
break;
}
case undefined: {
artifacts = {};
const buildCtx = ctx as BuildContext<Generic.Job>;
const { runResult } = await runGenericJobAsync(buildCtx, {
expoApiV2BaseUrl: config.wwwApiV2BaseUrl,
});
if (!runResult.ok) {
throw runResult.reason;
}
break;
}
}
if (ctx.job.platform) {
try {
await logProjectDependenciesAsync(ctx as BuildContext<BuildJob>, analytics, buildId);
} catch {
// do nothing
}
}
analytics.logEvent(Event.WORKER_BUILD_SUCCESS, {});
return artifacts;
} catch (err: any) {
if ('mode' in job && ![BuildMode.CUSTOM, BuildMode.REPACK].includes(job.mode)) {
logBuildError(logger, analytics, err);
}
throw err;
} finally {
if (config.env === 'development') {
await cleanUpWorkingdir();
}
await analytics.flushEventsAsync();
}
}
function logBuildError(logger: bunyan, analytics: Analytics, err: Error): void {
const l = logger.child({ phase: BuildPhase.FAIL_BUILD });
l.info({ marker: LogMarker.START_PHASE }, `Start phase: ${BuildPhase.FAIL_BUILD}`);
if (err instanceof errors.BuildError) {
analytics.logEvent(Event.WORKER_BUILD_FAIL, {
reason: err?.message,
error_code: err.errorCode,
build_phase: err.buildPhase,
});
if (err.errorCode !== errors.ErrorCode.UNKNOWN_ERROR) {
l.error(`Build failed: ${err.userFacingMessage}`);
} else {
l.error({ err: err.innerError ?? err.userFacingMessage }, `Build failed\n`);
}
} else {
// This can only happen when error is thrown outside of a build phase.
analytics.logEvent(Event.WORKER_BUILD_FAIL, {
reason: err?.message,
error_code: errors.ErrorCode.UNKNOWN_ERROR,
});
l.error({ err }, `Build failed\n`);
}
l.info(
{ marker: LogMarker.END_PHASE, result: BuildPhaseResult.FAIL },
`End phase: ${BuildPhase.FAIL_BUILD}`
);
}
-110
View File
@@ -1,110 +0,0 @@
import { GCS, GCSLoggerStream } from '@expo/build-tools';
import path from 'path';
import { Environment } from './constants';
import { ResourceClass, Worker } from './external/turtle';
import env from './utils/env';
type ReplaceUndefinedWithNull<T> = undefined extends T ? Exclude<T, undefined> | null : T;
function createBase64EnvTransformer<TFieldName extends string>(
field: TFieldName
): (
valueRaw: string
) => TFieldName extends keyof Worker.RuntimeConfig
? ReplaceUndefinedWithNull<Worker.RuntimeConfig[TFieldName]>
: null {
return (valueRaw: string) => {
const json = JSON.parse(Buffer.from(valueRaw, 'base64').toString('utf8'));
const value = json?.[field];
return value ?? null;
};
}
export default {
env: env<Environment>('ENVIRONMENT', { oneOf: Environment }),
port: env('PORT', { defaultValue: 3005, transform: Number }),
metricsServerPort: env('METRICS_SERVER_PORT', { defaultValue: 3007, transform: Number }),
workerDescriptionPath: env('WORKER_DESCRIPTION_PATH', {
defaultValue: '/usr/local/share/eas-build/worker-description',
}),
loggers: {
base: {
name: env('LOGGER_NAME', { defaultValue: 'turtle-worker' }),
uploadIntervalMs: env('LOGGER_INTERVAL_MS', { defaultValue: 10000, transform: Number }),
},
gcs: {
compressionMethod: GCSLoggerStream.CompressionMethod.BR,
signedUploadUrlForLogs: env<GCS.SignedUrl | null>('WORKER_RUNTIME_CONFIG_BASE64', {
transform: createBase64EnvTransformer('gcsSignedUploadUrlForLogs'),
defaultValue: null,
}),
signedUploadUrlForXcodeBuildLogs: env<GCS.SignedUrl | null>('WORKER_RUNTIME_CONFIG_BASE64', {
transform: createBase64EnvTransformer('gcsSignedUploadUrlForXcodeBuildLogs'),
defaultValue: null,
}),
},
},
buildCache: {
gcsSignedUploadUrlForBuildCache: env<GCS.SignedUrl | null>('WORKER_RUNTIME_CONFIG_BASE64', {
transform: createBase64EnvTransformer('gcsSignedUploadUrlForBuildCache'),
defaultValue: null,
}),
gcsSignedBuildCacheDownloadUrl: env<string | null>('WORKER_RUNTIME_CONFIG_BASE64', {
transform: createBase64EnvTransformer('gcsSignedBuildCacheDownloadUrl'),
defaultValue: null,
}),
},
gcsSignedUploadUrlForApplicationArchive: env<GCS.SignedUrl | null>(
'WORKER_RUNTIME_CONFIG_BASE64',
{
transform: createBase64EnvTransformer('gcsSignedUploadUrlForApplicationArchive'),
defaultValue: null,
}
),
gcsSignedUploadUrlForBuildArtifacts: env<GCS.SignedUrl | null>('WORKER_RUNTIME_CONFIG_BASE64', {
transform: createBase64EnvTransformer('gcsSignedUploadUrlForBuildArtifacts'),
defaultValue: null,
}),
workingdir: env('WORKINGDIR', { defaultValue: path.join(__dirname, '../workingdir') }),
sentry: {
dsn: env('SENTRY_DSN', { defaultValue: '' }),
},
rudderstack: {
dataPlaneURL: env<string | null>('RUDDERSTACK_DATA_PLANE_URL', { defaultValue: null }),
writeKey: env<string | null>('RUDDERSTACK_WRITE_KEY', { defaultValue: null }),
},
npmCacheUrl: env<string | null>('WORKER_RUNTIME_CONFIG_BASE64', {
transform: createBase64EnvTransformer('npmCacheUrl'),
defaultValue: null,
}),
nodeJsCacheUrl: env<string | null>('WORKER_RUNTIME_CONFIG_BASE64', {
transform: createBase64EnvTransformer('nodeJsCacheUrl'),
defaultValue: null,
}),
mavenCacheUrl: env<string | null>('WORKER_RUNTIME_CONFIG_BASE64', {
transform: createBase64EnvTransformer('mavenCacheUrl'),
defaultValue: null,
}),
cocoapodsCacheUrl: env<string | null>('WORKER_RUNTIME_CONFIG_BASE64', {
transform: createBase64EnvTransformer('cocoapodsCacheUrl'),
defaultValue: null,
}),
runMetricsServer: env<boolean | null>('WORKER_RUNTIME_CONFIG_BASE64', {
transform: createBase64EnvTransformer('runMetricsServer'),
defaultValue: null,
}),
resourceClass: env<ResourceClass | null>('WORKER_RUNTIME_CONFIG_BASE64', {
transform: createBase64EnvTransformer('resourceClass'),
defaultValue: null,
}),
buildId: env<string>('WORKER_RUNTIME_CONFIG_BASE64', {
transform: createBase64EnvTransformer('buildId'),
}),
wwwApiV2BaseUrl:
process.env.ENVIRONMENT === 'development'
? 'http://api.expo.test/v2/'
: process.env.ENVIRONMENT === 'staging'
? 'https://staging-api.expo.dev/v2/'
: 'https://api.expo.dev/v2/',
};
-6
View File
@@ -1,6 +0,0 @@
export enum Environment {
DEVELOPMENT = 'development',
STAGING = 'staging',
TEST = 'test',
PRODUCTION = 'production',
}
-94
View File
@@ -1,94 +0,0 @@
import { BuildContext, BuildContextOptions, LogBuffer } from '@expo/build-tools';
import { BuildPhaseStats, Job, ManagedArtifactType, Metadata, Platform } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import assert from 'assert';
import { GCSCacheManager } from './CacheManager';
import config from './config';
import { getBuildEnv } from './env';
import { Analytics } from './external/analytics';
import { uploadXcodeBuildLogs } from './ios/xcodeLogs';
import sentry from './sentry';
import {
uploadApplicationArchiveAsync,
uploadBuildArtifactsAsync,
uploadWithAnalyticsAsync,
uploadWorkflowArtifactAsync,
} from './upload';
export function createBuildContext<TJob extends Job>({
job,
logBuffer,
analytics,
metadata,
projectId,
buildId,
buildLogger,
reportBuildPhaseStatsFn,
}: {
job: TJob;
logBuffer: LogBuffer;
analytics: Analytics;
metadata: Metadata;
projectId: string;
buildId: string;
buildLogger: bunyan;
reportBuildPhaseStatsFn: (stats: BuildPhaseStats) => void;
}): BuildContext<TJob> {
const env = getBuildEnv({ job, projectId, metadata, buildId });
const childLogger = buildLogger.child({ buildId });
const uploadArtifact: BuildContextOptions['uploadArtifact'] = async ({ artifact, logger }) => {
const { paths, type } = artifact;
switch (type) {
case ManagedArtifactType.XCODE_BUILD_LOGS: {
if (job.platform === Platform.IOS) {
await uploadXcodeBuildLogs(logger, paths[0]);
return { filename: null };
}
throw new Error('Uploading Xcode logs in non-iOS builds is not supported');
}
case ManagedArtifactType.APPLICATION_ARCHIVE: {
assert(job.platform, 'Uploading application archives outside of builds is not supported.');
return await uploadWithAnalyticsAsync(
() => uploadApplicationArchiveAsync(ctx, { artifactPaths: paths, buildId, logger }),
analytics
);
}
case ManagedArtifactType.BUILD_ARTIFACTS: {
assert(job.platform, 'Uploading build artifacts outside of builds is not supported.');
return await uploadWithAnalyticsAsync(
() => uploadBuildArtifactsAsync(ctx, { artifactPaths: paths, buildId, logger }),
analytics
);
}
default: {
return await uploadWithAnalyticsAsync(
() =>
uploadWorkflowArtifactAsync(ctx, {
artifactPaths: paths,
logger,
name: artifact.name,
}),
analytics
);
}
}
};
const ctx = new BuildContext<TJob>(job, {
workingdir: config.workingdir,
logger: childLogger,
logBuffer,
env,
uploadArtifact,
reportError: (msg, err, { tags, extras } = {}) => {
sentry.handleError(msg, err, { tags, extras });
},
cacheManager: new GCSCacheManager(),
metadata,
reportBuildPhaseStats: reportBuildPhaseStatsFn,
});
return ctx;
}
-132
View File
@@ -1,132 +0,0 @@
import { BuildContext } from '@expo/build-tools';
import { EnvironmentSecretType, Job } from '@expo/eas-build-job';
import fs from 'fs-extra';
import config from './config';
import { Environment } from './constants';
import { ResourceClass } from './external/turtle';
const RESOURCE_CLASS_DESCRIPTION: Record<ResourceClass, string> = {
[ResourceClass.ANDROID_N2_1_3_12]: 'Intel, 4 vCPUs, 16 GB RAM',
[ResourceClass.ANDROID_N2_2_6_24]: 'Intel, 8 vCPUs, 32 GB RAM',
[ResourceClass.IOS_M1_4_16]: 'M1, 2 cores, 8 GB RAM',
[ResourceClass.IOS_M2_2_8]: 'M2, 2 cores, 8 GB RAM',
[ResourceClass.IOS_M2_PRO_4_12]: 'M2 Pro, 4 cores, 12 GB RAM',
[ResourceClass.IOS_M4_PRO_5_20]: 'M4 Pro, 5 cores, 20 GB RAM',
[ResourceClass.IOS_M4_PRO_10_40]: 'M4 Pro, 10 cores, 40 GB RAM',
[ResourceClass.IOS_M2_4_22]: 'M2, 4 cores, 22 GB RAM',
[ResourceClass.LINUX_C3D_STANDARD_4]: 'AMD, 4 vCPUs, 16 GB RAM',
[ResourceClass.LINUX_C3D_STANDARD_8]: 'AMD, 8 vCPUs, 32 GB RAM',
[ResourceClass.LINUX_C4D_STANDARD_4]: 'AMD, 4 vCPUs, 15 GB RAM',
[ResourceClass.LINUX_C4D_STANDARD_8]: 'AMD, 8 vCPUs, 31 GB RAM',
};
export function displayWorkerRuntimeInfo(ctx: BuildContext<Job>): void {
printVMSpecs(ctx);
printImageDescription(ctx);
printImageMessage(ctx);
printEnvs(ctx);
}
function printVMSpecs(ctx: BuildContext<Job>): void {
const { resourceClass } = config;
if (resourceClass) {
const resourceClassDescription = RESOURCE_CLASS_DESCRIPTION[resourceClass];
if (resourceClassDescription) {
ctx.logger.info(resourceClassDescription);
} else {
ctx.logger.debug(
`Resource class is unsupported: ${resourceClass}, valid values: ${Object.keys(
RESOURCE_CLASS_DESCRIPTION
)}}`
);
}
ctx.logger.info('');
}
}
function printImageMessage(ctx: BuildContext<Job>): void {
const imageMessage = ctx.metadata?.trackingContext?.imageMessage;
if (imageMessage) {
ctx.logger.info(imageMessage);
}
}
function printImageDescription(ctx: BuildContext<Job>): void {
const workerDescriptionPath = config.workerDescriptionPath;
if (!fs.existsSync(workerDescriptionPath)) {
return;
}
const { logger } = ctx;
let description = fs.readFileSync(workerDescriptionPath, 'utf8');
description = description.replaceAll(
'macos-sequoia-15.6.1-xcode-26.1',
'macos-sequoia-15.6-xcode-26.1'
);
description
.trim()
.split('\n')
.forEach(line => {
logger.info(line);
});
}
function printEnvs(ctx: BuildContext<Job>): void {
const { logger, job } = ctx;
const publicEnv: Record<string, string> = {};
const secretEnv: Record<string, string> = {};
const instanceEnv: Record<string, string> = {};
// skip development and testing to avoid leaking local credentials from envs to bucket
if (config.env !== Environment.DEVELOPMENT && config.env !== Environment.TEST) {
Object.entries(ctx.env).forEach(([key, value]) => {
instanceEnv[key] = value;
});
}
Object.entries(job.builderEnvironment?.env ?? ({} as Record<string, string>)).forEach(
([key, value]) => {
publicEnv[key] = value;
delete instanceEnv[key];
}
);
job.secrets?.environmentSecrets?.forEach(({ name, type }) => {
if (type === EnvironmentSecretType.FILE) {
secretEnv[name] = ctx.env[name];
} else {
secretEnv[name] = '********';
}
delete publicEnv[name];
delete instanceEnv[name];
});
const hasPublicEnv = Object.keys(publicEnv).length > 0;
const hasSecretEnv = Object.keys(secretEnv).length > 0;
const hasInstanceEnv = Object.keys(instanceEnv).length > 0;
if (hasPublicEnv) {
logger.info('');
logger.info('Project environment variables:');
Object.entries(publicEnv).forEach(([key, value]) => {
logger.info(` ${key}=${value}`);
});
}
if (hasSecretEnv) {
logger.info('');
logger.info('Environment secrets:');
Object.entries(secretEnv).forEach(([key, value]) => {
logger.info(` ${key}=${value}`);
});
}
if (hasInstanceEnv) {
logger.info('');
logger.info('EAS Build environment variables:');
Object.entries(instanceEnv).forEach(([key, value]) => {
logger.info(` ${key}=${value}`);
});
}
logger.info('');
}
Loaded 100 of 129 files, more files were not shown because too many files have changed in this diff. Show more