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
112 changed files with 841 additions and 11216 deletions

No files matched your search

@@ -1,16 +0,0 @@
name: build-android
on:
workflow_dispatch: {}
jobs:
build:
name: Build Android
type: build
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,164 +0,0 @@
name: build-and-deploy-worker
# If environment is empty we want to build (for testing)
# and upload (if on `main`, for later deployment).
# If environment is not empty, we want to build and upload (unless
# already uploaded) 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:
setup:
runs-on: ubuntu-latest
if: inputs.environment != ''
outputs:
needs-to-build: ${{ steps.check_worker_exists.outputs.worker_exists == 'false' }}
permissions:
id-token: write
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- uses: ./.github/internal-actions/setup-gcloud
- name: Check if worker already exists
id: check_worker_exists
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"
build-android:
runs-on: ubuntu-latest
needs:
- setup
if: (!failure() && !cancelled() && inputs.environment == '') || needs.setup.outputs.needs-to-build == 'true'
permissions:
id-token: write
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- uses: volta-cli/action@5c175f92dea6f48441c436471e6479dbc192e194 # v4
- uses: ruby/setup-ruby@v1
with:
ruby-version: "2.7"
- name: Build worker tarball
working-directory: packages/worker
run: ./package.sh worker-android.tar.gz android
- uses: ./.github/internal-actions/setup-gcloud
if: github.ref == 'refs/heads/main' || inputs.environment != ''
- name: Upload worker tarball to GCP
if: github.ref == 'refs/heads/main' || inputs.environment != ''
working-directory: packages/worker
run: |
ARTIFACT_TARBALL="worker-android.tar.gz"
GCP_TARBALL="worker-android-sha1-$GITHUB_SHA.tar.gz"
gsutil cp $ARTIFACT_TARBALL gs://eas-build-worker-tarballs/$GCP_TARBALL
build-ios:
runs-on: ubuntu-latest
needs:
- setup
if: (!failure() && !cancelled() && inputs.environment == '') || needs.setup.outputs.needs-to-build == 'true'
permissions:
id-token: write
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- uses: volta-cli/action@5c175f92dea6f48441c436471e6479dbc192e194 # v4
- name: Run EAS Workflow to build iOS worker
id: eas_workflow
working-directory: packages/worker
env:
EXPO_TOKEN: ${{ secrets.EXPO_DEV_EXPO_GITHUB_ROBOT_ACCESS_TOKEN }}
run: |
SHOULD_UPLOAD_ARTIFACT="${{ github.ref == 'refs/heads/main' || inputs.environment != '' }}"
# Run workflow and capture output (includes full workflow run with jobs and artifacts)
WORKFLOW_OUTPUT=$(npx -y eas-cli@latest workflow:run build-worker.yml \
--ref ${{ github.sha }} \
--input upload_artifact=$SHOULD_UPLOAD_ARTIFACT \
--non-interactive \
--wait \
--json)
# Skip artifact URL extraction if we didn't upload
if [ "$SHOULD_UPLOAD_ARTIFACT" == "false" ]; then
echo "Skipping artifact download - upload was disabled"
exit 0
fi
# Extract artifact download URL from workflow output
# Artifacts are in jobs[].turtleJobRun.artifacts[] for custom jobs
DOWNLOAD_URL=$(echo "$WORKFLOW_OUTPUT" | jq -r '
.jobs[]
| .turtleJobRun.artifacts[]?
| select(.name == "worker-ios-tarball")
| .downloadUrl
' | head -1)
if [ -z "$DOWNLOAD_URL" ] || [ "$DOWNLOAD_URL" = "null" ]; then
echo "Error: Could not find artifact download URL"
echo "Workflow output:"
echo "$WORKFLOW_OUTPUT" | jq .
exit 1
fi
# Mask the download URL
echo "::add-mask::$DOWNLOAD_URL"
echo "download_url=$DOWNLOAD_URL" >> "$GITHUB_OUTPUT"
- uses: ./.github/internal-actions/setup-gcloud
if: github.ref == 'refs/heads/main' || inputs.environment != ''
- name: Upload worker tarball to GCP
if: github.ref == 'refs/heads/main' || inputs.environment != ''
run: |
GCP_TARBALL="worker-ios-sha1-$GITHUB_SHA.tar.gz"
curl -L "${{ steps.eas_workflow.outputs.download_url }}" | gsutil cp - gs://eas-build-worker-tarballs/$GCP_TARBALL
deploy:
runs-on: ubuntu-latest
needs:
- setup
- build-android
- build-ios
if: (!failure() && !cancelled() && inputs.environment != '')
permissions:
id-token: write
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- uses: ./.github/internal-actions/setup-gcloud
- name: Promote worker to ${{ 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
-76
View File
@@ -1,76 +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: |
# Start iOS build in background
npx -y eas-cli@latest build --platform ios --profile system-test --non-interactive --wait &
PID_IOS=$!
# Start Android workflow in background
npx -y eas-cli@latest workflow:run build-android.yml --wait --non-interactive &
PID_ANDROID=$!
# Wait for both and capture exit codes
wait $PID_IOS
EXIT_IOS=$?
wait $PID_ANDROID
EXIT_ANDROID=$?
# Report results
echo "iOS build exit code: $EXIT_IOS"
echo "Android workflow exit code: $EXIT_ANDROID"
# Fail if either failed
if [ $EXIT_IOS -ne 0 ] || [ $EXIT_ANDROID -ne 0 ]; then
echo "One or more builds failed"
exit 1
fi
echo "All builds succeeded"
-78
View File
@@ -1,78 +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
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:
uses: ./.github/workflows/build-and-deploy-worker.yml
secrets: inherit
with:
environment: ""
deploy-worker-staging:
if: github.ref == 'refs/heads/main'
needs:
- worker-build
- worker-checks
uses: ./.github/workflows/build-and-deploy-worker.yml
secrets: inherit
with:
environment: staging
system-tests-staging:
if: github.ref == 'refs/heads/main' && !failure() && !cancelled()
needs:
- deploy-worker-staging
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: expo-small
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 }}
-7
View File
@@ -8,17 +8,10 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- 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
- 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))
## [16.28.0](https://github.com/expo/eas-cli/releases/tag/v16.28.0) - 2025-11-20
### 🎉 New features
-151
View File
@@ -1,151 +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');
```
## 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}'],
+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
@@ -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
+566 -1356
View File
File diff suppressed because it is too large. Load diff
+1 -3
View File
@@ -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';
@@ -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,
+1 -1
View File
@@ -168,7 +168,7 @@ export default class UpdateEdit extends EasCommand {
return await PublishMutation.setRolloutPercentageAsync(
graphqlClient,
u.updateId,
rolloutPercentageToSet
rolloutPercentageToSet!
);
})
);
+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;
}
};
@@ -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,
{
+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[] {
+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
+1 -1
View File
@@ -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,29 +0,0 @@
name: Build Worker (iOS)
on:
workflow_dispatch:
inputs:
upload_artifact:
description: 'Whether to upload the artifact after building'
type: boolean
default: true
jobs:
build_ios_worker:
name: Build iOS Worker Tarball
runs_on: macos-medium
image: latest
steps:
- uses: eas/checkout
- uses: eas/install_node_modules
- name: Build worker tarball
run: ./package.sh worker-ios.tar.gz ios
- name: Upload 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": "0.1.70",
"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.260",
"@expo/config-plugins": "8.0.8",
"@expo/config-types": "52.0.1",
"@expo/downloader": "1.0.260",
"@expo/eas-build-job": "1.0.260",
"@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.260",
"@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 "$@"
-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('');
}
-174
View File
@@ -1,174 +0,0 @@
import { Env, Job, Metadata, Platform, Workflow } from '@expo/eas-build-job';
import { spawnSync } from 'child_process';
import micromatch from 'micromatch';
import path from 'path';
import config from './config';
import { Environment } from './constants';
import {
ResourceClass,
ResourceClassToPlatform,
androidImagesWithJavaVersionLowerThen11,
} from './external/turtle';
import { getAccessedEnvs } from './utils/env';
// keep in sync with local-build-plugin env vars
// https://github.com/expo/eas-build/blob/main/packages/local-build-plugin/src/build.ts
export function getBuildEnv({
job,
projectId,
metadata,
buildId,
}: {
job: Job;
projectId: string;
metadata: Metadata;
buildId: string;
}): Env {
const env = getFilteredEnv();
setEnv(env, 'CI', '1');
// This helps Maestro Simulator test runs time out less.
// See https://maestro.mobile.dev/advanced/configuring-maestro-driver-timeout and
// https://github.com/mobile-dev-inc/maestro/issues/1257#issuecomment-1654803779
setEnv(env, 'MAESTRO_DRIVER_STARTUP_TIMEOUT', '120000');
setEnv(env, 'MAESTRO_CLI_NO_ANALYTICS', '1');
setEnv(env, 'EAS_BUILD', 'true');
setEnv(env, 'EAS_BUILD_RUNNER', 'eas-build');
setEnv(env, 'EAS_BUILD_PLATFORM', job.platform);
// NPM_CACHE_URL is deprecated
setEnv(env, 'NPM_CACHE_URL', config.npmCacheUrl);
setEnv(env, 'NVM_NODEJS_ORG_MIRROR', config.nodeJsCacheUrl);
setEnv(env, 'EAS_BUILD_NPM_CACHE_URL', config.npmCacheUrl);
setEnv(env, 'EAS_BUILD_PROFILE', metadata.buildProfile);
setEnv(env, 'EAS_BUILD_GIT_COMMIT_HASH', metadata.gitCommitHash);
setEnv(env, 'EAS_BUILD_ID', buildId);
setEnv(env, 'LANG', 'en_US.UTF-8');
setEnv(env, 'EAS_BUILD_WORKINGDIR', path.join(config.workingdir, 'build'));
setEnv(env, 'EAS_BUILD_PROJECT_ID', projectId);
const runnerPlatform =
job.platform ?? (config.resourceClass && ResourceClassToPlatform[config.resourceClass]);
if (runnerPlatform === Platform.IOS) {
setEnv(env, 'EAS_BUILD_COCOAPODS_CACHE_URL', config.cocoapodsCacheUrl);
if (job.builderEnvironment?.env?.EAS_USE_CACHE === '1') {
setEnv(env, 'USE_CCACHE', '1');
setEnv(env, 'CCACHE_CPP2', '1');
// Locate ccache binary path if installed, otherwise command will provide a null value
const ccachePath = spawnSync('command -v ccache', { env, shell: true });
const binPath = ccachePath.stdout?.toString().trim();
if (binPath) {
setEnv(env, 'CCACHE_BINARY', binPath);
}
}
} else if (runnerPlatform === Platform.ANDROID) {
if (job.builderEnvironment?.env?.EAS_USE_CACHE === '1') {
const ccachePath = spawnSync('command -v ccache', { env, shell: true });
const binPath = ccachePath.stdout?.toString().trim();
if (binPath) {
setEnv(env, 'ANDROID_CCACHE', binPath);
}
}
setEnv(env, 'EAS_BUILD_MAVEN_CACHE_URL', config.mavenCacheUrl);
}
if (config.env !== Environment.TEST) {
const maxHeapSize = config.resourceClass
? ResourceClassToMaxHeapSize[config.resourceClass] ?? '4g'
: '4g';
setEnv(
env,
'GRADLE_OPTS',
[
// MaxMetaspaceSize is infinite by default, so we set it to 1g.
// On web people set it to 256-512m.
//
// We need to be careful with Xmx, because the same value will be
// used for Gradle and Kotlin compiler.
// https://kotlinlang.org/docs/gradle-compilation-and-caches.html#gradle-daemon-arguments-inheritance
`-Dorg.gradle.jvmargs="-XX:MaxMetaspaceSize=1g -Xmx${maxHeapSize} ${
job.builderEnvironment?.image &&
androidImagesWithJavaVersionLowerThen11.includes(job.builderEnvironment?.image)
? '-XX:MaxPermSize=512m '
: ''
}-XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8"`,
// Executes projects in parallel. https://docs.gradle.org/current/userguide/performance.html#parallel_execution
'-Dorg.gradle.parallel=true',
// Disable the deamon since it's a one-time run.
'-Dorg.gradle.daemon=false',
].join(' ')
);
}
if (job.platform === Platform.ANDROID) {
if (job.version?.versionCode) {
setEnv(env, 'EAS_BUILD_ANDROID_VERSION_CODE', job.version.versionCode);
}
if (job.version?.versionName) {
setEnv(env, 'EAS_BUILD_ANDROID_VERSION_NAME', job.version.versionName);
}
} else if (job.platform === Platform.IOS) {
if (job.version?.buildNumber) {
setEnv(env, 'EAS_BUILD_IOS_BUILD_NUMBER', job.version.buildNumber);
}
if (job.version?.appVersion) {
setEnv(env, 'EAS_BUILD_IOS_APP_VERSION', job.version.appVersion);
}
}
let username = metadata.username;
if (!username && 'type' in job && job.type === Workflow.MANAGED) {
username = job.username;
}
setEnv(env, 'EAS_BUILD_USERNAME', username);
if (config.env === Environment.DEVELOPMENT) {
setEnv(env, 'EXPO_LOCAL', '1');
} else if (config.env === Environment.STAGING) {
setEnv(env, 'EXPO_STAGING', '1');
}
return env;
}
function getFilteredEnv(): Env {
const envToFilter = [...getAccessedEnvs(), 'KUBERNETES_*'];
const envToReturn = micromatch(
Object.keys(process.env),
envToFilter.map(env => `!${env}`)
);
const result: Env = {};
for (const key of envToReturn) {
const value = process.env[key];
if (value) {
result[key] = value;
}
}
return result;
}
function setEnv(env: Env, key: string, value: string | null | undefined): void {
if (value) {
env[key] = value;
}
}
const ResourceClassToMaxHeapSize: Record<ResourceClass, string | null> = {
[ResourceClass.LINUX_C3D_STANDARD_4]: '4g', // 4 out of 16 GB, 12 GB left
[ResourceClass.LINUX_C3D_STANDARD_8]: '8g', // 8 out of 32 GB, 24 GB left
[ResourceClass.LINUX_C4D_STANDARD_4]: '4g', // 4 out of 15 GB, 11 GB left
[ResourceClass.LINUX_C4D_STANDARD_8]: '8g', // 8 out of 31 GB, 23 GB left
[ResourceClass.ANDROID_N2_1_3_12]: '4g', // 4 out of 12 GB, 8 GB left
[ResourceClass.ANDROID_N2_2_6_24]: '8g', // 8 out of 24 GB, 16 GB left
// We don't care about Gradle heap size for macOS resource classes
[ResourceClass.IOS_M1_4_16]: null,
[ResourceClass.IOS_M2_2_8]: null,
[ResourceClass.IOS_M2_PRO_4_12]: null,
[ResourceClass.IOS_M4_PRO_5_20]: null,
[ResourceClass.IOS_M4_PRO_10_40]: null,
[ResourceClass.IOS_M2_4_22]: null,
};
-212
View File
@@ -1,212 +0,0 @@
import { ConfigItem, PluginItem, loadPartialConfig } from '@babel/core';
import { BuildContext } from '@expo/build-tools';
import { AndroidConfig, IOSConfig } from '@expo/config-plugins';
import { ExpoConfig } from '@expo/config-types';
import { BuildJob, EnvironmentSecret, Platform, Workflow } from '@expo/eas-build-job';
import spawnAsync from '@expo/spawn-async';
import { readFile, readJson } from 'fs-extra';
import path from 'path';
import semver from 'semver';
import * as RudderStack from './rudderstack';
import config from '../config';
import { maybeStringBase64Decode, simpleSecretsWhitelist } from '../secrets';
import sentry from '../sentry';
export enum Event {
WORKER_BUILD_START = 'build turtle worker build start',
WORKER_BUILD_SUCCESS = 'build turtle worker build success',
WORKER_BUILD_FAIL = 'build turtle worker build fail',
PROJECT_DEPENDENCIES = 'project dependencies',
ARTIFACT_UPLOAD_SUCCESS = 'build turtle artifact upload success',
ARTIFACT_UPLOAD_FAIL = 'build turtle artifact upload fail',
}
RudderStack.initialize(config.rudderstack.writeKey, config.rudderstack.dataPlaneURL);
export class Analytics extends RudderStack.Analytics<Event> {}
export async function logProjectDependenciesAsync(
ctx: BuildContext<BuildJob>,
analytics: Analytics,
buildId: string
): Promise<void> {
try {
const packageJSON = await readJson(
path.join(ctx.getReactNativeProjectDirectory(), 'package.json')
);
const dependencies = (packageJSON?.dependencies as Record<string, string> | undefined) ?? {};
const devDependencies =
(packageJSON?.devDependencies as Record<string, string> | undefined) ?? {};
const plugins = filterSecretsAndParsePlugins(
ctx.appConfig.plugins,
ctx.job.secrets?.environmentSecrets
);
const babelConfig = loadPartialConfig({ cwd: ctx.getReactNativeProjectDirectory() });
const babelPlugins =
babelConfig?.options?.plugins
?.map(plugin => {
if (isConfigItem(plugin) && plugin.file?.request) {
return plugin?.file?.request;
}
return null;
})
.filter((i): i is string => !!i) ?? [];
analytics.logEvent(Event.PROJECT_DEPENDENCIES, {
buildId,
dependencies: Object.entries(dependencies).map(dependency => ({
name: dependency[0],
version: dependency[1],
})),
devDependencies: Object.entries(devDependencies).map(dependency => ({
name: dependency[0],
version: dependency[1],
})),
packageManager: ctx.packageManager,
packageManagerVersion: await getPackageManagerVersion(ctx.packageManager),
jsEngine: resolveJsEngine(ctx, dependencies),
newArchEnabled: await resolveNewArchEnabled(ctx, dependencies['react-native']),
source: 'Turtle Worker',
plugins,
babelPlugins,
iosAssociatedDomains: ctx.appConfig.ios?.associatedDomains,
// @ts-expect-error - ApiObject is not exactly the same as AndroidIntentFiltersData though they're compatible
androidIntentFilters: ctx.appConfig.android?.intentFilters,
});
} catch (error: any) {
sentry.handleError('Failed to report project dependencies metrics', error, {
tags: {
errorCode: 'FAILED_TO_REPORT_PROJECT_DEPENDENCIES_EVENT',
},
});
}
}
function resolveJsEngine(
ctx: BuildContext<BuildJob>,
dependencies: Record<string, string>
): 'jsc' | 'hermes' | 'v8' | undefined {
const { job, appConfig } = ctx;
const appConfigJsEngine = appConfig?.[job.platform]?.jsEngine ?? appConfig.jsEngine;
if (job.type === Workflow.GENERIC || !ctx.metadata?.sdkVersion) {
return undefined;
}
if (Object.keys(dependencies).includes('react-native-v8')) {
if (
appConfigJsEngine === 'jsc' ||
(semver.satisfies(ctx.metadata.sdkVersion, '<=47') && !appConfigJsEngine)
) {
return 'v8';
}
}
if (appConfigJsEngine) {
return appConfigJsEngine;
} else if (semver.satisfies(ctx.metadata.sdkVersion, '<=47')) {
return 'jsc';
} else {
return 'hermes';
}
}
async function resolveNewArchEnabled(
ctx: BuildContext<BuildJob>,
reactNativeVersion: string | undefined
): Promise<boolean | undefined> {
const { job, appConfig } = ctx;
// Support for disabling the new architecture was removed in react-native 0.83
if (
reactNativeVersion &&
semver.satisfies(reactNativeVersion, '>=0.83', { includePrerelease: true })
) {
return true;
}
if (
appConfig?.[job.platform]?.newArchEnabled !== undefined ||
appConfig.newArchEnabled !== undefined
) {
return appConfig?.[job.platform]?.newArchEnabled ?? appConfig.newArchEnabled;
}
if (job.type === Workflow.GENERIC) {
try {
if (job.platform === Platform.ANDROID) {
const gradleProperties = await readFile(
path.join(ctx.getReactNativeProjectDirectory(), 'android', 'gradle.properties'),
'utf8'
);
const properties = AndroidConfig.Properties.parsePropertiesFile(gradleProperties);
return properties.some(
property =>
property.type === 'property' &&
property.key === 'newArchEnabled' &&
property.value === 'true'
);
} else {
// Check if -DRN_FABRIC_ENABLED is set on Xcode project
const pbxProjectPath = IOSConfig.Paths.getPBXProjectPath(
ctx.getReactNativeProjectDirectory()
);
const pbxProject = await readFile(pbxProjectPath, 'utf8');
return pbxProject.includes('-DRN_FABRIC_ENABLED');
}
} catch (error: any) {
sentry.handleError('Failed to detect react native new architecture', error);
return undefined;
}
}
const buildPropertiesPlugin = appConfig?.plugins?.find(
p => Array.isArray(p) && p[0] === 'expo-build-properties'
);
const buildPropertiesParams = buildPropertiesPlugin?.[1];
return buildPropertiesParams?.[job.platform]?.newArchEnabled ?? false;
}
function filterSecretsAndParsePlugins(
plugins: ExpoConfig['plugins'],
secrets: EnvironmentSecret[] = []
): string[] | undefined {
const secretValues = secrets.map(({ value }) => value);
const secretList: string[] = [
...secretValues,
...secretValues.map(maybeStringBase64Decode).filter((i): i is string => !!i),
].filter(i => i.length > 1 && !simpleSecretsWhitelist.includes(i));
return plugins
?.map(plugin => {
if (typeof plugin === 'string') {
return plugin;
} else if (Array.isArray(plugin) && plugin.length > 0) {
const pluginStringified = JSON.stringify(plugin);
const pluginWithoutSecrets = secretList.reduce((acc: string, pattern: string): string => {
return acc.replaceAll(pattern, '*'.repeat(pattern.length));
}, pluginStringified);
return pluginWithoutSecrets;
}
return null;
})
.filter((i): i is string => !!i);
}
function isConfigItem(plugin: PluginItem): plugin is ConfigItem {
return !Array.isArray(plugin) && (plugin as any)?.file;
}
async function getPackageManagerVersion(packageManager: string): Promise<string | undefined> {
try {
const { stdout } = await spawnAsync(packageManager, ['--version']);
return stdout.toString().trim();
} catch {
return undefined;
}
}
-51
View File
@@ -1,51 +0,0 @@
import RudderStackClient from '@expo/rudder-sdk-node';
let client: RudderStackClient | null = null;
interface APIObject {
[index: string]:
| string
| number
| boolean
| undefined
| APIObject
| (string | number | boolean | APIObject)[];
}
export class Analytics<Event extends string> {
constructor(
public readonly userId: string,
public readonly properties: APIObject
) {}
public logEvent(event: Event, properties: APIObject): void {
if (!client) {
return;
}
client.track({
event,
properties: {
...this.properties,
...properties,
},
userId: this.userId,
});
}
public async flushEventsAsync(): Promise<void> {
if (!client) {
return;
}
await client.flush();
}
}
export function initialize(writeKey: string | null, dataPlaneURL: string | null): void {
if (!(writeKey && dataPlaneURL)) {
return;
}
client = new RudderStackClient(writeKey, dataPlaneURL);
}
-77
View File
@@ -1,77 +0,0 @@
import { bunyan } from '@expo/logger';
import * as sentry from '@sentry/node';
import Flatted from 'flatted';
import { ZodError } from 'zod';
import { boomify } from '../utils/boom';
interface SentryOptions {
dsn: string | null;
environment: string;
tags?: Record<string, string>;
logger: bunyan;
}
interface ErrorOptions {
tags?: Record<string, string>;
extras?: Record<string, unknown>;
logger?: bunyan;
level?: sentry.SeverityLevel;
}
class Sentry {
private readonly logger: bunyan;
constructor(private readonly options: SentryOptions) {
this.logger = options.logger;
sentry.init({
...(options.dsn ? { dsn: options.dsn } : {}),
environment: options.environment,
});
}
public handleError(msg: string, err?: Error, options: ErrorOptions = {}): void {
const l = options.logger ?? this.logger;
const tags = { ...this.options.tags, ...options.tags };
const extras = options.extras ?? {};
// TurtleErrors don't need the explanatory ${msg} prefix,
// because their message should already be user-facing.
// ZodError#message can't be modified, so we don't reassign either.
if (err instanceof Error && !(err instanceof ZodError)) {
err.message = `${msg}\n${err.message}`;
}
const boom = err instanceof Error ? boomify(err) : err;
l.error({ tags, extras, err: boom }, msg);
sentry.withScope((scope: sentry.Scope) => {
scope.setTags(tags);
scope.setExtras(extras);
if (options?.level) {
scope.setLevel(options?.level);
}
if (err instanceof Error) {
sentry.captureException(err);
} else {
let serializedErr;
try {
serializedErr = JSON.stringify(err);
} catch {
serializedErr = Flatted.stringify(err);
}
const sentryMessage = err ? `${msg}\n${serializedErr}` : msg;
sentry.captureMessage(sentryMessage);
}
});
}
public handleCritical(msg: string, err?: Error, options: ErrorOptions = {}): void {
this.handleError(`Critical error, might require an urgent action [${msg}]`, err, {
...options,
tags: { ...options.tags, severity: 'critical' },
});
}
}
export default Sentry;
-189
View File
@@ -1,189 +0,0 @@
import { GCS } from '@expo/build-tools';
import {
BuildJob,
BuildPhase,
BuildPhaseResult,
Generic,
Metadata,
Platform,
errors,
} from '@expo/eas-build-job';
export enum ResourceClass {
ANDROID_N2_1_3_12 = 'android-n2-1.3-12',
ANDROID_N2_2_6_24 = 'android-n2-2.6-24',
IOS_M1_4_16 = 'ios-m1-4-16',
IOS_M2_2_8 = 'ios-m2-2-8',
IOS_M2_PRO_4_12 = 'ios-m2-pro-4-12',
IOS_M2_4_22 = 'ios-m2-4-22',
IOS_M4_PRO_5_20 = 'ios-m4-pro-5-20',
IOS_M4_PRO_10_40 = 'ios-m4-pro-10-40',
LINUX_C3D_STANDARD_4 = 'linux-c3d-standard-4',
LINUX_C3D_STANDARD_8 = 'linux-c3d-standard-8',
LINUX_C4D_STANDARD_4 = 'linux-c4d-standard-4',
LINUX_C4D_STANDARD_8 = 'linux-c4d-standard-8',
}
export const ResourceClassToPlatform: Record<ResourceClass, Platform> = {
'android-n2-1.3-12': Platform.ANDROID,
'android-n2-2.6-24': Platform.ANDROID,
'ios-m1-4-16': Platform.IOS,
'ios-m2-2-8': Platform.IOS,
'ios-m2-4-22': Platform.IOS,
'ios-m2-pro-4-12': Platform.IOS,
'ios-m4-pro-5-20': Platform.IOS,
'ios-m4-pro-10-40': Platform.IOS,
[ResourceClass.LINUX_C3D_STANDARD_4]: Platform.ANDROID,
[ResourceClass.LINUX_C3D_STANDARD_8]: Platform.ANDROID,
[ResourceClass.LINUX_C4D_STANDARD_4]: Platform.ANDROID,
[ResourceClass.LINUX_C4D_STANDARD_8]: Platform.ANDROID,
};
export const androidImagesWithJavaVersionLowerThen11 = [
'ubuntu-20.04-jdk-8-ndk-r19c',
'ubuntu-20.04-jdk-11-ndk-r19c',
'ubuntu-20.04-jdk-8-ndk-r21e',
'ubuntu-20.04-jdk-11-ndk-r21e',
'ubuntu-20.04-jdk-11-ndk-r23b',
'ubuntu-22.04-jdk-8-ndk-r21e',
'ubuntu-22.04-jdk-11-ndk-r21e',
'ubuntu-22.04-jdk-11-ndk-r23b',
];
export namespace Worker {
export enum Status {
NEW = 'new',
IN_PROGRESS = 'in-progress',
SUCCESS = 'success',
ERROR = 'error',
ABORTED = 'aborted',
}
type JobRunWorkerRuntimeConfig = {
gcsSignedUploadUrlForLogs: GCS.SignedUrl;
nodeJsCacheUrl: string | undefined;
npmCacheUrl: string | undefined;
mavenCacheUrl: string | undefined;
cocoapodsCacheUrl: string | undefined;
runMetricsServer: boolean;
resourceClass: ResourceClass;
type: 'jobRun';
buildId: string;
};
type BuildWorkerRuntimeConfig = {
gcsSignedUploadUrlForApplicationArchive: GCS.SignedUrl | null;
gcsSignedUploadUrlForBuildArtifacts: GCS.SignedUrl | null;
gcsSignedUploadUrlForLogs: GCS.SignedUrl;
gcsSignedUploadUrlForXcodeBuildLogs?: GCS.SignedUrl;
gcsSignedUploadUrlForBuildCache?: GCS.SignedUrl;
gcsSignedBuildCacheDownloadUrl?: string;
nodeJsCacheUrl: string | undefined;
npmCacheUrl: string | undefined;
mavenCacheUrl: string | undefined;
cocoapodsCacheUrl: string | undefined;
runMetricsServer: boolean;
resourceClass: ResourceClass;
type?: never;
buildId: string;
};
export type RuntimeConfig = BuildWorkerRuntimeConfig | JobRunWorkerRuntimeConfig;
}
export namespace WorkerMessage {
export enum MessageType {
STATE_RESPONSE = 'state-response',
SUCCESS = 'success',
ERROR = 'error',
BUILD_PHASE_STATS = 'build-phase-stats',
ABORTED = 'aborted',
}
export enum AbortReason {
CANCEL = 'cancel',
TIMEOUT = 'timeout',
}
export type Message = StateResponse | BuildSuccess | BuildError | BuildPhaseStats | BuildAborted;
export interface StateResponse {
type: MessageType.STATE_RESPONSE;
status: Worker.Status;
applicationArchiveName: string | null;
buildArtifactsName: string | null;
externalBuildError?: errors.ExternalBuildError;
internalErrorCode?: string;
abortReason?: AbortReason;
}
export interface BuildSuccess {
type: MessageType.SUCCESS;
applicationArchiveName: string | null;
buildArtifactsName: string | null;
}
export interface BuildError {
type: MessageType.ERROR;
externalBuildError?: errors.ExternalBuildError;
internalErrorCode?: string;
applicationArchiveName: string | null;
buildArtifactsName: string | null;
}
export interface BuildPhaseStats {
type: MessageType.BUILD_PHASE_STATS;
buildPhase: BuildPhase;
result: BuildPhaseResult;
durationMs: number;
}
export interface BuildAborted {
type: MessageType.ABORTED;
reason?: AbortReason;
}
}
export namespace LauncherMessage {
export enum MessageType {
DISPATCH = 'dispatch',
STATE_QUERY = 'state-query',
CLOSE = 'close',
ABORT = 'abort',
}
export enum AbortReason {
CANCEL = 'cancel',
TIMEOUT = 'timeout',
}
export type Message = Dispatch | StateQuery | Close | BuildAbort;
export type Dispatch = {
initiatingUserId: string;
projectId: string;
metadata: Metadata;
type: MessageType.DISPATCH;
} & (
| {
jobType?: never;
job: BuildJob;
buildId: string;
}
| {
jobType: 'jobRun';
job: Generic.Job;
jobRunId: string;
}
);
export interface StateQuery {
type: MessageType.STATE_QUERY;
buildId: string;
}
export interface Close {
type: MessageType.CLOSE;
}
export interface BuildAbort {
type: MessageType.ABORT;
reason: AbortReason;
}
}
-52
View File
@@ -1,52 +0,0 @@
import { GCS } from '@expo/build-tools';
import { bunyan } from '@expo/logger';
import fs from 'fs-extra';
import os from 'os';
import path from 'path';
import { Readable, pipeline } from 'stream';
import { promisify } from 'util';
import { v4 as uuid } from 'uuid';
import zlib from 'zlib';
import config from '../config';
export async function uploadXcodeBuildLogs(logger: bunyan, logsPath: string): Promise<void> {
if (!config.loggers.gcs.signedUploadUrlForXcodeBuildLogs) {
logger.warn('GCS Presigned URL for Xcode logs was not specified, skipping upload');
} else {
await GCS.uploadWithSignedUrl({
signedUrl: config.loggers.gcs.signedUploadUrlForXcodeBuildLogs,
srcGeneratorAsync: async () => {
const stat = await fs.stat(logsPath);
const { stream } = await compress(
{ stream: fs.createReadStream(logsPath), length: stat.size },
config.loggers.gcs.compressionMethod
);
return stream;
},
});
}
}
// HACK(Mike): Copypasta of a fragment of functionality from GCSLoggerStream.
async function compress(
src: { stream: Readable; length: number },
method: 'gzip' | 'br' | null
): Promise<{ stream: Readable }> {
const encoder =
method === 'gzip' ? zlib.createGzip() : method === 'br' ? zlib.createBrotliCompress() : null;
if (!encoder) {
return src;
}
const fname = path.join(os.tmpdir(), `logs-${uuid()}.compressed`);
const pipe = promisify(pipeline);
const dst = fs.createWriteStream(fname);
await pipe(src.stream, encoder, dst);
const { size } = await fs.stat(fname);
return {
stream: fs.createReadStream(fname, { end: size }),
};
}
-116
View File
@@ -1,116 +0,0 @@
import { LogBuffer } from '@expo/build-tools';
import { EnvironmentSecret } from '@expo/eas-build-job';
import { LoggerLevel, createLogger } from '@expo/logger';
import { Transform, TransformCallback, Writable } from 'stream';
import config from './config';
import { maybeStringBase64Decode, simpleSecretsWhitelist } from './secrets';
import { BuildLogger as CommonBuildLogger, createGCSBuildLogger } from './utils/logger';
export interface BuildLogger extends CommonBuildLogger {
logBuffer: LogBuffer;
}
const defaultLogger = createLogger({
name: config.loggers.base.name,
level: LoggerLevel.INFO,
});
const MAX_LINES_IN_BUFFER = 100;
interface CachedLog {
msg: string;
phase: string;
}
export class WorkerLogBuffer extends Writable implements LogBuffer {
public writable: boolean = true;
public buffer: CachedLog[] = [];
constructor(private readonly numberOfLines: number) {
super();
}
public write(rec: any): boolean {
if (
// verify required fields
typeof rec !== 'object' ||
typeof rec?.msg !== 'string' ||
typeof rec?.phase !== 'string' ||
// use only logs from spawn commands
(rec?.source !== 'stdout' && rec?.source !== 'stderr') ||
// skip all short lines (it could potentially be some loader)
(rec?.msg ?? '').length < 3
) {
return true;
}
if (this.buffer.length >= this.numberOfLines) {
this.buffer.shift();
}
this.buffer.push({ msg: rec.msg, phase: rec.phase });
return true;
}
public getLogs(): string[] {
return this.buffer.map(({ msg }) => msg);
}
public getPhaseLogs(buildPhase: string): string[] {
return this.buffer.filter(({ phase }) => phase === buildPhase).map(({ msg }) => msg);
}
}
function createSecretMaskingStream(secrets: EnvironmentSecret[]): Transform {
const secretValues = secrets.map(({ value }) => value);
const secretList: string[] = [
...secretValues,
...secretValues.map(maybeStringBase64Decode).filter((i): i is string => !!i),
].filter(i => i.length > 1 && !simpleSecretsWhitelist.includes(i));
return new Transform({
readableObjectMode: true,
writableObjectMode: true,
transform(chunk: any, _encoding: BufferEncoding, callback: TransformCallback) {
if (chunk && typeof chunk === 'object' && chunk.msg) {
const msgWithoutSecrets = secretList.reduce((acc: string, pattern: string): string => {
return acc.replaceAll(pattern, '*'.repeat(pattern.length));
}, chunk.msg as string);
callback(null, {
...chunk,
msg: msgWithoutSecrets,
});
} else {
callback(null, chunk);
}
},
});
}
export async function createBuildLoggerWithSecretsFilter(
secrets?: EnvironmentSecret[]
): Promise<BuildLogger> {
const logBuffer = new WorkerLogBuffer(MAX_LINES_IN_BUFFER);
const childLogger = defaultLogger.child({ service: 'worker' });
const { logger, stream } = await createGCSBuildLogger({
uploadMethod: config.loggers.gcs.signedUploadUrlForLogs
? { signedUrl: config.loggers.gcs.signedUploadUrlForLogs }
: undefined,
options: {
uploadIntervalMs: config.loggers.base.uploadIntervalMs,
compress: config.loggers.gcs.compressionMethod,
},
transformStream: secrets && createSecretMaskingStream(secrets),
logger: childLogger,
});
logger.addStream({
type: 'raw',
stream: logBuffer,
reemitErrorEvents: true,
level: logger.level(),
});
return { logger, stream, logBuffer };
}
export default defaultLogger;
-24
View File
@@ -1,24 +0,0 @@
import config from './config';
import logger from './logger';
import { startServer } from './metricsServer';
import { prepareRuntimeEnvironmentConfigFiles } from './runtimeEnvironment';
import { prepareWorkingdir } from './workingdir';
import startWsServer from './ws';
async function main(): Promise<void> {
await prepareRuntimeEnvironmentConfigFiles();
await prepareWorkingdir();
startWsServer();
if (config.runMetricsServer) {
startServer();
}
// for pm2
if (config.env === 'development' && process.send) {
process.send('ready');
}
}
main().catch(err => {
logger.error({ err }, 'Something went wrong.');
process.exit(1);
});
-91
View File
@@ -1,91 +0,0 @@
import checkDiscSpace from 'check-disk-space';
import osu from 'node-os-utils';
import logger from './logger';
import sentry from './sentry';
interface RamMetrics {
avaliableGb: number;
usedGb: number;
usagePercentage: number;
}
interface CpuMetrics {
usagePercentage: number;
}
interface DiscMetrics {
avaliableGb: number;
usedGb: number;
usagePercentage: number;
}
function megabytesToGigabytes(bytes: number): number {
return bytes / 1024;
}
function bytesToGigabytes(bytes: number): number {
return bytes / 1024 / 1024 / 1024;
}
function handleError(err: any, message: string): void {
logger.error(message, { err });
sentry.handleError(message, err);
}
async function getRamMetricsGb(): Promise<RamMetrics | null> {
try {
const { usedMemMb, freeMemMb, usedMemPercentage } = await osu.mem.info();
return {
avaliableGb: megabytesToGigabytes(Number(freeMemMb)),
usedGb: megabytesToGigabytes(Number(usedMemMb)),
usagePercentage: Number(usedMemPercentage),
};
} catch (err: any) {
handleError(err, 'Failed to get worker VM RAM metrics');
return null;
}
}
async function getCpuUsagePercent(): Promise<CpuMetrics | null> {
try {
return {
usagePercentage: await osu.cpu.usage(),
};
} catch (err: any) {
handleError(err, 'Failed to get worker VM CPU metrics');
return null;
}
}
async function getDiscUsageGb(): Promise<DiscMetrics | null> {
try {
const { free, size } = await checkDiscSpace('/');
return {
avaliableGb: bytesToGigabytes(free),
usedGb: bytesToGigabytes(size - free),
usagePercentage: (size - free) / size,
};
} catch (err: any) {
handleError(err, 'Failed to get worker VM disc metrics');
return null;
}
}
export async function getWorkerVmMetrics(): Promise<{
ram: RamMetrics | null;
cpu: CpuMetrics | null;
disc: DiscMetrics | null;
}> {
const [ram, cpu, disc] = await Promise.all([
getRamMetricsGb(),
getCpuUsagePercent(),
getDiscUsageGb(),
]);
logger.info('Worker VM metrics', { ram, cpu, disc });
return {
ram,
cpu,
disc,
};
}
-52
View File
@@ -1,52 +0,0 @@
import { Server } from 'http';
import Koa from 'koa';
import koaBody from 'koa-body';
import Router from 'koa-router';
import config from './config';
import logger from './logger';
import { getWorkerVmMetrics } from './metrics';
import { boomHelper, boomify } from './utils/boom';
function createRouter(): Router {
const router = new Router();
router.get('/metrics', async (ctx: Router.RouterContext): Promise<void> => {
logger.info('Get VM metrics request received');
ctx.status = 200;
ctx.body = await getWorkerVmMetrics();
logger.info('Get VM metrics request handled');
});
return router;
}
function createApp(): Koa {
const app = new Koa();
const router = createRouter();
app.use(async function errorMiddleware(
ctx: Router.RouterContext,
next: () => Promise<void>
): Promise<void> {
try {
await next();
} catch (err: any) {
const boomErr = boomify(err);
boomHelper(ctx, boomErr);
throw err;
}
});
app.use(koaBody());
app.use(router.routes());
app.use(router.allowedMethods());
return app;
}
export function startServer(): Server {
const app = createApp();
const server = app.listen(config.metricsServerPort);
server.on('error', err => {
logger.error({ err }, 'http.Server error');
});
logger.info(`Metrics HTTP server is listening on port ${config.metricsServerPort}`);
return server;
}
-387
View File
@@ -1,387 +0,0 @@
import { BuildContext } from '@expo/build-tools';
import { Job, errors } from '@expo/eas-build-job';
import templateFile from '@expo/template-file';
import spawn from '@expo/turtle-spawn';
import fs from 'fs-extra';
import os from 'os';
import path from 'path';
import { v4 as uuidv4 } from 'uuid';
import config from './config';
class SystemDepsInstallError extends errors.UserFacingError {
constructor(dependency: string) {
super(
'EAS_BUILD_SYSTEM_DEPS_INSTALL_ERROR',
`Failed to install ${dependency}. Make sure you specified the correct version in eas.json.`
);
}
}
export async function prepareRuntimeEnvironmentConfigFiles(): Promise<void> {
if (config.env === 'development' || config.env === 'test') {
return;
}
if (config.npmCacheUrl) {
// create ~/.npmrc
await spawn('npm', ['config', 'set', 'registry', config.npmCacheUrl]);
// create ~/.yarnrc.yml
await templateFile(
path.join(__dirname, '../src/templates/yarnrc.yml'),
{
URL: config.npmCacheUrl,
},
path.join(os.homedir(), '.yarnrc.yml')
);
}
if (config.mavenCacheUrl) {
await fs.mkdirp(path.join(os.homedir(), '.gradle'));
await templateFile(
path.join(__dirname, '../src/templates/init.gradle'),
{
URL: config.mavenCacheUrl,
},
path.join(os.homedir(), '.gradle/init.gradle')
);
}
}
// At this step we don't yet have the code downloaded, so `packageManager` defaults to `yarn`
// and so should not be used for any logic
type PreDownloadBuildContext = Omit<BuildContext<Job>, 'packageManager'>;
export async function prepareRuntimeEnvironment(
ctx: PreDownloadBuildContext,
builderConfig: Job['builderEnvironment'],
skipIfTest = true
): Promise<void> {
if (skipIfTest && (config.env === 'development' || config.env === 'test')) {
ctx.logger.warn(
'Runtime environment customizations are disabled in local and test environments'
);
return;
}
if (process.platform === 'darwin') {
await installCocoapodsCachePlugin();
}
if (!builderConfig) {
return;
}
const currentYarnVersion = (await spawn('yarn', ['--version'], { stdio: 'pipe' })).stdout.trim();
const currentPnpmVersion = (await spawn('pnpm', ['--version'], { stdio: 'pipe' })).stdout.trim();
const currentBunVersion = (await spawn('bun', ['--version'], { stdio: 'pipe' })).stdout.trim();
if (builderConfig.node) {
const installedNodeVersion = await installNode(ctx, builderConfig.node);
await installSharpCli(ctx, installedNodeVersion);
}
if (builderConfig.corepack) {
ctx.logger.info(`Enabling corepack`);
await spawn('corepack', ['enable'], { logger: ctx.logger, env: ctx.env });
}
if (
Boolean(builderConfig.pnpm && currentPnpmVersion !== builderConfig.pnpm) ||
builderConfig.node
) {
await installPnpm({
ctx,
userSpecifiedVersion: builderConfig.pnpm,
currentVersion: currentPnpmVersion,
});
}
if (
Boolean(builderConfig.yarn && currentYarnVersion !== builderConfig.yarn) ||
builderConfig.node
) {
await installYarn({
ctx,
userSpecifiedVersion: builderConfig.yarn,
currentVersion: currentYarnVersion,
});
}
if (builderConfig.bun && currentBunVersion !== builderConfig.bun) {
await installBun({
ctx,
userSpecifiedVersion: builderConfig.bun,
currentVersion: currentBunVersion,
});
}
if ('ndk' in builderConfig && builderConfig.ndk) {
await installNdk(ctx, builderConfig.ndk);
}
if ('bundler' in builderConfig && builderConfig.bundler) {
await installBundler(ctx, builderConfig.bundler);
}
if ('fastlane' in builderConfig && builderConfig.fastlane) {
await installFastlane(ctx, builderConfig.fastlane);
}
if ('cocoapods' in builderConfig && builderConfig.cocoapods) {
await installCocoapods(ctx, builderConfig.cocoapods);
}
}
async function installNode(ctx: PreDownloadBuildContext, version: string): Promise<string> {
let sanitizedVersion = version.startsWith('v') ? version.slice(1) : version;
try {
ctx.logger.info(`Installing node v${sanitizedVersion}`);
const { stdout } = await spawn(
'bash',
['-c', `source ~/.nvm/nvm.sh && nvm install ${version}`],
{
logger: ctx.logger,
env: ctx.env,
}
);
sanitizedVersion = stdout.match(/Now using node v(\d+\.\d+\.\d+)/)?.[1] ?? sanitizedVersion;
await spawn('bash', ['-c', `source ~/.nvm/nvm.sh && nvm alias default ${sanitizedVersion}`], {
logger: ctx.logger,
env: ctx.env,
});
const nodeDir = `${os.homedir()}/.nvm/versions/node/v${sanitizedVersion}`;
ctx.env.PATH = `${nodeDir}/bin:${ctx.env.PATH}`;
const nodeBinPath = `${nodeDir}/bin/node`;
if (!(await fs.pathExists(nodeBinPath))) {
throw new Error(`node executable was not found in ${nodeBinPath}`);
}
} catch (err: any) {
ctx.logger.error({ err }, `Failed to install Node.js v${version}\n`);
throw new SystemDepsInstallError('Node.js');
}
return sanitizedVersion;
}
async function installBundler(ctx: PreDownloadBuildContext, version: string): Promise<void> {
try {
ctx.logger.info(`Installing bundler ${version}`);
await spawn(
'sudo',
['-E', 'gem', 'install', '--no-document', '--quiet', 'bundler', '-v', version],
{
logger: ctx.logger,
env: ctx.env,
}
);
} catch (err: any) {
ctx.logger.error({ err }, `Failed to install bundler ${version}\n`);
throw new SystemDepsInstallError('bundler');
}
}
async function installBun({
ctx,
userSpecifiedVersion,
currentVersion,
}: {
ctx: PreDownloadBuildContext;
userSpecifiedVersion: string | undefined;
currentVersion: string;
}): Promise<void> {
const versionToInstall = userSpecifiedVersion ?? currentVersion;
try {
ctx.logger.info(`Installing bun@${versionToInstall}`);
const bunInstallScriptPath = path.join(os.tmpdir(), `install-bun-${uuidv4()}.sh`);
await spawn('curl', ['-fsSL', 'https://bun.sh/install', '-o', bunInstallScriptPath], {
logger: ctx.logger,
env: ctx.env,
});
await spawn('bash', [bunInstallScriptPath, `bun-v${versionToInstall}`], {
logger: ctx.logger,
env: ctx.env,
});
await spawn('rm', [bunInstallScriptPath], {
logger: ctx.logger,
env: ctx.env,
});
const currentBunVersion = (
await spawn('bun', ['--version'], { stdio: 'pipe', env: ctx.env })
).stdout.trim();
if (currentBunVersion !== versionToInstall) {
throw new Error(
`Found wrong bun version ${currentBunVersion} (expected ${versionToInstall})`
);
}
} catch (err: any) {
ctx.logger.error({ err }, `Failed to install Bun ${versionToInstall}\n`);
if (ctx.metadata?.requiredPackageManager === 'bun') {
throw new SystemDepsInstallError('bun');
} else {
ctx.markBuildPhaseHasWarnings();
ctx.logger.warn(
`Failed to install bun@${versionToInstall}. Continuing because Bun is not the primary package manager for this project.`
);
}
}
}
async function installPnpm({
ctx,
userSpecifiedVersion,
currentVersion,
}: {
ctx: PreDownloadBuildContext;
userSpecifiedVersion: string | undefined;
currentVersion: string;
}): Promise<void> {
const versionToInstall = userSpecifiedVersion ?? currentVersion;
try {
ctx.logger.info(`Installing pnpm@${versionToInstall}`);
await spawn('npm', ['-g', 'install', `pnpm@${versionToInstall}`], {
logger: ctx.logger,
env: ctx.env,
});
const currentPnpmVersion = (
await spawn('pnpm', ['--version'], { stdio: 'pipe', env: ctx.env })
).stdout.trim();
if (currentPnpmVersion !== versionToInstall) {
throw new Error(
`Found wrong pnpm version ${currentPnpmVersion} (expected ${versionToInstall})`
);
}
} catch (err: any) {
ctx.logger.error({ err }, `Failed to install pnpm ${versionToInstall}\n`);
if (ctx.metadata?.requiredPackageManager === 'pnpm') {
throw new SystemDepsInstallError('pnpm');
} else {
ctx.markBuildPhaseHasWarnings();
ctx.logger.warn(
`Failed to install pnpm@${versionToInstall}. Continuing because pnpm is not the primary package manager for this project.`
);
}
}
}
async function installYarn({
ctx,
userSpecifiedVersion,
currentVersion,
}: {
ctx: PreDownloadBuildContext;
userSpecifiedVersion: string | undefined;
currentVersion: string;
}): Promise<void> {
const versionToInstall = userSpecifiedVersion ?? currentVersion;
try {
ctx.logger.info(`Installing yarn@${versionToInstall}`);
await spawn('npm', ['-g', 'install', `yarn@${versionToInstall}`], {
logger: ctx.logger,
env: ctx.env,
});
const currentYarnVersion = (
await spawn('yarn', ['--version'], { stdio: 'pipe', env: ctx.env })
).stdout.trim();
if (currentYarnVersion !== versionToInstall) {
throw new Error(
`Found wrong yarn version ${currentYarnVersion} (expected ${versionToInstall})`
);
}
} catch (err: any) {
ctx.logger.error({ err }, `Failed to install Yarn ${versionToInstall}\n`);
if (ctx.metadata?.requiredPackageManager === 'yarn') {
throw new SystemDepsInstallError('yarn');
} else {
ctx.markBuildPhaseHasWarnings();
ctx.logger.warn(
`Failed to install yarn@${versionToInstall}. Continuing because yarn is not the primary package manager for this project.`
);
}
}
}
async function installSharpCli(ctx: PreDownloadBuildContext, nodeVersion: string): Promise<void> {
const sharpCliVersion = (await spawn('sharp', ['--version'], { stdio: 'pipe' })).stdout.trim();
try {
await spawn('npm', ['-g', 'install', `sharp-cli@${sharpCliVersion}`], {
logger: ctx.logger,
env: ctx.env,
});
} catch (err: any) {
ctx.logger.warn(
{ err },
`Failed to install sharp-cli@${sharpCliVersion} for Node.js v${nodeVersion}`
);
ctx.logger.warn(
`If your version of Node.js does not support ${sharpCliVersion} version of sharp-cli`
);
ctx.logger.warn(` - build will fallback to slower (JS only) implementation.`);
ctx.logger.warn(
` - you can install different version in "eas-build-pre-install" hook (https://docs.expo.dev/build-reference/how-tos/#eas-build-specific-npm-hooks)`
);
}
}
async function installFastlane(ctx: PreDownloadBuildContext, version: string): Promise<void> {
try {
ctx.logger.info(`Installing fastlane ${version}`);
await spawn('sudo', ['-E', 'gem', 'uninstall', '--quiet', 'fastlane'], { logger: ctx.logger });
await spawn(
'sudo',
['-E', 'gem', 'install', '--no-document', '--quiet', 'fastlane', '-v', version],
{
logger: ctx.logger,
env: ctx.env,
}
);
} catch (err: any) {
ctx.logger.error({ err }, `Failed to install fastlane ${version}\n`);
throw new SystemDepsInstallError('fastlane');
}
}
async function installCocoapods(ctx: PreDownloadBuildContext, version: string): Promise<void> {
try {
ctx.logger.info(`Installing CocoaPods ${version}`);
await spawn('sudo', ['-E', 'gem', 'uninstall', '--quiet', 'cocoapods'], { logger: ctx.logger });
await spawn(
'sudo',
['-E', 'gem', 'install', '--no-document', '--quiet', 'cocoapods', '-v', version],
{
logger: ctx.logger,
env: ctx.env,
}
);
} catch (err: any) {
ctx.logger.error({ err }, `Failed to install CocoaPods ${version}\n`);
throw new SystemDepsInstallError('CocoaPods');
}
}
async function installNdk(ctx: PreDownloadBuildContext, version: string): Promise<void> {
try {
ctx.logger.info(`Installing NDK ${version}`);
await spawn('sdkmanager', [`ndk;${version}`], { env: ctx.env });
const ndkHome = `${ctx.env.ANDROID_HOME}/ndk/${version}`;
ctx.env.ANDROID_NDK_HOME = ndkHome;
if (!(await fs.pathExists(ndkHome))) {
throw new Error(`NDK was not found under ${ndkHome}`);
}
} catch (err: any) {
ctx.logger.error({ err }, `Failed to install NDK ${version}\n`);
throw new SystemDepsInstallError('NDK');
}
}
async function installCocoapodsCachePlugin(): Promise<void> {
try {
await spawn('sudo', [
'-E',
'gem',
'install',
'--quiet',
path.join(__dirname, '../expo-cocoapods-proxy.gem'),
]);
} catch {
throw new Error('Failed to install expo-cocoapods-proxy');
}
}
-29
View File
@@ -1,29 +0,0 @@
export function maybeStringBase64Decode(maybeEncodedString: string): string | null {
try {
const buffer = Buffer.from(maybeEncodedString, 'base64');
const decoded = buffer.toString('utf-8');
// toString('utf-8') replaces invalid Unicode characters with \uFFFD,
// so if the result includes it, we can assume it was not a valid text.
return decoded.includes('\uFFFD') ? null : decoded;
} catch {
return null;
}
}
export const simpleSecretsWhitelist = [
'production',
'prod',
'release',
'staging',
'preview',
'development',
'dev',
'debug',
'ios',
'android',
'true',
'false',
'null',
'yes',
'no',
];
-12
View File
@@ -1,12 +0,0 @@
import config from './config';
import Sentry from './external/sentry';
import logger from './logger';
export default new Sentry({
dsn: config.sentry.dsn,
environment: config.env,
tags: {
service: `worker:${process.platform}`,
},
logger,
});
-353
View File
@@ -1,353 +0,0 @@
import {
Artifacts,
BuildContext,
Hook,
findAndUploadXcodeBuildLogsAsync,
runHookIfPresent,
} from '@expo/build-tools';
import {
BuildJob,
BuildMode,
BuildPhase,
BuildPhaseStats,
Ios,
Job,
Metadata,
Platform,
errors,
} from '@expo/eas-build-job';
import { LoggerLevel } from '@expo/logger';
import assert from 'assert';
import fs from 'fs-extra';
import path from 'path';
import { setTimeout as setTimeoutAsync } from 'timers/promises';
import { build } from './build';
import config from './config';
import { createBuildContext } from './context';
import { Analytics } from './external/analytics';
import { LauncherMessage, Worker, WorkerMessage } from './external/turtle';
import logger, { createBuildLoggerWithSecretsFilter } from './logger';
import sentry from './sentry';
import State from './state';
import { WebSocketServer } from './utils/WebSocketServer';
import { LoggerStream } from './utils/logger';
export const HANGING_WORKER_CHECK_TIMEOUT_MS = 5 * 60 * 1000;
export default class BuildService {
private readonly _buildId: string;
private startedProcessingBuild: boolean;
private readonly state: State = new State();
private ws: WebSocketServer<LauncherMessage.Message, WorkerMessage.Message> | null = null;
private shouldCloseWorker = false;
private loggerStream?: LoggerStream;
private _startBuildTime?: number;
private buildContext?: BuildContext<Job>;
constructor() {
this._buildId = config.buildId;
this.startedProcessingBuild = false;
}
get startBuildTime(): number {
if (!this._startBuildTime) {
throw new Error('Call startBuild first!');
}
return this._startBuildTime;
}
get buildId(): string {
return this._buildId;
}
get isConnected(): boolean {
return !!this.ws;
}
get shouldClose(): boolean {
return this.shouldCloseWorker;
}
private getHangingWorkerCheckTimeoutMs(): number {
return HANGING_WORKER_CHECK_TIMEOUT_MS;
}
public setWS(ws: WebSocketServer | null): void {
this.ws = ws;
}
public startBuild({
job,
projectId: _projectId,
initiatingUserId: _initiatingUserId,
metadata,
...rest
}: LauncherMessage.Dispatch): void {
if (this.startedProcessingBuild) {
// the job has already been started
return;
}
logger.level(job.loggerLevel ?? LoggerLevel.INFO);
const projectId = job.appId ?? _projectId;
const initiatingUserId = job.initiatingUserId ?? _initiatingUserId;
const taskId = rest.jobType === 'jobRun' ? rest.jobRunId : rest.buildId;
if (taskId !== this.buildId) {
sentry.handleError(
`Launcher handling build with ID ${taskId} attempted to start build on worker assigned for handling build with ID ${this.buildId} (no action needed)`
);
return;
}
this.startedProcessingBuild = true;
this.state.buildStarted();
this._startBuildTime = Date.now();
this.startBuildInternal({ job, metadata, initiatingUserId, projectId });
}
public async finishError(err: errors.BuildError, artifacts: Artifacts | null): Promise<void> {
logger.error({ err }, 'Job finished with error');
this.state.finish(Worker.Status.ERROR, {
applicationArchiveName: artifacts?.APPLICATION_ARCHIVE ?? null,
buildArtifactsName: artifacts?.BUILD_ARTIFACTS ?? null,
userError: err,
});
const isSocketClosed: boolean = !this.ws;
// wait 5 seconds to make sure all logs are flushed
await setTimeoutAsync(5 * 1000);
await this.loggerStream?.cleanUp();
if (this.ws) {
logger.info('Send build result - error');
this.ws.send({
type: WorkerMessage.MessageType.ERROR,
applicationArchiveName: artifacts?.APPLICATION_ARCHIVE ?? null,
buildArtifactsName: artifacts?.BUILD_ARTIFACTS ?? null,
externalBuildError: err.format(),
internalErrorCode: err.errorCode,
});
}
this.checkForHangingWorker(isSocketClosed);
}
public async finishSuccess(artifacts: Artifacts): Promise<void> {
logger.info('Job finished successfully');
this.state.finish(Worker.Status.SUCCESS, {
applicationArchiveName: artifacts.APPLICATION_ARCHIVE ?? null,
buildArtifactsName: artifacts.BUILD_ARTIFACTS ?? null,
});
const isSocketClosed: boolean = !this.ws;
await this.loggerStream?.cleanUp();
if (this.ws) {
logger.info('Send build result - success');
this.ws.send({
type: WorkerMessage.MessageType.SUCCESS,
applicationArchiveName: artifacts.APPLICATION_ARCHIVE ?? null,
buildArtifactsName: artifacts.BUILD_ARTIFACTS ?? null,
});
}
this.checkForHangingWorker(isSocketClosed);
}
public async finishAbort({ reason }: LauncherMessage.BuildAbort): Promise<void> {
const currentState = this.state.stateResponse();
if ([Worker.Status.SUCCESS, Worker.Status.ERROR].includes(currentState.status)) {
// Build has finished in the meantime before receiving ABORT message
return;
}
this.state.setAbortReason(reason);
const wasBuildCanceled = reason === LauncherMessage.AbortReason.CANCEL;
logger.info('Job aborted - ' + wasBuildCanceled ? 'canceled by user' : 'build timed out');
await this.maybeUploadXCodeLogs();
this.state.finish(Worker.Status.ABORTED, {
applicationArchiveName: null,
buildArtifactsName: null,
});
if (
wasBuildCanceled &&
this.buildContext &&
'mode' in this.buildContext.job &&
![BuildMode.CUSTOM, BuildMode.REPACK].includes(this.buildContext.job.mode)
) {
const projectDir = this.buildContext.getReactNativeProjectDirectory();
const packageJsonPath = path.join(projectDir, 'package.json');
if (await fs.pathExists(packageJsonPath)) {
try {
await this.buildContext?.runBuildPhase(BuildPhase.ON_BUILD_CANCEL_HOOK, async () => {
assert(this.buildContext);
await runHookIfPresent(
this.buildContext as BuildContext<BuildJob>,
Hook.ON_BUILD_CANCEL
);
});
} catch (err: any) {
logger.warn({ err }, 'On cancel hook resulted in an error');
}
}
}
const isSocketClosed: boolean = !this.ws;
await this.loggerStream?.cleanUp();
if (this.ws) {
logger.info('Send build result - aborted');
this.ws.send({
type: WorkerMessage.MessageType.ABORTED,
reason,
});
}
this.checkForHangingWorker(isSocketClosed);
}
public async checkForHangingWorker(wasSocketClosedAtBuildFinish: boolean): Promise<void> {
await setTimeoutAsync(this.getHangingWorkerCheckTimeoutMs());
if (!this.shouldCloseWorker) {
// after sending BuildSuccess/BuildError message, worker should receive close message, which would set shouldCloseWorker to true
// if it's still false after 5 minutes it may mean that the communication failed and worker is hanging
logger.info(
'Worker still alive 5 minutes after sending BuildSuccess/BuildError message - notifying Sentry'
);
await this.reportHangingWorker(wasSocketClosedAtBuildFinish);
}
}
public async reportHangingWorker(wasSocketClosedAtBuildFinish: boolean): Promise<void> {
sentry.handleError(
'Worker still alive 5 minutes after sending BuildSuccess/BuildError message - possibly hanging',
undefined,
{
tags: {
errorCode: 'WORKER_POSSIBLY_HANGING',
},
extras: {
buildId: this.buildId,
state: this.state.stateResponse().status,
shouldCloseWorker: String(this.shouldCloseWorker),
startBuildTime: String(this.startBuildTime),
isSockedClosed: String(!this.ws),
wasSocketClosedAtBuildFinish: String(wasSocketClosedAtBuildFinish),
},
}
);
}
public reportBuildPhaseStats(stats: BuildPhaseStats): void {
if (this.ws) {
logger.info(stats, 'Sending build phase stats');
this.ws.send({
type: WorkerMessage.MessageType.BUILD_PHASE_STATS,
...stats,
});
}
}
public syncLauncherState({ buildId }: LauncherMessage.StateQuery): void {
if (this.buildId !== buildId) {
// should not happen (malicious or invalid connection)
} else {
const response = this.state.stateResponse();
if (this.ws) {
this.ws.send(response);
}
}
}
public closeWorker(): void {
if (this.ws) {
this.ws.close();
}
this.ws = null;
this.shouldCloseWorker = true;
}
private async startBuildInternal({
job,
metadata,
projectId,
initiatingUserId,
}: {
job: Job;
metadata: Metadata | undefined;
projectId: string;
initiatingUserId: string;
}): Promise<void> {
try {
const {
logger: buildLogger,
stream,
logBuffer,
} = await createBuildLoggerWithSecretsFilter(job.secrets?.environmentSecrets);
this.loggerStream = stream;
const analytics = new Analytics(initiatingUserId, metadata?.trackingContext ?? {});
const ctx = createBuildContext({
job,
logBuffer,
analytics,
metadata: metadata ?? {},
projectId,
buildId: this.buildId,
buildLogger,
reportBuildPhaseStatsFn: stats => {
this.reportBuildPhaseStats(stats);
},
});
this.buildContext = ctx;
const artifacts = await build({
ctx,
buildId: this.buildId,
analytics,
});
await this.finishSuccess(artifacts);
} catch (error: any) {
const maybeArtifacts = (error.artifacts as Artifacts | undefined) ?? null;
const unknownError =
'mode' in job && [BuildMode.CUSTOM, BuildMode.REPACK].includes(job.mode)
? new errors.UnknownCustomBuildError()
: new errors.UnknownBuildError();
const err = error instanceof errors.BuildError ? error : unknownError;
const maybeRawError = error instanceof errors.BuildError ? error.innerError : error;
sentry.handleError(err.message, maybeRawError, {
tags: {
...(err.buildPhase ? { buildPhase: err.buildPhase } : {}),
errorCode: err.errorCode,
...('type' in job ? { workflow: job.type } : {}),
},
extras: {
buildId: this.buildId,
...(maybeRawError.stdout ? { stdout: getLastNLines(100, maybeRawError.stdout) } : {}),
...(maybeRawError.stderr ? { stderr: getLastNLines(100, maybeRawError.stderr) } : {}),
},
});
await this.finishError(err, maybeArtifacts);
}
}
private async maybeUploadXCodeLogs(): Promise<void> {
if (this.buildContext?.job.platform === Platform.IOS) {
await findAndUploadXcodeBuildLogsAsync(this.buildContext as BuildContext<Ios.Job>, {
logger: this.buildContext.logger,
});
} else {
logger.debug('Not uploading XCode logs for Android job');
}
}
}
function getLastNLines(numberOfLines: number, stream: string): string {
const lines = stream.split('\n');
if (lines.length <= numberOfLines) {
return stream;
} else {
return lines.slice(lines.length - numberOfLines, lines.length).join('\n');
}
}
-60
View File
@@ -1,60 +0,0 @@
import { errors } from '@expo/eas-build-job';
import { LauncherMessage, Worker, WorkerMessage } from './external/turtle';
type FinalStatus = Worker.Status.SUCCESS | Worker.Status.ERROR | Worker.Status.ABORTED;
class State {
private status: Worker.Status = Worker.Status.NEW;
private applicationArchiveName: string | null = null;
private buildArtifactsName: string | null = null;
private userError?: errors.BuildError;
private abortReason?: LauncherMessage.AbortReason;
private readonly shouldCloseWorker = false;
get shouldClose(): boolean {
return this.shouldCloseWorker;
}
public buildStarted(): void {
this.status = Worker.Status.IN_PROGRESS;
}
public finish(
result: FinalStatus,
{
applicationArchiveName,
buildArtifactsName,
userError,
}: {
applicationArchiveName: string | null;
buildArtifactsName: string | null;
userError?: errors.BuildError;
}
): void {
this.status = result;
this.applicationArchiveName = applicationArchiveName;
this.buildArtifactsName = buildArtifactsName;
this.userError = userError;
}
public setAbortReason(reason: LauncherMessage.AbortReason): void {
this.abortReason = reason;
}
public stateResponse(): WorkerMessage.StateResponse {
return {
type: WorkerMessage.MessageType.STATE_RESPONSE,
status: this.status,
buildArtifactsName: this.buildArtifactsName ?? null,
applicationArchiveName: this.applicationArchiveName ?? null,
...(this.userError && {
externalBuildError: this.userError.format(),
internalErrorCode: this.userError.errorCode,
}),
abortReason: this.abortReason,
};
}
}
export default State;
-79
View File
@@ -1,79 +0,0 @@
initscript {
repositories {
maven {
url '{{URL}}/artifactory/libs-release'
if (it.hasProperty("allowInsecureProtocol")) {
allowInsecureProtocol = true
}
}
maven {
url 'https://plugins.gradle.org/m2/'
}
}
dependencies {
classpath 'org.jfrog.buildinfo:build-info-extractor-gradle:5.2.5'
}
}
// Configure plugin repositories globally - this runs before settings are evaluated
gradle.beforeSettings { settings ->
settings.pluginManagement {
repositories {
maven {
url '{{URL}}/artifactory/libs-release'
if (it.hasProperty("allowInsecureProtocol")) {
allowInsecureProtocol = true
}
}
}
}
// Also configure dependency resolution strategy for settings dependencies
settings.dependencyResolutionManagement {
repositories {
maven {
url '{{URL}}/artifactory/libs-release'
if (it.hasProperty("allowInsecureProtocol")) {
allowInsecureProtocol = true
}
}
}
}
}
allprojects {
buildscript {
repositories {
maven {
url '{{URL}}/artifactory/libs-release'
if (it.hasProperty("allowInsecureProtocol")) {
allowInsecureProtocol = true
}
}
}
}
repositories {
maven {
url '{{URL}}/artifactory/libs-release'
if (it.hasProperty("allowInsecureProtocol")) {
allowInsecureProtocol = true
}
}
}
}
apply plugin: RemoveJcenterPlugin
class RemoveJcenterPlugin implements Plugin<Gradle> {
void apply(Gradle gradle) {
gradle.allprojects { project ->
project.repositories {
all { ArtifactRepository repo ->
if (repo.hasProperty('url') && repo.url.host == 'jcenter.bintray.com') {
remove repo
}
}
}
}
}
}
-4
View File
@@ -1,4 +0,0 @@
unsafeHttpWhitelist:
- "*"
npmRegistryServer: "{{URL}}"
enableImmutableInstalls: false
Loaded 100 of 112 files, more files were not shown because too many files have changed in this diff. Show more